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/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
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}
|