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.
Amended 2026-10-23 (STA-351): a subagent’s hook ask is a deny. When the hook input carries an
agent_id (a subagent; the main thread never has one, a claude --agent session included), every
ask from the guard hook becomes a deny that carries its reason and one line on how to
rewrite the command so the guard can read it (no bash -c, sh -c or eval around a loop, no
computed command name), because the owner does not watch a subagent’s prompts and the agent sat idle
until answered. The decisions in HUMAN_ASKS (_rules.mjs) still reach a human from a subagent:
secrets:import … --confirm, tag pushes, encore db reset, encore env, wrangler deploy and the
other Cloudflare changes, and eas. The main thread keeps every hook ask; the ask rules of
.claude/settings.json are not the hook’s and can still prompt from a subagent. OpenCode’s ask-to-deny
is the same unattended function. The rm -rf scratch place is the session’s own scratchpad
directory (the hook input’s scratchpad_dir, or /tmp/claude-<uid>/<project>/<session>/scratchpad
for the hook’s session_id or CLAUDE_CODE_SESSION_ID and the project’s own folder, its real path
only), no longer the whole /tmp/claude-<uid> tree.Amended 2026-10-23 (STA-352): the owner chose a middle confirmation tier.
.claude/settings.json
also asks before pnpm pr:merge, a pnpm bot:git push, pnpm secrets:import, git worktree remove and pnpm new, from every session and agent, so nothing merges, pushes, imports a secret,
removes a worktree or scaffolds a member without the owner. Local git on an agent’s own branch,
pnpm bot:gh pull-request work and reads, and pnpm linear updates stay allowed. Settings ask
rules prompt in bypass mode too; dangerous rm stays with the guard hook. The patterns cover the
pnpm run and --filter spellings of those scripts. This amends decision 1’s Allow list, which
named a bot push of HEAD and pnpm pr:merge. OpenCode executors read opencode.jsonc, not
these settings: they still commit and push their own feature branch (never main) under the guard,
and the OpenCode guard denies pnpm pr:merge, so merges stay with the lead. The settings also
carry the bot commit identity (GIT_AUTHOR_*, GIT_COMMITTER_*) and
enableAllProjectMcpServers.Amended 2026-10-23 (STA-400), superseding the STA-352 middle tier: only a delete, a deploy or a
release asks the owner; the rest is allowed (owner decision 2026-10-23). The ask list of
.claude/settings.json keeps only deletes (rm, a recursive rm anywhere in the line, find -delete,
find -exec rm, git clean -f, git branch -D, Infisical secrets, folders and dynamic secrets, Wrangler,
git stash drop, git reflog expire|delete, a forced git worktree remove, Docker volumes,
encore env delete, encore db reset, eas <x>:delete, a pnpm bot:gh repo, release, variable, secret or
API DELETE, the App Store and Play Store review deletes), deploys and rollbacks (wrangler deploy|rollback,
eas submit|deploy|update|workflow:run|channel:edit|channel:rollout, the EAS update rollbacks and
republish, --auto-submit, wrangler preview,
wrangler pages deployment create, wrangler d1 … --file, a deploy workflow run or dispatches call,
mcp__cloudflare-api__execute, mcp__expo__build_submit) and releases (npm, pnpm and changeset publish, a bot push of tags); merges, bot pushes, secret imports, worktree removal and pnpm new no longer
ask. Its allow list has pnpm, git, node, npx, mise, infisical, encore, wrangler and eas (the
narrow entries they cover are gone; rm and raw gh are not allowed). In the guard hook, HUMAN_ASKS is the
same list, read through every runner and version (npx wrangler@3 deploy, pnpm dlx eas-cli submit,
mise exec -- npm publish, pnpm --silent publish, a line quoted for ssh): an rm in any form outside the
session’s scratchpad and build output (a recursive one that reaches home, root, .git or another checkout is
still a deny), find -delete, git clean -f, the Infisical deletes, encore db reset, encore env delete,
wrangler deploy|rollback|delete|preview, wrangler pages deployment create, wrangler d1 --remote with
DROP, DELETE or TRUNCATE, migrations apply or an unread --file,
eas submit|deploy|update|workflow:run|channel:edit|channel:rollout|<x>:delete, the update rollbacks and
republish (update:republish|rollback|roll-back-to-embedded|revert-update-rollout) and --auto-submit*, a
registry publish, rm -rf or
a discard in another nested worktree, git branch -D of an unpushed branch, a bot push of tags, and a
pnpm bot:gh release, <noun> delete, DELETE method (any spelling), GraphQL delete… mutation, deploy
workflow run or dispatches call. The hook’s other categorical asks are gone, so these are allowed: npx and
pnpm dlx, pnpm bot:gh api with -f or -X (GraphQL reads included), bot:gh repo edit, variable set and
secret set, shared git config writes, encore env set|create, wrangler secret list and --remote reads,
eas build, and secrets:import … --confirm. That last one has no enforced yes until the request page
(STA-187): the confirm token is printed to the same caller, a process rule only (ADR 0257). Raw infisical run
is a deny, not an ask: the program it starts gets every /agents value, so one line (node -e on process.env)
prints a secret, or mints the bot or reviewer token and forges an AI-REVIEW verdict (ADR 0230). The reason
names the pnpm scripts that wrap it (pnpm bot:gh, bot:git, linear, secrets:import), which stay
allowed. The hook reads an infisical command past its global options and their values (--domain <url>,
--log-level), by any path, @infisical/cli, npx or pnpm dlx, so secrets get|set, a bare secrets,
export and run keep their denies in every spelling; .claude/settings.json adds four start-anchored
denies that hold if the hook fails open: Bash(infisical *secrets get*), Bash(infisical *secrets set*),
Bash(infisical * secrets) and Bash(infisical *run *) (an unanchored * infisical *run * was dropped: it
denied prose).
tools/scripts/mcp.sh is denied from the Bash tool: it runs its arguments under infisical run, and MCP servers
launch it from .mcp.yaml. Only an ask the guard raises because it cannot read the command (a
computed command name, text piped into a shell, a GraphQL label mutation by id) is a deny with its reason and the
rewrite hint, from the main thread as well as a subagent, so STA-351’s main-thread exemption ends. No deny rule
is loosened. OpenCode still denies every ask, HUMAN_ASKS included, and passes executor to the shared rules,
which deny npx, pnpm dlx, eas, wrangler secret|--remote, encore env set|create and
secrets:import --confirm (the import script run by node too) under the wrappers the shared rules read
(env, timeout, mise exec --, pnpm exec, xargs, a path before the name). A wrapper the shared rules
do not know can still hide one; opencode.jsonc’s prefix patterns are the second layer.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; so is a Claude subagent’s hook ask, outsideHUMAN_ASKS(amendment of 2026-10-23).- 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’s own scratchpad directory (not the/tmp/claude-<uid>root or another project’s or session’s tree), 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.