agent-postmortem 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,505 @@
1
+ """Exact strings the Claude Code runtime writes into transcripts.
2
+
3
+ This is the ONE place a marker literal may live. Detectors and the reader
4
+ import from here. Each constant carries a comment citing the fixture case
5
+ under tests/fixtures/runs that exercises the string (or, when no case on
6
+ disk carries it, the test that builds the event in memory) and the Claude
7
+ Code version range it was observed on. No comment here names a real run id;
8
+ DESIGN.md 3 requires that every marker comment cite a fixture case, or, when
9
+ none carries the string yet, the in-memory test that does, instead.
10
+
11
+ docs/DESIGN.md 4.13.2 (v0.3, N1): REGISTRY below is the marker provenance
12
+ table -- one MarkerSpec per canonical id, with its producer, the permitted
13
+ shape(s) reader.py evaluates it against, the candidacy fragments (the
14
+ constants above, or a dedicated candidate function for the one regex-based
15
+ id), and the anchored predicate. reader.py builds Attempt.marker_hits from
16
+ this; detectors import canonical ids from here, never fragments, and never
17
+ read Event.markers (kept only for compatibility, 4.13.2).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from typing import Callable
25
+
26
+ # A Workflow subagent transcript has no human in it. When the runtime kills an
27
+ # attempt it appends a user-role message starting with this prefix (observed
28
+ # as "[Request interrupted by user]" and "[Request interrupted by user for
29
+ # tool use]" on Claude Code 2.1.251 to 2.1.280). The bare form is what
30
+ # agent_postmortem/demo/wf_00000000-d30 carries.
31
+ KILL_SENTINEL_PREFIX = "[Request interrupted by user"
32
+
33
+ # Both observed full forms of the kill sentinel, on Claude Code 2.1.251 to
34
+ # 2.1.280. "for tool use" appears when the kill lands while a tool_use block
35
+ # is in flight; the bare form appears otherwise and is the one
36
+ # agent_postmortem/demo/wf_00000000-d30 carries (agent-ac200000000000001.jsonl
37
+ # and agent-ac200000000000003.jsonl). No fixture on disk carries the "for
38
+ # tool use" form yet; it is kept because the runtime emits it, not because a
39
+ # test currently exercises it.
40
+ KILL_SENTINEL_VARIANTS = (
41
+ "[Request interrupted by user]",
42
+ "[Request interrupted by user for tool use]",
43
+ )
44
+
45
+ # A Bash tool_result reporting that the command outlived its timeout and was
46
+ # detached to run in the background instead of being killed. Observed as
47
+ # "Command did not complete within its 30s timeout and was moved to the
48
+ # background (ID: ...)." on Claude Code 2.1.251 to 2.1.280; carried by
49
+ # tests/fixtures/runs/bash_backgrounded_timeout (agent-ac300000000000001.jsonl,
50
+ # line 3). The timeout value and background id vary per call, so match on the
51
+ # surrounding substrings only.
52
+ BASH_BACKGROUNDED = (
53
+ "did not complete within its",
54
+ "moved to the background",
55
+ )
56
+
57
+ # A Bash tool_result reporting a hard timeout kill. Observed as "Exit code
58
+ # 143\nCommand timed out after 10m 0s" on Claude Code 2.1.251 to 2.1.280;
59
+ # carried by tests/fixtures/runs/bash_backgrounded_timeout
60
+ # (agent-ac300000000000001.jsonl, line 5).
61
+ BASH_TIMEOUT = (
62
+ "Exit code 143",
63
+ "Command timed out after",
64
+ )
65
+
66
+ # Exit code the runtime uses for a Bash hard timeout kill (SIGTERM, 128+15),
67
+ # on Claude Code 2.1.251 to 2.1.280; carried as text by
68
+ # tests/fixtures/runs/reader_minimal
69
+ # (-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/
70
+ # wf_b0000000-002/agent-a1111111111111111.jsonl, line 3), which
71
+ # reader._parse_exit_code turns into the numeric Event.exit_code
72
+ # test_reader.py::test_tool_result_exit_code_and_is_error_parsed checks.
73
+ BASH_TIMEOUT_EXIT_CODE = 143
74
+
75
+ # A tool_result wrapping a tool-input validation failure. Observed as
76
+ # "<tool_use_error>InputValidationError: Read was called with input that
77
+ # could not be parsed as JSON." on Claude Code 2.1.251 to 2.1.280, and the
78
+ # same wrapper/prefix for other tools (docs/DESIGN.md 4.13.2's registry
79
+ # anchors on the full "<tool_use_error>InputValidationError:" prefix, on
80
+ # line1). No fixture on disk carries this marker; it is exercised by the
81
+ # in-memory run tests/test_detectors_tooling.py builds for tool_json_error.
82
+ TOOL_INPUT_INVALID = ("InputValidationError",)
83
+
84
+ # A StructuredOutput tool_result reporting the payload did not satisfy the
85
+ # schema. Two observed forms on Claude Code 2.1.251 to 2.1.280: a JSON Schema
86
+ # validation failure, "Output does not match required schema: root: must have
87
+ # required property '...'", carried by tests/fixtures/runs/structured_output_corrupt
88
+ # (agent-ac400000000000001.jsonl, line 3); and an unparsable payload,
89
+ # "<tool_use_error>InputValidationError: StructuredOutput was called with
90
+ # input that could not be parsed as JSON." (docs/DESIGN.md 4.13.2's registry
91
+ # anchors this form on the same wrapper/prefix as tool_input_invalid), which
92
+ # no fixture on disk carries.
93
+ STRUCTURED_OUTPUT_MISMATCH = (
94
+ "does not match required schema",
95
+ "could not be parsed as JSON",
96
+ )
97
+
98
+ # A sandbox write denial from the Codex plugin. Two observed forms on Claude
99
+ # Code 2.1.251 to 2.1.280: the verdict token an agent is instructed to emit
100
+ # when a write is rejected, "WRITE_DENIED: patch rejected: writing outside of
101
+ # the project; rejected by user approval settings", carried by
102
+ # tests/fixtures/runs/codex_write_denied (agent-ac500000000000001.jsonl, line
103
+ # 3); and prose narrating that a write was refused by the sandbox, for
104
+ # example "was rejected by the read-only sandbox" and "was blocked by the
105
+ # read-only sandbox", which no fixture on disk carries.
106
+ SANDBOX_WRITE_DENIED = (
107
+ "WRITE_DENIED",
108
+ "read-only sandbox",
109
+ )
110
+
111
+ # The rote shell guard's block message: "Security: <command> blocked in
112
+ # workspace" (observed as "Security: node blocked in workspace" on Claude
113
+ # Code 2.1.251 to 2.1.280); carried by tests/fixtures/runs/codex_write_denied
114
+ # (agent-ac500000000000002.jsonl, line 3).
115
+ SHELL_GUARD_BLOCK_REGEX = r"Security: \S+ blocked in workspace"
116
+
117
+ # The Codex companion's refusal to start: "Codex CLI is not installed or is
118
+ # missing required runtime support" opens a line of the failing companion
119
+ # call's output (docs/DESIGN.md 4.13.2).
120
+ CODEX_UNAVAILABLE = ("Codex CLI is not installed or is missing required runtime support",)
121
+
122
+ # The model classifier auto mode relies on to judge Bash safety went down.
123
+ # Observed as "claude-sonnet-5[1m] is temporarily unavailable (timed out), so
124
+ # auto mode cannot determine the safety of Bash right now." on Claude Code
125
+ # 2.1.251 to 2.1.280. No fixture on disk carries this marker; it is exercised
126
+ # by the in-memory run tests/test_detectors_tooling.py builds for
127
+ # classifier_unavailable. Both fragments are required: "temporarily
128
+ # unavailable" alone is generic enough that an unrelated tool or service
129
+ # outage would match it too, mislabeling it as a Bash-safety classifier
130
+ # outage.
131
+ CLASSIFIER_UNAVAILABLE = ("temporarily unavailable", "cannot determine the safety of Bash")
132
+
133
+ # A Bash tool_result showing the command ran outside any git repository.
134
+ # Observed as "fatal: not a git repository (or any of the parent
135
+ # directories): .git" on Claude Code 2.1.251 to 2.1.280. No fixture on disk
136
+ # carries this marker; it is exercised by the in-memory run
137
+ # tests/test_detectors_tooling.py builds for git_cwd_mismatch.
138
+ GIT_NOT_REPO = ("fatal: not a git repository",)
139
+
140
+ # A workflow attempt the journal closed with a `failed` event has, as the
141
+ # last entry of its own transcript, an assistant message the runtime itself
142
+ # appends -- never something the agent wrote. Observed on Claude Code 2.1.251
143
+ # to 2.1.280, 13 of 21 real `failed` events on this machine: "You've hit your
144
+ # session limit · resets 2pm" ("·" is U+00B7 MIDDLE DOT; the time
145
+ # after "resets " varies per case). Carried by
146
+ # tests/fixtures/runs/attempt_failed_causes (agent-af100000000000001.jsonl,
147
+ # last line). detectors/attempt_failed.py treats everything up to and
148
+ # including "resets " as this fixed prefix and the remainder of the line as
149
+ # the reset time.
150
+ USAGE_LIMIT_SESSION = ("You've hit your session limit · resets ",)
151
+
152
+ # Same shape as USAGE_LIMIT_SESSION for the weekly limit instead of the
153
+ # session one. Observed on Claude Code 2.1.251 to 2.1.280, 2 of 21 real
154
+ # `failed` events. Carried by tests/fixtures/runs/attempt_failed_causes
155
+ # (agent-af100000000000002.jsonl, last line).
156
+ USAGE_LIMIT_WEEKLY = ("You've hit your weekly limit · resets ",)
157
+
158
+ # A workflow attempt's last transcript entry stating the machine slept
159
+ # mid-request, appended by the runtime the same way the two usage-limit
160
+ # markers above are. Observed as "API Error: Your computer went to sleep" on
161
+ # Claude Code 2.1.251 to 2.1.280, 4 of 21 real `failed` events. Carried by
162
+ # tests/fixtures/runs/attempt_failed_causes (agent-af100000000000003.jsonl,
163
+ # last line). Unlike the usage limits, no reset time follows.
164
+ MACHINE_SLEEP = ("API Error: Your computer went to sleep",)
165
+
166
+ # A workflow attempt's last transcript entry stating the runtime's own API
167
+ # call failed mid-response. Observed as "API Error: Server error
168
+ # mid-response" on Claude Code 2.1.251 to 2.1.280, 1 of 21 real `failed`
169
+ # events. Carried by tests/fixtures/runs/attempt_failed_causes
170
+ # (agent-af100000000000004.jsonl, last line).
171
+ API_ERROR = ("API Error: Server error mid-response",)
172
+
173
+ # The runtime's own replies when a tool call is refused (G7, v0.3.1; Claude Code 2.1.251 to
174
+ # 2.1.280). Each is the whole text of an `is_error` tool result, or its start: the permission-rule
175
+ # sentence carries the denied command, so only its fixed head and tail are fixed here. Carried by
176
+ # the in-memory runs tests/test_g_denied.py builds.
177
+ PERMISSION_DENIED_AUTO_MODE = (
178
+ "Permission for this action was denied by the Claude Code auto mode classifier."
179
+ )
180
+ PERMISSION_DENIED_USER = "The user doesn't want to proceed with this tool use."
181
+ PERMISSION_DENIED_SAFETY_CHECK = (
182
+ "Permission for this command was denied by a built-in Claude Code safety check, "
183
+ "not by the user."
184
+ )
185
+ PERMISSION_DENIED_RULE_HEAD = "Permission to use "
186
+ PERMISSION_DENIED_RULE_TAIL = " has been denied."
187
+ # The sentence may span lines (a multi-line command): the first ` has been denied.` that ends a line
188
+ # closes it (G-B1, GB-3). The tool name is `[A-Za-z0-9_]+` and never empty.
189
+ _PERMISSION_RULE_RE = re.compile(
190
+ r"Permission to use [A-Za-z0-9_]+ with command .*? has been denied\.[ \t\r]*(?:\n|\Z)",
191
+ re.DOTALL,
192
+ )
193
+
194
+ DENIAL_AUTO_MODE = "auto_mode"
195
+ DENIAL_USER = "user"
196
+ DENIAL_PERMISSION_RULE = "permission_rule"
197
+ DENIAL_SAFETY_CHECK = "safety_check"
198
+
199
+
200
+ def denial_kind(text: str) -> str | None:
201
+ """Which denial a tool result's text is (docs/DESIGN.md 4.13.2, G7), or None. `text` has had
202
+ its leading whitespace stripped. The text must START with exactly one of the four sentences;
203
+ the permission-rule sentence may span lines and ends at the first ` has been denied.` that ends
204
+ a line (docs/DESIGN.md 4.13.2). No wrapper is accepted."""
205
+ if text.startswith(PERMISSION_DENIED_AUTO_MODE):
206
+ return DENIAL_AUTO_MODE
207
+ if text.startswith(PERMISSION_DENIED_USER):
208
+ return DENIAL_USER
209
+ if text.startswith(PERMISSION_DENIED_SAFETY_CHECK):
210
+ return DENIAL_SAFETY_CHECK
211
+ if text.startswith(PERMISSION_DENIED_RULE_HEAD):
212
+ if _PERMISSION_RULE_RE.match(text):
213
+ return DENIAL_PERMISSION_RULE
214
+ return None
215
+
216
+
217
+ def denial_prefix_kind(text: str) -> str | None:
218
+ """The kind by the start of a (possibly truncated) text only: for an event text already
219
+ anchored by `denial_kind` on the full block."""
220
+ text = text.lstrip(" \t\r\n")
221
+ for kind, sentence in (
222
+ (DENIAL_AUTO_MODE, PERMISSION_DENIED_AUTO_MODE),
223
+ (DENIAL_USER, PERMISSION_DENIED_USER),
224
+ (DENIAL_SAFETY_CHECK, PERMISSION_DENIED_SAFETY_CHECK),
225
+ (DENIAL_PERMISSION_RULE, PERMISSION_DENIED_RULE_HEAD),
226
+ ):
227
+ if text.startswith(sentence):
228
+ return kind
229
+ return None
230
+
231
+
232
+ def _candidate_permission_denied(text: str) -> bool:
233
+ return (
234
+ "denied by the Claude Code auto mode classifier" in text
235
+ or PERMISSION_DENIED_USER in text
236
+ or "denied by a built-in Claude Code safety check" in text
237
+ or (PERMISSION_DENIED_RULE_HEAD in text and PERMISSION_DENIED_RULE_TAIL in text)
238
+ )
239
+
240
+
241
+ def _predicate_permission_denied(text: str, shape: str) -> bool:
242
+ return denial_kind(text) is not None
243
+
244
+
245
+ # === Marker provenance registry (docs/DESIGN.md 4.13.2, v0.3 N1) ===========
246
+ #
247
+ # Shape tags: which kind of block reader.py may evaluate a given id against.
248
+ # A block whose shape is not in an id's `shapes` is never even a candidate
249
+ # for that id (docs/DESIGN.md 4.13.2: "A shape that is not listed for an id
250
+ # is never a hit for it").
251
+ SHAPE_USER_ENTRY = "user_entry" # the whole flattened text of a user entry
252
+ SHAPE_TOOL_RESULT_BASH = "tool_result_bash" # a tool_result paired to Bash
253
+ SHAPE_TOOL_RESULT_BASH_CODEX = "tool_result_bash_codex" # ...whose command
254
+ # runs codex as a program (docs/DESIGN.md 4.3)
255
+ SHAPE_TOOL_RESULT_ANY_EXCEPT_STRUCTURED_OUTPUT = "tool_result_any_except_structured_output"
256
+ SHAPE_TOOL_RESULT_STRUCTURED_OUTPUT = "tool_result_structured_output"
257
+ SHAPE_ASSISTANT_CODEX_ATTEMPT = "assistant_codex_attempt" # an assistant
258
+ # entry's whole text, only when the attempt also has a Bash call running
259
+ # codex as a program somewhere
260
+ SHAPE_TOOL_RESULT_ERROR = "tool_result_error" # a tool_result block with is_error true,
261
+ # whatever tool it is paired to (G7)
262
+ SHAPE_FINAL_ASSISTANT_ENTRY = "final_assistant_entry" # the attempt's own
263
+ # last transcript entry, only when that entry is an assistant entry
264
+
265
+
266
+ def _lines(text: str) -> tuple[str, str]:
267
+ """line1, line2 of flattened text (docs/DESIGN.md 4.13.2): the text up
268
+ to the first newline, and the line after it (or '' when there is none)."""
269
+ parts = text.split("\n", 2)
270
+ line1 = parts[0] if parts else ""
271
+ line2 = parts[1] if len(parts) > 1 else ""
272
+ return line1, line2
273
+
274
+
275
+ _EXIT_CODE_LINE_RE = re.compile(r"Exit code [0-9]+")
276
+
277
+
278
+ def _predicate_kill_sentinel(text: str, shape: str) -> bool:
279
+ return text.rstrip(" \t\r\n") in KILL_SENTINEL_VARIANTS
280
+
281
+
282
+ def _predicate_bash_backgrounded(text: str, shape: str) -> bool:
283
+ line1, _line2 = _lines(text)
284
+ return line1.startswith("Command did not complete within its ") and (
285
+ "moved to the background" in line1
286
+ )
287
+
288
+
289
+ def _predicate_bash_timeout(text: str, shape: str) -> bool:
290
+ line1, line2 = _lines(text)
291
+ return line1 == "Exit code 143" and line2.startswith("Command timed out after")
292
+
293
+
294
+ def _predicate_tool_input_invalid(text: str, shape: str) -> bool:
295
+ line1, _line2 = _lines(text)
296
+ return line1.startswith("<tool_use_error>InputValidationError:")
297
+
298
+
299
+ def _predicate_structured_output_mismatch(text: str, shape: str) -> bool:
300
+ line1, _line2 = _lines(text)
301
+ if line1.startswith("Output does not match required schema"):
302
+ return True
303
+ return line1.startswith("<tool_use_error>InputValidationError:") and (
304
+ "could not be parsed as JSON" in line1
305
+ )
306
+
307
+
308
+ def _predicate_classifier_unavailable(text: str, shape: str) -> bool:
309
+ line1, _line2 = _lines(text)
310
+ return "is temporarily unavailable" in line1 and "cannot determine the safety of Bash" in line1
311
+
312
+
313
+ def _predicate_git_not_repo(text: str, shape: str) -> bool:
314
+ line1, line2 = _lines(text)
315
+ if line1.startswith("fatal: not a git repository"):
316
+ return True
317
+ return bool(_EXIT_CODE_LINE_RE.fullmatch(line1)) and line2.startswith(
318
+ "fatal: not a git repository"
319
+ )
320
+
321
+
322
+ def _predicate_sandbox_write_denied(text: str, shape: str) -> bool:
323
+ lines = text.split("\n")
324
+ if any(
325
+ line.startswith("[codex]") and ("WRITE_DENIED" in line or "read-only sandbox" in line)
326
+ for line in lines
327
+ ):
328
+ return True
329
+ if shape == SHAPE_TOOL_RESULT_BASH_CODEX:
330
+ line1 = lines[0] if lines else ""
331
+ return line1.startswith("WRITE_DENIED")
332
+ return False
333
+
334
+
335
+ def _predicate_codex_unavailable(text: str, shape: str) -> bool:
336
+ return any(line.startswith(CODEX_UNAVAILABLE[0]) for line in text.split("\n"))
337
+
338
+
339
+ _SHELL_GUARD_COMPILED_RE = re.compile(SHELL_GUARD_BLOCK_REGEX)
340
+
341
+
342
+ def _predicate_shell_guard_block(text: str, shape: str) -> bool:
343
+ return any(_SHELL_GUARD_COMPILED_RE.match(line) for line in text.split("\n"))
344
+
345
+
346
+ def _candidate_shell_guard_block(text: str) -> bool:
347
+ return bool(_SHELL_GUARD_COMPILED_RE.search(text))
348
+
349
+
350
+ def _make_startswith_predicate(fragment: str) -> Callable[[str, str], bool]:
351
+ def _predicate(text: str, shape: str, _fragment: str = fragment) -> bool:
352
+ return text.startswith(_fragment)
353
+
354
+ return _predicate
355
+
356
+
357
+ # codex run as a program: the first word of a command segment (start of the
358
+ # command, or after ";", "&", "|", "(", or a newline), optionally
359
+ # path-qualified, followed by whitespace or end; or the codex-companion
360
+ # script named anywhere. Shared by the reader (to decide the
361
+ # tool_result_bash_codex / assistant_codex_attempt shapes) and, previously,
362
+ # detectors/codex_write_denied.py's own copy (docs/DESIGN.md 4.3).
363
+ _CODEX_PROGRAM_RE = re.compile(r"(^|[;&|(\n])\s*(\S*/)?codex(\s|$)|codex-companion\.mjs")
364
+
365
+
366
+ def runs_codex_program(command: str | None) -> bool:
367
+ """Whether a Bash command runs codex as a program (docs/DESIGN.md 4.3):
368
+ the first word of a command segment names codex (optionally
369
+ path-qualified), or the command names codex-companion.mjs anywhere. A
370
+ command that only mentions "codex" inside data (a heredoc body, a file
371
+ name) does not match."""
372
+ if not isinstance(command, str) or not command:
373
+ return False
374
+ return bool(_CODEX_PROGRAM_RE.search(command.lower()))
375
+
376
+
377
+ @dataclass(frozen=True)
378
+ class MarkerSpec:
379
+ """One row of the marker provenance registry (docs/DESIGN.md 4.13.2)."""
380
+
381
+ id: str
382
+ producer: str
383
+ shapes: tuple[str, ...]
384
+ fragments: tuple[str, ...] # candidacy fragments; () when is_candidate is set
385
+ predicate: Callable[[str, str], bool] # (flattened_text, matched_shape) -> anchored?
386
+ is_candidate: Callable[[str], bool] | None = None
387
+
388
+ def candidate(self, text: str) -> bool:
389
+ if self.is_candidate is not None:
390
+ return self.is_candidate(text)
391
+ return any(fragment in text for fragment in self.fragments)
392
+
393
+
394
+ REGISTRY: dict[str, MarkerSpec] = {
395
+ spec.id: spec
396
+ for spec in (
397
+ MarkerSpec(
398
+ id="kill_sentinel",
399
+ producer="harness_user",
400
+ shapes=(SHAPE_USER_ENTRY,),
401
+ fragments=(KILL_SENTINEL_PREFIX,),
402
+ predicate=_predicate_kill_sentinel,
403
+ ),
404
+ MarkerSpec(
405
+ id="bash_backgrounded",
406
+ producer="harness_tool_notice",
407
+ shapes=(SHAPE_TOOL_RESULT_BASH,),
408
+ fragments=BASH_BACKGROUNDED,
409
+ predicate=_predicate_bash_backgrounded,
410
+ ),
411
+ MarkerSpec(
412
+ id="bash_timeout",
413
+ producer="harness_tool_notice",
414
+ shapes=(SHAPE_TOOL_RESULT_BASH,),
415
+ fragments=BASH_TIMEOUT,
416
+ predicate=_predicate_bash_timeout,
417
+ ),
418
+ MarkerSpec(
419
+ id="tool_input_invalid",
420
+ producer="harness_tool_notice",
421
+ shapes=(SHAPE_TOOL_RESULT_ANY_EXCEPT_STRUCTURED_OUTPUT,),
422
+ fragments=TOOL_INPUT_INVALID,
423
+ predicate=_predicate_tool_input_invalid,
424
+ ),
425
+ MarkerSpec(
426
+ id="structured_output_mismatch",
427
+ producer="harness_tool_notice",
428
+ shapes=(SHAPE_TOOL_RESULT_STRUCTURED_OUTPUT,),
429
+ fragments=STRUCTURED_OUTPUT_MISMATCH,
430
+ predicate=_predicate_structured_output_mismatch,
431
+ ),
432
+ MarkerSpec(
433
+ id="classifier_unavailable",
434
+ producer="harness_tool_notice",
435
+ shapes=(SHAPE_TOOL_RESULT_BASH,),
436
+ fragments=CLASSIFIER_UNAVAILABLE,
437
+ predicate=_predicate_classifier_unavailable,
438
+ ),
439
+ MarkerSpec(
440
+ id="git_not_repo",
441
+ producer="command_output",
442
+ shapes=(SHAPE_TOOL_RESULT_BASH,),
443
+ fragments=GIT_NOT_REPO,
444
+ predicate=_predicate_git_not_repo,
445
+ ),
446
+ MarkerSpec(
447
+ id="sandbox_write_denied",
448
+ producer="codex_companion",
449
+ shapes=(SHAPE_TOOL_RESULT_BASH_CODEX, SHAPE_ASSISTANT_CODEX_ATTEMPT),
450
+ fragments=SANDBOX_WRITE_DENIED,
451
+ predicate=_predicate_sandbox_write_denied,
452
+ ),
453
+ MarkerSpec(
454
+ id="shell_guard_block",
455
+ producer="codex_companion",
456
+ shapes=(SHAPE_TOOL_RESULT_BASH_CODEX,),
457
+ fragments=(),
458
+ is_candidate=_candidate_shell_guard_block,
459
+ predicate=_predicate_shell_guard_block,
460
+ ),
461
+ MarkerSpec(
462
+ id="codex_unavailable",
463
+ producer="codex_companion",
464
+ shapes=(SHAPE_TOOL_RESULT_BASH_CODEX,),
465
+ fragments=CODEX_UNAVAILABLE,
466
+ predicate=_predicate_codex_unavailable,
467
+ ),
468
+ MarkerSpec(
469
+ id="permission_denied",
470
+ producer="harness_tool_notice",
471
+ shapes=(SHAPE_TOOL_RESULT_ERROR,),
472
+ fragments=(),
473
+ is_candidate=_candidate_permission_denied,
474
+ predicate=_predicate_permission_denied,
475
+ ),
476
+ MarkerSpec(
477
+ id="usage_limit_session",
478
+ producer="runtime_assistant",
479
+ shapes=(SHAPE_FINAL_ASSISTANT_ENTRY,),
480
+ fragments=USAGE_LIMIT_SESSION,
481
+ predicate=_make_startswith_predicate(USAGE_LIMIT_SESSION[0]),
482
+ ),
483
+ MarkerSpec(
484
+ id="usage_limit_weekly",
485
+ producer="runtime_assistant",
486
+ shapes=(SHAPE_FINAL_ASSISTANT_ENTRY,),
487
+ fragments=USAGE_LIMIT_WEEKLY,
488
+ predicate=_make_startswith_predicate(USAGE_LIMIT_WEEKLY[0]),
489
+ ),
490
+ MarkerSpec(
491
+ id="machine_sleep",
492
+ producer="runtime_assistant",
493
+ shapes=(SHAPE_FINAL_ASSISTANT_ENTRY,),
494
+ fragments=MACHINE_SLEEP,
495
+ predicate=_make_startswith_predicate(MACHINE_SLEEP[0]),
496
+ ),
497
+ MarkerSpec(
498
+ id="api_error",
499
+ producer="runtime_assistant",
500
+ shapes=(SHAPE_FINAL_ASSISTANT_ENTRY,),
501
+ fragments=API_ERROR,
502
+ predicate=_make_startswith_predicate(API_ERROR[0]),
503
+ ),
504
+ )
505
+ }