toolcall-authz 0.2.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.
@@ -0,0 +1,60 @@
1
+ from .audit import (
2
+ AuditRecord,
3
+ AuditSink,
4
+ CallableAuditSink,
5
+ JsonlAuditSink,
6
+ MemoryAuditSink,
7
+ MultiAuditSink,
8
+ SigningAuditSink,
9
+ WebhookAuditSink,
10
+ sign_record,
11
+ verify_record,
12
+ )
13
+ from .bundled import bundled_module, bundled_names, with_bundled
14
+ from .decision import Decision, Verdict, clamp
15
+ from .guard import ApprovalRequest, BlockedError, Guard, guarded
16
+ from .judge import CallableJudge, Judge, JudgeRequest, LLMJudge, ReferenceJudge, build_prompt, parse_verdict
17
+ from .mcp import handle_line as mcp_handle_line
18
+ from .mcp import run_proxy as mcp_run_proxy
19
+ from .policy import Policy, Rule, load_policy
20
+ from .registry import CompiledPolicy, PolicyModule, PolicyRegistry
21
+ from .tiers import TRUST_TIERS
22
+
23
+ __all__ = [
24
+ "ApprovalRequest",
25
+ "AuditRecord",
26
+ "AuditSink",
27
+ "BlockedError",
28
+ "CallableAuditSink",
29
+ "CallableJudge",
30
+ "CompiledPolicy",
31
+ "Decision",
32
+ "Guard",
33
+ "Judge",
34
+ "JudgeRequest",
35
+ "JsonlAuditSink",
36
+ "LLMJudge",
37
+ "ReferenceJudge",
38
+ "build_prompt",
39
+ "parse_verdict",
40
+ "MemoryAuditSink",
41
+ "MultiAuditSink",
42
+ "Policy",
43
+ "WebhookAuditSink",
44
+ "PolicyModule",
45
+ "PolicyRegistry",
46
+ "Rule",
47
+ "SigningAuditSink",
48
+ "TRUST_TIERS",
49
+ "Verdict",
50
+ "bundled_module",
51
+ "bundled_names",
52
+ "clamp",
53
+ "guarded",
54
+ "load_policy",
55
+ "mcp_handle_line",
56
+ "mcp_run_proxy",
57
+ "sign_record",
58
+ "verify_record",
59
+ "with_bundled",
60
+ ]
agent_guard/audit.py ADDED
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import urllib.error
9
+ import urllib.request
10
+ from collections.abc import Callable
11
+ from dataclasses import asdict, dataclass, replace
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Protocol
15
+
16
+ from .decision import Verdict
17
+
18
+ Poster = Callable[[str, bytes, dict, float], None]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AuditRecord:
23
+ ts: str
24
+ agent_id: str
25
+ tool: str
26
+ args: dict[str, Any]
27
+ decision: str
28
+ reason: str
29
+ rule_id: str | None
30
+ executed: bool
31
+ sig: str | None = None
32
+
33
+
34
+ class AuditSink(Protocol):
35
+ def write(self, record: AuditRecord) -> None: ...
36
+
37
+
38
+ class JsonlAuditSink:
39
+ def __init__(self, path: str | Path) -> None:
40
+ self._path = Path(path)
41
+ self._path.parent.mkdir(parents=True, exist_ok=True)
42
+
43
+ def write(self, record: AuditRecord) -> None:
44
+ with self._path.open("a", encoding="utf-8") as handle:
45
+ handle.write(json.dumps(asdict(record)) + "\n")
46
+
47
+
48
+ class MemoryAuditSink:
49
+ def __init__(self) -> None:
50
+ self.records: list[AuditRecord] = []
51
+
52
+ def write(self, record: AuditRecord) -> None:
53
+ self.records.append(record)
54
+
55
+
56
+ class WebhookAuditSink:
57
+ """Ships each audit record to a SIEM / webhook (Splunk HEC, generic collector).
58
+ Audit is load-bearing: a failed delivery raises — it never silently drops a record.
59
+ Wrap in your own best-effort layer if you accept lossy audit. `poster` is injectable
60
+ for tests so the default suite needs no network."""
61
+
62
+ def __init__(
63
+ self,
64
+ url: str,
65
+ headers: dict[str, str] | None = None,
66
+ timeout: float = 5.0,
67
+ poster: Poster | None = None,
68
+ ) -> None:
69
+ self._url = url
70
+ self._headers = {"Content-Type": "application/json", **(headers or {})}
71
+ self._timeout = timeout
72
+ self._post = poster or _urllib_post
73
+
74
+ def write(self, record: AuditRecord) -> None:
75
+ body = json.dumps(asdict(record)).encode("utf-8")
76
+ self._post(self._url, body, self._headers, self._timeout)
77
+
78
+
79
+ def _urllib_post(url: str, body: bytes, headers: dict, timeout: float) -> None:
80
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
81
+ try:
82
+ with urllib.request.urlopen(request, timeout=timeout) as response:
83
+ if response.status // 100 != 2:
84
+ raise RuntimeError(f"audit webhook returned HTTP {response.status}")
85
+ except urllib.error.URLError as err:
86
+ raise RuntimeError(f"audit webhook POST to {url} failed: {err}") from err
87
+
88
+
89
+ class CallableAuditSink:
90
+ """Wraps any `emit(record)` callable — the escape hatch for OpenTelemetry, statsd,
91
+ a message queue, or a custom pipeline, without agent-guard depending on any of them.
92
+
93
+ sink = CallableAuditSink(lambda r: otel_logger.emit(body=asdict(r)))
94
+ """
95
+
96
+ def __init__(self, emit: Callable[[AuditRecord], None]) -> None:
97
+ self._emit = emit
98
+
99
+ def write(self, record: AuditRecord) -> None:
100
+ self._emit(record)
101
+
102
+
103
+ def _signable_body(record: AuditRecord) -> bytes:
104
+ payload = asdict(replace(record, sig=None))
105
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
106
+
107
+
108
+ def sign_record(record: AuditRecord, secret: bytes) -> str:
109
+ mac = hmac.new(secret, _signable_body(record), hashlib.sha256).digest()
110
+ return base64.urlsafe_b64encode(mac).decode()
111
+
112
+
113
+ def verify_record(record: AuditRecord, secret: bytes) -> bool:
114
+ if record.sig is None:
115
+ return False
116
+ try:
117
+ provided = base64.urlsafe_b64decode(record.sig)
118
+ except (binascii.Error, ValueError):
119
+ return False
120
+ expected = hmac.new(secret, _signable_body(record), hashlib.sha256).digest()
121
+ return hmac.compare_digest(expected, provided)
122
+
123
+
124
+ class SigningAuditSink:
125
+ """Wraps another sink and attaches an HMAC over each record before writing it.
126
+
127
+ Defends against tampering AFTER a record leaves this process: a party that does
128
+ NOT hold `secret` (a downstream store, a network hop, a differently-privileged
129
+ service) cannot forge or edit a record without `verify_record` catching it.
130
+
131
+ Does NOT defend against a compromised producer — this process holds `secret` to
132
+ sign in the first place, so a compromised producer signs a forged record just as
133
+ validly as a real one. It also does not detect suppression: a producer that simply
134
+ never calls `write()` for an action leaves no gap here. Defending either of those
135
+ needs a different mechanism (e.g. a verify-only secret the producer can't reach,
136
+ plus sequence numbers or a hash chain across records) — not implemented here.
137
+
138
+ Verify with `verify_record` against the same secret at the consuming end."""
139
+
140
+ def __init__(self, inner: AuditSink, secret: bytes) -> None:
141
+ if not secret:
142
+ raise ValueError("SigningAuditSink requires a non-empty secret")
143
+ self._inner = inner
144
+ self._secret = secret
145
+
146
+ def write(self, record: AuditRecord) -> None:
147
+ self._inner.write(replace(record, sig=sign_record(record, self._secret)))
148
+
149
+
150
+ class MultiAuditSink:
151
+ """Fan-out to several sinks (e.g. local JSONL + remote SIEM). Attempts every sink
152
+ even if one fails, so durable local audit survives a flaky remote, then raises an
153
+ aggregate if any sink failed — never a silent drop."""
154
+
155
+ def __init__(self, *sinks: AuditSink) -> None:
156
+ self._sinks = sinks
157
+
158
+ def write(self, record: AuditRecord) -> None:
159
+ errors = []
160
+ for sink in self._sinks:
161
+ try:
162
+ sink.write(record)
163
+ except Exception as err: # noqa: BLE001 - fan-out must attempt every sink before failing
164
+ errors.append(err)
165
+ if errors:
166
+ raise RuntimeError(f"{len(errors)} of {len(self._sinks)} audit sink(s) failed: {errors}")
167
+
168
+
169
+ def build_record(agent_id: str, tool: str, args: dict[str, Any], verdict: Verdict, executed: bool) -> AuditRecord:
170
+ return AuditRecord(
171
+ ts=datetime.now(timezone.utc).isoformat(),
172
+ agent_id=agent_id,
173
+ tool=tool,
174
+ args=args,
175
+ decision=verdict.decision.value,
176
+ reason=verdict.reason,
177
+ rule_id=verdict.rule_id,
178
+ executed=executed,
179
+ )
agent_guard/bundled.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from .decision import Decision
4
+ from .registry import PolicyModule, PolicyRegistry
5
+
6
+ BUNDLED_LAYER = 0
7
+
8
+ _MODULES: dict[str, dict] = {
9
+ "shell": {
10
+ "namespace": ["shell*", "exec*", "bash*"],
11
+ "rules": [
12
+ {
13
+ "id": "shell-rm-rf",
14
+ "decision": "deny",
15
+ "tools": ["shell*", "exec*", "bash*"],
16
+ "arg_patterns": [r"\brm\s+-rf\b", r"\brm\s+-fr\b"],
17
+ "reason": "recursive force delete",
18
+ },
19
+ {
20
+ "id": "shell-disk-wipe",
21
+ "decision": "deny",
22
+ "tools": ["shell*", "exec*", "bash*"],
23
+ "arg_patterns": [r"\bmkfs\b", r"\bdd\s+.*of=/dev/"],
24
+ "reason": "disk-destroying command",
25
+ },
26
+ {
27
+ "id": "shell-curl-pipe-sh",
28
+ "decision": "require_human",
29
+ "tools": ["shell*", "exec*", "bash*"],
30
+ "arg_patterns": [r"curl\b.*\|\s*(sh|bash)", r"wget\b.*\|\s*(sh|bash)"],
31
+ "reason": "piping remote script to a shell",
32
+ },
33
+ {
34
+ "id": "shell-chmod-777",
35
+ "decision": "require_human",
36
+ "tools": ["shell*", "exec*", "bash*"],
37
+ "arg_patterns": [r"chmod\s+-R\s+777"],
38
+ "reason": "world-writable recursive chmod",
39
+ },
40
+ ],
41
+ },
42
+ "git": {
43
+ "namespace": ["git*", "shell*", "exec*"],
44
+ "rules": [
45
+ {
46
+ "id": "git-force-push",
47
+ "decision": "require_human",
48
+ "tools": ["git*", "shell*", "exec*"],
49
+ "arg_patterns": [r"git\s+push\b.*--force", r"git\s+push\b.*-f\b"],
50
+ "reason": "force-push rewrites shared history",
51
+ },
52
+ {
53
+ "id": "git-hard-reset",
54
+ "decision": "require_human",
55
+ "tools": ["git*", "shell*", "exec*"],
56
+ "arg_patterns": [r"git\s+reset\b.*--hard"],
57
+ "reason": "hard reset discards work",
58
+ },
59
+ ],
60
+ },
61
+ "postgres": {
62
+ "namespace": ["postgres*", "sql*", "db_*"],
63
+ "rules": [
64
+ {
65
+ "id": "pg-drop-truncate",
66
+ "decision": "deny",
67
+ "tools": ["postgres*", "sql*", "db_*"],
68
+ "arg_patterns": [r"(?i)\bdrop\s+table\b", r"(?i)\btruncate\b"],
69
+ "reason": "destructive schema change",
70
+ },
71
+ {
72
+ "id": "pg-unscoped-delete",
73
+ "decision": "require_human",
74
+ "tools": ["postgres*", "sql*", "db_*"],
75
+ "arg_patterns": [r"(?i)delete\s+from\s+\w+\s*;?\s*$"],
76
+ "reason": "DELETE without a WHERE clause",
77
+ },
78
+ ],
79
+ },
80
+ "filesystem": {
81
+ "namespace": ["fs*", "write_file*"],
82
+ "rules": [
83
+ {
84
+ "id": "fs-write-secrets",
85
+ "decision": "require_human",
86
+ "tools": ["fs_write*", "write_file*"],
87
+ "arg_patterns": [r"(?i)\.env\b", r"(?i)id_rsa", r"(?i)/etc/"],
88
+ "reason": "writing to a sensitive path",
89
+ },
90
+ ],
91
+ },
92
+ "kubernetes": {
93
+ "namespace": ["k8s*", "kubectl*", "shell*"],
94
+ "rules": [
95
+ {
96
+ "id": "k8s-delete",
97
+ "decision": "require_human",
98
+ "tools": ["k8s*", "kubectl*", "shell*"],
99
+ "arg_patterns": [r"kubectl\s+delete\b"],
100
+ "reason": "deleting cluster resources",
101
+ },
102
+ ],
103
+ },
104
+ }
105
+
106
+
107
+ def bundled_module(name: str) -> PolicyModule:
108
+ if name not in _MODULES:
109
+ raise ValueError(f"unknown bundled module '{name}'; have {sorted(_MODULES)}")
110
+ spec = _MODULES[name]
111
+ return PolicyModule.from_dict(
112
+ {"name": f"bundled:{name}", "namespace": spec["namespace"], "layer": BUNDLED_LAYER, "rules": spec["rules"]}
113
+ )
114
+
115
+
116
+ def bundled_names() -> list[str]:
117
+ return sorted(_MODULES)
118
+
119
+
120
+ def with_bundled(default: Decision, names: list[str] | None = None) -> PolicyRegistry:
121
+ registry = PolicyRegistry(default=default)
122
+ for name in names or bundled_names():
123
+ registry.register(bundled_module(name))
124
+ return registry