From bf07f61e54f74f1c5d631da7b3064a298b68dcf6 Mon Sep 17 00:00:00 2001 From: Radu Topala Date: Tue, 28 Jul 2026 18:31:38 +0300 Subject: [PATCH 1/3] feat(review,gate): comment navigator + deny split button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review panel: a floating prev/next pinned bottom-right over the diff steps comment-to-comment. The toolbar's existing pair only moves file-to-file, which is too coarse once a file carries several comments. Order follows the render: files top-to-bottom, within a file by the anchoring diff line, out-of-diff comments last. Jumping into a collapsed file expands it first. The counter re-measures on scroll so it stays honest when the user scrolls by hand. Also hoists four useMemo calls above ReviewDiffView's "no diff content" early return. They were conditional hooks: the branch flips when the first out-of-diff comment lands on an empty session, and React counts hooks per render. Gate approval card: the deny actions collapse into a split button. Plain Deny stays the default — it is the only non-terminal deny — and the variants that change what happens after the denial move into a caret menu. That menu now also exposes deny-session, which the gate manager has always accepted but no client offered: it caches the denial under the request's cache key so a retrying agent is refused without re-prompting. --- app/src/api/gate.ts | 7 +- app/src/components/chat/ApprovalCard.tsx | 148 +++++++++-- .../components/panels/ReviewDiffView.test.ts | 101 ++++++++ app/src/components/panels/ReviewDiffView.tsx | 233 ++++++++++++++++-- docs/chat.md | 10 +- docs/gates.md | 2 +- docs/review.md | 22 ++ 7 files changed, 480 insertions(+), 43 deletions(-) create mode 100644 app/src/components/panels/ReviewDiffView.test.ts diff --git a/app/src/api/gate.ts b/app/src/api/gate.ts index 9eb8af8e..3b688de6 100644 --- a/app/src/api/gate.ts +++ b/app/src/api/gate.ts @@ -1,7 +1,12 @@ import type { GateApprovalRequestedData } from "../types"; import { getApiUrl } from "./api"; -export type GateDecision = "once" | "session" | "deny"; +/** + * `deny-session` caches the denial under the request's CacheKey for the + * container's lifetime, so an agent that retries the same blocked operation + * is refused without re-prompting the operator. See Manager.applyResolution. + */ +export type GateDecision = "once" | "session" | "deny" | "deny-session"; /** One pending approval as returned by GET /api/gate/approvals. */ export interface PendingApproval extends GateApprovalRequestedData { diff --git a/app/src/components/chat/ApprovalCard.tsx b/app/src/components/chat/ApprovalCard.tsx index 6a8dfe35..0c3fbe2b 100644 --- a/app/src/components/chat/ApprovalCard.tsx +++ b/app/src/components/chat/ApprovalCard.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from "react"; import { GateApprovalGoneError, type GateDecision } from "../../api/gate"; -import { resolveGateApproval, sendMessage } from "../../api/loopApi"; +import { resolveGateApproval, sendCommand, sendMessage } from "../../api/loopApi"; import { useTheme } from "../../ThemeContext"; import { fonts } from "../../theme"; import type { GateApprovalRequestedData } from "../../types"; +import { ContextMenu, type MenuItem } from "../shared/ContextMenu"; export function ApprovalCard({ data, @@ -38,11 +39,24 @@ export function ApprovalCard({ }); return () => cancelAnimationFrame(id); }, []); - const [sending, setSending] = useState(null); + const [sending, setSending] = useState(null); const [error, setError] = useState(null); const [showPrompt, setShowPrompt] = useState(false); const [prompt, setPrompt] = useState(""); + // The deny variants live behind a caret next to Deny rather than as their + // own pills: plain Deny is the only non-terminal one and by far the common + // choice, and four side-by-side deny buttons made the card read as if they + // were unrelated options. + const caretRef = useRef(null); + const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); + const openDenyMenu = () => { + const el = caretRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + setMenuPos({ x: r.left, y: r.bottom + 2 }); + }; + // Expiry: the gate auto-denies at data.expires_at. Track it locally so the // card greys out on time even if the gate.approval_resolved event was // missed (WS drop, subscription race), then retract shortly after so a @@ -87,6 +101,30 @@ export function ApprovalCard({ } }; + // Deny the request and end the run outright, rather than letting the agent + // carry on from the denial. The orchestrator's drain loop claims the next + // queued message as soon as the cancelled run returns, so this is the way + // to abandon what the agent is doing and move on to what's waiting behind + // it. Deny lands first so the agent sees a clean tool-denied result while + // its container is torn down, matching the deny-with-prompt ordering. + const denyAndStop = async () => { + setSending("deny-and-stop"); + setError(null); + try { + await resolveGateApproval(data.req_id, "deny"); + await sendCommand(channelId, "stop"); + onResolved?.(); + } catch (e) { + if (e instanceof GateApprovalGoneError) { + setExpired(true); + setSending(null); + return; + } + setError(e instanceof Error ? e.message : String(e)); + setSending(null); + } + }; + const denyWithPrompt = async () => { const text = prompt.trim(); if (!text) return; @@ -109,6 +147,44 @@ export function ApprovalCard({ } }; + // Each entry re-checks `sending`: the caret is disabled while a decision is + // in flight, but one can start between opening the menu and clicking an item + // (the card also resolves on a peer's click via gate.approval_resolved). + const denyMenuItems: MenuItem[] = [ + { + label: "Deny for session", + danger: true, + onClick: () => { + if (sending === null) void resolve("deny-session"); + }, + }, + // Chat/review only: `/loop stop` cancels the orchestrator run that owns the + // message queue. A terminal pane's agent isn't that run (it's a TUI on the + // pane's stdin, with nothing queued behind it), and those panes are exactly + // the ones that pass onDenyWithPrompt. + ...(onDenyWithPrompt + ? [] + : [ + { + label: "Deny & stop run", + danger: true, + onClick: () => { + if (sending === null) void denyAndStop(); + }, + }, + ]), + { + label: "Deny with prompt…", + danger: true, + separator: true, + onClick: () => { + if (sending !== null) return; + setShowPrompt(true); + setError(null); + }, + }, + ]; + const label = data.kind ? data.kind.toUpperCase() : "APPROVAL"; return ( @@ -155,27 +231,45 @@ export function ApprovalCard({
- - + {/* Split button: the default action is the plain, non-terminal deny — + block this one call and let the agent carry on. The variants that + change what happens *after* the denial hang off the caret. */} +
+ + +
+ {sending === "deny-session" || sending === "deny-and-stop" ? ... : null}
)} {!expired && showPrompt && ( @@ -265,6 +359,7 @@ export function ApprovalCard({ )} + {menuPos && setMenuPos(null)} items={denyMenuItems} />} ); } @@ -276,6 +371,8 @@ function ApprovalButton({ disabled, onClick, variant, + title, + style, }: { label: string; decision: GateDecision; @@ -283,6 +380,9 @@ function ApprovalButton({ disabled: boolean; onClick: (d: GateDecision) => void; variant: "primary" | "secondary" | "danger"; + title?: string; + /** Merged last, so a split-button caller can flatten the adjoining corners. */ + style?: React.CSSProperties; }) { const { colors } = useTheme(); const accent = variant === "primary" ? colors.active : variant === "danger" ? colors.warning : colors.border; @@ -292,6 +392,7 @@ function ApprovalButton({ + + {index + 1} / {total} + + + + ); +} + function DiffToolbar({ colors, focusedPath, @@ -388,6 +587,7 @@ function FileSection({ onPushComment, onPushCommentToChat, onDeleteComment, + registerCommentRef, }: { summary: FileSummary; comments: ReviewComment[]; @@ -398,16 +598,12 @@ function FileSection({ onPushComment: (c: ReviewComment) => void | Promise; onPushCommentToChat: (c: ReviewComment) => void | Promise; onDeleteComment: (c: ReviewComment) => void | Promise; + registerCommentRef: (id: string, el: HTMLDivElement | null) => void; }) { // Group comments by (line, side) so multiple comments on the same line // render as a stack underneath that line. The backend widens git's // `-U` enough to land every comment on a hunk line, so we don't need // a separate out-of-hunk path here. - const lineKey = (line: HunkLine) => { - if (line.newNum !== null) return `R:${line.newNum}`; - if (line.oldNum !== null) return `L:${line.oldNum}`; - return ""; - }; const commentMap = new Map(); for (const c of comments) { const k = commentLineSide(c) === "LEFT" ? `L:${c.line}` : `R:${c.line}`; @@ -516,7 +712,10 @@ function FileSection({ return (
- {matched && matched.map((c) => )} + {matched && + matched.map((c) => ( + + ))}
); })} @@ -600,12 +799,15 @@ function InlineComment({ onPush, onPushToChat, onDelete, + registerRef, }: { comment: ReviewComment; colors: ColorPalette; onPush: (c: ReviewComment) => void | Promise; onPushToChat: (c: ReviewComment) => void | Promise; onDelete: (c: ReviewComment) => void | Promise; + /** Hands the card's node to the floating navigator so it can scroll to it. */ + registerRef: (id: string, el: HTMLDivElement | null) => void; }) { // Local in-flight flag for the "Push to chat" button. Push-to-chat // doesn't flip the comment to `pushed`, so without this guard rapid @@ -626,6 +828,7 @@ function InlineComment({ return (
registerRef(comment.id, el)} style={{ margin: "4px 8px 4px 88px", padding: "6px 10px", @@ -774,6 +977,7 @@ function OrphanCommentsSection({ onPushComment, onPushCommentToChat, onDeleteComment, + registerCommentRef, }: { comments: ReviewComment[]; colors: ColorPalette; @@ -781,6 +985,7 @@ function OrphanCommentsSection({ onPushComment: (c: ReviewComment) => void | Promise; onPushCommentToChat: (c: ReviewComment) => void | Promise; onDeleteComment: (c: ReviewComment) => void | Promise; + registerCommentRef: (id: string, el: HTMLDivElement | null) => void; }) { return (
@@ -809,7 +1014,7 @@ function OrphanCommentsSection({ > {c.path}:{c.line}
- +
))} diff --git a/docs/chat.md b/docs/chat.md index 1f196a61..eae05b04 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -227,13 +227,15 @@ When the agent container hits a gate rule with `decision: approve`, the backend - **Target:** the full target string (socket path, command line, or `METHOD /path`) in a monospace style. - **Message:** the matching rule's `message` field, shown as a second-line caption when non-empty. - **Details:** for `DOCKER-HTTP` prompts on `/containers/create`, `/containers/{id}/exec`, `/networks/create`, and `/volumes/create`, the proxy extracts the security-relevant body fields (e.g. `cmd`, `user`, `privileged`, `binds`) and the card renders them as a sorted `key: value` list under the target. See [Gates: Body details surfaced in the prompt](gates.md#body-details-surfaced-in-the-prompt) for the full key set per endpoint. -- **Buttons:** four monospace pills, left-to-right: +- **Buttons:** three monospace controls, left-to-right: - `Allow once` — primary accent, lets this one syscall through. - `Allow for session` — secondary outline, caches the allow for the container's lifetime. - - `Deny` — warning accent, rejects the syscall. - - `Deny with prompt…` — warning outline. Expands an inline textarea: typing a follow-up and hitting `⌘/Ctrl+Enter` denies the gate, cancels the now-resumed run, and immediately sends the prompt with `interrupt=true` so it claims the next slot ahead of any queued messages (without deleting them — see [Message Queue Indicators](#message-queue-indicators)). + - `Deny ▾` — warning accent **split button**. Clicking the label body rejects the syscall and lets the agent carry on from the denial; this is the only non-terminal deny, which is why it stays the default action. The `▾` caret opens a [`ContextMenu`](../app/src/components/shared/ContextMenu.tsx) anchored under it with the variants that change what happens *after* the denial: + - `Deny for session` — sends the `deny-session` decision, which caches the denial under the request's cache key for the container's lifetime. An agent that retries the same blocked operation is refused without re-prompting. + - `Deny & stop run` — denies the gate and then issues `/loop stop`, ending the run instead of letting the agent continue. The orchestrator's drain loop claims the next queued message as soon as the cancelled run returns, so this is how to abandon the current run and move straight to what's waiting behind it. Omitted in terminal panes, where the agent is a TUI on the pane's stdin rather than the orchestrator run that owns the queue. + - `Deny with prompt…` — expands an inline textarea below the card: typing a follow-up and hitting `Enter` (`Shift+Enter` for a newline) denies the gate, cancels the now-resumed run, and immediately sends the prompt with `interrupt=true` so it claims the next slot ahead of any queued messages (without deleting them — see [Message Queue Indicators](#message-queue-indicators)). -While a button is busy it shows a dim "…" label; failures are rendered below the buttons in the warning color so the user can retry. +Every menu entry re-checks that no decision is already in flight, since the caret only guards menu-*open*: a peer window's click can resolve the card while the menu sits open. While a button is busy it shows a dim "…" label; failures are rendered below the buttons in the warning color so the user can retry. ### Dock bounce diff --git a/docs/gates.md b/docs/gates.md index 554ab21a..bf0e50ba 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -252,7 +252,7 @@ Approve decisions cross three platforms through the same `orchestrator.Bot.Appro - **Allow for session** — caches on `CacheKey` so 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](events.md#gateapproval_requested) and resolves via [`POST /api/gate/approvals/{id}`](api.md#post-apigateapprovalsid). See [Chat: Gate Approval Card](chat.md#gate-approval-card) for the desktop rendering. +Discord renders an `ActionsRow` with three buttons; Slack renders a `NewActionBlock`; the desktop renders the `ApprovalCard` component via the [`gate.approval_requested` WebSocket event](events.md#gateapproval_requested) and resolves via [`POST /api/gate/approvals/{id}`](api.md#post-apigateapprovalsid). 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](chat.md#gate-approval-card) for the desktop rendering. **Cache key scheme:** diff --git a/docs/review.md b/docs/review.md index 4dfd6f9c..d13e8ff8 100644 --- a/docs/review.md +++ b/docs/review.md @@ -70,6 +70,28 @@ per-global / per-project / per-worktree the same way as `github.gh_user`. 4. **Close** — closing the session deletes the in-memory session record and removes the worktree on disk. Pushed comments remain on GitHub. +## Navigating comments + +The diff view offers two granularities of navigation, because a review with +twenty comments spread over four files is painful to scroll by hand: + +- **Toolbar prev/next** (top of the diff, always visible) steps **file to + file**, skipping files with no comments. The counter reads + `n / m commented`, where `m` folds in unique out-of-diff paths so it + matches every commented entity on screen. +- **Floating prev/next** (`review-comment-nav`, pinned bottom-right over the + scroll) steps **comment to comment**, in render order: files top-to-bottom, + within a file by the diff line each comment anchors to, then out-of-diff + comments last. Jumping to a comment in a collapsed file expands that file + first and moves the file rail's highlight with it. The widget only appears + once the session has at least one anchored comment. + +The floating counter re-measures on scroll and reports whichever comment sits +nearest the viewport's midpoint, so it stays honest when the user scrolls by +hand rather than by button. A comment whose line falls outside every hunk has +no row to render under and is excluded from the count — the backend widens +`git diff -U` enough that this should not happen in practice. + ## Concurrency A second `POST /review/run` while the first is still in flight returns From f69b9c13b1e70c6f129c42fbef16ab68bf658d91 Mon Sep 17 00:00:00 2001 From: Radu Topala Date: Tue, 28 Jul 2026 19:15:43 +0300 Subject: [PATCH 2/3] feat(gate): pin a self-deny on the policy dir so config can't shadow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File rules are first-match-wins, and both config layers author rules by prepending them. The project layer is {workDir}/.loop/config.json — inside the workspace the agent may write — so any rule reachable through config, including the generic /etc/** system-path deny, can be shadowed by an allow the agent writes for itself, taking effect on the next container start. Inject a Deny on /etc/loop/** at the head of the list after the merge, the one position no config layer can precede. `link` is in the operation set (unlike the generic rule) because linkat/symlinkat are matched on the new path only: without it the policy file could be hardlinked into the blanket-allowed workspace and written through the second name. Reads stay allowed — seeing the active policy is useful when debugging a denial. --- docs/containers.md | 2 +- docs/gates.md | 27 ++++++----- internal/container/container_config.go | 36 +++++++++++++- internal/container/gate_test.go | 67 ++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/docs/containers.md b/docs/containers.md index c700af89..e8cbc587 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -156,7 +156,7 @@ When the config's `gates.agentgate.enabled` flag is true (the default), the runn Before `ContainerCreate`, the runner calls `writeGatePolicyFile` which: 1. Creates a host directory `{policyDir}/{containerID}/`. The serve command computes `policyDir` as `{cfg.LoopDir}/run` (typically `~/.loop/run`) and passes it into the runner via `SetGatePolicy` and `SetDockerProxyDeps`. The directory lives under the user's home rather than `/run/loop` because macOS `/run` is on the read-only system volume; the same path works on Linux so there's one code path for both OSes. -2. Marshals the gate rule subset (`default_decision`, `path_rules`, `command_rules`, `file_rules`) to `{policyDir}/{containerID}/gate-policy.json` with mode `0640`. The on-disk wire format is snake_case throughout, matching `~/.loop/config.json`. +2. Marshals the gate rule subset (`default_decision`, `path_rules`, `command_rules`, `file_rules`) to `{policyDir}/{containerID}/gate-policy.json` with mode `0640`. The on-disk wire format is snake_case throughout, matching `~/.loop/config.json`. Two rules are injected into `file_rules` after the config merge: the workspace allow, and — pinned at the head — a `deny` on `/etc/loop/**` so the policy file cannot be rewritten by a rule the agent authored in its own project config. 3. Adds a bind-mount `{hostPolicyPath}:/etc/loop/gate-policy.json:ro` and sets `LOOP_GATE_ENABLED=1`, `LOOP_GATE_POLICY_FILE=/etc/loop/gate-policy.json`, `LOOP_CHANNEL_ID={channelID}`, and `LOOP_GATE_TOKEN={32-hex}` on the container env. The bearer token is shared with the docker proxy layer when both are enabled. The file is written before the container starts so the first in-container read always succeeds. On container removal, the runner calls `gateResolver.Remove(containerID)` which frees the Manager + token. Policy files are left on disk (overwritten next spawn with the same cid). diff --git a/docs/gates.md b/docs/gates.md index bf0e50ba..2c732950 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -171,22 +171,23 @@ Git write-side operations (`push`, `commit`, `reset --hard`, …) are intentiona 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 rules, first-match-wins, plus one dynamic workspace rule) +### File ops (`FileRule`, 9 static rules, first-match-wins, plus two injected rules) -Order matters: denies first, then the dynamically injected workspace rule, then the tmp / system-read fast-paths. +Order matters: the pinned policy self-deny, then the static denies, then the injected workspace rule, then the tmp / system-read fast-paths. | # | Paths | Operations | Decision | Why | |---|---|---|---|---| -| 1 | `/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) | -| 2 | `/etc/shadow`, `/etc/gshadow`, `/etc/sudoers`, `/etc/sudoers.d/**`, `/etc/ssh/ssh_host_*_key`, `.pub` variants | all ops | `deny` | Root credential files | -| 3 | `**/.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 | -| 4 | `.docker/config.json` and `.npmrc` under `/root/`, `/home/*/`, `/Users/*/` | write, create, delete, chmod | `deny` | 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 | -| 5 | `**/.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) | -| 6 | `/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/`, macOS host-home bind-mount `/Users/`) so test fixtures writing a `.bashrc` inside a t.TempDir() don't trip | -| 7 | `/etc/**`, `/usr/**`, `/bin/**`, `/sbin/**`, `/lib/**`, `/lib64/**`, `/boot/**` | write, create, delete, chmod, chown | `deny` | System paths — writes would mutate the container image outside the workspace | -| 8 | *(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 so cred-path denies still win inside the workspace | -| 9 | `/tmp/**`, `/var/tmp/**` | all ops | `allow` | OS tmp fast-path | -| 10 | `/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` | +| 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: both config layers *prepend* their rules, 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 | +| 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 | +| 5 | `.docker/config.json` and `.npmrc` under `/root/`, `/home/*/`, `/Users/*/` | write, create, delete, chmod | `deny` | 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/`, macOS host-home bind-mount `/Users/`) 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 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). @@ -306,7 +307,7 @@ Per container (`internal/container/runner.go#createAndStartContainer`): 1. Generate a 32-byte `crypto/rand` bearer token (`newGateToken`) — shared by the proxy and gate layers. 2. `writeProxyPolicyFile` marshals `cfg.Gates.DockerProxy` to `{policyDir}//proxy-policy.json` (0640). Bind-mount `hostSock:/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`. -3. `writeGatePolicyFile` marshals the gate rule subset (`DefaultDecision`, `PathRules`, `CommandRules`, `FileRules`) to `{policyDir}//gate-policy.json` (0640). 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`. +3. `writeGatePolicyFile` marshals the gate rule subset (`DefaultDecision`, `PathRules`, `CommandRules`, `FileRules`) to `{policyDir}//gate-policy.json` (0640). `FileRules` is the merged config list wrapped by `injectWorkspaceRule` (workspace allow, before the first Allow) and then `injectPolicySelfDenyRule` (`/etc/loop/**` deny, at the head — see [file-op rule 1](#file-ops-filerule-9-static-rules-first-match-wins-plus-two-injected-rules)). 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`. 4. Shared env on both layers: `LOOP_CHANNEL_ID=`, `LOOP_GATE_TOKEN=<32-hex>`. 5. After `ContainerCreate` returns a `containerID`, call `gateResolver.AddWithToken(containerID, token, mgr, channelID)` — the token starts authenticating HTTP calls as soon as the container is up. 6. On container remove: `gateResolver.Remove(containerID)` — frees the token and Manager. Policy files under `{policyDir}//` 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). diff --git a/internal/container/container_config.go b/internal/container/container_config.go index 50864d17..6a5e44c2 100644 --- a/internal/container/container_config.go +++ b/internal/container/container_config.go @@ -697,7 +697,7 @@ func (r *DockerRunner) writeGatePolicyFile(cfg *config.Config, channelID, workDi DefaultDecision: cfg.Gates.Agentgate.DefaultDecision, PathRules: cfg.Gates.Agentgate.PathRules, CommandRules: injectWorkspaceRmRfRule(cfg.Gates.Agentgate.CommandRules, workDir, parentDirPath), - FileRules: injectWorkspaceRule(cfg.Gates.Agentgate.FileRules, workDir, parentDirPath), + FileRules: injectPolicySelfDenyRule(injectWorkspaceRule(cfg.Gates.Agentgate.FileRules, workDir, parentDirPath)), } raw, _ := json.Marshal(payload) path := filepath.Join(dir, "gate-policy.json") @@ -862,3 +862,37 @@ func injectWorkspaceRule(rules []types.FileRule, workDir, parentDirPath string) out = append(out, rules[insertAt:]...) return out } + +// gatePolicyMountDir is where runner.go bind-mounts the gate and docker-proxy +// policy files (read-only) inside the agent container. +const gatePolicyMountDir = "/etc/loop" + +// injectPolicySelfDenyRule pins a Deny on the gate's own policy directory at +// the head of the file rules, so the gate cannot be talked out of protecting +// the file it was compiled from. +// +// Position matters. Both config layers author rules by *prepending* them +// (config.mergeProjectConfig), and the project layer is {workDir}/.loop/ +// config.json — which sits inside the workspace the agent may write. So a rule +// that reaches the policy through config can always be shadowed by an `allow` +// the agent writes for itself, taking effect on the next container start. +// Injecting here, after every merge, is the one position no config can precede. +// +// `link` belongs in the operation list even though the generic /etc/** deny +// omits it: linkat/symlinkat are matched on the *new* path only (see +// agentgate.syscallSpecs), so without it an agent could hardlink the policy +// file into the workspace — same filesystem, blanket-allowed — and write +// through the second name. Reads stay allowed; the file is not a secret and +// seeing the active policy is useful when debugging a denial. +func injectPolicySelfDenyRule(rules []types.FileRule) []types.FileRule { + self := types.FileRule{ + Paths: []string{gatePolicyMountDir + "/**"}, + Operations: []string{"write", "create", "delete", "chmod", "chown", "link"}, + Decision: types.DecisionDeny, + Message: "gate policy directory is read-only to the agent", + } + out := make([]types.FileRule, 0, len(rules)+1) + out = append(out, self) + out = append(out, rules...) + return out +} diff --git a/internal/container/gate_test.go b/internal/container/gate_test.go index 2caecb3c..132638e3 100644 --- a/internal/container/gate_test.go +++ b/internal/container/gate_test.go @@ -296,6 +296,73 @@ func (s *GateSuite) TestInjectWorkspaceRuleAppendsWhenNoAllow() { require.Equal(s.T(), types.DecisionAllow, out[1].Decision, "appended at end when no existing Allow") } +// --- injectPolicySelfDenyRule --- + +func (s *GateSuite) TestInjectPolicySelfDenyRuleShape() { + out := injectPolicySelfDenyRule(nil) + + require.Len(s.T(), out, 1) + require.Equal(s.T(), []string{"/etc/loop/**"}, out[0].Paths) + require.Equal(s.T(), types.DecisionDeny, out[0].Decision) + // link closes the hardlink route: linkat is matched on the new path, so a + // link into the (blanket-allowed) workspace would otherwise hand the agent + // a writable second name for the same inode. + require.Contains(s.T(), out[0].Operations, "link") + // Reads stay allowed — seeing the active policy helps debug a denial. + require.NotContains(s.T(), out[0].Operations, "read") +} + +// The whole point of injecting here rather than in the static defaults: config +// layers prepend their rules and first-match-wins, and the project layer lives +// in the agent-writable workspace. An allow the agent authors for itself must +// not be able to get in front of this deny. +func (s *GateSuite) TestInjectPolicySelfDenyRulePrecedesConfigSuppliedAllow() { + in := []types.FileRule{ + {Paths: []string{"/etc/**"}, Operations: []string{"write"}, Decision: types.DecisionAllow, Message: "project override"}, + {Paths: []string{"**/.ssh/**"}, Decision: types.DecisionDeny}, + } + out := injectPolicySelfDenyRule(in) + + require.Len(s.T(), out, 3) + require.Equal(s.T(), "gate policy directory is read-only to the agent", out[0].Message) + require.Equal(s.T(), "project override", out[1].Message, "config rules keep their order behind the pinned deny") +} + +func (s *GateSuite) TestWriteGatePolicyFilePinsSelfDenyFirst() { + sys := newDefaultMockSystem() + var captured []byte + sys.ExpectedCalls = nil + sys.On("MkdirAll", mock.Anything, mock.Anything).Return(nil) + sys.On("WriteFile", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { captured = append([]byte(nil), args.Get(1).([]byte)...) }). + Return(nil) + + s.runner.sys = sys + s.runner.policyDir = "/run/loop" + cfg := &config.Config{ + Gates: config.GatesConfig{ + Agentgate: config.AgentgateConfig{ + Enabled: true, + FileRules: []types.FileRule{ + {Paths: []string{"/etc/loop/**"}, Operations: []string{"write"}, Decision: types.DecisionAllow}, + }, + }, + }, + } + + _, err := s.runner.writeGatePolicyFile(cfg, "ch-1", "/host/work", "") + require.NoError(s.T(), err) + + var got gatePolicyJSON + require.NoError(s.T(), json.Unmarshal(captured, &got)) + require.Equal(s.T(), []string{"/etc/loop/**"}, got.FileRules[0].Paths) + require.Equal(s.T(), types.DecisionDeny, got.FileRules[0].Decision, "self-deny wins over a config allow on the same path") + // The config allow is the list's first Allow, so the workspace rule lands + // ahead of it — both still behind the pinned deny. + require.Equal(s.T(), "workspace fast-path", got.FileRules[1].Message) + require.Equal(s.T(), types.DecisionAllow, got.FileRules[2].Decision) +} + // --- injectWorkspaceRmRfRule --- func (s *GateSuite) TestInjectWorkspaceRmRfRuleEmptyWorkDirReturnsInput() { From 33bdbe840726d9f65d33b66a6dbf5d526fb96a8e Mon Sep 17 00:00:00 2001 From: Radu Topala Date: Tue, 28 Jul 2026 19:27:34 +0300 Subject: [PATCH 3/3] test(bdd): follow the deny variants behind the caret The split button moved "Deny with prompt" out of the card body and into the caret menu, so the two scenarios that asserted it on the page failed. Open the menu and assert there instead, and pin the one asymmetry between the surfaces while we're in here: chat's agent is an orchestrator run that owns a message queue and gets "Deny & stop run"; a terminal pane's agent is a TUI on the pane's stdin with nothing queued behind it, so that variant is withheld. --- .../frontend/journey_gate_approval.feature | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/component/features/frontend/journey_gate_approval.feature b/test/component/features/frontend/journey_gate_approval.feature index 29cda2a2..3fa225cc 100644 --- a/test/component/features/frontend/journey_gate_approval.feature +++ b/test/component/features/frontend/journey_gate_approval.feature @@ -19,7 +19,15 @@ Feature: Per-source gate approval routing Then I wait for text "/tmp/bdd-gate-chat-A.txt" to appear And the page should contain text "Allow once" And the page should contain text "Allow for session" - And the page should contain text "Deny with prompt" + And the page should contain text "Deny" + # The deny variants hang off a caret rather than sitting as their own + # pills — plain Deny is the only non-terminal one, so it stays the default. + When I click on the button with title "More deny options" + Then I wait for text "Deny with prompt" to appear + And the page should contain text "Deny for session" + # Chat's agent is an orchestrator run that owns a message queue, so the + # run-stopping variant is offered here. + And the page should contain text "Deny & stop run" Scenario: Missing source defaults to chat (back-compat) # Older agentgate builds and the non-Linux stub omit `source` from the @@ -35,7 +43,12 @@ Feature: Per-source gate approval routing When I add a "Docker Agent" panel And I inject a gate.approval_requested event with req_id "gate-term-A", source "terminal:newest-docker-agent", and target "/tmp/bdd-gate-term-A.txt" Then I wait for text "/tmp/bdd-gate-term-A.txt" to appear - And the page should contain text "Deny with prompt" + When I click on the button with title "More deny options" + Then I wait for text "Deny with prompt" to appear + And the page should contain text "Deny for session" + # A pane's agent is a TUI on the pane's stdin with nothing queued behind + # it, not an orchestrator run — so deny-and-stop is withheld there. + And the page should not contain text "Deny & stop run" Scenario: Gate for a non-existent terminal pane renders nowhere # source="terminal:agent-99" doesn't match any pane (the layout has none