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,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()
|