Summary
Four domain plugins deny Edit(.env) in their settings.json and document .env as the operator-only surface. Nothing enforces that against a Bash write or against the Write tool, so an agent can add or change .env entries — including entries that widen its own permissions.
This is a real gap rather than a theoretical one now, because laravel-forge-hermit puts a security decision in .env: FORGE_POLICY_ALLOW_TIERS and FORGE_POLICY_ALLOW are what lift its secrets and destructive deny tiers, and its refusal text tells the operator "operator-only; the agent cannot edit .env". That sentence is currently not true.
Reproduction
Feeding payloads straight into the hook (AGENT_HOOK_PROFILE as noted, exit 2 = blocked):
| tool call |
profile |
exit |
expected |
echo FOO=1 >> .env |
standard |
0 |
2 |
echo FOO=1 | tee -a .env |
standard |
0 |
2 |
Write(file_path=".env") |
standard |
0 |
2 |
echo x > .claude/settings.json |
strict |
2 |
2 |
echo x>.claude/settings.json |
strict |
0 |
2 |
rm -rf build |
standard |
2 |
2 (control) |
Three distinct problems
1. No deny-pattern covers writes to .env. state-templates/deny-patterns.json blocks reads (Bash(cat .env*), Bash(cat */.env*)) but no write form. The precedent for exactly this shape already exists for other protected files — Bash(*> *.claude-code-hermit/OPERATOR.md*) in default, and Bash(*> .claude/settings.json*) in always_on — so .env is simply missing from that set.
2. Write(.env) is denied nowhere. All four plugins list only Edit(.env):
plugins/laravel-forge-hermit/settings.json:19
plugins/claude-code-fitness-hermit/settings.json:39
plugins/feed-hermit/settings.json:24
plugins/claude-code-homeassistant-hermit/settings.json:22
Edit requires an existing file and a prior read; Write will happily create or overwrite .env wholesale. Whichever plugins keep an Edit(.env) deny should carry the matching Write(.env).
3. Pre-existing bug in the redirect patterns: they require a space after >. The glob Bash(*> .claude/settings.json*) compiles to ^.*> \.claude/settings\.json.*$, so echo x>.claude/settings.json does not match — confirmed in the table above. The same flaw applies to the OPERATOR.md pattern today. Any new .env pattern should not inherit it, and the two existing ones want fixing in the same pass.
Note the leading .* does correctly absorb the first > of >>, so append redirects are covered once the spacing is handled.
Scope, honestly
A glob list over command strings cannot fully close this. sed -i, cp, mv, dd, a Python one-liner, or an editor invocation all reach .env without matching any redirect pattern, and enforce-deny-patterns.ts deliberately fails open on parse errors (scripts/enforce-deny-patterns.ts:177, :193, :213).
So the goal here is defense-in-depth parity with how OPERATOR.md and settings.json are already treated — not a claim of a sealed boundary. Worth deciding as part of this:
- Should the common redirect forms be patterns, or should a small dedicated hook match the write target rather than the command spelling? The latter would also cover
tee, sed -i and cp, and would not need a new glob per verb.
- Should
.env writes be in default or always_on? An interactive operator editing their own .env through the agent is a legitimate flow; an always-on hermit doing it unprompted is not. That argues always_on, matching how settings.json is handled.
- Once enforcement exists, the plugins that state "the agent cannot edit .env" can keep saying so. Until then that wording should be softened wherever it appears, since it currently overstates what is enforced.
Affected files
plugins/claude-code-hermit/state-templates/deny-patterns.json
plugins/claude-code-hermit/scripts/enforce-deny-patterns.ts (only if the target-matching option is chosen)
plugins/claude-code-hermit/tests/hooks.contract.test.ts — the table above is the case list
- the four
settings.json files listed above, for the Write(.env) companion deny
Summary
Four domain plugins deny
Edit(.env)in theirsettings.jsonand document.envas the operator-only surface. Nothing enforces that against a Bash write or against theWritetool, so an agent can add or change.enventries — including entries that widen its own permissions.This is a real gap rather than a theoretical one now, because
laravel-forge-hermitputs a security decision in.env:FORGE_POLICY_ALLOW_TIERSandFORGE_POLICY_ALLOWare what lift itssecretsanddestructivedeny tiers, and its refusal text tells the operator "operator-only; the agent cannot edit .env". That sentence is currently not true.Reproduction
Feeding payloads straight into the hook (
AGENT_HOOK_PROFILEas noted, exit 2 = blocked):echo FOO=1 >> .envecho FOO=1 | tee -a .envWrite(file_path=".env")echo x > .claude/settings.jsonecho x>.claude/settings.jsonrm -rf buildThree distinct problems
1. No deny-pattern covers writes to
.env.state-templates/deny-patterns.jsonblocks reads (Bash(cat .env*),Bash(cat */.env*)) but no write form. The precedent for exactly this shape already exists for other protected files —Bash(*> *.claude-code-hermit/OPERATOR.md*)indefault, andBash(*> .claude/settings.json*)inalways_on— so.envis simply missing from that set.2.
Write(.env)is denied nowhere. All four plugins list onlyEdit(.env):plugins/laravel-forge-hermit/settings.json:19plugins/claude-code-fitness-hermit/settings.json:39plugins/feed-hermit/settings.json:24plugins/claude-code-homeassistant-hermit/settings.json:22Editrequires an existing file and a prior read;Writewill happily create or overwrite.envwholesale. Whichever plugins keep anEdit(.env)deny should carry the matchingWrite(.env).3. Pre-existing bug in the redirect patterns: they require a space after
>. The globBash(*> .claude/settings.json*)compiles to^.*> \.claude/settings\.json.*$, soecho x>.claude/settings.jsondoes not match — confirmed in the table above. The same flaw applies to the OPERATOR.md pattern today. Any new.envpattern should not inherit it, and the two existing ones want fixing in the same pass.Note the leading
.*does correctly absorb the first>of>>, so append redirects are covered once the spacing is handled.Scope, honestly
A glob list over command strings cannot fully close this.
sed -i,cp,mv,dd, a Python one-liner, or an editor invocation all reach.envwithout matching any redirect pattern, andenforce-deny-patterns.tsdeliberately fails open on parse errors (scripts/enforce-deny-patterns.ts:177,:193,:213).So the goal here is defense-in-depth parity with how OPERATOR.md and
settings.jsonare already treated — not a claim of a sealed boundary. Worth deciding as part of this:tee,sed -iandcp, and would not need a new glob per verb..envwrites be indefaultoralways_on? An interactive operator editing their own.envthrough the agent is a legitimate flow; an always-on hermit doing it unprompted is not. That arguesalways_on, matching howsettings.jsonis handled.Affected files
plugins/claude-code-hermit/state-templates/deny-patterns.jsonplugins/claude-code-hermit/scripts/enforce-deny-patterns.ts(only if the target-matching option is chosen)plugins/claude-code-hermit/tests/hooks.contract.test.ts— the table above is the case listsettings.jsonfiles listed above, for theWrite(.env)companion deny