This document describes the threat model, what agy-mcp defends, and
what it explicitly does not defend. Read this before changing
anything in safety.py, worktree.py, install.py, or
utils.safe_write_text.
The bridge sits between two trust boundaries:
- Caller → bridge (Claude Code / Codex → MCP stdio): the caller is trusted with the user's repo and shell, but the prompt content may be adversarial (user input, scraped issue body, etc.). The bridge must not let a hostile prompt escape its sandbox.
- Bridge → agy (subprocess launch): the bridge controls argv,
environment, and cwd. The
agybinary itself is trusted (it was installed by the user) but its responses are NOT — they're streamed back into the caller's transcript.
The bridge is not a sandbox for agy — that's agy --sandbox's
job. It's a hardened gateway that:
- Refuses requests likely to leak secrets or perform destructive operations.
- Scrubs response and error content before returning to the caller.
- Isolates write-enabled
executeruns in a disposable git worktree.
Pydantic v2 with extra="forbid". Notable validators:
prompt: 1 ≤ length ≤ 256_000 characters (well under any platform's argv ceiling when fused as--print=<value>). Also passed toSafetyPolicy's pattern-based deny-list.extra_env(passed through toagysubprocess): keys must match^[A-Z_][A-Z0-9_]*$, values cannot contain\n/\r/ NUL, max 64 entries, max 4096 chars per value. Rejects the POSIX-special_key and runtime-control names such asNODE_OPTIONS,PYTHON*,LD_*,DYLD_*,GIT_CONFIG*,PATH,HOME,BASH_ENV,ENV,AGY_CLI_DISABLE_AUTO_UPDATE, andANTIGRAVITY_CONVERSATION_ID. (Phase 5 R2 sec P0-1.)mode,backend,output_protocol: closed enums.timeout: 1 ≤ value ≤ 86400 (24h ceiling; longer runs should usemode="long"+agy_start).max_output_chars: 1 ≤ value ≤ 8 MiB (caps the in-process buffered transcript; detachedagy_startjobs also persist the full event stream inevents.jsonl).SESSION_ID/session_id: max 96 chars before it can seed a conversation resume or worktree name.
Reads from / mentions of sensitive paths are refused outright in
execute mode and warned in other modes:
~/.ssh/,~/.aws/credentials,~/.gnupg/,~/.config/gh/- Browser cookie stores (
~/Library/Application Support/Google/Chrome,~/.mozilla/firefox/.../cookies.sqlite, etc.) - OS keychain (
security find-generic-password,secret-tool) - Destructive command shapes:
rm -rf /,dd if=/dev/zero of=/dev/...,mkfs,:(){:|:&};:, etc.
The deny-list is applied to both the prompt body AND the synthesised
argv (defence-in-depth: a prompt that ends up in --extra-args still
gets scanned).
Every flag that takes a value is fused as --flag=value instead of
two argv items (--flag, value). This stops a malicious value from
being parsed as a new flag. The fused form is what we pass to
subprocess.Popen(shell=False).
shell=Falsealways; argv only.start_new_session=True(POSIX) /CREATE_NEW_PROCESS_GROUP(Windows) so cancellation cankillpg(SIGTERM)/ sendCTRL_BREAK_EVENTwithout losing the whole tree.stdin=DEVNULL—agyis never given interactive input.- Environment is filtered, not inherited: start from
os.environ.copy(), drop any key matchingSECRET_ENV_NAME_PATTERN(regex covering*TOKEN,*API_KEY,*SECRET,*PASSWORD,*CRED*, etc.) PLUS the explicitDEFAULT_SCRUB_ENV_NAMESlist (AWS_*,GCP_*,AZURE_*,OPENAI_*,ANTHROPIC_*,GH_TOKEN,GITHUB_TOKEN,NPM_TOKEN,PYPI_TOKEN, etc. — 32 entries). The regex and the list run in tandem so an env name likeMY_CUSTOM_API_KEY(regex match) andAWS_PROFILE(explicit list match) both get dropped.
Used by session_store, install, and worktree. When
verify_under is provided on POSIX platforms with openat support, it
defends against parent-symlink swaps by:
- Opening the resolved
verify_underroot once withO_DIRECTORY|O_NOFOLLOW. - Creating or opening each intermediate directory via
dir_fdwithO_NOFOLLOW. - Creating a random tempfile with
O_CREAT|O_EXCL|O_NOFOLLOWagainst the pinned parent directory fd. - Renaming the tempfile to the final leaf with
src_dir_fdanddst_dir_fd, so the final publish does not re-resolve the parent path.
On Windows or filesystems that do not expose the required openat
family, the fallback path still does a pre-write and post-rename
symlink walk with relative_to(verify_under) checks. That fallback is
detect-after-the-fact for a successful parent swap; the POSIX openat
path is the airtight path.
When mode=execute and allow_write=True, the bridge:
- Creates
<repo>/.agy-mcp/worktrees/<session_id>/withgit worktree addon a fresh branch. - Runs the child process with
cwdset to the worktree so the agent's edits land there. - Leaves the worktree in place after the run so the caller can inspect,
merge, or discard the branch. Remove it manually with
git worktree remove <path>when finished.
Configurable via ~/.config/agy-mcp/config.toml:
[execute]
worktree_default = true # opt-out via falseEnv var override: AGY_MCP_WORKTREE_DEFAULT=0/1.
Every string that leaves the process (error, warnings,
agent_messages, installed[*].path, command_preview, log lines):
- PEM blocks →
*** - JWT tokens →
*** - AWS access key IDs (
AKIA...) →*** Bearer <token>/Authorization: <scheme> <token>→Bearer ***/Authorization: <scheme> ***. The same redaction is applied to a wider header allow-list driven by_AUTHZ_HEADER(utils.py:62-66):Authorization,X-Api-Key,X-Auth-Token,X-Auth-Key,Api-Key,Apikey,Proxy-Authorization,X-Goog-Api-Key,X-OpenAI-Key,X-Anthropic-Key.- Slack tokens (
xoxb-…,xoxp-…) →*** - GitHub fine-grained PATs (
github_pat_…) →*** - Generic high-entropy key=value secrets →
*** /Users/<u>/→~/,/home/<u>/→~/,C:\Users\<u>\→~\
The placeholder is the opaque token *** (defined as
utils.REDACTION_PLACEHOLDER) rather than a typed marker like
<REDACTED PEM>. The opacity is deliberate: a tagged placeholder
would tell an observer the original value type, giving an attacker
an oracle on what kind of secret leaked. Operators auditing logs
should treat any *** as "credential-shaped material was redacted
here"; the exact type lives only in the process that did the
redaction, not in the persisted output.
Compiled patterns are cached behind a threading.RLock so two
concurrent MCP tool calls cannot race on first redaction. The lock
is re-entrant so a future custom user pattern that itself raises and
gets re-redacted in an except block via _extra_patterns will not
deadlock.
agy_install_skill:targetscapped at 16 entries, each rejected unless it isstrandin {"claude", "codex", "antigravity", "all"}.scopeallow-listed.project_rootvalidated (leaf is not a symlink) beforeinstall_skillsruns. Deliberate defence-in-depth with_expand_targetsdoing the same allow-list check (Phase 7 R1 arch P2-2). The leaf check is only the surface layer; the ancestor symlink-swap window is closed at write time by thesafe_write_textparent walk described in § 5.agy_status/agy_read/agy_result/agy_cancel:job_idmust match^job_[A-Za-z0-9_-]{1,80}$. Oversized values are rejected with a structured error.- All sync tools route through
_structured_failureon exception — never a bare traceback to the caller.
Documenting what the bridge does NOT defend prevents callers from assuming protection that isn't there.
- Compromised
agybinary. Ifagyitself is hostile, the bridge cannot detect it. We probeagy --help/agy --versiononce for capability detection and trust the output. - Caller-side prompt leaks. The caller can paste the bridge's response wherever it likes. We redact secrets before returning, but if the response is logged into an external system the caller is on the hook.
AGY_BRIDGE_CMDenv var. This is an advanced override consumed byskills/.../scripts/agy_bridge.pythat lets the forwarder shell out to an arbitrary command. Treat it as a trust boundary: anything withAGY_BRIDGE_CMDset can run that command with the user's privileges.- Editable installs.
pip install -e ./uv pip install -e .makes_skill_bodies/read from the working tree at install time, so a hostile working tree feeds hostile install content. By design — the developer is trusted on their own machine. - TOCTOU residue on the non-openat fallback path. On Windows or
filesystems that do not support the required
openatAPIs,safe_write_text's post-walk surfaces the breach to the caller (via a raisedOSError) but does NOT undo a published leaf if an attacker wins the race. POSIX platforms with openat support use the anchored dir-fd path described in § 5. - Versioned
uvxfallback availability. The skill forwarder uses a fixed package version (agy-mcp==0.1.4) rather than a mutable branch ref. If that version is not published in the user's configured Python index, the fallback fails closed and the user must install the bridge locally or setAGY_BRIDGE_CMD. - System-level symlinks on macOS / Linux.
/tmp/...→/private/tmp/...and/var/...→/private/var/...are honest symlinks but they exist on every macOS install._validate_project_rootintentionally does not refuse paths whose ancestors include such symlinks; the write-timesafe_write_textwalk under the resolved root provides the real defence.
The session store records every job, including:
- The redacted
command_preview(argv after_structured_failure's redact pass). - The full
events.jsonlstream (raw canonical events, never truncated). stdout.log,stderr.log,agy.log(the klog file).meta.jsonwithcreated_at,updated_at,exit_code,cancel_reason.
An operator can review past runs with agy_sessions() +
agy_read(job_id, translate="raw") without needing the original
caller's transcript.