agentsec-pack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentsec/__init__.py +2 -0
- agentsec/assertions.py +99 -0
- agentsec/baseline.py +119 -0
- agentsec/cli.py +454 -0
- agentsec/data/measured_profiles.json +156 -0
- agentsec/data/owasp_asi_2026.json +15 -0
- agentsec/data/presets.json +120 -0
- agentsec/data/promptfoo_plugin_asi.json +44 -0
- agentsec/integrations/__init__.py +0 -0
- agentsec/integrations/promptfoo_assert.py +77 -0
- agentsec/integrations/promptfoo_combine.py +189 -0
- agentsec/presets.py +46 -0
- agentsec/probe/__init__.py +0 -0
- agentsec/probe/blast_probe.py +852 -0
- agentsec/redact.py +49 -0
- agentsec/report.py +103 -0
- agentsec/runner.py +216 -0
- agentsec/scoring.py +400 -0
- agentsec/ui/__init__.py +51 -0
- agentsec/ui/server.py +302 -0
- agentsec/ui/template.html +551 -0
- agentsec_pack-0.1.0.dist-info/METADATA +321 -0
- agentsec_pack-0.1.0.dist-info/RECORD +27 -0
- agentsec_pack-0.1.0.dist-info/WHEEL +5 -0
- agentsec_pack-0.1.0.dist-info/entry_points.txt +2 -0
- agentsec_pack-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentsec_pack-0.1.0.dist-info/top_level.txt +1 -0
agentsec/__init__.py
ADDED
agentsec/assertions.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Ask a narrow question instead of producing a finding list.
|
|
2
|
+
|
|
3
|
+
`blast-radius` answers "what can this reach", against a fixed list of things
|
|
4
|
+
worth worrying about generally. Some questions are narrower and recur: can this
|
|
5
|
+
specific child read this specific file. A grading harness wants to know whether
|
|
6
|
+
the process it is about to run can read the answer key; a build wants to know
|
|
7
|
+
whether a step can write outside its workspace.
|
|
8
|
+
|
|
9
|
+
That question does not need a score, it needs a pass or a fail per path, and it
|
|
10
|
+
needs to be asked from inside the sandbox rather than from the launcher, for the
|
|
11
|
+
same reason everything else here is.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
KINDS = ("readable", "not-readable", "writable", "not-writable", "exists", "not-exists")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_script(expectations: list[tuple[str, str]]) -> str:
|
|
23
|
+
"""A stdlib-only script that tests the paths and prints one JSON line.
|
|
24
|
+
|
|
25
|
+
Written as source rather than imported so it can run inside a container that
|
|
26
|
+
has nothing but a python3 binary, exactly like the probe.
|
|
27
|
+
"""
|
|
28
|
+
return (
|
|
29
|
+
"import json, os\n"
|
|
30
|
+
"E = " + repr(expectations) + "\n"
|
|
31
|
+
"out = []\n"
|
|
32
|
+
"for kind, path in E:\n"
|
|
33
|
+
" r = {'kind': kind, 'path': path}\n"
|
|
34
|
+
" try:\n"
|
|
35
|
+
" r['exists'] = os.path.exists(path)\n"
|
|
36
|
+
" r['readable'] = os.access(path, os.R_OK)\n"
|
|
37
|
+
" if os.path.isdir(path):\n"
|
|
38
|
+
" r['writable'] = os.access(path, os.W_OK | os.X_OK)\n"
|
|
39
|
+
" else:\n"
|
|
40
|
+
" parent = os.path.dirname(path) or '.'\n"
|
|
41
|
+
" r['writable'] = os.access(path, os.W_OK) if os.path.exists(path) \\\n"
|
|
42
|
+
" else os.access(parent, os.W_OK)\n"
|
|
43
|
+
" except Exception as exc:\n"
|
|
44
|
+
" r['error'] = type(exc).__name__\n"
|
|
45
|
+
" out.append(r)\n"
|
|
46
|
+
"print(json.dumps({'assertions': out}))\n"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def evaluate(kind: str, observed: dict[str, Any]) -> tuple[bool, str]:
|
|
51
|
+
"""Did this expectation hold? Returns (ok, what was actually seen)."""
|
|
52
|
+
if "error" in observed:
|
|
53
|
+
return False, f"could not be tested ({observed['error']})"
|
|
54
|
+
exists = observed.get("exists", False)
|
|
55
|
+
readable = observed.get("readable", False)
|
|
56
|
+
writable = observed.get("writable", False)
|
|
57
|
+
if kind == "exists":
|
|
58
|
+
return exists, "present" if exists else "absent"
|
|
59
|
+
if kind == "not-exists":
|
|
60
|
+
return not exists, "present" if exists else "absent"
|
|
61
|
+
if kind == "readable":
|
|
62
|
+
return readable, "readable" if readable else ("absent" if not exists else "not readable")
|
|
63
|
+
if kind == "not-readable":
|
|
64
|
+
# A file that is not there is not reachable. Say which, because "absent"
|
|
65
|
+
# and "present but denied" are different sandbox designs.
|
|
66
|
+
return not readable, "READABLE" if readable else ("absent" if not exists else "present, denied")
|
|
67
|
+
if kind == "writable":
|
|
68
|
+
return writable, "writable" if writable else "not writable"
|
|
69
|
+
if kind == "not-writable":
|
|
70
|
+
return not writable, "WRITABLE" if writable else "not writable"
|
|
71
|
+
raise ValueError(f"unknown expectation: {kind}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check(results: list[dict[str, Any]], expectations: list[tuple[str, str]]) -> dict[str, Any]:
|
|
75
|
+
by_key = {(r["kind"], r["path"]): r for r in results}
|
|
76
|
+
rows, failed = [], 0
|
|
77
|
+
for kind, path in expectations:
|
|
78
|
+
observed = by_key.get((kind, path))
|
|
79
|
+
if observed is None:
|
|
80
|
+
rows.append({"kind": kind, "path": path, "ok": False, "observed": "not reported by the probe"})
|
|
81
|
+
failed += 1
|
|
82
|
+
continue
|
|
83
|
+
ok, observed_word = evaluate(kind, observed)
|
|
84
|
+
rows.append({"kind": kind, "path": path, "ok": ok, "observed": observed_word})
|
|
85
|
+
if not ok:
|
|
86
|
+
failed += 1
|
|
87
|
+
return {"rows": rows, "failed": failed, "total": len(expectations)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render(outcome: dict[str, Any]) -> str:
|
|
91
|
+
width = max((len(r["kind"]) for r in outcome["rows"]), default=10)
|
|
92
|
+
lines = []
|
|
93
|
+
for r in outcome["rows"]:
|
|
94
|
+
mark = "ok " if r["ok"] else "FAIL"
|
|
95
|
+
lines.append(f"{mark} {r['kind']:<{width}} {r['path']} -> {r['observed']}")
|
|
96
|
+
n, total = outcome["failed"], outcome["total"]
|
|
97
|
+
lines.append("")
|
|
98
|
+
lines.append(f"{total - n}/{total} expectations held" if n else f"all {total} expectations held")
|
|
99
|
+
return "\n".join(lines)
|
agentsec/baseline.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Compare a measurement with a saved baseline and say what got worse.
|
|
2
|
+
|
|
3
|
+
`--max-score` fails a build when the blast radius crosses a number. That
|
|
4
|
+
misses drift under the number: a sandbox at 41 can lose its seccomp filter and
|
|
5
|
+
gain a readable credential and still be under a budget of 45. A baseline gate
|
|
6
|
+
fails on any change for the worse, whatever the total:
|
|
7
|
+
|
|
8
|
+
* a finding that is new;
|
|
9
|
+
* a finding whose severity rose;
|
|
10
|
+
* a finding whose evidence widened: one more readable credential, one more
|
|
11
|
+
reachable host, one more writable path.
|
|
12
|
+
|
|
13
|
+
A finding that disappeared is reported as fixed, never as a failure, with a
|
|
14
|
+
reminder to re-baseline so the gate keeps the ground that was gained.
|
|
15
|
+
|
|
16
|
+
Changing the instrument is not the sandbox changing. When the two runs used
|
|
17
|
+
different probe versions, a new finding may be a new check rather than a looser
|
|
18
|
+
sandbox; the gate still fails, because it cannot tell which, and says so.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .scoring import SEVERITY_WEIGHT
|
|
27
|
+
|
|
28
|
+
# Evidence keys whose lists are things the sandbox let the probe reach. Only
|
|
29
|
+
# these can widen a finding; bookkeeping lists (which home resolved a path, the
|
|
30
|
+
# error a refusal returned) changing is not the sandbox getting looser.
|
|
31
|
+
REACH_KEYS = {"reached", "readable_of_those_probed", "agent_credentials", "names", "connected",
|
|
32
|
+
"paths", "writable", "mounts", "capabilities", "proxy_accepted_connect_to"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _key(v: Any) -> str:
|
|
36
|
+
return v if isinstance(v, str) else json.dumps(v, sort_keys=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _widened(old: dict, new: dict) -> dict[str, list]:
|
|
40
|
+
"""Reach-evidence entries present now beyond what the baseline had, per key.
|
|
41
|
+
|
|
42
|
+
Counted, not set-compared: a redacted report turns every variable name into
|
|
43
|
+
the same placeholder, and a third '<redacted>' is still a third secret.
|
|
44
|
+
"""
|
|
45
|
+
out = {}
|
|
46
|
+
for k, v in (new or {}).items():
|
|
47
|
+
if k not in REACH_KEYS or not isinstance(v, list):
|
|
48
|
+
continue
|
|
49
|
+
before = Counter(_key(x) for x in (old or {}).get(k) or [])
|
|
50
|
+
gained = []
|
|
51
|
+
for x in v:
|
|
52
|
+
kx = _key(x)
|
|
53
|
+
if before[kx] > 0:
|
|
54
|
+
before[kx] -= 1
|
|
55
|
+
else:
|
|
56
|
+
gained.append(x)
|
|
57
|
+
if gained:
|
|
58
|
+
out[k] = gained
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compare(baseline: dict, current: dict) -> dict[str, Any]:
|
|
63
|
+
"""Both arguments are result envelopes (what `blast-radius` and `score` write)."""
|
|
64
|
+
if "summary" not in baseline:
|
|
65
|
+
raise ValueError("the baseline is not a single-run report (no summary): pass a report JSON, not a matrix")
|
|
66
|
+
old = {f["id"]: f for f in baseline["summary"]["findings"]}
|
|
67
|
+
new = {f["id"]: f for f in current["summary"]["findings"]}
|
|
68
|
+
regressions, fixed = [], []
|
|
69
|
+
for fid, f in new.items():
|
|
70
|
+
if fid not in old:
|
|
71
|
+
regressions.append({"id": fid, "kind": "new", "severity": f["severity"], "title": f["title"]})
|
|
72
|
+
continue
|
|
73
|
+
was = old[fid]
|
|
74
|
+
if SEVERITY_WEIGHT[f["severity"]] > SEVERITY_WEIGHT[was["severity"]]:
|
|
75
|
+
regressions.append({"id": fid, "kind": "severity", "title": f["title"],
|
|
76
|
+
"was": was["severity"], "now": f["severity"]})
|
|
77
|
+
gained = _widened(was.get("evidence"), f.get("evidence"))
|
|
78
|
+
if gained:
|
|
79
|
+
regressions.append({"id": fid, "kind": "widened", "title": f["title"], "gained": gained})
|
|
80
|
+
for fid, f in old.items():
|
|
81
|
+
if fid not in new:
|
|
82
|
+
fixed.append({"id": fid, "severity": f["severity"], "title": f["title"]})
|
|
83
|
+
redacted = [((e.get("probe") or {}).get("identity") or {}).get("hostname") == "<redacted>" for e in (baseline, current)]
|
|
84
|
+
pv_old = (baseline.get("probe") or {}).get("probe_version")
|
|
85
|
+
pv_new = (current.get("probe") or {}).get("probe_version")
|
|
86
|
+
return {"regressions": regressions, "fixed": fixed,
|
|
87
|
+
"score": {"baseline": baseline["summary"]["score"], "current": current["summary"]["score"]},
|
|
88
|
+
"probe_versions": {"baseline": pv_old, "current": pv_new},
|
|
89
|
+
"instrument_changed": pv_old != pv_new,
|
|
90
|
+
"redaction_differs": redacted[0] != redacted[1]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render(result: dict[str, Any], baseline_label: str = "baseline") -> str:
|
|
94
|
+
lines = []
|
|
95
|
+
s = result["score"]
|
|
96
|
+
lines.append(f"against {baseline_label}: score {s['baseline']} -> {s['current']}")
|
|
97
|
+
for r in result["regressions"]:
|
|
98
|
+
if r["kind"] == "new":
|
|
99
|
+
lines.append(f" REGRESSION new finding {r['id']} [{r['severity']}] {r['title']}")
|
|
100
|
+
elif r["kind"] == "severity":
|
|
101
|
+
lines.append(f" REGRESSION severity rose {r['id']} {r['was']} -> {r['now']}: {r['title']}")
|
|
102
|
+
else:
|
|
103
|
+
gained = "; ".join(f"{k} + {', '.join(_key(x) for x in v)}" for k, v in r["gained"].items())
|
|
104
|
+
lines.append(f" REGRESSION widened {r['id']} {r['title']}: {gained}")
|
|
105
|
+
for f in result["fixed"]:
|
|
106
|
+
lines.append(f" fixed {f['id']} [{f['severity']}] {f['title']}")
|
|
107
|
+
if result["fixed"] and not result["regressions"]:
|
|
108
|
+
lines.append(" Re-baseline with this report, so the gate holds the ground just gained.")
|
|
109
|
+
if result["instrument_changed"]:
|
|
110
|
+
pv = result["probe_versions"]
|
|
111
|
+
lines.append(f" note: baseline was taken with probe {pv['baseline']}, this run with {pv['current']}. "
|
|
112
|
+
"A new finding may be a new check rather than a looser sandbox; the gate cannot tell which, "
|
|
113
|
+
"so it still fails. Re-baseline deliberately once you have read the difference.")
|
|
114
|
+
if result.get("redaction_differs"):
|
|
115
|
+
lines.append(" note: one report is redacted and the other is not, so variable names cannot match. "
|
|
116
|
+
"Gate a run against a baseline taken with the same --redact setting.")
|
|
117
|
+
if not result["regressions"]:
|
|
118
|
+
lines.append("OK: nothing is worse than the baseline")
|
|
119
|
+
return "\n".join(lines)
|