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.

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.
    • 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 scratchpad, 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).