diff --git a/.codearbiter/security-controls.md b/.codearbiter/security-controls.md
index a674eb8d..ecb3c2d5 100644
--- a/.codearbiter/security-controls.md
+++ b/.codearbiter/security-controls.md
@@ -148,10 +148,31 @@ Hook input parsing fails open (not closed) on malformed stdin — see
## Audit trail
-`overrides.log` and `triage.log` are append-only artifacts. They may never be
-truncated, rewritten, or deleted. The `pre-bash.py` H-05 guard and the
-`pre-write.py` / `pre-edit.py` H-05 guards enforce this at every tool-call
-boundary.
+`overrides.log`, `triage.log`, and `sprint-log.md` are append-only artifacts.
+They may never be truncated, rewritten, or deleted. The `pre-bash.py` H-05 guard
+and the `pre-write.py` / `pre-edit.py` H-05 guards enforce this at every
+tool-call boundary.
+
+**Enforcement scope (accepted residual risk).** These guards are *integrity*
+controls, not *completeness* controls — they protect a log once written, they do
+not compel a write (see `observability-002` / the "compel a log write" CONFIRM).
+The `pre-bash.py` shell guard is lexical and anchored on the literal log name, so
+the following truncation/indirection spellings are out of scope and accepted as
+residual risk (the sanctioned bypass for legitimate log management is
+`/ca:override`):
+
+- file-descriptor redirects where no filename token is adjacent to the verb —
+ `exec 3>.codearbiter/overrides.log`;
+- triple-chevron `>>>` (treated as append by some shells);
+- process-substitution spellings;
+- verb-with-variable targets where the literal name never appears beside the
+ verb — `f=.codearbiter/overrides.log; rm "$f"` (bash) or `$f='overrides.log';
+ rm $f` (PowerShell).
+
+The `pre-write.py` / `pre-edit.py` guards close the Write/Edit flank (including an
+empty-`old_string` Edit, which is not a verifiable append). The append-only path
+set is centralized in `_hooklib` (`is_audit_log`, `AUDIT_LOG_NAMES`) so the three
+guards never drift on which files are covered.
---
diff --git a/.github/scripts/test_hooklib.py b/.github/scripts/test_hooklib.py
index c190ae1d..df48a4d4 100644
--- a/.github/scripts/test_hooklib.py
+++ b/.github/scripts/test_hooklib.py
@@ -10,6 +10,7 @@
Stdlib only. Exit 0 = all tests pass; non-zero = failure.
"""
+import json
import os
import sys
import tempfile
@@ -18,6 +19,7 @@
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
HOOKS = os.path.join(REPO, "plugins", "ca", "hooks")
+SECRET_CORPUS = os.path.join(HOOKS, "secret-detection-corpus.json")
sys.path.insert(0, HOOKS)
import _hooklib # noqa: E402 — needs sys.path mutation above
@@ -55,6 +57,18 @@ def test_still_matches_banned_primitives(self):
for s in ("createHash('md5')", "import bcrypt", "new DES()", "x509 cert"):
self.assertTrue(self._matches(s), s)
+ # --- secrets-001: RC2 and Blowfish are forbidden by security-controls.md
+ # but were absent from CRYPTO_RE, so a commit adding either passed the
+ # H-09b gate with no crypto-compliance review. ---
+ def test_matches_rc2(self):
+ for s in ("createCipheriv('rc2-cbc', key, iv)", "new RC2(key)"):
+ self.assertTrue(self._matches(s), s)
+
+ def test_matches_blowfish(self):
+ for s in ("new Blowfish(key)", "cipher = blowfish.new(key)",
+ "algorithm: BLOWFISH"):
+ self.assertTrue(self._matches(s), s)
+
# --- direct coverage for every CRYPTO_RE branch (checkpoint 2026-06-22
# NEEDS-TRIAGE: ~18 branches were exercised only indirectly). Each string
# is chosen to match via its named branch only (no incidental RSA/sha1). ---
@@ -116,6 +130,16 @@ def test_matches_ts_object_key(self):
def test_matches_aws_secret_keyword(self):
self.assertTrue(self._matches('aws_secret_access_key = "wJalrXUtnFEMI1234"'))
+ # --- secrets-002: compound names like FARM_API_KEY. The leading `\b` on the
+ # keyword alternation did NOT fire before `api_key` when the char to its
+ # left is a word char (the `_` in FARM_API_KEY), so a hardcoded
+ # `FARM_API_KEY = "..."` silently passed the H-10b secret gate. ---
+ def test_matches_compound_name_underscore_prefix(self):
+ self.assertTrue(self._matches('const FARM_API_KEY = "sk-CVlQvxKsecretvalue123";'))
+
+ def test_matches_compound_name_object_form(self):
+ self.assertTrue(self._matches('MY_SECRET: "longsecretvalue123"'))
+
# --- known high-entropy key prefixes (keyword-independent) ---
def test_matches_aws_access_key_id_prefix(self):
self.assertTrue(self._matches("AKIAIOSFODNN7EXAMPLE"))
@@ -134,6 +158,77 @@ def test_does_not_match_prose_mention(self):
self.assertFalse(self._matches("# load the secret from the env store"))
+class SecretCorpusTest(unittest.TestCase):
+ """architecture-001: the SECRET_RE commit gate and farm.ts's SECRET_LINE
+ outbound redactor are deliberately distinct in shape, but must never drift
+ apart on the AGREEMENT region — real secret shapes both must flag, and benign
+ lines both must pass. This pins the Python (SECRET_RE) side of that shared
+ corpus; plugins/ca/tools/farm.unit.test.ts pins the TS (SECRET_LINE) side
+ against the SAME file, so a divergence on any entry fails CI on one side."""
+
+ @classmethod
+ def setUpClass(cls):
+ with open(SECRET_CORPUS, encoding="utf-8") as f:
+ cls.corpus = json.load(f)
+
+ def _matches(self, s):
+ return bool(_hooklib.SECRET_RE.search(s))
+
+ def test_corpus_has_both_sets(self):
+ self.assertTrue(self.corpus.get("must_match"), "corpus must_match is empty")
+ self.assertTrue(self.corpus.get("must_not_match"), "corpus must_not_match is empty")
+
+ def test_secret_re_flags_every_must_match(self):
+ for s in self.corpus["must_match"]:
+ with self.subTest(line=s):
+ self.assertTrue(self._matches(s),
+ "SECRET_RE must flag corpus secret: %r" % s)
+
+ def test_secret_re_passes_every_must_not_match(self):
+ for s in self.corpus["must_not_match"]:
+ with self.subTest(line=s):
+ self.assertFalse(self._matches(s),
+ "SECRET_RE must NOT flag benign corpus line: %r" % s)
+
+
+class AuditPathHelperTest(unittest.TestCase):
+ """architecture-004: the append-only-log and ADR-decisions path sets were
+ triplicated inline across pre-write/pre-edit/pre-bash. They now live once in
+ _hooklib (the same home as CRYPTO_RE/SECRET_RE/MIGRATION globs) so adding an
+ audit artifact touches one file. These pin the centralized API + its scope."""
+
+ def test_is_audit_log_matches_the_three_append_only_files(self):
+ for rel in (".codearbiter/overrides.log",
+ ".codearbiter/triage.log",
+ ".codearbiter/sprint-log.md"):
+ with self.subTest(rel=rel):
+ self.assertTrue(_hooklib.is_audit_log(rel), rel)
+
+ def test_is_audit_log_normalizes_windows_separators(self):
+ self.assertTrue(_hooklib.is_audit_log(".codearbiter\\overrides.log"))
+
+ def test_is_audit_log_rejects_non_audit_paths(self):
+ for rel in (".codearbiter/CONTEXT.md", "src/overrides.log.bak",
+ ".codearbiter/open-tasks.md"):
+ with self.subTest(rel=rel):
+ self.assertFalse(_hooklib.is_audit_log(rel), rel)
+
+ def test_is_decisions_path_matches_adrs(self):
+ for rel in (".codearbiter/decisions/0001-seed.md",
+ ".codearbiter/decisions/draft.md",
+ ".codearbiter/decisions/sub/0002-x.md"):
+ with self.subTest(rel=rel):
+ self.assertTrue(_hooklib.is_decisions_path(rel), rel)
+
+ def test_is_decisions_path_normalizes_windows_separators(self):
+ self.assertTrue(_hooklib.is_decisions_path(".codearbiter\\decisions\\0001-x.md"))
+
+ def test_is_decisions_path_rejects_non_decisions(self):
+ for rel in (".codearbiter/CONTEXT.md", "src/decisions/x.md"):
+ with self.subTest(rel=rel):
+ self.assertFalse(_hooklib.is_decisions_path(rel), rel)
+
+
class PathGlobTest(unittest.TestCase):
"""is_migration_path / is_ci_path / is_deploy_path against default globs
(no security-controls.md in the tmp root, so defaults only)."""
diff --git a/plugins/ca/hooks/_hooklib.py b/plugins/ca/hooks/_hooklib.py
index ba400403..59dad9ce 100755
--- a/plugins/ca/hooks/_hooklib.py
+++ b/plugins/ca/hooks/_hooklib.py
@@ -40,6 +40,8 @@
# is_migration_path(rel, root) -> bool True iff rel is a DB migration (H-14)
# is_ci_path(rel, root) -> bool True iff rel is a CI/CD workflow (H-15)
# is_deploy_path(rel, root) -> bool True iff rel is a deployment/IaC manifest (H-16)
+# is_audit_log(rel) -> bool True iff rel is an append-only audit log (H-05)
+# is_decisions_path(rel) -> bool True iff rel is an ADR under decisions/ (H-11)
# marker_fresh(path, minutes) -> bool True iff marker file exists and is recent
# block(tag, msg) -> None BLOCK tool call: print to stderr and exit 2
# remind(tag, msg) -> None non-blocking nudge to stderr
@@ -62,6 +64,7 @@
# not, a password-hashing change is exactly what crypto-compliance reviews.
CRYPTO_RE = re.compile(
r"(createHash|createCipher|createHmac|\bmd5\b|\bsha1\b|\brc4\b|\bdes\b|3des"
+ r"|\brc2\b|\bblowfish\b"
r"|\bRSA\b|x509|bcrypt"
r"|crypto\.(subtle|sign|verify|createSign|createVerify|generateKey"
r"|publicEncrypt|privateDecrypt|pbkdf2|scrypt|randomBytes|createDiffieHellman)"
@@ -75,8 +78,14 @@
# (the colon/object form dominates this TS/JSON repo) — the quoted-value
# requirement keeps it from firing on every bare `token:` reference; (2) known
# high-entropy key prefixes, keyword-independent (AWS / GitHub / Anthropic).
+# No LEADING word boundary on the keyword group (secrets-002): a `\b` there
+# never fires when the keyword is the trailing segment of a compound identifier
+# (the char left of `api_key` in `FARM_API_KEY` is `_`, a word char), so a
+# hardcoded `FARM_API_KEY = "..."` silently passed the gate. The right-hand
+# quoted-assignment anchor still bounds the match — a bare `token:` reference
+# without a quoted value never matches.
SECRET_RE = re.compile(
- r"\b(?:password|secret|token|api_key|apikey|private_key|passphrase|credential"
+ r"(?:password|secret|token|api_key|apikey|private_key|passphrase|credential"
r"|aws_secret_access_key|client_secret)"
r"""["']?\s*[:=]\s*["'][^"']{4,}"""
r"|AKIA[0-9A-Z]{16}"
@@ -88,6 +97,36 @@
ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)
+# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
+# here (architecture-004) so the three pre-* hooks import ONE definition instead
+# of re-encoding the regex inline (the exact drift this module exists to
+# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
+# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
+#
+# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
+# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
+# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
+# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
+# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
+# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
+# flanks derive from one source.
+AUDIT_LOG_NAMES = r"(?:(?:overrides|triage)\.log|sprint-log\.md)"
+AUDIT_LOG_RE = re.compile(r"\.codearbiter/" + AUDIT_LOG_NAMES + r"$")
+DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
+DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
+
+
+def is_audit_log(rel):
+ """True iff `rel` is one of the append-only .codearbiter audit logs
+ (overrides.log, triage.log, sprint-log.md) — the H-05 guard set."""
+ return bool(AUDIT_LOG_RE.search(norm_path(rel)))
+
+
+def is_decisions_path(rel):
+ """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
+ the H-11 guard set (a non-numbered draft or a nested path still counts)."""
+ return bool(DECISIONS_PATH_RE.search(norm_path(rel)))
+
def utf8_stdio():
"""Force UTF-8 on stdout/stderr. Windows pipes default to the locale code
diff --git a/plugins/ca/hooks/pre-bash.py b/plugins/ca/hooks/pre-bash.py
index 37173f46..79c4ed59 100755
--- a/plugins/ca/hooks/pre-bash.py
+++ b/plugins/ca/hooks/pre-bash.py
@@ -21,8 +21,9 @@
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _hooklib import ( # noqa: E402
- CRYPTO_RE, SECRET_RE, arbiter_active, block, content_digest, is_migration_path,
- line_digest, marker_fresh, project_root, read_input, tool_input, utf8_stdio,
+ AUDIT_LOG_NAMES, CRYPTO_RE, DECISIONS_DIR_RE, SECRET_RE, arbiter_active, block,
+ content_digest, is_migration_path, line_digest, marker_fresh, project_root,
+ read_input, tool_input, utf8_stdio,
)
# `git` followed by any run of global options (-C
, -c k=v, --git-dir=…,
@@ -61,15 +62,20 @@
# N-3: Known limitations — this regex catches the common `> file` and `>| file`
# (force-clobber) truncation forms but NOT every shell spelling that produces a
# new file descriptor on the log. Specific gaps: triple-chevron (`>>>`, treated
-# as append by some shells), and file-descriptor forms like
-# `exec 3>.codearbiter/overrides.log`. These are difficult to close with a single
-# regex and represent an accepted residual risk. The sanctioned bypass for
-# legitimate log management is /ca:override.
+# as append by some shells), file-descriptor forms like
+# `exec 3>.codearbiter/overrides.log`, and verb-with-VARIABLE-target spellings
+# where the literal log name never appears adjacent to the verb (appsec-003):
+# e.g. `f=.codearbiter/overrides.log; rm "$f"` or PowerShell `$f='overrides.log';
+# rm $f` — the guard is purely lexical and anchored on the literal name, so an
+# indirected target defeats it. These are difficult to close with a single regex
+# and represent an accepted residual risk. The sanctioned bypass for legitimate
+# log management is /ca:override.
# The optional `\|?` admits `>|` (clobber even under `set -o noclobber`); the
# leading `(?!>)` still excludes the append form `>>`.
# `sprint-log.md` joins overrides.log/triage.log as an append-only audit artifact
-# (the /sprint decision record).
-LOG_NAMES = r"(?:(?:overrides|triage)\.log|sprint-log\.md)"
+# (the /sprint decision record). The bare-name alternation is centralized in
+# _hooklib.AUDIT_LOG_NAMES so the Write/Edit and shell flanks never drift.
+LOG_NAMES = AUDIT_LOG_NAMES
LOG_TRUNC_RE = re.compile(r"(?)>(?!>)\|?\s*\S*" + LOG_NAMES)
LOG_DESTROY_RE = re.compile(
r"\b(rm|del|mv|cp|copy|dd|tee|sed|truncate|sponge"
@@ -80,7 +86,7 @@
# guard the Write/Edit tools; this guards redirection and file verbs). Any
# redirect into .codearbiter/decisions/, or any write/delete verb naming it,
# blocks — `cat`/`ls`/`grep` reads pass untouched.
-DECISIONS = r"\.codearbiter[\\/]+decisions\b"
+DECISIONS = DECISIONS_DIR_RE + r"\b"
# `>>?\|?` covers `>`, `>>`, and the `>|` force-clobber form into decisions/.
DECISIONS_REDIRECT_RE = re.compile(r">>?\|?\s*\S*" + DECISIONS, re.I)
DECISIONS_WRITE_RE = re.compile(
diff --git a/plugins/ca/hooks/pre-edit.py b/plugins/ca/hooks/pre-edit.py
index 62f82aea..3039b5ab 100755
--- a/plugins/ca/hooks/pre-edit.py
+++ b/plugins/ca/hooks/pre-edit.py
@@ -9,13 +9,12 @@
# backslash paths too. Runs only in arbiter-enabled repos.
import os
-import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _hooklib import ( # noqa: E402
- arbiter_active, block, marker_fresh, norm_path, project_root, read_input,
- tool_input, utf8_stdio,
+ arbiter_active, block, is_audit_log, is_decisions_path, marker_fresh,
+ norm_path, project_root, read_input, tool_input, utf8_stdio,
)
@@ -31,8 +30,8 @@ def main():
# H-05: the audit logs (and the /sprint decision record) are append-only — an
# Edit must leave existing lines intact (new_string extends old_string), never
- # alter or delete them.
- if re.search(r"\.codearbiter/(?:(?:overrides|triage)\.log|sprint-log\.md)$", fpath):
+ # alter or delete them. (path set: _hooklib.is_audit_log)
+ if is_audit_log(fpath):
# MultiEdit applies a batch of edits and cannot express a verifiable pure
# append to an append-only file — the sanctioned append path is a single
# Edit (or '>>'). Block it outright rather than reason about the batch.
@@ -42,6 +41,17 @@ def main():
"(ORCHESTRATOR §7). Append with a single Edit or '>>'.")
old = ti.get("old_string", "") or ""
new = ti.get("new_string", "") or ""
+ # migration-003: `new.startswith("")` is ALWAYS True, so an empty
+ # old_string defeats the append check entirely — it could prepend or
+ # replace arbitrary content with the guard never firing. An empty
+ # old_string can never be a verifiable append (there is no existing tail
+ # to extend); block it outright.
+ if not old:
+ block("H-05", "An Edit with an empty old_string on an append-only .codearbiter "
+ "audit log (overrides.log, triage.log, sprint-log.md) cannot be a "
+ "verifiable pure append (ORCHESTRATOR §7) — every string starts with "
+ "the empty string. Append with '>>', or a single Edit whose old_string "
+ "is the file's current trailing content.")
if not new.startswith(old):
block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, "
"sprint-log.md) are append-only (ORCHESTRATOR §7). This Edit alters "
@@ -50,7 +60,8 @@ def main():
# H-11: ADRs may only be edited via /adr — any .md anywhere under decisions/
# (a non-numbered draft or a nested path is still an immutable decision).
- if re.search(r"\.codearbiter/decisions/.+\.md$", fpath):
+ # (path set: _hooklib.is_decisions_path)
+ if is_decisions_path(fpath):
marker = os.path.join(root, ".codearbiter", ".markers", "adr-authoring-active")
if not os.path.isfile(marker):
block("H-11", "ADR files are edited only via /adr (ORCHESTRATOR §3) — user "
diff --git a/plugins/ca/hooks/pre-write.py b/plugins/ca/hooks/pre-write.py
index 31b6ffce..c8ae27b6 100755
--- a/plugins/ca/hooks/pre-write.py
+++ b/plugins/ca/hooks/pre-write.py
@@ -8,13 +8,12 @@
# repos.
import os
-import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _hooklib import ( # noqa: E402
- arbiter_active, block, marker_fresh, norm_path, project_root, read_input,
- tool_input, utf8_stdio,
+ arbiter_active, block, is_audit_log, is_decisions_path, marker_fresh,
+ norm_path, project_root, read_input, tool_input, utf8_stdio,
)
@@ -26,15 +25,15 @@ def main():
fpath = norm_path(tool_input(read_input()).get("file_path", "") or "")
# H-05: the audit logs (and the /sprint decision record) are append-only —
- # a Write is a full overwrite.
- if re.search(r"\.codearbiter/(?:(?:overrides|triage)\.log|sprint-log\.md)$", fpath):
+ # a Write is a full overwrite. (path set: _hooklib.is_audit_log)
+ if is_audit_log(fpath):
block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md) "
"are append-only (ORCHESTRATOR §7). Append with Edit or '>>', never Write.")
# H-11: ADRs may only be authored via /adr (the skill drops the marker first).
# Any .md anywhere under decisions/ is covered — a non-numbered draft or a
- # nested path is still an immutable decision artifact.
- if re.search(r"\.codearbiter/decisions/.+\.md$", fpath):
+ # nested path is still an immutable decision artifact. (set: is_decisions_path)
+ if is_decisions_path(fpath):
marker = os.path.join(root, ".codearbiter", ".markers", "adr-authoring-active")
if not os.path.isfile(marker):
block("H-11", "ADR files are authored only via /adr (ORCHESTRATOR §3) — user "
diff --git a/plugins/ca/hooks/secret-detection-corpus.json b/plugins/ca/hooks/secret-detection-corpus.json
new file mode 100644
index 00000000..c4eb2c7e
--- /dev/null
+++ b/plugins/ca/hooks/secret-detection-corpus.json
@@ -0,0 +1,42 @@
+{
+ "_doc": [
+ "Shared secret-detection corpus (architecture-001). The two first-party secret",
+ "classifiers are DELIBERATELY DISTINCT in shape, not a hand-synced fork:",
+ " - _hooklib.SECRET_RE (the H-10/H-10b commit gate) is NARROW: a secret",
+ " keyword must be assigned a quoted literal, or a known high-entropy key",
+ " prefix must appear. Narrowness keeps it from firing on every bare",
+ " `token:` reference in source.",
+ " - farm.ts SECRET_LINE (the outbound injected-context redactor) is BROAD:",
+ " a bare trigger word anywhere on a line redacts the whole line. Over-",
+ " redaction is the safe direction for content crossing the trust boundary.",
+ "They therefore disagree by design on bare-keyword lines (e.g. a `// token`",
+ "comment): the redactor scrubs it, the gate does not. That difference is",
+ "intentional and is NOT pinned here.",
+ "",
+ "What IS pinned: on the AGREEMENT region below, the two must never drift apart.",
+ " must_match real secret shapes (quoted-assignment forms + key prefixes)",
+ " that BOTH classifiers must flag. A miss by either is a gap.",
+ " must_not_match benign lines carrying NO trigger word and NO key prefix that",
+ " BOTH must pass. A false positive by either is a regression.",
+ "This corpus is asserted against SECRET_RE by .github/scripts/test_hooklib.py",
+ "and against SECRET_LINE (via redactSecrets) by plugins/ca/tools/farm.unit.test.ts,",
+ "so a divergence on any entry fails CI on one side or the other."
+ ],
+ "must_match": [
+ "const FARM_API_KEY = \"sk-CVlQvxKsecretvalue123\";",
+ "password: \"correct-horse-battery\"",
+ "\"client_secret\": \"abc12345def\"",
+ "aws_secret_access_key = \"wJalrXUtnFEMIabcd1234\"",
+ "\"api_key\": \"sk-test-abcd1234\"",
+ "AKIAIOSFODNN7EXAMPLE",
+ "ghp_abcdefghijklmnopqrstuvwxyz0123456789",
+ "sk-ant-api03-AbCdEf1234567890abcdef"
+ ],
+ "must_not_match": [
+ "const total = sum(a, b);",
+ "function handleRequest(req, res) {}",
+ "let items = data.filter((x) => x.active);",
+ "import { readFileSync } from \"node:fs\";",
+ "return out.join(separator);"
+ ]
+}
diff --git a/plugins/ca/hooks/tests/test_pre_edit.py b/plugins/ca/hooks/tests/test_pre_edit.py
index 4b6ca6ff..7b51226b 100644
--- a/plugins/ca/hooks/tests/test_pre_edit.py
+++ b/plugins/ca/hooks/tests/test_pre_edit.py
@@ -185,6 +185,27 @@ def test_pure_append_to_sprint_log_is_allowed(self):
)
self.assertAllowed(res)
+ def test_empty_old_string_on_audit_log_is_blocked(self):
+ # migration-003: `new.startswith("")` is ALWAYS True, so an Edit with an
+ # empty old_string slipped the append-only check entirely — it could
+ # prepend/replace arbitrary content on overrides.log without the gate
+ # ever firing. An empty old_string can never be a verifiable pure append;
+ # it must block outright.
+ res = self.run_edit(
+ os.path.join(self.ca, "overrides.log"),
+ old_string="",
+ new_string="[2099-01-01T00:00:00Z] | BY: attacker | GATE: none | REASON: forged\n",
+ )
+ self.assertBlocked(res, "H-05")
+
+ def test_empty_old_string_on_sprint_log_is_blocked(self):
+ res = self.run_edit(
+ os.path.join(self.ca, "sprint-log.md"),
+ old_string="",
+ new_string="## SD-99 forged\n- chosen: tampered\n",
+ )
+ self.assertBlocked(res, "H-05")
+
def test_windows_backslash_path_still_triggers_h05_branch(self):
# norm_path() folds backslashes to forward slashes, so a Windows-style
# path to overrides.log must still take the H-05 branch and block a
diff --git a/plugins/ca/tools/farm.ts b/plugins/ca/tools/farm.ts
index 61953aa8..54914f05 100644
--- a/plugins/ca/tools/farm.ts
+++ b/plugins/ca/tools/farm.ts
@@ -325,9 +325,18 @@ export type InjectedFile = { path: string; contents: string; readOnly: boolean }
// no END is present) as a single unit. Single-line trigger-word matches keep
// the existing per-line behavior. Matching, never transmitting a matched
// secret, is the invariant — see spec AC-05 / D5.
-// Trigger words plus known high-entropy key prefixes (AWS / GitHub), kept in
-// step with the hook-side SECRET_RE so the outbound redactor and the commit
-// gate never disagree on what a secret looks like (checkpoint 2026-06-22).
+// Trigger words plus known high-entropy key prefixes (AWS / GitHub). This
+// outbound redactor is DELIBERATELY DISTINCT in shape from the hook-side
+// _hooklib.SECRET_RE commit gate (architecture-001): SECRET_LINE is BROAD — a
+// bare trigger word anywhere on a line redacts the whole line, because over-
+// redaction is the safe direction for content crossing the trust boundary;
+// SECRET_RE is NARROW — it requires a quoted-literal assignment so it does not
+// fire on every `token:` reference in committed source. They therefore disagree
+// by design on bare-keyword lines. What is pinned is the AGREEMENT region:
+// plugins/ca/hooks/secret-detection-corpus.json lists real secret shapes both
+// must flag and benign lines both must pass, asserted against SECRET_LINE here
+// (farm.unit.test.ts) and against SECRET_RE in test_hooklib.py, so neither side
+// can silently regress on it.
const SECRET_LINE = /(api[_-]?key|token|secret|password|BEGIN.*PRIVATE|sk-ant|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36})/i;
// PEM-style armor delimiters. BEGIN opens a span; END closes it. Matched
// independently of SECRET_LINE so even a `-----BEGIN CERTIFICATE-----` (no
diff --git a/plugins/ca/tools/farm.unit.test.ts b/plugins/ca/tools/farm.unit.test.ts
index 4c94cb1a..e57750da 100644
--- a/plugins/ca/tools/farm.unit.test.ts
+++ b/plugins/ca/tools/farm.unit.test.ts
@@ -34,6 +34,41 @@ describe("redactSecrets — high-entropy key prefixes (checkpoint 2026-06-22)",
});
});
+// ---------------------------------------------------------------------------
+// Shared secret-detection corpus (architecture-001). SECRET_LINE (this
+// outbound redactor) and _hooklib.SECRET_RE (the commit gate) are deliberately
+// distinct in shape, but must never drift apart on the AGREEMENT region. This
+// pins the TS (SECRET_LINE) side against the SAME corpus file that
+// .github/scripts/test_hooklib.py asserts SECRET_RE against — a divergence on
+// any entry fails CI on one side or the other. (For a single-line input
+// redactSecrets returns the marker iff SECRET_LINE matched.)
+// ---------------------------------------------------------------------------
+describe("redactSecrets — shared secret-detection corpus parity", () => {
+ const corpusPath = path.join(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "hooks",
+ "secret-detection-corpus.json",
+ );
+ const corpus = JSON.parse(readFileSync(corpusPath, "utf8")) as {
+ must_match: string[];
+ must_not_match: string[];
+ };
+
+ it("has both corpus sets", () => {
+ expect(corpus.must_match.length).toBeGreaterThan(0);
+ expect(corpus.must_not_match.length).toBeGreaterThan(0);
+ });
+
+ it.each(corpus.must_match)("redacts a must_match secret: %s", (line) => {
+ expect(redactSecrets(line)).toContain("[REDACTED");
+ });
+
+ it.each(corpus.must_not_match)("passes a must_not_match benign line: %s", (line) => {
+ expect(redactSecrets(line)).toBe(line);
+ });
+});
+
// ---------------------------------------------------------------------------
// extractFileBlocks
// ---------------------------------------------------------------------------