Skip to content

isolation: the per-task HOME volume persists across turns, so a poisoned turn can arm the next one #88

Description

@naashw

Intent

The agent HOME volume is created once per task and reused by every turn of that task. bootstrapAgentHome (task-isolation.ts:1163-1171) returns early when the volume already exists:

if (await inspectOk(exec, opts.runtime, 'volume', volume)) {
  return { volume, credentials: 'already-bootstrapped' }
}

Inside the cage, the claude command is rewritten to always carry --dangerously-skip-permissions (task-isolation.ts:1584-1586). That trade is deliberate and documented (:1558-1561): "the container IS the guarantee. Nothing here restricts settings or MCP: a hostile repo file can only reach the cage."

The reasoning holds for a single turn. It does not hold across turns, because the HOME volume is exactly the part of the cage that survives one. CAGE_HOME_DIR is where the agent CLI reads its own settings, hooks and MCP configuration, and the agent has full write access to it with permission prompts disabled.

The consequence is that "reaching the cage" is no longer bounded by the turn: a turn driven by untrusted input (a hostile diff, a forge issue body parsed by forge-issues.ts, fetched web content) can write agent configuration that a later turn of the same task executes before it has read anything. The worktree those later turns write to is then committed from the host by the runner, so the effect leaves the cage through the commit path rather than through the container boundary.

Scope note: the volume is per task (agentHomeVolume(taskId), :1163), so this is turn-to-turn within one task (agent turn, review turn, fix turn, resumed turn), not task-to-task.

Scope

In scope:

  • The lifetime of the per-task HOME volume across turns.
  • Agent-owned configuration inside CAGE_HOME_DIR (settings, hooks, MCP servers).

Out of scope:

  • --dangerously-skip-permissions itself. The container is the right boundary for a single turn.
  • Provider session persistence, which is the legitimate reason the volume outlives a turn.
  • The policy fallback mode, which has no cage at all and is tracked separately.

Approach

Separate what must persist between turns (provider session/credentials) from what must not (executable configuration). Either reset the agent-configuration paths in the volume at the start of every turn, or split HOME into a persisted credential volume and a per-turn ephemeral overlay.

ADDED Requirements

Requirement: Agent configuration MUST NOT survive a turn

The system SHALL ensure that agent-executable configuration written inside the cage during one turn is not in effect at the start of any later turn of the same task.

Scenario: a poisoned turn tries to arm the next one

  • GIVEN a task whose turn 1 processes untrusted input
  • AND that turn writes a hook or MCP server definition under CAGE_HOME_DIR
  • WHEN turn 2 of the same task starts in the same cage
  • THEN the configuration written by turn 1 is absent
  • AND turn 2 starts from the same agent configuration as turn 1 did

Scenario: provider session still survives

  • GIVEN a task whose HOME volume was bootstrapped with credentials
  • WHEN a later turn of that task starts
  • THEN the provider session is still usable
  • AND no re-bootstrap of credentials is required

Tasks

1. Establish the boundary

  • 1.1 Enumerate the paths under CAGE_HOME_DIR that the supported agents read as executable configuration (claude, opencode)
  • 1.2 Decide per path: persisted (credentials/session) or reset-per-turn (settings, hooks, MCP)

2. Enforce it

  • 2.1 Reset the per-turn paths before each turn, in bootstrapAgentHome or at the container run seam
  • 2.2 Keep the early return for credentials so no re-bootstrap happens

3. Prove it

  • 3.1 Test: a file written to a reset path during turn 1 is absent in turn 2
  • 3.2 Test: the credential path written at bootstrap is still present in turn 2

Activity

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

    bugSomething isn't workingseverity:majorMajor severity review finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions