Security Gate
The security gate is a seccomp RET_USER_NOTIF filter plus a Docker HTTP proxy that sit between the agent process inside each container and the kernel / Docker daemon. It exists because agent containers run claude with --dangerously-skip-permissions: Claude’s in-process tool approvals are disabled, so every Bash, Edit, Write, and docker call reaches the syscall layer with no human in the loop. The gate is the sole source of human-in-the-loop approval for anything touching the host.
The gate is enabled by default for new installs and on upgrades. Operators disable it explicitly with gates.agentgate.enabled: false.
This page is a single-page overview. Each subsystem has its own reference:
- Config surface: Configuration: Security Gate
- WebSocket events: Events:
gate.approval_requested/gate.approval_resolved - Resolve endpoint: API:
POST /api/gate/approvals/{id} - Desktop UI card: Chat: Gate Approval Card
- Host plumbing & env: Containers: Security Gate
Threat model under --dangerously-skip-permissions
--dangerously-skip-permissions disables every Claude-side tool prompt. Without the gate, a prompt injection in fetched content runs commands in the container with no brake. The gate closes the gap at the kernel boundary, where Claude’s userspace flag has no meaning.
| Claude tool | Underlying action | Gate coverage |
|---|---|---|
Bash | execve on every subprocess | Covered — seccomp notify + CommandRule + wrapper unwrap |
Edit / Write / NotebookEdit | openat(O_WRONLY|O_CREAT|O_TRUNC) + renameat2 | Covered — seccomp notify on 9-syscall file-op set + FileRule |
Read / Glob / Grep / NotebookRead | openat(O_RDONLY) + statx + readdir | Covered — same machinery, default-allow with a credential denylist |
WebFetch / WebSearch | TCP+TLS inside Claude’s process | Not covered in v1 — operator mitigation is a no-internet Docker network plus HTTPS egress proxy |
TodoWrite, memory tools | No syscalls | Not coverable by any syscall gate |
MCP tool calls (mcp__loop__*, etc.) | stdio to MCP servers in loop-server on the host | Out of scope — enforcement belongs in loop-server’s MCP exposure policy |
Parent-kill / orphan-fd attack
The seccomp notify fd is held by the loop syscallwrap parent inside the container. If the agent could kill the parent, the kernel would close the fd and subsequent notifications on the filter would drop through unhandled — effectively allow-all for the trap set. Two layers close this:
- Different uids. The parent keeps the uid that entrypoint invoked it as (root). The child is spawned with
SysProcAttr.Credential={uid, gid}looked up from$LOOP_HOST_USER, andsyscall.Execs claude from that uid. Under POSIXkill(2), a non-root process cannot signal a process owned by a different uid —EPERM. The agent cannot signal the parent at all. PR_SET_PDEATHSIG(SIGKILL). Set by the parent inSysProcAttr.Pdeathsigand re-set by the child inprctl(PR_SET_PDEATHSIG, SIGKILL)before the filter install (belt-and-braces — the parent-set value is cleared by Linux on uid change, so the child re-sets it after the credential switch). If the parent dies by any means (panic, OOM, external SIGKILL from pid 1), the kernel delivers SIGKILL to the child, which transitively kills claude and every descendant via the process tree. The container exits instead of continuing with an ungated filter.
These two together mean the only way to end up with claude running past a dead parent is a kernel bug in one of kill(2) / pdeathsig / seccomp inheritance.
Architecture
Both enforcement layers live inside the container. The host publishes per-container policy JSON files under ~/.loop/run/<CID>/ and bind-mounts them read-only; no host-side unix sockets are bind-mounted any more. Approval clicks round-trip back over HTTP, authenticated by a per-container bearer token.
host agent container
┌───────────────────────────────────┐ ┌───────────────────────────────┐
│ loop-server │ │ entrypoint.sh (root) │
│ │ │ ├─ loop dockerproxy & │
│ ~/.loop/run/<CID>/ │ │ │ listens /var/run/docker │
│ ├─ proxy-policy.json ──────────┼─────┼──▶ .sock (tmpfs) │
│ └─ gate-policy.json ───────────┼──bind┼───▶ /etc/loop/{proxy,gate}- │
│ │ :ro │ policy.json │
│ /var/run/docker.sock ────────────┼─bind─┼───▶ /var/run/docker.sock.host│
│ │ (ro) │ (daemon, root-only) │
│ │ │ └─ exec loop syscallwrap -- │
│ │ │ │ (still root) │
│ │ │ ├─ re-exec /proc/self/exe│
│ │ │ │ ExtraFiles=[child] │
│ │ │ │ Credential=agent uid │
│ │ │ │ Pdeathsig=SIGKILL │
│ │ │ │ │
│ │ │ │ child (agent user): │
│ │ │ │ install filter, │
│ │ │ │ send notify fd via │
│ │ │ ◀──┤◀─── SCM_RIGHTS │
│ │ │ │ syscall.Exec claude │
│ │ │ │ │
│ │ │ └─ parent runs │
│ │ │ agentgate.Server loop │
│ │ │ on the notify fd │
│ │ │ │
│ POST /api/gate/container-approval│ │ ▲ │
│ Authorization: Bearer <token> │◀────┼──────┤ httpapprover.Approver │
│ ByToken → (cid, Manager, │ HTTP│ │ (shared by proxy+gate) │
│ channelID) │ │ │ │
│ → mgr.Request → bot prompt │ │ │ │
│ │ │ └─ claude (filtered) │
└───────────────────────────────────┘ └───────────────────────────────┘Two enforcement layers, same approval backend:
internal/agentgate/+internal/syscallwrap/— kernel-level syscall gate.entrypoint.shexecsloop syscallwrapas root; the parent re-execs/proc/self/exewithLOOP_SYSCALLWRAP_MODE=child, drops the child to the agent uid, and hands the seccomp notify fd back over a socketpair. The parent then runsagentgate.Serverin-container on that fd.internal/dockerproxy/— application-level Docker HTTP proxy.loop dockerproxylistens on/var/run/docker.sock(tmpfs inside the container) and reverse-proxies to/var/run/docker.sock.host(the real daemon, bind-mounted read-only). Every byte is reparsed as HTTP, matched againstHTTPServiceRuleandBodyRule.
Both layers authenticate HTTP callbacks with the same per-container bearer token (the gate token, 32 random bytes hex-encoded, copied into the container as /run/loop/gate-token, root-owned and mode 0400, so the agent can’t read it and docker inspect doesn’t show it) and target the same endpoint: POST /api/gate/container-approval. MultiManagerResolver.ByToken does a constant-time lookup that yields the per-container Manager and the channel id to prompt on.
Both layers feed into the same agentgate.Manager for approve decisions, so approval prompts, caching, and rate limits are unified.
Components
| Package | Role |
|---|---|
internal/agentgate/bpf.go | Hand-assembled BPF: arch check (x86_64 + aarch64, kill-on-mismatch), 12 trap syscalls (connect, execve, execveat, openat, openat2, renameat2, unlinkat, linkat, symlinkat, fchmodat, fchownat, mkdirat), 3 deny syscalls (io_uring_setup / _enter / _register → EPERM); on x86_64, every x32-ABI syscall number (bit 30 set) → ENOSYS, since those report the native arch but match no native trap number. make prove-seccomp proves the emitted program against this spec (see Verifying the filter
) |
internal/agentgate/server.go | Seccomp notify loop; dispatches by syscall nr to connect / execve / file handlers. Runs in-container inside the loop syscallwrap parent process |
internal/agentgate/connect.go | Parses AF_UNIX sockaddr; abstract sockets rendered as @name |
internal/agentgate/execve.go | Reads argv via ProcessVMReadv; transparent-wrapper unwrap (env, sudo, nice, ionice, chrt, timeout, nohup, unshare, setsid, taskset, stdbuf, ld-linux*), each parsed with its own option grammar; execveat+AT_EMPTY_PATH resolves fd and denies memfd: unconditionally |
internal/agentgate/file.go, file_syscalls.go | Per-syscall arg-layout dispatcher; dirfd resolution via /proc/<pid>/fd/<N>; AT_FDCWD via /proc/<pid>/cwd; mandatory filepath.EvalSymlinks; FIFO decision cache (1024 entries) |
internal/agentgate/handshake.go | ReceiveHandshake / SendAck — [4-byte BE length][channel-id][SCM_RIGHTS fd]. Shared code path for the in-container socketpair handshake |
internal/agentgate/policy.go | PathRule / CommandRule / FileRule compilation: doublestar for paths, regexp for argv, filepath.Match for command basenames |
internal/agentgate/approval.go | Manager with session cache (populated only on session / deny-session), checkLimits for pending / per-minute / total rate caps, Resolve(reqID, decision, actorID) entrypoint, Shutdown() that resolves every in-flight request with deny/actor=shutdown so gate.approval_resolved
fans out on teardown, and ListPending() returning a snapshot of in-flight requests for rehydration |
internal/agentgate/multi_resolver.go | MultiManagerResolver: click routing by reqID, plus per-container bearer-token routing (AddWithToken / ByToken) for the in-container HTTP approval hop. ByToken compares in constant time via crypto/subtle. Remove(containerID) invokes the Manager’s Shutdown() before unregistering so stale containers can’t leave the FE card/dock-bouncer stuck. ListPending() aggregates across all live Managers and is what backs GET /api/gate/approvals |
internal/syscallwrap/app.go, parent.go, child.go | In-container gate wrapper — the body of the loop syscallwrap subcommand. app.go dispatches parent vs child on LOOP_SYSCALLWRAP_MODE. Parent: load policy JSON → socketpair → re-exec /proc/self/exe with ExtraFiles=[child-end], Credential={agent uid,gid}, Pdeathsig=SIGKILL → receive SCM_RIGHTS handshake → build agentgate.Server → run until child exits. Child (agent user): LockOSThread → install filter via SECCOMP_SET_MODE_FILTER | NEW_LISTENER | TSYNC → send notify fd over fd 3 → read ack → syscall.Exec target |
internal/dockerproxy/app.go | In-container docker-proxy binary — the body of the loop dockerproxy subcommand. Loads the policy JSON, builds an httpapprover.Approver, listens on /var/run/docker.sock (tmpfs), runs dockerproxy.Server until SIGTERM |
internal/httpapprover/approver.go | Shared HTTP-backed Approver used by both the in-container docker proxy and the seccomp-gate parent. POSTs {kind, target, message, cache_key, burst_key} to {LOOP_API_URL}/api/gate/container-approval with Authorization: Bearer <gate token> (read from /run/loop/gate-token); fail-closed on any transport or non-200 error |
internal/container/image/entrypoint.sh | Branches on $LOOP_DOCKERPROXY_ENABLED=1 (starts loop dockerproxy as root before dropping privileges) and $LOOP_GATE_ENABLED=1 (execs loop syscallwrap -- "$@" as root, which drops its child to $AGENT_USER). When neither is set, falls back to plain gosu "$AGENT_USER" "$@" |
internal/dockerproxy/fold.go | Case-insensitive key lookup and the ambiguous-key check shared by body rules, approval details and the socket rewrite |
internal/dockerproxy/nested.go | Rewrites docker socket mounts in container creates to the nested proxy socket; looks up the nested volume’s name |
internal/dockerproxy/server.go | HTTP handler + reverse proxy to the upstream socket (/var/run/docker.sock.host in production); hijack on POST /containers/*/attach and POST /exec/*/start; FlushInterval: 100ms for streaming; strips the API version prefix before rule match the way the daemon’s router reads it (/v and any run of digits and dots, so /v1.41.0/... too) |
internal/dockerproxy/policy.go | HTTPServiceRule compile + MatchHTTP (first-match + default); CheckBody |
internal/dockerproxy/bodyrule.go | JSONPath-lite evaluator: present, empty_array, equals, contains_any, starts_with_any, not_in, capability_not_in (normalises names as the daemon does), source_path_in (with an optional except list), source_path_not_in (with an optional read_only_values list, which counts as inside only for read-only binds; on a whole mount object it reads Source and ReadOnly), path_in (like source_path_in, but never fires on a path it can’t resolve). The source ops resolve symlinks and clean .. first; only HostConfig.Binds[*]-style strings are split on :, so a long-form Source or a device option is read whole. Keys match case-insensitively with the daemon’s Unicode folding (ſ matches s), and a body naming one key twice, or two keys that fold to one, is rejected. bind_alias_spec_test.go checks every spelling of a forbidden source (extra slashes, ./.., symlinks to it or a parent) is refused; FuzzDecodeBodyMatchesDaemon checks the fields the rules read against the daemon’s own decoding of a create body. |
internal/orchestrator/gate_adapter.go | Maps agentgate.ApprovalRequest to bot.ApprovalPrompt; same adapter for all three platforms |
internal/api/gate_handler.go | Three handlers: GET /api/gate/approvals (snapshot of every Manager’s pending requests — used by the renderer on WS reconnect to reconcile its local map and the electron dock-bouncer), POST /api/gate/approvals/{id} (bot-click resolve — 204 / 400 / 404), and POST /api/gate/container-approval (in-container call-in — bearer-token auth → ByToken → mgr.Request → {decision, actor, reason}, 200 / 401 / 503) |
internal/types/gate.go | Shared rule types (Decision, PathRule, CommandRule, FileRule, HTTPServiceRule, BodyRule, JSONCheck, RateLimits, AuditConfig) — leaf package to avoid config↔agentgate cycles |
internal/config/defaults_gate.go | Baseline rule factories: DefaultGatePathRules / CommandRules / FileRules / DefaultDockerProxyHTTPRules / BodyRules — each returns a fresh slice per call |
Verifying the filter
make prove-seccomp checks the seccomp program buildFilter emits, not a model of it. TestSeccompProofSuite (internal/agentgate/bpf_proof_test.go) renders the program as one SMT-LIB bit-vector term, the verdict for a symbolic syscall number and audit arch, and has the z3
SMT solver look for any input that breaks each property below. Finding none proves it for all 2^32 syscall numbers. The target runs the suite as amd64 and as arm64 (the other arch under emulation), in the test-runner image, which ships z3; CI runs it in the prove-seccomp job. Plain go test skips the suite when z3 isn’t installed.
| Property | Claim |
|---|---|
| P1 | A wrong audit arch always ends in KILL_PROCESS |
| P2 | On the native arch, every number gets exactly its trap / deny / allow verdict (x32 numbers ENOSYS on x86_64) |
| P2' | x86_64: no x32-ABI number is allowed |
| P3 | No number is both trapped and denied |
| P4 | x86_64: every number the kernel dispatches to a trapped syscall is stopped, with the x32 ABI off and on |
TestFilterPropertiesCanFail checks that the proof can fail: a filter without the x32 guard, or with a trap entry dropped, is refuted. The trusted base is the cBPF semantics in smtVerdict, the property statements, z3 and the x32 dispatch table in x32Numbers. P4 checks the filter against the gate’s own trap list; it doesn’t claim that list covers every way to reach a file or socket.
Default policy
All defaults ship from code (internal/config/defaults_gate.go), not from an example JSON. The example templates at internal/config/config.*.example.json document the surface but are commented out so new installs pick up the baseline automatically.
Decision semantics:
deny— hard reject, no prompt. Agent seesEACCES/ 403.approve— emits an approval prompt; cached perCacheKeyonce the operator picks Allow for session.allow— silent pass-through.
The default posture is default-allow with targeted exceptions: the agent can work freely in its workspace, read standard system paths, and talk to the Docker daemon; only credential-adjacent writes, container-escape shapes, and specific lateral-movement operations block or prompt. Fall-through hits default_decision, which is allow for both agentgate and docker_proxy.
Connect (PathRule, 2 rules, first-match-wins)
Covers connect(2) on AF_UNIX paths. A rule names a socket, not a spelling: the requested path and each rule’s pattern are resolved in the agent’s view (cwd, ., .., duplicate slashes, symlinks) before comparing, so /run/docker.sock.host (via the /var/run → /run link) or a symlink in /tmp still hits rule 1. A relative path whose cwd can’t be read is denied.
| # | Pattern | Decision | Why |
|---|---|---|---|
| 1 | /var/run/docker.sock.host | deny | The real host daemon socket, bind-mounted here so only the in-container Docker proxy can dial it. Agents must go through the proxied /var/run/docker.sock |
| 2 | /var/run/docker.sock | allow | Proxied socket — every HTTP request is re-gated at the Docker-HTTP layer below, so a prompt here would be duplicate noise |
Exec (CommandRule, 1 static rule + 1 dynamic workspace rule, first-match-wins)
Seccomp catches execve / execveat. The command is the file the kernel runs, not argv[0], which the caller picks (exec -a ls git push is git push); the name it’s run by still counts, so a link or a copy of git named ls matches as ls. An exec file rule
matches the resolved path instead. Transparent wrappers (env, sudo, nice, timeout, …) are unwrapped so the rule matches the real target. Each wrapper is read with its real option grammar — option values (sudo -u root, nice -n 5, env -u X) and operands (timeout’s DURATION, taskset’s MASK, chrt’s PRIORITY) are skipped, so timeout 5 git push matches a git push rule. When the command can’t be named for sure — an unknown or malformed option, a mode that doesn’t run it as given (sudo -s/-i/-e/-l, taskset -p, --help), or an env -S string that uses quotes, escapes or ${VAR} — the wrapper itself stays the command. That never hides one: every wrapper but the loader execs its command, which traps again on its own. script isn’t unwrapped; it runs $SHELL -c CMD, which traps with the shell named. internal/agentgate/execve_unwrap_linux_test.go checks the grammar against the installed wrapper binaries. FuzzUnwrapCommandMatchesExec (internal/agentgate/execve_unwrap_diff_fuzz_test.go) checks that the command the gate judges is the one the wrapper actually execs: it builds argvs from the wrappers’ options (attached and separate values, clustered flags, long-option prefixes, --, NAME=VALUE, env -S strings, chained wrappers, the loader), runs them through the real binaries with recording probes on PATH, and compares each recorded exec with what unwrapCommand named one step earlier.
| # | Command | Args regex | Decision | Why |
|---|---|---|---|---|
| 0 | (injected) rm | every positional arg must lie under {workDir} (or {parentDirPath}) — flag-only prefix ^(-[a-zA-Z]+\s+)* followed by an alternation of workspace path prefixes | allow | Workspace fast-path. Inserted per-container by injectWorkspaceRmRfRule (internal/container/runner.go:1583). Agents routinely rm -rf build/ or rm -rf dist/ inside their own tree; without this carve-out rule #1 would block legitimate cleanup. The pattern is all-args-or-nothing — a mixed rm -rf /workspace/build /etc/passwd falls through to rule #1 and is denied. Omitted when workDir is empty (ad-hoc one-shot runs). |
| 1 | rm | -[a-zA-Z]*r[fF]?.* /.* | deny | rm -rf on an absolute path outside the workspace — blast-radius block. Overridable: a trusted project’s command rules go before it. The /tmp/ allow is injected before this for the same reason as the workspace carve-out (test cleanup like rm -rf /tmp/testgit). |
Git write-side operations (push, commit, reset --hard, …) are intentionally not gated by default; add an approve rule if you want prompts.
Example — gate git commit and git push. Drop this into ~/.loop/config.json for a global rule that applies to every project, or into {project}/.loop/config.json for just one repo — the schema is identical. Project rules go after the global deny rules and before the other global rules (first-match-wins), so a project can’t loosen a deny you wrote; once trusted, they also go before the overridable default denies (see Project config merge
).
{
"gates": {
"agentgate": {
"command_rules": [
{
"commands": ["git"],
"args_patterns": ["^(commit|push)(\\s|$)"],
"decision": "approve",
"message": "git commit/push (approval required)"
}
]
}
}
}The args regex runs against strings.Join(argv[1:], " "), so \s covers the space before the next arg and embedded newlines in multi-line commit messages; the $ alternation handles bare git push with no trailing args.
File ops (FileRule, 9 static rules, first-match-wins, plus three injected rules)
Order matters: the pinned policy self-deny, then the pinned project-config approve, then the static denies, then the injected workspace rule, then the tmp / system-read fast-paths.
| # | Paths | Operations | Decision | Why |
|---|---|---|---|---|
| 1 | (injected) /etc/loop/** | write, create, delete, chmod, chown, link | deny | The gate’s own policy file, pinned ahead of everything else by injectPolicySelfDenyRule. Rule 8 already covers /etc/**, but rules that arrive through config can be shadowed: a non-empty global list replaces the baseline, project rules sit ahead of every global allow and approve, and the project layer is {workDir}/.loop/config.json — inside the workspace the agent may write. Injecting after the merge is the one position no config can precede. link is in the op list (unlike rule 8) because linkat/symlinkat match on the new path only: without it the agent could hardlink the policy into the blanket-allowed workspace and write through the second name. Reads stay allowed — seeing the active policy helps debug a denial |
| 1a | (injected) {workDir}/.loop, .loop/config.json, .loop/container, .loop/container/**; same under {parentDirPath} | write, create, delete, chmod, chown, link | approve | Project config. Loop builds the next container from these (mounts, copy_files, gates, image), so an unreviewed write would outlast the session. Pinned after the merge by injectProjectConfigRule, like rule 1. The .loop directory itself is listed so it can’t be swapped by rename; the cost is that GNU mkdir -p .loop/<sub> asks too, since mkdirat is checked before the kernel returns EEXIST. Containers the agent starts get the directories read-only (see Project config stays read-only to nested containers
) |
| 2 | /proc/*/mem, /proc/kcore | read | deny | Kernel / process-memory exfiltration. /proc/*/environ is intentionally NOT denied — Go test binaries, runtime probes, and tooling open it routinely (chronic noise) and the gate parent’s env carries no exploitable secret (the notify fd is passed via SCM_RIGHTS, not authenticated by an env-readable token) |
| 3 | /etc/shadow, /etc/gshadow, /etc/sudoers, /etc/sudoers.d/**, /etc/ssh/ssh_host_*_key, .pub variants | all ops | deny | Root credential files |
| 4 | **/.ssh/**, **/.aws/**, **/.gcp/**, **/.config/gcloud/**, **/.kube/**, **/.netrc, **/.pgpass | read, write, create, delete, chmod | deny | User credential directories — apply to any path, including inside the workspace. Overridable: a trusted project’s rules go before it, so a project that runs aws or kubectl can allow **/.aws/** / **/.kube/** |
| 5 | .docker/config.json and .npmrc under /root/, /home/*/, /Users/*/ | write, create, delete, chmod | deny | Overridable, like rule 4. Registry/proxy credential files — reads stay allowed because the docker CLI and npm read them on every invocation (missing file would surface as a confusing EPERM warning). Scoped to real home-dir layouts rather than **/: a filename-anywhere glob caught nodeenv’s bundled .npmrc template inside ~/.cache/pre-commit/, breaking pre-commit hook installs |
| 6 | **/.claude/settings.json, settings.local.json | write, create, delete, chmod | deny | Claude harness settings. The rule is narrow on purpose — CLAUDE.md, mcp*.json, plugins/**, and the rest of ~/.claude are tree the agent legitimately writes (memory updates, per-project MCP configs, plugins state, ephemeral harness session/todos/snapshot dirs) |
| 7 | /root/.bashrc (and .bash_profile, .zshrc, .zprofile, .profile, .bash_login, .inputrc); same set under /home/*/ and /Users/*/ | write, create, delete, chmod | deny | Shell rcfile write — persistence vector. Scoped to real home-dir layouts (root, Linux /home/<user>, macOS host-home bind-mount /Users/<user>) so test fixtures writing a .bashrc inside a t.TempDir() don’t trip |
| 8 | /etc/**, /usr/**, /bin/**, /sbin/**, /lib/**, /lib64/**, /boot/** | write, create, delete, chmod, chown | deny | System paths — writes would mutate the container image outside the workspace |
| 9 | (injected) {workDir}/**, {parentDirPath}/** | all ops | allow | Workspace fast-path. Inserted per-container by writeGatePolicyFile using the real host bind-mount path for that channel / thread. Positioned after all Deny rules (and after a trusted project’s rules and the overridable denies behind them) so cred-path denies still win inside the workspace |
| 10 | /tmp/**, /var/tmp/** | all ops | allow | OS tmp fast-path |
| 11 | /proc/**, /sys/**, /dev/null, /dev/zero, /dev/urandom, /dev/random, /dev/tty, /dev/pts/** | read, stat, list | allow | System reads fast-path — reads are silent, writes to these paths fall through to default_decision |
Anything that doesn’t match falls through to gates.agentgate.default_decision ("allow" by default).
A rule names a file, not a spelling: the path is resolved in the agent’s view (cwd or dirfd, ., .., duplicate slashes, symlinks) before matching. A file that doesn’t exist yet resolves to where the create puts it, so /tmp/s/rc with /tmp/s linked to ~/.ssh, or a dangling link to ~/.ssh/rc, still hits rule 4. file_alias_spec_test.go checks every spelling against rule 4’s operations. Patterns are matched as written, so write them in resolved form: under a /var/run → /run link, a rule on /var/run/x matches nothing.
Exec file rules
A file rule whose operations list exec also covers the file an execve / execveat runs, matched on its absolute, symlink-resolved path: a relative name resolves against the cwd, or the dirfd for execveat, since the kernel doesn’t search $PATH. Only rules that list exec count. A rule with no operations doesn’t, and neither does default_decision, so a policy without one runs as before. A deny refuses the exec whatever the command rules say; allow and approve leave it to them. None of the defaults use it; plugin containers
deny running files from the dirs they can write.
{"gates": {"agentgate": {"file_rules": [
{"paths": ["/tmp/**"], "operations": ["exec"], "decision": "deny"}
]}}}Approve rules show the diff of a rename
Claude Code’s Edit and Write tools, sed -i and most editors write a temp file next to the target (config.json.tmp.<pid>.<hex>) and rename it over the target. When an approve file rule covers a rename’s target (rule 1a for .loop/config.json among them, and any approve rule of yours), the gate reads the new content and asks with a unified diff of the current file against it, plus the rename’s source. On allow it writes the exact bytes it showed, as the agent’s uid and gid, and removes the source while it still holds them, the way the git guard
installs a config change; the agent’s call returns as if its own rename succeeded. The source’s check is part of that card, so a rename whose source an approve rule also covers asks once; a rule that denies the source still wins.
Allow for session covers the same bytes landing on the same path again, not later writes there.
The card is the rule’s usual one, with the path and the rule’s message only, when the gate can’t show the content: the source isn’t a regular file (a directory or a symlink), either file is over 32 KiB or isn’t plain text (see the git guard), or the rename is a RENAME_EXCHANGE or RENAME_WHITEOUT. A write in place (echo > .loop/config.json) has no content for the gate to read either. Renames onto git paths stay with the git guard.
Editing a file under .loop/container/ asks twice: rule 1a covers the whole directory, so creating the temp file there asks too, with the path only.
Git guard
A repo’s config and hooks make git run programs: core.fsmonitor, core.sshCommand, filter drivers, aliases, a pre-commit hook. In a repo on a host mount, those run the next time you use git there, outside the container. So the gate guards the files git reads them from, under every writable host bind (git_guard_roots in the gate policy, from gitGuardRoots). Read-only binds, named volumes and scratch repos in the container (/tmp) don’t reach your git and aren’t guarded. No rule, from any config layer, can allow past the guard; a deny rule on a guarded path still wins, since a rename reaches the guard only once the file rules allow it.
Guarded paths, at any depth under a root:
- the
.gitentry itself (a git dir, or agitdir:file pointing elsewhere); .git/configand.git/config.worktree;.git/commondir;.git/hooksand everything under it, except the*.samplefiles;- the same inside submodule and linked-worktree git dirs (
.git/modules/…,.git/worktrees/…); - a
HEADoutside any.gitdir. Next toobjects/andrefs/, it makes its dir a git dir that git uses when run there, and that dir’sconfigandhooksaren’t named.git. Git writes one there only when it creates a bare repo or a--separate-git-dir.
Components match case-insensitively, with Unicode folding, because the host filesystem behind a mount often is case-insensitive.
Writes the gate can’t read are refused, git dir pointers aside (below). An open that creates or writes, truncate, link, symlink and mknod on a guarded path get EPERM, with no approval card: the card couldn’t show what would be written. So does a hardlink from a guarded file, which would give the agent a second, unguarded name to write through. Reads, deletes, chmod, chown and mkdir go on to the file rules as usual. Hook installers (pre-commit install, git lfs install) write hooks this way, so they fail in the container; run them on the host.
Pointers ask with the command line. git worktree add, submodule checkouts and git init --separate-git-dir write the .git file and commondir with a plain open, not through a lock file. Once the file rules allow such a write (a deny rule on the path still wins), it asks instead of being refused. The card can’t show the content, since the gate never sees the bytes: it shows the path and the writing process, its executable and its command line (arguments with anything but letters, digits and _@%+=:,./- are quoted, escapes and invisible characters included, and the line is capped at 1 KiB). Allow only a git command you expect. The decision covers that one process (pid and start time), so git worktree add, which writes both files, asks once; a submodule checkout runs two processes and asks twice. mknod, link and symlink of a pointer stay refused.
Renames show the content. Git writes config and hooks through a lock file: config.lock, renamed over config. So a rename onto a guarded path is where the content appears. The gate:
- refuses
RENAME_EXCHANGE(either side guarded) andRENAME_WHITEOUT; - reads the source as the agent’s uid and gid (not as root), refusing it if it isn’t a regular file, is over 32 KiB, or isn’t plain text. Plain text means valid UTF-8 with no control characters but tab and newline, and no invisible format characters such as bidi overrides that could make the card show something other than what git reads;
- installs a config change silently when every entry it adds or changes is one of the keys below. Anything else shows an approval card with a unified diff of the current file against the new one;
- on allow, writes the exact bytes it read to a temp file next to the target and renames that into place, still as the agent’s uid and gid. It then removes the rename’s source, but only if that name still holds the bytes it read; a source rewritten after the decision stays. The check compares content, not inode numbers, because Docker Desktop’s file sharing renumbers inodes between lookups. The agent’s call returns as if its own rename succeeded.
Because the gate writes the file itself, the agent can’t swap the content between your click and the rename.
Whole trees can’t be moved in or out. A rename into a .git dir onto a path that isn’t guarded itself (a ref, the index, an object) goes on to the file rules as usual, if its source is a regular file. So does a rename out of a .git dir, or of a .git entry that is a gitdir: file. A directory or symlink source is refused, and so is RENAME_EXCHANGE with either side inside a .git dir. Otherwise the agent could build a git dir elsewhere, with its own hooks and config, and move it in whole as .git/modules/<name> or .git/worktrees/<name>; or move a git dir out under a name the guard doesn’t know (mv .git gd), then rewrite its config, unguarded, and point core.worktree back at the repo. Git itself doesn’t move directories in or out of there, except git submodule absorbgitdirs, which fails in the container.
gitguard_git_spec_test.go checks the guard against real git: after any script of writes, moves and deletes the guard lets through without asking, git status, diff, log -p, add, commit and checkout in the repo, its worktree, its submodule and every touched dir must never run a probe program. FuzzGitGuardMatchesGit generates the scripts, seeded with one known route to each hook, driver, pointer and bare-repo trick; a guard that allows everything must fail on every seed.
Keys installed without asking, which is what git init, clone, remote add, fetch, branch --set-upstream-to, push -u, sparse-checkout, worktree and git config user.* write:
| Section | Keys |
|---|---|
core | repositoryformatversion, filemode, bare, logallrefupdates, ignorecase, precomposeunicode, symlinks, sparsecheckout, sparsecheckoutcone; worktree only in a submodule’s git dir (see below) |
index | sparse |
extensions | objectformat, worktreeconfig |
user | name, email |
remote "<name>" | url, pushurl (only https, http, ssh, git URLs and user@host:path; local paths, file:// and <helper>:: URLs ask), fetch |
branch "<name>" | remote, merge |
submodule "<name>" | url (same URL check), active |
init | defaultbranch |
push | default, autosetupremote |
pull | rebase |
A submodule checkout sets core.worktree in .git/modules/<name>/config to the submodule’s checkout, relative to that dir. It goes through when the value is relative and lands under the same guard root, outside every .git dir; it only moves the files git works on. Anywhere else, or with any other value, it asks.
Both the old and the new file must parse under a strict subset of git’s syntax: [section] or [section "sub"] headers on their own line, and key = value lines whose value has no quotes, backslashes, ; or #. A file outside it, for example one with a continuation line or an escape, always asks, so the guard can never read a file differently from git. Removed entries don’t count against a change, since removing one never makes git run anything.
The card’s cache key includes a hash of the content: Allow for session covers the same bytes landing on the same path again, not any later write there. Discord and Slack show the diff as a code block, or tell you to review it in the desktop app when it doesn’t fit in a message.
What the guard doesn’t cover:
fchmodon an already-open descriptor. A hook installed through the guard keeps the source’s mode; making a non-executable hook executable later goes throughchmod(2), which the file rules see, butfchmod(2)on an open fd isn’t trapped.- Bare repos and git dirs not named
.gitthat already exist (git clone --bare x.git, the target of--separate-git-dir): theirconfigandhooksare plain files. Making a new one goes through the guard, since it needs aHEAD. - The content of a pointer written in place: the card shows which process writes it, not what it writes.
- A
core.hooksPaththat points into the worktree (husky, lefthook). Setting it asks, but the hook files it points at are ordinary workspace files. - The multi-threaded path race below: a sibling thread can change a path between the gate’s read and the kernel’s. The gate’s own install isn’t affected, since it opens its target directories with
RESOLVE_NO_SYMLINKSand writes only bytes it already read.
Docker HTTP (HTTPServiceRule, 6 rules + default allow)
The default posture is allow. The body rules below are the real container-escape guardrails — given that, the agent can run Docker freely (builds, tests, docker run, docker logs, attach, wait, …) without per-call prompts. This table only enumerates the exceptions:
| # | Methods | Paths | Decision | Why |
|---|---|---|---|---|
| 1 | POST | ^/containers/[^/]+/exec$, ^/exec/[^/]+/start$ | approve | Lateral movement — exec / attach-start into an arbitrary container. The agent owns the containers it creates, but the proxy cannot distinguish “my lint container” from “the user’s local postgres” given only a container id |
| 2 | PUT, GET, HEAD | ^/containers/[^/]+/archive$ | approve | docker cp into / out of a container — same lateral-movement concern as exec |
| 3 | POST, GET | ^/containers/[^/]+/attach$, ^/containers/[^/]+/attach/ws$ | approve | Attach reads another container’s output and writes its stdin. Containers the proxy itself created are exempt (tracked by the ID and name each create returns), so docker run and compose up don’t ask |
| 4 | GET | ^/containers/[^/]+/export$ | approve | Exports a container’s whole filesystem |
| 5 | POST | ^/commit$ | approve | Snapshots a container, secrets in its filesystem included, into an image the agent can run |
| 6 | * | ^/swarm/, ^/nodes/, ^/secrets/, ^/configs/, ^/plugins/ | deny | Swarm / secrets / plugin APIs — off-limits surfaces with no legitimate dev-loop use |
All other Docker API calls fall through to allow: _ping, version, info, container create / start / stop / kill / restart / pause / wait, attach to containers the agent created, image create / build / push / pull / rm, volume / network CRUD, events, logs, inspect, stats, top, and so on.
Docker body (BodyRule, 5 static rules + 1 injected — the container-escape guardrails)
Body rules are evaluated independently of the HTTP rule match and support all three decisions:
deny— returns403 Forbiddenwith no prompt; always wins over theHTTPServiceRuledecision.approve— blocks the request and prompts the operator (same approval flow as an HTTP-rule approve), keyed ondocker:<METHOD>:body:<rule_id>so “Allow for session” caches per body rule rather than per request path.allow— passes through silently to the upstream daemon (still subject to theHTTPServiceRuledecision falling through normally).
JSON paths match keys case-insensitively, as the daemon’s decoder does: it creates a privileged container from {"hostconfig":{"privileged":true}}, so an exact-case match would let that through. A body holding two keys that differ only in case — where a rule path, the approval summary or the socket rewrite reads that key — is rejected with 400, since the daemon keeps one of them depending on key order. Free-form maps such as Labels may still hold foo and FOO.
A JSON body larger than the matching rules’ MaxBodyBytes (1 MiB default) is rejected with 413: the rules can’t inspect it, and padding (a big label) would otherwise carry any field past them. Other content types (build contexts) and endpoints without body rules are forwarded whole.
POST /containers/create — denies any of:
| JSON path | Op | Values | What it blocks |
|---|---|---|---|
HostConfig.Binds[*] | source_path_in | ^/$, ^/etc(/|$), ^/root, ^/home, ^/boot, ^/usr, ^/lib(64)?, ^/proc, ^/sys, ^/dev, ^(/var)?/run/docker\.sock$, ^/run/loop/, ^/host_mnt, ^(/var)?/run/host-services | Host bind-mount of sensitive paths (escape via file write). The last two are Docker Desktop VM paths: every shared host folder, and the VM’s host-service sockets (the daemon’s among them) |
HostConfig.Mounts[*].Source | source_path_in | same set as above | Same, via the newer long-form Mounts API used by docker-compose v2 and docker run --mount |
HostConfig.Privileged | equals | true | Privileged container |
HostConfig.PidMode | equals | host | Host PID namespace (visibility into host processes) |
HostConfig.NetworkMode | equals | host | Host network namespace |
HostConfig.IpcMode | equals | host | Host IPC namespace |
HostConfig.UsernsMode | equals | host | Disable user-namespace remapping |
HostConfig.CgroupnsMode | equals | host | Host cgroup namespace |
HostConfig.UTSMode | equals | host | Host UTS namespace |
HostConfig.CgroupParent | present | — | Placing the container in an arbitrary cgroup |
HostConfig.Runtime | not_in | "", runc, io.containerd.runc.v2 | Picking another runtime the daemon offers, which may isolate less |
HostConfig.CapAdd[*] | capability_not_in | Docker’s default caps plus SYS_NICE, IPC_LOCK | Capability escalation. Names are normalised as the daemon does (cap_sys_admin, Sys_Admin → SYS_ADMIN), and ALL is outside the list |
HostConfig.SecurityOpt[*] | contains_any | apparmor=unconfined, seccomp=unconfined, :unconfined variants, systempaths=unconfined | Dropping the container sandbox |
HostConfig.Devices[*] | present | — | Device passthrough |
HostConfig.DeviceCgroupRules[*] | present | — | Cgroup device allowlist bypass |
HostConfig.VolumesFrom[*] | present | — | Inherit another container’s mounts |
HostConfig.MaskedPaths | empty_array, present | — | Any value replaces the default list: [] or a shorter list un-masks kernel files (/proc/kcore etc.) |
HostConfig.ReadonlyPaths | empty_array, present | — | Any value replaces the default list: [] or a shorter list makes kernel paths writable |
POST /containers/create — asks for approval when the container joins another container’s PID, IPC, network or UTS namespace (HostConfig.PidMode / IpcMode / NetworkMode / UTSMode starting with container:). Like exec, that reaches into the other container: as root with the same caps, the new container can read /proc/<pid>/root of the target’s processes, or reach its localhost-only services, and the proxy can’t tell the agent’s own containers from others. Compose’s network_mode: service:… asks too.
Device-backed volumes — ask for approval. A local volume with a device option mounts that path or disk from the daemon’s side (docker volume create --opt type=none --opt o=bind --opt device=/etc), which the bind-source deny list never sees since named-volume sources aren’t host paths. tmpfs and NFS volumes use the option too, so it’s an approval rather than a deny; the prompt shows the options.
| Endpoint | JSON path | Op |
|---|---|---|
POST /volumes/create | DriverOpts.device | present |
POST /containers/create | HostConfig.Mounts[*].VolumeOptions.DriverConfig.Options.device | present |
A create that mounts an existing volume by name (-v name:/x, --mount type=volume,src=name) asks too when that volume has a device option: the proxy looks the volume up on the daemon before forwarding.
Hard denies ahead of every rule — no approval, no agent-mount exemption:
- Loop’s own dirs.
writeProxyPolicyFileprepends deny rules (path_in) for binds, mount sources and volumedeviceoptions at or under<user config dir>/loop(the owner token) and~/.loop/run(policy files, audit logs), matched case-insensitively and through Docker Desktop’s/host_mntand/run/desktop/mnt/hostprefixes. The runner likewise never mounts those dirs, anything under them or an ancestor (such as$HOME) into an agent container, whether frommounts,extra_dirsor the workspace itself. - Other channels’ containers. Any per-container request (
/containers/{id}/…,POST /commit?container=) is checked against the target’sloop-channellabel; another channel’s agent container is refused. If the daemon can’t say whose container it is, the request is refused too.
“Allow for session” on a per-container action covers that container only: the session cache key keeps the container ID.
POST /containers/{id}/update — denies:
| JSON path | Op | Values |
|---|---|---|
Privileged | equals | true |
CapAdd[*] | capability_not_in | same list as create |
Bind allowlist (injected per container) — writeProxyPolicyFile confines the binds of containers the agent creates to the agent’s own mounts: the host paths it already has at the same path, read-write (workspace, worktree parent, extra_dirs, the playground, same-path mounts) or read-only (same-path :ro mounts such as ~/.gitconfig or ~/.ssh). Named volumes and the docker socket are left out. Two edits, both by injectBindAllowlist:
- An approve rule is appended last: a host-path source in
HostConfig.Binds[*]orHostConfig.Mounts[*]that resolves outside the read-write paths (source_path_not_in) asks first —/tmp, a sibling project, your home folder — unless the bind is read-only and inside a read-only path (read_only_values). So a dev container that mounts~/.gitconfig:rodoesn’t ask, while a read-write bind of it does. The deny list alone can’t cover this on Docker Desktop, where the daemon resolves binds on the host’s shared folders (/Users,/Volumes,/private,/tmp):-v /Users/<you>:/hhands the container your whole home directory. - In deny rules, a
source_path_invalue that would block one of those paths outright (^/home(/|$)for a workspace under/homeon Linux) is split into its own check with the paths (read-write and read-only) asexcept, so it stops firing for sources that resolve inside them. Narrower values — say a rule of yours for^<workspace>/secrets— keep firing.
Body rules are first-match per request, so the allowlist can’t be an allow rule: an earlier version prepended one for the workspace, and a single workspace bind then skipped every deny for the request’s other fields (--privileged -v $PWD:/w -v /:/host).
The checks resolve symlinks in the agent’s view, which matches the daemon’s for same-path mounts. A nested container writing to the workspace isn’t subject to the agent’s file-op rules. The race between this check and the daemon’s mount is closed by pinning (below).
On Docker Desktop the daemon reports bind sources as its VM sees them, under /host_mnt. Tools that look up the host path of their working directory by inspecting their own container, such as pre-commit’s docker_image hooks (gitleaks, hadolint, shellcheck), then ask for binds like /host_mnt/Users/<you>/project:/src. That path doesn’t exist in the agent container, so the checks couldn’t resolve it and the ^/host_mnt deny fired. Before the rules run, the proxy maps a /host_mnt source under one of the agent’s read-write mounts (as mounted, or its host path with symlinks resolved) back to the path the agent sees. The allowlist, the read-only .loop overlays and pinning then treat it like the same bind written that way. Every other /host_mnt source still hits the deny.
Together these body rules are the reason the HTTP layer can afford to default-allow: no amount of docker run or docker create can produce a privileged container, a host-namespace container, or a host-bind-mounted container.
Nested docker socket
Containers the agent starts can have the docker socket mounted (docker run -v /var/run/docker.sock:/var/run/docker.sock, testcontainers, CI-style coverage runners). They get the proxy, not the daemon: the daemon resolves bind sources on its own filesystem, where /var/run/docker.sock is the real engine socket, so such a bind would hand the nested container unfiltered daemon access. Instead:
- The runner gives the agent container an anonymous volume at
/run/loop-dproxy(LOOP_DOCKERPROXY_NESTED_DIR). The volume is removed with the container. - At startup,
loop dockerproxyinspects its own container over the upstream socket to learn the volume’s name, and listens on a second socket,/run/loop-dproxy/docker.sock, served by the same policy and approver. - Before the body rules run,
POST /containers/createbodies are rewritten: everyHostConfig.Bindsentry and bind-typeHostConfig.Mountsentry whose source is/var/run/docker.sockor/run/docker.sock(literally or through a symlink) becomes a volume mount of that volume withVolumeOptions.Subpath: docker.sock, keeping the target and read-only flag. Keys match case-insensitively, like the body rules.
The rewrite fails closed:
| Condition | Response |
|---|---|
| Nested socket unavailable (volume lookup or listen failed at startup) | 403; plain container creates are unaffected |
Client pins Docker API < 1.45 (Subpath needs 1.45+) | 400 |
Keys that differ only in case (HostConfig and hostconfig) — the daemon keeps one depending on key order | 400 |
| Body over 1 MiB | 413 |
Every rejection is audited with rule id create-body.
Project config stays read-only to nested containers
Loop builds each agent container from the workspace’s .loop/config.json (mounts, copy_files, extra_dirs, gates, container_image) and .loop/container/Dockerfile, and both sit in the workspace the agent can write. The agent itself needs an approval to change them (file-op rule 1a
), but the seccomp gate doesn’t see into containers it starts, and the bind allowlist lets those bind the workspace read-write. So the same POST /containers/create rewrite keeps the .loop directories (the workspace’s, and the worktree parent’s) read-only there, passed in as LOOP_DOCKERPROXY_READONLY_DIRS:
- a read-write bind that contains one gets a read-only bind of it on top —
-v $PWD:/appalso mounts$PWD/.loopat/app/.loopread-only, and being a mountpoint it can’t be renamed away either; - a read-write bind inside one is made read-only.
The runner creates the directories before the container starts, since only an existing directory can be mounted over.
Binds are pinned to the agent’s mounts
The body rules check a bind’s source when the container is created, but the daemon mounts it when the container starts, following any symlink on the way. In between, the agent can swap a checked directory for a symlink — docker create -v $PWD/src:/app, then mv src src.real && ln -s /Users/<you> src, then docker start — and the container gets whatever the link points to. The gap is as long as the agent likes.
So once a POST /containers/create is approved, the proxy rewrites its host-path binds (HostConfig.Binds and bind-type HostConfig.Mounts, including the read-only .loop overlays):
- a source that resolves under one of the agent’s read-write directory mounts (the read-write paths of the bind allowlist that are directories, passed in as
LOOP_DOCKERPROXY_BIND_ROOTS) becomes a mount of a named volume bound to that mount, with the rest of the path asVolumeOptions.Subpath. The daemon opens a subpath beneath the volume, one component at a time, when it mounts it, and mounts what it opened: a symlink leading out of the mount fails the start instead. The mounts themselves can’t be swapped, since their parents aren’t in the agent container; with nested mounts, the outermost one is used for the same reason.NoCopyis set, so an empty directory isn’t filled with the image’s files as a volume would be; - any other source was approved as the path it resolved to, and is forwarded as that path. That includes the agent’s read-only mounts, where the agent can’t create a symlink, and its single-file mounts, which a volume can’t be bound to (the start fails with “not a directory”) and which, being mount points in the agent container, can’t be replaced with a symlink (
EBUSY).
The volumes are named loop-bind-<hash of the path> and labelled app=loop-bind. They hold no data — each only points at a host directory — and are reused by every container binding under the same path; the agent can’t create or mount volumes with that prefix. The proxy checks that an existing volume of that name is bound to the same directory. Docker Desktop reports the directory as its VM sees it, under /host_mnt and with symlinks resolved on the host (macOS’s /tmp is /private/tmp), so loop passes each mount’s resolved host path as LOOP_DOCKERPROXY_BIND_HOST_PATHS where it differs. Each rewritten mount carries the volume’s driver options and label, so a volume removed between the proxy creating it and the container being created is recreated as it was. Whenever loop removes a container it also removes the loop-bind volumes no container uses; the proxy recreates them on next use.
Differences from a plain bind, and rejections (audited with rule id create-binds):
| Condition | Response |
|---|---|
Source doesn’t exist (a plain -v would create it) or can’t be resolved | 400 |
| Client pins Docker API < 1.45 and a bind is below a mount’s top | 400 |
Daemon older than API 1.45, or a loop-bind-* volume bound elsewhere | 502 |
A mount or bind of a loop-bind-* volume by name | 403 |
Bind options other than read-only (propagation, SELinux labels) are dropped.
docker inspect (GET /containers/{id}/json) reports a pinned mount as the bind the agent asked for: Mounts[].Source is the mount’s path joined with its subpath (the path the agent sees, e.g. its $PWD), not the volume’s /var/lib/docker/volumes/loop-bind-<hash>/_data, and HostConfig.Mounts lists it as a bind. A script that checks whether a running container already mounts the current directory then finds it, instead of recreating the container on every call. Other endpoints, such as docker ps (GET /containers/json), still show the loop-bind-* volume. Comparing a mount’s source with $PWD stays fragile anyway (symlinks, Docker Desktop’s /host_mnt), so a label on the container naming its directory is the sturdier check.
The nested socket lives as long as the agent container: a nested container that outlives it loses Docker access. Subpath pins the socket file, so a proxy restart inside the agent container also breaks sockets already mounted into running nested containers.
Approval UI

Approve decisions cross three platforms through the same orchestrator.Bot.ApprovalPrompt interface. Each prompt shows three buttons:
- Allow once — default action; allows this single call. The decision is remembered for a few seconds only for the exact same call (same full argv for exec), so the repeat probes of one command (a
PATHsearch) don’t prompt again; a different command run in the same shell line still gets its own card. - Allow for session — caches on
CacheKeyso the same operation skips future prompts for the life of the container. - Deny — blocks this call (subsequent identical calls prompt again).
Discord renders an ActionsRow with three buttons; Slack renders a NewActionBlock; the desktop renders the ApprovalCard component via the gate.approval_requested WebSocket event
and resolves via POST /api/gate/approvals/{id}
. The desktop card additionally hangs three deny variants — deny-session, deny-and-stop, and deny-with-prompt — off a caret next to Deny; the chat platforms only offer the three decisions above. See Chat: Gate Approval Card
for the desktop rendering.
Cache key scheme:
connect:<absolute-socket-path>— one allow-for-session covers everydocker ps/docker logs/docker execround.execve:<basename>:<rule>:<matched args>— for a command rule withargs_patterns: the rule and the argument text its patterns matched, never option values. Under(^|\s)(commit|push)(\s|$),git -c x=y commitkeys oncommitandgit -c x=y pushonpush, so “Allow for session” on the commit doesn’t cover the push. Every match counts:git -C commit pushkeys on both.execve:<basename>:<first-2-argv-tokens>— for a rule withoutargs_patterns, or an approve default: “Allow git push for session” doesn’t also covergit config --global.docker:<METHOD>:<normalized-path>— container IDs and hex hashes collapse to*so repeatdocker rm <id>doesn’t re-prompt after session approval.
Scope: cache lives inside the per-container Manager. Container dies → cache dies. No cross-session persistence in v1.
Teardown: when the container goes away the runner calls gateResolver.Remove(containerID), which invokes Manager.Shutdown() before unregistering. Shutdown() walks every still-pending request, resolves each one as deny/actor=shutdown, and lets the normal resolve path broadcast gate.approval_resolved
on every channel that had a card up. The renderer and the electron dock-bouncer both clear their pending entries from that event — no orphan cards, no orphan bounces.
WS-reconnect rehydration: the renderer doesn’t trust that it saw every event. On every WebSocket onOpen (page reload, network blip, daemon restart) it GETs /api/gate/approvals
— a snapshot built from MultiManagerResolver.ListPending() — and reconciles three places against it:
- The chat-state
gateApprovalsmap: locally-known entries missing from the snapshot are synthesized as resolved (decision=deny, actor=rehydrate); snapshot entries the renderer didn’t know about are synthesized as requested. Both go through the sameapplyEvent/listener path as a real WS event, so the UI converges without a special-case code path. - The electron-main dock-bouncer’s
pendingApprovalsset: the renderer hands the full snapshot id list back over thereconcile-approvalsIPC; any bouncer entry not in the snapshot is dropped, and the bounce loop is stopped if the set ends up empty. - Per-channel running-state derived from the union of (a) and (b).
The net result: a missed gate.approval_resolved (deny-by-timeout that fired while the renderer was disconnected, container teardown during a network drop, etc.) self-heals on the next reconnect, and any pending entry that is still live is reattached to its card. This is the only path that can fix state desync today — there is no per-event resend.
Rate limits (gates.rate_limits, per container, shared across both gate layers): pending: 30, per_minute: 60, total: 500. Tripping a cap returns Outcome{RateLimited: true} to the requester (which surfaces as a deny at the syscall); the agent sees EACCES on the blocked call.
Body details surfaced in the prompt
For Docker-HTTP approvals, the proxy decodes the request body and attaches a small Details map to ApprovalRequest (internal/dockerproxy/details.go) so the operator sees what is actually being asked for, not just POST /containers/abc/exec. The card renders the keys sorted; long values are truncated with …. Only fields meaningful to a human-in-the-loop decision are extracted:
| Endpoint | Keys |
|---|---|
POST /containers/create | image, cmd, entrypoint, user, working_dir, binds, privileged, network_mode, pid_mode, ipc_mode, userns_mode, cap_add, devices, security_opt |
POST /containers/{id}/exec | cmd, user, privileged, attach_stdin, tty |
POST /networks/create | name, driver, internal, attachable |
POST /volumes/create | name, driver |
Default-valued / absent fields are omitted (e.g. Privileged: false → no privileged key, NetworkMode: "default" → no network_mode key) so the prompt only highlights non-default values. POST /exec/{id}/start carries no useful body, so its prompt falls back to Target only — the relevant detail (the Cmd) was approved at the preceding POST /containers/{id}/exec. Bodies larger than MaxBodyBytes (1 MiB) skip detail extraction along with body-rule evaluation.
The same map flows through agentgate.ApprovalRequest.Details to every renderer: the desktop ApprovalCard, Discord embeds, and Slack section blocks (see Chat: Gate Approval Card
).
Runtime wiring
Set when cfg.Gates.Agentgate.Enabled is true (cmd/loop/serve.go):
gateResolver := agentgate.NewMultiManagerResolver()— one resolver for the whole server. Same struct serves two roles:Resolve(reqID, decision, actorID)for bot clicks andByToken(token)for in-container HTTP call-ins.policyDir := filepath.Join(cfg.LoopDir, "run")+os.MkdirAll(policyDir, 0o750)— per-container policy JSON files live here. Lives under the user’s home rather than/run/loopbecause macOS/runis on the read-only system volume; the same path works on Linux so there’s one code path for both OSes.runner.SetGateDeps(gateResolver, &orchestrator.GateBotRouter{Bot: chatBot}, cfg.Gates.RateLimits)— runner builds anagentgate.Managerper spawn and registers it with the resolver viaAddWithToken(cid, token, mgr, channelID).runner.SetDockerProxyDeps(policyDir, "")— plumbs the samepolicyDirthrough to the proxy-policy writer; second arg is the host docker socket (""→/var/run/docker.sock).a.wireGatePolicy(cfg, runner, policyDir, logger)— callsagentgate.CompilePolicyonce at startup so a broken user config fails fast, thenrunner.SetGatePolicy(policy, policyDir). The runner re-serialises the policy to JSON per spawn;loop syscallwrapparent recompiles it in-container.apiSrv.SetApprovalResolver(gateResolver)— wiresPOST /api/gate/approvals/{id}(bot-click resolve) to the same resolver.
Per container (internal/container/runner.go#createAndStartContainer):
- Generate a 32-byte
crypto/randbearer token (newGateToken) — shared by the proxy and gate layers. writeProxyPolicyFilemarshalscfg.Gates.DockerProxy, with the bind allowlist (see Docker body rules) injected, to{policyDir}/<channel>/proxy-policy.json(0640). Bind-mounthostSock:/var/run/docker.sock.host:ro+.../proxy-policy.json:/etc/loop/proxy-policy.json:ro. Env:LOOP_DOCKERPROXY_ENABLED=1,LOOP_DOCKERPROXY_POLICY_FILE=/etc/loop/proxy-policy.json,LOOP_DOCKERPROXY_UPSTREAM=/var/run/docker.sock.host,LOOP_DOCKERPROXY_NESTED_DIR=/run/loop-dproxy,LOOP_DOCKERPROXY_READONLY_DIRS(the workspace’s.loopdirs, colon-separated),LOOP_DOCKERPROXY_BIND_ROOTS(the agent’s read-write same-path directory mounts),LOOP_DOCKERPROXY_BIND_HOST_PATHS(root=pathpairs for the roots whose host path resolves elsewhere), plus an anonymous volume at/run/loop-dproxy.writeGatePolicyFilemarshals the gate rule subset (DefaultDecision,PathRules,CommandRules,FileRules) to{policyDir}/<channel>/gate-policy.json(0640).FileRulesis the merged config list wrapped byinjectWorkspaceRule(workspace allow, before the first Allow) and theninjectPolicySelfDenyRule(/etc/loop/**deny, at the head — see file-op rule 1 ).GitGuardRootslists the container paths of the writable host binds (see Git guard ). Bind-mount.../gate-policy.json:/etc/loop/gate-policy.json:ro. Env:LOOP_GATE_ENABLED=1,LOOP_GATE_POLICY_FILE=/etc/loop/gate-policy.json.- Shared env on both layers:
LOOP_CONTAINER_ID=<container name>, plus the always-setLOOP_CHANNEL_ID=<channelID>. The gate token (32-hex) is copied in as/run/loop/gate-token(root, 0400) before the container starts, never set in the env. - After
ContainerCreatereturns acontainerID, callgateResolver.AddWithToken(containerID, token, mgr, channelID)— the token starts authenticating HTTP calls as soon as the container is up. - On container remove:
gateResolver.Remove(containerID)— frees the token and Manager. Policy files under{policyDir}/<channel>/are left on disk (overwritten next spawn for the same channel — the payload is derived from global config + the channel’s stable workDir, so overwrites are idempotent).
The in-container side (see internal/container/image/entrypoint.sh):
- When
LOOP_DOCKERPROXY_ENABLED=1and/usr/local/bin/loopis executable, entrypoint forksloop dockerproxy &as root, then waits up to 2s for/var/run/docker.sockto appear. The proxy stampsfilepath.EvalSymlinksonto everysource_path_inbody-rule check viaPolicy.SetSymlinkResolver, so an agent that creates/workdir/link → /then submitsdocker run -v /workdir/link:/hostis matched against the resolved/rather than the literal source. Resolve failures fire deny rules (suspect path → block); allow/approve rules withsource_path_inare not auto-fired on failure. Named-volume sources (the<name>:<target>short syntax inHostConfig.Binds[*], where the source has no leading/) are not host paths, so the resolver is skipped and the deny does not fire —docker run -v loop-cache:/cacheand compose v2 named volumes pass through cleanly. - The proxy socket is
0666, so the agent needs no group membership; the docker-sock group logic (adding$AGENT_USERto the GID that owns/var/run/docker.sock) runs only for a directly mounted socket when the proxy is off — the proxy’s socket is root-owned, and joining its GID would put the agent in the root group. WithLOOP_DOCKERPROXY_NESTED_DIRset, the proxy also listens on<dir>/docker.sockfor containers the agent starts (see Nested docker socket ). - When
LOOP_GATE_ENABLED=1, entrypointexecs/usr/local/bin/loop syscallwrap -- "$@"as root (nogosuwrapper). Otherwise it falls back toexec gosu "$AGENT_USER" "$@". loop syscallwrapparent (root): loads the gate policy fromLOOP_GATE_POLICY_FILE, builds anhttpapprover.Approveragainst$LOOP_API_URL+ the token in/run/loop/gate-token(or, withLOOP_GATE_APPROVER=deny, an approver that denies everyaskwith reasondeny-onlyand needs neither; any value other than unset,httpordenyfails startup), creates asocketpair(AF_UNIX, SOCK_STREAM, 0), re-execs/proc/self/exewithLOOP_SYSCALLWRAP_MODE=child,ExtraFiles=[child-end],SysProcAttr.Credential={uid, gid}(looked up from$LOOP_HOST_USER, or taken as is when it’s a numericuidoruid:gid, the gid defaulting to the uid), andSysProcAttr.Pdeathsig=SIGKILL. Receives the SCM_RIGHTS handshake on the parent-end, acks, and runsagentgate.Serveron the notify fd.loop syscallwrapchild (agent user):runtime.LockOSThread→prctl(PR_SET_PDEATHSIG, SIGKILL)(belt-and-braces on top of the parent-set value) → install the seccomp filter withSECCOMP_SET_MODE_FILTER | NEW_LISTENER | TSYNC→ send the notify fd +$LOOP_CHANNEL_IDover fd 3 via SCM_RIGHTS → read the 1-byte ack →syscall.Execthe target command (claude). The filter carries into claude and every descendant by kernel inheritance.
No notify fd ever crosses the container boundary — this is what lets the gate work on macOS. Docker Desktop’s virtiofs cannot expose host-created unix sockets as sockets inside the Linux VM, but it has no issue with the forwarded /var/run/docker.sock or with bind-mounting JSON policy files read-only.
Terminal (docker-exec) mode
Stream-mode claude is spawned as PID 1 by entrypoint.sh, so the gate hooks the process at its one entry point. The terminal-mode path is different: the UI docker execs into a long-lived shell container and types a claude … line into the shell’s stdin. docker exec uses setns(2) to join the container’s namespaces, but seccomp is a per-process attribute — the exec’d shell does not inherit the filter the container’s PID 1 installed. A naive claude invocation would run ungated.
The fix is in the exec wrapper (interactiveExecScript in internal/terminal/docker.go): every interactive exec starts as root, and when the container has the gate on (LOOP_GATE_ENABLED=1) the wrapper execs loop syscallwrap -- <command>. That re-enters the same parent/child dance as the stream-mode path: install the filter, hand the notify fd to an in-exec parent, and run agentgate.Server until the command exits.
The in-exec parent runs as root, like the stream-mode one: it reads the root-only gate token and drops the child to the agent uid through Credential. Nothing in the terminal path needs the gate token at the agent uid, and the agent can’t signal its gate-parent. pdeathsig=SIGKILL couples parent death to child death, so the agent can’t outlive its gate on an orphaned notify fd.
Project config merge
A project .loop/config.json can add rules with any decision (allow / deny / approve), so a project can open a surgical hole in a global approve without turning a whole layer off: for example, an allow body rule with source_path_in ^/opt/shared-fixtures(/|$) binds that host dir without the “bind mount outside the agent’s own mounts” prompt. The global denies and switches still hold: /etc/ssl/certs stays refused under the baseline ^/etc deny, and a project can’t switch a gate off. An exception to a deny belongs in the global config, with one exception: Loop’s overridable default denies.
Loop’s defaults come in two kinds. The pinned ones guard the host and the gate itself: kernel memory, root credentials, system paths, shell rcfiles, Claude settings, the host docker socket and every docker proxy default. The overridable ones guard credentials a project may legitimately need: the credential dirs (file rule 4, e.g. ~/.aws, ~/.kube), registry credential writes (file rule 5) and rm -rf on an absolute path (exec rule 1). The order is:
- The injected rules (policy self-deny, project config approve, workspace
rmallow). - The
denyrules you wrote in the global config, and the pinned default denies. - The project rules.
- The overridable default denies, only while the project config is trusted. Untrusted, or changed since you trusted it, they stay in step 2 and the last trusted project rules can’t precede them.
- The other global rules, the workspace allow among them.
A deny you write yourself is never overridable, even a copy of a default one: when you set a global rule list it replaces the defaults, and every deny in it comes first. So a project that runs aws or kubectl with ~/.aws / ~/.kube mounted can allow them in its own config, without a global allow that would reach every project:
{"gates": {"agentgate": {"file_rules": [
{"paths": ["**/.aws/**", "**/.kube/**"], "operations": ["read", "write", "create"], "decision": "allow"}
]}}}The AWS CLI and kubectl write their caches (~/.aws/sso/cache, ~/.aws/cli/cache, ~/.kube/cache) by creating a temp file and renaming it over the cache file, so a rule for them needs write and create. The gate checks a rename within one directory as a write of the old name and a create of the new one; a rename across directories checks the old name as a delete. Deleting a leftover temp file outright still needs delete; Example: aws and kubectl
has a rule that allows it for the temp files only.
| Field | Merge rule |
|---|---|
gates.agentgate.enabled | Ignored: the global setting decides, in both directions |
gates.agentgate.path_rules / command_rules / file_rules | Global and pinned default deny rules, then project rules, then (when trusted) the overridable default denies, then the other global rules (first-match-wins) |
gates.agentgate.default_decision | Ignored — global wins |
gates.docker_proxy.enabled | Ignored, like gates.agentgate.enabled |
gates.docker_proxy.http_rules / body_rules | Global deny rules, then project rules, then the other global rules; no docker proxy default is overridable |
gates.docker_proxy.default_decision | Ignored — global wins |
gates.rate_limits / gates.audit | Ignored — global wins |
Trust model: the project config is in the workspace, so an agent can write it. The whole gates block is one of the fields that only apply once you’ve trusted them (see Configuration: Project Config Trust
); until then the last trusted version applies. See Configuration: Project Config
for the full merge table.
Docker Desktop host settings
The proxy decides what the agent may ask the daemon for; Docker Desktop decides what the daemon can reach. Two settings narrow the latter, so a request that slips past a rule (or is approved by mistake) has less to hit:
File sharing (Settings → Resources → File sharing). The daemon resolves bind sources on its own side, and on macOS the default shares —
/Users,/Volumes,/private,/tmp,/var/folders— make most of the host bindable. Trimming the list to the directories loop actually mounts (your project roots,~/.loop, and the host paths inmounts) turns a stray-v /Users/<you>:/hinto a daemon error instead of a home-directory mount. Anything left out fails to mount for every container, not just agents, so keep whatever other tools need.Enhanced Container Isolation (Docker Business; enforced through
admin-settings.json). Containers run in a Linux user namespace, so root in a container — including one started with--privileged— is not root in the Docker Desktop VM, and ECI adds its own checks on sensitive mounts. It also blocks bind-mounting the Docker socket by default, which is how the agent container reaches the daemon through the proxy (hostSock:/var/run/docker.sock.host). The exception list isenhancedContainerIsolation.dockerSocketMount.imageList(withallowDerivedImagesfor images built on top of a listed one), but Docker Desktop validates entries against image digests it downloads from a registry.loop-agentis built locally and exists in no registry, so to use ECI with Docker access enabled, push the agent image (or a base it derives from) to a registry you control, reference it incontainer_image, and list that reference. See Docker’s ECI configuration for the exact matching rules.Containers the agent starts get the proxy’s nested socket, not the daemon’s, so they need no entry. Loop’s own test suite doesn’t run under ECI; if the gate or proxy misbehaves there, turn ECI off to confirm it’s the cause and open an issue.
Known gaps
Gaps operators should know about — what’s enforced vs what’s aspirational:
- No host-side audit aggregation / shipping.
FileAuditorwrites rotatingagentgate-YYYY-MM-DD.jsonlrecords into{policyDir}/<channel>/audit/(bind-mounted into the container at/var/log/loop-gate); the dir is keyed by channel/thread — not per container — so restarts of the same channel accumulate one continuous history rather than fragmenting into a new dir per spawn. Channel is the primary key so all per-channel artifacts (policy file, audit log, future per-channel state) live under one tree.gates.audit.retention_daysis honoured viaLOOP_GATE_AUDIT_RETENTION_DAYS.gates.audit.verbose(defaultfalse) controls log verbosity viaLOOP_GATE_AUDIT_VERBOSE=1: when unset,FileAuditor.Writedrops entries whosedecision=="allow"ANDprompted_who==""— i.e. silent policy-allow matches and cache-hit allows — so the default trail focuses on every deny plus every user-clicked decision. Setverbose=truewhen debugging rule authoring or exporting a full trace. Each record carries{ts, channel, pid, kind, target, rule_id, decision, event, prompted_who, latency_ns}—pidis the requesting process so operators can attribute spam (e.g.grep '"pid":7'to find the gate’s own process). File-op cache hits do not emit audit lines (only the first-miss decision is recorded) so directory walks over allow paths don’t drown the log. Theeventfield distinguishes two flavors of record: it is omitted on the standard decision record (existing shape, written after the gate resolves), and set to"request"on a pre-decision record emitted the moment a prompt is dispatched to the user surface. Crucially, arequestrecord is written only when a real prompt fires —Manager.Requestshort-circuits cache hits and rate-limit denies before invoking the prompt callback, so neither produces arequestentry; the audit log therefore reflects only approvals that actually reached a human. Arequestrecord carries the same{ts, channel, pid, kind, target, rule_id}context but withdecision,prompted_who, andlatency_nsall zero — pair it with the matching decision record (samekind+target+rule_id) to reconstruct the prompt’s wait time. The pre-decision record falls outside the silent-allow filter (itsdecisionis empty) so it survives non-verbose mode by design. Pre-decision records are emitted by the connect / file / execve handlers and by the dockerproxyrunApprovalFlow(both the HTTP-rule and body-rule branches). The desktop app ships a singleton Audit panel (see Layouts: Panel Types ) with a file list on the left and a livetail -f -n 100xterm on the right (run viadocker execagainst/var/log/loop-gate/agentgate-<date>.jsonl); the same files are also exposed over HTTP for tooling —GET /api/channels/{id}/audit(list) andDELETE /api/channels/{id}/audit/{date}(unlink); content is read live via thetail -fterminal, not via HTTP. There is still no SIEM-side fan-out or unified cross-channel log; the per-channel files remain the source of truth. - No performance benchmark test. There is no
perf_bench_test.gopinning P50 / P99 latency over a 10K-filefindpass. - No end-to-end integration test. Per-component
_linux_test.gofiles exist but there’s nointegration_linux_test.godriving the full install → trap → handler → policy →EACCESloop against a real kernel. - No first-run banner. A one-shot “Loop gate is active” notification per channel per install is not implemented.
- No rate-limit-tripped user notification. When
checkLimitsdenies, the caller sees the deny; no single-shot Discord/Slack/Local notification is posted to the operator. - File-op decision cache is FIFO, not LRU. Functionally equivalent under the workload; the code comment acknowledges the simplification.
v1 non-goals (intentional)
These are deferred by design, not missing work:
- TCP/DNS egress —
WebFetch/WebSearchare not gated. Operator mitigation: Docker network with no internet route + HTTPS egress proxy with URL allowlist. eBPFcgroup_connectis the v2 plan. - Multi-threaded argv / path TOCTOU — a sibling thread can mutate argv/path between our
ProcessVMReadvread and the kernel’s re-dereference. The git guard ’s path checks inherit this. Single-threaded callers (Claude’sBashtool per invocation) are unaffected; exploit requires the agent to write+compile+run a multi-threaded program. - Policy hot-reload — rule changes require a container restart (the policy JSON is read once when
loop syscallwrap/loop dockerproxystarts). - Docker daemon-level authorization plugin — out of scope; the HTTP proxy is per-container.
- Podman / nerdctl / docker-over-TCP — only unix-socket Docker is in scope.
- Image content trust —
docker pull evil/imageruns; only the request is inspected, not the image runtime behaviour. - LLM-response intercept for MCP tool-call allowlisting — MCP servers run in
loop-serveron the host; gate scope is the container only.