custos-code 0.0.1__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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
custos_code/scope.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""The second checker: did the agent do what it was ASKED, not just what it said?
|
|
2
|
+
|
|
3
|
+
`rules.py` and `review.py` answer integrity -- did the claim happen. This answers scope -- was the
|
|
4
|
+
action inside the boundary the user granted. Design and rationale: docs/SCOPE.md.
|
|
5
|
+
|
|
6
|
+
**Scope is a blast radius, not a task description.** The design constraint is a real session
|
|
7
|
+
(cart-service, 2026-09-19): asked to fix failing tests, the agent also built a scratchpad venv, a
|
|
8
|
+
repro directory, and ran a mutation test. Good work, none of it requested. A rule keyed on "you
|
|
9
|
+
touched a file outside tests/" would have blocked it four times and been wrong every time.
|
|
10
|
+
|
|
11
|
+
What made that fine was not proximity to the request -- the mutation test was nowhere near it. It
|
|
12
|
+
was that every step was RECOVERABLE: temp dirs and uncommitted working-tree changes, all undoable
|
|
13
|
+
with `git checkout` or `rm -rf /tmp/...`.
|
|
14
|
+
|
|
15
|
+
Recoverability is the wiggle room. If git or a scratch directory can undo it, the agent may be
|
|
16
|
+
as creative as it likes and we do not spend a token looking.
|
|
17
|
+
|
|
18
|
+
Three bands (docs/SCOPE.md §4):
|
|
19
|
+
GREEN never gate, zero cost. Reads; writes git can revert; scratch dirs; tests/builds/linters.
|
|
20
|
+
YELLOW pause and ask. Writes outside cwd, unrecoverable edits, dependency installs, egress.
|
|
21
|
+
RED block always, attended or not. Irreversible AND outside the radius.
|
|
22
|
+
|
|
23
|
+
Everything here is PURE and DETERMINISTIC: no model call, no network, no subprocess beyond the git
|
|
24
|
+
queries `RepoState` already caches. That is what keeps GREEN free and keeps alpha at zero for the
|
|
25
|
+
bands that block -- and per docs/SCOPE.md §5, only grounded scope may block. The moment a block
|
|
26
|
+
rests on a model's judgement, alpha climbs and the stopping arithmetic in §2 turns against us.
|
|
27
|
+
|
|
28
|
+
Thresholds here are DEFAULTS, not findings. Issue #57 calibrates them against ~400 local sessions
|
|
29
|
+
of accepted work, where every YELLOW or RED is a false positive by construction. Until that lands,
|
|
30
|
+
treat the magnitude rules as unset.
|
|
31
|
+
|
|
32
|
+
Owner: Oliver.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import fnmatch
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import shlex
|
|
40
|
+
import tomllib
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from enum import StrEnum
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from .models import Claim, ClaimType, EventKind, LedgerEvent, Verdict, VerdictRecord
|
|
47
|
+
from .rules import RepoState
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Band(StrEnum):
|
|
51
|
+
GREEN = "green" # no gate, no cost
|
|
52
|
+
YELLOW = "yellow" # pause and ask (attended) / block (unattended)
|
|
53
|
+
RED = "red" # block always
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Irreversible by nature. These are matched against the command text, so they fire whether or not
|
|
57
|
+
# we can resolve the target path -- an `rm -rf` we cannot localise is exactly the case to stop.
|
|
58
|
+
_RED_COMMAND = [
|
|
59
|
+
(re.compile(r"\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*[rR])\b"), "rm-recursive-force"),
|
|
60
|
+
(re.compile(r"\bgit\s+push\b[^|;&]*(--force\b|(?<!-)-f\b)"), "git-force-push"),
|
|
61
|
+
(re.compile(r"\bgit\s+reset\s+--hard\b"), "git-reset-hard"),
|
|
62
|
+
(re.compile(r"\bgit\s+clean\b[^|;&]*-[a-zA-Z]*f"), "git-clean-force"),
|
|
63
|
+
(re.compile(r"(^|[|;&]\s*)sudo\b"), "sudo"),
|
|
64
|
+
(re.compile(r"\b(npm|yarn|pnpm)\s+publish\b|\btwine\s+upload\b|\bcargo\s+publish\b"), "package-publish"),
|
|
65
|
+
(re.compile(r"\bgh\s+(release|repo)\s+(create|delete)\b|\bgit\s+push\b[^|;&]*--delete\b"), "remote-mutation"),
|
|
66
|
+
(re.compile(r"\bshutdown\b|\breboot\b|\bdiskutil\b|\bmkfs\b"), "system-level"),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Paths that are never in the blast radius, wherever they sit. A write here is RED even under $HOME.
|
|
70
|
+
_PROTECTED = (
|
|
71
|
+
"~/.ssh", "~/.aws", "~/.gnupg", "~/.kube", "~/.docker/config.json",
|
|
72
|
+
"~/.netrc", "~/.npmrc", "~/.pypirc", "~/.git-credentials",
|
|
73
|
+
)
|
|
74
|
+
_PROTECTED_SUFFIX = (".env", ".pem", ".key", "id_rsa", "id_ed25519", "credentials")
|
|
75
|
+
|
|
76
|
+
# Reaches the network or mutates the environment. Recoverable, but the user should know.
|
|
77
|
+
_YELLOW_COMMAND = [
|
|
78
|
+
(re.compile(r"\b(pip|pip3|uv|npm|yarn|pnpm|cargo|go|gem|brew|apt|apt-get)\s+(install|add|get)\b"),
|
|
79
|
+
"dependency-install"),
|
|
80
|
+
(re.compile(r"\b(curl|wget|nc|ncat|ssh|scp|rsync|sftp)\b"), "network-egress"),
|
|
81
|
+
(re.compile(r"\bgit\s+push\b"), "git-push"),
|
|
82
|
+
(re.compile(r"\bcrontab\b|\blaunchctl\b|\bsystemctl\b"), "scheduling"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# Reads and inspections. Free, always, regardless of where they point.
|
|
86
|
+
_READ_TOOLS = frozenset({"Read", "Glob", "Grep", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite"})
|
|
87
|
+
_WRITE_TOOLS = frozenset({"Edit", "Write", "MultiEdit", "NotebookEdit", "str_replace_based_edit_tool"})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_POLICY_PATH = os.path.expanduser("~/.custos-code/policy.toml")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Policy:
|
|
95
|
+
"""User-widened or -narrowed scope rules, from `~/.custos-code/policy.toml` (SCOPE.md §6.4).
|
|
96
|
+
|
|
97
|
+
Every rule in this module keeps working with no file at all: `Policy()` adds nothing and
|
|
98
|
+
narrows nothing. Everything here is ADDITIVE -- a policy file can widen what counts as scratch
|
|
99
|
+
or protected, but it cannot silently drop a built-in RED rule out from under a user who never
|
|
100
|
+
asked for that; narrowing a built-in would need a code change and review, same as any other
|
|
101
|
+
safety-critical default.
|
|
102
|
+
|
|
103
|
+
`max_files_changed` is carried through but not yet consulted by `classify` -- SCOPE.md §7's
|
|
104
|
+
calibration against the real corpus reported thresholds as UNSET at this sample size (9
|
|
105
|
+
sessions vs. the ~400 the design calls for), and a magnitude rule that fires on a guess is the
|
|
106
|
+
same mistake the whole scope gate ships OFF to avoid.
|
|
107
|
+
"""
|
|
108
|
+
scratch: tuple[str, ...] = ()
|
|
109
|
+
red: tuple[str, ...] = ()
|
|
110
|
+
protect: tuple[str, ...] = ()
|
|
111
|
+
max_files_changed: int = 0
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def load(path: str | None = None) -> Policy:
|
|
115
|
+
p = path or _POLICY_PATH
|
|
116
|
+
if not os.path.exists(p):
|
|
117
|
+
return Policy()
|
|
118
|
+
try:
|
|
119
|
+
with open(p, "rb") as fh:
|
|
120
|
+
data = tomllib.load(fh)
|
|
121
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
122
|
+
return Policy()
|
|
123
|
+
scope = data.get("scope")
|
|
124
|
+
scope = scope if isinstance(scope, dict) else {}
|
|
125
|
+
|
|
126
|
+
def _tup(key: str) -> tuple[str, ...]:
|
|
127
|
+
v = scope.get(key)
|
|
128
|
+
return tuple(str(x) for x in v) if isinstance(v, list) else ()
|
|
129
|
+
|
|
130
|
+
max_files = scope.get("max_files_changed")
|
|
131
|
+
return Policy(
|
|
132
|
+
scratch=_tup("scratch"),
|
|
133
|
+
red=_tup("red"),
|
|
134
|
+
protect=_tup("protect"),
|
|
135
|
+
max_files_changed=max_files if isinstance(max_files, int) else 0,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Grant:
|
|
141
|
+
"""The blast radius the user allowed, explicitly and implicitly.
|
|
142
|
+
|
|
143
|
+
`cwd` is the implicit grant: invoking an agent in a directory grants that directory. `named`
|
|
144
|
+
is what the request mentioned. `approved` is ratcheted -- once the user approves a path this
|
|
145
|
+
session we never ask again, because a gate that re-asks is a gate people turn off (docs/SCOPE.md
|
|
146
|
+
§5), and a disabled gate verifies nothing.
|
|
147
|
+
"""
|
|
148
|
+
cwd: str
|
|
149
|
+
scratch: tuple[str, ...] = ()
|
|
150
|
+
named: tuple[str, ...] = ()
|
|
151
|
+
approved: tuple[str, ...] = ()
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def for_session(cwd: str, named: tuple[str, ...] = (), approved: tuple[str, ...] = (),
|
|
155
|
+
policy: Policy | None = None) -> Grant:
|
|
156
|
+
extra = [os.path.realpath(os.path.expandvars(os.path.expanduser(p)))
|
|
157
|
+
for p in (policy.scratch if policy else ())]
|
|
158
|
+
scratch = [os.path.realpath(p) for p in
|
|
159
|
+
(os.environ.get("TMPDIR", "/tmp"), "/tmp", "/private/tmp",
|
|
160
|
+
os.path.expanduser("~/.custos-code"), *extra) if p]
|
|
161
|
+
root = os.path.realpath(os.path.expanduser(cwd)) if cwd else ""
|
|
162
|
+
# Scratch roots are kept whole. An earlier version dropped any root that CONTAINED the
|
|
163
|
+
# project -- which deleted /tmp from the list whenever cwd was anywhere beneath it, so
|
|
164
|
+
# /tmp/scratch/build stopped being scratch and a routine cleanup became RED. It broke CI
|
|
165
|
+
# (pytest's tmp_path lives under /tmp on Linux) and, worse, would have silently removed
|
|
166
|
+
# scratch protection from any real session running in a container or sandbox under /tmp.
|
|
167
|
+
# Caught by Anush in review on #60.
|
|
168
|
+
#
|
|
169
|
+
# The grant still wins, but only where it actually applies: `_in_scratch` excludes paths
|
|
170
|
+
# inside cwd, so a project living in /tmp is banded normally while its siblings under /tmp
|
|
171
|
+
# remain disposable.
|
|
172
|
+
return Grant(cwd=root, scratch=tuple(dict.fromkeys(scratch)), named=named, approved=approved)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class Finding:
|
|
177
|
+
band: Band
|
|
178
|
+
rule: str # stable id, so calibration (#57) can bucket by cause
|
|
179
|
+
detail: str # one line naming the path or command
|
|
180
|
+
recoverable: bool
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def gates(self) -> bool:
|
|
184
|
+
return self.band is not Band.GREEN
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
GREEN_OK = Finding(Band.GREEN, "in-radius", "", recoverable=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _under(path: str, root: str) -> bool:
|
|
191
|
+
"""True when `path` is inside `root`.
|
|
192
|
+
|
|
193
|
+
`os.path.commonpath`, never `startswith`: `/x/proj-evil` is not inside `/x/proj`, and a
|
|
194
|
+
prefix test says it is. Same bug class as the CUSTOS_CODE_ONLY_IN fence in hooks.py.
|
|
195
|
+
"""
|
|
196
|
+
if not root:
|
|
197
|
+
return False
|
|
198
|
+
try:
|
|
199
|
+
return os.path.commonpath([os.path.realpath(root), os.path.realpath(path)]) == os.path.realpath(root)
|
|
200
|
+
except (ValueError, OSError):
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _abs(path: str, cwd: str) -> str:
|
|
205
|
+
p = os.path.expanduser(path)
|
|
206
|
+
return p if os.path.isabs(p) else os.path.normpath(os.path.join(cwd or os.getcwd(), p))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _protected(abs_path: str, extra: tuple[str, ...] = ()) -> str | None:
|
|
210
|
+
for p in _PROTECTED:
|
|
211
|
+
if _under(abs_path, os.path.expanduser(p)) or abs_path == os.path.expanduser(p):
|
|
212
|
+
return p
|
|
213
|
+
base = os.path.basename(abs_path)
|
|
214
|
+
for suf in _PROTECTED_SUFFIX:
|
|
215
|
+
if base == suf or base.endswith(suf):
|
|
216
|
+
return suf
|
|
217
|
+
for pat in extra:
|
|
218
|
+
expanded = os.path.expanduser(pat)
|
|
219
|
+
if fnmatch.fnmatch(abs_path, expanded) or fnmatch.fnmatch(base, expanded):
|
|
220
|
+
return pat
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _in_scratch(abs_path: str, grant: Grant) -> bool:
|
|
225
|
+
"""Inside a scratch directory -- but never a scratch root itself.
|
|
226
|
+
|
|
227
|
+
`rm -rf /tmp/scratch/build` is an agent tidying up. `rm -rf /tmp` is not, and a plain
|
|
228
|
+
"is it under a scratch root" test waves it through because a directory is trivially under
|
|
229
|
+
itself. The root belongs to the machine, not to the session.
|
|
230
|
+
"""
|
|
231
|
+
rp = os.path.realpath(abs_path)
|
|
232
|
+
if grant.cwd and _under(rp, grant.cwd):
|
|
233
|
+
return False # inside the granted project: band it normally, wherever it lives
|
|
234
|
+
return any(_under(rp, s) and rp != os.path.realpath(s) for s in grant.scratch)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def recoverable(abs_path: str, grant: Grant, state: RepoState) -> bool:
|
|
238
|
+
"""Can this write be undone without the user losing anything?
|
|
239
|
+
|
|
240
|
+
Scratch is free by definition. Inside cwd, git is the undo mechanism: in a work tree, both a
|
|
241
|
+
tracked edit and a new untracked file are revertible (`git checkout` / delete). Outside both,
|
|
242
|
+
assume not -- that is the conservative direction, and it is only ever used to escalate a
|
|
243
|
+
YELLOW, never to justify a RED on its own.
|
|
244
|
+
"""
|
|
245
|
+
if _in_scratch(abs_path, grant):
|
|
246
|
+
return True
|
|
247
|
+
if _under(abs_path, grant.cwd) and state.is_git:
|
|
248
|
+
return True
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _paths_in(tool: str, inp: dict[str, Any]) -> list[str]:
|
|
253
|
+
out = [v for k in ("file_path", "path", "notebook_path")
|
|
254
|
+
if isinstance(v := inp.get(k), str) and v]
|
|
255
|
+
if tool == "Bash" and isinstance(cmd := inp.get("command"), str):
|
|
256
|
+
try:
|
|
257
|
+
toks = shlex.split(cmd)
|
|
258
|
+
except ValueError:
|
|
259
|
+
toks = cmd.split()
|
|
260
|
+
out += [t for t in toks if ("/" in t or t.startswith("~")) and not t.startswith("-")]
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_CHAIN_OPS = frozenset({"&&", "||", ";", "|"})
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _cmd_segments(cmd: str) -> list[list[str]]:
|
|
268
|
+
"""Split a shell command line into its separate simple commands' argv lists.
|
|
269
|
+
|
|
270
|
+
Tokenizes once with shlex (so quoting is respected), then partitions the token stream on
|
|
271
|
+
unquoted `&&`/`||`/`;`/`|`. This is a tokenizer, not a shell: an operator glued to its
|
|
272
|
+
neighbours with no surrounding whitespace (`foo&&bar`) stays inside one token, the same
|
|
273
|
+
limitation the single-command check this replaces already had.
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
tokens = shlex.split(cmd)
|
|
277
|
+
except ValueError:
|
|
278
|
+
return []
|
|
279
|
+
segments: list[list[str]] = [[]]
|
|
280
|
+
for tok in tokens:
|
|
281
|
+
if tok in _CHAIN_OPS:
|
|
282
|
+
segments.append([])
|
|
283
|
+
else:
|
|
284
|
+
segments[-1].append(tok)
|
|
285
|
+
return [s for s in segments if s]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def classify(tool: str, tool_input: dict[str, Any], grant: Grant,
|
|
289
|
+
state: RepoState | None = None, policy: Policy | None = None) -> Finding:
|
|
290
|
+
"""Band one tool call. Pure, deterministic, no model call.
|
|
291
|
+
|
|
292
|
+
Order matters: RED first (an irreversible action is RED wherever it points), then reads (free),
|
|
293
|
+
then writes and the YELLOW command families. The first match wins, so a `sudo rm -rf` reports
|
|
294
|
+
as `rm-recursive-force` rather than as whichever rule happens to be checked last.
|
|
295
|
+
|
|
296
|
+
`policy` widens the built-in bands (SCOPE.md §6.4); it never narrows one, so an empty or
|
|
297
|
+
missing policy file classifies identically to no policy at all.
|
|
298
|
+
"""
|
|
299
|
+
st = state if state is not None else RepoState(grant.cwd or None)
|
|
300
|
+
pol = policy or Policy()
|
|
301
|
+
inp = tool_input or {}
|
|
302
|
+
raw_command = inp.get("command")
|
|
303
|
+
cmd = raw_command if isinstance(raw_command, str) else ""
|
|
304
|
+
|
|
305
|
+
# --- RED: irreversible, wherever it points -------------------------------------------------
|
|
306
|
+
if tool == "Bash" and cmd:
|
|
307
|
+
for pat, rule in _RED_COMMAND:
|
|
308
|
+
if pat.search(cmd):
|
|
309
|
+
# An rm -rf confined to scratch is how agents clean up after themselves.
|
|
310
|
+
if rule == "rm-recursive-force":
|
|
311
|
+
targets = [_abs(p, grant.cwd) for p in _paths_in(tool, inp)]
|
|
312
|
+
if targets and all(_in_scratch(t, grant) for t in targets):
|
|
313
|
+
return GREEN_OK
|
|
314
|
+
return Finding(Band.RED, rule, f"{rule}: {cmd[:160]}", recoverable=False)
|
|
315
|
+
for phrase in pol.red:
|
|
316
|
+
if phrase and phrase.lower() in cmd.lower():
|
|
317
|
+
return Finding(Band.RED, "policy-red", f"policy-red: {cmd[:160]}", recoverable=False)
|
|
318
|
+
|
|
319
|
+
if tool in _WRITE_TOOLS or (tool == "Bash" and cmd):
|
|
320
|
+
for raw in _write_relevant_paths(tool, inp, cmd):
|
|
321
|
+
p = _abs(raw, grant.cwd)
|
|
322
|
+
if hit := _protected(p, pol.protect):
|
|
323
|
+
return Finding(Band.RED, "protected-path", f"writes {hit}: {raw}", recoverable=False)
|
|
324
|
+
|
|
325
|
+
# --- GREEN: reads are free, everywhere -----------------------------------------------------
|
|
326
|
+
if tool in _READ_TOOLS:
|
|
327
|
+
return GREEN_OK
|
|
328
|
+
if tool == "Bash" and cmd and _is_read_only_cmd(cmd):
|
|
329
|
+
return GREEN_OK
|
|
330
|
+
|
|
331
|
+
# --- writes: banded by recoverability, not by distance from the request --------------------
|
|
332
|
+
if tool in _WRITE_TOOLS or (tool == "Bash" and cmd):
|
|
333
|
+
for raw in _write_relevant_paths(tool, inp, cmd):
|
|
334
|
+
p = _abs(raw, grant.cwd)
|
|
335
|
+
if _in_scratch(p, grant):
|
|
336
|
+
continue
|
|
337
|
+
if any(_under(p, _abs(a, grant.cwd)) for a in grant.approved):
|
|
338
|
+
continue # ratcheted: never ask twice
|
|
339
|
+
if not _under(p, grant.cwd):
|
|
340
|
+
return Finding(Band.YELLOW, "write-outside-cwd",
|
|
341
|
+
f"writes outside the granted directory: {raw}",
|
|
342
|
+
recoverable=recoverable(p, grant, st))
|
|
343
|
+
if not recoverable(p, grant, st):
|
|
344
|
+
return Finding(Band.YELLOW, "unrecoverable-write",
|
|
345
|
+
f"not revertible (no git work tree here): {raw}",
|
|
346
|
+
recoverable=False)
|
|
347
|
+
|
|
348
|
+
# --- YELLOW command families ---------------------------------------------------------------
|
|
349
|
+
if tool == "Bash" and cmd:
|
|
350
|
+
for pat, rule in _YELLOW_COMMAND:
|
|
351
|
+
if pat.search(cmd):
|
|
352
|
+
return Finding(Band.YELLOW, rule, f"{rule}: {cmd[:160]}", recoverable=True)
|
|
353
|
+
|
|
354
|
+
return GREEN_OK
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
_READ_ONLY_FIRST = frozenset({
|
|
358
|
+
"ls", "cat", "head", "tail", "grep", "rg", "find", "wc", "diff", "stat", "file", "which",
|
|
359
|
+
"pwd", "echo", "printf", "date", "env", "tree", "du", "df", "ps", "sed", "awk", "sort", "uniq",
|
|
360
|
+
"pytest", "npx", "node", "python", "python3", "go", "cargo", "make", "ruff", "mypy", "tsc",
|
|
361
|
+
"eslint", "jest", "vitest",
|
|
362
|
+
# Shell scaffolding: these never touch a file on disk themselves, so they cost nothing to
|
|
363
|
+
# allow -- and without them, ANY multi-step command wrapped in `cd project && ...` or
|
|
364
|
+
# `source ~/.env && ...` fell through as "unrecognised" on the first segment alone (the old,
|
|
365
|
+
# single-token check), which is what let a plain `source ~/.env` get reported as "writes .env".
|
|
366
|
+
"cd", "source", ".", "set", "export", "unset",
|
|
367
|
+
})
|
|
368
|
+
_GIT_READ_ONLY = frozenset({"status", "log", "diff", "show", "branch", "remote", "rev-parse",
|
|
369
|
+
"ls-files", "blame", "describe", "config", "stash"})
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _segment_is_read_only(parts: list[str]) -> bool:
|
|
373
|
+
"""One simple command's argv, non-mutating and without a redirect.
|
|
374
|
+
|
|
375
|
+
A redirect turns any of these into a write (`ls > file`), so a token containing `>` (`>`,
|
|
376
|
+
`>>`, or one glued to its target like `2>/dev/null`) disqualifies this segment. Being wrong
|
|
377
|
+
here costs a needless YELLOW, not a missed RED.
|
|
378
|
+
"""
|
|
379
|
+
if not parts or any(">" in t for t in parts):
|
|
380
|
+
return False
|
|
381
|
+
head = os.path.basename(parts[0])
|
|
382
|
+
if head == "git":
|
|
383
|
+
return len(parts) > 1 and parts[1] in _GIT_READ_ONLY
|
|
384
|
+
return head in _READ_ONLY_FIRST
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _is_read_only_cmd(cmd: str) -> bool:
|
|
388
|
+
"""Conservative: true only when EVERY step of a `&&`/`;`/`|`-chained line is non-mutating.
|
|
389
|
+
|
|
390
|
+
Checking just the line's first word (as this used to) meant `cd proj && pytest -q` was never
|
|
391
|
+
recognised as read-only at all, because "cd" wasn't even in the allowlist -- the compound
|
|
392
|
+
form fell through as if it were unrecognised, not as if it were safe.
|
|
393
|
+
"""
|
|
394
|
+
segments = _cmd_segments(cmd)
|
|
395
|
+
return bool(segments) and all(_segment_is_read_only(s) for s in segments)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _write_relevant_paths(tool: str, inp: dict[str, Any], cmd: str) -> list[str]:
|
|
399
|
+
"""Path-like arguments worth banding as a potential write.
|
|
400
|
+
|
|
401
|
+
For every tool but Bash this is just `_paths_in` -- an Edit/Write call's `file_path` is
|
|
402
|
+
always the thing being written. For Bash, a path mentioned only inside a segment recognised
|
|
403
|
+
as read-only is not a write candidate at all: `source ~/.env && uv run ...` does not write
|
|
404
|
+
`.env`, whatever the rest of the line goes on to do, and treating "the line has an
|
|
405
|
+
unrecognised step somewhere" as "every path in the line might be written" is what made that
|
|
406
|
+
read get reported as a write in the first place. A path inside a segment we don't recognise
|
|
407
|
+
still counts, same as before -- this narrows false positives, it does not loosen real ones.
|
|
408
|
+
"""
|
|
409
|
+
if tool != "Bash":
|
|
410
|
+
return _paths_in(tool, inp)
|
|
411
|
+
out: list[str] = []
|
|
412
|
+
for parts in _cmd_segments(cmd):
|
|
413
|
+
if _segment_is_read_only(parts):
|
|
414
|
+
continue
|
|
415
|
+
out += [t for t in parts if ("/" in t or t.startswith("~")) and not t.startswith("-")]
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---------------------------------------------------------------------------------------------
|
|
420
|
+
# Reaching the receipt (issue #64). `PreToolUse` (hooks.py) decides ask/deny before an action
|
|
421
|
+
# runs and never itself writes to the ledger -- a denied call leaves no CALL event to report on,
|
|
422
|
+
# by design. But plenty of gated actions DO end up in the ledger anyway: warn mode never blocks,
|
|
423
|
+
# an attended "ask" the user approved still ran, and a class-R bundle (Copilot, Devin) has no live
|
|
424
|
+
# gate in front of it at all. `scan` is how those reach a receipt post-hoc, by re-running the same
|
|
425
|
+
# pure `classify` over the CALL events a session already recorded.
|
|
426
|
+
# ---------------------------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
def scan(ledger: Sequence[LedgerEvent], grant: Grant, state: RepoState | None = None,
|
|
429
|
+
policy: Policy | None = None) -> list[tuple[LedgerEvent, Finding]]:
|
|
430
|
+
"""Every top-level tool call that would have gated, paired with the event that made it.
|
|
431
|
+
|
|
432
|
+
Sidechain (sub-agent) calls are excluded -- the same rule integrity already applies to
|
|
433
|
+
evidence: a sub-agent's actions have no write path either checker treats as the top-level
|
|
434
|
+
agent's own.
|
|
435
|
+
"""
|
|
436
|
+
st = state if state is not None else RepoState(grant.cwd or None)
|
|
437
|
+
hits: list[tuple[LedgerEvent, Finding]] = []
|
|
438
|
+
for event in ledger:
|
|
439
|
+
if event.kind is not EventKind.CALL or event.flags.sidechain or not event.tool:
|
|
440
|
+
continue
|
|
441
|
+
finding = classify(event.tool, event.input or {}, grant, st, policy)
|
|
442
|
+
if finding.gates:
|
|
443
|
+
hits.append((event, finding))
|
|
444
|
+
return hits
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def to_verdict(event: LedgerEvent, finding: Finding) -> tuple[Claim, VerdictRecord]:
|
|
448
|
+
"""One scope hit as a (Claim, VerdictRecord) pair, so report.py renders it like any other row.
|
|
449
|
+
|
|
450
|
+
Deliberately `out_of_scope`, never `contradicted` -- SCOPE.md §4: this is an action against a
|
|
451
|
+
boundary, not positive evidence a claim is false, and `contradicted` is reserved for that.
|
|
452
|
+
"""
|
|
453
|
+
claim = Claim(
|
|
454
|
+
id=f"scope-{event.seq}",
|
|
455
|
+
session_id=event.session_id,
|
|
456
|
+
text=f"{event.tool} at #{event.seq}: {finding.detail}",
|
|
457
|
+
type=ClaimType.OTHER,
|
|
458
|
+
polarity="did",
|
|
459
|
+
source="scope",
|
|
460
|
+
)
|
|
461
|
+
record = VerdictRecord(
|
|
462
|
+
claim_id=claim.id,
|
|
463
|
+
verdict=Verdict.OUT_OF_SCOPE,
|
|
464
|
+
tier=2,
|
|
465
|
+
method="rule",
|
|
466
|
+
confidence=1.0,
|
|
467
|
+
evidence=[event.seq],
|
|
468
|
+
rationale=f"[{finding.band.value}] {finding.rule}: {finding.detail}",
|
|
469
|
+
band=finding.band.value,
|
|
470
|
+
)
|
|
471
|
+
return claim, record
|