Permission Model¶
VERGIL constrains AI agents to a validated set of operations
through layered enforcement. Every system interaction flows through
wrapper tools that validate, constrain, and log what happens. Raw
shell access to git and gh is denied mechanically — not by
convention, not by instruction, but by the permission system
itself.
For the identity model that permissions protect, see Identity Architecture. For how credentials are selected per-operation, see Credential Management.
Base Permission Mode¶
bypassPermissions is the standard mode — but only because the
agent runs inside a sandboxed, ephemeral VM (see
The VM Sandbox Boundary). Inside that
sandbox the agent reads, edits, and runs shell commands without
per-action prompts, which is what makes it usable for real work.
Why not acceptEdits¶
acceptEdits was the original target: auto-approve file edits, prompt
on any shell command not in the allowlist. It proved impractical for
the work VERGIL actually does. An agent doing infrastructure work runs
a constant stream of shell commands — container builds, cloud CLIs,
system inspection, ad-hoc scripts — and under acceptEdits every
non-allowlisted one stops for human approval. The prompts never end,
and autonomous work becomes impossible. Chasing an ever-growing bash
allowlist was a losing race.
So VERGIL moved the boundary outward instead of inward: rather than
constrain which commands the agent may run, sandbox the whole agent
in a disposable VM and let it run in bypassPermissions there. The
blast radius is the VM, which is ephemeral and reproducible.
Bypass is not "no enforcement"¶
bypassPermissions removes the prompts, not the guardrails. The
vrg-* wrappers, the deny rules, and the hook guard all still apply —
a hook hard-block (exit code 2) overrides even bypass mode — and the
server-side GitHub App permission shapes remain the ultimate boundary
(see The Actual Security Boundary).
Bypass changes the agent's experience inside the sandbox; it does not
widen what the agent can do to the outside world.
vrg-git Wrapper¶
A Python CLI tool that validates subcommands and flags before
executing git. Arguments arrive as a Python argv list — no
shell expansion, no pipes, no redirection.
Subcommand Allowlist¶
| Subcommand | Denied Flags | Notes |
|---|---|---|
status |
— | Read-only |
log |
— | Read-only |
diff |
— | Read-only |
show |
— | Read-only |
branch |
--force; -D conditional |
-d allowed; -D allowed when upstream is [gone] |
ls-remote |
— | Read-only |
rev-parse |
— | Read-only |
annotate |
— | Read-only — line-level authorship |
blame |
— | Read-only — line-level authorship |
cat-file |
— | Read-only — object type/size/content |
cherry |
— | Read-only — commits not yet upstream |
count-objects |
— | Read-only — repo object stats |
describe |
— | Read-only — nearest reachable tag |
diff-files |
— | Read-only — plumbing diff |
diff-index |
— | Read-only — plumbing diff |
diff-tree |
— | Read-only — plumbing diff |
for-each-ref |
— | Read-only — enumerate refs |
grep |
— | Read-only — search tracked content |
ls-files |
— | Read-only — list tracked files |
ls-tree |
— | Read-only — list a tree's contents |
merge-base |
— | Read-only — common-ancestor lookup |
name-rev |
— | Read-only — symbolic names for commits |
reflog |
expire, delete |
Read-only show/exists allowed; mutating sub-ops denied |
rev-list |
— | Read-only — list commits |
shortlog |
— | Read-only — summarized log |
show-branch |
— | Read-only — compare branches |
show-ref |
— | Read-only — list refs |
symbolic-ref |
<name> <ref>, -d/--delete, -m |
Read form only — one ref, optionally -q/--quiet/--short |
var |
— | Read-only — logical git variables |
verify-commit |
— | Read-only — signature verification |
verify-tag |
— | Read-only — signature verification |
whatchanged |
— | Read-only — log with file changes |
worktree add |
— | Parallel agent work |
worktree list |
— | Read-only |
worktree remove |
— | Cleanup after merge |
add |
— | Staging files |
push |
--force, -f; --force-with-lease conditional |
--force-with-lease allowed on non-protected branches |
fetch |
— | Read-only |
pull |
— | Fast-forward updates |
checkout |
-- ., -- * |
Specific-file restore allowed |
switch |
— | Branch switching |
stash |
— | All stash subcommands allowed |
merge |
— | Branch updates |
cherry-pick |
— | Selective commit application |
rebase |
-i, --interactive |
Non-interactive only |
Denied Subcommands¶
Anything not on the allowlist is rejected. Notable denials:
commit— all commits flow throughvrg-commitreset— too dangerous in all formsclean— file deletionconfig— modifying git configurationremote— modifying remote configurationfilter-branch,replace— history rewriting
Escape Hatch¶
None in the wrapper. If the agent genuinely needs a denied
operation, it explains the situation to the human, who runs the
raw command via ! git <command> in the Claude Code prompt.
vrg-gh Wrapper¶
Same architecture as vrg-git. Validates the top-level and
second-level subcommands as a pair.
Subcommand Allowlist¶
The allowlist depends on the identity mode (resolved by
vrg-whoami). The human and user identities share one
allowlist (_ALLOWED), from which the user agent loses the
agent-denied pairs below. The audit identity has its own, much
narrower allowlist (_ALLOWED_AUDIT). Any pair not listed is
rejected as not recognized.
| Subcommand | human |
user |
audit |
Notes |
|---|---|---|---|---|
issue view |
yes | yes | — | Read-only |
issue list |
yes | yes | — | Read-only |
issue edit |
yes | yes | — | Updating metadata |
issue comment |
yes | yes | — | Adding comments |
issue close |
yes | yes | — | Closing is mechanical and reversible |
issue reopen |
yes | yes | — | Non-destructive; an errant reopen is re-closed |
issue transfer |
yes | yes | — | Intra-org issue move (finite-epic relocation) |
pr view |
yes | yes | yes | Read-only |
pr checks |
yes | yes | yes | CI status |
pr list |
yes | yes | yes | Read-only |
pr diff |
yes | yes | yes | Read-only |
pr comment |
yes | yes | yes | Review context |
pr review |
yes | yes | yes | --approve is refused for user |
pr update-branch |
yes | yes | — | Server-side update onto the base (--rebase allowed) |
pr edit |
yes | — | — | Agents do not edit PRs |
pr merge |
yes | — | — | Human only, and a PR number or URL is required |
run list |
yes | yes | — | CI status |
run view |
yes | yes | — | CI status |
run watch |
yes | yes | — | Blocking wait for CI |
run rerun |
yes | yes | — | Retry a failed CI job (--failed allowed) |
workflow list |
yes | yes | yes | Read-only CI configuration |
workflow view |
yes | yes | yes | Read-only CI configuration |
cache list |
yes | yes | yes | Read-only CI state |
repo view |
yes | yes | — | Read-only |
repo list |
yes | yes | — | Read-only |
release list |
yes | yes | — | Read-only release facts |
release view |
yes | yes | — | Read-only release facts |
label list |
yes | yes | — | Read-only |
label create |
yes | yes | — | Used by vrg-ensure-label |
search code / commits / issues / prs / repos |
yes | yes | — | Read-only, cross-org search |
api |
full | — | GET only | See gh api |
The audit identity is a read/review reviewer, so it gets the
PR read and review actions plus the read-only workflow list,
workflow view and cache list, which only read CI
configuration and state a reviewer may need to judge a PR's
checks. Every write (run rerun, pr update-branch, the
issue actions) stays off its allowlist.
Denied Subcommands¶
These pairs are refused with an explanatory message rather than a bare "not recognized":
| Subcommand | Denied for | Reason |
|---|---|---|
issue create |
every identity | Use vrg-issue-create, vrg-epic-create or vrg-intake-create, which force the issue's linkage |
pr close |
every identity | Agents do not close PRs |
repo edit |
every identity | Admin operation |
repo create |
every identity | Admin operation |
repo delete |
every identity | Destructive |
workflow run |
every identity | Dispatching a workflow can start CD or a release, which is a human action |
auth |
every identity | Credential management |
pr create |
every identity | Agents are told PR creation needs a human; a human is pointed to vrg-submit-pr |
pr edit |
user, audit |
PR edit requires a human maintainer |
pr merge |
user, audit |
PR merge requires a human maintainer |
pr review --approve |
user |
Approval is restricted to the audit identity or a human |
api |
user |
Broad, write-capable escape hatch |
api (non-GET) |
audit |
Restricted to read-only GET calls |
Other write actions of allowed top-level commands (release
create, workflow enable/disable, cache delete and so on)
are rejected by the allowlist's deny-by-default.
gh api by Identity¶
gh api can perform any GitHub API operation — create repos,
delete branches, modify settings, push code via the Contents API.
It has no fixed sub-actions, so the wrapper gates it by identity
instead of by pair:
human— full access.audit— read-onlyGETcalls only. A call is treated as a write when it passes-X/--methodwith anything butGET, or passes a field flag (-f,-F,--field,--raw-field,--input) without an explicitGET.user— denied entirely, which closes the escape hatch that individual hook-based blocks cannot fully cover.
Credential Selection¶
vrg-gh is responsible for choosing which account's token to use
per-command. See Credential Management
for the selection logic.
VRG Tools That Bypass the Wrapper¶
Mechanized VRG tools (vrg-submit-pr, vrg-release,
vrg-update-deps, vrg-ensure-label, vrg-github-repo-config, vrg-finalize-pr)
call gh directly in their Python code. They bypass the wrapper
because they are already validated — the wrapper only constrains
agent-initiated gh calls via the Bash tool.
Permission Configuration¶
Project Settings (.claude/settings.json)¶
{
"permissions": {
"allow": [
"Bash(vrg-*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
}
]
}
]
}
}
The project layer allowlists all VRG tools via a single wildcard
and wires the Claude Code hook guard (guard.sh) as a PreToolUse
hook on every Bash tool invocation. The hook guard delegates to
vrg-hook-guard, which uses regex matching to block raw git and
gh commands while allowing vrg-git/vrg-gh wrappers through.
This configuration applies to everyone who clones the repo — no per-developer setup needed.
Global User Settings (~/.claude/settings.json)¶
This is the operator's per-host setting. vrg-vm copies it into every
identity VM — Lima and off-platform alike — via copy_claude_config,
so the agent boots straight into bypass inside the sandbox. The mode is
independent of the deny rules: raw git/gh are blocked regardless,
because the hook guard hard-blocks them even under bypass.
Local Settings (.claude/settings.local.json)¶
Gitignored, and holds nothing. In a managed repo, every copy of this
file that Claude Code may load must be absent or exactly {}. The file
outranks the project settings, so any rule in it would silently
override the managed template for that checkout. A rule you need
belongs in the repo template or in your user settings instead.
The agent chokepoints vrg-commit, vrg-pr-workflow report-ready and
vrg-container-run check this. An agent identity is refused; a human
gets the same message as a warning, and the tool proceeds. See
Local settings in the
Claude Config Ownership Model for which copies are checked, why the
check is not a Claude Code hook, and its known limit.
What This Means in Practice¶
| Command | Result |
|---|---|
vrg-commit |
No prompt (project allow) |
vrg-git status |
No prompt (project allow) |
grep -r "foo" src/ |
Runs (bypass) |
git push |
Blocked (hook guard) |
curl https://... |
Runs (bypass; contained to the sandbox VM) |
rm -rf . |
Runs (bypass; the disposable VM is the containment) |
vrg-gh pr merge |
No Claude Code prompt, but wrapper rejects it |
Defense-in-Depth¶
The Vergil Assumption¶
Every client-side enforcement layer in this model assumes the agent
is cooperative — that it is operating as Vergil, not Mimir. An
agent with file write access (which bypassPermissions grants) can
dismantle the entire client-side stack:
- Delete or edit
.claude/settings.json— Layer 1 gone - Run raw
gitandgh— Layer 2 bypassed (the wrappers are never invoked) - Remove plugin hook configuration — Layer 3 weakened
- Delete
.claude/hooks/guard.shor remove the hooks wiring from.claude/settings.json— Layer 4 gone
After those four steps, the agent has unrestricted local access. This is not a theoretical attack — it is trivial for any model that decides to do it.
Client-side layers are behavioral constraints, not security boundaries. They keep a well-intentioned agent on the rails. They prevent mistakes, enforce consistency, and provide an audit trail. They do not stop an adversary.
The two boundaries an agent cannot edit its way out of are not client-side: the VM sandbox that contains it locally, and the server-side GitHub App permission shape that bounds what reaches GitHub.
The VM Sandbox Boundary¶
Agents run inside a per-identity Vergil VM — never on the host. This is
what makes bypassPermissions acceptable: the agent has full rein
inside the VM, but the VM is the wall.
- Containment. The agent reaches only what the VM can reach. The host filesystem, other identities' VMs, and the operator's wider environment are outside the sandbox.
- Disposability. The VM is ephemeral and reproducible from its
declared profile, so a bad outcome inside it (an
rm -rf, a wedged toolchain) is recovered by a rebuild, not a forensic cleanup. On the off-platform backend, irreplaceable state lives on a separate persistent volume; injected credentials stay on the ephemeral boot disk and die with the VM. - Credential bounding. Even with full local control, the agent can only reach GitHub through its injected App token — which the server-side boundary below constrains regardless of what happens in the sandbox.
The sandbox is why VERGIL runs bypassPermissions rather than chasing
a per-command allowlist: move the boundary to the edge of a disposable
VM, and the prompts inside it stop being the thing that protects you.
The Actual Security Boundary¶
At the GitHub layer, the enforcement an agent cannot edit its way out of is server-side: the GitHub App's installation permission shape and branch protection rulesets.
- Each agent is a GitHub App whose installation token is bounded by
its declared permission shape. The user App holds
pull_requests: read, so it physically cannot open, approve, or merge a PR; the audit App holdscontents: read, so it cannot write code or merge (merging through the API requirescontents: write). Neither App holds Workflows access, so neither can push under.github/workflows/. GitHub rejects the API call regardless of what happens locally. - Branch protection rulesets require review approval from a different identity before merging. No client-side manipulation changes this.
- An App can only act on accounts it is installed on and repos that installation covers.
This and the VM sandbox are the real security model. Everything else is convenience.
Where these boundaries meet other systems, their guarantees do not always line up. Known gaps of that kind are recorded in Security Boundaries.
Why Client-Side Layers Still Matter¶
If server-side enforcement is the only real boundary, why bother with four client-side layers?
- Mistake prevention. Most failures are not adversarial. An
agent that accidentally runs
git reset --hardis not trying to subvert the tooling — it made a mistake. The wrappers catch this. - Consistency. Routing all operations through
vrg-*tools ensures commits have the right format, PRs have the right metadata, and branches follow the naming convention. - Audit trail. Wrapper logging records what the agent did and when. Even if a layer is bypassed, the gap in the log is itself a signal.
- Friction. An agent that has to actively dismantle four layers is doing something visibly wrong. That visibility is the value — not the strength of any individual gate.
The Mimir project exists specifically to test this boundary — to verify that server-side controls hold when every client-side layer is deliberately bypassed.
Client-Side Layers¶
Four layers, each catching mistakes the layer above misses. Redundancy is intentional — no single layer is trusted, and none are sufficient against adversarial behavior.
Layer 1 — Claude Code Permissions (outermost). The allowlist/deny configuration. Denied commands are blocked without prompt. Unknown commands prompt the human.
Layer 2 — VRG Wrappers. vrg-git and vrg-gh validate
subcommands and flags before executing the underlying tool. Even
if vrg-git reset --hard passes the permission layer (it is
allowlisted as vrg-*), the wrapper rejects it.
Layer 3 — Vergil Plugin Hooks. The existing hook system remains as a backstop. Hooks exit code 2 (hard block) overrides even bypass mode. Hooks that become redundant with layers 1-2 are retained — they catch regressions if a higher layer is misconfigured.
Layer 4 — Claude Code Hook Guard (.claude/hooks/guard.sh).
The innermost layer. A PreToolUse hook wired in
.claude/settings.json that delegates to vrg-hook-guard,
blocking all raw git and gh commands via regex matching.
With layers 1-3 in place, agents cannot reach this layer
through normal operation.
Layer Interaction Matrix¶
| Operation | L1 Permissions | L2 Wrapper | L3 Plugin Hook | L4 Hook Guard | Server-Side |
|---|---|---|---|---|---|
vrg-commit |
allowed | n/a | n/a | allows | push accepted |
vrg-git push |
allowed | validates: no --force |
n/a | n/a | push accepted |
git push |
denied | n/a | blocked | n/a | push accepted (if layers bypassed) |
vrg-gh pr merge |
allowed | rejected | blocked | n/a | API rejected (no merge permission) |
gh pr create |
denied | n/a | blocked | n/a | PR created (if layers bypassed) |
gh pr merge (raw) |
denied | n/a | blocked | n/a | API rejected (no merge permission) |
rm -rf . |
prompts human | n/a | n/a | n/a | n/a |
vrg-git reset --hard |
allowed | rejected | n/a | n/a | n/a |
The rightmost column is the only one that holds against Mimir.
History: the acceptEdits direction, and why it was dropped¶
The original plan phased toward a tight acceptEdits + allowlist
model — acceptEdits as the default mode, then progressively shrinking
the bash allowlist until Bash(vrg *) was the only allowed pattern and
everything else required human approval.
That direction was not adopted. In practice, prompting on every non-allowlisted shell command made the agent unusable for the infrastructure work VERGIL actually does, where running many varied commands is the job. The allowlist could never keep up, and the operator spent sessions approving commands instead of getting work done.
VERGIL resolved this by changing where the boundary sits rather than
how tight the allowlist is: the agent runs in bypassPermissions
inside a disposable, sandboxed VM (see
Base Permission Mode and
The VM Sandbox Boundary). The vrg-*
wrappers, deny rules, and hooks remain — as consistency and
mistake-prevention guardrails, and as hard blocks on raw git/gh
that hold even under bypass — but they are no longer what makes
autonomy safe. The VM sandbox and the server-side App permissions
are.
The earlier acceptEdits design spec
(docs/specs/2026-05-14-permission-model-design.md) is retained as
historical context; it does not describe the model in use.
Related¶
- Identity Architecture — the accounts that permissions protect
- Credential Management — how
vrg-ghselects credentials per-operation - Account Setup — creating and configuring accounts
- Security Boundaries — register of known cross-system seams and their mitigations
- Claude Config Ownership — which settings or instruction file owns each Claude Code setting
- Permission model design spec — full decision rationale and alternatives considered