Amends 0066 (rule 1: OpenCode executors now
commit and push their own feature branch). Builds on
0230 (reviewer identity and the owner’s
login), 0173 (Infisical is the
source of every value), 0257 (nobody reads
a value) and 0282 (the
guard later installs at user level). Rule 5 of 0230 is not amended here.
Context
Claude Code and OpenCode each carried their own permission rules:.claude/settings.json and the bash and path hooks
for Claude, opencode.jsonc and a guard plugin for OpenCode. The plugin’s copy drifted (it lacked the .dev.vars,
$GH_TOKEN, DROP and generated-file rules), and a permission audit on 2026-10-22 found live bypasses in both:
pnpm gh pr merge, pnpm exec infisical run, pnpm git stash pop, pnpm printenv and
pnpm exec node …secrets-import.mjs --confirm passed, because prefix rules and command-position regexes did not look
through pnpm. At the same time the guards blocked ordinary work: grep "git push origin main" matched the push rule,
git reset --hard was refused in an agent’s own worktree, and git stash pop was allowed although refs/stash is
shared by every worktree. OpenCode executors could not commit, so the lead re-did their git work by hand. GitHub’s
rulesets, the bot and reviewer split and pnpm pr:merge already decide what reaches main; local rules only have to
cover what GitHub cannot see.
Decision
- Three tiers, the same for both tools. Allow, ask (a human confirms) and deny. OpenCode runs unattended and
--autoapproves any ask, so for OpenCode every ask is a deny.- Allow: local git in the session’s own worktree (commit, branch, rebase, reset, cherry-pick, stash push, list,
show and
apply <ref>,git config --worktreeof a display, identity or rerere key), the repository’s pnpm scripts by name,pnpm execof the formatter, linter, test runner and compiler, GitHub asstackra-botthroughpnpm bot:gh,pnpm bot:git -- fetch, a bot push ofHEAD,pnpm pr:mergeandpnpm linear, and read-only MCP servers. - Ask: deploys and remote cloud changes (
wrangler deploy,--remote,eas,encore env set), unreviewed registry code (npx,pnpx,pnpm dlx), rawinfisical run,secrets:import … --confirm,infisical secrets delete, publishing and tag pushes, raw GitHub API writes and GraphQL label mutations through the bot, deleting local data git cannot restore (git stash drop,git branch -Dof a branch no remote holds,encore db reset),git config <key> <value>without--worktree(the repository’s config, which every linked worktree shares; git refuses--worktreethere unlessextensions.worktreeConfigis on, so that form never writes the shared file), discarding git orrm -rfin a worktree nested in the main checkout when reached from there, MCP tools that spend money or reach people, and shell text or a computed command name the guard cannot read. - Deny: reading a secret value (any command naming
.env*,.dev.vars, keys or the owner’s credential files, by name or by a glob’s literal text, so*.pemcounts and*.jsondoes not,infisical export,infisical secrets getin every form,infisical secrets set,infisical user get token, keychain reads,env,printenv, process-environment listings,bot:token, the secrets inbox); the owner’s credentials (ghin any form, raw network git);pnpm bot:gitoptions that run a program in the bot’s environment (--upload-pack,--receive-pack,--exec,rebase -x,submodule foreach); merges and ref moves outsidepnpm pr:merge(api …/merges,mergeBranch,createCommitOnBranch,updateRef); pushes that rewrite or delete (to main,--forcewithout a lease,-f,+refspec,--delete,--mirror,--prune, or any branch but the current one);git stash pop,clear,branchandapplywithout a ref; destructive git andrm -rfoutside the session’s own worktree and scratchpad;changeset version, release tags,encore deploy,encore secret set,DROP DATABASEand ORM push or migrate.
- Allow: local git in the session’s own worktree (commit, branch, rebase, reset, cherry-pick, stash push, list,
show and
- Settings are the floor; one module holds the parsing. The deny and ask rules in
.claude/settings.jsonapply in every mode. The hooks add only what a prefix rule cannot see, from one module,.agents/hooks/_rules.mjs, whichguard-bash.mjs,guard-paths.mjsand the OpenCode guard plugin all evaluate: runner wrappers (pnpm [exec|dlx],pnpm <bin>,npx,npm exec,mise exec --) are looked through likesudo,env,timeoutor a shell keyword; a quoted or escaped command name is read unquoted; quoted prose is not a command; arguments are read as the shell passes them (quotes, redirections, groups and subshells); the checkout a git command targets is resolved throughcd(a subshell’s ends at its), a failed one leaves the old directory),-C,--git-dirandGIT_DIR=; every word namedrmorfindis read as a command, and a command line passed as one quoted word or here-string that holds a guarded command name (rm,find,git,infisical,encore,wrangler,changeset,drizzle-kit,mikro-orm,security, a SQL client) is read as commands (tmux new -d '…',ssh h '…',watch '…',trap '…' EXIT,at now <<< '…'), as are the file names in it, and so is a line quoted inside that line, so an unknown wrapper never hides a recursive delete, a discard, a forbidden push, a stash or one of those secret, deploy, release and drop commands. The plain-gh, network-gitandbot:tokenrules do not read a quoted line. A double-quoted line loses its\",\\,\$and escaped backticks as the shell drops them, sosh -c "sh -c \"rm -rf ~\""reads as the delete it runs. That reading stops past eight quoting levels, single or double, without denying. A decision is bounded, not linear: a command nesting substitutions or script strings deeper than the guard reads, needing more git lookups than it runs, or taking longer than two seconds, is denied, because a hook that runs out of time fails open. Settings keep the prefix denies forrm -rf /andrm -rf ~,$HOME; a prefix deny of every absolute path would also refuse the scratchpad that rule 3 allows. They also denybot:tokenandchangeset:versionbehindpnpm run,pnpm --filterandpnpm -F, which the broad allows would otherwise cover.opencode.jsoncrepeats the tiers as order-sensitive patterns. - Own worktree only.
reset --hard,checkoutorrestoreof the tree,checkout -f,switch --discard-changes,clean -fandworktree remove --forcerun only when the target checkout is the session’s own linked worktree, never the main checkout or another agent’s worktree.rm -rfruns only inside the session’s own checkout or the session scratchpad, never on a worktree itself or.git; afind … -delete(or-exec rm) from a checkout root is denied when its name tests may select.gitor a file git keeps inside it (-path './.git/*',-name HEAD,-name '*.pack'). A subagent whose shell starts in the main checkout and reaches a nested worktree bycdor-Cgets an ask, because the guard cannot tell its own worktree from another agent’s; build output there needs no yes. Worktrees use WIP commits, never the shared stash. owner-approvedis the owner’s. No agent command writes the label, in any spelling or from a computed value (the guard andbot-gh.mjs, which sees the expanded arguments), andpnpm pr:mergecounts it only when the latestlabeled/unlabeledissue event for it is alabeledby the owner user, never a bot; an API error refuses the merge.pnpm bot:reviewis for reviewer agents. Only an agent type whose source setsreviewer: true(projected byagents-syncinto.agents/hooks/_reviewer-agents.mjs) may run it. This is a tripwire, not a boundary: a builder can still spawn a reviewer. Once a CI job posts the review verdict, no local agent runsbot:review.- Executors commit and push their own feature branch (amends rule 1 of
0066). An OpenCode executor commits on its delegate branch
and runs
pnpm bot:git -- fetch origin [<ref>]andpnpm bot:git -- push [--force-with-lease] origin HEAD; every other bot wrapper (bot:gh,bot:review,bot:token,pr:merge,linear), Infisical and raw network git stay the lead’s.pnpm bot:gitruns no git hook (core.hooksPath=/dev/null): a hook is code from the worktree being pushed (lefthook runs itslefthook.yml) and would see every /agents value; the Quality Gate runs the checks a hook would. The executor’s guard allows these two forms only as the whole command: an environment prefix (NODE_OPTIONS=,PATH=,env), anexportorsourcebefore it, or acdwould run the executor’s own code with those values. The lead still reviews, opens the pull request and merges throughpnpm pr:merge. - An agent never widens the project rules. An OpenCode agent’s frontmatter permissions only narrow
opencode.jsonc;agents-syncrefuses analloweffect for an agent projected to OpenCode.
Consequences
- Ordinary work stops prompting: search patterns, commit messages and PR bodies that name a guarded command pass, and an agent can reset or clean its own worktree. The main checkout and other agents’ worktrees keep their work.
- Raw
infisical run,npxandpnpm dlxnow prompt, so an unattended run stalls on them; a flow that needs a token gets its own pnpm script. - OpenCode executors push their own branch, so the lead no longer re-commits their output. They still do not update
Linear themselves until the
/agentsInfisical folder is split. pnpm bot:gitstill runs the worktree’s ownpackage.jsonscript andtools/scripts/bot-gh.mjswith the /agents values, so an executor that edits them before a push runs its code with those values. The guard does not see edits; splitting the/agentsfolder, or pushing from the main checkout’s copy of the script, closes it.- Every local rule stays an accident guard: hooks run as the owner’s macOS user and fail open, and text rules miss
child processes and wrappers they do not know. Known misses, by design: code in another language passed inline
(
node -e,python -c,ruby -e,perl -e, ajqorawkprogram; their file operands are still read), SQL fed to a package script that wraps a SQL client (pnpm <script> <<< 'DROP …'), a glob that spells a secret name with wildcards (.e?v,.en[v]), plaingh, networkgitandbot:tokenin a quoted line (watch 'gh pr merge 1',watch 'git fetch',watch 'pnpm bot:token'; a push there still meets the push rules), a bare secret file name in a wrapper read only two quoting levels deep (cat .envwrapped three times inwatch '…'is allowed; a line that also holds a guarded command name is read eight deep), a secret path built by a substitution (cat "$(pwd)/.env"), and quoting nested more than eight levels deep. One known false deny: a heredoc written to a file is read as a script when a body line starts with/*. A search or test-name pattern (grep,rg,git grep,vitest -t) and ajqoption value are no file name; their file operands are still read. Isolation comes from GitHub’s rulesets, a bot without admin rights, the CI reviewer and moving the owner’s credentials out of agents’ reach. - Rule 5 of 0230 still denies raw read-only network git; a later amendment may allow it once no owner credential can answer it.
Enforcement
.agents/hooks/_rules.mjs(decideBash,decideRead,decideWrite), evaluated by.agents/hooks/guard-bash.mjs,.agents/hooks/guard-paths.mjsand.opencode/plugins/guard/decide.ts(ask is deny there); tests in.agents/hooks/__tests__/guard-bash.test.mjs,.agents/hooks/__tests__/guard-paths.test.mjsand.opencode/plugins/guard/__tests__/decide.test.ts, including one regression test per bypass the audit found, and.agents/hooks/__tests__/guard-differential.test.mjs: a generated matrix of every known wrapper against every guarded command, more than 150 ordinary agent commands that must stay allowed, and a 250 ms bound on the guard’s own time per decision..claude/settings.json(permissions.allow,ask,deny, andenvwithGIT_EDITOR=true) and thepermissionsblock ofopencode.jsonc.tools/scripts/agents-sync.mjs: thereviewerflag, the generated.agents/hooks/_reviewer-agents.mjs(drift failspnpm agents:check), and the refusal of anallowpermission for an OpenCode agent.tools/scripts/delegate.mjs(RULES): the executor prompt says to commit and push its own branch.tools/scripts/pr-merge.mjs(ownerLabelled) andtools/scripts/bot-gh.mjs(unsafeArgs) for rule 4, with tests intools/scripts/__tests__/pr-merge.test.mjsandbot-gh.test.mjs.