fix: validate session IDs on resume/rewind to prevent arbitrary file write · Entire

fix: validate session IDs on resume/rewind to prevent arbitrary file write

8968a76→main·

Soph·1mo ago·4 files·+162 added/-0 removed

Session IDs read from checkpoint metadata on the shared entire/checkpoints/v1 branch flowed into agent.ResolveSessionFile + WriteSession during entire session resume and entire checkpoint rewind with no validation. A crafted absolute or "../"-laden session ID escaped the agent session directory (and for Codex/Pi, which return absolute IDs verbatim, landed anywhere), letting attacker-controlled transcript bytes overwrite arbitrary files such as ~/.bashrc — RCE on the next resume, with no prompt shown to the victim.

Validate the session ID with validation.ValidateSessionID at the two restore choke points before any path construction:

This mirrors the invariant already enforced when checkpoints are written, so it cannot reject a legitimately-created checkpoint while closing every separator/absolute traversal. Unsafe IDs are rejected (resolveTranscriptPath) or skipped with a warning (RestoreLogsOnly).

Adds end-to-end and choke-point regression tests proving a traversal session ID writes nothing outside the agent session directory.

Sessions

6963a4a931b1View transcript

Changes

4

// TestResumeSingleSession_RejectsPathTraversalSessionID is an end-to-end proof
// that a malicious session ID cannot cause an arbitrary file write during resume.
//
// The checkpoint transcript is stored under a benign session ID; the attack is the
// session ID that flows into path construction (in production this comes from the
// remote checkpoint metadata via readCheckpointInfoFromStore). A "../"-laden ID
// resolves to a path outside the agent's session directory. Before the fix,
// resumeSingleSession would resolve the path, write the attacker-controlled
// transcript there, and overwrite the sentinel — RCE if the target is e.g. a
// shell init file. The fix must reject the ID and write nothing.
func TestResumeSingleSession_RejectsPathTraversalSessionID(t *testing.T) {

tmpDir := t.TempDir()

t.Chdir(tmpDir)

repo, _, _ := setupResumeTestRepo(t, tmpDir, false)

if err := os.MkdirAll(filepath.Join(tmpDir, ".entire"), 0o755); err != nil {
    t.Fatalf("failed to create settings dir: %v", err)
}
if err := os.WriteFile(
    filepath.Join(tmpDir, ".entire", "settings.json"),
    []byte(`{"enabled": true}`),
    0o644,
); err != nil {
    t.Fatalf("failed to write settings: %v", err)
}

ctx := context.Background()
cpID := id.MustCheckpointID("dddddddddddd")
raw := []byte(`{"type":"user","message":{"content":[{"type":"text","text":"payload"}]}}` + "\n")

v1Store := checkpoint.NewGitStore(repo)
if err := v1Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
    CheckpointID: cpID,
    SessionID:    "benign-session",
    Strategy:     "manual-commit",
    Transcript:   redact.AlreadyRedacted(raw),
    AuthorName:   "Test",
    AuthorEmail:  "test@example.com",
}); err != nil {
    t.Fatalf("failed to write v1 checkpoint: %v", err)
}

sessionDir := filepath.Join(tmpDir, "sessions")
ag := &recordingResumeAgent{sessionDir: sessionDir}

// Sentinel lives outside the session directory; the traversal targets it.
// recordingResumeAgent.ResolveSessionFile appends ".jsonl".
victimDir := filepath.Join(tmpDir, "victim")
if err := os.MkdirAll(victimDir, 0o755); err != nil {
    t.Fatalf("failed to create victim dir: %v", err)
}
sentinel := filepath.Join(victimDir, "secret.jsonl")
if err := os.WriteFile(sentinel, []byte("SAFE"), 0o600); err != nil {
    t.Fatalf("failed to write sentinel: %v", err)
}

maliciousSessionID := "../victim/secret"

var stdout, stderr bytes.Buffer
err := resumeSingleSession(ctx, &stdout, &stderr, ag, maliciousSessionID, cpID, tmpDir, true)
if err == nil {
    t.Fatalf("resumeSingleSession() with traversal session ID = nil error, want rejection\nstdout: %s", stdout.String())
}
if ag.writtenSession != nil {
    t.Fatalf("resumeSingleSession() wrote a session despite malicious ID: ref=%s", ag.writtenSession.SessionRef)
}
got, readErr := os.ReadFile(sentinel)
if readErr != nil {
    t.Fatalf("failed to read sentinel: %v", readErr)
}
if string(got) != "SAFE" {
    t.Fatalf("sentinel was overwritten via path traversal: %q", string(got))
}
}

func TestResumeSingleSession_UsesV1Transcript(t *testing.T) {

tmpDir := t.TempDir()

t.Chdir(tmpDir)

}
// TestResolveTranscriptPath_RejectsTraversalSessionID verifies that session IDs
// containing path-traversal primitives are rejected before being used to build a
// filesystem write path.
//
// Session IDs reaching the resume/rewind restore paths originate from checkpoint
// metadata stored on the shared entire/checkpoints/v1 branch, which an attacker
// with push access can craft. Without validation, an absolute or "../"-laden
// session ID escapes the agent session directory (and for agents like Pi/Codex
// that return absolute paths verbatim, lands anywhere), letting attacker-controlled
// transcript bytes overwrite arbitrary files such as ~/.bashrc.
func TestResolveTranscriptPath_RejectsTraversalSessionID(t *testing.T) {

tmpDir := t.TempDir()
setupResumeTestRepo(t, tmpDir, false)
t.Chdir(tmpDir)

ag := &recordingResumeAgent{sessionDir: filepath.Join(tmpDir, "sessions")}
ctx := context.Background()

cases := []struct {
    name      string
    sessionID string
}{
    {"absolute unix path", "/tmp/entire-pwned"},
    {"absolute path to dotfile", filepath.Join(tmpDir, "victim", ".bashrc")},
    {"parent traversal", "../../../../../../tmp/entire-pwned"},
    {"backslash traversal", `..\..\..\evil`},
    {"embedded separator", "sessions/../../evil"},
    {"empty", ""},
}
for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        got, err := resolveTranscriptPath(ctx, tc.sessionID, ag)
        if err == nil {
            t.Fatalf("resolveTranscriptPath(%q) = %q, want error (traversal must be rejected)", tc.sessionID, got)
        }
    })
}
}

// TestResolveTranscriptPath_AllowsLegitSessionID is the regression guard ensuring
// the traversal check does not reject ordinary (UUID-style) session IDs.
func TestResolveTranscriptPath_AllowsLegitSessionID(t *testing.T) {

tmpDir := t.TempDir()
setupResumeTestRepo(t, tmpDir, false)
t.Chdir(tmpDir)

sessionDir := filepath.Join(tmpDir, "sessions")
ag := &recordingResumeAgent{sessionDir: sessionDir}
ctx := context.Background()

sessionID := "11111111-2222-3333-4444-555555555555"
got, err := resolveTranscriptPath(ctx, sessionID, ag)
if err != nil {
    t.Fatalf("resolveTranscriptPath(%q) unexpected error: %v", sessionID, err)
}
want := filepath.Join(sessionDir, sessionID+".jsonl")
if got != want {
    t.Fatalf("resolveTranscriptPath(%q) = %q, want %q", sessionID, got, want)
}
}