agentflow-proof 1.0.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.
agentflow/risk.py ADDED
@@ -0,0 +1,328 @@
1
+ """Deterministic, no-network, no-LLM command risk screening.
2
+
3
+ Classifies a command's argv (and any ``sh -c`` payload) into the existing
4
+ low/medium/high vocabulary BEFORE ``agentflow run`` executes it. This is
5
+ screening, not a sandbox: it does not contain execution or fully model shell
6
+ semantics. It is a conservative pattern matcher over the argv the caller passed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import posixpath
12
+ import re
13
+ import shlex
14
+ from fnmatch import fnmatch
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from .contracts import RISK_POLICIES
19
+ from .validation import effective_scope, matches_path
20
+
21
+ _LEVEL_RANK = {"low": 0, "medium": 1, "high": 2}
22
+
23
+ _SHELL_PROGRAMS = {"sh", "bash", "zsh", "dash", "ksh"}
24
+ _WRAPPER_PROGRAMS = {"env", "command"}
25
+ _PRIVILEGE_PROGRAMS = {"sudo", "doas", "su"}
26
+ _SEGMENT_SPLIT = re.compile(r"\|\||&&|[;|&]")
27
+ _CREDENTIAL_GLOBS = ("*.pem", "*.key")
28
+ _CREDENTIAL_BASENAMES = {
29
+ "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
30
+ "credentials", ".netrc", ".env",
31
+ }
32
+ _SHELL_LONG_OPTIONS_WITH_VALUES = {"--init-file", "--rcfile"}
33
+ _SHELL_SHORT_OPTIONS_WITH_VALUES = {"o", "O"}
34
+
35
+
36
+ def _basename(token: str) -> str:
37
+ return token.rsplit("/", 1)[-1]
38
+
39
+
40
+ def _finding(category: str, level: str, detail: str) -> Dict[str, str]:
41
+ return {"category": category, "level": level, "detail": detail}
42
+
43
+
44
+ def _extract_shell_payload(argv: List[str]) -> Optional[str]:
45
+ index = 1
46
+ while index < len(argv):
47
+ token = argv[index]
48
+ if token == "--":
49
+ return None
50
+ if token == "-c":
51
+ payload_index = index + 1
52
+ return argv[payload_index] if payload_index < len(argv) else None
53
+ if token in _SHELL_LONG_OPTIONS_WITH_VALUES:
54
+ index += 2
55
+ continue
56
+ if any(
57
+ token.startswith(f"{option}=")
58
+ for option in _SHELL_LONG_OPTIONS_WITH_VALUES
59
+ ):
60
+ index += 1
61
+ continue
62
+ if (
63
+ (token.startswith("-") and not token.startswith("--"))
64
+ or token.startswith("+")
65
+ ):
66
+ if token.startswith("-") and "c" in token[1:]:
67
+ payload_index = index + 1
68
+ return argv[payload_index] if payload_index < len(argv) else None
69
+ if any(option in token[1:] for option in _SHELL_SHORT_OPTIONS_WITH_VALUES):
70
+ index += 2
71
+ else:
72
+ index += 1
73
+ continue
74
+ if token.startswith("--"):
75
+ index += 1
76
+ continue
77
+ return None
78
+ return None
79
+
80
+
81
+ def _max_level(findings: List[Dict[str, str]]) -> str:
82
+ level = "low"
83
+ for finding in findings:
84
+ if _LEVEL_RANK[finding["level"]] > _LEVEL_RANK[level]:
85
+ level = finding["level"]
86
+ return level
87
+
88
+
89
+ def normalize_command(command: List[str]) -> Dict[str, Any]:
90
+ """Peel known wrappers, identify the real program and any shell payload."""
91
+ tokens = [str(item) for item in command]
92
+ privilege: List[str] = []
93
+ index = 0
94
+ while index < len(tokens):
95
+ base = _basename(tokens[index])
96
+ if base in _PRIVILEGE_PROGRAMS:
97
+ privilege.append(base)
98
+ index += 1
99
+ continue
100
+ if base in _WRAPPER_PROGRAMS:
101
+ index += 1
102
+ # env may carry NAME=VALUE assignments and short flags
103
+ while index < len(tokens) and (
104
+ "=" in tokens[index] or tokens[index].startswith("-")
105
+ ):
106
+ index += 1
107
+ continue
108
+ break
109
+ rest = tokens[index:]
110
+ program = _basename(rest[0]) if rest else ""
111
+ shell_payload: Optional[str] = None
112
+ if program in _SHELL_PROGRAMS:
113
+ shell_payload = _extract_shell_payload(rest)
114
+ return {
115
+ "program": program,
116
+ "argv": rest,
117
+ "shell_payload": shell_payload,
118
+ "privilege_wrappers": privilege,
119
+ }
120
+
121
+
122
+ def _safe_split(text: str) -> List[str]:
123
+ try:
124
+ return shlex.split(text)
125
+ except ValueError:
126
+ return text.split()
127
+
128
+
129
+ def _scan_program(program: str, argv: List[str]) -> List[Dict[str, str]]:
130
+ findings: List[Dict[str, str]] = []
131
+ flags = _option_flags(argv)
132
+ if program == "rm":
133
+ recursive = any(_rm_flag_has(flag, {"r", "R"}, {"--recursive"}) for flag in flags)
134
+ force = any(_rm_flag_has(flag, {"f"}, {"--force"}) for flag in flags)
135
+ if recursive and force:
136
+ findings.append(_finding("destructive_delete", "high", "rm recursive force"))
137
+ if program == "find" and "-delete" in argv:
138
+ findings.append(_finding("destructive_delete", "high", "find -delete"))
139
+ if program == "chmod":
140
+ if any(_is_broad_mode(token) for token in argv[1:]):
141
+ findings.append(_finding("permission_change", "high", "chmod broad mode"))
142
+ if program == "chown":
143
+ if any(flag in ("-R", "--recursive") for flag in flags):
144
+ findings.append(_finding("permission_change", "high", "chown -R"))
145
+ if program in _PRIVILEGE_PROGRAMS:
146
+ findings.append(_finding("privilege_escalation", "high", program))
147
+ return findings
148
+
149
+
150
+ def _option_flags(argv: List[str]) -> List[str]:
151
+ flags: List[str] = []
152
+ for token in argv[1:]:
153
+ if token == "--":
154
+ break
155
+ if token.startswith("-") and token != "-":
156
+ flags.append(token)
157
+ return flags
158
+
159
+
160
+ def _rm_flag_has(flag: str, short_names: set[str], long_names: set[str]) -> bool:
161
+ if flag in long_names:
162
+ return True
163
+ if flag.startswith("--"):
164
+ return False
165
+ if flag.startswith("-") and flag != "-":
166
+ return any(name in flag[1:] for name in short_names)
167
+ return False
168
+
169
+
170
+ def _is_broad_mode(token: str) -> bool:
171
+ candidate = token.lower()
172
+ if candidate in {"777", "0777", "a+rwx", "a=rwx", "o+rwx", "o+w", "+w"}:
173
+ return True
174
+ return candidate.endswith("777")
175
+
176
+
177
+ def _looks_like_credential(token: str) -> bool:
178
+ base = _basename(token)
179
+ if base in _CREDENTIAL_BASENAMES:
180
+ return True
181
+ if base.startswith("id_") and "/.ssh/" in "/" + token:
182
+ return True
183
+ if any(fnmatch(base, pattern) for pattern in _CREDENTIAL_GLOBS):
184
+ return True
185
+ if "/.aws/credentials" in "/" + token:
186
+ return True
187
+ return False
188
+
189
+
190
+ def _path_tokens(argv: List[str]) -> List[str]:
191
+ return [token for token in argv[1:] if not token.startswith("-")]
192
+
193
+
194
+ def _normalize_repo_path(token: str, root: Optional[Path]) -> str:
195
+ cleaned = token
196
+ if cleaned.startswith("./"):
197
+ cleaned = cleaned[2:]
198
+ if root is not None and cleaned.startswith("/"):
199
+ root_text = root.as_posix().rstrip("/")
200
+ if cleaned == root_text:
201
+ return "."
202
+ if cleaned.startswith(f"{root_text}/"):
203
+ cleaned = cleaned[len(root_text) + 1 :]
204
+ return posixpath.normpath(cleaned)
205
+
206
+
207
+ def _scan_credentials(tokens: List[str]) -> List[Dict[str, str]]:
208
+ findings: List[Dict[str, str]] = []
209
+ for token in tokens:
210
+ if _looks_like_credential(token):
211
+ findings.append(_finding("credential_read", "high", _basename(token)))
212
+ return findings
213
+
214
+
215
+ def _scan_payload(payload: str) -> List[Dict[str, str]]:
216
+ findings: List[Dict[str, str]] = []
217
+ if re.search(r"\b(curl|wget)\b", payload) and re.search(
218
+ r"\|\s*(sudo\s+)?(sh|bash|zsh|dash|ksh)\b", payload
219
+ ):
220
+ findings.append(_finding("pipe_to_shell", "high", "pipe to shell"))
221
+ for segment in _SEGMENT_SPLIT.split(payload):
222
+ argv = _safe_split(segment)
223
+ if not argv:
224
+ continue
225
+ program = _basename(argv[0])
226
+ findings.extend(_scan_program(program, argv))
227
+ findings.extend(_scan_credentials(_path_tokens(argv)))
228
+ return findings
229
+
230
+
231
+ def _redirect_targets(payload: str) -> List[str]:
232
+ return re.findall(r">>?\s*([^\s;|&>]+)", payload)
233
+
234
+
235
+ def _ambiguous_write_targets(argv: List[str]) -> List[str]:
236
+ program = _basename(argv[0]) if argv else ""
237
+ operands = [token for token in argv[1:] if not token.startswith("-")]
238
+ if program in {"cp", "mv", "install"} and len(operands) >= 2:
239
+ return [operands[-1]]
240
+ if program == "tee" and operands:
241
+ return operands
242
+ return []
243
+
244
+
245
+ def _scan_plan_relative(
246
+ model: Dict[str, Any],
247
+ plan: Dict[str, Any],
248
+ step_id: str,
249
+ root: Optional[Path],
250
+ ) -> List[Dict[str, str]]:
251
+ findings: List[Dict[str, str]] = []
252
+ effective_allowed, blocked = effective_scope(plan, step_id)
253
+
254
+ payload = model["shell_payload"]
255
+ payload_argv: List[str] = _safe_split(payload) if payload else []
256
+ all_tokens = _path_tokens(model["argv"]) + _path_tokens(payload_argv)
257
+
258
+ for token in all_tokens:
259
+ norm = _normalize_repo_path(token, root)
260
+ if blocked and matches_path(norm, blocked):
261
+ findings.append(_finding("blocked_path", "high", norm))
262
+
263
+ explicit_writes = _redirect_targets(payload) if payload else []
264
+ for token in explicit_writes:
265
+ norm = _normalize_repo_path(token, root)
266
+ if not matches_path(norm, effective_allowed):
267
+ findings.append(_finding("write_outside_scope", "high", norm))
268
+
269
+ ambiguous = _ambiguous_write_targets(model["argv"])
270
+ if payload_argv:
271
+ ambiguous += _ambiguous_write_targets(payload_argv)
272
+ for token in ambiguous:
273
+ norm = _normalize_repo_path(token, root)
274
+ if not matches_path(norm, effective_allowed):
275
+ findings.append(_finding("write_outside_scope", "medium", norm))
276
+ return findings
277
+
278
+
279
+ def _dedupe_sorted(findings: List[Dict[str, str]]) -> List[Dict[str, str]]:
280
+ seen = set()
281
+ unique: List[Dict[str, str]] = []
282
+ for finding in findings:
283
+ key = (finding["category"], finding["level"], finding["detail"])
284
+ if key in seen:
285
+ continue
286
+ seen.add(key)
287
+ unique.append(finding)
288
+ unique.sort(key=lambda f: (f["category"], f["level"], f["detail"]))
289
+ return unique
290
+
291
+
292
+ def evaluate_policy(level: str, policy: Optional[str], confirmed: bool) -> str:
293
+ """Return "allow" or "block".
294
+
295
+ Missing policy (None) maps to "warn" for legacy contracts. A present but
296
+ unknown value raises ValueError so enforcement fails closed instead of
297
+ silently degrading to "warn".
298
+ """
299
+ effective = "warn" if policy is None else policy
300
+ if effective not in RISK_POLICIES:
301
+ raise ValueError(f"unknown risk_policy {policy!r}")
302
+ if effective == "warn" or level != "high":
303
+ return "allow"
304
+ if effective == "block":
305
+ return "block"
306
+ return "allow" if confirmed else "block"
307
+
308
+
309
+ def classify_command(
310
+ command: List[str],
311
+ plan: Dict[str, Any],
312
+ step_id: str,
313
+ root: Optional[Path] = None,
314
+ ) -> Dict[str, Any]:
315
+ """Return {"level": "low|medium|high", "findings": [...]}."""
316
+ if not command:
317
+ return {"level": "low", "findings": []}
318
+ model = normalize_command(command)
319
+ findings: List[Dict[str, str]] = []
320
+ for wrapper in model["privilege_wrappers"]:
321
+ findings.append(_finding("privilege_escalation", "high", wrapper))
322
+ findings.extend(_scan_program(model["program"], model["argv"]))
323
+ findings.extend(_scan_credentials(_path_tokens(model["argv"])))
324
+ if model["shell_payload"] is not None:
325
+ findings.extend(_scan_payload(model["shell_payload"]))
326
+ findings.extend(_scan_plan_relative(model, plan, step_id, root))
327
+ findings = _dedupe_sorted(findings)
328
+ return {"level": _max_level(findings), "findings": findings}