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,94 @@
1
+ """Detects a Bash command the runtime hard-killed after its timeout.
2
+
3
+ DESIGN.md 4.3: minor severity, fires when a Bash tool_result shows exit code
4
+ 143 (BASH_TIMEOUT_EXIT_CODE, read from Event.exit_code directly -- never a
5
+ marker) or carries an anchored, not-in-payload 'bash_timeout' MarkerHit
6
+ (docs/DESIGN.md 4.13.2: the registry's own predicate already requires
7
+ line1 == 'Exit code 143' and line2 to start with 'Command timed out after').
8
+ These two triggers are independent (M1 review): an exit code alone, with no
9
+ explanatory second line (an in-memory build, a truncated result), must still
10
+ count. metrics: count. rule_id: R-NO-LONG-BASH.
11
+
12
+ Title (field milestone F, P10): "hit a hard timeout" only when every hit carries the anchored
13
+ marker; when any hit is the exit code alone, "ended with exit code 143 (a hard timeout or
14
+ another termination)". Severity and metrics do not change.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .. import severity as severity_rules
20
+ from ..markers import BASH_TIMEOUT_EXIT_CODE
21
+ from ..models import Event, Finding, Run
22
+ from .base import (
23
+ KINDS_BOTH,
24
+ Detector,
25
+ anchored_marker_hits,
26
+ event_for_marker_hit,
27
+ evidence_line,
28
+ is_bash_event,
29
+ make_finding_id,
30
+ single_agent_id,
31
+ tooling_attempts,
32
+ )
33
+
34
+ ID = "bash_timeout"
35
+ SEVERITY = "minor"
36
+ RULE_ID = "R-NO-LONG-BASH"
37
+
38
+
39
+ class BashTimeout:
40
+ id = ID
41
+ severity_default = SEVERITY
42
+ kinds = KINDS_BOTH
43
+
44
+ def run(self, run: Run) -> list[Finding]:
45
+ hits: list[Event] = []
46
+ exit_code_only = False # P10: some hit has no anchored timeout marker
47
+ hit_agents: list[str] = []
48
+ for attempt in tooling_attempts(run):
49
+ marker_events = {
50
+ id(event)
51
+ for hit in anchored_marker_hits(attempt, ID)
52
+ if (event := event_for_marker_hit(attempt, hit)) is not None
53
+ }
54
+ for event in attempt.events:
55
+ if not is_bash_event(event):
56
+ continue
57
+ if event.exit_code == BASH_TIMEOUT_EXIT_CODE or id(event) in marker_events:
58
+ hits.append(event)
59
+ if id(event) not in marker_events:
60
+ exit_code_only = True
61
+ hit_agents.append(attempt.agent_id)
62
+ if not hits:
63
+ return []
64
+ agent_id = single_agent_id(hit_agents)
65
+
66
+ count = len(hits)
67
+ plural = "s" if count != 1 else ""
68
+ if exit_code_only:
69
+ title = (
70
+ f"{count} Bash command{plural} ended with exit code 143 "
71
+ "(a hard timeout or another termination)"
72
+ )
73
+ else:
74
+ title = f"{count} Bash command{plural} hit a hard timeout"
75
+ evidence = [evidence_line(run, event) for event in hits]
76
+
77
+ return [
78
+ Finding(
79
+ detector=ID,
80
+ severity=severity_rules.resolve(run, ID),
81
+ title=title,
82
+ run_id=run.run_id,
83
+ agent_id=agent_id,
84
+ evidence=evidence,
85
+ metrics={"count": count},
86
+ rule_id=RULE_ID,
87
+ finding_id=make_finding_id(
88
+ run.run_id, ID, agent_id, evidence[0] if evidence else None
89
+ ),
90
+ )
91
+ ]
92
+
93
+
94
+ DETECTOR: Detector = BashTimeout()
@@ -0,0 +1,251 @@
1
+ """claim_contradiction: a completed workflow attempt whose own self-reported
2
+ result contradicts the outcome the journal recorded (.reviews/proposals/
3
+ 2026-09-v0.2-guided-rescue.md section 4.3, unit M1c).
4
+
5
+ Reads only Attempt.reported (docs/DESIGN.md section 3/4.1), which the
6
+ reader already restricted to an allowlisted set of self-reported claims --
7
+ never a raw transcript scan, never the free-text keys ("note", "summary",
8
+ ...) a real result carries alongside them. The one filesystem check this
9
+ detector performs (report_path existence) follows missing_artifact's own
10
+ convention: severity.absence() on the path as reported. A relative
11
+ report_path cannot be checked against anything the reader records today
12
+ (Attempt/Event carry no cwd field in this milestone) and is reported
13
+ 'unverified' rather than counted as a contradiction.
14
+
15
+ Severity (docs/DESIGN.md 4.3, 4.13.4): a tests, suite, ok, ready, or blockers
16
+ clause is major; a report_path clause is info (the path is absent now, which
17
+ says nothing about the end of the run). A finding takes the highest of its
18
+ clauses, and a finding of report_path clauses alone carries the D2 title.
19
+
20
+ G3 (v0.3.1): when no tests, suite, ok, ready or blockers clause holds, a result
21
+ that carries a failure count of its own or `blocked: true` (the attempt's private
22
+ claims.SelfReport, decided once by the reader through the shared predicate) adds the
23
+ clause self_reported_failure or self_reported_blocked, major. Such a finding has a
24
+ fixed title and a bare journal pointer as its evidence: no key name and no result
25
+ text reaches it.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from datetime import datetime, timedelta, timezone
32
+
33
+ from .. import severity as severity_rules
34
+ from ..claims import SelfReport
35
+ from ..models import Finding, Run, TitleParts
36
+ from .base import KINDS_WORKFLOW_ONLY, Detector, make_finding_id
37
+
38
+ ID = "claim_contradiction"
39
+ SEVERITY = "major"
40
+
41
+ # Every clause this detector can emit (docs/DESIGN.md 4.13.4); severity.VOCAB["clause"] has
42
+ # exactly these, and each has a row in severity.RULES.
43
+ CLAUSE_IDS = (
44
+ "tests_failed", "suite_failed", "ok_false", "ready_false", "blockers",
45
+ "report_path_missing", "self_reported_failure", "self_reported_blocked",
46
+ )
47
+ _LEGACY_MAJOR = frozenset({"tests_failed", "suite_failed", "ok_false", "ready_false", "blockers"})
48
+
49
+ # A report_path is checked on disk only while the file could still be
50
+ # expected to exist. Reports land in temp directories that are cleaned up
51
+ # days later: on the machine this was built on, 31 of the 50 findings this
52
+ # detector produced over old runs were report_path clauses on runs weeks
53
+ # old. For a run that ended more than this long ago the clause is not
54
+ # evidence either way and is skipped; a run with no known end time is
55
+ # treated as recent, since nothing says it is old.
56
+ REPORT_PATH_MAX_AGE = timedelta(hours=72)
57
+
58
+
59
+ def _now() -> datetime:
60
+ return datetime.now(timezone.utc)
61
+
62
+
63
+ def _report_path_checkable(run: Run) -> bool:
64
+ if run.ended_at is None:
65
+ return True
66
+ ended = run.ended_at
67
+ if ended.tzinfo is None:
68
+ ended = ended.replace(tzinfo=timezone.utc)
69
+ return _now() - ended <= REPORT_PATH_MAX_AGE
70
+
71
+
72
+ def _plural(n: int, word: str) -> str:
73
+ return word if n == 1 else f"{word}s"
74
+
75
+
76
+ def _report_path_state(claims: dict) -> str | None:
77
+ """The absence of claims['report_path'] when it is set, absolute, and not
78
+ present on disk at analysis time: `absent_now`, or `unknown` when the stat
79
+ call itself failed. None otherwise. A relative path is never checked (no
80
+ attempt.cwd to resolve it against in this milestone) and never makes a
81
+ clause -- it is simply not evidence either way."""
82
+ report_path = claims.get("report_path")
83
+ if not isinstance(report_path, str) or not report_path:
84
+ return None
85
+ if not os.path.isabs(report_path):
86
+ return None
87
+ state = severity_rules.absence(report_path)
88
+ return None if state == "present_now" else state
89
+
90
+
91
+ def _clauses(
92
+ claims: dict, check_report_path: bool = True, self_report: SelfReport | None = None,
93
+ ) -> list[tuple[str, str, str]]:
94
+ """(clause id, human phrase, absence) for every clause of docs/DESIGN.md 4.3
95
+ that holds over claims, in a fixed order. `absence` is "none" for every clause
96
+ but report_path_missing (not applicable). A non-empty `self_report` adds its one
97
+ clause (G3) only when no legacy major clause holds, so an attempt carries exactly
98
+ one major finding either way."""
99
+ clauses: list[tuple[str, str, str]] = []
100
+
101
+ tests_failed = claims.get("tests_failed")
102
+ if isinstance(tests_failed, int) and tests_failed > 0:
103
+ clauses.append((
104
+ "tests_failed", f"{tests_failed} failed {_plural(tests_failed, 'test')}", "none",
105
+ ))
106
+
107
+ suite_failed = claims.get("suite_failed")
108
+ if isinstance(suite_failed, int) and suite_failed > 0:
109
+ clauses.append((
110
+ "suite_failed", f"{suite_failed} failed suite {_plural(suite_failed, 'check')}",
111
+ "none",
112
+ ))
113
+
114
+ if claims.get("ok") is False:
115
+ clauses.append(("ok_false", "ok is false", "none"))
116
+
117
+ if claims.get("ready") is False:
118
+ clauses.append(("ready_false", "ready is false", "none"))
119
+
120
+ blockers_count = claims.get("blockers_count")
121
+ if isinstance(blockers_count, int) and blockers_count > 0:
122
+ clauses.append((
123
+ "blockers", f"{blockers_count} {_plural(blockers_count, 'blocker')}", "none",
124
+ ))
125
+
126
+ if check_report_path:
127
+ state = _report_path_state(claims)
128
+ if state is not None:
129
+ clauses.append(("report_path_missing", "a report_path that does not exist", state))
130
+
131
+ if self_report and not any(clause_id in _LEGACY_MAJOR for clause_id, _p, _a in clauses):
132
+ if self_report.failed_counts > 0:
133
+ n = self_report.failed_counts
134
+ clauses.append((
135
+ "self_reported_failure", f"{n} {_plural(n, 'failure count')}", "none",
136
+ ))
137
+ else:
138
+ clauses.append(("self_reported_blocked", "being blocked", "none"))
139
+
140
+ return clauses
141
+
142
+
143
+ def _self_report_title(self_report: SelfReport) -> str:
144
+ """The fixed G3 title: no label, no key name, no value (the same at every level)."""
145
+ if self_report.failed_counts > 0:
146
+ n = self_report.failed_counts
147
+ return (
148
+ f"completed, but its own returned result reports failures in {n} {_plural(n, 'field')}"
149
+ )
150
+ return "completed, but its own returned result reports it was blocked"
151
+
152
+
153
+ _ABSENT_TEMPLATE = (
154
+ "{path} is absent now; the tool cannot tell whether it existed when the run ended"
155
+ )
156
+ _UNCHECKED_TEMPLATE = (
157
+ "{path} could not be checked now; the tool cannot tell whether it existed "
158
+ "when the run ended"
159
+ )
160
+
161
+
162
+ def _report_path_title_parts(path: str, state: str) -> TitleParts:
163
+ """The D2 title of a finding whose only clause is an absent report_path, as a fixed template
164
+ and the path (F fix 7, W7-2): a renderer formats it once and never scans the result."""
165
+ template = _ABSENT_TEMPLATE if state == "absent_now" else _UNCHECKED_TEMPLATE
166
+ return TitleParts(template, path)
167
+
168
+
169
+ def _report_path_title(path: str, state: str) -> str:
170
+ """The title with the real path (what `raw` prints)."""
171
+ return _report_path_title_parts(path, state).raw()
172
+
173
+
174
+ class ClaimContradiction:
175
+ id = ID
176
+ severity_default = SEVERITY
177
+ kinds = KINDS_WORKFLOW_ONLY
178
+
179
+ def run(self, run: Run) -> list[Finding]:
180
+ findings: list[Finding] = []
181
+ check_report_path = _report_path_checkable(run)
182
+
183
+ for attempt in run.attempts:
184
+ if attempt.outcome != "completed":
185
+ continue
186
+ reported = attempt.reported
187
+ if not isinstance(reported, dict) or reported.get("type") != "dict":
188
+ continue
189
+ claims = reported.get("claims", {})
190
+ self_report = attempt._self_report
191
+ if not isinstance(self_report, SelfReport):
192
+ self_report = None
193
+ clauses = _clauses(claims, check_report_path, self_report)
194
+ if not clauses:
195
+ continue
196
+
197
+ phrases = "; ".join(phrase for _clause_id, phrase, _absence in clauses)
198
+ title_parts = None
199
+ self_clause = any(
200
+ clause_id in ("self_reported_failure", "self_reported_blocked")
201
+ for clause_id, _p, _a in clauses
202
+ )
203
+ if self_clause:
204
+ title = _self_report_title(self_report)
205
+ elif all(clause_id == "report_path_missing" for clause_id, _p, _a in clauses):
206
+ title_parts = _report_path_title_parts(claims["report_path"], clauses[0][2])
207
+ title = title_parts.raw()
208
+ else:
209
+ title = (
210
+ f"{attempt.label} attempt {attempt.ordinal} completed but its own "
211
+ f"result reports {phrases}"
212
+ )
213
+ severity = severity_rules.worst([
214
+ severity_rules.resolve(run, ID, clause=clause_id, absence=absence)
215
+ for clause_id, _phrase, absence in clauses
216
+ ])
217
+ excerpt = " ".join(phrases.split())[:160]
218
+ line = attempt.reported_line
219
+ if self_clause:
220
+ evidence = [f"journal.jsonl:{line}"] if line is not None else []
221
+ else:
222
+ evidence = [f"journal.jsonl:{line}: {excerpt}"] if line is not None else []
223
+ metrics = {
224
+ "clauses": [clause_id for clause_id, _phrase, _absence in clauses],
225
+ "claims": claims,
226
+ }
227
+ if self_clause:
228
+ metrics["self_reported_failure_counts"] = self_report.failed_counts
229
+ metrics["self_reported_blocked"] = self_report.blocked
230
+
231
+ findings.append(
232
+ Finding(
233
+ detector=ID,
234
+ severity=severity,
235
+ title=title,
236
+ title_parts=title_parts,
237
+ run_id=run.run_id,
238
+ agent_id=attempt.agent_id,
239
+ evidence=evidence,
240
+ metrics=metrics,
241
+ rule_id=None,
242
+ finding_id=make_finding_id(
243
+ run.run_id, ID, attempt.agent_id, evidence[0] if evidence else None
244
+ ),
245
+ )
246
+ )
247
+
248
+ return findings
249
+
250
+
251
+ DETECTOR: Detector = ClaimContradiction()
@@ -0,0 +1,73 @@
1
+ """Detects the Bash-safety classifier going unavailable in auto mode.
2
+
3
+ DESIGN.md 4.3: minor severity, fires on every anchored, not-in-payload
4
+ 'classifier_unavailable' MarkerHit (docs/DESIGN.md 4.13.2: the registry's
5
+ own shape restricts this id to a tool_result paired to Bash). Not something
6
+ a prompt or hook can fix, so there is no rule_id. metrics: count. Skips a
7
+ read_only_output event (docs/DESIGN.md 4.3's self-reference rule): a
8
+ Read/Grep/sed/etc. call quoting the outage sentence from a file on disk is
9
+ not the classifier actually going unavailable during this agent's own Bash
10
+ call.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .. import severity as severity_rules
16
+ from ..models import Event, Finding, Run
17
+ from .base import (
18
+ KINDS_BOTH,
19
+ Detector,
20
+ anchored_marker_hits,
21
+ event_for_marker_hit,
22
+ evidence_line,
23
+ make_finding_id,
24
+ single_agent_id,
25
+ tooling_attempts,
26
+ )
27
+
28
+ ID = "classifier_unavailable"
29
+ SEVERITY = "minor"
30
+
31
+
32
+ class ClassifierUnavailable:
33
+ id = ID
34
+ severity_default = SEVERITY
35
+ kinds = KINDS_BOTH
36
+
37
+ def run(self, run: Run) -> list[Finding]:
38
+ hits: list[Event] = []
39
+ hit_agents: list[str] = []
40
+ for attempt in tooling_attempts(run):
41
+ for hit in anchored_marker_hits(attempt, ID):
42
+ event = event_for_marker_hit(attempt, hit)
43
+ if event is None or event.read_only_output:
44
+ continue
45
+ hits.append(event)
46
+ hit_agents.append(attempt.agent_id)
47
+ if not hits:
48
+ return []
49
+ agent_id = single_agent_id(hit_agents)
50
+
51
+ count = len(hits)
52
+ plural = "s" if count != 1 else ""
53
+ title = f"Bash-safety classifier was unavailable {count} time{plural}"
54
+ evidence = [evidence_line(run, event) for event in hits]
55
+
56
+ return [
57
+ Finding(
58
+ detector=ID,
59
+ severity=severity_rules.resolve(run, ID),
60
+ title=title,
61
+ run_id=run.run_id,
62
+ agent_id=agent_id,
63
+ evidence=evidence,
64
+ metrics={"count": count},
65
+ rule_id=None,
66
+ finding_id=make_finding_id(
67
+ run.run_id, ID, agent_id, evidence[0] if evidence else None
68
+ ),
69
+ )
70
+ ]
71
+
72
+
73
+ DETECTOR: Detector = ClassifierUnavailable()
@@ -0,0 +1,102 @@
1
+ """Detects a Codex companion call that could not start.
2
+
3
+ DESIGN.md 4.3, 4.13.2, 4.13.4: one finding per attempt that holds at least one anchored,
4
+ not-in-payload `codex_unavailable` MarkerHit (the main thread of a session included, its finding
5
+ carrying no agent id). The marker's only permitted shape is a Bash tool_result whose command runs
6
+ codex as a program or names codex-companion.mjs, and its predicate needs a line that starts with
7
+ the companion's own sentence, so assistant prose and the output of a non-codex command (a `cat` of
8
+ a notes file) never count.
9
+
10
+ Severity goes through severity.py: major when the attempt's outcome is `completed` (it returned a
11
+ result though its Codex call never started) and no companion call after the first failing one has
12
+ a result without the marker; minor otherwise (a later call succeeded, or the attempt was killed or
13
+ failed, which other findings carry). The title is a fixed sentence and carries no transcript text.
14
+ A read_only_output event is skipped, as for codex_write_denied.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .. import severity as severity_rules
20
+ from ..models import Attempt, Event, Finding, Run
21
+ from .base import (
22
+ KINDS_BOTH,
23
+ Detector,
24
+ anchored_marker_hits,
25
+ event_for_marker_hit,
26
+ evidence_line,
27
+ make_finding_id,
28
+ tooling_attempts,
29
+ )
30
+
31
+ ID = "codex_unavailable"
32
+ SEVERITY = "major"
33
+ RULE_ID = "R-CODEX-CONTRACT"
34
+
35
+
36
+ def _attempt_hits(attempt: Attempt) -> list[tuple[Event, str]]:
37
+ """(event, tool_use_id) of every counted hit, in transcript order."""
38
+ ordered = sorted(
39
+ anchored_marker_hits(attempt, ID), key=lambda hit: (hit.event_line, hit.tool_use_id or ""),
40
+ )
41
+ found: list[tuple[Event, str]] = []
42
+ for hit in ordered:
43
+ event = event_for_marker_hit(attempt, hit)
44
+ if event is None or event.read_only_output or hit.tool_use_id is None:
45
+ continue
46
+ found.append((event, hit.tool_use_id))
47
+ return found
48
+
49
+
50
+ def _later_success(attempt: Attempt, hits: list[tuple[Event, str]]) -> bool:
51
+ """Whether a Bash companion call after the first failing one has a result without the
52
+ marker."""
53
+ failed_ids = {tool_use_id for _event, tool_use_id in hits}
54
+ first = next((c for c in attempt.tool_calls if c.tool_use_id == hits[0][1]), None)
55
+ if first is None:
56
+ return False
57
+ for call in attempt.tool_calls:
58
+ if call.tool_name != "Bash" or call.line_no <= first.line_no:
59
+ continue
60
+ if not call.runs_codex_program:
61
+ continue
62
+ if call.result_line is not None and call.tool_use_id not in failed_ids:
63
+ return True
64
+ return False
65
+
66
+
67
+ class CodexUnavailable:
68
+ id = ID
69
+ severity_default = SEVERITY
70
+ kinds = KINDS_BOTH
71
+
72
+ def run(self, run: Run) -> list[Finding]:
73
+ findings: list[Finding] = []
74
+ for attempt in tooling_attempts(run):
75
+ hits = _attempt_hits(attempt)
76
+ if not hits:
77
+ continue
78
+ count = len(hits)
79
+ evidence = [evidence_line(run, event) for event, _id in hits]
80
+ agent_id = None if attempt is run.main else attempt.agent_id
81
+ findings.append(Finding(
82
+ detector=ID,
83
+ severity=severity_rules.resolve(
84
+ run, ID,
85
+ outcome="completed" if attempt.outcome == "completed" else "other",
86
+ later_success="yes" if _later_success(attempt, hits) else "no",
87
+ ),
88
+ title=(
89
+ f"{count} Codex companion {'call' if count == 1 else 'calls'} could not "
90
+ "start: the Codex CLI is missing or lacks its runtime"
91
+ ),
92
+ run_id=run.run_id,
93
+ agent_id=agent_id,
94
+ evidence=evidence,
95
+ metrics={"count": count},
96
+ rule_id=RULE_ID,
97
+ finding_id=make_finding_id(run.run_id, ID, agent_id, evidence[0]),
98
+ ))
99
+ return findings
100
+
101
+
102
+ DETECTOR: Detector = CodexUnavailable()
@@ -0,0 +1,100 @@
1
+ """Detects a Codex sandbox denying a write, or the workspace shell guard
2
+ blocking a command outright.
3
+
4
+ DESIGN.md 4.3, 4.13.4: minor severity, one finding per attempt, fires on every
5
+ anchored, not-in-payload 'sandbox_write_denied' or 'shell_guard_block'
6
+ MarkerHit (docs/DESIGN.md 4.13.2). The registry's own permitted shapes
7
+ already encode what D-C used to check by hand: 'sandbox_write_denied' only
8
+ ever anchors on a Bash tool_result whose own command runs codex as a program,
9
+ or an assistant entry's own whole text when the attempt also has such a Bash
10
+ call anywhere in it; 'shell_guard_block' only ever anchors on that same
11
+ Bash-codex tool_result shape. A `user` event is never a permitted shape for
12
+ either id (D-B): a review or spec pasted into a prompt that happens to quote
13
+ WRITE_DENIED is not a sandbox denying anything.
14
+
15
+ A denial alone does not show that a required write was blocked (when a
16
+ required output is really missing, missing_artifact or claim_contradiction
17
+ carries the severity), so the finding is always minor and carries both rules
18
+ in one `rule_ids`: R-CODEX-CONTRACT first, then R-ASSERT-ARTIFACT. metrics:
19
+ count, marker (the distinct marker labels matched), both per attempt.
20
+
21
+ Skips a read_only_output event (docs/DESIGN.md 4.3's self-reference rule): a
22
+ read-only Bash command (including one buried in a loop, D-B) quoting
23
+ WRITE_DENIED or the shell-guard sentence from a file on disk is not a denial
24
+ that happened to this agent.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from .. import severity as severity_rules
30
+ from ..models import Attempt, Event, Finding, MarkerHit, Run
31
+ from .base import (
32
+ KINDS_BOTH,
33
+ Detector,
34
+ anchored_marker_hits,
35
+ event_for_marker_hit,
36
+ evidence_line,
37
+ make_finding_id,
38
+ tooling_attempts,
39
+ )
40
+
41
+ ID = "codex_write_denied"
42
+ SEVERITY = "minor"
43
+ RULE_ID_DENIAL = "R-CODEX-CONTRACT"
44
+ RULE_ID_ARTIFACT = "R-ASSERT-ARTIFACT"
45
+ RULE_IDS = (RULE_ID_DENIAL, RULE_ID_ARTIFACT)
46
+ _MARKER_IDS = ("sandbox_write_denied", "shell_guard_block")
47
+
48
+
49
+ def _attempt_hits(attempt: Attempt) -> list[tuple[Event, str]]:
50
+ """(event, marker label) pairs for this attempt, in transcript order
51
+ (by event_line, then tool_use_id for a stable tie-break between two
52
+ tool_result blocks sharing a line)."""
53
+ candidates: list[tuple[int, str, MarkerHit]] = []
54
+ for marker in _MARKER_IDS:
55
+ for hit in anchored_marker_hits(attempt, marker):
56
+ candidates.append((hit.event_line, hit.tool_use_id or "", hit))
57
+ candidates.sort(key=lambda row: (row[0], row[1]))
58
+
59
+ hits: list[tuple[Event, str]] = []
60
+ for _line, _tie, hit in candidates:
61
+ event = event_for_marker_hit(attempt, hit)
62
+ if event is None or event.read_only_output:
63
+ continue
64
+ hits.append((event, hit.marker))
65
+ return hits
66
+
67
+
68
+ class CodexWriteDenied:
69
+ id = ID
70
+ severity_default = SEVERITY
71
+ kinds = KINDS_BOTH
72
+
73
+ def run(self, run: Run) -> list[Finding]:
74
+ findings: list[Finding] = []
75
+ for attempt in tooling_attempts(run):
76
+ hits = _attempt_hits(attempt)
77
+ if not hits:
78
+ continue
79
+ count = len(hits)
80
+ markers = sorted({marker for _, marker in hits})
81
+ evidence = [evidence_line(run, event) for event, _ in hits]
82
+ plural = "s" if count != 1 else ""
83
+ findings.append(Finding(
84
+ detector=ID,
85
+ severity=severity_rules.resolve(run, ID),
86
+ title=f"{count} sandboxed write{plural} denied",
87
+ run_id=run.run_id,
88
+ agent_id=attempt.agent_id,
89
+ evidence=evidence,
90
+ metrics={"count": count, "marker": markers},
91
+ rule_id=RULE_IDS[0],
92
+ rule_ids=RULE_IDS,
93
+ finding_id=make_finding_id(
94
+ run.run_id, ID, attempt.agent_id, evidence[0] if evidence else None
95
+ ),
96
+ ))
97
+ return findings
98
+
99
+
100
+ DETECTOR: Detector = CodexWriteDenied()