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:
- resolveTranscriptPath (covers resume-single, rewind restore, attach)
- RestoreLogsOnly write + status loops (multi-session resume/rewind)
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
cmd/entire/cli
Mresume_test.go+76
strategy
Mmanual_commit_rewind.go+14
Mtranscript.go+10
Mtranscript_test.go+62
// 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)
}
}