feat(opencode): home-manager-managed opencode config β generated AGENTS.md, env plugin, permissions, 3 subagents - #276
Merged
Conversation
opencode was running with none of the repo's ruleset and none of the
kubeconfig handles. Both are now deployed from this repo.
AGENTS.md is GENERATED (not symlinked) by concatenating claude/PRINCIPLES.md
+ claude/RULES.md + a new claude/opencode-addendum.md at switch time. It has
to be a concatenation: opencode does NOT expand `@`-imports inside
AGENTS.md/CLAUDE.md (measured on v1.18.4 with an all-tools-denied agent, so
no file read was possible β an imported passphrase returned NONE, the same
content inline returned verbatim). ~/.claude/CLAUDE.md is ~1.5 KB of
@PRINCIPLES.md/@RULES.md import lines, so pointing opencode at it would have
delivered none of the 32 KB of actual rules. Generating means it can never
drift from what Claude Code reads. Measured result: 38,033 B ~= 8.8k tokens.
env.js is a `shell.env` plugin exporting HOMELAB / KC_HOMELAB / KC_WORKBENCH
/ KC_PROD. There is no `env` config key in opencode and its bash tool does
not source zsh startup files β a plugin hook is the only seam. It must land
directly at plugin/env.js as `.js`: the glob is `{plugin,plugins}/*.{ts,js}`,
non-recursive, and a `.mjs` will not load.
opencode.jsonc pins the model + the hidden title/summary/compaction agents to
the cheap model (`small_model` covers title generation ONLY, not compaction),
makes the built-in `plan` genuinely read-only, and carries the bash permission
block. That block's ordering is load-bearing and is the INVERSE of Claude
Code: opencode is LAST-MATCH-WINS, so `"*": "allow"` is the FIRST key and
every deny/ask follows it. Sorting those keys silently disables every deny β
two tests pin the ordering, and a reorder mutation is caught by those two
alone while the other 75 tests stay green.
Three subagents only β nav (bash DENIED, the deterministic fix for file
navigation shelling out), k8s (three clusters, read-before-mutate,
commit-to-trunk-is-a-deploy) and review (adversarial). Each additional
subagent permanently enlarges the primary agent's `task` tool description on
every request.
home.activation.opencodeDropStaleConfig backs up the pre-existing unmanaged
~/.config/opencode/opencode.jsonc before checkLinkTargets, since `force` alone
does not displace a hand-placed regular file at a managed path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
β¦ against the resolved agent dump
Three corrections, all from running `opencode debug agent` against the real
config rather than reasoning about the JSON.
1. browser-agent must not inherit the new global AGENTS.md.
MEASURED: the global ~/.config/opencode/AGENTS.md IS injected into a run
whose --dir is an unrelated scratch project β a tool-less probe in a scratch
dir quoted a passphrase planted at the END of the file, so it arrives in
full. Cost 8,343 input tokens vs 631 isolated = ~7.7k extra PER REQUEST,
with cache.read/cache.write both 0, so it is multiplied by $STEPS (default
12): up to ~90k tokens per run, for an agent whose every host tool is denied
and which can act on none of it.
Neither obvious mitigation works: OPENCODE_DISABLE_CLAUDE_CODE=1 had ZERO
effect (byte-identical token count), and a project-local AGENTS.md does NOT
suppress the global one β they are CONCATENATED (both passphrases returned,
tokens went up). Relocating OPENCODE_CONFIG_DIR is what works.
The dir is STABLE and REUSED, never mktemp'd per run: a fresh dir makes
opencode materialise package.json + 63 MB of node_modules (2.6-4.5 s), and
with no network it HUNG β killed at 120 s having produced 0 bytes, which
trips the harness's own tool-set gate and dies with "produced NO output". A
warm dir runs offline fine (+0.05-0.3 s). Verified safe: auth is unaffected
(auth.json is in the DATA dir) and the gate output was BYTE-IDENTICAL
(10,670 B, same resolved tools map, browser:true and every host tool false)
under live vs isolated config β the custom `browser` tool loads from the
scratch project's .opencode/tools/, not the config dir.
2. There is NO `list` tool on opencode 1.18.4. The resolved tool map is exactly
{bash, edit, glob, grep, invalid, question, read, skill, task, todowrite,
webfetch, write}. nav.md and the addendum were telling agents to use a tool
that does not exist β whose likely fallback is the shell-out these agents
exist to prevent. Replaced with `glob` on `<dir>/*`.
3. nav was not actually lean. Resolved, it carried skill/task/todowrite/webfetch
β and `skill` alone injects the whole catalogue at ~3,730 tokens per request,
on the cheap high-frequency agent. Denying them takes nav's resolved tool set
to exactly {glob, grep, read}.
Also records what review's deny-all really does. Resolved order is global block
then agent block: [0] allow * β¦ [31] deny * [32..36] the git/rg allows. Last
match wins, so [31] is the effective default and any non-allowlisted command
resolves deny β the restriction is real. But bash stays `true` in the tool map,
i.e. it is NOT pruned from the request schema. That buys safety, not tokens, and
the comment now says so rather than implying otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
β¦ective outcomes Ten findings from the adversarial audit of #276. The permission claims in the original PR body were too strong: the block was measured by reading the config, not by resolving it, and three of the four load-bearing guarantees did not hold. CRITICAL * The `k8s` agent's `bash: {"*": allow}` nullified the ENTIRE global block. Agent rules are appended AFTER the global ones and opencode is last-match-wins, so that wildcard landed at index 74 (after all 30 global rules) and only its own 4 survived. Blind-staging, the stash ban, hard-reset, `rm -rf ~β¦`, `sops -d`, `nixos-rebuild` and `home-manager switch` were all plain ALLOW on that agent. The agent block now only TIGHTENS: no wildcard, just `*talosctl*: ask` with `*talosctl reset*: deny` restated after it. * The global `permission` block re-enabled all tools on the hidden title/summary/compaction agents, whose stock tool set is EMPTY. compaction runs automatically on every context overflow, on the cheap model β it had bash and write, and every title generation carried the ~3,730-token skill catalogue. Each now carries `"permission": {"*": "deny"}`; all three resolve `enabled=[]` again, matching a stock control. * Every deny/ask was bypassable by a prefix or wrapper. opencode matches a command NODE's full text, so `FOO=1 β¦`, `sudo -n β¦` and `git -C <path> β¦` all missed the anchored patterns β and those are exactly the spellings the house style mandates. Every dangerous pattern is now leading-`*`; `*` is an unrestricted dotAll `.*` that crosses spaces, `/` and `-`, verified against the real engine. * The ordering tests were vacuous. A trailing `"git *": "allow"` and an alphabetical key sort both passed at 82/82. Added the missing "no allow after the wildcard" assertion, an "all asks precede all denies" assertion, and β the real fix β a faithful port of opencode's resolver that pins the EFFECTIVE resolved action for a matrix of dangerous commands on EVERY agent. Both mutations now go red, each with its own assertion. ALSO * Dropped the blanket `read: allow`, which was appended after opencode's built-in .env guard and defeated it on every agent. * Restored `plan`'s built-in `task: {general: deny}` (the global `task: allow` had flipped it, giving plan a shell by delegation) and added `write: deny`. * Closed ~30 unlisted command families: secret reads, kubectl exec/cp/edit/ replace/rollback, flux delete, privilege wrappers, recursive chmod/chown, device writes, systemctl mutations, and the `.`/`$HOME` delete targets. * `review` could not run `git -C <path> diff` β DENIED. In a worktree-first workflow that is the only spelling it uses, so it silently fell back to read/grep and reported a review it had not performed. Allow-listed the `git -C * <verb>*` forms, anchored on purpose. * browser-agent's isolated config dir existed but was EMPTY, i.e. exactly the cold-start it exists to avoid. Added a warm/bootstrap step with an atomic lock for concurrent runs, a binary-identity stamp for staleness, and a loud degrade-to-global-config on failure. Verified by OUTCOME: bare `opencode debug` commands exit 0 while installing nothing β only resolving a project's .opencode/tools/ triggers the install. Cold 7.0s, warm 0.7s, 4 parallel cold runs converge, no lock leak. * env.js was a checked-in file hardcoding /home/zach with no existence guard, duplicating the zsh handle block. Both are now generated from one source, nix/agent-handles.nix, and KC_PROD is reconciled into zsh. * Test-file fixes: handle paths pinned to full literals against a synthetic home (the old assertion passed with HOMELAB set to /tmp/totally-wrong and with the two kubeconfig targets swapped), and PyYAML is required rather than importorskip (a run without it reported 77 passed / 5 skipped / exit 0 while silently dropping every agent-permission test). * browser-agent tests now pin their own OPENCODE_CONFIG_DIR β otherwise the new bootstrap would judge the operator's real warmed cache stale and delete it. CORRECTION carried into the docs: the repeated claim that opencode's bash tool "does not source zsh startup files" is FALSE on this host (v1.18.4). The tool shell IS zsh and does source .zshenv. The original negative control was real but misattributed β the kubeconfigs are gitignored and absent, so zsh correctly declines to export KC_HOMELAB while the old UNGUARDED env.js exported it regardless. The plugin looked load-bearing because it was pointing a handle at a file that does not exist. It is kept as belt-and-braces for a non-zsh $SHELL. Verification: 384 tests in the opencode suite (was 82), 162 of which are red at e353841 and green here. 10/10 mutations red, control green. Real-engine probes via `opencode debug agent --tool bash` confirm all 18 deny cases across build/ k8s/review. Full repo gate green except two pre-existing dependency failures (psycopg2, minio) that are identical at e353841. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 2, 2026
β¦est can't fail (#283) Two lessons from the 1.4.0 element-ref enrichment leak (homelab-infra fcaca875 / PR #276), plus three corrections to a developer note that had been wrong since it was written. reference/element-references.md - Correct the note: the walk uses previousSibling/nextSibling (NODE variants β text nodes count), not the Element variants, and slices at 40 chars, not 120 (refText then caps at MAX_REF_TEXT_CHARS=80). Both claims were false as written. - Record the choke point: every page-text read must go through adjacentText β pageText. Reading .textContent directly is what shipped the leak. Never add a second "is this a text field" predicate β that root cause has now recurred seven times. - π΄ New section: most obvious tests of this guard CANNOT fail. A native <input>/<textarea> never exposes its current value via textContent (typing updates .value), so a canary typed into a form field passes under the leaking build too. Three consecutive live captures were wasted this way (tasks #147-#149), each read as a pass. Only contenteditable / role=textbox and a textarea's SERVER-RENDERED initial content carry user text. Documents the fixture that actually settled it (#150), including the positive control that catches a blanket refusal. SKILL.md - The extension does NOT ship via Flux: merging to trunk deploys nothing. Brave loads a flat copy at ~/clawgate-extension ON THE WORKBENCH, delivered by sync-clawgate-extension.sh, and does not hot-reload unpacked extensions. - A missing .synced-from stamp means a hand-copy: that is how a build which never passed CI ran live for ~11 hours while trunk looked clean. - Workbench firewall allowedTCPPorts is a short allowlist β serve scratch test pages on localhost, don't open a port for a throwaway.
β¦tic parser Two rounds of patching permission globs each closed the spellings we thought of and left the ones we did not. Measured at c1e4c02 by replaying that ref's config through the resolver model - all resolving ALLOW on the primary agent: talosctl -n 192.168.50.94 reset <- a node wipe, no prompt talosctl --nodes=192.168.50.94 reset rm -f -r / rm --recursive --force / mke2fs /dev/sdc mkswap /dev/sdd because the ask pattern required the tool and the verb to be ADJACENT, and the rm/mkfs patterns knew one flag order and one binary name. The deny block had been given infix wildcards; the ask block had not. Separately, the review agent's allow-list let a stash whose message contained the word "diff" EXECUTE (verified - it created a stash; the same command without that word was denied), because opencode's wildcard is an unrestricted dotAll that crosses spaces, so argument text satisfied the pattern. A glob over full command text cannot express "this command wipes a node". So the hard denies move to something that parses. WHAT THIS ADDS scripts/claude-hooks/guard_core.py - a shared, caller-agnostic core with named policy sets. It splits a command line on ;/&&/||/|/&, strips VAR= prefixes and sudo/doas/env/timeout/nice/... wrappers, recurses into `bash -c` and `eval`, and reasons about argv rather than adjacency. scripts/opencode/plugin/guard.js - an opencode plugin running that core from `tool.execute.before`, throwing on a deny (which hard-blocks the call). WHICH HOOK, AND WHETHER ask IS EXPRESSIBLE (measured, 1.18.4, 2026-08-02) `permission.ask` is in the Hooks type and its output.status is typed "ask"|"deny"|"allow", so an ask decision LOOKS expressible. It is not: the hook never fired in any probe - not on the allow path, and not on the ask path either (an ask rule under `opencode run` printed "auto-rejecting" without the hook logging a line). `tool.execute.before` fired on every bash call and throwing from it hard-blocks. So DENY is expressible from a plugin and ASK is not; ask-grade families stay as globs. Also measured: `opencode run` AUTO-REJECTS an ask; only the interactive TUI prompts, and `opencode debug agent --tool` auto-APPROVES. ask is friction for a human, never a control on an unattended agent. CLAUDE CODE IS UNCHANGED bash-guard.py becomes a thin adapter running the "claude-code" policy, which is FROZEN at the original six checks. Proof: the existing suite is 113/113 green unchanged, and a before/after decision matrix over a 2,097-command corpus differs on 0 rows (positive control OLD-vs-OLD = 0 diffs; negative control OLD-vs-one-check-removed = 324 diffs, so the harness can see a change). ZERO new denies reach Claude Code. The opencode-only checks are: talosctl reset, mkfs/mke2fs/mkswap, dd to a block device, rm -r of / | $HOME | ~ | cwd | a top-level system dir, git stash (list/show still allowed), git clean -f, and git reset --hard through a -C hop. That last one is a GAP IN CLAUDE CODE TODAY, found while doing this: the frozen check_git_reset_hard is a raw-text regex anchored on "git reset", so `git -C <path> reset --hard` - the worktree-first spelling RULES.md mandates - does not match it. Closing it for Claude Code is a one-line policy change and is the operator's call, so it is NOT made here. ALSO IN THIS COMMIT * review.md re-states the writing verbs as denies AFTER its allow-list, so argument text can no longer ride the `git -C * diff*` pattern. * browser-agent: the `command -v opencode` preflight is hoisted above the config-dir bootstrap. Without opencode on PATH, readlink -f resolved a bare string against cwd, the stamp could never match, and the bootstrap rm -rf'd the shared cache on every run - reachable from systemd/cron/a switch. Also: the lock wait now derives from OC_WARM_TIMEOUT (it was 30s around a 90s job, so the loser force-stole the lock and deleted node_modules while the first process was still writing), and a timeout-killed warm (exit 124) is no longer stamped valid. * test_opencode_config.py: pytest.importorskip("yaml") -> a hard import. The comment at :90-92 claimed that had already been done; it had not. Without PyYAML the run reported "486 passed, 1 skipped", exit 0, with 384 assertions silently gone. * The browser-agent tests now assert against COMMENT-STRIPPED source: one assertion was being satisfied by a comment saying the code deliberately does the opposite. TESTS Red/green: the new tests were run against a pre-change seed tree (c1e4c02 config + review.md + browser-agent, guard degraded to the six) - 175 failing there, 1,171 passing at HEAD. That run also caught a vacuous assertion in one of the NEW tests (a bare `command -v` substring that also matched the copy inside oc_warm_version, so it passed against the very ordering bug it was written for); it is now anchored on the `|| die` preflight. Every new rule is exercised across an outer product of nine prefixes, each git global-option hop, and each of the five separators. That matrix immediately found a real bug in the first draft: `sudo -n <cmd>` peeled wrong, because -n had been put in a shared value-flag set for `nice -n 5`, so the node-wipe guard silently stopped firing on the most ordinary sudo spelling there is. Live end-to-end under a real `opencode run` against a sandbox config dir: `mkswap /dev/zzz-nonexistent` and a flag-interleaved talosctl reset were both BLOCKED with the guard's message (the sandbox config had bash "*": "allow", so only the guard could have stopped them); echo ran as the negative control; and with DEVRC_GUARD_CORE pointed at a missing file every call was refused, confirming fail-closed. Full hermetic suite: 4,015 passed, 1 skipped, 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 52-mutant sweep built DIFFERENTLY from the suite it attacks (pattern-NARROWING
mutants that keep the function, the policy entry and the deny action, and change
only what matches) killed 39/50 and left 11 survivors. Its own harness was broken
on the first run - a helper argument bug made all 52 "mutants" byte-identical to
pristine, and the pre-flight check passed because nothing was applied. Only the
known-bad control caught it; without that negative control it would have reported
~50 bogus survivors.
Every one of the 11 survivors was a hole in the TEST SUITE, not a defect in
guard_core.py - the code already handled each case and nothing pinned it. That is
exactly the failure mode of the previous round, whose 10/10 sweep had blind spots
only a differently-built sweep could see.
N16/P14 argv[0] and wrapper tokens are basename-normalised, but no test used
an absolute path. On NixOS `/run/current-system/sw/bin/talosctl` and
`/run/wrappers/bin/sudo` are the NORMAL spellings.
P04 `_peel_variants`' arity branching - the thing the docstring calls the
reason a wrong flag table "cannot go silently open" - was pure prose.
Now a generated matrix over every (wrapper, value-flag) pair.
P13 bundled `-c` shells (`bash -xc '...'`); only bare `-c` was pinned.
S08 the unlexable-input fallback in `_tokenise`, untested for EVERY check.
S09 `cd <path> ; git ...`; only the `&&` spelling was pinned.
S11 private-key headers other than `RSA PRIVATE KEY` - i.e. the wildcard
that covers modern OPENSSH keys.
N14 no test pinned any INDIVIDUAL secret shape; deleting three did nothing.
N15 only the `gh ... create` sinks; `gh pr comment/edit/review` unpinned.
S12 the public-IP arm - the check whose docstring says it exists because a
real session leaked an ingress origin IP into a public-repo comment -
was entirely unpinned, exemptions included.
S03 the `--` boundary in `_flags_and_operands`. The sweep brute-forced
1,102 shapes and found it currently safety-NEUTRAL, so it is pinned on
the helper directly rather than through an outcome that happens not to
depend on it.
Also strengthened three weak kills the sweep flagged: N13 (`check_git_reset_hard_
argv` was killed by exactly one test - a one-test-deep guard on its own headline
case) and S01/S04 (killed only by over-block assertions, so a relaxation there
would have silently un-covered them).
ONE REAL CODE FIX
The new (wrapper, value-flag) matrix immediately went red on
`timeout -k talosctl reset` and `chrt -p talosctl reset`: for a wrapper that takes
POSITIONALS, the alternate arity branch still hit the positional consumption and
swallowed the real command, so both peelings produced `['reset']`.
`_peel_variants` now branches on positional consumption too. Same class of bug as
the `sudo -n` one the previous matrix caught, and invisible for the same reason -
nothing exercised the branch.
Claude Code re-verified after the parser change: the before/after decision matrix
over the 2,097-command corpus still differs on 0 rows, and test_bash_guard.py is
still 113/113. Full hermetic suite: 4,230 passed, 1 skipped, 0 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 2, 2026
ZacxDev
added a commit
that referenced
this pull request
Aug 2, 2026
β¦tests were not running (#289) #276 added a FILE to the runner's target list: scripts/claude-hooks/tests/test_guard_core.py and `run_pytest()` guarded with `if [ ! -d "$d" ]`, which rejects a regular file. The gate reported FAIL scripts/claude-hooks/tests/test_guard_core.py (missing directory) so the pytest check has been RED on main ever since, and the 913 tests in that file never ran. The wording made it worse: "missing directory" reads as an environment fault, so the natural conclusion was "the new #284 gate is noisy" rather than "a real target is being dropped". THE FIX * `run_pytest()` checks existence with `-e` and branches on `-d`/`-f` only to pick the message β `python -m pytest` accepts either. Per-target parse and attribution are untouched, so #284's guarantee that a per-suite collapse stays attributable is preserved. * `HERMETIC_DIRS` -> `HERMETIC_TARGETS` (`DEVHOST_DIRS`/`DIRS` likewise): the name asserted something false about its own contents. * Header comment now says entries may be files, and documents GUARD 5. NEW GUARD 5 β validate the WHOLE list up front, naming every bad entry A typo, a moved suite, an unexpanded glob and a file all failed the same indistinguishable way, and only once the runner reached that suite. GUARD 5 checks every entry before anything runs and reports each one with a reason. `--check-targets` runs just that guard (no pytest, no tool precondition), which is what makes it testable in milliseconds. Corrected a claim while writing it: bash DOES expand globs inside an array literal (measured β injecting `scripts/tests/test_*.py` took the list from 15 to 32 entries), so only an UNMATCHED glob survives as a literal. The comment and the test case now say that instead of the opposite. MEASUREMENTS (same tree, only the runner differs) before collected=3385 FAIL β¦ test_guard_core.py (missing directory) after collected=4306 PASS β¦ test_guard_core.py (collected=913 passed=913) delta +921 = 913 recovered + 8 from the new test file Accepting the target without executing it would look identical from outside the gate, so the count is the proof β not the exit status. TESTS (scripts/tests/test_run_tests_targets.py) red at origin/main's run-tests.sh: 8 failed in 0.06s green at HEAD: 8 passed in 0.06s Reachability, on the REAL list: injecting a bogus entry makes GUARD 5 exit 2 naming it, and exactly the two list-acceptance tests go red while the other six still pass β attributable, not a blanket collapse. The file labels which of its tests are regression coverage and which is an invariant guard, per claude/RULES.md. NOT fixed here, filed separately: #276 also moved SECRET_PATTERNS out of bash-guard.py into guard_core.py, so session_insight's test_patterns_cover_bash_guard parses [] and FAILS on any host where the hook is deployed. It SKIPS in the nix sandbox, so the flake gate never sees it. Different file, different failure mode β not bundled. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 2, 2026
β¦s-vs-instances instances Both measured while unbreaking the pytest gate (#289). TWO TIERS. 'Gate on the merged tree' extended to environments. The same suite runs in the nix sandbox and on a dev host, and each environment silently decides which tests execute β so a defect can be permanently unobservable in the tier you happen to read. #276 shipped THREE regressions that masked each other: (1) a FILE in run-tests.sh's target list rejected by `[ ! -d ]` -> gate red, 913 tests never ran (2) SECRET_PATTERNS moved to guard_core.py -> the drift test parses [] and FAILS on a host with the hook deployed, SKIPS in the sandbox (3) 10 nix-instantiate tests pytest.fail() without the binary -> FAIL in the sandbox, pass on every dev host (2) and (3) are exact complements; both hid behind (1)'s red. DECLARATIONS VS INSTANCES gets its third instance in one session: 2 skipif decorators -> 123 tests; 1 list entry -> 913 tests; 1 nix_eval() helper -> 10 parametrized tests. Three is a pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 2, 2026
Merged
ZacxDev
added a commit
that referenced
this pull request
Aug 2, 2026
β¦un in the sandbox (#290) scripts/tests/test_opencode_config.py's `nix_eval()` shells out to `nix-instantiate --eval` to pin the GENERATED handle values from nix/agent-handles.nix, and calls pytest.fail() β NOT skip β when the binary is absent, deliberately: 'a skip here is how a wrong kubeconfig path ships'. `nix` was not in the check's nativeBuildInputs, so all 10 test_handles_resolve_to_the_exact_expected_paths[...] cases failed in the nix sandbox and passed on every dev host. This was INVISIBLE until #289: it hid behind the gate being red for an unrelated reason. Its sibling defect is the exact complement β session_insight's test_patterns_cover_bash_guard fails only where ~/.claude/hooks/bash-guard.py is DEPLOYED and skips in the sandbox. Both came from #276. MEASURED, not predicted (the pure-eval argument was a prediction; this is the check's own output): scripts/tests before collected=959 passed=949 failed=10 scripts/tests after collected=959 passed=959 failed=0 TOTAL collected=4353 passed=4351 skipped=2 failed=0 skips pinned 2, observed 2 nodetests files=14 tests=468 pass=468 fail=0 nix flake check all checks passed Identical collected count with failures to zero β the 10 PASS rather than merely stopping to fail. The 10-failure diagnosis was confirmed first by a positive control on a dev host: stripping PATH to python alone reproduced exactly '10 failed, 455 passed' in that file. COST, stated as deliberately as the nodejs one above it: +30 store paths, 22 of them the nix closure (nix-{util,store,expr,main,flake,fetchers,cmd} incl. -dev outputs, plus boost and libarchive), and a nixpkgs bump moving `nix` now invalidates this check's cache β the same trade already accepted for nodejs. Bought: 10 tests that structurally cannot run without it, pinning that the handles every agent shell exports resolve to exact paths. Rejected DEVHOST_TARGETS as the alternative: it keeps the closure small but weakens the pin to 'runs only where someone remembers to run it', which is the failure mode this whole area keeps hitting. Also adds nix-instantiate to REQUIRED_TOOLS so a missing binary is ONE named precondition failure instead of 10 unexplained assertion failures deep in the run β that guard's entire purpose. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 2, 2026
β¦be tier-invisible (#291) test_patterns_cover_bash_guard compared scrub.py's SECRET_PATTERNS against the DEPLOYED hook (Path.home()/'.claude'/'hooks'/'bash-guard.py') and skipped when that file was absent. Two problems, and the second is why it sat broken: 1. WRONG REFERENT. The deployed copy is generated from the repo file by home-manager, so the test answered a question about the machine, not about the commit. 2. IT COULD NOT FAIL IN CI. Keyed on $HOME it SKIPPED in the nix sandbox and RAN only on a switched dev host. When #276 moved SECRET_PATTERNS out of bash-guard.py into guard_core.py, the ast parser returned [] and the test failed on every dev host while the hermetic gate β the tier that gates merges β stayed green and silent. Now compares two files both TRACKED IN THIS REPO (session_insight/scrub.py vs claude-hooks/guard_core.py), so it runs in every tier and NEVER skips. Renamed to test_patterns_cover_guard_core. The empty-parse case is now an explicit, loud assertion naming the cause rather than the silence that hid #276. π΄ run-tests.sh: the conditional EXPECTED_SKIPS entry that pinned the old environment-dependent skip is REMOVED β mandatory, not cosmetic. The test no longer skips, so leaving the pin would fail the gate with 'FEWER than pinned'. Replaced with a comment saying why it must not come back. REACHABILITY β both assertions broken on purpose, each failing for its OWN reason (not a neighbour's): * inject a pattern into guard_core.py that scrub.py lacks -> 'scrub.py drifted ... no longer covered: ['\\bmutant-XYZ-[0-9]{9}\\b']' * rename the literal, reproducing #276's move -> 'could not parse SECRET_PATTERNS from guard_core.py ...' guard_core.py restored byte-identical after each. session_insight suite: 57 passed, 0 skipped (was 56 passed + 1 skipped in the sandbox / 56 passed + 1 FAILED on a dev host). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 2, 2026
β¦ations-vs-instances + output-format class, CLAUDE.md CI/byte facts, #273 tab discard, clawgate check-chart) (#287) * docs: record what 2026-08-02 measured (RULES, CLAUDE.md, tab discard, clawgate) RULES.md β three additions, all from measured ground cases: * Verify the CONSUMER is running your artifact, not that the deploy reported success. `ship.sh` reported "VERIFIED β on branch main at origin/main + switched" while the browser-bridge unit was crash-looping on `OSError: [Errno 98] Address already in use`; an orphaned process from the previous day (Aug 1 16:18, in NO systemd cgroup) held 127.0.0.1:8788 and served the OLD server.py. The converge check verified branch + switch and structurally could not see that the service never started. Recipe (`ss -lptn` -> `/proc/<pid>/cgroup` -> `systemctl show -p MainPID`) was executed live before being written. * A count of DECLARATIONS is not a count of INSTANCES. A grep of `skipif` decorators found "2 node-related skips"; the two decorators gated 123 tests (initiatives: 660 passed/123 skipped sandboxed vs 783 passed/0 skipped with node). A 60x sizing error. * Sharpen the harness bullet from a MANIFESTATION to a CLASS: when you parse a tool's OUTPUT, its format is an unpinned dependency, and "no matches" means "possibly the wrong pattern". The file already named `diff`'s unified default; that rule was READ this session and the trap was hit anyway in three new shapes (a false CLEAN over a 1,445-byte difference that only `cmp` caught; node 24's reporter change emptying a `^# (tests|pass|fail)` grep; `rc=$?` reading `echo`'s status). CLAUDE.md β correct stale facts in the browser-bridge bullet: * Drop the hand-pinned "281 B free today". test_skill_size.py owns MAX_BYTES/MIN_HEADROOM_BYTES (the floor was raised to 250 in #275); point at it instead of re-pinning a figure that rots. * Record that CI now gates BOTH suites β nodetests (#280) and the pytest gate's silent-coverage-collapse guards (#284) β and the headline outcome, skips 125 -> 2. * Extension 0.7.1; `nav`/`open` accept `--wake[=MS]`. reference/tabs-instances.md β record the #273 measurement: a forced discard assigns a NEW tabId and releases ownership (484065264 -> 484065273, ownedTabId None), so the stale-documentEmulation hazard is not reachable on this Chromium. Scope stated honestly (one build, one profile, one mechanism; auto-discard not exercised; onReplaced inferred, not observed) and the load-bearing caveat kept: the safety is a property of the BROWSER, not of the bridge. Placed in reference/, not the byte-capped core β SKILL.md is unchanged at 11,845 B. clawgate/SKILL.md β live version 0.7.82 (embedded kubeclaw chart 0.7.1, so the pending re-sync note is resolved); "derive from the LIVE pin" stays primary. Adds the `make check-chart` hazard: it depends on `sync-chart`, which rsyncs from ~/workspace/kubeclaw β that clone sat at 0.3.14 against a vendored 0.7.1, so running it would have clobbered the deployed chart and reported a false failure. Fetch + `merge --ff-only` that clone first. Docs-only. No source file touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(rules): two-tier suites must be green in BOTH; three declarations-vs-instances instances Both measured while unbreaking the pytest gate (#289). TWO TIERS. 'Gate on the merged tree' extended to environments. The same suite runs in the nix sandbox and on a dev host, and each environment silently decides which tests execute β so a defect can be permanently unobservable in the tier you happen to read. #276 shipped THREE regressions that masked each other: (1) a FILE in run-tests.sh's target list rejected by `[ ! -d ]` -> gate red, 913 tests never ran (2) SECRET_PATTERNS moved to guard_core.py -> the drift test parses [] and FAILS on a host with the hook deployed, SKIPS in the sandbox (3) 10 nix-instantiate tests pytest.fail() without the binary -> FAIL in the sandbox, pass on every dev host (2) and (3) are exact complements; both hid behind (1)'s red. DECLARATIONS VS INSTANCES gets its third instance in one session: 2 skipif decorators -> 123 tests; 1 list entry -> 913 tests; 1 nix_eval() helper -> 10 parametrized tests. Three is a pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 2, 2026
β¦nal green state (#293) The handoff was written mid-session; its 'State now' is superseded. Records the four regressions from PR #276 that concealed each other (two are exact complements - one invisible in the sandbox, one on dev hosts), the three declarations-vs-instances cases, and the preserved operator file on the workbench that ship.sh was blocked on. Final: main c9cb47a, both hosts converged, nix flake check green (pytest 4373/4372/1 skip/0 fail, node 468/468), zero open PRs. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 3, 2026
#297) RULES.md's "Tool Optimization" section says "Grep over bash grep, Glob over find". Measured over a 30-day telemetry window that prose rule is not working: Bash is 71% of all Claude tool calls (workbench 31,355 / laptop 6,164) against 50 Grep+Glob calls total β and ZERO on the laptop. Per RULES.md "Deterministic Over Prose", the replacement has to be structural, so nudge at the moment the search runs. search-tool-nudge.py fires PostToolUse on Bash calls that are a tree search (`grep -r`, bare `rg`/`ag`/`ack`, `find <path> -name`, `ls -R`, `find | xargs grep`, `find -exec grep`) and injects additionalContext pointing at Grep/Glob. It is a NUDGE: it never denies and always exits 0, matching shell-env-nudge/audit-pr-nudge. Deduped to once per KIND (content / files) per session, so a session sees at most two. Conservative by construction, because false positives on a per-Bash-call hook train the operator to ignore it. Silent on: a non-recursive grep (single file OR a pipeline filter such as `git log | grep push`), `rg` fed by a pipe, `find -exec rm/chmod`, `find -delete`, `find` with no name/path predicate, plain `ls`, search-shaped text inside a quoted string, and heredoc bodies (`ssh host 'bash -s' <<'EOF' β¦ EOF` runs on a REMOTE host that Grep/Glob cannot reach). Also fixes the silent-skip in run-tests.sh's HOOK_TESTS loop: a missing entry was `|| continue`, the exact #276 shape GUARD 5 exists to prevent. It now fails loudly. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 3, 2026
β¦run (#306) `scripts/dl-router/tests` was absent from `scripts/run-tests.sh`'s target list since the suite was written: 989 tests, zero of them gated. Same declarations-vs-instances shape as #298 (166 opencode tests) and #276 (913 guard-core tests) β one missing line in a target list. Adding it surfaced two SANDBOX-ONLY portability defects, both of the kind that are structurally invisible on a dev host: * test_setup_script.py's write_pgrep() wrote a stub with `#!/usr/bin/env bash`. The nix build sandbox has no /usr/bin/env; every NixOS dev host does. Now goes through the helper #298 landed. * test_cli.py / test_server_wiring.py hard-coded port 8799 for their "sidecar is DOWN/unreachable" assertions. That is a claim about the whole machine, not the test β an orphaned python3 (pid 2994086, started Aug 1 13:40, ppid 1) was listening on it and the test reached a REAL dl-router, failing with `sidecar HTTP 409: not_owned_tab`. Ports now come from a `closed_port` fixture. Shared instead of re-derived (RULES.md "One rule, one place"): * `scripts/collector/opencode/tests/_mockbin.py` -> `scripts/testlib/ mockbin.py`, importable by any suite. * #298's runtime-shebang scan was scoped to ONE directory, which is exactly why it could not see the dl-router defect. The scanner moved to `scripts/testlib/shebang_scan.py` and the guard is now REPO-WIDE (`scripts/tests/test_runtime_shebangs.py`) with a pinned allowlist that fails BOTH ways β an unpinned offender and a pin that matches nothing. MIN_TESTS 2850 -> 5600. The old floor had drifted below HALF the real total, so a whole 989-test suite could have vanished underneath it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 3, 2026
ZacxDev
added a commit
that referenced
this pull request
Aug 3, 2026
β¦ver run (#309) `scripts/run-node-tests.sh` hard-coded its collection to one directory: FILES=(scripts/browser-bridge/tests/*.test.mjs) so every other `.test.mjs` suite in the repo was invisible to the only check that runs node. Measured at origin/main (13bc8bd): * scripts/dl-router/tests 508 tests, never gated * scripts/collector/browser-ext/tests 21 tests, never gated 529 tests, ungated since each suite was written. The gate reported `RESULT: PASS` with a 468-test total the whole time β a hard-coded list cannot know what it is not looking at. Fourth instance of the same shape (#276: 913 pytest tests behind one list entry, #298: 166, #306: 989). Adding two lines would leave the NEXT suite ungated identically, so collection is now DISCOVERY (bash globstar over scripts/**/*.test.mjs) plus a TWO-WAY PIN: * a discovered directory absent from SUITES -> FATAL (forces an accounting entry with a measured floor, rather than being swept in under the total) * a pinned suite discovery does not find -> FATAL (the suite vanished) Discovery alone would reintroduce the silent-collapse hole the old hard-coded glob at least did not have: an emptied dl-router/tests would just collect fewer files and still pass over a global floor. Each suite also runs in its OWN `node --test` invocation with its own TAP summary and its own floor. A single global floor of 970 is fully satisfied by browser-bridge (468) + dl-router (508) with browser-ext's 21 tests entirely gone; per-suite floors make that loud. A PORTABILITY DEFECT found while building this, worth recording because the first draft shipped it and the harness hid it: discovery used `find -printf '%h\n' 2>/dev/null`. `-printf` is a GNU extension, and THREE different `find`s are reachable from this repo β busybox under bash (~/.nix-profile/bin/find, rejects it), bfs 4.1.1 under the interactive zsh, GNU findutils in the nix sandbox. Paired with `2>/dev/null` the rejection became an EMPTY discovery list with no error β a false "no suites found". Collection is now a bash builtin (globstar), which depends on no external binary and behaves identically in every tier; `test_runner_does_not_use_find_printf` pins it. MEASURED, both tiers: nix build .#checks.x86_64-linux.nodetests 997 tests / 997 pass / 0 fail bash scripts/run-node-tests.sh (dev host) 997 tests / 997 pass / 0 fail 468 (baseline, browser-bridge) + 508 + 21 = 997. No unexplained delta. scripts/tests/test_run_node_tests_suites.py guards the pin, with its regression and invariant guards labelled honestly in the module docstring, mutation proofs that the guard goes red naming the offending directory, and a positive control on both parsers (the reassuring answer here is an empty set, which an unwired harness also produces). The 529 newly-gated tests are NOT regression coverage for this change β they are pre-existing tests whose value is that they now run at all. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 3, 2026
β¦dition (#310) * fix(gate): run-tests.sh could not check its own most important precondition Two measured holes, plus an honest correction to a third. 1. REQUIRED_TOOLS could not express the precondition that matters most. It is a list of BINARIES checked with `command -v`. pytest is not a binary this runner calls β it is a MODULE (`python -m pytest`) β so the one guard whose entire job is "the thing that runs the tests is present" was structurally unable to check pytest. It also asserted `python3` while the runner actually invokes `python`. MEASURED on the dev host with every REQUIRED_TOOLS binary present but no pytest importable: all 17 targets printed run-tests: ERROR β could not parse pytest's summary for <dir>. and the run ended `TOTAL collected=0 β¦ RESULT: FAIL`, exit 1. CORRECTION, stated because it changes the severity: the gate did NOT go green, and the briefed "reports per-target PASS with collected=0" does not exist on this revision β GUARD 4 (unparseable summary) and GUARD 3 (the collected floor) both fire. The defect is DIAGNOSTIC: seventeen copies of a message blaming pytest's OUTPUT FORMAT for a missing dependency, pointing at the wrong subsystem. That is the #276 shape β a real finding that reads like an environment fault. Now one named FATAL, exit 2, before any suite runs. 2. `declare -a RESULTS` / `declare -a SKIP_LINES` leave the arrays DECLARED BUT UNSET. Under `set -u` the first `${#arr[@]}` on a still-empty array aborts the command with "unbound variable" (measured, bash 5.3.15). With zero skips this printed a raw scripts/run-tests.sh: line 479: SKIP_LINES: unbound variable where GUARD 2's skip list belonged, and the unpinned-skip loop below it never executed. No `set -e`, so the script continued and the skip-TOTAL accounting still fired β the damage was confined to the DIAGNOSTIC path, at exactly the moment someone is reading why the gate is red. Fixed with `NAME=()`. 3. "printed RESULT: FAIL and exited 0" did NOT reproduce. Measured: exit 1. The structure forbids it β `RESULT: FAIL` is printed only when `fail != 0` and the next statement is `exit "$fail"`, whose only non-zero value is 1. The likely origin is reading the status through a pipeline (`| tail`), which yields the last command's status rather than the runner's. Pinned end-to-end anyway by `test_a_failing_run_never_exits_zero`, which forces a red run and asserts the two can never disagree β and pytest.fail()s if it could not force one, so it cannot pass vacuously. Known-bad states, each proven LOUD by before/after rather than by reading code: no pytest module before: 17 misleading errors, exit 1 after: one named FATAL, exit 2, no suite started empty target dir fails on the per-directory `collected 0 tests` floor typo'd target GUARD 5 aborts naming the entry and saying "does not exist" The last two were already guarded before this PR; nothing had ever proven those paths could fire, so they are labelled REACHABILITY proofs, not regression coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(test): write the python shim via testlib.mockbin, not a hand-rolled shebang The repo-wide runtime-shebang scanner (#306, scripts/tests/ test_runtime_shebangs.py) failed this file IN THE SANDBOX ONLY. The dev-host run was green because I ran this ONE file, and the scanner lives in another β a per-file run structurally cannot see a repo-wide guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings opencode under home-manager, the way
~/.claude/already is. Measured against opencode v1.18.4 on this host; nohome-manager switchwas run and the live~/.config/opencode/was never modified.π΄
2bacd5fβ the glob approach is replaced by a parserEverything below the next horizontal rule describes the glob permission system and the two rounds of patching it. That approach has now been abandoned as the primary control, and this section says why and what replaced it. The rest of the PR body (generated
AGENTS.md, theenv.jscorrection, cost measurements, subagents) is unaffected and still stands.The measurement that ended it
Two rounds of pattern-patching each closed the spellings we thought of and left the ones we did not. Replaying
c1e4c02's own config through the resolver model, on the primary agent:c1e4c02talosctl -n 192.168.50.94 resettalosctl --nodes 192.168.50.94 resettalosctl --nodes=192.168.50.94 resettalosctl -e 1.2.3.4 -n 5.6.7.8 resetrm -f -r /rm --recursive --force /mke2fs /dev/sdcmkswap /dev/sddgit -C /tmp/x reset --hardreview)The ask block required the tool and the verb to be adjacent (
"*kubectl delete*"), so any interleaved flag walked past it. The deny block had been given infix wildcards in the previous round; the ask block had not."*rm -rf*"and"*mkfs*"each knew exactly one flag order and one binary name.And on the
reviewagent,git -C * diff*sits after its own"*": deny, and opencode's*is an unrestricted dotAll.*that crosses spaces β so the word "diff" anywhere in the argument text satisfied it. Verified by execution:A glob over full command text cannot express "this command wipes a node" β the set of spellings is unbounded. So the hard denies moved to something that parses.
The architecture
Layer 1 β
opencode.jsoncglobs: FRICTION. Broadaskon mutation families so a human sees the command. Now infix-tolerant (*kubectl*delete*), deliberately over-matching, and honest about being non-airtight. Shrunk from 73 rules to 64, and the header no longer claims the patterns catch everything.Layer 2 β
scripts/opencode/plugin/guard.jsβscripts/claude-hooks/guard_core.py: ENFORCEMENT. Splits on;/&&/||/|/&, stripsVAR=β¦prefixes andsudo/doas/env/timeout/nice/β¦ wrappers, recurses intobash -c 'β¦'andeval, scans$( )/backtick bodies, and reasons about argv. Throws fromtool.execute.before, which hard-blocks the call. ~26 ms per bash call.One implementation, two harnesses, two named policy sets β
guard_core.pyis deployed by home-manager to both~/.claude/hooks/guard_core.py(imported bybash-guard.py) and~/.config/opencode/guard_core.py(resolved by the plugin relative to its own module URL).Which hook β and whether
askis expressibleMeasured on 1.18.4, this host, 2026-08-02, with a probe plugin in a sandbox
OPENCODE_CONFIG_DIR:permission.askis in theHookstype and itsoutput.statusis typed"ask" | "deny" | "allow", so returning an ask decision looks expressible. It is not. The hook never fired in any probe β not on the allow path, and not on the ask path either (anaskrule underopencode runprintedauto-rejectingwithout the hook logging a single line). A hook that does not run cannot upgrade an allow into an ask, which is the one thing a guard would need it for.tool.execute.beforefired on every bash call, and throwing from it hard-blocks: opencode reports the thrown message to the model as a tool error and the command never runs. (The probe model then triedprintf,type echoand aPROBE_THROW='' β¦re-spelling β every matching variant was blocked.)So DENY is expressible from a plugin and ASK is not. Ask-grade families stay as globs, per the brief: globs are acceptable for friction, unacceptable as the only thing between the agent and an irreversible action.
Two further measurements worth recording:
opencode runAUTO-REJECTS anask(it printsauto-rejecting) β only the interactive TUI turns one into a prompt, whileopencode debug agent --toolauto-approves it. Soaskis friction for a human, never a control on an unattended agent. Andtool.execute.beforefires before the permission check.Not verified: whether
permission.askfires in the interactive TUI. Driving the TUI was out of reach here, so the claim is scoped to headlessopencode runanddebug agent --tool.π΄ Claude Code's behaviour is unchanged β and here is the proof
bash-guard.pybecomes a thin adapter running theclaude-codepolicy, which is frozen at the original six checks. It fires on every Bash call in every Claude Code session on both hosts, so this was treated as untouchable.test_bash_guard.py(unmodified)c1e4c02) vs NEWcheck_git_add_all-removedtest_claude_code_policy_is_frozen_at_the_original_sixtest_the_new_checks_do_not_leak_into_claude_codeThe corpus is an outer product of blind/safe pathspecs Γ git global-option hops Γ wrapper prefixes, plus every new-check family across nine prefixes and every separator.
New checks β
opencodepolicy ONLY, none reach Claude Codetalosctl reset(any argv position β-n <ip>,--nodes,--nodes=,--talosconfig β¦ -n β¦ reset)mkfs/mkfs.*/mke2fs/mkswap/newfs/mkntfs/mkdosfsdd of=<block device>(/dev/null,/dev/zero,/dev/std*,/dev/fd/Nremain allowed; reading a device withif=is allowed)rm -rof/,/*,~,$HOME,.,.., or a top-level system dirgit stash(listandshowstay allowed β RULES callsstash listthe safe diagnostic)git clean -fgit reset --hardthrough a-Chopπ΄ One thing for you to veto or approve
git -C <path> reset --hardis a live gap in Claude Code right now. The frozencheck_git_reset_hardis a raw-text regex anchored on\bgit\s+reset\b, so the worktree-first spellingRULES.mdmandates does not match it. Measured: it passed the Claude Code guard atc1e4c02and still does.check_git_reset_hard_argvcloses it and is enabled for opencode only. Turning it on for Claude Code is a one-line change (_CLAUDE_CODE_CHECKS.append(check_git_reset_hard_argv)) and is deliberately left to you, because it is a new deny on your primary tool. It is the only item on this list I would actually recommend adopting.One deliberate loosening
The old glob
"*rm -rf /*"also deniedrm -rf /tmp/scratch, because*crosses/. With a parser the fatal set is exact, so an ordinary absolute-path cleanup now resolves to the broadrm -rask instead of a deny. Recorded incheck_rm_rf_critical's docstring so it is a decision rather than an accident.The other audit π΄s
review.mdβ the writing verbs (stash,commit,push,reset,clean,checkout,restore,rebase,add -A,branch -D,worktree add|remove,rm,mv,>) are re-stated as denies after the allow-list, so last-match-wins makes them win. Scoped rather than blanket: an unscoped*merge*/*branch*/*add*would also blockgit log --merges,--branchesand-S'add', and a reviewer that cannot read history invents findings. The agent prompt now tells the reviewer this over-block exists and not to route around it.browser-agent:136-137β thecommand -v opencodepreflight is hoisted above the bootstrap. Without opencode on$PATH,readlink -fresolved a bare string against cwd, the stamp could never match,oc_is_warmalways failed, and_oc_bootstrap_lockedtook its "stale tree" branch andrm -rf'dnode_modulesout of the shared cache on every run β then failed to re-warm, leaving the dir cold. Reachable from systemd/cron/a switch.test_opencode_config.py:93βpytest.importorskip("yaml")β a hardimport yaml, and the false comment above it is replaced with what was actually measured (486 passed, 1 skipped, exit 0, with 384 assertions silently gone).rm -rf'dnode_moduleswhile the first process was still writing. Now derived fromOC_WARM_TIMEOUT + 30 s.[ -d node_modules ]alone is not completeness. Now requires thetimeoutnot to have fired (rc != 124) andnode_modules/@opencode-ai/pluginto resolve."--version" in src) was being satisfied by a comment saying the code deliberately does not use--version. Added: preflight-ordering, config-dir-is-not-the-live-one, complete-warm, and lock-wait tests.Test discipline
Red/green. The new tests were run against a pre-change seed tree (
c1e4c02'sopencode.jsonc+review.md+browser-agent, guard degraded to the six): 175 failing there, 1,171 passing at HEAD.That run earned its keep immediately β it caught a vacuous assertion in one of the new tests.
test_browser_agent_checks_for_opencode_before_touching_the_cachematched the bare substringcommand -v "$OPENCODE_BIN", which also matches the copy insideoc_warm_version()at line ~136 β before the bootstrap even atc1e4c02. It passed against the exact ordering bug it was written to catch. It is now anchored on the|| diepreflight and is red on the seed.Multi-spelling matrices, not one spelling per rule. The glob-era suite pinned one spelling per pattern β always the one the pattern was written around β which is precisely why it was blind. Every new rule is now exercised across an outer product of nine prefixes (bare,
VAR=,sudo,sudo -n,doas,env VAR=,timeout N,nohup,KUBECONFIG=β¦ sudo), each git global-option hop (-C, doubled-C,--git-dir=,--no-pager,-c k=v,-P), and each of the five separators plusbash -c/eval/$( ).That matrix found a real bug in the first draft of the parser:
sudo -n <cmd>peeled to the wrong argv, because-nhad been put in a shared value-flag set fornice -n 5, sosudo -n talosctl resetbecame['reset']and the node-wipe guard silently stopped firing on the most ordinary sudo spelling there is. Fixed with per-wrapper arity tables plus_peel_variants, which branches on the other arity interpretation so a future table error cannot fail open.Mutation-tested by an independently-constructed sweep (
fe2e56e). 52 mutants, built deliberately unlike the suite they attack β pattern-narrowing mutants that keep the function, the policy entry and the deny action and change only what matches, plus parser, policy and off-by-one mutants. 39/50 killed, 11 survived, every kill by assertion (test counts were checked per run β no truncation, no collection errors scored as kills).The sweep's own harness was broken on its first run: a helper-argument bug made all 52 "mutants" byte-identical to pristine, and the pre-flight "all mutants apply and parse" check passed because nothing was applied. Only the known-bad control caught it. Both controls now behave: the obvious mutant is killed (49 failures), the byte-identical control survives with counts matching pristine exactly.
All 11 survivors were holes in the test suite, not defects in
guard_core.pyβ the code already handled each case; nothing pinned it. Closed infe2e56e:argv[0]or wrapper path/run/current-system/sw/bin/talosctland/run/wrappers/bin/sudoare the normal spellings_peel_variants' arity branching was pure prosesudo -nclass of bug-cshells (bash -xc 'β¦')-cwas pinned_tokenisecd <path> **;** git β¦&&spelling was pinnedRSA PRIVATE KEYsk-*patterns changed nothinggh β¦ createsinksgh pr comment/edit/reviewcould post a secret--boundary in_flags_and_operandsThree weak kills were also strengthened: N13 (
check_git_reset_hard_argvwas killed by exactly one test β a one-test-deep guard on its own headline case) and S01/S04 (killed only by over-block assertions, so relaxing those would silently un-cover them).And the new coverage found one real code bug. The generated (wrapper, value-flag) matrix went red immediately on
timeout -k talosctl resetandchrt -p talosctl reset: for a wrapper that takes positionals, the alternate arity branch still hit the positional consumption and swallowed the real command, so both peelings produced['reset']._peel_variantsnow branches on positional consumption too. Same class as thesudo -nbug, invisible for the same reason.Claude Code was re-verified after that parser change: the 2,097-command matrix still differs on 0 rows and
test_bash_guard.pyis still 113/113.Invariant guards are labelled as such (
test_browser_agent_config_dir_is_not_the_live_one,test_browser_agent_warm_handles_races_staleness_and_failure, the harness negative controls) and are not counted as regression coverage.Full hermetic suite: 4,230 passed, 1 skipped, 0 errors (
scripts/run-tests.shundernix-shell -p 'python312.withPackages(p:[p.pytest p.pyyaml p.psycopg2 p.requests p.minio])'). Test counts were read per-suite, not from an exit code. Note:scripts/mail-actions/testsandscripts/initiatives/testsfail only whenpsycopg2/minioare absent from the shell β with them present both are green.Live end-to-end verification
Against a sandbox
OPENCODE_CONFIG_DIRlaid out exactly as home-manager deploys it (plugin/guard.js+../guard_core.py), with the sandbox config set to bash{"*": "allow"}so only the guard could refuse anything:echo guard-negative-control(negative control)mkswap /dev/zzz-nonexistent-devicetalosctl --talosconfig /nonexistent-zzz -n 1.2.3.4 resetallowatc1e4c02echo hellowithDEVRC_GUARD_CORE=/nonexistent/guard_core.pyHarmless targets were chosen deliberately, so a guard failure could not have caused damage.
The
reviewfix, verified through thetasktoolopencode run --agent reviewsilently falls back tobuild(review is a subagent), which invalidated an earlier probe matrix β so this was delegated properly via thetasktool, in a throwaway git repo with a dirty tree, and withDEVRC_GUARD_DISABLE=1so layer 1 (the globs) was tested in isolation:ls -lareviewβ under thebuildfallback this runs. The discriminating negative control.git -C <repo> log --oneline -3a24edc0 initial)git -C <path> β¦allow-list still works; the agent is not over-blocked into uselessnessgit -C <repo> stash push -m "wip on the diff"c1e4c02git stash listin that repo: 0 before, 0 after.What I could not verify
permission.askfires in the interactive TUI β only headlessopencode runanddebug agent --toolwere probed.home-manager switchwas run,~/.config/opencode/and~/.claude/were not touched, and~/.cache/browser-agent-opencode-configwas left alone. The home-manager wiring is asserted by tests overhome.nixand by checking everysource =path exists, not by a switch.askoutcomes rest on the ported resolver model, not on execution:askis auto-approved under--tooland auto-rejected underopencode run, so neither mode can distinguish it fromallow/deny. Test docstrings say so.guard_core.py):ssh host talosctl reset,xargs-fed argv, and argv assembled from variables ($CMD reset). The command text does not carry those values. The broadaskglobs still apply to them.π΄ Correction β the original permission claims in this PR were too strong
The first version of this PR described the permission block by reading the config, not by resolving it. An adversarial audit resolved it with
opencode debug agent, and three of the four load-bearing guarantees did not hold.c1e4c02fixes them. Recording it plainly, because the wrong version read as thorough:"*": "allow"is first and every deny/ask follows it, so the denies apply"k8sβ its agent-levelbash: {"*": allow}is appended after all 30 global rules and nullified every one of themplanis genuinely read-only"task: {general: deny}had been flipped to allow by the globaltask: allow, soplancould reach a shell by delegating togeneral"git *": "allow"and an alphabetical key sort β passed at 82/82ZSH_VERSION= 5.9 inside a bash tool call) and it does source.zshenvThe
AGENTS.md, cost-measurement and subagent sections below were re-checked and stand.What was wrong, and what it meant
1. The
k8sagent nullified the entire global bash block. Agent rules are appended AFTER the global ones, and opencode is last-match-wins over one flat array.k8s'sbash: {"*": allow}landed at index 74 β after all 30 global rules β so only its own 4 survived. On that agent, blind-staging,git stash, hard-reset,rm -rf ~β¦,sops -d,nixos-rebuildandhome-manager switchwere all plain allow.Fix: the agent block now only ever TIGHTENS β no wildcard at all (the global one already keeps
bashenabled), just*talosctl*: askwith*talosctl reset*: denyrestated after it so last-match-wins cannot re-open it.2. The global block re-enabled every tool on the hidden
title/summary/compactionagents. Their stock tool set is empty (verified against a bare config dir:enabled=[]). Listing tools in the globalpermissionblock appended them to these agents too, giving all threebash, edit, glob, grep, read, skill, task, todowrite, webfetch, write.compactionruns automatically on every context overflow, on the cheap model, on a path nobody watches β so this handed it a shell and a writer, and made every title generation carry the ~3,730-token skill catalogue.Fix: each carries an explicit
"permission": {"*": "deny"}. All three now resolveenabled=[], matching the stock control.3. Every deny/ask was bypassable by a prefix or a wrapper. opencode matches a command node's full text, so an anchored
"git stash*"missesFOO=1 git stash,sudo -n git stashandgit -C /tmp stash. That is acute here because the house style mandates the bypassing spelling βk8s.mdsays to writeKUBECONFIG=$KC_HOMELAB kubectl β¦,RULES.mdsays to writegit -C <path> β¦.Fix: every dangerous pattern is leading-
*. Confirmed against the real engine that*compiles to an unrestricted dotAll.*crossing spaces,/and-, so*git*stash*catches all four spellings. (&&chains and pipelines are already checked per-command, so only the prefix/wrapper case needed closing.)4. The ordering tests were vacuous. A key-order assertion cannot catch an alphabetical sort, because
"*"(0x2A) sorts to the front β the wildcard still looks correct while everything else reorders and a broadaskovertakes the narrowdenyit should lose to.Fix: added the missing "no
allowafter the wildcard" and "all asks precede all denies" assertions, and β the real protection β a faithful port of opencode's resolver (findLastover the flat array + its globβregex) that pins the effective resolved action for a matrix of dangerous commands on every agent.Also fixed
read: allow. opencode ships a built-in.envguard (*.envβ ask at index 25-27); the blanket allow landed at index 30 and silently defeated it on every agent. The default already allows every non-.envread.plan'stask: {general: deny}and addedwrite: deny.kubectl get/describe secret(base64 = plaintext),exec/cp/edit/replace/rollout undo,flux delete,helm rollback,sops exec-env,age -d,sudo/doasas wrappers, recursivechmod/chown,dd/mkfs, mutatingsystemctlverbs,nix profile remove,git checkout --/restore/branch -D/push --force,talosctl upgrade/apply-config/shutdown, and the./$HOMEdelete targets.reviewcould not rungit -C <path> diffβ resolved DENY. In the worktree-first workflowRULES.mdmandates, that is the only spelling a reviewer uses, so it silently fell back toread/grepand reported a review it had never performed. Thegit -C * <verb>*forms are now allow-listed, deliberately anchored (a leading-*allow there would punch a hole through the global deny block).env.jswas a checked-in file hardcoding/home/zach/workspace/homelab-talos, with no existence guard, duplicatingprograms/zshand defining aKC_PRODzsh lacked. Both are now generated from one source (nix/agent-handles.nix);KC_PRODis reconciled into zsh.π΄ Correction: why the
env.jsplugin existsThe claim that "opencode's bash tool does not source zsh startup files" is false here. With the plugin absent and
VITEST_MAX_WORKERSexplicitly unset in the parent, a bash tool call still reported4β a value set only by.zshenvβ andZSH_VERSIONreported 5.9.The original negative control ("with the plugin present
$KC_HOMELABresolves; with it absent it is empty") was real but misattributed. The kubeconfigs are gitignored and absent from the checkout, so zsh's existence guard correctly declines to exportKC_HOMELABβ while the old unguardedenv.jsexported it regardless. The plugin looked load-bearing because it was pointing a handle at a file that does not exist β precisely the "runs against no cluster while looking like it worked" failurek8s.mdwarns about.The plugin is kept as belt-and-braces (it is independent of
$SHELL), and now existence-guards exactly as zsh does. The "169 hand-retypedKUBECONFIG=" motivation is unaffected.What each item does
1 β generated
~/.config/opencode/AGENTS.md. Built at switch time by concatenatingclaude/PRINCIPLES.md+claude/RULES.md+claude/opencode-addendum.md.It has to be a concatenation: opencode does not expand
@-imports inAGENTS.md/CLAUDE.md. Proven with an all-tools-denied agent, so no file read was possible β an imported passphrase returnedNONE, the same content inline returned verbatim.~/.claude/CLAUDE.mdis ~1.5 KB of import lines, so opencode reading it would receive none of the 32 KB of rules. A projectAGENTS.mdalso suppressesCLAUDE.md(first match wins).Measured: 38,363 B (37.5 KB) β 8.9k tokens (previously stated as 38,033 B β stale). Tests enforce a 100 KB ceiling; a 331 KB file causes a permanent compaction loop.
2 β generated
plugin/env.js. Fromnix/agent-handles.nix, the single source of truth that also generates the zsh exports. Existence-guarded. Deployed as a single file β the glob is{plugin,plugins}/*.{ts,js}, non-recursive, and a.mjswill not load.3 β
opencode.jsonc. Model +small_model; the hidden agents pinned to flash and tool-denied;planmade genuinely read-only; compaction/tool_output/watcher/subagent_depth;autoupdate: false,share: "disabled".The
bashblock is ordered"*": allowβ every ASK β every DENY. Deny-last is load-bearing: it is what lets a narrow*talosctl reset*: denybeat a broad*talosctl*: ask.4 β three subagents.
nav(read-only navigator, bash denied),k8s(three clusters, read-before-mutate, commit-to-trunk-is-a-deploy),review(adversarial). Deliberately only three β every subagent permanently enlarges the primary'stasktool description on every request. (The addendum previously said "there is deliberately no third agent" while three shipped; corrected, andreviewis now described to the primary agent.)There is no
listtool and nowebsearchtool on 1.18.4; both dead keys are removed.browser-agent: isolation was right, warming was missing
The global
AGENTS.mddoes reach browser-agent's scratch-dir runs β a tool-less probe in an unrelated scratch dir quoted a passphrase planted at the end of the file.NONEOPENCODE_DISABLE_CLAUDE_CODE=1AGENTS.mdcache.read/cache.writewere 0 in every run, so it is multiplied by$STEPS(default 12): up to ~90k tokens per run.But the dir shipped EMPTY (
ls -Aβ 0 entries), which is exactly the cold-start case. Fixed with a warm/bootstrap step:debug paths,debug config,debug skill,debug v2and evendebug agentfrom a plain directory all exit 0 in ~1.2 s and install nothing. Only resolving a project's.opencode/tools/(browser.js imports@opencode-ai/plugin) triggers the 62 MB install. A warm step using a baredebugcommand would have looked successful and left the dir just as cold.mkdirlock with a bounded wait. 4 simultaneous cold runs all succeed, one warms, no lock leak.opencode --versionβ that is an extra process, and it lands in the test rig's invocation log.autoupdate:false,share:"disabled"and thetool_outputcaps. Decision: re-state all three in the isolated dir (no self-update mid-run; a browser session must never be shareable; a page dump is exactly the huge tool result those caps exist for). Deliberately not copied: the permission block,agent/,plugin/andAGENTS.mdβ those are the cost this exists to shed, and the agent denies every host tool anyway.Measured: cold 7.0 s, warm 0.73 s, stale stamp β clean re-warm 5.2 s, unwritable dir β loud degrade.
The browser-agent tests now pin their own
OPENCODE_CONFIG_DIRβ without it the new bootstrap would judge the operator's real warmed cache stale andrm -rfit as a side effect of running the suite.Verification
Suite: 82 β 384 tests. 162 are red at
e353841and green at HEAD, covering every finding.Mutation matrix (each applied to a copy, whole suite re-run, tests counted β never a bare exit code):
"git *": "allow"test_no_allow_rule_follows_the_wildcard+ effective-action)test_all_asks_precede_all_denies;sudo -n git stashdenyβask)"*": allowtok8s*git*stash*βgit stash*read: allowtest_no_blanket_read_allowpermissiontest_hidden_agents_are_pinned_and_tool_lesstaskdenytest_plan_agent_is_genuinely_read_onlygit -C *allows*git*stash*)Each fails with its own assertion, not a collateral parse error (
errors=0throughout). The harness itself was validated against a known-bad state first β the negative control turns it red.Real-engine probes.
opencode debug agent <a> --tool bashperforms a genuine permission check. All 18 deny cases pass onbuild,k8sandreview, including every prefix/wrapper form, with 3 allow controls. Every probe is inert if executed (destructive ones are probed inecho <cmd>form, which still exercises the leading-*pattern).F3 bypass matrix β before β after (real engine)
git stashFOO=1 git stashsudo -n git stashgit -C /tmp stashgit -C /tmp reset --hardFOO=1 git add -AKUBECONFIG=β¦ talosctl resetsudo rm -rf /β¦KUBECONFIG=β¦ kubectl delete pod xkubectl get secret x -o yamlResolved
enabled=for the hidden agents:title/summary/compactionβ[](stock control:[]; before this commit: 10 tools each).Full repo gate (
scripts/run-tests.sh): green exceptscripts/mail-actions/tests(3 failures, missingminio) β identical ate353841, pre-existing and unrelated.What I could NOT verify
home-manager switchwas run. The nix now evaluates (nix evalrenders the generatedenv.jsand the zshenvExtrain full, and the generated plugin was loaded into a sandbox and confirmed to inject handles against a scrubbed environment), but activation and the resulting symlinks are unverified against a real switch.askcannot be distinguished fromallowby the executing prober. Indebug agent --tool(non-interactive) anaskis auto-approved β proven by a 3-way control (allow/ask/denyon the same pattern β ALLOW/ALLOW/DENY). So theaskrows are pinned by the resolver model, validated against the real engine on the semantics that decide them, not by execution.commandnode separately and denies if any node denies; the model matches a single node. Chains are therefore safer than the model suggests, never less safe β but no test asserts chain behaviour.browser-agentrun (needs a live tab + token spend). The warm/bootstrap path was exercised directly; thebrowsertool's runtime behaviour under isolation is not re-verified here.KC_HOMELAB/KC_WORKBENCH/KC_PRODresolve to unset under the new existence guard. That is correct behaviour and matches zsh, but it means the guarded handles could not be observed resolving to a real file.deny, and the auto-generated pattern is arity-derived (approvinggit -C /tmp stashonce would installgit -C *). Not fixable in config.π€ Generated with Claude Code