4 Commits

Author SHA1 Message Date
Kit OC5
5347c11f69 compute-guard: Hub conditions (90-day cap, named approver, CODEOWNERS, engine-port noise cut)
- non-structural exemptions need approved_by (windy-hub|windy-mind) and expire within 90 days;
  a longer amnesty simply does not apply and is reported (OVER-CAP). compute-door/guard-self: yearly.
- .github/CODEOWNERS on the allow-lists + guard.
- engine-port rule skips contracts/schemas/specs/openapi dirs and *.json (53 baseline hits, was 57).
- tests: findings carry kind+name never the value; shipped allow file obeys its own rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:13:18 -04:00
Kit OC5
5fa1e652ae compute-guard: gatekeeper rules (Mind 10-02) + allow-list exemptions that expire
New kinds: voice-ai host/key (Deepgram, ElevenLabs, Cartesia, PlayHT, Resemble, HeyGen, Google
Vision/Speech/TTS, AWS Transcribe/Polly), cloudflare workers ai (REST /ai/ + wrangler [ai] binding),
talk engine port (:8791/:8788/:8794/:8099). Own kinds so they roll out WARN-first via
COMPUTE_GUARD_WARN_KINDS. Allow entries now need a named exemption (local-user-hardware |
owner-approved | compute-door | guard-self) and an expires date; expired entries stop excusing
code and are reported. ci-hygiene keeps its own (non-strict) format.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:11:07 -04:00
Kit OC5
6f19e23eaf lockbox-names: names-only lister (section + label + resolvable yes/no/dup), never a value
All checks were successful
check / gate (push) Successful in 14s
canary / probe (push) Successful in 8s
So lanes can discover what the lockbox holds without opening it (Super Admin 50 request).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:23:47 -04:00
ef96051d6f Merge pull request #2 from sneakyfree/runner-guard
All checks were successful
check / gate (push) Successful in 14s
canary / probe (push) Successful in 6s
runner-guard: no stranger code on self-hosted runners (PR check + nightly sweep)
2026-10-01 05:29:07 -04:00
9 changed files with 450 additions and 13 deletions

7
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,7 @@
# Changes to the guard allow-lists / exemptions need review by the account owner on GitHub, AND an
# approved_by (windy-hub | windy-mind) on every non-structural entry, which the guard enforces itself
# (scripts/compute_guard.py: load_allow). A lane never approves its own exemption (Hub 10-02).
/ci/compute-guard-allow.yml @sneakyfree
/ci/secret-guard-allow.yml @sneakyfree
/ci/ci-hygiene-allow.yml @sneakyfree
/scripts/compute_guard.py @sneakyfree

View File

@@ -86,7 +86,7 @@ def test_warn_mode_never_turns_red(monkeypatch):
def test_allow_file_is_line_scoped_exceptions_only(): def test_allow_file_is_line_scoped_exceptions_only():
"""Every exception is line-scoped (`matches`), so an allowed file can't hide a """Every exception is line-scoped (`matches`), so an allowed file can't hide a
NEW floating install or docker step. Today: windy-pro's if:false deploy job.""" NEW floating install or docker step. Today: windy-pro's if:false deploy job."""
allow = hy.cg.load_allow(hy.ALLOW_FILE) allow = hy.cg.load_allow(hy.ALLOW_FILE, strict=False)
assert [(e["repo"], e["paths"]) for e in allow] == [("windy-pro", [".github/workflows/ci.yml"])] assert [(e["repo"], e["paths"]) for e in allow] == [("windy-pro", [".github/workflows/ci.yml"])]
assert all(e.get("matches") for e in allow) assert all(e.get("matches") for e in allow)
ok = " run: docker build -f account-server/Dockerfile -t windy-pro:${{ github.sha }} ." ok = " run: docker build -f account-server/Dockerfile -t windy-pro:${{ github.sha }} ."

View File

@@ -77,6 +77,88 @@ def test_allow_list_needs_a_reason_per_entry(tmp_path):
cg.load_allow(bad) cg.load_allow(bad)
def _entry(**kw):
base = dict(repo="x", paths=["*"], reason="r", exemption="owner-approved", expires="2099-01-01",
approved_by="windy-hub")
base.update(kw)
lines = ["allow:", " - repo: x", " paths: ['*']", " reason: r"]
for k in ("exemption", "expires", "approved_by"):
if base.get(k) is not None:
lines.append(f" {k}: {base[k]}")
return "\n".join(lines) + "\n"
def test_allow_entries_need_a_named_exemption_and_an_expiry(tmp_path):
from datetime import date
f = tmp_path / "a.yml"
f.write_text(_entry(exemption=None))
with pytest.raises(ValueError):
cg.load_allow(f)
f.write_text(_entry(exemption="because-i-said-so"))
with pytest.raises(ValueError):
cg.load_allow(f)
f.write_text(_entry(expires=None))
with pytest.raises(ValueError):
cg.load_allow(f)
f.write_text(_entry(expires="someday"))
with pytest.raises(ValueError):
cg.load_allow(f)
f.write_text(_entry(expires="2026-12-01"))
assert len(cg.load_allow(f, today=date(2026, 10, 2))) == 1
def test_expired_exemption_stops_excusing_and_is_reported(tmp_path):
from datetime import date
f = tmp_path / "a.yml"
f.write_text(_entry(expires="2026-10-01"))
assert cg.load_allow(f, today=date(2026, 10, 1)) # the expiry day is still valid
assert cg.load_allow(f, today=date(2026, 10, 2)) == [] # the next day it no longer excuses anything
assert cg.EXPIRED and cg.EXPIRED[0]["repo"] == "x" and cg.EXPIRED[0]["expires"] == "2026-10-01"
def test_shipped_allow_file_is_valid_today():
assert cg.load_allow() and not cg.EXPIRED # nothing in the repo's own file may already be expired
@pytest.mark.parametrize("text, kind", [
('u = "https://api.deepgram.com/v1/listen"', "voice-ai host"),
("fetch(`https://api.elevenlabs.io/v1/tts`)", "voice-ai host"),
('h = "api.cartesia.ai"', "voice-ai host"),
('h = "app.resemble.ai"', "voice-ai host"),
('h = "speech.googleapis.com"', "voice-ai host"),
('h = "transcribe.us-east-1.amazonaws.com"', "voice-ai host"),
('h = "polly.eu-west-1.amazonaws.com"', "voice-ai host"),
("DEEPGRAM_API_KEY=abc", "voice-ai key"),
("ELEVENLABS_API_KEY = x", "voice-ai key"),
('u = f"https://api.cloudflare.com/client/v4/accounts/{a}/ai/run/@cf/m"', "cloudflare workers ai"),
('ENGINE = "http://10.0.0.5:8791/v1"', "talk engine port"),
('ENGINE = "http://h:8788/ws"', "talk engine port"),
('x = "http://h:8099/health"', "talk engine port"),
])
def test_gatekeeper_rules_fire(text, kind):
assert kind in [k for k, _ in cg.scan_line("app/x.py", text)]
def test_workers_ai_binding_only_in_wrangler_and_near_misses_are_quiet():
assert [k for k, _ in cg.scan_line("wrangler.toml", "[ai]")] == ["workers ai binding"]
assert [k for k, _ in cg.scan_line("apps/x/wrangler.jsonc", ' "ai": {')] == ["workers ai binding"]
assert cg.scan_line("other.toml", "[ai]") == []
assert cg.scan_line("app/x.py", "port = 87912") == []
assert cg.scan_line("app/x.py", "# talk engine was :8791 (removed)") == []
def test_rolling_out_kinds_warn_but_dont_block_and_hard_kinds_still_do(monkeypatch):
monkeypatch.setattr(cg, "MODE", "block")
monkeypatch.setattr(cg, "SOFT_KINDS", {"voice-ai host", "voice-ai key"})
soft = cg.Finding("a.py", 1, "voice-ai host", "api.deepgram.com")
hard = cg.Finding("a.py", 2, "provider host", "api.openai.com")
state, desc, _ = cg.status_for([soft], whole_tree=True)
assert state == "success" and "rolling out" in desc and len(desc) <= 140
assert cg.status_for([soft, hard], whole_tree=True)[0] == "failure"
monkeypatch.setattr(cg, "SOFT_KINDS", set())
assert cg.status_for([soft], whole_tree=True)[0] == "failure" # rollout over: it blocks
@pytest.mark.parametrize( @pytest.mark.parametrize(
"repo, path, ok", "repo, path, ok",
[ [
@@ -250,3 +332,39 @@ def test_ollama_in_added_pr_lines_only():
"+URL = 'http://veron:11434/api/chat'\n") "+URL = 'http://veron:11434/api/chat'\n")
got = cg.parse_added("some-repo", diff, []) got = cg.parse_added("some-repo", diff, [])
assert [(f.kind, f.line) for f in got] == [("veron ollama", 2)] assert [(f.kind, f.line) for f in got] == [("veron ollama", 2)]
def test_non_structural_exemptions_need_an_independent_approver_and_a_90_day_cap(tmp_path):
from datetime import date
f = tmp_path / "a.yml"
f.write_text(_entry(approved_by=None))
with pytest.raises(ValueError):
cg.load_allow(f, today=date(2026, 10, 2))
f.write_text(_entry(approved_by="windy-chat")) # a lane may not approve itself/another lane
with pytest.raises(ValueError):
cg.load_allow(f, today=date(2026, 10, 2))
f.write_text(_entry(expires="2026-12-31")) # exactly 90 days: fine
assert len(cg.load_allow(f, today=date(2026, 10, 2))) == 1
f.write_text(_entry(expires="2027-01-01")) # 91 days: does NOT apply, and is reported
assert cg.load_allow(f, today=date(2026, 10, 2)) == [] and cg.OVERCAP
f.write_text(_entry(exemption="compute-door", approved_by=None, expires="2027-10-02"))
assert len(cg.load_allow(f, today=date(2026, 10, 2))) == 1 # structural: yearly, no approver field
def test_shipped_allow_file_obeys_its_own_rules():
allow = cg.load_allow()
assert allow and not cg.EXPIRED and not cg.OVERCAP
for e in allow:
if e["exemption"] not in cg.STRUCTURAL:
assert e["approved_by"] in cg.APPROVERS
def test_findings_carry_kind_and_name_never_the_value_and_ports_skip_contracts():
for line, kind in [("ELEVENLABS_API_KEY=sk_live_SUPERSECRET123456789", "voice-ai key"),
('DEEPGRAM_API_KEY = "dg-VALUE-0123456789abcdef"', "voice-ai key")]:
hits = cg.scan_line("app/x.py", line)
assert [k for k, _ in hits] == [kind]
assert all("SUPERSECRET" not in m and "VALUE" not in m for _, m in hits)
assert cg.scan_line("engine/contracts/ops.mcp.v1.json", '"url": "http://h:8099/x"') == []
assert cg.scan_line("services/api/openapi/spec.json", '"url": "http://h:8099/x"') == []
assert [k for k, _ in cg.scan_line("deploy/docker-compose.yml", " - 8099:8099 # :8099")] == ["talk engine port"]

View File

@@ -0,0 +1,64 @@
"""lockbox-names: headings + labels + resolvable, NEVER a value or prose after a label."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
V1 = "Qm7xT2vLp9RkZw4HnB8dYc3S" # synthetic values
V2 = "other-fake-value-1234567890"
V3 = "dup-one-aaaaaaaaaaaaaaaa"
V4 = "dup-two-bbbbbbbbbbbbbbbb"
PROSE = "ZZPROSEZZ-not-for-printing"
def make(tmp_path):
r = tmp_path / "kit"
(r / "secrets" / "x").mkdir(parents=True)
(r / "ACCESS_LOCKBOX.md").write_text(
"# LOCKBOX\n\n## 🔷 AZURE signing (added 10-01)\n"
f"- **Tenant:** {PROSE} lives in the portal\n"
f"- **`AZURE_CLIENT_ID`**: `{V1}`\n"
f"- **Secret (AZURE_CLIENT_SECRET):** `{V2}`\n"
"## GOOGLE oauth\n"
f"GOOGLE_OAUTH_CLIENT_ID={V2}\n"
f"- **`DUP_KEY`**: `{V3}`\n- **`DUP_KEY`**: `{V4}`\n"
f"## stray\n**{V1}** is a heading-like bold that is secret shaped? no, just label\n")
(r / "secrets" / "x" / "a.env").write_text(f"FILE_KEY={V1}\n")
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=r, check=True)
return r
def run(r, *args):
e = {**os.environ, "LOCKBOX_REPO": str(r), "LOCKBOX_REF": "WORKTREE"}
p = subprocess.run([sys.executable, str(ROOT / "scripts" / "lockbox_names.py"), *args],
capture_output=True, text=True, env=e)
return p.returncode, p.stdout + p.stderr
def test_lists_labels_and_resolvable_without_values_or_prose(tmp_path):
r = make(tmp_path)
rc, out = run(r, "AZURE|GOOGLE|FILE|DUP")
assert rc == 0
assert "AZURE signing (added 10-01) | AZURE_CLIENT_ID | md | yes" in out
assert "| GOOGLE_OAUTH_CLIENT_ID | env | yes" in out
assert "| FILE_KEY | file | yes" in out
assert "| DUP_KEY | md | dup" in out
# a prose label is listed but never resolvable, and nothing after the label leaks
assert "| Tenant: " not in out or "| Tenant" in out
assert "| label | no" in out
for secret in (V1, V2, V3, V4, PROSE, "lives in the portal"):
assert secret not in out
for i in range(0, len(secret) - 7):
assert secret[i:i + 8] not in out
def test_filter_and_bad_regex(tmp_path):
r = make(tmp_path)
rc, out = run(r, "NOSUCHTHING")
assert rc == 0 and "0 entries" in out
rc, out = run(r, "(")
assert rc == 2 and "bad regex" in out

View File

@@ -2,11 +2,16 @@
# Windy Mind is the ONLY door to AI compute (Grant, 2026-09-23). Every entry # Windy Mind is the ONLY door to AI compute (Grant, 2026-09-23). Every entry
# here is an exception to that rule and MUST say why. Paths are fnmatch globs # here is an exception to that rule and MUST say why. Paths are fnmatch globs
# relative to the repo root. Owner of this file: Windy Git lane (13); changes # relative to the repo root. Owner of this file: Windy Git lane (13); changes
# go through the orchestrator. Source of the first entries: COMPUTE_BYPASS_AUDIT.md. # go through the orchestrator. EVERY entry needs `exemption` (local-user-hardware | owner-approved |
# compute-door | guard-self) and `expires` (YYYY-MM-DD): nothing gets permanent amnesty (Mind 10-02);
# non-structural exemptions also need approved_by (windy-hub | windy-mind; a lane never approves its own)
# and expire within 90 days; an expired entry stops excusing code on that date and shows in the guard report. Source of the first entries: COMPUTE_BYPASS_AUDIT.md.
allow: allow:
- repo: windy-mind - repo: windy-mind
paths: ["*"] paths: ["*"]
reason: "Windy Mind IS the door: provider clients belong here by definition." reason: "Windy Mind IS the door: provider clients belong here by definition."
exemption: compute-door
expires: 2027-10-02
- repo: windy-agent - repo: windy-agent
paths: ["*"] paths: ["*"]
@@ -14,14 +19,26 @@ allow:
User BYOK: self-hosted agents call providers on the USER's own keys. User BYOK: self-hosted agents call providers on the USER's own keys.
Mind stays opt-in there, or every self-hosted user's inference lands on Mind stays opt-in there, or every self-hosted user's inference lands on
Grant's bill (no-cloud-cost-liability rule; audit #7). Grant's bill (no-cloud-cost-liability rule; audit #7).
exemption: owner-approved
approved_by: windy-hub
approved_on: 2026-10-02
expires: 2026-12-31
- repo: windy-code - repo: windy-code
paths: ["extensions/windy-ai/*"] paths: ["extensions/windy-ai/*"]
reason: "User BYOK AI extension: the user's own provider keys; Mind is one opt-in provider (audit #8)." reason: "User BYOK AI extension: the user's own provider keys; Mind is one opt-in provider (audit #8)."
exemption: owner-approved
approved_by: windy-hub
approved_on: 2026-10-02
expires: 2026-12-31
- repo: windy-connect - repo: windy-connect
paths: ["*writers/*"] paths: ["*writers/*"]
reason: "Writes client configs that NAME the user's own provider env vars; makes no provider calls (audit #11)." reason: "Writes client configs that NAME the user's own provider env vars; makes no provider calls (audit #11)."
exemption: owner-approved
approved_by: windy-hub
approved_on: 2026-10-02
expires: 2026-12-31
- repo: windy-pro - repo: windy-pro
paths: ["src/client/desktop/*"] paths: ["src/client/desktop/*"]
@@ -30,10 +47,18 @@ allow:
enters (renderer localStorage -> electron-store; env var only for dev), and enters (renderer localStorage -> electron-store; env var only for dev), and
the CSP line allows exactly those user-keyed hosts (audit #10). The the CSP line allows exactly those user-keyed hosts (audit #10). The
account-server is NOT covered: server-side calls go through Mind. account-server is NOT covered: server-side calls go through Mind.
exemption: owner-approved
approved_by: windy-hub
approved_on: 2026-10-02
expires: 2026-12-31
- repo: windy-pro - repo: windy-pro
paths: ["src/client/web/src/pages/panels/MindPanel.jsx"] paths: ["src/client/web/src/pages/panels/MindPanel.jsx"]
reason: "Validates the USER's own OpenRouter key for BYOK (audit #10); spends no house money." reason: "Validates the USER's own OpenRouter key for BYOK (audit #10); spends no house money."
exemption: owner-approved
approved_by: windy-hub
approved_on: 2026-10-02
expires: 2026-12-31
- repo: windy-pro - repo: windy-pro
paths: ["src/client/web/src/pages/panels/MindKeychain.jsx"] paths: ["src/client/web/src/pages/panels/MindKeychain.jsx"]
@@ -41,12 +66,20 @@ allow:
# /api/v1/chat, i.e. inference) still flags. Orchestrator-approved 09-23. # /api/v1/chat, i.e. inference) still flags. Orchestrator-approved 09-23.
matches: ['openrouter\.ai/auth\?', 'openrouter\.ai/api/v1/auth/keys'] matches: ['openrouter\.ai/auth\?', 'openrouter\.ai/api/v1/auth/keys']
reason: "BYOK key acquisition via OpenRouter OAuth PKCE; no inference; successor of MindPanel allow (ADR-064)." reason: "BYOK key acquisition via OpenRouter OAuth PKCE; no inference; successor of MindPanel allow (ADR-064)."
exemption: owner-approved
approved_by: windy-hub
approved_on: 2026-10-02
expires: 2026-12-31
- repo: windy-git - repo: windy-git
paths: ["scripts/compute_guard.py", "ci/compute-guard-allow.yml"] paths: ["scripts/compute_guard.py", "ci/compute-guard-allow.yml"]
reason: "The guard's own pattern list and this file." reason: "The guard's own pattern list and this file."
exemption: guard-self
expires: 2027-10-02
- repo: windy-mind - repo: windy-mind
paths: ["*"] paths: ["*"]
matches: [':11434'] matches: [':11434']
reason: "Windy Mind IS the compute door (endpoint + key); it may call Ollama. Only the Ollama port is allowed here, any provider host/SDK in Mind still flags." reason: "Windy Mind IS the compute door (endpoint + key); it may call Ollama. Only the Ollama port is allowed here, any provider host/SDK in Mind still flags."
exemption: compute-door
expires: 2027-10-02

View File

@@ -204,7 +204,7 @@ def _check(repo: str, sha: str, default_branch: str, is_default_head: bool):
bare = cg.WORK / f"{repo}.git" bare = cg.WORK / f"{repo}.git"
if not bare.is_dir() or not cg.fetched(bare, sha): # pushed after the fetch: next cycle if not bare.is_dir() or not cg.fetched(bare, sha): # pushed after the fetch: next cycle
return None return None
allow = cg.load_allow(ALLOW_FILE) allow = cg.load_allow(ALLOW_FILE, strict=False)
rules = hashlib.sha256((PREFILTER + INCLUDE.pattern + EXACT_PY.pattern + EXACT_NPM.pattern).encode()).hexdigest()[:8] rules = hashlib.sha256((PREFILTER + INCLUDE.pattern + EXACT_PY.pattern + EXACT_NPM.pattern).encode()).hexdigest()[:8]
fp = cg._fingerprint(allow) + ":" + rules # hashlib, not hash(): hash() is per-process random fp = cg._fingerprint(allow) + ":" + rules # hashlib, not hash(): hash() is per-process random
kw = dict(line_fn=scan_line, path_ok=path_ok) kw = dict(line_fn=scan_line, path_ok=path_ok)
@@ -232,7 +232,7 @@ def status_for(findings, whole_tree: bool, grant=()):
def report(repos: list[str]) -> int: def report(repos: list[str]) -> int:
allow = cg.load_allow(ALLOW_FILE) allow = cg.load_allow(ALLOW_FILE, strict=False)
total = 0 total = 0
for repo in repos: for repo in repos:
bare = cg.WORK / f"{repo}.git" bare = cg.WORK / f"{repo}.git"

View File

@@ -31,6 +31,7 @@ import re
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path from pathlib import Path
import yaml import yaml
@@ -49,6 +50,17 @@ HOSTS = [
"api.cohere.com", "api.fireworks.ai", "api.replicate.com", "api.cohere.com", "api.fireworks.ai", "api.replicate.com",
"api-inference.huggingface.co", "api-inference.huggingface.co",
] ]
# Mind's 10-02 gatekeeper list (speech / voice / avatar / vision / cloud ML): own kinds, so a rollout
# can be WARN-first (COMPUTE_GUARD_WARN_KINDS) without softening the original provider rules.
VOICE_HOSTS = [
"api.deepgram.com", "api.elevenlabs.io", "api.cartesia.ai", "api.play.ht", "api.playht.com",
"app.resemble.ai", "f.cluster.resemble.ai", "api.heygen.com",
"vision.googleapis.com", "speech.googleapis.com", "texttospeech.googleapis.com",
]
VOICE_KEYS = [
"DEEPGRAM_API_KEY", "ELEVENLABS_API_KEY", "ELEVEN_API_KEY", "CARTESIA_API_KEY", "PLAYHT_API_KEY",
"PLAY_HT_API_KEY", "PLAYHT_USER_ID", "RESEMBLE_API_KEY", "HEYGEN_API_KEY",
]
KEYS = [ KEYS = [
"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN",
"OPENAI_API_KEY", "GROQ_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "OPENAI_API_KEY", "GROQ_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY",
@@ -61,11 +73,23 @@ PY_SDKS = r"anthropic|openai|groq|mistralai|cohere|google\.generativeai|google\.
JS_SDKS = (r"@anthropic-ai/sdk|openai|groq-sdk|@google/generative-ai|@google/genai|@mistralai/mistralai" JS_SDKS = (r"@anthropic-ai/sdk|openai|groq-sdk|@google/generative-ai|@google/genai|@mistralai/mistralai"
r"|cohere-ai|together-ai|@ai-sdk/(?:anthropic|openai|groq|google|mistral)") r"|cohere-ai|together-ai|@ai-sdk/(?:anthropic|openai|groq|google|mistral)")
# The engine-port rule is about CODE/CONFIG that calls the engine, not API contracts, schemas or specs.
PORT_SKIP = re.compile(r"(^|/)(contracts?|schemas?|specs?|openapi)/|\.json$", re.I)
WRANGLER = re.compile(r"(^|/)wrangler\.(toml|jsonc?)$")
WRANGLER_AI = re.compile(r'^\s*\[ai\]\s*$|^\s*"ai"\s*:\s*\{')
RULES: list[tuple[str, re.Pattern]] = [ RULES: list[tuple[str, re.Pattern]] = [
("provider host", re.compile("|".join(re.escape(h) for h in HOSTS))), ("provider host", re.compile("|".join(re.escape(h) for h in HOSTS))),
# Grant via Boss 10-01: compute = Windy Mind. A NEW reference to an Ollama port (Veron's :11434) is a # Grant via Boss 10-01: compute = Windy Mind. A NEW reference to an Ollama port (Veron's :11434) is a
# direct call around Mind's metering/caps. WARN-only, never red, and only for lines a PR ADDS. # direct call around Mind's metering/caps. WARN-only, never red, and only for lines a PR ADDS.
("veron ollama", re.compile(r"(?::|%3[aA])11434(?![0-9])")), ("veron ollama", re.compile(r"(?::|%3[aA])11434(?![0-9])")),
("voice-ai host", re.compile("|".join(re.escape(h) for h in VOICE_HOSTS))),
("voice-ai key", re.compile(r"\b(?:" + "|".join(VOICE_KEYS) + r")\b")),
("voice-ai host", re.compile(r"(?:transcribe|polly)\.[a-z0-9-]+\.amazonaws\.com")),
("provider host", re.compile(r"(?:bedrock-runtime|bedrock)\.[a-z0-9-]+\.amazonaws\.com")),
("cloudflare workers ai", re.compile(r"api\.cloudflare\.com/client/v4/accounts/[^\s'\"/]+/ai/")),
("talk engine port", re.compile(r"(?::|%3[aA])(?:8791|8788|8794|8099)(?![0-9])")),
("workers ai binding", WRANGLER_AI),
("provider key", re.compile(r"\b(?:" + "|".join(KEYS) + r")\b")), ("provider key", re.compile(r"\b(?:" + "|".join(KEYS) + r")\b")),
("provider SDK", re.compile(rf"^\s*(?:from|import)\s+(?:{PY_SDKS})(?:\s|\.|$|,)")), ("provider SDK", re.compile(rf"^\s*(?:from|import)\s+(?:{PY_SDKS})(?:\s|\.|$|,)")),
("provider SDK", re.compile(rf"""(?:from\s+|require\(\s*|import\(\s*)['"](?:{JS_SDKS})(?:/[^'"]*)?['"]""")), ("provider SDK", re.compile(rf"""(?:from\s+|require\(\s*|import\(\s*)['"](?:{JS_SDKS})(?:/[^'"]*)?['"]""")),
@@ -75,6 +99,9 @@ RULES: list[tuple[str, re.Pattern]] = [
] ]
# Kinds that never block (even in MODE=block) and are only judged on ADDED lines, never the baseline tree. # Kinds that never block (even in MODE=block) and are only judged on ADDED lines, never the baseline tree.
WARN_ONLY_KINDS = {"veron ollama"} WARN_ONLY_KINDS = {"veron ollama"}
# Rolled out WARN-first: these kinds still show (tree + PRs) but never block, until the env var
# (a systemd drop-in on the sync, like SECRET_GUARD_WARN_KINDS) is removed.
SOFT_KINDS = {k for k in os.environ.get("COMPUTE_GUARD_WARN_KINDS", "").split(",") if k}
OLLAMA_MSG = "compute = Windy Mind (endpoint + key); do not call Veron's Ollama directly" OLLAMA_MSG = "compute = Windy Mind (endpoint + key); do not call Veron's Ollama directly"
DEP_FILES = re.compile(r"(^|/)(package\.json|requirements[^/]*\.txt|pyproject\.toml|setup\.cfg|Pipfile)$") DEP_FILES = re.compile(r"(^|/)(package\.json|requirements[^/]*\.txt|pyproject\.toml|setup\.cfg|Pipfile)$")
@@ -95,13 +122,47 @@ class Finding:
match: str match: str
def load_allow(path: Path = ALLOW_FILE) -> list[dict]: EXEMPTIONS = {"local-user-hardware", "owner-approved", "compute-door", "guard-self"}
STRUCTURAL = {"compute-door", "guard-self"} # the door itself and the guard's own files: yearly review
APPROVERS = {"windy-hub", "windy-mind"} # a lane never approves its own exemption (Hub 10-02)
MAX_DAYS = 90 # every other exemption: 90 days max, then re-approve
EXPIRED: list[dict] = [] # entries dropped as expired on the last load_allow (reported, never silent)
OVERCAP: list[dict] = [] # entries dropped because their expiry is further out than MAX_DAYS
def load_allow(path: Path = ALLOW_FILE, today: date | None = None, strict: bool = True) -> list[dict]:
"""Active entries only. Every entry needs repo, paths, a reason, a NAMED exemption and an expiry
date (Mind 10-02: nothing gets permanent amnesty). An expired entry stops excusing code at once.
`strict=False` is for OTHER guards reusing this loader (ci-hygiene) with their own file format."""
today = today or date.today()
data = yaml.safe_load(path.read_text()) or {} data = yaml.safe_load(path.read_text()) or {}
entries = data.get("allow") or [] entries = data.get("allow") or []
for e in entries: # a reason per entry is the whole point of the file active = []
EXPIRED.clear()
OVERCAP.clear()
for e in entries:
if not (e.get("repo") and e.get("paths") and str(e.get("reason", "")).strip()): if not (e.get("repo") and e.get("paths") and str(e.get("reason", "")).strip()):
raise ValueError(f"allow entry needs repo, paths and a reason: {e}") raise ValueError(f"allow entry needs repo, paths and a reason: {e}")
return entries if not strict:
active.append(e)
continue
if e.get("exemption") not in EXEMPTIONS:
raise ValueError(f"allow entry needs exemption in {sorted(EXEMPTIONS)}: {e.get('repo')} {e.get('paths')}")
try:
exp = e["expires"] if isinstance(e.get("expires"), date) else date.fromisoformat(str(e.get("expires")))
except ValueError as err:
raise ValueError(f"allow entry needs expires: YYYY-MM-DD: {e.get('repo')} {e.get('paths')}") from err
if e["exemption"] not in STRUCTURAL:
if e.get("approved_by") not in APPROVERS:
raise ValueError(f"allow entry needs approved_by in {sorted(APPROVERS)}: {e.get('repo')} {e.get('paths')}")
if exp > today + timedelta(days=MAX_DAYS):
OVERCAP.append({**e, "expires": exp.isoformat()}) # a longer amnesty simply does not apply
continue
if exp < today:
EXPIRED.append({**e, "expires": exp.isoformat()})
else:
active.append(e)
return active
def allowed(repo: str, path: str, allow: list[dict], text: str | None = None) -> bool: def allowed(repo: str, path: str, allow: list[dict], text: str | None = None) -> bool:
@@ -132,6 +193,10 @@ def scan_line(path: str, text: str) -> list[tuple[str, str]]:
for kind, rx in RULES: for kind, rx in RULES:
if kind == "provider SDK dep" and not DEP_FILES.search(path): if kind == "provider SDK dep" and not DEP_FILES.search(path):
continue continue
if kind == "workers ai binding" and not WRANGLER.search(path):
continue
if kind == "talk engine port" and PORT_SKIP.search(path):
continue
m = rx.search(text) m = rx.search(text)
if m: if m:
hits.append((kind, m.group(0).strip()[:60])) hits.append((kind, m.group(0).strip()[:60]))
@@ -156,9 +221,11 @@ def scan_tree(repo: str, bare: Path, sha: str, allow: list[dict], *, line_fn=Non
# Other guards (ci_hygiene) reuse this walker with their own line rules. # Other guards (ci_hygiene) reuse this walker with their own line rules.
line_fn = line_fn or scan_line line_fn = line_fn or scan_line
path_ok = path_ok or _default_path_ok path_ok = path_ok or _default_path_ok
pre = prefilter or "|".join([re.escape(h) for h in HOSTS] + KEYS + [ pre = prefilter or "|".join([re.escape(h) for h in HOSTS + VOICE_HOSTS] + KEYS + VOICE_KEYS + [
"anthropic", "openai", "groq", "mistral", "generativeai", "genai", "cohere", "anthropic", "openai", "groq", "mistral", "generativeai", "genai", "cohere",
"together", "cerebras", "litellm"]) "together", "cerebras", "litellm", "deepgram", "elevenlabs", "cartesia", "play\\.ht", "resemble",
"heygen", "googleapis\\.com", "amazonaws\\.com", "api\\.cloudflare\\.com", ":8791", ":8788",
":8794", ":8099", "%3[aA]87", "%3[aA]8099", r"^\s*\[ai\]", '"ai"'])
try: try:
out = _git(bare, "grep", "-nIE", "-e", pre, sha, "--", ".") out = _git(bare, "grep", "-nIE", "-e", pre, sha, "--", ".")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
@@ -215,7 +282,8 @@ def parse_added(repo: str, diff: str, allow: list[dict], *, line_fn=None, path_o
# ---- cache: a tree scan runs once per (repo, sha, rules+allow) -------------- # ---- cache: a tree scan runs once per (repo, sha, rules+allow) --------------
def _fingerprint(allow: list[dict]) -> str: def _fingerprint(allow: list[dict]) -> str:
return hashlib.sha256( return hashlib.sha256(
json.dumps([HOSTS, KEYS, PY_SDKS, JS_SDKS, SKIP.pattern, allow], sort_keys=True).encode() json.dumps([HOSTS, KEYS, VOICE_HOSTS, VOICE_KEYS, [r.pattern for _, r in RULES], PY_SDKS, JS_SDKS,
SKIP.pattern, allow], sort_keys=True, default=str).encode()
).hexdigest()[:16] ).hexdigest()[:16]
@@ -280,6 +348,13 @@ def status_for(findings: list[Finding], whole_tree: bool,
if not findings and not grant and soft: if not findings and not grant and soft:
f = soft[0] f = soft[0]
return "success", f"⚠ WARN: new Veron Ollama ref {f.path}:{f.line}. {OLLAMA_MSG}"[:140], f return "success", f"⚠ WARN: new Veron Ollama ref {f.path}:{f.line}. {OLLAMA_MSG}"[:140], f
rolling = [f for f in findings if f.kind in SOFT_KINDS]
if findings and len(rolling) == len(findings) and not grant:
f, n = rolling[0], len(rolling)
return "success", (f"⚠ WARN (rolling out, not blocking): {n} direct AI-provider use{'s' if n > 1 else ''} "
f"{scope}, e.g. {f.path}:{f.line} {f.match}")[:140], f
if rolling:
findings = [f for f in findings if f.kind not in SOFT_KINDS]
if not findings and grant: if not findings and grant:
g, n = grant[0], len(grant) g, n = grant[0], len(grant)
desc = (f"⚠ WARN (Grant-owned, not blocking): {n} direct AI-provider use{'s' if n > 1 else ''} " desc = (f"⚠ WARN (Grant-owned, not blocking): {n} direct AI-provider use{'s' if n > 1 else ''} "
@@ -298,6 +373,12 @@ def status_for(findings: list[Finding], whole_tree: bool,
def report(repos: list[str]) -> int: def report(repos: list[str]) -> int:
allow = load_allow() allow = load_allow()
for e in OVERCAP:
print(f"## OVER-CAP exemption (> {MAX_DAYS} days, NOT applied): {e['repo']} {e['paths']} "
f"[{e['exemption']}] expires {e['expires']}")
for e in EXPIRED:
print(f"## EXPIRED exemption (no longer excuses anything): {e['repo']} {e['paths']} "
f"[{e['exemption']}] expired {e['expires']}")
total = 0 total = 0
for repo in repos: for repo in repos:
bare = WORK / f"{repo}.git" bare = WORK / f"{repo}.git"

View File

@@ -5,10 +5,10 @@ set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd) here=$(cd "$(dirname "$0")" && pwd)
dest="$HOME/.local/share/secret-tools" dest="$HOME/.local/share/secret-tools"
mkdir -p "$dest" "$HOME/.local/bin" mkdir -p "$dest" "$HOME/.local/bin"
cp "$here/secret_shapes.py" "$here/secret_scan.py" "$here/env_names.py" "$here/lockbox_put.py" "$dest/" cp "$here/secret_shapes.py" "$here/secret_scan.py" "$here/env_names.py" "$here/lockbox_put.py" "$here/lockbox_names.py" "$dest/"
for pair in "secret-scan:secret_scan.py" "env-names:env_names.py" "lockbox-put:lockbox_put.py"; do for pair in "secret-scan:secret_scan.py" "env-names:env_names.py" "lockbox-put:lockbox_put.py" "lockbox-names:lockbox_names.py"; do
n=${pair%%:*}; f=${pair##*:} n=${pair%%:*}; f=${pair##*:}
printf '#!/usr/bin/env bash\nexec python3 "%s/%s" "$@"\n' "$dest" "$f" > "$HOME/.local/bin/$n" printf '#!/usr/bin/env bash\nexec python3 "%s/%s" "$@"\n' "$dest" "$f" > "$HOME/.local/bin/$n"
chmod 755 "$HOME/.local/bin/$n" chmod 755 "$HOME/.local/bin/$n"
done done
echo "installed secret-scan, env-names and lockbox-put (shapes from secret_shapes.py, same as secret-guard)" echo "installed secret-scan, env-names, lockbox-put and lockbox-names (shapes from secret_shapes.py, same as secret-guard)"

134
scripts/lockbox_names.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""lockbox-names: DISCOVER what the lockbox holds without reading it (Boss rule 10-01).
lockbox-names [REGEX] (case-insensitive; matches the section heading or the label)
Prints one row per entry: SECTION HEADING | LABEL | kind | resolvable
kind env = `KEY=value` line md = `- **`KEY`**: `value`` line
label = a bold prose label (`**Password (X):** ...`) file = secrets/**/*.env key
resolvable yes = `lockbox-get LABEL FILE` returns exactly one value
dup = defined with 2+ different values (lockbox-get refuses)
no = a prose-only label, or not an exact key
NEVER prints a value or any prose after a label. A label or heading that itself looks
like a secret (secret_shapes) is replaced by <secret-shaped>. Reads the COMMITTED lockbox at
origin/main (like lockbox-get; LOCKBOX_REF=<ref> or WORKTREE overrides). Memory only, stdout only.
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import secret_shapes as ss # noqa: E402
REPO = os.environ.get("LOCKBOX_REPO", os.path.expanduser("~/kit-army-config"))
REF = os.environ.get("LOCKBOX_REF", "origin/main")
HEAD = re.compile(r"^#{1,6}\s+(.*\S)\s*$")
ENV = re.compile(r"^([A-Z][A-Z0-9_]{2,})=(.*)$")
MD = re.compile(r"^\s*[-*]?\s*\*\*`([A-Za-z0-9_]+)`\*\*\s*:\s*`([^`]+)`")
LABEL = re.compile(r"\*\*([^*`]{2,70}?)\*\*")
def git(*a: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", REPO, *a], capture_output=True, text=True, errors="ignore")
def read_sources() -> dict[str, str]:
"""{path: text} for ACCESS_LOCKBOX.md and secrets/**/*.env."""
if REF == "WORKTREE":
out = {}
for p in [Path(REPO, "ACCESS_LOCKBOX.md"), *Path(REPO, "secrets").rglob("*.env")]:
if p.is_file():
out[str(p.relative_to(REPO))] = p.read_text(errors="ignore")
return out
if REF.startswith("origin/"):
git("fetch", "-q", "origin", REF.split("/", 1)[1])
names = ["ACCESS_LOCKBOX.md"] + [
p for p in git("ls-tree", "-r", "--name-only", REF, "--", "secrets").stdout.splitlines() if p.endswith(".env")]
out = {}
for n in names:
r = git("show", f"{REF}:{n}")
if r.returncode == 0:
out[n] = r.stdout
return out
def safe(text: str, limit: int = 70) -> str:
text = re.sub(r"\s+", " ", text).strip()
return "<secret-shaped>" if ss.find(text) else text[:limit]
def h(v: str) -> str:
return hashlib.sha256(v.strip().strip('"').strip("'").encode()).hexdigest()[:16]
def collect(src: dict[str, str]):
"""(rows, values) where values[KEY] = {hash,...} for resolvability; nothing printed from it."""
rows, values = [], {}
for path, text in src.items():
section = path
for line in text.splitlines():
m = HEAD.match(line) if path.endswith(".md") else None
if m:
section = safe(m.group(1), 90)
continue
m = ENV.match(line)
if m:
values.setdefault(m.group(1), set()).add(h(m.group(2)))
rows.append((section, m.group(1), "file" if path.startswith("secrets/") else "env"))
continue
m = MD.match(line)
if m:
values.setdefault(m.group(1), set()).add(h(m.group(2)))
rows.append((section, m.group(1), "md"))
continue
if path.endswith(".md"):
mm = LABEL.search(line)
if mm and not mm.group(1).startswith("http"):
rows.append((section, safe(mm.group(1)), "label"))
return rows, values
def resolvable(label: str, kind: str, values) -> str:
if kind not in ("env", "md", "file"):
return "no"
n = len(values.get(label, ()))
return "yes" if n == 1 else "dup" if n > 1 else "no"
def main(argv=None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
rx = re.compile(argv[0], re.I) if argv else None
src = read_sources()
if not src:
print("lockbox-names: cannot read the lockbox")
return 2
rows, values = collect(src)
seen, n = set(), 0
for section, label, kind in rows:
if rx and not (rx.search(section) or rx.search(label)):
continue
key = (section, label, kind)
if key in seen:
continue
seen.add(key)
n += 1
print(f"{section} | {label} | {kind} | {resolvable(label, kind, values)}")
print(f"# {n} entr{'y' if n == 1 else 'ies'}; names only, values never printed")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except re.error:
print("lockbox-names: bad regex")
sys.exit(2)
except Exception as e: # never a traceback
print(f"lockbox-names: error: {type(e).__name__}")
sys.exit(2)