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.
- agent_guard/__init__.py +60 -0
- agent_guard/audit.py +179 -0
- agent_guard/bundled.py +124 -0
- agent_guard/cli.py +686 -0
- agent_guard/decision.py +40 -0
- agent_guard/guard.py +187 -0
- agent_guard/judge.py +98 -0
- agent_guard/mcp.py +93 -0
- agent_guard/policy.py +295 -0
- agent_guard/py.typed +0 -0
- agent_guard/registry.py +78 -0
- agent_guard/tiers.py +19 -0
- agentguard_identity/__init__.py +39 -0
- agentguard_identity/attestation.py +54 -0
- agentguard_identity/broker.py +46 -0
- agentguard_identity/egress.py +47 -0
- agentguard_identity/pop.py +140 -0
- agentguard_identity/py.typed +0 -0
- agentguard_identity/remote.py +117 -0
- agentguard_identity/runtime.py +170 -0
- agentguard_identity/token.py +68 -0
- toolcall_authz-0.2.0.dist-info/METADATA +356 -0
- toolcall_authz-0.2.0.dist-info/RECORD +27 -0
- toolcall_authz-0.2.0.dist-info/WHEEL +5 -0
- toolcall_authz-0.2.0.dist-info/entry_points.txt +2 -0
- toolcall_authz-0.2.0.dist-info/licenses/LICENSE +202 -0
- toolcall_authz-0.2.0.dist-info/top_level.txt +2 -0
agent_guard/__init__.py
ADDED
|
@@ -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
|