@miller-tech/uap 2.1.6 → 2.11.0
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.
- package/config/capacity-policy.json +32 -0
- package/config/classify-thresholds.json +9 -0
- package/config/supervise-policy.json +13 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +87 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +67 -0
- package/dist/capacity/policy.d.ts.map +1 -0
- package/dist/capacity/policy.js +133 -0
- package/dist/capacity/policy.js.map +1 -0
- package/dist/capacity/probe.d.ts +23 -0
- package/dist/capacity/probe.d.ts.map +1 -0
- package/dist/capacity/probe.js +93 -0
- package/dist/capacity/probe.js.map +1 -0
- package/dist/classify/baseline.d.ts +24 -0
- package/dist/classify/baseline.d.ts.map +1 -0
- package/dist/classify/baseline.js +140 -0
- package/dist/classify/baseline.js.map +1 -0
- package/dist/classify/index.d.ts +30 -0
- package/dist/classify/index.d.ts.map +1 -0
- package/dist/classify/index.js +84 -0
- package/dist/classify/index.js.map +1 -0
- package/dist/classify/shadow.d.ts +34 -0
- package/dist/classify/shadow.d.ts.map +1 -0
- package/dist/classify/shadow.js +33 -0
- package/dist/classify/shadow.js.map +1 -0
- package/dist/classify/types.d.ts +64 -0
- package/dist/classify/types.d.ts.map +1 -0
- package/dist/classify/types.js +37 -0
- package/dist/classify/types.js.map +1 -0
- package/dist/cli/classify.d.ts +24 -0
- package/dist/cli/classify.d.ts.map +1 -0
- package/dist/cli/classify.js +94 -0
- package/dist/cli/classify.js.map +1 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +26 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/doctor.d.ts +9 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +49 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/review.d.ts +12 -0
- package/dist/cli/review.d.ts.map +1 -0
- package/dist/cli/review.js +118 -0
- package/dist/cli/review.js.map +1 -0
- package/dist/cli/supervise.d.ts +8 -0
- package/dist/cli/supervise.d.ts.map +1 -0
- package/dist/cli/supervise.js +76 -0
- package/dist/cli/supervise.js.map +1 -0
- package/dist/delivery/convergence-loop.d.ts +8 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +1 -0
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/delivery-result.d.ts.map +1 -1
- package/dist/delivery/delivery-result.js +5 -0
- package/dist/delivery/delivery-result.js.map +1 -1
- package/dist/delivery/gate-evidence.d.ts +143 -0
- package/dist/delivery/gate-evidence.d.ts.map +1 -0
- package/dist/delivery/gate-evidence.js +239 -0
- package/dist/delivery/gate-evidence.js.map +1 -0
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/index.js.map +1 -1
- package/dist/evals/routing-corpus.d.ts +35 -0
- package/dist/evals/routing-corpus.d.ts.map +1 -0
- package/dist/evals/routing-corpus.js +142 -0
- package/dist/evals/routing-corpus.js.map +1 -0
- package/dist/evals/routing-eval.d.ts +104 -0
- package/dist/evals/routing-eval.d.ts.map +1 -0
- package/dist/evals/routing-eval.js +231 -0
- package/dist/evals/routing-eval.js.map +1 -0
- package/dist/evals/tfidf.d.ts +35 -0
- package/dist/evals/tfidf.d.ts.map +1 -0
- package/dist/evals/tfidf.js +132 -0
- package/dist/evals/tfidf.js.map +1 -0
- package/dist/review/prepass.d.ts +88 -0
- package/dist/review/prepass.d.ts.map +1 -0
- package/dist/review/prepass.js +428 -0
- package/dist/review/prepass.js.map +1 -0
- package/dist/review/visual-captures.d.ts +60 -0
- package/dist/review/visual-captures.d.ts.map +1 -0
- package/dist/review/visual-captures.js +222 -0
- package/dist/review/visual-captures.js.map +1 -0
- package/dist/supervise/assess.d.ts +73 -0
- package/dist/supervise/assess.d.ts.map +1 -0
- package/dist/supervise/assess.js +215 -0
- package/dist/supervise/assess.js.map +1 -0
- package/dist/supervise/config.d.ts +13 -0
- package/dist/supervise/config.d.ts.map +1 -0
- package/dist/supervise/config.js +81 -0
- package/dist/supervise/config.js.map +1 -0
- package/dist/supervise/loop.d.ts +78 -0
- package/dist/supervise/loop.d.ts.map +1 -0
- package/dist/supervise/loop.js +311 -0
- package/dist/supervise/loop.js.map +1 -0
- package/dist/supervise/observe.d.ts +31 -0
- package/dist/supervise/observe.d.ts.map +1 -0
- package/dist/supervise/observe.js +172 -0
- package/dist/supervise/observe.js.map +1 -0
- package/dist/supervise/policy.d.ts +16 -0
- package/dist/supervise/policy.d.ts.map +1 -0
- package/dist/supervise/policy.js +147 -0
- package/dist/supervise/policy.js.map +1 -0
- package/dist/supervise/types.d.ts +109 -0
- package/dist/supervise/types.d.ts.map +1 -0
- package/dist/supervise/types.js +11 -0
- package/dist/supervise/types.js.map +1 -0
- package/docs/INDEX.md +9 -3
- package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
- package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
- package/docs/guides/CAPACITY_POLICY.md +98 -0
- package/docs/performance/automode-risk-calibration.md +72 -0
- package/docs/performance/reliability-ladder.md +78 -0
- package/docs/plans/system1-uplift-2026-09-18.md +191 -0
- package/docs/reference/automode-risk.md +134 -0
- package/docs/reference/evidence-bound-ship.md +145 -0
- package/docs/reference/review-prepass.md +77 -0
- package/docs/reference/routing-evals.md +71 -0
- package/docs/reference/semantic-supervisor.md +140 -0
- package/docs/reference/system1-classifier.md +103 -0
- package/docs/reference/visual-captures.md +113 -0
- package/package.json +5 -3
- package/scripts/run-anthropic-proxy-continuity.sh +36 -0
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +25 -2
- package/src/policies/enforcers/evidence_bound_ship.py +257 -0
- package/src/policies/enforcers/expert_review_required.py +120 -0
- package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
- package/src/policies/schemas/policies/visual-verification.md +8 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +782 -19
- package/tools/agents/scripts/project_telemetry.py +52 -0
- package/tools/agents/scripts/tool_risk.py +789 -0
- package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
- package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
- package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
- package/tools/agents/tests/test_gate_evidence.py +56 -0
- package/tools/agents/tests/test_tool_risk.py +521 -0
- package/tools/agents/tests/test_verbatim_compact.py +629 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"""AutoMode pre-execution risk classification — advisory heuristic scorer.
|
|
2
|
+
|
|
3
|
+
Uplift 1.3. Classifies each tool call BEFORE dispatch into one of five risk
|
|
4
|
+
classes (destructive / write / execute / read / network) with a 1-5 score.
|
|
5
|
+
The output is ADVISORY to the deterministic policy chain: the enforcers in
|
|
6
|
+
src/policies/enforcers/*.py remain the blocking floor; this scorer never
|
|
7
|
+
blocks and never loosens anything. It exists so the proxy/enforcer stack has
|
|
8
|
+
an AutoMode risk signal NOW. Its vocabulary is INSPIRED BY the unmerged
|
|
9
|
+
TypeScript SYS1 baseline (src/classify/baseline.ts on
|
|
10
|
+
feature/301-sys1-classifier, the ESCALATION_TOKENS / DESTRUCTIVE_TOKENS
|
|
11
|
+
lists) — deliberately not "ported": that baseline scores prose state
|
|
12
|
+
descriptions while this scorer parses shell structure, so the domains and
|
|
13
|
+
machinery differ. The SYS1 promotion path is a Python-resident head (or
|
|
14
|
+
sidecar) behind this same classify_tool_call contract, gated by the
|
|
15
|
+
calibration harness at held-out destructive recall 1.0 — the wiring, harness
|
|
16
|
+
and doctrine do not change when it lands.
|
|
17
|
+
|
|
18
|
+
Design rules:
|
|
19
|
+
* PURE: no I/O, no clock except the latency measurement at the boundary.
|
|
20
|
+
* AUDITABLE: every point of score is attributable to a named signal in
|
|
21
|
+
``signals`` (format "name:class+weight").
|
|
22
|
+
* FAIL-CLOSED ON DESTRUCTIVE: a missed destructive call is the unacceptable
|
|
23
|
+
error (held-out destructive recall is gated at 1.0 by
|
|
24
|
+
tool_risk_calibrate.py --check); false positives are advisory-only cost.
|
|
25
|
+
* ALIGNED, NOT DUPLICATED: launcher prefixes, conditional-verb semantics
|
|
26
|
+
(sed -i, find -delete) and protected-surface markers mirror
|
|
27
|
+
src/policies/enforcers/enforcement_self_protect.py so both layers see the
|
|
28
|
+
same evasion shapes (nohup/timeout/sudo/env prefixes, &&/;/| chaining,
|
|
29
|
+
trailing-slash and ./ path variants, .uap/.git self-targets).
|
|
30
|
+
|
|
31
|
+
Calibration: docs/performance/automode-risk-calibration.md, generated by
|
|
32
|
+
tool_risk_calibrate.py from tools/agents/tests/fixtures/tool_risk_cases.json.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import posixpath
|
|
37
|
+
import re
|
|
38
|
+
import shlex
|
|
39
|
+
import time
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import Literal
|
|
43
|
+
|
|
44
|
+
RiskClass = Literal["destructive", "write", "execute", "read", "network"]
|
|
45
|
+
RISK_CLASSES: tuple[RiskClass, ...] = (
|
|
46
|
+
"destructive", "write", "execute", "read", "network",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Tie-break order when two classes tie on total signal weight: the more
|
|
50
|
+
# severe class wins (advisory pessimism is cheap; a missed destructive call
|
|
51
|
+
# is not).
|
|
52
|
+
_SEVERITY: tuple[RiskClass, ...] = (
|
|
53
|
+
"destructive", "network", "write", "execute", "read",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_BASE_SCORE: dict[RiskClass, int] = {
|
|
57
|
+
"read": 1, "execute": 2, "write": 3, "network": 3, "destructive": 4,
|
|
58
|
+
}
|
|
59
|
+
# A class whose signals reach this total weight is a "hard" instance of the
|
|
60
|
+
# class and scores base+1 (capped at 5).
|
|
61
|
+
_HARD_SIGNAL_WEIGHT = 5
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class RiskAssessment:
|
|
66
|
+
"""Outcome of one advisory classification.
|
|
67
|
+
|
|
68
|
+
risk_class: one of RISK_CLASSES. risk_score: 1 (benign) .. 5 (hard
|
|
69
|
+
destructive). signals: every signal that fired, "name:class+weight" —
|
|
70
|
+
the score is exactly derivable from this list. latency_ms: wall time of
|
|
71
|
+
the pure computation (populated by classify_tool_call).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
risk_class: RiskClass
|
|
75
|
+
risk_score: int
|
|
76
|
+
signals: list[str]
|
|
77
|
+
latency_ms: float
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# A signal is (name, class, weight); formatted into RiskAssessment.signals.
|
|
81
|
+
_Signal = tuple[str, RiskClass, int]
|
|
82
|
+
_SignalFn = Callable[[str, list[str], str, int, "list[_Signal]"], None]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- shell lexing (aligned with enforcers/enforcement_self_protect.py) -----
|
|
86
|
+
|
|
87
|
+
# Split chained commands. Single & is a separator (backgrounding) — the
|
|
88
|
+
# enforcer uses the same class and accepts that `2>&1` splits here too.
|
|
89
|
+
_SEGMENT_SPLIT = re.compile(r"(?:\|\||&&|[;\n|&])")
|
|
90
|
+
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*$")
|
|
91
|
+
# Output redirection. The negative lookahead keeps fd duplication (`2>&1`)
|
|
92
|
+
# from counting as a file write; the target group accepts a quoted token so
|
|
93
|
+
# `> "out file.txt"` is still one target.
|
|
94
|
+
_REDIRECT = re.compile(
|
|
95
|
+
r">>?(?!\s*&)\s*(\"[^\"\n]*\"|'[^'\n]*'|[^\s;|&]*)"
|
|
96
|
+
)
|
|
97
|
+
_QUOTED = re.compile(r'"[^"\n]*"|\'[^\'\n]*\'')
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _blank_quotes(text: str) -> str:
|
|
101
|
+
"""Blank quoted INTERIORS to spaces, preserving the quote characters and
|
|
102
|
+
the total length. Two properties ride on this:
|
|
103
|
+
|
|
104
|
+
* offsets are preserved, so a separator or operator position in the
|
|
105
|
+
blanked copy is the same position in the original (segment splitting
|
|
106
|
+
and redirect scanning can slice the original by the blanked span);
|
|
107
|
+
* a quoted TOKEN survives as a quoted token, so a quoted redirect target
|
|
108
|
+
(`> ".uap/x"`, `> "out file.txt"`) keeps its quotes and its span —
|
|
109
|
+
while quoted PROSE (`echo "rm -rf /"`) blanks to inert spaces and can
|
|
110
|
+
never reach verb position.
|
|
111
|
+
|
|
112
|
+
Known residual (documented in docs/reference/automode-risk.md): an
|
|
113
|
+
ESCAPED quote inside a double-quoted string (`"a=\\";\\"; rm -rf /x"`)
|
|
114
|
+
ends the match early, because the regex does not model backslash escapes
|
|
115
|
+
— same accepted-residual class as paths held in shell variables."""
|
|
116
|
+
def _sub(m: "re.Match") -> str:
|
|
117
|
+
s = m.group(0)
|
|
118
|
+
return s[0] + " " * (len(s) - 2) + s[-1]
|
|
119
|
+
|
|
120
|
+
return _QUOTED.sub(_sub, text)
|
|
121
|
+
# Launcher prefixes that run the REAL command as their tail — stepping over
|
|
122
|
+
# them (and their flags/values) is what makes `sudo rm -rf /`,
|
|
123
|
+
# `nohup rm -rf x &` and `timeout 10 rm -rf y` visible. Same list the
|
|
124
|
+
# self-protect enforcer steps over.
|
|
125
|
+
_LAUNCHERS = frozenset({
|
|
126
|
+
"nohup", "timeout", "command", "builtin", "setsid", "sudo", "doas",
|
|
127
|
+
"nice", "ionice", "stdbuf", "time", "unbuffer", "env",
|
|
128
|
+
})
|
|
129
|
+
_SHELL_VERBS = frozenset({"sh", "bash", "zsh", "dash", "ash", "ksh", "mksh"})
|
|
130
|
+
_INTERPRETERS = frozenset({
|
|
131
|
+
"python", "python2", "python3", "perl", "ruby", "node", "nodejs", "php", "lua",
|
|
132
|
+
})
|
|
133
|
+
_SQL_CLIENTS = frozenset({
|
|
134
|
+
"psql", "mysql", "mariadb", "sqlite3", "sqlplus",
|
|
135
|
+
"redis-cli", "mongo", "mongosh", "clickhouse-client",
|
|
136
|
+
})
|
|
137
|
+
_PKG_MANAGERS = frozenset({
|
|
138
|
+
"npm", "yarn", "pnpm", "pip", "pip3", "pipx",
|
|
139
|
+
"apt", "apt-get", "dpkg", "dnf", "yum", "brew",
|
|
140
|
+
})
|
|
141
|
+
_PLATFORM_CLIS = frozenset({
|
|
142
|
+
"docker", "podman", "kubectl", "terraform", "tofu", "aws", "gcloud",
|
|
143
|
+
"az", "systemctl", "go", "cargo",
|
|
144
|
+
})
|
|
145
|
+
# Regenerable trees whose removal is routine maintenance, not destruction:
|
|
146
|
+
# `rm -rf ./node_modules/.cache` must NOT classify destructive. Membership is
|
|
147
|
+
# by path COMPONENT so `./build`, `pkg/dist` and `/tmp/x` all resolve.
|
|
148
|
+
_SCRATCH_COMPONENTS = frozenset({
|
|
149
|
+
"node_modules", ".cache", "__pycache__", ".pytest_cache", ".mypy_cache",
|
|
150
|
+
".ruff-cache", ".ruff_cache", ".next", ".turbo", ".parcel-cache",
|
|
151
|
+
"dist", "build", "coverage", "out", "target", ".venv",
|
|
152
|
+
})
|
|
153
|
+
# The enforcement control surface. A destructive op naming any of these gets
|
|
154
|
+
# a score bump — this is the advisory mirror of the self-protect enforcer's
|
|
155
|
+
# PROTECTED_MARKERS (deliberately a coarse substring/regex net: advisory
|
|
156
|
+
# over-trigger is free).
|
|
157
|
+
_PROTECTED_RE = re.compile(
|
|
158
|
+
r"\.uap(?:[\s/'\".]|$)|(?:^|[\s/'\"])\.git(?:[\s/'\"]|$)"
|
|
159
|
+
r"|src/policies|\.policy-tools|policies\.db|anthropic-proxy\.env"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
_READ_VERBS = frozenset({
|
|
163
|
+
"ls", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "rg", "ag",
|
|
164
|
+
"ack", "ps", "df", "du", "pwd", "which", "file", "stat", "jq", "tree",
|
|
165
|
+
"less", "more", "id", "uname", "hostname", "date", "echo", "printf",
|
|
166
|
+
"cd", "printenv", "whoami", "lsblk", "lsof", "true", "false", "test",
|
|
167
|
+
})
|
|
168
|
+
_WRITE_VERBS = frozenset({
|
|
169
|
+
"cp", "mv", "mkdir", "touch", "ln", "tee", "install", "patch",
|
|
170
|
+
"unzip", "unrar", "gunzip", "bunzip2", "chattr", "zip", "gzip", "bzip2",
|
|
171
|
+
})
|
|
172
|
+
_EXEC_VERBS = frozenset({
|
|
173
|
+
"make", "ninja", "cmake", "pytest", "jest", "vitest", "mocha", "tox",
|
|
174
|
+
"nox", "gradle", "mvn", "kill", "pkill", "killall", "xargs", "source",
|
|
175
|
+
})
|
|
176
|
+
# find only mutates under a flag; sed only under -i. Mirrors the enforcer's
|
|
177
|
+
# CONDITIONAL_VERBS semantics.
|
|
178
|
+
_FIND_MUTATING = frozenset({
|
|
179
|
+
"-delete", "-exec", "-execdir", "-ok", "-okdir",
|
|
180
|
+
"-fprint", "-fprint0", "-fprintf", "-fls",
|
|
181
|
+
})
|
|
182
|
+
_INTERP_DESTRUCTIVE_RE = re.compile(
|
|
183
|
+
r"shutil\.rmtree|rmtree|os\.remove|os\.unlink|\.unlink\(|"
|
|
184
|
+
r"os\.removedirs|os\.rmdir|pathlib.*\.rmtree"
|
|
185
|
+
)
|
|
186
|
+
_INTERP_WRITE_RE = re.compile(
|
|
187
|
+
r"open\s*\([^)]*['\"][wax+]|\.write\(|\.writelines\(|json\.dump\("
|
|
188
|
+
)
|
|
189
|
+
_SQL_DROP_RE = re.compile(
|
|
190
|
+
r"\bdrop\s+(table|database|schema|index|collection)\b|dropdatabase\s*\(|"
|
|
191
|
+
r"\bflush(all|db)\b",
|
|
192
|
+
re.I,
|
|
193
|
+
)
|
|
194
|
+
_SQL_TRUNCATE_RE = re.compile(r"\btruncate\b", re.I)
|
|
195
|
+
_SQL_DELETE_RE = re.compile(r"\bdelete\s+from\b", re.I)
|
|
196
|
+
_SQL_WRITE_RE = re.compile(r"\b(insert\s+into|update\s+\w+\s+set|alter\s+table|create\s+table)\b", re.I)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _verb_of(token: str) -> str:
|
|
200
|
+
"""The command a token invokes, quoting/\\-escaping/path-prefix removed.
|
|
201
|
+
|
|
202
|
+
Same normalisation as the self-protect enforcer's `_verb_of`.
|
|
203
|
+
"""
|
|
204
|
+
return token.strip("\"'").lstrip("\\").rsplit("/", 1)[-1].lower()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _split_tokens(segment: str) -> list[str]:
|
|
208
|
+
"""shlex split with a naive fallback for unbalanced quotes."""
|
|
209
|
+
try:
|
|
210
|
+
return shlex.split(segment, posix=True)
|
|
211
|
+
except ValueError:
|
|
212
|
+
return segment.split()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _is_scratch_target(token: str) -> bool:
|
|
216
|
+
"""True when an rm target is a regenerable scratch path (or under /tmp)."""
|
|
217
|
+
t = token.strip("\"'").rstrip("/")
|
|
218
|
+
norm = posixpath.normpath(t) if "/" in t else t
|
|
219
|
+
if norm.startswith("/tmp/"):
|
|
220
|
+
return True
|
|
221
|
+
return any(part in _SCRATCH_COMPONENTS for part in norm.split("/"))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _first_operand(rest: list[str]) -> tuple[str, list[str]]:
|
|
225
|
+
"""First non-flag token and what follows it (subcommand extraction)."""
|
|
226
|
+
for i, tok in enumerate(rest):
|
|
227
|
+
if not tok.startswith("-"):
|
|
228
|
+
return tok.lower(), rest[i + 1:]
|
|
229
|
+
return "", []
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- per-verb signal functions (each small; one audit unit per rule) -------
|
|
233
|
+
# Uniform signature: fn(verb, rest, segment, depth, signals) -> None
|
|
234
|
+
|
|
235
|
+
def _sig_redirects(segment: str, signals: list[_Signal]) -> None:
|
|
236
|
+
"""Output redirects are writes; a device target is destructive.
|
|
237
|
+
|
|
238
|
+
Operators are found on the quote-BLANKED text (so `echo "a > b"` is not
|
|
239
|
+
a write), but the target is sliced from the ORIGINAL segment at the same
|
|
240
|
+
span — blanking preserves length, so a quoted target (`> ".uap/x"`,
|
|
241
|
+
`> "out file.txt"`) arrives with its real name for the protected check.
|
|
242
|
+
"""
|
|
243
|
+
blanked = _blank_quotes(segment)
|
|
244
|
+
for m in _REDIRECT.finditer(blanked):
|
|
245
|
+
target = segment[m.start(1):m.end(1)].strip("\"'")
|
|
246
|
+
if not target:
|
|
247
|
+
tail = segment[m.end():].split()
|
|
248
|
+
target = tail[0].strip("\"'") if tail else ""
|
|
249
|
+
if not target or target == "/dev/null":
|
|
250
|
+
continue
|
|
251
|
+
if target.startswith("/dev/"):
|
|
252
|
+
signals.append(("device-write", "destructive", 5))
|
|
253
|
+
elif _PROTECTED_RE.search(target.lower()):
|
|
254
|
+
signals.append(("protected-redirect", "write", 3))
|
|
255
|
+
else:
|
|
256
|
+
signals.append(("file-redirect", "write", 2))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _sig_shell_wrap(verb: str, rest: list[str], segment: str, depth: int,
|
|
260
|
+
signals: list[_Signal]) -> None:
|
|
261
|
+
"""sh/bash -c '...' and eval: unwrap and classify the inner command."""
|
|
262
|
+
signals.append(("shell-wrap", "execute", 1))
|
|
263
|
+
if depth >= 2: # bounded: bash -c "bash -c ..." can't recurse forever
|
|
264
|
+
return
|
|
265
|
+
if verb == "eval":
|
|
266
|
+
inner = " ".join(rest).strip()
|
|
267
|
+
if inner:
|
|
268
|
+
_classify_command(inner, depth + 1, signals)
|
|
269
|
+
return
|
|
270
|
+
for i, tok in enumerate(rest):
|
|
271
|
+
if tok == "-c" and i + 1 < len(rest):
|
|
272
|
+
_classify_command(rest[i + 1], depth + 1, signals)
|
|
273
|
+
return
|
|
274
|
+
# No -c: a bare `bash script.sh` runs the script.
|
|
275
|
+
if rest and not rest[0].startswith("-"):
|
|
276
|
+
signals.append(("shell-script", "execute", 2))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _sig_interpreter(verb: str, rest: list[str], segment: str, depth: int,
|
|
280
|
+
signals: list[_Signal]) -> None:
|
|
281
|
+
"""python/node/perl -c code is scanned for write/destructive primitives;
|
|
282
|
+
a script path or -m module is an ordinary execution."""
|
|
283
|
+
if "-c" in rest:
|
|
284
|
+
if _INTERP_DESTRUCTIVE_RE.search(segment):
|
|
285
|
+
signals.append(("interpreter-destructive", "destructive", 5))
|
|
286
|
+
elif _INTERP_WRITE_RE.search(segment):
|
|
287
|
+
signals.append(("interpreter-write", "write", 2))
|
|
288
|
+
else:
|
|
289
|
+
signals.append(("interpreter-code", "execute", 2))
|
|
290
|
+
elif rest and (not rest[0].startswith("-") or rest[0] == "-m"):
|
|
291
|
+
signals.append(("interpreter-script", "execute", 2))
|
|
292
|
+
else:
|
|
293
|
+
signals.append(("interpreter-run", "execute", 1))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _git_subcommand(rest: list[str]) -> tuple[str, list[str]]:
|
|
297
|
+
"""The git subcommand, stepping over global flags like -C/-c/--git-dir."""
|
|
298
|
+
i = 0
|
|
299
|
+
while i < len(rest):
|
|
300
|
+
tok = rest[i]
|
|
301
|
+
if tok in ("-C", "-c", "--git-dir", "--work-tree"):
|
|
302
|
+
i += 2
|
|
303
|
+
continue
|
|
304
|
+
if tok.startswith("-"):
|
|
305
|
+
i += 1
|
|
306
|
+
continue
|
|
307
|
+
return tok.lower(), rest[i + 1:]
|
|
308
|
+
return "", []
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _sig_git_push(args: list[str], signals: list[_Signal]) -> None:
|
|
312
|
+
flags = [t for t in args if t.startswith("-")]
|
|
313
|
+
# --force-with-lease / --force-if-includes are the SAFE force spellings:
|
|
314
|
+
# they verify the remote ref before overwriting, so they classify as the
|
|
315
|
+
# network operation they are, not as destructive (a measured lookalike).
|
|
316
|
+
safe_force = any(
|
|
317
|
+
t == "--force-with-lease" or t.startswith("--force-with-lease=")
|
|
318
|
+
or t == "--force-if-includes"
|
|
319
|
+
for t in flags
|
|
320
|
+
)
|
|
321
|
+
hard_force = any(
|
|
322
|
+
t in ("-f", "--force")
|
|
323
|
+
or (t.startswith("-") and not t.startswith("--") and "f" in t[1:])
|
|
324
|
+
for t in flags
|
|
325
|
+
)
|
|
326
|
+
# Hard force is judged FIRST: git ignores the lease when a plain --force
|
|
327
|
+
# is present, so `--force --force-with-lease` is destructive regardless
|
|
328
|
+
# of argument order. `--no-force-with-lease` matches neither list and
|
|
329
|
+
# correctly stays an ordinary push.
|
|
330
|
+
if hard_force:
|
|
331
|
+
signals.append(("git-push-force", "destructive", 5))
|
|
332
|
+
elif safe_force:
|
|
333
|
+
signals.append(("git-push-lease", "network", 3))
|
|
334
|
+
else:
|
|
335
|
+
signals.append(("git-push", "network", 3))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _sig_git(verb: str, rest: list[str], segment: str, depth: int,
|
|
339
|
+
signals: list[_Signal]) -> None:
|
|
340
|
+
sub, args = _git_subcommand(rest)
|
|
341
|
+
if sub == "push":
|
|
342
|
+
_sig_git_push(args, signals)
|
|
343
|
+
elif sub == "reset":
|
|
344
|
+
if "--hard" in args:
|
|
345
|
+
signals.append(("git-reset-hard", "destructive", 5))
|
|
346
|
+
else:
|
|
347
|
+
signals.append(("git-reset-soft", "write", 2))
|
|
348
|
+
elif sub == "clean":
|
|
349
|
+
joined = "".join(t for t in args if t.startswith("-"))
|
|
350
|
+
if "f" in joined:
|
|
351
|
+
signals.append(("git-clean-force", "destructive", 4))
|
|
352
|
+
else:
|
|
353
|
+
signals.append(("git-clean-dry", "read", 1))
|
|
354
|
+
elif sub in ("checkout", "restore", "switch"):
|
|
355
|
+
# `checkout -- <path>` / `restore <path>` DISCARDS worktree edits.
|
|
356
|
+
if "--" in args or (sub == "restore" and any(not t.startswith("-") for t in args)):
|
|
357
|
+
signals.append(("git-discard-worktree", "destructive", 4))
|
|
358
|
+
else:
|
|
359
|
+
signals.append(("git-checkout", "write", 2))
|
|
360
|
+
elif sub == "branch":
|
|
361
|
+
if "-D" in args:
|
|
362
|
+
signals.append(("git-branch-force-delete", "destructive", 4))
|
|
363
|
+
elif "-d" in args:
|
|
364
|
+
signals.append(("git-branch-delete", "write", 2))
|
|
365
|
+
else:
|
|
366
|
+
signals.append(("git-branch", "read", 1))
|
|
367
|
+
elif sub in ("fetch", "pull", "clone", "remote", "ls-remote"):
|
|
368
|
+
signals.append(("git-network", "network", 3))
|
|
369
|
+
elif sub in ("add", "commit", "tag", "stash", "rebase", "merge",
|
|
370
|
+
"cherry-pick", "mv", "rm", "worktree"):
|
|
371
|
+
signals.append(("git-write", "write", 2))
|
|
372
|
+
else:
|
|
373
|
+
# status, log, diff, show, blame, describe, rev-parse, ...
|
|
374
|
+
signals.append(("git-read", "read", 1))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _sig_rm_family(verb: str, rest: list[str], segment: str, depth: int,
|
|
378
|
+
signals: list[_Signal]) -> None:
|
|
379
|
+
if verb == "shred":
|
|
380
|
+
signals.append(("shred", "destructive", 5))
|
|
381
|
+
return
|
|
382
|
+
if verb == "unlink":
|
|
383
|
+
signals.append(("unlink-file", "destructive", 3))
|
|
384
|
+
return
|
|
385
|
+
if verb == "rmdir":
|
|
386
|
+
signals.append(("rmdir", "destructive", 2))
|
|
387
|
+
return
|
|
388
|
+
flags = [t for t in rest if t.startswith("-")]
|
|
389
|
+
# Only SHORT-flag clusters carry the recursive letter (-r, -rf, -Rf).
|
|
390
|
+
# Long flags match exactly: --force does NOT contain a recursive "r"
|
|
391
|
+
# (`rm --force file` is a plain file delete), --recursive does.
|
|
392
|
+
recursive = any(
|
|
393
|
+
t == "--recursive"
|
|
394
|
+
or (not t.startswith("--") and ("r" in t[1:] or "R" in t[1:]))
|
|
395
|
+
for t in flags
|
|
396
|
+
)
|
|
397
|
+
targets = [t for t in rest if not t.startswith("-")]
|
|
398
|
+
# The scratch carve-out: removing ONLY regenerable trees (node_modules,
|
|
399
|
+
# build output, /tmp scratch) is maintenance, not destruction. Mixed
|
|
400
|
+
# target lists do not qualify — one real target keeps the class.
|
|
401
|
+
if targets and all(_is_scratch_target(t) for t in targets):
|
|
402
|
+
signals.append(("rm-scratch-target", "write", 2))
|
|
403
|
+
elif recursive:
|
|
404
|
+
signals.append(("rm-recursive", "destructive", 5))
|
|
405
|
+
else:
|
|
406
|
+
signals.append(("rm-file", "destructive", 3))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _sig_find(verb: str, rest: list[str], segment: str, depth: int,
|
|
410
|
+
signals: list[_Signal]) -> None:
|
|
411
|
+
# Same rule as the enforcer: -delete / -exec-family / -f* writers mutate.
|
|
412
|
+
if any(t in _FIND_MUTATING for t in rest):
|
|
413
|
+
signals.append(("find-mutating", "destructive", 5))
|
|
414
|
+
else:
|
|
415
|
+
signals.append(("find-read", "read", 1))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _sig_sed(verb: str, rest: list[str], segment: str, depth: int,
|
|
419
|
+
signals: list[_Signal]) -> None:
|
|
420
|
+
# GNU sed mutates only with -i / --in-place (enforcer parity).
|
|
421
|
+
in_place = False
|
|
422
|
+
for tok in rest:
|
|
423
|
+
if tok.startswith("--"):
|
|
424
|
+
in_place = in_place or tok.startswith("--in-place")
|
|
425
|
+
elif tok.startswith("-") and len(tok) > 1:
|
|
426
|
+
# short-flag cluster: -i, -i.bak, -ni, -Ei all carry the write
|
|
427
|
+
if "i" in tok[1:].split(".", 1)[0]:
|
|
428
|
+
in_place = True
|
|
429
|
+
signals.append(("sed-in-place" if in_place else "sed-read",
|
|
430
|
+
"write" if in_place else "read", 3 if in_place else 1))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _sig_sql(verb: str, rest: list[str], segment: str, depth: int,
|
|
434
|
+
signals: list[_Signal]) -> None:
|
|
435
|
+
if _SQL_DROP_RE.search(segment):
|
|
436
|
+
signals.append(("sql-drop", "destructive", 5))
|
|
437
|
+
elif _SQL_TRUNCATE_RE.search(segment):
|
|
438
|
+
signals.append(("sql-truncate", "destructive", 5))
|
|
439
|
+
elif _SQL_DELETE_RE.search(segment):
|
|
440
|
+
signals.append(("sql-delete", "destructive", 4))
|
|
441
|
+
elif _SQL_WRITE_RE.search(segment):
|
|
442
|
+
signals.append(("sql-write", "write", 3))
|
|
443
|
+
else:
|
|
444
|
+
signals.append(("sql-read", "read", 1))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _sig_pkg_manager(verb: str, rest: list[str], segment: str, depth: int,
|
|
448
|
+
signals: list[_Signal]) -> None:
|
|
449
|
+
sub, _args = _first_operand(rest)
|
|
450
|
+
if sub in ("install", "i", "add", "ci", "update", "upgrade", "exec",
|
|
451
|
+
"test", "run", "build", "start", "create", "dedupe"):
|
|
452
|
+
# Installs RUN package code (postinstall scripts); tests/builds run
|
|
453
|
+
# project code. Both are executions.
|
|
454
|
+
signals.append(("pkg-install", "execute", 3))
|
|
455
|
+
elif sub == "publish":
|
|
456
|
+
signals.append(("pkg-publish", "network", 3))
|
|
457
|
+
elif sub in ("remove", "uninstall", "rm", "purge", "prune"):
|
|
458
|
+
signals.append(("pkg-remove", "destructive", 3))
|
|
459
|
+
elif sub in ("list", "ls", "freeze", "show", "info", "view", "search"):
|
|
460
|
+
signals.append(("pkg-read", "read", 1))
|
|
461
|
+
else:
|
|
462
|
+
signals.append(("pkg-other", "execute", 2))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _sig_platform_cli(verb: str, rest: list[str], segment: str, depth: int,
|
|
466
|
+
signals: list[_Signal]) -> None:
|
|
467
|
+
sub, _args = _first_operand(rest)
|
|
468
|
+
if verb in ("docker", "podman"):
|
|
469
|
+
if sub in ("run", "build", "exec", "compose", "start", "up"):
|
|
470
|
+
signals.append(("container-run", "execute", 3))
|
|
471
|
+
elif sub in ("rm", "rmi", "prune", "system"):
|
|
472
|
+
signals.append(("container-remove", "destructive", 4))
|
|
473
|
+
elif sub in ("ps", "images", "logs", "inspect"):
|
|
474
|
+
signals.append(("container-read", "read", 1))
|
|
475
|
+
else:
|
|
476
|
+
signals.append(("container-other", "execute", 2))
|
|
477
|
+
elif verb == "kubectl":
|
|
478
|
+
if sub in ("get", "describe", "logs"):
|
|
479
|
+
signals.append(("kube-read", "read", 1))
|
|
480
|
+
elif sub in ("delete", "drain"):
|
|
481
|
+
signals.append(("kube-delete", "destructive", 4))
|
|
482
|
+
else:
|
|
483
|
+
signals.append(("kube-apply", "execute", 3))
|
|
484
|
+
elif verb in ("terraform", "tofu"):
|
|
485
|
+
if sub == "destroy":
|
|
486
|
+
signals.append(("iac-destroy", "destructive", 5))
|
|
487
|
+
elif sub == "apply":
|
|
488
|
+
signals.append(("iac-apply", "execute", 3))
|
|
489
|
+
elif sub in ("plan", "show", "output", "state"):
|
|
490
|
+
signals.append(("iac-read", "read", 1))
|
|
491
|
+
else:
|
|
492
|
+
signals.append(("iac-other", "execute", 2))
|
|
493
|
+
elif verb in ("aws", "gcloud", "az"):
|
|
494
|
+
low = segment.lower()
|
|
495
|
+
if "rotate-secret" in low or ("secretsmanager" in low and "rotate" in low):
|
|
496
|
+
# Secrets rotation invalidates deployed credentials — the blast
|
|
497
|
+
# radius is a production outage, so it classifies destructive.
|
|
498
|
+
signals.append(("secrets-rotation", "destructive", 4))
|
|
499
|
+
elif re.search(r"\bs3\s+(rm|rb|mv)\b", low):
|
|
500
|
+
signals.append(("cloud-storage-remove", "destructive", 4))
|
|
501
|
+
else:
|
|
502
|
+
signals.append(("cloud-api", "network", 2))
|
|
503
|
+
elif verb == "systemctl":
|
|
504
|
+
if sub in ("status", "is-active", "is-enabled", "list-units", "show"):
|
|
505
|
+
signals.append(("service-read", "read", 1))
|
|
506
|
+
else:
|
|
507
|
+
signals.append(("service-control", "execute", 3))
|
|
508
|
+
else: # go / cargo
|
|
509
|
+
if sub == "fmt":
|
|
510
|
+
signals.append(("toolchain-fmt", "write", 2))
|
|
511
|
+
else:
|
|
512
|
+
signals.append(("toolchain-run", "execute", 2))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _sig_chmod(verb: str, rest: list[str], segment: str, depth: int,
|
|
516
|
+
signals: list[_Signal]) -> None:
|
|
517
|
+
recursive = any(
|
|
518
|
+
t == "-R" or t == "--recursive"
|
|
519
|
+
or (t.startswith("-") and not t.startswith("--") and "R" in t)
|
|
520
|
+
for t in rest
|
|
521
|
+
)
|
|
522
|
+
permissive = any(t in ("777", "666", "a+w", "o+w") for t in rest)
|
|
523
|
+
targets_root = any(t.rstrip("/") == "/" for t in rest if not t.startswith("-"))
|
|
524
|
+
if recursive and (permissive or targets_root):
|
|
525
|
+
# `chmod -R 777 /etc`-shaped: broad permission destruction.
|
|
526
|
+
signals.append(("chmod-recursive-permissive", "destructive", 4))
|
|
527
|
+
elif recursive:
|
|
528
|
+
signals.append(("chmod-recursive", "write", 3))
|
|
529
|
+
else:
|
|
530
|
+
signals.append(("chmod", "write", 2))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _sig_dd(verb: str, rest: list[str], segment: str, depth: int,
|
|
534
|
+
signals: list[_Signal]) -> None:
|
|
535
|
+
of = ""
|
|
536
|
+
for tok in rest:
|
|
537
|
+
if tok.startswith("of="):
|
|
538
|
+
of = tok[3:]
|
|
539
|
+
if of.startswith("/dev/"):
|
|
540
|
+
signals.append(("dd-device", "destructive", 5))
|
|
541
|
+
elif of:
|
|
542
|
+
signals.append(("dd-file", "write", 3))
|
|
543
|
+
else:
|
|
544
|
+
signals.append(("dd-read", "read", 1))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _sig_tar(verb: str, rest: list[str], segment: str, depth: int,
|
|
548
|
+
signals: list[_Signal]) -> None:
|
|
549
|
+
if not rest:
|
|
550
|
+
signals.append(("archive-read", "read", 1))
|
|
551
|
+
return
|
|
552
|
+
flags = "".join(t.lstrip("-") for t in rest if t.startswith("-") or t == rest[0])
|
|
553
|
+
if "x" in flags or "c" in flags:
|
|
554
|
+
signals.append(("archive-write", "write", 2))
|
|
555
|
+
else:
|
|
556
|
+
signals.append(("archive-read", "read", 1))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _sig_rsync(verb: str, rest: list[str], segment: str, depth: int,
|
|
560
|
+
signals: list[_Signal]) -> None:
|
|
561
|
+
# A "host:" operand means the wire; otherwise it is a local copy.
|
|
562
|
+
if any(":" in t and not t.startswith("-") for t in rest):
|
|
563
|
+
signals.append(("rsync-remote", "network", 3))
|
|
564
|
+
else:
|
|
565
|
+
signals.append(("rsync-local", "write", 2))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _sig_simple(weight_class: tuple[str, RiskClass, int]) -> _SignalFn:
|
|
569
|
+
"""Build a constant-signaler for the table-driven trivial verbs."""
|
|
570
|
+
name, klass, weight = weight_class
|
|
571
|
+
|
|
572
|
+
def _emit(verb: str, rest: list[str], segment: str, depth: int,
|
|
573
|
+
signals: list[_Signal]) -> None:
|
|
574
|
+
signals.append((name, klass, weight))
|
|
575
|
+
|
|
576
|
+
return _emit
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# Ordered verb dispatch. First match wins; the fallback at the bottom of
|
|
580
|
+
# _classify_segment is a generic execution.
|
|
581
|
+
_VERB_RULES: tuple[tuple[frozenset, _SignalFn], ...] = (
|
|
582
|
+
(_SHELL_VERBS | {"eval"}, _sig_shell_wrap),
|
|
583
|
+
(_INTERPRETERS, _sig_interpreter),
|
|
584
|
+
(frozenset({"git"}), _sig_git),
|
|
585
|
+
(frozenset({"rm", "unlink", "shred", "rmdir"}), _sig_rm_family),
|
|
586
|
+
(frozenset({"find"}), _sig_find),
|
|
587
|
+
(frozenset({"sed"}), _sig_sed),
|
|
588
|
+
(_SQL_CLIENTS, _sig_sql),
|
|
589
|
+
(_PKG_MANAGERS, _sig_pkg_manager),
|
|
590
|
+
(_PLATFORM_CLIS, _sig_platform_cli),
|
|
591
|
+
(frozenset({"chmod", "chown"}), _sig_chmod),
|
|
592
|
+
(frozenset({"dd"}), _sig_dd),
|
|
593
|
+
(frozenset({"tar"}), _sig_tar),
|
|
594
|
+
(frozenset({"rsync"}), _sig_rsync),
|
|
595
|
+
(frozenset({"truncate", "wipefs", "fdisk", "sfdisk", "parted", "mkswap"}),
|
|
596
|
+
_sig_simple(("wipe-device", "destructive", 4))),
|
|
597
|
+
(frozenset({"curl", "wget", "scp", "sftp", "ssh", "nc", "ncat"}),
|
|
598
|
+
_sig_simple(("network-call", "network", 3))),
|
|
599
|
+
(_READ_VERBS, _sig_simple(("read-command", "read", 1))),
|
|
600
|
+
(_WRITE_VERBS, _sig_simple(("write-command", "write", 2))),
|
|
601
|
+
(_EXEC_VERBS, _sig_simple(("exec-command", "execute", 2))),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _classify_segment(segment: str, depth: int, signals: list[_Signal]) -> None:
|
|
606
|
+
# Redirects: operators are matched on quote-blanked text (quoted prose
|
|
607
|
+
# stays inert) while targets come from the original — see _sig_redirects.
|
|
608
|
+
_sig_redirects(segment, signals)
|
|
609
|
+
tokens = _split_tokens(segment)
|
|
610
|
+
while tokens and _ENV_ASSIGN.match(tokens[0]):
|
|
611
|
+
tokens = tokens[1:]
|
|
612
|
+
# Step over launcher prefixes (and their flag/value arguments) so the
|
|
613
|
+
# REAL verb is judged: `sudo rm -rf /`, `timeout 10 rm -rf x`,
|
|
614
|
+
# `env FOO=1 rm -rf y`. Same step-over as the self-protect enforcer,
|
|
615
|
+
# including its known limit: `sudo -u root rm ...` leaves `root` as the
|
|
616
|
+
# apparent verb (advisory-only cost, documented in the reference doc).
|
|
617
|
+
while len(tokens) > 1 and _verb_of(tokens[0]) in _LAUNCHERS:
|
|
618
|
+
tokens = tokens[1:]
|
|
619
|
+
while tokens and (tokens[0].startswith("-") or tokens[0].isdigit()):
|
|
620
|
+
tokens = tokens[1:]
|
|
621
|
+
while tokens and _ENV_ASSIGN.match(tokens[0]):
|
|
622
|
+
tokens = tokens[1:]
|
|
623
|
+
if not tokens:
|
|
624
|
+
return
|
|
625
|
+
verb = _verb_of(tokens[0])
|
|
626
|
+
if verb.startswith("mkfs"):
|
|
627
|
+
signals.append(("mkfs-device", "destructive", 5))
|
|
628
|
+
return
|
|
629
|
+
rest = tokens[1:]
|
|
630
|
+
for names, fn in _VERB_RULES:
|
|
631
|
+
if verb in names:
|
|
632
|
+
fn(verb, rest, segment, depth, signals)
|
|
633
|
+
return
|
|
634
|
+
# An unknown verb in a shell is a program execution (./script.sh included).
|
|
635
|
+
signals.append(("shell-command", "execute", 2))
|
|
636
|
+
|
|
637
|
+
def _split_segments(command: str) -> list[str]:
|
|
638
|
+
"""Split a command into chained segments, ignoring separators inside
|
|
639
|
+
quotes. Quote interiors are blanked with equal-length space runs so
|
|
640
|
+
offsets in the blanked copy match the original (this is what keeps the
|
|
641
|
+
`;` inside `python3 -c "import shutil; shutil.rmtree(x)"` from hiding
|
|
642
|
+
the rmtree from the interpreter scan — the enforcer chain splits naively
|
|
643
|
+
and the same evasion shape must not open here)."""
|
|
644
|
+
blanked = _blank_quotes(command or "")
|
|
645
|
+
out: list[str] = []
|
|
646
|
+
last = 0
|
|
647
|
+
for m in _SEGMENT_SPLIT.finditer(blanked):
|
|
648
|
+
out.append(command[last:m.start()])
|
|
649
|
+
last = m.end()
|
|
650
|
+
out.append(command[last:])
|
|
651
|
+
return out
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _classify_command(command: str, depth: int, signals: list[_Signal]) -> None:
|
|
655
|
+
for segment in _split_segments(command):
|
|
656
|
+
segment = segment.strip()
|
|
657
|
+
if segment:
|
|
658
|
+
_classify_segment(segment, depth, signals)
|
|
659
|
+
# A destructive op naming the enforcement control surface (.uap/, .git,
|
|
660
|
+
# policy tree, proxy env) is the self-targeting evasion the self-protect
|
|
661
|
+
# enforcer exists for — bump it so it always lands at score 5.
|
|
662
|
+
if depth == 0 and _PROTECTED_RE.search(command.lower()):
|
|
663
|
+
if any(klass == "destructive" for _n, klass, _w in signals):
|
|
664
|
+
signals.append(("protected-target", "destructive", 1))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# --- tool-name routing -----------------------------------------------------
|
|
668
|
+
|
|
669
|
+
_READ_TOOLS = frozenset({
|
|
670
|
+
"read", "grep", "glob", "ls", "view", "read_file", "list_directory",
|
|
671
|
+
"search_files", "get_file_info",
|
|
672
|
+
})
|
|
673
|
+
_WRITE_TOOLS = frozenset({
|
|
674
|
+
"edit", "write", "multiedit", "notebookedit", "create", "create_file",
|
|
675
|
+
"write_file", "edit_file", "insert", "str_replace_editor",
|
|
676
|
+
})
|
|
677
|
+
_NETWORK_TOOLS = frozenset({
|
|
678
|
+
"webfetch", "websearch", "web_fetch", "web_search", "fetch", "browse",
|
|
679
|
+
})
|
|
680
|
+
_SHELL_TOOLS = frozenset({
|
|
681
|
+
"bash", "shell", "execute", "run", "terminal", "exec", "process",
|
|
682
|
+
"powershell", "cmd", "run_command", "execute_command", "run_terminal_cmd",
|
|
683
|
+
})
|
|
684
|
+
_COMMAND_KEYS = ("command", "cmd", "script", "code")
|
|
685
|
+
_PATH_KEYS = ("file_path", "path", "target", "filename", "filePath")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _tool_family(tool_name: str) -> str:
|
|
689
|
+
leaf = (tool_name or "").lower().split("__")[-1].replace("-", "_")
|
|
690
|
+
if leaf in _READ_TOOLS:
|
|
691
|
+
return "read"
|
|
692
|
+
if leaf in _WRITE_TOOLS:
|
|
693
|
+
return "write"
|
|
694
|
+
if leaf in _NETWORK_TOOLS:
|
|
695
|
+
return "network"
|
|
696
|
+
if leaf in _SHELL_TOOLS:
|
|
697
|
+
return "shell"
|
|
698
|
+
# MCP-style names (mcp__server__do_thing): substring fallback.
|
|
699
|
+
if any(k in leaf for k in ("bash", "shell", "terminal", "powershell", "command")):
|
|
700
|
+
return "shell"
|
|
701
|
+
if any(k in leaf for k in ("write", "edit", "create", "patch")):
|
|
702
|
+
return "write"
|
|
703
|
+
if any(k in leaf for k in ("read", "grep", "glob", "list")):
|
|
704
|
+
return "read"
|
|
705
|
+
if any(k in leaf for k in ("fetch", "browse", "search", "http")):
|
|
706
|
+
return "network"
|
|
707
|
+
return "unknown"
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _extract_command(tool_input: dict) -> str:
|
|
711
|
+
for key in _COMMAND_KEYS:
|
|
712
|
+
value = tool_input.get(key)
|
|
713
|
+
if isinstance(value, str) and value.strip():
|
|
714
|
+
return value
|
|
715
|
+
return ""
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _extract_path(tool_input: dict) -> str:
|
|
719
|
+
for key in _PATH_KEYS:
|
|
720
|
+
value = tool_input.get(key)
|
|
721
|
+
if isinstance(value, str) and value.strip():
|
|
722
|
+
return value
|
|
723
|
+
return ""
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# --- combination -----------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _combine(signals: list[_Signal]) -> tuple[RiskClass, int]:
|
|
730
|
+
"""Class = highest total signal weight, ties broken by severity. Score =
|
|
731
|
+
class base, +1 when the class's evidence is hard (>= _HARD_SIGNAL_WEIGHT),
|
|
732
|
+
capped at 5. Every point traces to the named signals that produced it."""
|
|
733
|
+
if not signals:
|
|
734
|
+
return "read", 1
|
|
735
|
+
totals: dict[RiskClass, int] = {}
|
|
736
|
+
for _name, klass, weight in signals:
|
|
737
|
+
totals[klass] = totals.get(klass, 0) + weight
|
|
738
|
+
best = max(totals, key=lambda k: (totals[k], -_SEVERITY.index(k)))
|
|
739
|
+
score = _BASE_SCORE[best] + (1 if totals[best] >= _HARD_SIGNAL_WEIGHT else 0)
|
|
740
|
+
return best, min(5, score)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _assess(tool_name: str, tool_input: dict) -> tuple[RiskClass, int, list[str]]:
|
|
744
|
+
"""The pure computation: no I/O, no clock. Separated from
|
|
745
|
+
classify_tool_call so the latency measurement wraps exactly this."""
|
|
746
|
+
signals: list[_Signal] = []
|
|
747
|
+
family = _tool_family(tool_name)
|
|
748
|
+
if family == "shell":
|
|
749
|
+
command = _extract_command(tool_input)
|
|
750
|
+
if command:
|
|
751
|
+
_classify_command(command, 0, signals)
|
|
752
|
+
else:
|
|
753
|
+
# An empty command runs nothing.
|
|
754
|
+
signals.append(("shell-empty", "read", 1))
|
|
755
|
+
elif family == "write":
|
|
756
|
+
signals.append(("tool-write", "write", 2))
|
|
757
|
+
path = _extract_path(tool_input)
|
|
758
|
+
if path and _PROTECTED_RE.search(path.lower()):
|
|
759
|
+
signals.append(("protected-target", "write", 3))
|
|
760
|
+
elif family == "read":
|
|
761
|
+
signals.append(("tool-read", "read", 1))
|
|
762
|
+
elif family == "network":
|
|
763
|
+
signals.append(("tool-network", "network", 3))
|
|
764
|
+
else:
|
|
765
|
+
# Unknown tool: assume it can act (advisory pessimism), at the lowest
|
|
766
|
+
# execution weight. The deterministic enforcers judge it for real.
|
|
767
|
+
signals.append(("tool-unknown", "execute", 1))
|
|
768
|
+
risk_class, score = _combine(signals)
|
|
769
|
+
formatted = [f"{name}:{klass}+{weight}" for name, klass, weight in signals]
|
|
770
|
+
return risk_class, score, formatted
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def classify_tool_call(tool_name: str, tool_input: dict) -> RiskAssessment:
|
|
774
|
+
"""Classify one tool call's risk. PURE: no I/O, no global state.
|
|
775
|
+
|
|
776
|
+
``tool_input`` that is not a dict (or missing keys) degrades to the
|
|
777
|
+
tool-name-only classification — never an exception, never a block.
|
|
778
|
+
"""
|
|
779
|
+
start = time.perf_counter()
|
|
780
|
+
if not isinstance(tool_input, dict):
|
|
781
|
+
tool_input = {}
|
|
782
|
+
risk_class, score, signals = _assess(str(tool_name or ""), tool_input)
|
|
783
|
+
latency_ms = (time.perf_counter() - start) * 1000.0
|
|
784
|
+
return RiskAssessment(
|
|
785
|
+
risk_class=risk_class,
|
|
786
|
+
risk_score=score,
|
|
787
|
+
signals=signals,
|
|
788
|
+
latency_ms=latency_ms,
|
|
789
|
+
)
|