Skip to content

[security] Kernel enforcement can be absent without anyone noticing #123

Closed

Description

@yusiwen

Context

The containment layer is platform- and kernel-dependent. openat2 needs Linux >= 5.6; the no-follow walk needs openat, which other platforms lack. Where that is missing, kernelEscapeCheck returns false and openBeneathRoot reports "unsupported", so the caller falls back to a plain open after the portable userspace checks.

The fallback is correct and deliberate, but it is a capability downgrade that nothing reports. It is visible only in code comments and debug logs. Neither the tool result nor the session record says "this ran with userspace checks only", so:

  • a caller that needs a hard boundary cannot tell the difference;
  • a silent downgrade cannot be caught in CI, on a platform, or after a kernel change;
  • the same will be true of the command-confinement backend once it exists (there it is worse: no backend means no boundary at all).

What to build

  • An enforcement fact per operation — at minimum kernel vs userspace, extended to full/partial once a command backend exists — reported in the tool result next to the existing sandbox facts and persisted with the session.
  • A startup probe that records which containment mechanisms and which command-confinement backend are actually usable, and surfaces the verdict once (log + runtime context) instead of failing at first use.
  • An opt-in "require a hard boundary" setting: when on, an operation that would fall back to userspace-only checks is refused with a clear error rather than silently weakened.

Acceptance

  • A test that disables the kernel path asserts the result carries the userspace fact; with the hard-boundary requirement on, the same operation is refused.
  • On a platform without openat2, the fact is visible in a tool result — not only in a log line.
  • The startup verdict is deterministic and testable on a machine where the backend is missing, and a test asserts the probe runs once per process.
  • The fact appears in the persisted session record, so a later reader can tell how an operation was enforced.

Related: #121 (kernel boundary for bash).

Verification

  • Go. The enforcement fact is kernel where the kernel path is active and userspace where it is disabled; with "require a hard boundary" on, the same operation is refused. The startup probe runs once per process, and its verdict is deterministic on a host without the backend.
  • Persistence. The fact is asserted in the written session record, so a later reader can tell how an operation was enforced.
  • Scenario — only if the fact gets a screen representation. A fact that lives in a tool result is invisible: the TUI renders tool calls, not result text, and nothing else shows it. Recommended companion: a /sandbox command (or a status-bar indicator) printing the active backend, the enforcement level, and the effective writable roots. Then tui/testdata/scenarios/confine-status.scenario asserts that output, including its degraded wording on a host without the kernel path — and a silent downgrade becomes visible to the person running the agent, not only to a debug log. Without that representation this issue stays Go-only.

Landing plan and progress: #127.

Activity

  1. added
    securitySecurity hardening, sandbox or SSRF residual
    on Oct 8, 2026
  2. yusiwen commented on Oct 8, 2026

    @yusiwen
    OwnerAuthor

    Landed in PR #129, rebase-merged onto master as 67397cb.

    What landed

    • ContainmentInfo() probes once per process for the mechanism the open layer will actually use, and names it: openat2 RESOLVE_BENEATH, the component walk, or in-process checks.
    • A mutation's success result carries the containment note only when the level is not kernel, so a degraded host is visible in the result the model reads and ordinary results are unchanged.
    • /sandbox prints level, mechanism, whether a hard boundary is required, and the effective writable roots.
    • sandbox.require_hard_boundary refuses an operation that would be enforced by in-process checks alone, including when no project root is configured.
    • The verdict is persisted with the session.
    • The walk's comment claimed macOS has "no kernel-side containment at all"; corrected — the walk refuses a swapped component with ELOOP, which is a kernel decision attached to the open.

    Measurements

    • go test ./... -count=1 → 13 packages ok / 0 failed; -race → 13 packages ok / 0 failed; make staticcheck exit 0; gofmt -l empty; go vet ./... clean.
    • New tests: probe runs once (5 calls, 1 probe); note empty on a kernel host and present otherwise; a real apply_patch mutation under a disabled kernel path carries the note; the hard-boundary policy proceeds on a kernel host, refuses with ErrHardBoundaryUnavailable on a userspace host and with no project root, and leaves the weaker boundary usable when off; the fact survives Flush/Load and appears in the raw JSON.
    • tui/testdata/scenarios/sandbox-command.scenario → ok 13 steps; the rendered screen reads containment: kernel (openat walk, O_NOFOLLOW per component) on macOS and lists one writable root.
    • CI on PR feat(tool): report what confines file operations, and refuse to weaken it #129: 8/8 checks green on ad5a059; annotations at baseline, no new ones.

    Acceptance sentence reinterpreted, not fudged. The issue said "on a platform without openat2, the fact is visible in a tool result". macOS has no openat2 but does enforce the boundary through the component walk, so pinning "no openat2 ⇒ userspace" would have misreported a real boundary as missing. The level is therefore kernel for both kernel mechanisms, and userspace means no kernel mechanism at all (the !linux && !darwin build). Recorded in the PR under "Honest scope".

    Handed off. #130: the first CI run of this PR failed in permission-allow-always.scenario at wait-exit, because two Ctrl+C presses that both land while the run is streaming cancel the run and arm no quit. Diagnosed, filed with a capture recipe, and fixed separately rather than folded in here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity hardening, sandbox or SSRF residual

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions