> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackra.figentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 0293 — Agent permissions: allow, ask and deny tiers, one rule module, executors push their own branch

> Accepted · 2026-10-22

> [!NOTE]
>
> Amends [0066](/adr/0066-delegation-to-opencode-by-routing-matrix.md) (rule 1: OpenCode executors now
> commit and push their own feature branch). Builds on
> [0230](/adr/0230-reviewer-identity-separate-from-builder.md) (reviewer identity and the owner's
> login), [0173](/adr/0173-secrets-infisical-is-the-source-of-production-secrets.md) (Infisical is the
> source of every value), [0257](/adr/0257-secret-manager-agent-and-the-secrets-inbox.md) (nobody reads
> a value) and [0282](/adr/0282-developer-experience-skills-cli-mcp-and-vellum-as-the-sdlc.md) (the
> guard later installs at user level). Rule 5 of 0230 is not amended here.

> [!NOTE]
>
> 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.

## 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](/adr/0066-delegation-to-opencode-by-routing-matrix.md)). 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](/adr/0230-reviewer-identity-separate-from-builder.md) 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](/adr/0066-delegation-to-opencode-by-routing-matrix.md) rule 1. Sources: the owner's
decisions of 2026-10-22 (STA-330).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.