diff --git a/internal/transcript/codex.go b/internal/transcript/codex.go index 528b1b3..18670fb 100644 --- a/internal/transcript/codex.go +++ b/internal/transcript/codex.go @@ -32,11 +32,65 @@ func isCodexUserPrompt(e map[string]any) bool { case "event_msg": return getStr(p, "type") == "user_message" && pyStrip(getStr(p, "message")) != "" case "response_item": - return getStr(p, "type") == "message" && getStr(p, "role") == "user" + if getStr(p, "type") != "message" || getStr(p, "role") != "user" { + return false + } + // Text that is nothing but Codex's own injected context is not a + // prompt; an image-only message (no text at all) still is. + if text := codexUserText(p); text != "" && stripCodexInjected(text) == "" { + return false + } + return true } return false } +// codexUserText joins the text parts of a role=user response_item payload, +// whichever shape Codex used for content (a string or a list of parts). +func codexUserText(p map[string]any) string { + switch c := p["content"].(type) { + case string: + return c + case []any: + var b strings.Builder + for _, x := range c { + bm, ok := x.(map[string]any) + if !ok { + continue + } + switch getStr(bm, "type") { + case "input_text", "text", "output_text": + b.WriteString(getStr(bm, "text")) + } + } + return b.String() + } + return "" +} + +// Codex opens a thread, and re-opens it after a cwd change, by injecting its +// own context as role=user response_items: the AGENTS.md chain wrapped in +// (older builds: ), usually with an +// block of cwd/shell/date/permissions appended, and a +// bare later on. Nobody typed those. Captured as +// prompts they open every Codex log with a ~10 KB copy of AGENTS.md, which a +// brain fold then reads as owner instructions and re-learns in every +// project. Anchored at the start of the text and closed by the end tag, so a +// prompt that merely mentions a tag, or an unclosed block, is left alone. +var codexInjectedRe = regexp.MustCompile(`(?s)\A\s*(?:# AGENTS\.md instructions.*?|.*?|.*?)\s*`) + +// stripCodexInjected removes every leading injected block from a user +// message's text. A message that is only injected context strips to "". +func stripCodexInjected(text string) string { + for { + loc := codexInjectedRe.FindStringIndex(text) + if loc == nil || loc[1] == 0 { + return text + } + text = text[loc[1]:] + } +} + // codexPromptText is _codex_prompt_text: the stripped user prompt carried by // an event, or "". func codexPromptText(e map[string]any) string { @@ -50,23 +104,7 @@ func codexPromptText(e map[string]any) string { if getStr(p, "type") != "message" || getStr(p, "role") != "user" { return "" } - switch c := p["content"].(type) { - case string: - return pyStrip(c) - case []any: - var b strings.Builder - for _, x := range c { - bm, ok := x.(map[string]any) - if !ok { - continue - } - switch getStr(bm, "type") { - case "input_text", "text", "output_text": - b.WriteString(getStr(bm, "text")) - } - } - return pyStrip(b.String()) - } + return pyStrip(stripCodexInjected(codexUserText(p))) } return "" } diff --git a/internal/transcript/codex_injected_test.go b/internal/transcript/codex_injected_test.go new file mode 100644 index 0000000..14d4d9f --- /dev/null +++ b/internal/transcript/codex_injected_test.go @@ -0,0 +1,65 @@ +package transcript + +// Codex opens a thread by injecting its own context as role=user +// response_items: the AGENTS.md chain wrapped in (with an +// block appended) and, after a cwd change, a bare +// . Nobody typed those. Captured as prompts they open +// every Codex log with a ~10 KB copy of AGENTS.md, which a brain fold then +// reads as owner instructions and re-learns in every project. + +import ( + "strings" + "testing" +) + +const codexInjected = `{"type":"session_meta","payload":{"cwd":"/codex/inj"}} +{"type":"turn_context","payload":{"collaboration_mode":{"settings":{"model":"gpt-5.3"}}}} +{"type":"response_item","timestamp":"2026-08-01T00:00:00Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions for /codex/inj\n\n\n## House rules\n\n- Never push to main.\n\n\n /codex/inj\n zsh\n"}]}} +{"type":"response_item","timestamp":"2026-08-01T00:00:01Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"fix the flaky test"}]}} +{"type":"response_item","timestamp":"2026-08-01T00:00:05Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Pinned the clock."}]}} +{"type":"event_msg","timestamp":"2026-08-01T00:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}} +{"type":"response_item","timestamp":"2026-08-01T00:01:00Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n /codex/inj/sub\n"}]}} +{"type":"response_item","timestamp":"2026-08-01T00:01:01Z","payload":{"type":"message","role":"user","content":"second task"}} +{"type":"response_item","timestamp":"2026-08-01T00:01:05Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}} +{"type":"event_msg","timestamp":"2026-08-01T00:01:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20,"cached_input_tokens":0,"output_tokens":3}}}} +` + +func TestCodexInjectedContextIsNotAPrompt(t *testing.T) { + t.Parallel() + path := writeFixture(t, "codex-injected.jsonl", codexInjected) + want := []string{ + `dt=2026-08-01T00:00:01Z|cwd=/codex/inj|prompt="fix the flaky test"|texts=["Pinned the clock."]|tools=|files=|turn_ts=2026-08-01T00:00:06Z|tok=10/5/0/0|model=gpt-5.3`, + `dt=2026-08-01T00:01:01Z|cwd=/codex/inj|prompt="second task"|texts=["Done."]|tools=|files=|turn_ts=2026-08-01T00:01:06Z|tok=20/3/0/0|model=gpt-5.3`, + } + // Injected context is never a prompt, whichever way synthetic prompts + // (the markers) are treated. + checkTurns(t, Turns(path, 0, true), want) + checkTurns(t, Turns(path, 0, false), want) + for _, turn := range Turns(path, 0, false) { + if strings.Contains(turn.Prompt, "AGENTS.md") || strings.Contains(turn.Prompt, "environment_context") { + t.Errorf("Codex-injected context leaked into a prompt: %q", turn.Prompt) + } + } +} + +func TestStripCodexInjected(t *testing.T) { + t.Parallel() + cases := []struct{ name, in, want string }{ + {"instructions-only", "# AGENTS.md instructions for /x\n\n\n- rule\n", ""}, + {"instructions-then-environment", "# AGENTS.md instructions\n\n\n- rule\n\n\n /x\n", ""}, + {"environment-only", "\n /x\n", ""}, + {"legacy-user-instructions", "\nbe terse\n", ""}, + {"environment-then-typed-text", "\n /x\n\n\nfix the flaky test", "fix the flaky test"}, + {"typed-text-untouched", "fix the flaky test", "fix the flaky test"}, + {"prose-mention-untouched", "why does appear in my logs?", "why does appear in my logs?"}, + {"unclosed-block-untouched", "\n /x", "\n /x"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := stripCodexInjected(c.in); got != c.want { + t.Errorf("stripCodexInjected(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +}