recusal 0.1.0__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.
- recusal/__init__.py +69 -0
- recusal/audit.py +177 -0
- recusal/checks.py +242 -0
- recusal/classify.py +207 -0
- recusal/claude.py +119 -0
- recusal/claude_code.py +310 -0
- recusal/evidence.py +228 -0
- recusal/gates.py +172 -0
- recusal/py.typed +0 -0
- recusal-0.1.0.dist-info/METADATA +542 -0
- recusal-0.1.0.dist-info/RECORD +13 -0
- recusal-0.1.0.dist-info/WHEEL +4 -0
- recusal-0.1.0.dist-info/licenses/LICENSE +201 -0
recusal/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Recusal, separation-of-powers governance for Claude agents.
|
|
3
|
+
|
|
4
|
+
A judge recuses themselves from a case they can't impartially decide. The same
|
|
5
|
+
principle governs autonomous agents: the thing that *generates* the work must
|
|
6
|
+
never be the thing that *certifies* it. Recusal is that independent authority -
|
|
7
|
+
you collect evidence, it adjudicates deterministically into PASS / RETRY / FAIL,
|
|
8
|
+
and it can **refuse to certify**. No model call in the decision path. Zero
|
|
9
|
+
dependencies. Same evidence, same verdict, every time.
|
|
10
|
+
|
|
11
|
+
The spine is the evidence contract (see ``evidence`` and docs/EVIDENCE.md):
|
|
12
|
+
|
|
13
|
+
Finding one observation about the work.
|
|
14
|
+
Verdict the decision those findings add up to.
|
|
15
|
+
|
|
16
|
+
Everything hangs off it:
|
|
17
|
+
|
|
18
|
+
- ``evidence``, ``Finding`` / ``Verdict`` / ``Severity`` / ``Decision`` and
|
|
19
|
+
``compute_verdict(findings)`` (the typed core).
|
|
20
|
+
- ``checks`` , built-in deterministic checks that turn raw data into Findings.
|
|
21
|
+
- ``claude`` , drop the gate in front of a Claude agent's tool calls so it can
|
|
22
|
+
refuse *before* a tool runs.
|
|
23
|
+
- ``gates`` , staged release checkpoints (G0-G8) rolled into release evidence.
|
|
24
|
+
|
|
25
|
+
The constitution it encodes: **builders cannot grade their own work** ·
|
|
26
|
+
deterministic before AI · the judge owns evidence, not progression · no shadow
|
|
27
|
+
authority. (See CONSTITUTION.md.)
|
|
28
|
+
|
|
29
|
+
Zero runtime dependencies, standard library only.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from .audit import AuditLog, verify
|
|
33
|
+
from .classify import (
|
|
34
|
+
DEFAULT_TAXONOMY,
|
|
35
|
+
Classification,
|
|
36
|
+
FailureClass,
|
|
37
|
+
classify_failure,
|
|
38
|
+
classify_verdict,
|
|
39
|
+
)
|
|
40
|
+
from .evidence import (
|
|
41
|
+
Decision,
|
|
42
|
+
Finding,
|
|
43
|
+
RuleSeverity,
|
|
44
|
+
Severity,
|
|
45
|
+
Verdict,
|
|
46
|
+
compute_verdict,
|
|
47
|
+
)
|
|
48
|
+
from .gates import GateAdjudicator, GateResult, ReleaseEvidence
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Severity",
|
|
52
|
+
"RuleSeverity",
|
|
53
|
+
"Finding",
|
|
54
|
+
"Verdict",
|
|
55
|
+
"Decision",
|
|
56
|
+
"compute_verdict",
|
|
57
|
+
"GateAdjudicator",
|
|
58
|
+
"GateResult",
|
|
59
|
+
"ReleaseEvidence",
|
|
60
|
+
"AuditLog",
|
|
61
|
+
"verify",
|
|
62
|
+
"classify_failure",
|
|
63
|
+
"classify_verdict",
|
|
64
|
+
"FailureClass",
|
|
65
|
+
"Classification",
|
|
66
|
+
"DEFAULT_TAXONOMY",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
__version__ = "0.1.0"
|
recusal/audit.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hash-chained audit log, a linked record of every verdict.
|
|
3
|
+
|
|
4
|
+
A gate that can refuse is only half of an auditable control; the other half is a
|
|
5
|
+
record you can replay and an auditor can read. ``recusal.audit`` appends each verdict to an
|
|
6
|
+
append-only, hash-chained log: every entry carries the SHA-256 hash of the entry
|
|
7
|
+
before it, so an **in-place edit or a reordering** of existing entries breaks the
|
|
8
|
+
chain and ``verify`` catches it, naming the entry and the reason.
|
|
9
|
+
|
|
10
|
+
What this does and does not guarantee (read before relying on it):
|
|
11
|
+
|
|
12
|
+
- It detects in-place edits and reordering. It is tamper-**evident**, not tamper-proof.
|
|
13
|
+
- The digest is **unkeyed** and the head is **unanchored**, so an attacker with write
|
|
14
|
+
access to the file can truncate the tail, or rewrite the whole chain and recompute every
|
|
15
|
+
hash, and still pass ``verify``. To catch those, commit the head ``(count, last_hash)``
|
|
16
|
+
somewhere the attacker cannot also rewrite (a witness, a WORM store, a signature) and
|
|
17
|
+
pass it as ``verify(..., expected_head=...)``.
|
|
18
|
+
- It is **single-writer**: two processes appending to the same file will fork the chain.
|
|
19
|
+
|
|
20
|
+
Deterministic and dependency-free: SHA-256 over canonical JSON, standard library only.
|
|
21
|
+
The record shape maps cleanly onto OWASP Agentic logging and EU AI Act Article 12
|
|
22
|
+
(record-keeping).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
from .evidence import Verdict
|
|
33
|
+
|
|
34
|
+
GENESIS = "0" * 64 # the prev_hash of the first entry
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _canonical(entry: Dict[str, Any]) -> str:
|
|
38
|
+
"""Stable serialization of an entry for hashing, everything but the hash field."""
|
|
39
|
+
payload = {k: v for k, v in entry.items() if k != "hash"}
|
|
40
|
+
return json.dumps(
|
|
41
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, default=str
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _digest(entry: Dict[str, Any]) -> str:
|
|
46
|
+
return hashlib.sha256(_canonical(entry).encode("utf-8")).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _summarize(verdict: Verdict) -> Dict[str, Any]:
|
|
50
|
+
return {
|
|
51
|
+
"decision": verdict.decision.value,
|
|
52
|
+
"highest_severity": verdict.highest_severity.value,
|
|
53
|
+
"reasons": verdict.reasons(),
|
|
54
|
+
"failures": [
|
|
55
|
+
{"check": f.check, "severity": f.severity.value, "message": f.message}
|
|
56
|
+
for f in verdict.failures
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuditLog:
|
|
62
|
+
"""Append-only, hash-chained log of verdicts.
|
|
63
|
+
|
|
64
|
+
Pass ``path`` to persist as JSONL (one entry per line); otherwise it lives in
|
|
65
|
+
memory on ``self.entries``. An existing file is resumed, the chain continues
|
|
66
|
+
from its last entry.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
path: Optional[str] = None,
|
|
72
|
+
*,
|
|
73
|
+
clock: Optional[Callable[[], datetime]] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.path = path
|
|
76
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
77
|
+
self.entries: List[Dict[str, Any]] = []
|
|
78
|
+
self.last_hash = GENESIS
|
|
79
|
+
if path:
|
|
80
|
+
for entry in load(path):
|
|
81
|
+
self.entries.append(entry)
|
|
82
|
+
head = entry.get("hash")
|
|
83
|
+
if head is not None:
|
|
84
|
+
self.last_hash = head
|
|
85
|
+
|
|
86
|
+
def append(
|
|
87
|
+
self,
|
|
88
|
+
verdict: Verdict,
|
|
89
|
+
*,
|
|
90
|
+
action: Any = None,
|
|
91
|
+
actor: Optional[str] = None,
|
|
92
|
+
timestamp: Optional[str] = None,
|
|
93
|
+
) -> Dict[str, Any]:
|
|
94
|
+
"""Record one verdict. ``action`` is any JSON-serializable description of what
|
|
95
|
+
was adjudicated (e.g. ``{"tool": "Bash", "command": "..."}``); ``actor`` is an
|
|
96
|
+
optional agent/session id. Returns the written entry (including its hash)."""
|
|
97
|
+
entry: Dict[str, Any] = {
|
|
98
|
+
"seq": len(self.entries),
|
|
99
|
+
"timestamp": timestamp or self._clock().isoformat(),
|
|
100
|
+
"actor": actor,
|
|
101
|
+
"action": action,
|
|
102
|
+
**_summarize(verdict),
|
|
103
|
+
"prev_hash": self.last_hash,
|
|
104
|
+
}
|
|
105
|
+
entry["hash"] = _digest(entry)
|
|
106
|
+
|
|
107
|
+
self.entries.append(entry)
|
|
108
|
+
self.last_hash = entry["hash"]
|
|
109
|
+
if self.path:
|
|
110
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
111
|
+
fh.write(json.dumps(entry, ensure_ascii=True, default=str) + "\n")
|
|
112
|
+
return entry
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load(path: str) -> List[Dict[str, Any]]:
|
|
116
|
+
"""Read a JSONL audit file into a list of entries (empty if the file is absent).
|
|
117
|
+
|
|
118
|
+
A line that is not valid JSON (e.g. a partial final line left by a process killed
|
|
119
|
+
mid-append) is skipped rather than crashing the read, so a half-written tail can never
|
|
120
|
+
brick the log. ``verify`` still surfaces any resulting chain break.
|
|
121
|
+
"""
|
|
122
|
+
entries: List[Dict[str, Any]] = []
|
|
123
|
+
try:
|
|
124
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
125
|
+
for line in fh:
|
|
126
|
+
line = line.strip()
|
|
127
|
+
if not line:
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
entries.append(json.loads(line))
|
|
131
|
+
except json.JSONDecodeError:
|
|
132
|
+
continue # skip a corrupt/partial line instead of bricking the log
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
return []
|
|
135
|
+
return entries
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def verify(
|
|
139
|
+
entries: List[Dict[str, Any]],
|
|
140
|
+
*,
|
|
141
|
+
expected_head: Optional[Tuple[int, str]] = None,
|
|
142
|
+
) -> Tuple[bool, List[str]]:
|
|
143
|
+
"""Check the hash chain. Returns ``(intact, problems)``, ``problems`` is empty when the
|
|
144
|
+
log is intact, otherwise it names each broken entry and why.
|
|
145
|
+
|
|
146
|
+
This detects in-place edits and reordering of existing entries. It does **not**, on its
|
|
147
|
+
own, detect truncation of the tail or a full re-hash by an attacker with write access
|
|
148
|
+
(the digest is unkeyed and the head is unanchored). Pass ``expected_head=(count,
|
|
149
|
+
last_hash)``, a value committed somewhere the attacker cannot also rewrite, to catch
|
|
150
|
+
truncation and whole-chain forgery.
|
|
151
|
+
"""
|
|
152
|
+
problems: List[str] = []
|
|
153
|
+
prev: str = GENESIS
|
|
154
|
+
for i, entry in enumerate(entries):
|
|
155
|
+
seq = entry.get("seq")
|
|
156
|
+
if seq != i:
|
|
157
|
+
problems.append(f"entry {i} (seq {seq}): out of order or gap - seq != position")
|
|
158
|
+
if entry.get("prev_hash") != prev:
|
|
159
|
+
problems.append(f"entry {i} (seq {seq}): broken link - prev_hash does not match")
|
|
160
|
+
if entry.get("hash") != _digest(entry):
|
|
161
|
+
problems.append(f"entry {i} (seq {seq}): content tampered - hash does not match")
|
|
162
|
+
prev = entry.get("hash") or ""
|
|
163
|
+
if expected_head is not None:
|
|
164
|
+
count, last_hash = expected_head
|
|
165
|
+
if len(entries) != count:
|
|
166
|
+
problems.append(
|
|
167
|
+
f"length mismatch: {len(entries)} entries, anchor expects {count} (possible truncation)"
|
|
168
|
+
)
|
|
169
|
+
actual_head = entries[-1].get("hash") if entries else GENESIS
|
|
170
|
+
if actual_head != last_hash:
|
|
171
|
+
problems.append("head mismatch: last hash does not match the external anchor")
|
|
172
|
+
return (not problems), problems
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def verify_file(path: str) -> Tuple[bool, List[str]]:
|
|
176
|
+
"""Convenience: load a JSONL audit file and verify its chain."""
|
|
177
|
+
return verify(load(path))
|
recusal/checks.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in deterministic checks, turn data into evidence.
|
|
3
|
+
|
|
4
|
+
The verdict kernel (``compute_verdict``) decides; these functions do the tedious
|
|
5
|
+
part that earns the decision: inspect real data and emit findings. Each check
|
|
6
|
+
takes a materialized sequence of dict-like rows (a ``list`` of dicts, JSON records,
|
|
7
|
+
or pandas rows, anything with ``[]`` access and a length). Iterators such as a
|
|
8
|
+
generator or ``csv.DictReader`` must be wrapped first: ``list(csv.DictReader(f))``.
|
|
9
|
+
Each check returns a single ``Finding`` ready to hand to ``compute_verdict``.
|
|
10
|
+
|
|
11
|
+
Severity is a parameter, not a hardcode, *you* decide whether a high null rate
|
|
12
|
+
is a CRITICAL stop or a WARNING. Pure logic, standard library only, no pandas
|
|
13
|
+
required.
|
|
14
|
+
|
|
15
|
+
Typical use::
|
|
16
|
+
|
|
17
|
+
from recusal import compute_verdict
|
|
18
|
+
from recusal.checks import row_count, null_rate, referential_integrity
|
|
19
|
+
|
|
20
|
+
findings = [
|
|
21
|
+
row_count(users, min_rows=1),
|
|
22
|
+
null_rate(users, "email", max_rate=0.10),
|
|
23
|
+
referential_integrity(orders, users, fk="user_id", pk="id"),
|
|
24
|
+
]
|
|
25
|
+
verdict = compute_verdict(findings) # PASS / RETRY / FAIL
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from typing import Any, Iterable, Sequence
|
|
29
|
+
|
|
30
|
+
from .evidence import Finding, RuleSeverity
|
|
31
|
+
|
|
32
|
+
Rows = Sequence[Any] # each row supports row["column"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _passed(check_type: str, severity: str, message: str, **context: Any) -> Finding:
|
|
36
|
+
return Finding.ok(check_type, severity=severity, message=message, **context)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _failed(check_type: str, severity: str, message: str, **context: Any) -> Finding:
|
|
40
|
+
return Finding.fail(check_type, severity=severity, message=message, **context)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def row_count(
|
|
44
|
+
rows: Rows,
|
|
45
|
+
min_rows: int = 1,
|
|
46
|
+
severity: str = RuleSeverity.CRITICAL.value,
|
|
47
|
+
label: str = "dataset",
|
|
48
|
+
) -> Finding:
|
|
49
|
+
"""Fail if there are fewer than ``min_rows`` rows (empty data usually means
|
|
50
|
+
generation silently failed)."""
|
|
51
|
+
n = len(rows)
|
|
52
|
+
if n < min_rows:
|
|
53
|
+
return _failed(
|
|
54
|
+
"row_count",
|
|
55
|
+
severity,
|
|
56
|
+
f"{label}: {n} rows < required {min_rows}.",
|
|
57
|
+
actual=n,
|
|
58
|
+
min_rows=min_rows,
|
|
59
|
+
label=label,
|
|
60
|
+
)
|
|
61
|
+
return _passed("row_count", severity, f"{label}: {n} rows.", actual=n, label=label)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def null_rate(
|
|
65
|
+
rows: Rows,
|
|
66
|
+
column: str,
|
|
67
|
+
max_rate: float = 0.15,
|
|
68
|
+
severity: str = RuleSeverity.ERROR.value,
|
|
69
|
+
) -> Finding:
|
|
70
|
+
"""Fail if the fraction of null/empty values in ``column`` exceeds ``max_rate``."""
|
|
71
|
+
total = len(rows)
|
|
72
|
+
if total == 0:
|
|
73
|
+
return _passed("null_rate", severity, f"{column}: no rows to check.", column=column)
|
|
74
|
+
nulls = sum(1 for r in rows if _is_null(_get(r, column)))
|
|
75
|
+
rate = nulls / total
|
|
76
|
+
if rate > max_rate:
|
|
77
|
+
return _failed(
|
|
78
|
+
"null_rate",
|
|
79
|
+
severity,
|
|
80
|
+
f"{column}: null rate {rate:.1%} > max {max_rate:.1%} ({nulls}/{total}).",
|
|
81
|
+
column=column,
|
|
82
|
+
null_rate=rate,
|
|
83
|
+
max_rate=max_rate,
|
|
84
|
+
)
|
|
85
|
+
return _passed(
|
|
86
|
+
"null_rate",
|
|
87
|
+
severity,
|
|
88
|
+
f"{column}: null rate {rate:.1%} within {max_rate:.1%}.",
|
|
89
|
+
column=column,
|
|
90
|
+
null_rate=rate,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def referential_integrity(
|
|
95
|
+
child_rows: Rows,
|
|
96
|
+
parent_rows: Rows,
|
|
97
|
+
fk: str,
|
|
98
|
+
pk: str,
|
|
99
|
+
severity: str = RuleSeverity.CRITICAL.value,
|
|
100
|
+
) -> Finding:
|
|
101
|
+
"""Fail if any child row's foreign key has no matching parent primary key
|
|
102
|
+
(orphans = broken relationships)."""
|
|
103
|
+
parent_keys = {_get(r, pk) for r in parent_rows}
|
|
104
|
+
orphans = [
|
|
105
|
+
_get(r, fk)
|
|
106
|
+
for r in child_rows
|
|
107
|
+
if not _is_null(_get(r, fk)) and _get(r, fk) not in parent_keys
|
|
108
|
+
]
|
|
109
|
+
if orphans:
|
|
110
|
+
sample = ", ".join(str(o) for o in orphans[:5])
|
|
111
|
+
return _failed(
|
|
112
|
+
"referential_integrity",
|
|
113
|
+
severity,
|
|
114
|
+
f"{len(orphans)} orphan(s) in {fk} -> {pk} (e.g. {sample}).",
|
|
115
|
+
fk=fk,
|
|
116
|
+
pk=pk,
|
|
117
|
+
orphan_count=len(orphans),
|
|
118
|
+
)
|
|
119
|
+
return _passed(
|
|
120
|
+
"referential_integrity",
|
|
121
|
+
severity,
|
|
122
|
+
f"{fk} -> {pk}: no orphans.",
|
|
123
|
+
fk=fk,
|
|
124
|
+
pk=pk,
|
|
125
|
+
orphan_count=0,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def in_set(
|
|
130
|
+
rows: Rows,
|
|
131
|
+
column: str,
|
|
132
|
+
allowed: Iterable[Any],
|
|
133
|
+
severity: str = RuleSeverity.ERROR.value,
|
|
134
|
+
) -> Finding:
|
|
135
|
+
"""Fail if any non-null value in ``column`` is outside the ``allowed`` set."""
|
|
136
|
+
allowed_set = set(allowed)
|
|
137
|
+
bad = [
|
|
138
|
+
_get(r, column)
|
|
139
|
+
for r in rows
|
|
140
|
+
if not _is_null(_get(r, column)) and _get(r, column) not in allowed_set
|
|
141
|
+
]
|
|
142
|
+
if bad:
|
|
143
|
+
sample = ", ".join(str(b) for b in sorted(set(map(str, bad)))[:5])
|
|
144
|
+
return _failed(
|
|
145
|
+
"in_set",
|
|
146
|
+
severity,
|
|
147
|
+
f"{column}: {len(bad)} value(s) not in approved set (e.g. {sample}).",
|
|
148
|
+
column=column,
|
|
149
|
+
violation_count=len(bad),
|
|
150
|
+
)
|
|
151
|
+
return _passed("in_set", severity, f"{column}: all values in approved set.", column=column)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def in_range(
|
|
155
|
+
rows: Rows,
|
|
156
|
+
column: str,
|
|
157
|
+
min_value: float,
|
|
158
|
+
max_value: float,
|
|
159
|
+
severity: str = RuleSeverity.ERROR.value,
|
|
160
|
+
) -> Finding:
|
|
161
|
+
"""Fail if any numeric value in ``column`` falls outside [min_value, max_value]."""
|
|
162
|
+
violations = 0
|
|
163
|
+
for r in rows:
|
|
164
|
+
v = _get(r, column)
|
|
165
|
+
if _is_null(v):
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
fv = float(v)
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
violations += 1
|
|
171
|
+
continue
|
|
172
|
+
if fv < min_value or fv > max_value:
|
|
173
|
+
violations += 1
|
|
174
|
+
if violations:
|
|
175
|
+
return _failed(
|
|
176
|
+
"in_range",
|
|
177
|
+
severity,
|
|
178
|
+
f"{column}: {violations} value(s) outside [{min_value}, {max_value}].",
|
|
179
|
+
column=column,
|
|
180
|
+
violation_count=violations,
|
|
181
|
+
)
|
|
182
|
+
return _passed(
|
|
183
|
+
"in_range",
|
|
184
|
+
severity,
|
|
185
|
+
f"{column}: all values within [{min_value}, {max_value}].",
|
|
186
|
+
column=column,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def required_keys(
|
|
191
|
+
rows: Rows,
|
|
192
|
+
keys: Iterable[str],
|
|
193
|
+
severity: str = RuleSeverity.CRITICAL.value,
|
|
194
|
+
) -> Finding:
|
|
195
|
+
"""Fail if any row is missing one of the required ``keys`` (schema drift)."""
|
|
196
|
+
required = list(keys)
|
|
197
|
+
missing_rows = 0
|
|
198
|
+
seen_missing: set = set()
|
|
199
|
+
for r in rows:
|
|
200
|
+
absent = [k for k in required if not _has(r, k)]
|
|
201
|
+
if absent:
|
|
202
|
+
missing_rows += 1
|
|
203
|
+
seen_missing.update(absent)
|
|
204
|
+
if missing_rows:
|
|
205
|
+
cols = ", ".join(sorted(seen_missing))
|
|
206
|
+
return _failed(
|
|
207
|
+
"required_keys",
|
|
208
|
+
severity,
|
|
209
|
+
f"{missing_rows} row(s) missing required key(s): {cols}.",
|
|
210
|
+
missing_keys=sorted(seen_missing),
|
|
211
|
+
affected_rows=missing_rows,
|
|
212
|
+
)
|
|
213
|
+
return _passed(
|
|
214
|
+
"required_keys", severity, f"all rows have required keys: {', '.join(required)}."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── tiny accessors that work for dicts and pandas-like rows ────────────────────
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _get(row: Any, key: str) -> Any:
|
|
222
|
+
try:
|
|
223
|
+
return row[key]
|
|
224
|
+
except (KeyError, IndexError, TypeError, AttributeError):
|
|
225
|
+
return getattr(row, key, None)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _has(row: Any, key: str) -> bool:
|
|
229
|
+
try:
|
|
230
|
+
row[key]
|
|
231
|
+
return True
|
|
232
|
+
except (KeyError, IndexError, TypeError, AttributeError):
|
|
233
|
+
return hasattr(row, key)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _is_null(value: Any) -> bool:
|
|
237
|
+
if value is None:
|
|
238
|
+
return True
|
|
239
|
+
if isinstance(value, str) and value.strip() == "":
|
|
240
|
+
return True
|
|
241
|
+
# NaN is the only value not equal to itself.
|
|
242
|
+
return value != value
|
recusal/classify.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic failure classification + routing.
|
|
3
|
+
|
|
4
|
+
When an agent's action or output fails, the next move depends on *what kind* of
|
|
5
|
+
failure it is: a transient hiccup (retry), a policy refusal (don't retry as-is),
|
|
6
|
+
injected instructions in tool output (quarantine), a code bug (fix the code), the
|
|
7
|
+
wrong data shape (re-shape), missing data (fetch), or an ambiguous request
|
|
8
|
+
(ask a human). Guessing wastes a retry; asking a model is non-deterministic.
|
|
9
|
+
|
|
10
|
+
``classify_failure`` decides by explicit marker rules, same input, same class,
|
|
11
|
+
same route, every time. It ships a default taxonomy (extracted from a real
|
|
12
|
+
autonomous build system and generalized) that you can extend or replace.
|
|
13
|
+
|
|
14
|
+
Pure logic, standard library only.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Optional, Sequence
|
|
19
|
+
|
|
20
|
+
from .evidence import Verdict
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FailureClass:
|
|
25
|
+
"""One category of failure and where it routes.
|
|
26
|
+
|
|
27
|
+
``markers`` are case-insensitive substrings that signal this class; ``route`` is
|
|
28
|
+
the remediation channel/action a downstream system acts on (e.g. ``"retry"``,
|
|
29
|
+
``"fix-code"``, ``"ask-human"``).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
route: str
|
|
34
|
+
markers: Sequence[str]
|
|
35
|
+
description: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Classification:
|
|
40
|
+
"""The class a failure was assigned and how it routes."""
|
|
41
|
+
|
|
42
|
+
failure_class: str
|
|
43
|
+
route: str
|
|
44
|
+
marker: Optional[str] # the marker that matched, or None if it fell through
|
|
45
|
+
message: str
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def matched(self) -> bool:
|
|
49
|
+
return self.marker is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Order matters: the most specific / security-critical classes come first so a
|
|
53
|
+
# policy refusal or an injection is never misread as a generic code/data error.
|
|
54
|
+
DEFAULT_TAXONOMY: Sequence[FailureClass] = (
|
|
55
|
+
FailureClass(
|
|
56
|
+
"policy_violation",
|
|
57
|
+
"refuse",
|
|
58
|
+
(
|
|
59
|
+
"refused by the gate",
|
|
60
|
+
"recusal refused",
|
|
61
|
+
"subject guard",
|
|
62
|
+
"blocked by policy",
|
|
63
|
+
"permission denied",
|
|
64
|
+
),
|
|
65
|
+
"An action a deterministic policy refused, do not retry as-is; escalate or change the plan.",
|
|
66
|
+
),
|
|
67
|
+
FailureClass(
|
|
68
|
+
"prompt_injection",
|
|
69
|
+
"quarantine",
|
|
70
|
+
(
|
|
71
|
+
"ignore previous instructions",
|
|
72
|
+
"disregard the above",
|
|
73
|
+
"ignore the system prompt",
|
|
74
|
+
"exfiltrate",
|
|
75
|
+
"send the api key",
|
|
76
|
+
),
|
|
77
|
+
"Tool output appears to carry injected instructions, quarantine, don't act on it.",
|
|
78
|
+
),
|
|
79
|
+
FailureClass(
|
|
80
|
+
"transient",
|
|
81
|
+
"retry",
|
|
82
|
+
(
|
|
83
|
+
"timed out",
|
|
84
|
+
"timeout",
|
|
85
|
+
"rate limit",
|
|
86
|
+
"too many requests",
|
|
87
|
+
"connection reset",
|
|
88
|
+
"econnreset",
|
|
89
|
+
"temporarily unavailable",
|
|
90
|
+
"service unavailable",
|
|
91
|
+
),
|
|
92
|
+
"A recoverable infrastructure hiccup, retry with backoff.",
|
|
93
|
+
),
|
|
94
|
+
FailureClass(
|
|
95
|
+
"code_bug",
|
|
96
|
+
"fix-code",
|
|
97
|
+
(
|
|
98
|
+
"traceback",
|
|
99
|
+
"syntaxerror",
|
|
100
|
+
"nameerror",
|
|
101
|
+
"typeerror",
|
|
102
|
+
"attributeerror",
|
|
103
|
+
"assertionerror",
|
|
104
|
+
"modulenotfounderror",
|
|
105
|
+
"importerror",
|
|
106
|
+
"no module named",
|
|
107
|
+
"command not found",
|
|
108
|
+
"compile error",
|
|
109
|
+
"exit code 1",
|
|
110
|
+
"test failed",
|
|
111
|
+
"tests failed",
|
|
112
|
+
),
|
|
113
|
+
"The generated code is wrong, route to the builder/coding agent.",
|
|
114
|
+
),
|
|
115
|
+
FailureClass(
|
|
116
|
+
"data_shape",
|
|
117
|
+
"fix-data",
|
|
118
|
+
(
|
|
119
|
+
"schema mismatch",
|
|
120
|
+
"invalid type",
|
|
121
|
+
"column not found",
|
|
122
|
+
"unexpected field",
|
|
123
|
+
"validation error",
|
|
124
|
+
"null rate",
|
|
125
|
+
"keyerror",
|
|
126
|
+
),
|
|
127
|
+
"Data is the wrong shape, re-synthesize or re-shape it.",
|
|
128
|
+
),
|
|
129
|
+
FailureClass(
|
|
130
|
+
"data_missing",
|
|
131
|
+
"fetch-data",
|
|
132
|
+
(
|
|
133
|
+
"0 rows",
|
|
134
|
+
"no rows",
|
|
135
|
+
"empty result",
|
|
136
|
+
"not found",
|
|
137
|
+
"does not exist",
|
|
138
|
+
"orphan",
|
|
139
|
+
"missing data",
|
|
140
|
+
),
|
|
141
|
+
"Expected data is absent, fetch or re-migrate it.",
|
|
142
|
+
),
|
|
143
|
+
FailureClass(
|
|
144
|
+
"spec_ambiguity",
|
|
145
|
+
"ask-human",
|
|
146
|
+
(
|
|
147
|
+
"ambiguous",
|
|
148
|
+
"unclear",
|
|
149
|
+
"underspecified",
|
|
150
|
+
"which record",
|
|
151
|
+
"did you mean",
|
|
152
|
+
"need clarification",
|
|
153
|
+
"acceptance criteria",
|
|
154
|
+
),
|
|
155
|
+
"The request is ambiguous, escalate to a human for refinement.",
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def classify_failure(
|
|
161
|
+
text: str,
|
|
162
|
+
*,
|
|
163
|
+
taxonomy: Sequence[FailureClass] = DEFAULT_TAXONOMY,
|
|
164
|
+
fallback_class: str = "unknown",
|
|
165
|
+
fallback_route: str = "ask-human",
|
|
166
|
+
) -> Classification:
|
|
167
|
+
"""Classify a failure (an error string, a verdict reason, a log line) and route it.
|
|
168
|
+
|
|
169
|
+
First matching class wins (taxonomy order is precedence). If nothing matches,
|
|
170
|
+
returns the fallback class/route, never guesses. Non-string input is coerced with
|
|
171
|
+
``str()`` so the router never raises on the wrong type.
|
|
172
|
+
"""
|
|
173
|
+
haystack = str(text or "").lower()
|
|
174
|
+
for fc in taxonomy:
|
|
175
|
+
for marker in fc.markers:
|
|
176
|
+
if marker.lower() in haystack:
|
|
177
|
+
return Classification(
|
|
178
|
+
fc.name,
|
|
179
|
+
fc.route,
|
|
180
|
+
marker,
|
|
181
|
+
f"{fc.name} -> {fc.route} (matched '{marker}')",
|
|
182
|
+
)
|
|
183
|
+
return Classification(
|
|
184
|
+
fallback_class,
|
|
185
|
+
fallback_route,
|
|
186
|
+
None,
|
|
187
|
+
f"unclassified -> {fallback_route}",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def classify_verdict(
|
|
192
|
+
verdict: Verdict,
|
|
193
|
+
*,
|
|
194
|
+
taxonomy: Sequence[FailureClass] = DEFAULT_TAXONOMY,
|
|
195
|
+
fallback_class: str = "unknown",
|
|
196
|
+
fallback_route: str = "ask-human",
|
|
197
|
+
) -> Classification:
|
|
198
|
+
"""Classify a non-PASS ``Verdict`` from its reasons, what kind of failure, and where it
|
|
199
|
+
goes. A PASS verdict has nothing to route, so it returns ``pass -> proceed``."""
|
|
200
|
+
if verdict.passed:
|
|
201
|
+
return Classification("pass", "proceed", None, "verdict passed; nothing to route")
|
|
202
|
+
return classify_failure(
|
|
203
|
+
verdict.reasons(),
|
|
204
|
+
taxonomy=taxonomy,
|
|
205
|
+
fallback_class=fallback_class,
|
|
206
|
+
fallback_route=fallback_route,
|
|
207
|
+
)
|