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 ADDED
@@ -0,0 +1,2 @@
1
+ """agentsec-pack: adversarial tests for AI agent deployments."""
2
+ __version__ = "0.0.1"
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)