Skip to content

feat: add readonly mode with bash safety and spawn child filtering#7

Open
ofriw wants to merge 87 commits into
mainfrom
feat/readonly
Open

feat: add readonly mode with bash safety and spawn child filtering#7
ofriw wants to merge 87 commits into
mainfrom
feat/readonly

Conversation

@ofriw

@ofriw ofriw commented May 27, 2026

Copy link
Copy Markdown
Contributor

Note: This PR was generated by an AI agent. If you would like to talk with other humans, drop by our Discord!


Readonly Mode: OS-Level Sandboxing with Token-Efficient Guardrails

What changed and why

This PR implements the readonly mode requested in issue #2, but significantly stronger than the original proposal. Instead of relying solely on command-pattern filtering – which models consistently escaped by spawning interpreters (python -c, node -e, etc.) or using indirect write mechanisms – we built a two-layer enforcement with an OS-level sandbox as the primary barrier.

On macOS, commands run through sandbox-exec with a Seatbelt profile that denies all file-write operations except to the OS temp dir and /dev/null. On Linux, bubblewrap (bwrap) mounts the root filesystem read-only with a writable tmpfs overlay at the temp dir. On Windows (no native sandboxing), or when the OS tool is missing, we fall back to a comprehensively rewritten command-pattern classifier that understands shell pipelines, redirects, command substitutions, interpreter -c flags, xargs, sed -i, package manager mutations, and more.

Beyond bash, the PR blocks write, edit, and handoff at the tool-call layer, with a cache-aware strategy: parent sessions keep these tools in the tool list (to avoid context-cache invalidation) and block at call time, while child spawn sessions remove them entirely since they start fresh. We also gated the /handoff command in readonly mode, added TUI indicators, watchdog nudges, and context-hook messages.

Also tuned the context primer for token efficiency: added a Plan-then-execute section, simplified primacy-zone guidance, and rephrased spawn section for directness.

Value and implications

For projects running token-cost-sensitive coding agents, this is a meaningful UX and cost optimization. The model keeps its entire tool list (maximizing cache hit rates) while being unable to mutate the filesystem outside a controlled scratch space. The OS sandbox is explicitly not a security boundary – it is an active guardrail that prevents accidental writes from agentic loops, interpreter injections, or misdirected redirects. The fallback classifier on Windows means the feature works cross-platform even without OS sandbox tooling.

Breaking changes

None. Readonly is off by default, toggled on demand via /readonly, Ctrl+Shift+R, or the --readonly CLI flag. Existing workflows are unaffected.


Attached is an agent optimized description of the changes in this PR - AGENT_REVIEW.md

@grzegorznowak

Copy link
Copy Markdown
Collaborator

Summary: GH-2’s readonly-mode safety value is aligned, but concrete bash mutation bypasses and a no-UI toggle crash mean the PR should not merge as-is.

Business / Product Assessment

Verdict: REQUEST CHANGES

Strengths

  • The PR implements the requested opt-in readonly mode with a CLI flag, /readonly toggle, persisted branch entries, and runtime blocking for write/edit/handoff tools. Sources: work/pr_context.json:5, index.ts:63, index.ts:72, index.ts:127
  • Readonly behavior is propagated to spawned children by filtering write/edit and installing a readonly bash override when bash is inherited. Sources: spawn/index.ts:269, spawn/index.ts:274, spawn/index.ts:278
  • User-facing guidance was added through a status indicator and readonly-specific nudges so the mode is visible to users and the agent. Sources: tui.ts:54, index.ts:306, index.ts:318

In Scope Issues

  • Readonly bash still allows concrete mutation paths through git mixed commands and unparsed command substitution. Sources: readonly-bash.ts:45, readonly-bash.ts:239, readonly-bash.ts:241, readonly-bash.ts:242, readonly-bash.ts:312

    High severity · Medium likelihood

    Why: Readonly mode is sold as blocking destructive bash during exploratory sessions. git branch new-name, git tag v1, or git status $(git add .) can still mutate repository state while readonly is enabled.

    Assumptions / Preconditions: The agent invokes bash while readonly mode is active.

    Downgrade Factors: This PR describes readonly as guardrails rather than a security boundary, but these are ordinary git commands an agent may plausibly use.

    Code Trail: The classifier splits only on shell separators and quote state, not command substitution. A segment is sent to the git allowlist only when the whole trimmed segment starts with git. Separately, branch and tag mixed policies accept any non-flag argument, which includes ref-creating forms.

    Reproduction: In readonly mode, ask bash to run git branch review-temp or git status $(git add .). The classifier path can return safe instead of blocking the mutation.

  • /readonly is not safe in no-UI contexts and can throw after partially applying state. Sources: index.ts:69, index.ts:72, index.ts:73, index.ts:74, tui.ts:32

    Medium severity · Medium likelihood

    Why: The toggle mutates state and persists an entry before unconditionally calling ctx.ui.notify. In a headless command context, users can end up with readonly toggled and persisted even though the command failed.

    Assumptions / Preconditions: /readonly or the idle shortcut is invoked with ctx.hasUI === false or without ctx.ui.

    Downgrade Factors: Most interactive use likely has a UI, but other commands in this extension already support no-UI operation.

    Code Trail: toggleReadonly flips state.readonlyEnabled, sets readonlyNudgePending, and appends agenticoding-readonly; updateIndicators no-ops when there is no UI, then ctx.ui.notify is dereferenced without checking ctx.hasUI.

    Reproduction: Invoke the registered readonly command with a context shaped like { hasUI: false, getContextUsage: () => null }. State is changed before the notify call throws.

Out of Scope Issues

  • None.

Technical Assessment

Verdict: REQUEST CHANGES

Input Boundary Shape Risk Assessment

Status: Triggered
Boundary: raw tool-call input and persisted session branch entries -> stricter readonly command and rehydration assumptions.
Evidence / mitigation: event.input.command is cast directly to string before classification, and the classifier immediately expects string-like behavior; tests cover valid { command: string } inputs but not missing or malformed bash input. Branch rehydration also scans raw branch entries, though current issue evidence is strongest at the bash tool boundary. Sources: index.ts:136, index.ts:137, readonly-bash.ts:45, agenticoding.test.ts:3994

Strengths

  • The readonly classifier is factored behind a reusable isSafeReadonlyCommand API and is reused by both parent tool-call blocking and child bash override enforcement. Sources: readonly-bash.ts:307, index.ts:138, spawn/index.ts:141
  • The PR adds focused static coverage for tool blocking, child tool filtering, session rehydration, readonly nudges, shortcut gating, and state reset. Sources: agenticoding.test.ts:3922, agenticoding.test.ts:4005, agenticoding.test.ts:4183, agenticoding.test.ts:4604

In Scope Issues

  • The parent bash tool boundary does not validate command before classification. Sources: index.ts:136, index.ts:137, readonly-bash.ts:45, agenticoding.test.ts:3994

    Medium severity · Medium likelihood

    Why: A malformed bash tool payload should be blocked cleanly in readonly mode. Instead, missing command can throw, and some non-string values can be treated as empty/safe because the runtime cast does not validate shape.

    Assumptions / Preconditions: The framework or model supplies malformed bash tool input while readonly mode is active.

    Downgrade Factors: Normal bash calls likely provide { command: string }, and existing tests cover that happy path.

    Code Trail: The tool-call handler casts event.input.command to string and passes it to isSafeReadonlyCommand. splitUnquotedShellSegments then reads cmd.length, so the boundary depends on runtime string shape without a guard.

    Reproduction: Call the readonly tool_call handler with { toolName: "bash", input: {} } or a non-string command. The handler does not return a controlled readonly block result.

Out of Scope Issues

  • None.

Reusability

  • buildChildToolNames remains exported and testable, keeping child tool inheritance and readonly filtering easy to validate independently. Sources: spawn/index.ts:126, agenticoding.test.ts:4005
  • buildNudge centralizes readonly/topic guidance, with tests for readonly topic, no-topic, and boundary-hint cases. Sources: watchdog.ts:15, agenticoding.test.ts:3354, agenticoding.test.ts:3373

review generated with CURe v. 0.7.3 · multi-stage - stages: 4 · sha 6540936 · model gpt-5.5/high · tok 8m/82k/8m · session agenticoding-pi-agenticoding-pr7-20260528-053241-21cc · 16m32s

ofriw added 30 commits June 10, 2026 19:49
Merge conflicts resolved:
  - agenticoding.test.ts: accept main's deletion, port ~2000 lines
    of readonly tests into tests/unit/ (6 new files, 198 tests)
  - notebook/rehydration.ts: take main's typed approach, add null
    guard for malformed branch entries
  - package.json: union of both sides' devDependencies
  - handoff.test.ts, watchdog.test.ts: update expectations for
    readonly-aware pendingRequestedHandoff shape
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants