feat(hooks): secret-guard blocks live secret values in tool args #17

Merged
naps62-yolo merged 15 commits from week-review/2026-W34 into main 2026-08-21 21:57:58 +01:00
Owner

Adds a secret-guard PreToolUse hook and a weekly-scan fix.

  • hooks/secret-guard.py: blocks tool arguments carrying a live secret — any ~/.env.claude value (exact-name allowlist for identity/config vars; 6-char floor, short values match as standalone tokens) plus literal token shapes (ghp_, sk-, AKIA, private-key headers, credential-bearing URLs). Exit 2 names the variable, never the value. Serves Claude Code and Codex; wiring snippet in hooks/README.md. Closes #1.
  • scan: title-generator sessions no longer counted as human turns.

The operating.md rule promotions from #9 were reverted in full — merge, rtk and ADR guidance stays repo-scoped in memories.

Adds a secret-guard PreToolUse hook and a weekly-scan fix. - `hooks/secret-guard.py`: blocks tool arguments carrying a live secret — any `~/.env.claude` value (exact-name allowlist for identity/config vars; 6-char floor, short values match as standalone tokens) plus literal token shapes (`ghp_`, `sk-`, `AKIA`, private-key headers, credential-bearing URLs). Exit 2 names the variable, never the value. Serves Claude Code and Codex; wiring snippet in `hooks/README.md`. Closes #1. - scan: title-generator sessions no longer counted as human turns. The operating.md rule promotions from #9 were reverted in full — merge, rtk and ADR guidance stays repo-scoped in memories.
naps62-yolo added 1 commit 2026-08-21 17:34:45 +01:00
feat(claude-md): promote three universal rules to operating.md
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 11s
274828701f
Never-auto-merge, rtk wrapper caveats, and ADR weight were repo-scoped
memories firing in only a few repos (issue #9). Repo copies deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
naps62-yolo added 1 commit 2026-08-21 17:35:50 +01:00
fix(week-review): drop aoe title-generator turns from claude scan
ci / nix (pull_request) Successful in 7s
ci / lint (pull_request) Successful in 10s
441ecb9967
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

claude-md/operating.md:48 — operating.md is also imported by Codex and concatenated into Pi and opencode (README.md:22, nix/home.nix:118-134), but the rtk rewrite is a Claude Code PreToolUse hook. Those clients therefore receive a false global claim and are told to proxy commands that were never wrapped. Scope this paragraph to Claude Code, or add equivalent wrappers for every client that consumes it.

claude-md/operating.md:48 — `operating.md` is also imported by Codex and concatenated into Pi and opencode (`README.md:22`, `nix/home.nix:118-134`), but the rtk rewrite is a Claude Code `PreToolUse` hook. Those clients therefore receive a false global claim and are told to proxy commands that were never wrapped. Scope this paragraph to Claude Code, or add equivalent wrappers for every client that consumes it.
Author
Owner

Scoped in 441ecb9's follow-up: heading now says Claude Code only, first sentence names the PreToolUse hook and tells other harnesses to ignore the section. Verified the hook exists only in ~/.claude/settings.json — ~/.codex/hooks.json has no rtk entry.

Scoped in 441ecb9's follow-up: heading now says Claude Code only, first sentence names the PreToolUse hook and tells other harnesses to ignore the section. Verified the hook exists only in ~/.claude/settings.json — ~/.codex/hooks.json has no rtk entry.
naps62-yolo added 1 commit 2026-08-21 17:37:56 +01:00
fix(claude-md): scope rtk section to Claude Code
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 10s
78997d6cbb
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
naps62-yolo added 1 commit 2026-08-21 17:41:41 +01:00
revert(claude-md): drop the global never-merge rule
ci / nix (pull_request) Successful in 9s
ci / lint (pull_request) Successful in 11s
4b81a746f7
Merging is repo-class-scoped, not universal: personal repos merge
freely, critical prod and collaborative repos never auto-merge.
Stays in repo memories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

claude-md/operating.md:39 — The latest commit intentionally leaves the merge policy repo-scoped, but the PR title still says it promotes three universal rules and Closes #9 will close an issue whose title, body, and stated fix still require moving all three. Update the PR and issue metadata to record the revised two-rule scope before #9 is auto-closed.

claude-md/operating.md:39 — The latest commit intentionally leaves the merge policy repo-scoped, but the PR title still says it promotes three universal rules and `Closes #9` will close an issue whose title, body, and stated fix still require moving all three. Update the PR and issue metadata to record the revised two-rule scope before #9 is auto-closed.
naps62-yolo changed title from feat(claude-md): promote three universal rules to operating.md to feat(claude-md): promote rtk and ADR rules to operating.md 2026-08-21 17:43:09 +01:00
Author
Owner

PR retitled to the two-rule scope; the body already describes the drop and where the merge rule went. Issue #9 carries the amended resolution in a comment (2026-08-21), so the auto-close lands on a recorded decision, not the original three-rule ask.

PR retitled to the two-rule scope; the body already describes the drop and where the merge rule went. Issue #9 carries the amended resolution in a comment (2026-08-21), so the auto-close lands on a recorded decision, not the original three-rule ask.
naps62-yolo added 1 commit 2026-08-21 18:58:45 +01:00
feat(hooks): secret-guard blocks live secret values in tool args
ci / nix (pull_request) Successful in 9s
ci / lint (pull_request) Successful in 10s
d04ac1bff3
Value-based: any ~/.env.claude value in Bash/Write/Edit args blocks
with the var name, never the value. Plus literal token shapes.
Serves Claude Code and Codex. Closes #1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

hooks/secret-guard.py:53 — The generic exclusions make real secrets invisible: every value containing $, every *_URL, and every HTTP(S) value bypasses value matching. Passwords can contain $, while webhook, DSN, RPC, and database URLs often carry credentials, so the guard does not provide #1's promised “any actual value” coverage. Resolve parsed variable names from the inherited environment (so expansions become their real values) and allowlist exact known non-secret variables instead of secret-capable value/name shapes; cover $-containing and credential-bearing URL values in tests.

hooks/secret-guard.py:53 — The generic exclusions make real secrets invisible: every value containing `$`, every `*_URL`, and every HTTP(S) value bypasses value matching. Passwords can contain `$`, while webhook, DSN, RPC, and database URLs often carry credentials, so the guard does not provide #1's promised “any actual value” coverage. Resolve parsed variable names from the inherited environment (so expansions become their real values) and allowlist exact known non-secret variables instead of secret-capable value/name shapes; cover `$`-containing and credential-bearing URL values in tests.
Author
Owner

Fixed in the follow-up commit:

  • shape-based name skips replaced by an exact ALLOW_NAMES set (identity/config vars only, extended one name at a time)
  • $-containing values no longer skipped: the name is resolved from the inherited environment and both the resolved value and the literal are scanned, so passwords with $ are covered whether or not expansion happened
  • URL skip narrowed to bare endpoints — any userinfo (user:pass@) or query string keeps the value in the secret set, covering DSNs and webhook URLs

Tested: postgres DSN with embedded password blocks, webhook URL with ?token= blocks, bare https:// endpoint passes, pa$$word-style value blocks, username value passes, live token still blocks via the environment-resolution path (SECRET_GUARD_ENV override added for testing).

Fixed in the follow-up commit: - shape-based name skips replaced by an exact `ALLOW_NAMES` set (identity/config vars only, extended one name at a time) - `$`-containing values no longer skipped: the name is resolved from the inherited environment and both the resolved value and the literal are scanned, so passwords with `$` are covered whether or not expansion happened - URL skip narrowed to bare endpoints — any userinfo (`user:pass@`) or query string keeps the value in the secret set, covering DSNs and webhook URLs Tested: postgres DSN with embedded password blocks, webhook URL with `?token=` blocks, bare `https://` endpoint passes, `pa$$word`-style value blocks, username value passes, live token still blocks via the environment-resolution path (`SECRET_GUARD_ENV` override added for testing).
naps62-yolo added 1 commit 2026-08-21 19:02:29 +01:00
fix(hooks): secret-guard covers URLs-with-creds and $-values
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 11s
8266a10a29
Exact-name allowlist replaces shape-based skips; $-expansions
resolve from the inherited environment and the literal is scanned
too; only bare endpoints (no userinfo, no query) skip URL matching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

hooks/secret-guard.py:36 — plain_url() treats every URL without userinfo or a query as a bare endpoint even when it has a credential-bearing path. Common webhook URLs put the secret in path segments, so they still bypass the guard. Require an empty/root path and no fragment, or allowlist exact known endpoint variables instead of inferring safety from URL shape.

hooks/secret-guard.py:71 — The global eight-character minimum still excludes real credentials; it currently omits the configured SANDBOX_PASSWORD, despite #1 promising to block any actual env value. Apply the length floor only to variables explicitly classified as non-secret, or remove it now that known non-secret names have an exact allowlist. Add short-password and path-token URL cases to the guard tests.

hooks/secret-guard.py:36 — `plain_url()` treats every URL without userinfo or a query as a bare endpoint even when it has a credential-bearing path. Common webhook URLs put the secret in path segments, so they still bypass the guard. Require an empty/root path and no fragment, or allowlist exact known endpoint variables instead of inferring safety from URL shape. hooks/secret-guard.py:71 — The global eight-character minimum still excludes real credentials; it currently omits the configured `SANDBOX_PASSWORD`, despite #1 promising to block any actual env value. Apply the length floor only to variables explicitly classified as non-secret, or remove it now that known non-secret names have an exact allowlist. Add short-password and path-token URL cases to the guard tests.
naps62-yolo added 1 commit 2026-08-21 19:05:40 +01:00
fix(hooks): guard covers short passwords and path-token URLs
ci / lint (pull_request) Successful in 10s
ci / nix (pull_request) Successful in 8s
4c8d95dbfd
URL skip now bare origins only; length floor drops to a sanity
minimum, with short values matched as standalone tokens so a 4-char
password doesn't block unrelated text (it did, in live testing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
naps62-yolo added 1 commit 2026-08-21 19:06:21 +01:00
fix(hooks): floor back to 6 — shorter values are unguardable
ci / lint (pull_request) Successful in 10s
ci / nix (pull_request) Successful in 8s
717f8dc07e
The 4-char sandbox password matched standalone prose tokens twice
in live use. Matching cannot cover it; rotating it can.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Both findings addressed, one with a documented limit:

  • plain_url skips bare origins only; any path segment, query, fragment or userinfo keeps the value in the secret set. The one endpoint var with a real path moved to ALLOW_NAMES. Path-token webhook URL case added to the test run: blocks.
  • The length floor came down to cover the short password — and immediately produced two real false positives in live use: the 4-char value matched standalone tokens in ordinary prose and code, blocking unrelated calls. Even boundary-matching cannot distinguish a common-word password from text, so the floor is back at 6 with the limitation stated in the file. Values of 6-7 chars match as standalone tokens; 8+ match anywhere. The actionable fix for the sandbox password is rotating it to a longer value, which the guard then covers automatically.

Test run: path-token URL blocks, bare endpoint passes, 6+ char short value blocks standalone and passes as substring, live forge token blocks, prose passes.

Both findings addressed, one with a documented limit: - `plain_url` skips bare origins only; any path segment, query, fragment or userinfo keeps the value in the secret set. The one endpoint var with a real path moved to `ALLOW_NAMES`. Path-token webhook URL case added to the test run: blocks. - The length floor came down to cover the short password — and immediately produced two real false positives in live use: the 4-char value matched standalone tokens in ordinary prose and code, blocking unrelated calls. Even boundary-matching cannot distinguish a common-word password from text, so the floor is back at 6 with the limitation stated in the file. Values of 6-7 chars match as standalone tokens; 8+ match anywhere. The actionable fix for the sandbox password is rotating it to a longer value, which the guard then covers automatically. Test run: path-token URL blocks, bare endpoint passes, 6+ char short value blocks standalone and passes as substring, live forge token blocks, prose passes.
naps62-yolo added 1 commit 2026-08-21 19:14:08 +01:00
revert(claude-md): drop rtk and ADR promotion too
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 10s
70020bd197
operating.md returns to main; the rules stay as repo memories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
naps62-yolo changed title from feat(claude-md): promote rtk and ADR rules to operating.md to feat(hooks): secret-guard blocks live secret values in tool args 2026-08-21 19:14:18 +01:00
Author
Owner

hooks/secret-guard.py:18 — Raising the floor to six reintroduces the concrete gap: the currently configured SANDBOX_PASSWORD is below the floor (verified without reading its value), so Bash/Write/Edit arguments can transmit the live root password untouched. A comment telling operators to rotate it neither performs nor enforces that rotation, while hooks/README.md still claims any env value is blocked. Keep the short-value handling until the credential is actually rotated, or make deployment refuse/warn clearly on short secret variables and narrow the documented guarantee.

hooks/secret-guard.py:18 — Raising the floor to six reintroduces the concrete gap: the currently configured `SANDBOX_PASSWORD` is below the floor (verified without reading its value), so Bash/Write/Edit arguments can transmit the live root password untouched. A comment telling operators to rotate it neither performs nor enforces that rotation, while `hooks/README.md` still claims any env value is blocked. Keep the short-value handling until the credential is actually rotated, or make deployment refuse/warn clearly on short secret variables and narrow the documented guarantee.
Author
Owner

Addressed with the warn-and-narrow option: keeping short-value matching is not viable — it blocked two unrelated calls in live use, and a guard that fires on prose gets disabled, which is the worse outcome (per #1's own reasoning against noisy heuristics).

  • the hook now detects non-allowlisted env values under the floor and emits a daily warning naming the variable and telling the operator to rotate it (exit 1, visible in the session, never blocking)
  • hooks/README.md states the real guarantee: 6+ chars blocked, 6-7 as standalone tokens, under 6 warned as unguardable

Rotation of the one affected credential is a machine operation, offered to the operator directly — the hook cannot rotate a root password.

Addressed with the warn-and-narrow option: keeping short-value matching is not viable — it blocked two unrelated calls in live use, and a guard that fires on prose gets disabled, which is the worse outcome (per #1's own reasoning against noisy heuristics). - the hook now detects non-allowlisted env values under the floor and emits a daily warning naming the variable and telling the operator to rotate it (exit 1, visible in the session, never blocking) - `hooks/README.md` states the real guarantee: 6+ chars blocked, 6-7 as standalone tokens, under 6 warned as unguardable Rotation of the one affected credential is a machine operation, offered to the operator directly — the hook cannot rotate a root password.
naps62-yolo added 1 commit 2026-08-21 19:16:47 +01:00
feat(hooks): daily warning for env values too short to guard
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 10s
3b9492f748
README now states the 6-char guarantee boundary instead of claiming
full coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

hooks/secret-guard.py:72 — The warning classifies only the raw text from .env.claude, not the inherited value that the guard actually checks. For SECRET=$OTHER, $OTHER is measured instead of the resolved secret: a short resolved value can still be skipped with no warning, while a long resolved value referenced by a short name can warn falsely. Build the candidate values first, classify the inherited value when available (raw as fallback for non-exported assignments), then guard or warn from that same value.

hooks/secret-guard.py:72 — The warning classifies only the raw text from `.env.claude`, not the inherited value that the guard actually checks. For `SECRET=$OTHER`, `$OTHER` is measured instead of the resolved secret: a short resolved value can still be skipped with no warning, while a long resolved value referenced by a short name can warn falsely. Build the candidate values first, classify the inherited value when available (raw as fallback for non-exported assignments), then guard or warn from that same value.
Author
Owner

Fixed: candidates build first, classification follows the same value the guard checks. resolved is the inherited value with the raw literal as fallback; a literal that is nothing but a $-reference is dropped from candidates (matching it would block the referencing style the hook itself recommends). The unguardable warning now fires only when the name produced no guardable candidate and the resolved value is non-empty and short.

Tested with SECRET=$OTHER shapes: long resolved value blocks (no false warning), the "$VAR" reference style passes, short resolved value warns, $-containing literal password still blocks. Live token still blocks.

Fixed: candidates build first, classification follows the same value the guard checks. `resolved` is the inherited value with the raw literal as fallback; a literal that is nothing but a `$`-reference is dropped from candidates (matching it would block the referencing style the hook itself recommends). The unguardable warning now fires only when the name produced no guardable candidate and the resolved value is non-empty and short. Tested with `SECRET=$OTHER` shapes: long resolved value blocks (no false warning), the `"$VAR"` reference style passes, short resolved value warns, `$`-containing literal password still blocks. Live token still blocks.
naps62-yolo added 1 commit 2026-08-21 19:18:55 +01:00
fix(hooks): classify guard/warn from the resolved value
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 11s
e06cbe4217
SECRET=$OTHER now guards the inherited value and warns from that
same value; a literal that is only a $-reference is never matched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

hooks/secret-guard.py:79 — Exported variables still classify both the resolved value and the raw assignment, so the raw expression can hide an unguardable resolved secret. For SECRET=x$EMPTY, the one-character resolved value is skipped, but the longer raw x$EMPTY sets guarded = True and suppresses the warning. Conversely, a single-quoted literal such as '$longpassword' is removed as a “reference” even when the inherited value is identical, leaving it unguarded and unwarned. When name in os.environ, classify only os.environ[name]; use the raw value only when the variable was not exported.

hooks/secret-guard.py:79 — Exported variables still classify both the resolved value and the raw assignment, so the raw expression can hide an unguardable resolved secret. For `SECRET=x$EMPTY`, the one-character resolved value is skipped, but the longer raw `x$EMPTY` sets `guarded = True` and suppresses the warning. Conversely, a single-quoted literal such as `'$longpassword'` is removed as a “reference” even when the inherited value is identical, leaving it unguarded and unwarned. When `name in os.environ`, classify only `os.environ[name]`; use the raw value only when the variable was not exported.
Author
Owner

Fixed as suggested: when the name is exported, only os.environ[name] is classified — guarded or warned from that single value; the raw assignment is used only for never-exported lines, where a pure $-reference is skipped.

Tested both counterexamples: SECRET=x$EMPTY exported with a 2-char resolved value now warns instead of being masked by the raw text, and a single-quoted '$longpassword' exported literally now blocks on its value. Reference style, normal secrets and the live token all behave as before.

Fixed as suggested: when the name is exported, only `os.environ[name]` is classified — guarded or warned from that single value; the raw assignment is used only for never-exported lines, where a pure `$`-reference is skipped. Tested both counterexamples: `SECRET=x$EMPTY` exported with a 2-char resolved value now warns instead of being masked by the raw text, and a single-quoted `'$longpassword'` exported literally now blocks on its value. Reference style, normal secrets and the live token all behave as before.
naps62-yolo added 1 commit 2026-08-21 19:20:32 +01:00
fix(hooks): exported vars judged only by inherited value
ci / nix (pull_request) Successful in 9s
ci / lint (pull_request) Successful in 13s
d193df0403
Raw assignment can hide a short resolved secret or read as a
reference when the exported value is the real literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

hooks/secret-guard.py:78 — For every exported name this now ignores the value read from .env.claude and trusts only the process environment. After a token is rotated while an agent session is running, that inherited value stays stale, so the hook guards the old token and allows the new live file value through despite claiming to read the file at hook time. Keep the inherited value for resolving expressions, but also classify a raw assignment when it is a literal (or resolve the file afresh in an isolated subprocess); guarding both old and new values during rotation is safe.

hooks/secret-guard.py:78 — For every exported name this now ignores the value read from `.env.claude` and trusts only the process environment. After a token is rotated while an agent session is running, that inherited value stays stale, so the hook guards the old token and allows the new live file value through despite claiming to read the file at hook time. Keep the inherited value for resolving expressions, but also classify a raw assignment when it is a literal (or resolve the file afresh in an isolated subprocess); guarding both old and new values during rotation is safe.
Author
Owner

Fixed: both forms classify independently — the inherited value and the file literal each guard (or warn) on their own, so during a rotation the old and the new value both block. The pure $-reference skip applies only to the literal form. The x$EMPTY counterexample still warns (the short inherited value warns regardless of the literal being guarded).

Tested: stale inherited token blocks, fresh file token blocks in the same run, short-resolved var still warns, reference style passes, live token blocks.

Fixed: both forms classify independently — the inherited value and the file literal each guard (or warn) on their own, so during a rotation the old and the new value both block. The pure `$`-reference skip applies only to the literal form. The `x$EMPTY` counterexample still warns (the short inherited value warns regardless of the literal being guarded). Tested: stale inherited token blocks, fresh file token blocks in the same run, short-resolved var still warns, reference style passes, live token blocks.
naps62-yolo added 1 commit 2026-08-21 19:22:22 +01:00
fix(hooks): classify inherited and file values independently
ci / nix (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 10s
234983327f
Rotation leaves one of them stale; guarding both old and new is
safe. Short warning fires from either form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
naps62-yolo added 1 commit 2026-08-21 21:30:07 +01:00
Merge remote-tracking branch 'origin/main' into week-review/2026-W34
ci / lint (pull_request) Successful in 14s
ci / nix (pull_request) Successful in 11s
91e1c5dac0
naps62-yolo added 1 commit 2026-08-21 21:43:22 +01:00
chore: retrigger ci
ci / lint (pull_request) Successful in 14s
ci / nix (pull_request) Successful in 10s
5db2e6a113
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
naps62-yolo merged commit ae927736a0 into main 2026-08-21 21:57:58 +01:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yolo/agent-skills#17