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.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- 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
|
+
}
|