Skip to main content
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

  1. Three tiers, the same for both tools. Allow, ask (a human confirms) and deny. OpenCode runs unattended and --auto approves any ask, so for OpenCode every ask is a deny; so is a Claude subagent’s hook ask, outside HUMAN_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 --worktree of a display, identity or rerere key), the repository’s pnpm scripts by name, pnpm exec of the formatter, linter, test runner and compiler, GitHub as stackra-bot through pnpm bot:gh, pnpm bot:git -- fetch, a bot push of HEAD, pnpm pr:merge and pnpm 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), raw infisical 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 -D of 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 --worktree there unless extensions.worktreeConfig is on, so that form never writes the shared file), discarding git or rm -rf in 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 *.pem counts and *.json does not, infisical export, infisical secrets get in 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 (gh in any form, raw network git); pnpm bot:git options that run a program in the bot’s environment (--upload-pack, --receive-pack, --exec, rebase -x, submodule foreach); merges and ref moves outside pnpm pr:merge (api …/merges, mergeBranch, createCommitOnBranch, updateRef); pushes that rewrite or delete (to main, --force without a lease, -f, +refspec, --delete, --mirror, --prune, or any branch but the current one); git stash pop, clear, branch and apply without a ref; destructive git and rm -rf outside the session’s own worktree and scratchpad; changeset version, release tags, encore deploy, encore secret set, DROP DATABASE and ORM push or migrate.
  2. Settings are the floor; one module holds the parsing. The deny and ask rules in .claude/settings.json apply in every mode. The hooks add only what a prefix rule cannot see, from one module, .agents/hooks/_rules.mjs, which guard-bash.mjs, guard-paths.mjs and the OpenCode guard plugin all evaluate: runner wrappers (pnpm [exec|dlx], pnpm <bin>, npx, npm exec, mise exec --) are looked through like sudo, env, timeout or 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 through cd (a subshell’s ends at its ), a failed one leaves the old directory), -C, --git-dir and GIT_DIR=; every word named rm or find is 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-git and bot:token rules do not read a quoted line. A double-quoted line loses its \", \\, \$ and escaped backticks as the shell drops them, so sh -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 for rm -rf / and rm -rf ~, $HOME; a prefix deny of every absolute path would also refuse the scratchpad that rule 3 allows. They also deny bot:token and changeset:version behind pnpm run, pnpm --filter and pnpm -F, which the broad allows would otherwise cover. opencode.jsonc repeats the tiers as order-sensitive patterns.
  3. Own worktree only. reset --hard, checkout or restore of the tree, checkout -f, switch --discard-changes, clean -f and worktree remove --force run only when the target checkout is the session’s own linked worktree, never the main checkout or another agent’s worktree. rm -rf runs 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; a find … -delete (or -exec rm) from a checkout root is denied when its name tests may select .git or 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 by cd or -C gets 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.
  4. owner-approved is the owner’s. No agent command writes the label, in any spelling or from a computed value (the guard and bot-gh.mjs, which sees the expanded arguments), and pnpm pr:merge counts it only when the latest labeled / unlabeled issue event for it is a labeled by the owner user, never a bot; an API error refuses the merge.
  5. pnpm bot:review is for reviewer agents. Only an agent type whose source sets reviewer: true (projected by agents-sync into .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 runs bot:review.
  6. 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>] and pnpm 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:git runs no git hook (core.hooksPath=/dev/null): a hook is code from the worktree being pushed (lefthook runs its lefthook.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), an export or source before it, or a cd would run the executor’s own code with those values. The lead still reviews, opens the pull request and merges through pnpm pr:merge.
  7. An agent never widens the project rules. An OpenCode agent’s frontmatter permissions only narrow opencode.jsonc; agents-sync refuses an allow effect 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, npx and pnpm dlx now 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 /agents Infisical folder is split.
  • pnpm bot:git still runs the worktree’s own package.json script and tools/scripts/bot-gh.mjs with 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 /agents folder, 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, a jq or awk program; 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]), plain gh, network git and bot:token in 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 .env wrapped three times in watch '…' 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 a jq option 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.mjs and .opencode/plugins/guard/decide.ts (ask is deny there); tests in .agents/hooks/__tests__/guard-bash.test.mjs, .agents/hooks/__tests__/guard-paths.test.mjs and .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, and env with GIT_EDITOR=true) and the permissions block of opencode.jsonc.
  • tools/scripts/agents-sync.mjs: the reviewer flag, the generated .agents/hooks/_reviewer-agents.mjs (drift fails pnpm agents:check), and the refusal of an allow permission 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) and tools/scripts/bot-gh.mjs (unsafeArgs) for rule 4, with tests in tools/scripts/__tests__/pr-merge.test.mjs and bot-gh.test.mjs.

Supersedes and sources

Supersedes: none. Amends 0066 rule 1. Sources: the owner’s decisions of 2026-10-22 (STA-330).