Compass client push guard
Freezes on merge; later changes supersede by citation, never rewrite.
Issue: RIG-1882. Decisions DL-507..DL-510. Ruling (Matt, 2026-08-07): two layers. The client OMP extension in the agent image is load-bearing, because Compass cannot control a user’s own repo settings. Server-side branch protection plus the scoped per-agent forge token is a recommended backstop, not a guarantee.
Problem / Intent
Section titled “Problem / Intent”The jj skill admits the never-push-main invariant is unenforced
(config/skills/jj/SKILL.md: “the invariant is behavioral: you enforce
it; nothing blocks you”). The brokered GitHub App token cannot write outside
the agent’s granted repos, but it can push a granted repo’s main and merge
its PRs. This record adds an in-process guard that blocks three things before
the tool runs: a push to a protected branch, a PR merge by any path, and a
write to an owner or host outside the agent’s allowlist.
Approach
Section titled “Approach”D1 Two layers; the client guard is the one Compass owns
Section titled “D1 Two layers; the client guard is the one Compass owns”Matt ruled the layering; this record does not reopen it. The guard (D2-D7)
ships in every agent image. Branch protection (D8) is advice to users. The
guard stops a cooperative agent’s mistaken or habitual command. It is not a
sandbox: a command assembled at run time inside a script file, or a process an
eval cell spawns itself (Bun.$, Python subprocess), can evade it, and so
can state the persistent bash session keeps between calls (functions,
aliases, an exported GH_REPO). The token scope and branch protection bound
those cases.
D2 An inline extension compiled into the binary
Section titled “D2 An inline extension compiled into the binary”maininpackages/compass-agent/src/cli.tspassesextensions: [pushGuard(readPushGuardConfig(env))]tocreateAgentSession(“Inline extensions (merged with discovery)”,CreateAgentSessionOptionsin the SDK’ssdk.ts). Mounted extensions come from the user’s bundle (enumerateMountedExtensionsinsrc/config-reader.ts), which could omit a mounted guard. Thebun build --compilebinary (agent-image/entrypoint.nix) cannot drop a compiled-in factory.- The handler is
pi.on("tool_call", ...)returning{ block: true, reason };ToolCallEventResult.reasonis “Reason for blocking (returned to LLM as error)”.ExtensionRunner.emitToolCallreturns on the first block, and a handler error or timeout blocks too (“On-timeout policy: fail-closed”). - Subagents run
bashin the same container. The locked SDK 18.0.11 drops inline factories for child sessions:createAgentSessionsetstoolSession.preparedExtensions = extensionsResult.preparedExtensionsbefore its inline loop. 18.3.5 keeps them (“Caller-provided factories are safe to rebind, so preserve them with file-backed prepared extensions for/tanand other child sessions”), andstructured-subagent.tspassespreloadedPreparedExtensions: session.preparedExtensions. The SDK floor rises to 18.3.5 (T1). - Isolated tasks still lose it:
runIsolatedSubprocess(task/isolation-runner.ts) passespreloadedPreparedExtensions: undefined. Isolation is off by default, but a user settings bundle can turn it on, socli.tsforcestask.isolation.enabledtofalseand T3 tests it.
D3 Inspected tools
Section titled “D3 Inspected tools”bashand any tool whoseinput.commandis a string, parsed per D6, ininput.cwdor elsectx.cwd.eval:input.codegets the D6 text check. A tool the cell calls through the bridge (tool.bash(...)in JS, the Python equivalent) is a separatetool_call:callSessionTool(eval/js/tool-bridge.ts) resolves it throughgetToolForEvalBridge, which returns theExtensionToolWrapper-wrapped registry entry, so the guard sees the assembled command (verified on 18.3.5).- The SDK
githubtool (present whenghis onPATH): opspr_createandpr_push. - GitHub MCP tools,
/^mcp__(?:[a-z0-9]+_)?github_/. The SDK names MCP toolsmcp__${server}_${tool}(mcp/tool-bridge.ts); a server under another name is not recognized, and D8 bounds it. - Not
forge_*: they have no merge verb (TransitionPullRequestStateRequestinproto/compass/v1/agent_gateway.proto: “merge is a separate concern never expressed as a transition”), and with scope enforcement on,forgeService.requireForgeScope(go/server/forge.go) checks their writes against the same grants as D4.
D4 Allowlist from the agent’s forge grants (assumes OQ-1 option a)
Section titled “D4 Allowlist from the agent’s forge grants (assumes OQ-1 option a)”- At provision the server calls
Store.ListForgeScopeRepos(ctx, agent, store.ForgeProviderGitHub, host)(“returns the exact repo grants and wildcard for an account and its owner”) for the configured forge host (ForgeConfig.Host, defaultgithub.com).gitCredentialBroker.credential(go/server/git_credential.go) mints the token from the same call. - Owners: the distinct lowercased owners of the
owner/namegrants; a*grant gives*, meaning any owner. Hosts: the forge host. - New
ProvisionAgentWorkspaceRequestfields carry both, and the server overwrites any client value, as it doespersona. The Runner exportsCOMPASS_FORGE_ALLOW_OWNERSandCOMPASS_FORGE_ALLOW_HOSTS; the guard reads them once at session start. A grant change applies only at the next provision, which is a new container:ReloadAgentSessiondoes not re-provision, while the token broker picks up grant changes live. OQ-1 (d) avoids that gap. - An empty owner or host list blocks every write, with a reason that names the variable. This holds for every OQ-1 option.
The token already cannot reach ungranted repos. The guard repeats the check because a user-mounted MCP server may carry its own GitHub token (DL-441 keeps user-declared GitHub secrets out of the agent, not MCP servers’ own config), and an in-process block gives a reason, not a 403.
D5 Protected branches, independent of the allowlist
Section titled “D5 Protected branches, independent of the allowlist”Protected names are main, master, and the target repo’s default branch.
The default branch is read locally first (refs/remotes/<remote>/HEAD, or
jj’s trunk()), then from gh api --hostname <host> repos/<owner>/<repo> --jq .default_branch, memoized per repo; a failed read blocks a push but not
other commands. They apply to every owner.
git push: a refspec destination after stripping+andrefs/heads/(main,+main,HEAD:main,refs/heads/main,:main).--all,--branchesand--mirrorblock. With no refspec the destination depends onpush.defaultandremote.<name>.push, so the guard blocks a baregit pushunless the current branch is not protected and neither setting is present; OQ-3 covers asking git itself.jj git push,jj-hp push,jj-vine submit: a protected name given as a positional,-b/--bookmark, or--named <name>=<rev>blocks. So does a protected bookmark whose local and remote targets differ, because--all,--tracked,--deleted,-r, a default push, or a stack submit can carry it. T2 gives the template. The remote is--remoteor elseorigin.
D6 Shell text is parsed, not pattern-matched
Section titled “D6 Shell text is parsed, not pattern-matched”The SDK’s tokenizeShellSegments (tools/shell-tokenize.ts) leaves
“heredocs, command substitution, and arithmetic expansion” out of scope, so
the guard adds a small parser, push-guard/parse.ts, the one new abstraction
here. It returns a flat list of simple commands, each with its words and
leading NAME=value assignments:
- quotes, backslash and
$'...'escapes; the operators;,&&,||,|,&,(,)and newline; $(...), backticks and<(...)parsed as nested commands; a word that holds one is marked dynamic;- a heredoc body parsed as a script when it feeds
bash,shorzsh; - wrappers removed:
env(its assignments kept),sudo,command,exec,nice,nohup,timeout,time,xargs,eval, andbash,shorzshwith-c; cd <literal>sets the directory of later commands; a dynamic target makes it unknown.
Fail closed: a command whose first word is dynamic and a later word is push,
merge, submit or api blocks ($(echo git) push origin main). So do text
that does not parse and names a guarded program (git, gh, jj,
jj-vine, jj-hp), xargs running a guarded program, and a guarded write in
an unknown directory. eval code gets one text check: a line holding git
or jj, push, and a protected name blocks. No other raw-text match blocks,
so rg 'gh pr merge' or a commit message naming a merge is allowed.
The SDK’s natives bundle tree-sitter-bash (THIRD-PARTY-NOTICES.txt in
@oh-my-pi/pi-natives), but no exported bash parse API was found. T2 uses
one if it exists and keeps only the unwrap and evaluate logic.
D7 Writes, decision order, and rule calls
Section titled “D7 Writes, decision order, and rule calls”A write is a push (git push, jj git push, jj-hp push,
jj-vine submit, github pr_push); a gh command under pr, issue,
repo, release, label, workflow, run, secret or variable whose
verb is not view, list, status, diff, checks, search, browse,
watch, download or checkout; a gh api mutation; or a GitHub MCP tool
whose name holds none of get_, list_, search_, _read. Its target repo
is the first found of: the push remote (a URL, or a name through
git remote get-url), -R/--repo, GH_REPO/GH_HOST, a gh repo
positional OWNER/REPO, the gh api path, and the MCP owner argument.
Otherwise a gh write follows gh’s own choice: the gh repo set-default
repo, else remotes ranked upstream, github, origin (remoteNameSortScore
in gh’s context/remote.go). The guard checks every remote of the clone and
blocks if any is outside the allowlist, so a fork’s upstream cannot be
written by accident.
First match wins:
- Merge: a parsed
gh pr merge(verb found past flags;--autotoo); agh apipath ending/pulls/<n>/mergeor/merges; agh api graphqlbody whosemutationnamesmergePullRequest,enablePullRequestAutoMergeorenqueuePullRequest; a GitHub MCP tool name holdingmerge. - No allowlist: any write while owners or hosts is empty.
- Owner unresolved or not allowed (
*allows any). - Host not allowed (
api.github.comcounts asgithub.com). gh apimutation (-X/--methodnotGET, or-f/-F/--field/--raw-field/--inputon a REST path) whose path is notrepos/<allowed owner>/.... Agh api graphqlcall is a mutation only if its query text holdsmutation; then it blocks, because GraphQL names node ids, not owners, and the reason points to theforge_*tools. A GraphQL query (review threads, check state) is allowed.- PR create:
gh pr create,githubpr_createandpr_pushblock with “usejj-vine submit <bookmark>”. - Protected push (D5).
Calls on the rules the guard the operator’s own agent fleet runs today enforces:
| Rule | Call | Reason |
|---|---|---|
Broad process kill (pkill, killall, kill -1) |
Drop | Scope here is push authorization; a separate guard can own it. |
gh pr create redirect |
Keep | jj-vine submit is the documented Compass push path (DL-142). |
Block pushes to the upstream remote |
Drop | A user’s agent may work a fork; rules 3 and 4 bound the target. |
| Fail-closed rules | Keep | An unknown target or unparsed text must never read as consent. |
gh issue create needs an allowlisted -R |
Fold into rule 3 | The target resolves from origin when -R is absent. |
| First push to an empty repo is allowed | Drop | That push lands on main; the user seeds an empty repo. |
ssh and recipe tool names |
Generalize | Not Compass tools; any tool with a string command is inspected. |
D8 Server backstop: advice, not built here
Section titled “D8 Server backstop: advice, not built here”Users should protect main with a ruleset: require a pull request approved by
a human code owner, block force pushes, and block deletion. The reviewer App
(ForgeConfig.ReviewerApp) must not count as that approval. Then the token’s
contents: write and pull_requests: write (gitCredentialPermissions in
go/server/git_credential.go) cannot land on main without a human. The
token is already repo-scoped by grants. This record adds no server code for it.
Alternatives considered
Section titled “Alternatives considered”- A mounted extension in the config bundle. Rejected: the user’s bundle can omit it, and the guard must hold for every bundle.
- Git hooks or wrapper binaries on
PATH. Rejected:--no-verify, an absolute binary path,curl, and MCP tools all bypass them, and none sees a merge made through the API. - A narrower App token (no
pull_requests: write). Rejected: it breaksjj-vine submitPR creation, andcontents: writestill pushesmain. - Regex on the raw command text only. Rejected: it misses
bash -cand substitutions and blocks harmless text such asecho "git push origin main". D6 keeps a text check only as the merge backstop.
Global Constraints
Section titled “Global Constraints”- Every
@oh-my-pi/*dependency ofpackages/compass-agentis^18.3.5or later. - The guard only blocks. It never returns
inputoradditionalContext. A user-mounted handler that rewritesinputis outside its view (ToolCallEventResult.input: “each sees the originalevent.input”); the user owns that code (OQ-2). - Every reason starts with
push-guard:, names the rule, and names the allowed path (jj-vine submit <bookmark>, aforge_*tool, or a human). - Env vars are
COMPASS_FORGE_ALLOW_OWNERSandCOMPASS_FORGE_ALLOW_HOSTS: comma-separated, lowercased, omitted when empty, likeCOMPASS_PERSONAinAgentEnv.execSpec. Absent and empty mean the same thing: block every write. - Unit tests use
bun test, colocated, with injected probes and no network. - This repo is public: no reference to any private repo.
T1 SDK floor 18.3.5 (lane agent, RIG-1882)
Section titled “T1 SDK floor 18.3.5 (lane agent, RIG-1882)”- Raise the five
@oh-my-pi/*ranges inpackages/compass-agent/package.json, refreshbun.lock, and update bothoutputHashBySystemhashes inagent-image/entrypoint.nix. - Acceptance: the existing
packages/compass-agenttests pass, and the image builds on both systems. - Interfaces: none new.
T2 Guard module (lane agent, RIG-1882)
Section titled “T2 Guard module (lane agent, RIG-1882)”- New
packages/compass-agent/src/push-guard/:parse.ts(D6),evaluate.ts(D3, D5, D7),probes.ts(process-backed probes),index.ts(config and factory), each with a colocated test. probes.movedBookmarksruns the template below. A protected name blocks when its@localrow isfalse, or no row for the push remote istrue. A jj error blocks. The test runs it against a real jj repo.- Acceptance: blocks
git push origin main,git push -f origin HEAD:main,jj-vine submitwith a moved localmain,gh pr merge 12,gh api -X PUT repos/o/r/pulls/1/merge, a GraphQLmergePullRequest,gh pr create, a push andgh issue create -R other/repoto an owner not allowed, a push to a host not allowed, an MCP merge tool,bash -c 'git push origin main',$(echo git) push origin main, and a heredoc intobashthat pushesmain. Allows an allowed feature-branchgit push,jj-vine submit feat-x,gh api repos/o/r/pulls, andgh pr view 12. - Interfaces:
jj bookmark list --all-remotes -T 'name ++ "\t" ++ if(remote, remote, "@local") ++ "\t" ++ if(remote, tracked, synced) ++ "\n"'export interface ShellCommand { words: string[]; dynamic: boolean[]; assigns: Record<string, string>; cwd: string | null }export type ParseResult = { ok: true; commands: ShellCommand[] } | { ok: false };export function parseShell(text: string, cwd: string): ParseResult;// evaluate.tsexport interface PushGuardConfig { owners: string[]; hosts: string[] }export interface Probes { remoteUrl(cwd: string, remote: string): Promise<string | null>; currentBranch(cwd: string): Promise<string | null>; defaultBranch(host: string, owner: string, repo: string): Promise<string | null>; movedBookmarks(cwd: string, remote: string): Promise<string[] | null>;}export type Verdict = { block: true; reason: string } | null;export function evaluate(toolName: string, input: Record<string, unknown>, cwd: string, config: PushGuardConfig, probes: Probes): Promise<Verdict>;// index.tsexport function readPushGuardConfig(env: Record<string, string | undefined>): PushGuardConfig;export function pushGuard(config: PushGuardConfig, probes?: Probes): ExtensionFactory;T3 Wire the guard into the agent (lane agent, RIG-1882)
Section titled “T3 Wire the guard into the agent (lane agent, RIG-1882)”maininsrc/cli.tsaddsextensions: [pushGuard(readPushGuardConfig(env))]to itscreateAgentSessioncall.- Acceptance: a session test built like
native-tools.test.tsshows a blockedbashcall returns thepush-guard:reason to the model as a tool error, that ataskchild session blocks the same call, that a settings bundle enabling task isolation does not turn it on, and that abashcall made through the eval bridge (callSessionTool) with a command assembled at run time is blocked. - Interfaces: consumes T2
pushGuardandreadPushGuardConfig; depends on T1.
T4 Allowlist delivery (lane server, RIG-1882)
Section titled “T4 Allowlist delivery (lane server, RIG-1882)”- Add fields to
ProvisionAgentWorkspaceRequestinproto/compass/v1/compass.proto, with comments in thepersonastyle. - A server helper derives them (D4). All three provision sites call it and
overwrite the client value:
service.provisionAgent,lifecycleService.provisionAndStart,lifecycleService.reprovision. A store error fails the provision. - The Runner threads them like
Persona:configSpecBuilder.BuildSpecintoruntime.AgentSpec,AgentHandleaccessors,agentHost.agentEnv, andAgentEnv.execSpec. - Acceptance: a client-sent value is overwritten, as
service_placement_pgtest_test.goproves forpersona;execSpecomits both vars when the lists are empty. - Interfaces:
repeated string forge_allow_owners = 5;repeated string forge_allow_hosts = 6;func forgeAllowlist(ctx context.Context, st *store.Store, agent store.AccountID, host string) (owners, hosts []string, err error)T5 Skill text (lane agent, RIG-1882)
Section titled “T5 Skill text (lane agent, RIG-1882)”- After T3 and T4 merge, replace the
[TODO RIG-1882]paragraph inconfig/skills/jj/SKILL.mdwith the enforced rule: the guard blocks a push tomain,masteror the default branch, every merge, and writes outside the allowlist. - Interfaces: none.
- T1 SDK floor 18.3.5 and image hashes
- T2
push-guardmodule and unit tests - T3
cli.tswiring and session test - T4 Allowlist from forge grants to the agent env
- T5
jjskill paragraph
Decisions
Section titled “Decisions”- DL-507: two layers; the guard is an inline extension compiled into the agent binary and reaches task subagents (D1, D2).
- DL-508: writes outside the owner and host allowlist block; an empty list blocks every write (D4, D7). The allowlist source is OQ-1.
- DL-509:
main,masterand the default branch are protected for every owner, and every merge path is blocked (D5, D7). - DL-510:
gh pr createis redirected tojj-vine submit(D7).
Open Questions
Section titled “Open Questions”- OQ-1 Allowlist source (load-bearing, RIG-5298). (a) Server-derived from forge grants at provision (D4): one source with the token; costs two proto fields and wiring at three provision sites; a grant change applies only in a new container. (b) Runner-wide config, like model and batching: simple, but per deployment, not per user, and it can drift from grants. (c) A file in the user’s config bundle (#2230 adds tenant and user overlays to that bundle): per user, but a second source that can disagree with grants. (d) The guard asks for the grants over the agent’s gateway socket at session start and on a short cache: same source as (a), live with grant changes, no provision fields; costs a new gateway call and a block when the server is unreachable. Recommendation: (a) for the first cut, as the smallest change; (d) if live grant changes matter. Under every option an empty list blocks every write, which locks out a user who brings their own credential with no forge grants.
- OQ-2 Input rewrites by mounted extensions (non-load-bearing). Accept the
limit above, or ask the SDK for a
tool_callview of the final input. Recommendation: accept it; the bundle is the user’s own code. - OQ-3 How a push’s target refs are found (load-bearing, RIG-5298). (a) As D5:
re-implement git and jj ref selection (refspecs,
push.default, moved bookmarks). No network; drifts from the tools. (b) Run the same command with--dry-run(git push --dry-run --porcelain,jj git push --dry-run) and read the refs it would update. Exact, but one network round trip per push, andjj-vine submithas no known dry-run, so it keeps (a)’s bookmark check. Recommendation: (b) forgit pushandjj git push, (a) forjj-vine submitandjj-hp push.