toolgovern-cli 0.1.1__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.
- toolgovern/__init__.py +222 -0
- toolgovern/approval/__init__.py +25 -0
- toolgovern/approval/pending_registry.py +379 -0
- toolgovern/classifier/__init__.py +19 -0
- toolgovern/classifier/credential_access.py +180 -0
- toolgovern/classifier/cross_agent_inheritance.py +216 -0
- toolgovern/classifier/filesystem_scope.py +202 -0
- toolgovern/classifier/index.py +80 -0
- toolgovern/classifier/information_flow.py +154 -0
- toolgovern/classifier/network_egress.py +289 -0
- toolgovern/classifier/shell_risk.py +357 -0
- toolgovern/classifier/util.py +259 -0
- toolgovern/cli.py +298 -0
- toolgovern/mcp_trust/__init__.py +342 -0
- toolgovern/middleware/__init__.py +30 -0
- toolgovern/middleware/idempotency_cache.py +117 -0
- toolgovern/middleware/on_tool_call.py +538 -0
- toolgovern/policy/__init__.py +10 -0
- toolgovern/policy/load_policy.py +41 -0
- toolgovern/policy/validate_policy.py +106 -0
- toolgovern/py.typed +0 -0
- toolgovern/scoping/__init__.py +13 -0
- toolgovern/scoping/inheritance_enforcer.py +145 -0
- toolgovern/scoping/scope_declaration.py +84 -0
- toolgovern/shared/__init__.py +0 -0
- toolgovern/shared/paths.py +266 -0
- toolgovern/trace/__init__.py +33 -0
- toolgovern/trace/canonical_json.py +27 -0
- toolgovern/trace/trace_reader.py +206 -0
- toolgovern/trace/trace_writer.py +247 -0
- toolgovern/types.py +247 -0
- toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
- toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
- toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
- toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
- toolgovern_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .canonical_json import canonical_json
|
|
2
|
+
from .trace_reader import (
|
|
3
|
+
ChainVerificationIssue,
|
|
4
|
+
ChainVerificationResult,
|
|
5
|
+
TraceQuery,
|
|
6
|
+
VerifyChainOptions,
|
|
7
|
+
filter_trace,
|
|
8
|
+
parse_since,
|
|
9
|
+
read_trace,
|
|
10
|
+
verify_chain,
|
|
11
|
+
)
|
|
12
|
+
from .trace_writer import (
|
|
13
|
+
TraceWriter,
|
|
14
|
+
TraceWriterOptions,
|
|
15
|
+
compute_entry_content_hash,
|
|
16
|
+
compute_entry_signature,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"canonical_json",
|
|
21
|
+
"TraceWriter",
|
|
22
|
+
"TraceWriterOptions",
|
|
23
|
+
"compute_entry_content_hash",
|
|
24
|
+
"compute_entry_signature",
|
|
25
|
+
"read_trace",
|
|
26
|
+
"filter_trace",
|
|
27
|
+
"parse_since",
|
|
28
|
+
"verify_chain",
|
|
29
|
+
"TraceQuery",
|
|
30
|
+
"ChainVerificationResult",
|
|
31
|
+
"ChainVerificationIssue",
|
|
32
|
+
"VerifyChainOptions",
|
|
33
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Deterministic JSON serialization: object keys are sorted recursively so the same logical
|
|
2
|
+
content always hashes to the same bytes, regardless of the insertion order the caller used.
|
|
3
|
+
|
|
4
|
+
Ported from ``packages/toolgovern/src/trace/canonical-json.ts``. This is what makes the trace's
|
|
5
|
+
sha256 content hash reproducible and verifiable later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sort_keys_deep(value: Any) -> Any:
|
|
15
|
+
if isinstance(value, list):
|
|
16
|
+
return [_sort_keys_deep(v) for v in value]
|
|
17
|
+
if isinstance(value, dict):
|
|
18
|
+
return {key: _sort_keys_deep(value[key]) for key in sorted(value.keys())}
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def canonical_json(value: Any) -> str:
|
|
23
|
+
"""Serializes ``value`` to JSON with all object keys sorted recursively (array order is
|
|
24
|
+
preserved). ``separators`` matches JavaScript's ``JSON.stringify`` compact-by-default
|
|
25
|
+
output (no extra whitespace), so the byte sequence signed by the TypeScript and Python
|
|
26
|
+
implementations is identical for the same logical content."""
|
|
27
|
+
return json.dumps(_sort_keys_deep(value), separators=(",", ":"), ensure_ascii=False)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Reads a JSON Lines trace file for local inspection, filtering, and chain verification.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/trace/trace-reader.ts``. Powers both
|
|
4
|
+
``toolgovern-cli audit`` and any programmatic post-session review.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from typing import Dict, List, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from ..types import Decision, ScopeDeclaration, TraceEntry
|
|
17
|
+
from .trace_writer import SecretKeyLike, compute_entry_signature
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _signatures_match(actual: str, expected: str) -> bool:
|
|
21
|
+
"""Constant-time comparison of two signature strings. ``hmac.compare_digest`` requires
|
|
22
|
+
equal-length inputs to stay constant-time; a length mismatch is checked first and
|
|
23
|
+
short-circuits to False -- this leaks nothing an attacker doesn't already know, since both
|
|
24
|
+
the scheme prefix and the hex-encoded hash length are fixed and public (``sha256:`` + 64
|
|
25
|
+
hex chars, ``hmac-sha256:`` + 64 hex chars), not a function of the secret being compared."""
|
|
26
|
+
actual_bytes = actual.encode("utf-8")
|
|
27
|
+
expected_bytes = expected.encode("utf-8")
|
|
28
|
+
if len(actual_bytes) != len(expected_bytes):
|
|
29
|
+
return False
|
|
30
|
+
return hmac.compare_digest(actual_bytes, expected_bytes)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TraceQuery:
|
|
35
|
+
since: Optional[str] = None
|
|
36
|
+
decision: Optional[Decision] = None
|
|
37
|
+
agent_id: Optional[str] = None
|
|
38
|
+
rule_id: Optional[str] = None
|
|
39
|
+
"""Matches entries where this rule ID appears anywhere in rule_fired."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ChainVerificationIssue:
|
|
44
|
+
trace_id: str
|
|
45
|
+
reason: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ChainVerificationResult:
|
|
50
|
+
valid: bool
|
|
51
|
+
issues: Sequence[ChainVerificationIssue] = field(default_factory=tuple)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class VerifyChainOptions:
|
|
56
|
+
"""``secret_key`` is required to verify entries signed with ``hmac-sha256:``. Entries
|
|
57
|
+
signed with the legacy unkeyed ``sha256:`` scheme verify without it."""
|
|
58
|
+
|
|
59
|
+
secret_key: Optional[SecretKeyLike] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entry_from_dict(raw: dict) -> TraceEntry:
|
|
63
|
+
scope_raw = raw.get("declared_scope", {})
|
|
64
|
+
scope = ScopeDeclaration(
|
|
65
|
+
network=scope_raw.get("network", False),
|
|
66
|
+
filesystem=tuple(scope_raw.get("filesystem", ())),
|
|
67
|
+
credentials=tuple(scope_raw.get("credentials", ())),
|
|
68
|
+
)
|
|
69
|
+
return TraceEntry(
|
|
70
|
+
trace_id=raw["trace_id"],
|
|
71
|
+
timestamp=raw["timestamp"],
|
|
72
|
+
session_id=raw["session_id"],
|
|
73
|
+
agent_id=raw["agent_id"],
|
|
74
|
+
tool=raw["tool"],
|
|
75
|
+
arguments_hash=raw["arguments_hash"],
|
|
76
|
+
decision=raw["decision"],
|
|
77
|
+
rule_fired=tuple(raw.get("rule_fired", ())),
|
|
78
|
+
declared_scope=scope,
|
|
79
|
+
signature=raw["signature"],
|
|
80
|
+
prior_trace_id=raw.get("prior_trace_id"),
|
|
81
|
+
agent_id_source=raw.get("agent_id_source"),
|
|
82
|
+
approved_by=raw.get("approved_by"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_trace(file_path: str) -> List[TraceEntry]:
|
|
87
|
+
"""Reads and parses every line of a JSON Lines trace file. Blank lines are skipped."""
|
|
88
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
89
|
+
raw = f.read()
|
|
90
|
+
entries: List[TraceEntry] = []
|
|
91
|
+
for index, line in enumerate(raw.split("\n")):
|
|
92
|
+
trimmed = line.strip()
|
|
93
|
+
if not trimmed:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
parsed = json.loads(trimmed)
|
|
97
|
+
except json.JSONDecodeError as cause:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Malformed trace line {index + 1} in {file_path}: not valid JSON"
|
|
100
|
+
) from cause
|
|
101
|
+
entries.append(_entry_from_dict(parsed))
|
|
102
|
+
return entries
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_SINCE_PATTERN = re.compile(r"^(\d+)(m|h|d)$")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_since(since: str, now: Optional[datetime] = None) -> datetime:
|
|
109
|
+
"""Parses a ``since`` window string into an absolute cutoff datetime."""
|
|
110
|
+
now = now or datetime.now(timezone.utc)
|
|
111
|
+
match = _SINCE_PATTERN.match(since)
|
|
112
|
+
if not match:
|
|
113
|
+
try:
|
|
114
|
+
as_date = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
115
|
+
except ValueError as cause:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f'Invalid --since value "{since}". Use "<n>m", "<n>h", "<n>d", or an ISO 8601 timestamp.'
|
|
118
|
+
) from cause
|
|
119
|
+
return as_date
|
|
120
|
+
amount = int(match.group(1))
|
|
121
|
+
unit = match.group(2)
|
|
122
|
+
delta = {"m": timedelta(minutes=amount), "h": timedelta(hours=amount), "d": timedelta(days=amount)}[unit]
|
|
123
|
+
return now - delta
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def filter_trace(entries: Sequence[TraceEntry], query: TraceQuery) -> List[TraceEntry]:
|
|
127
|
+
"""Filters trace entries by time window, decision, agent identity, and/or fired rule ID."""
|
|
128
|
+
cutoff = parse_since(query.since) if query.since else None
|
|
129
|
+
result = []
|
|
130
|
+
for entry in entries:
|
|
131
|
+
if cutoff is not None:
|
|
132
|
+
entry_time = datetime.fromisoformat(entry.timestamp.replace("Z", "+00:00"))
|
|
133
|
+
if entry_time < cutoff:
|
|
134
|
+
continue
|
|
135
|
+
if query.decision and entry.decision != query.decision:
|
|
136
|
+
continue
|
|
137
|
+
if query.agent_id and entry.agent_id != query.agent_id:
|
|
138
|
+
continue
|
|
139
|
+
if query.rule_id and query.rule_id not in entry.rule_fired:
|
|
140
|
+
continue
|
|
141
|
+
result.append(entry)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def verify_chain(
|
|
146
|
+
entries: Sequence[TraceEntry], options: Optional[VerifyChainOptions] = None
|
|
147
|
+
) -> ChainVerificationResult:
|
|
148
|
+
"""Recomputes each entry's signature and confirms it matches ``signature``, and confirms
|
|
149
|
+
``prior_trace_id`` correctly links to the previous entry in the same session. Returns every
|
|
150
|
+
issue found rather than stopping at the first one, so a reviewer can see the full extent of
|
|
151
|
+
a broken or tampered trace file.
|
|
152
|
+
|
|
153
|
+
An entry signed ``hmac-sha256:`` cannot be verified without the matching ``secret_key`` --
|
|
154
|
+
that is reported as an issue (not silently skipped, and not silently treated as valid),
|
|
155
|
+
because a trace a reviewer cannot actually verify is not a trace they should trust as-is.
|
|
156
|
+
"""
|
|
157
|
+
options = options or VerifyChainOptions()
|
|
158
|
+
issues: List[ChainVerificationIssue] = []
|
|
159
|
+
last_seen_by_session: Dict[str, Optional[str]] = {}
|
|
160
|
+
|
|
161
|
+
for entry in entries:
|
|
162
|
+
scheme = entry.signature.split(":", 1)[0]
|
|
163
|
+
if scheme == "hmac-sha256" and options.secret_key is None:
|
|
164
|
+
issues.append(
|
|
165
|
+
ChainVerificationIssue(
|
|
166
|
+
trace_id=entry.trace_id,
|
|
167
|
+
reason="Entry is signed with hmac-sha256 but no secret_key was supplied to verify it.",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
elif scheme in ("hmac-sha256", "sha256"):
|
|
171
|
+
# Only pass the key through for entries actually signed with it. A sha256: entry
|
|
172
|
+
# must always be recomputed unkeyed, even if the caller supplied a secret_key
|
|
173
|
+
# (e.g. verifying a trace file that turns out to predate keyed signing, or a
|
|
174
|
+
# mixed-mode file) -- otherwise every legitimate unkeyed entry would spuriously
|
|
175
|
+
# fail to verify against the wrong scheme.
|
|
176
|
+
expected = compute_entry_signature(
|
|
177
|
+
entry, options.secret_key if scheme == "hmac-sha256" else None
|
|
178
|
+
)
|
|
179
|
+
if not _signatures_match(entry.signature, expected):
|
|
180
|
+
issues.append(
|
|
181
|
+
ChainVerificationIssue(
|
|
182
|
+
trace_id=entry.trace_id, reason="Signature does not match entry content."
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
issues.append(
|
|
187
|
+
ChainVerificationIssue(
|
|
188
|
+
trace_id=entry.trace_id, reason=f'Unrecognized signature scheme "{scheme}".'
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
expected_prior = last_seen_by_session.get(entry.session_id)
|
|
193
|
+
if entry.prior_trace_id != expected_prior:
|
|
194
|
+
issues.append(
|
|
195
|
+
ChainVerificationIssue(
|
|
196
|
+
trace_id=entry.trace_id,
|
|
197
|
+
reason=(
|
|
198
|
+
f'prior_trace_id "{entry.prior_trace_id or "null"}" does not match the '
|
|
199
|
+
f'expected previous entry "{expected_prior or "null"}" for session '
|
|
200
|
+
f'"{entry.session_id}".'
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
last_seen_by_session[entry.session_id] = entry.trace_id
|
|
205
|
+
|
|
206
|
+
return ChainVerificationResult(valid=len(issues) == 0, issues=issues)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Signed, append-only JSON Lines trace writer.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/trace/trace-writer.ts``.
|
|
4
|
+
|
|
5
|
+
Every gate decision -- allow, deny, or require-approval -- gets one line. ``prior_trace_id``
|
|
6
|
+
chains each entry to the one before it in the same session, so a reader can walk the chain and
|
|
7
|
+
detect a missing, reordered, or tampered entry.
|
|
8
|
+
|
|
9
|
+
By default, "signed" means a ``sha256:`` content hash, not a keyed signature -- a deliberate
|
|
10
|
+
v0.1 default that needs no key management: it proves an entry has not changed since it was
|
|
11
|
+
written, but it does not stop someone with write access to the trace file from editing an entry
|
|
12
|
+
and recomputing a signature that still passes, since the hash itself requires no secret to
|
|
13
|
+
reproduce. Pass ``secret_key`` in ``TraceWriter`` to sign with ``hmac-sha256:`` instead, which
|
|
14
|
+
closes that gap for anyone who does not also hold the key. See ``docs/security-model.md``.
|
|
15
|
+
|
|
16
|
+
toolgovern does not generate, store, or rotate the key -- the caller is responsible for its
|
|
17
|
+
lifecycle (e.g. a locally generated file with restrictive permissions, or a secret manager).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import hmac
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import threading
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import Any, Dict, Optional, Sequence, Union
|
|
30
|
+
|
|
31
|
+
from ..types import AgentIdSource, Decision, ScopeDeclaration, TraceEntry, TraceEntryInput
|
|
32
|
+
from .canonical_json import canonical_json
|
|
33
|
+
|
|
34
|
+
# A raw bytes-like secret key, matching Node's BinaryLike (str/bytes) for this port's purposes.
|
|
35
|
+
SecretKeyLike = Union[bytes, str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sha256_hex(content: str) -> str:
|
|
39
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _hmac_sha256_hex(key: SecretKeyLike, content: str) -> str:
|
|
43
|
+
key_bytes = key.encode("utf-8") if isinstance(key, str) else key
|
|
44
|
+
return hmac.new(key_bytes, content.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scope_to_content(scope: ScopeDeclaration) -> Dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"network": scope.network if isinstance(scope.network, bool) else list(scope.network),
|
|
50
|
+
"filesystem": list(scope.filesystem),
|
|
51
|
+
"credentials": list(scope.credentials),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _entry_content(
|
|
56
|
+
*,
|
|
57
|
+
trace_id: Optional[str],
|
|
58
|
+
timestamp: str,
|
|
59
|
+
session_id: str,
|
|
60
|
+
agent_id: str,
|
|
61
|
+
tool: str,
|
|
62
|
+
arguments_hash: str,
|
|
63
|
+
decision: Decision,
|
|
64
|
+
rule_fired: Sequence[str],
|
|
65
|
+
declared_scope: ScopeDeclaration,
|
|
66
|
+
agent_id_source: Optional[AgentIdSource],
|
|
67
|
+
prior_trace_id: Optional[str],
|
|
68
|
+
approved_by: Optional[str],
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Builds the canonical-JSON content that gets hashed/signed. When ``trace_id`` is
|
|
71
|
+
``None``, the key is omitted entirely from the object (matching the TS ``withoutIds``
|
|
72
|
+
object, which has no ``trace_id`` field at all before one has been derived) rather than
|
|
73
|
+
included with an empty-string placeholder -- an empty string is still a value that would
|
|
74
|
+
change the hash output versus a genuinely absent key.
|
|
75
|
+
"""
|
|
76
|
+
content: Dict[str, Any] = {}
|
|
77
|
+
if trace_id is not None:
|
|
78
|
+
content["trace_id"] = trace_id
|
|
79
|
+
content.update(
|
|
80
|
+
{
|
|
81
|
+
"timestamp": timestamp,
|
|
82
|
+
"session_id": session_id,
|
|
83
|
+
"agent_id": agent_id,
|
|
84
|
+
"tool": tool,
|
|
85
|
+
"arguments_hash": arguments_hash,
|
|
86
|
+
"decision": decision,
|
|
87
|
+
"rule_fired": list(rule_fired),
|
|
88
|
+
"declared_scope": _scope_to_content(declared_scope),
|
|
89
|
+
"agent_id_source": agent_id_source,
|
|
90
|
+
"prior_trace_id": prior_trace_id,
|
|
91
|
+
"approved_by": approved_by,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
return canonical_json(content)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def compute_entry_content_hash(entry: TraceEntry) -> str:
|
|
98
|
+
"""Computes the content hash a TraceEntry should have, given everything except
|
|
99
|
+
``signature``. This is the unkeyed form -- kept for backward compatibility and as the
|
|
100
|
+
fallback ``sha256:`` scheme ``compute_entry_signature()`` uses when no secret key is
|
|
101
|
+
configured."""
|
|
102
|
+
return _sha256_hex(
|
|
103
|
+
_entry_content(
|
|
104
|
+
trace_id=entry.trace_id,
|
|
105
|
+
timestamp=entry.timestamp,
|
|
106
|
+
session_id=entry.session_id,
|
|
107
|
+
agent_id=entry.agent_id,
|
|
108
|
+
tool=entry.tool,
|
|
109
|
+
arguments_hash=entry.arguments_hash,
|
|
110
|
+
decision=entry.decision,
|
|
111
|
+
rule_fired=entry.rule_fired,
|
|
112
|
+
declared_scope=entry.declared_scope,
|
|
113
|
+
agent_id_source=entry.agent_id_source,
|
|
114
|
+
prior_trace_id=entry.prior_trace_id,
|
|
115
|
+
approved_by=entry.approved_by,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def compute_entry_signature(entry: TraceEntry, secret_key: Optional[SecretKeyLike] = None) -> str:
|
|
121
|
+
"""Computes what ``signature`` should be for ``entry`` (everything except ``signature``).
|
|
122
|
+
|
|
123
|
+
With no ``secret_key``, this is ``sha256:<hex>`` of the entry's canonicalized content --
|
|
124
|
+
proves the entry has not changed since it was written, but the hash is reproducible by
|
|
125
|
+
anyone (no secret required), so it does not stop an attacker who has write access to the
|
|
126
|
+
trace file from editing an entry and recomputing a signature that still verifies.
|
|
127
|
+
|
|
128
|
+
With a ``secret_key``, this is ``hmac-sha256:<hex>`` -- only someone holding the same key
|
|
129
|
+
can produce a signature that verifies. This is what makes the trace tamper-evident against
|
|
130
|
+
an attacker who can write to the trace file but does not also hold the key. See
|
|
131
|
+
``docs/security-model.md`` for the residual limitation (an attacker who reads both the
|
|
132
|
+
trace file and the key file can still forge a valid trace -- v0.1 has no external anchor or
|
|
133
|
+
key-management service).
|
|
134
|
+
"""
|
|
135
|
+
content = _entry_content(
|
|
136
|
+
trace_id=entry.trace_id,
|
|
137
|
+
timestamp=entry.timestamp,
|
|
138
|
+
session_id=entry.session_id,
|
|
139
|
+
agent_id=entry.agent_id,
|
|
140
|
+
tool=entry.tool,
|
|
141
|
+
arguments_hash=entry.arguments_hash,
|
|
142
|
+
decision=entry.decision,
|
|
143
|
+
rule_fired=entry.rule_fired,
|
|
144
|
+
declared_scope=entry.declared_scope,
|
|
145
|
+
agent_id_source=entry.agent_id_source,
|
|
146
|
+
prior_trace_id=entry.prior_trace_id,
|
|
147
|
+
approved_by=entry.approved_by,
|
|
148
|
+
)
|
|
149
|
+
if secret_key is not None:
|
|
150
|
+
return f"hmac-sha256:{_hmac_sha256_hex(secret_key, content)}"
|
|
151
|
+
return f"sha256:{_sha256_hex(content)}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class TraceWriterOptions:
|
|
156
|
+
"""Options for ``TraceWriter``.
|
|
157
|
+
|
|
158
|
+
``secret_key``: when provided, every entry is signed with ``hmac-sha256:<hex>`` using this
|
|
159
|
+
key instead of the unkeyed ``sha256:<hex>`` content hash. toolgovern does not generate,
|
|
160
|
+
store, or rotate this key -- the caller is responsible for its lifecycle. Pass the same key
|
|
161
|
+
to ``verify_chain()``.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
secret_key: Optional[SecretKeyLike] = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TraceWriter:
|
|
168
|
+
"""Appends signed, append-only JSON Lines trace entries to a file.
|
|
169
|
+
|
|
170
|
+
Writes are serialized with a lock so concurrent calls within one process never interleave
|
|
171
|
+
lines or race on the last-trace-id-per-session bookkeeping, which would silently break the
|
|
172
|
+
chain (the Python port's equivalent of the TS implementation's promise write-queue).
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, file_path: str, options: Optional[TraceWriterOptions] = None) -> None:
|
|
176
|
+
self._file_path = file_path
|
|
177
|
+
self._options = options or TraceWriterOptions()
|
|
178
|
+
self._last_trace_id_by_session: Dict[str, Optional[str]] = {}
|
|
179
|
+
self._lock = threading.Lock()
|
|
180
|
+
|
|
181
|
+
def append(self, entry_input: TraceEntryInput) -> TraceEntry:
|
|
182
|
+
"""Appends one gate decision to the trace file and returns the entry that was written."""
|
|
183
|
+
with self._lock:
|
|
184
|
+
prior_trace_id = self._last_trace_id_by_session.get(entry_input.session_id)
|
|
185
|
+
now = datetime.now(timezone.utc)
|
|
186
|
+
timestamp = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
187
|
+
arguments_hash = f"sha256:{_sha256_hex(canonical_json(dict(entry_input.args)))}"
|
|
188
|
+
rule_fired = list(entry_input.rule_fired)
|
|
189
|
+
|
|
190
|
+
# trace_id is derived from the entry's own (unkeyed) content hash of everything
|
|
191
|
+
# else -- it is an identifier, not a security boundary, so it stays
|
|
192
|
+
# reproducible/public even when the signature itself is keyed.
|
|
193
|
+
id_seed_content = _entry_content(
|
|
194
|
+
trace_id=None,
|
|
195
|
+
timestamp=timestamp,
|
|
196
|
+
session_id=entry_input.session_id,
|
|
197
|
+
agent_id=entry_input.agent_id,
|
|
198
|
+
tool=entry_input.tool,
|
|
199
|
+
arguments_hash=arguments_hash,
|
|
200
|
+
decision=entry_input.decision,
|
|
201
|
+
rule_fired=rule_fired,
|
|
202
|
+
declared_scope=entry_input.declared_scope,
|
|
203
|
+
agent_id_source=entry_input.agent_id_source,
|
|
204
|
+
prior_trace_id=prior_trace_id,
|
|
205
|
+
approved_by=entry_input.approved_by,
|
|
206
|
+
)
|
|
207
|
+
id_seed_hash = _sha256_hex(id_seed_content)
|
|
208
|
+
trace_id = f"tg_{timestamp[:10]}_{id_seed_hash[:6]}"
|
|
209
|
+
|
|
210
|
+
unsigned_entry = TraceEntry(
|
|
211
|
+
trace_id=trace_id,
|
|
212
|
+
timestamp=timestamp,
|
|
213
|
+
session_id=entry_input.session_id,
|
|
214
|
+
agent_id=entry_input.agent_id,
|
|
215
|
+
tool=entry_input.tool,
|
|
216
|
+
arguments_hash=arguments_hash,
|
|
217
|
+
decision=entry_input.decision,
|
|
218
|
+
rule_fired=rule_fired,
|
|
219
|
+
declared_scope=entry_input.declared_scope,
|
|
220
|
+
agent_id_source=entry_input.agent_id_source,
|
|
221
|
+
prior_trace_id=prior_trace_id,
|
|
222
|
+
approved_by=entry_input.approved_by,
|
|
223
|
+
signature="",
|
|
224
|
+
)
|
|
225
|
+
signature = compute_entry_signature(unsigned_entry, self._options.secret_key)
|
|
226
|
+
entry = TraceEntry(
|
|
227
|
+
trace_id=trace_id,
|
|
228
|
+
timestamp=timestamp,
|
|
229
|
+
session_id=entry_input.session_id,
|
|
230
|
+
agent_id=entry_input.agent_id,
|
|
231
|
+
tool=entry_input.tool,
|
|
232
|
+
arguments_hash=arguments_hash,
|
|
233
|
+
decision=entry_input.decision,
|
|
234
|
+
rule_fired=rule_fired,
|
|
235
|
+
declared_scope=entry_input.declared_scope,
|
|
236
|
+
agent_id_source=entry_input.agent_id_source,
|
|
237
|
+
prior_trace_id=prior_trace_id,
|
|
238
|
+
approved_by=entry_input.approved_by,
|
|
239
|
+
signature=signature,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
os.makedirs(os.path.dirname(self._file_path) or ".", exist_ok=True)
|
|
243
|
+
with open(self._file_path, "a", encoding="utf-8") as f:
|
|
244
|
+
f.write(json.dumps(entry.to_dict(), ensure_ascii=False) + "\n")
|
|
245
|
+
|
|
246
|
+
self._last_trace_id_by_session[entry_input.session_id] = trace_id
|
|
247
|
+
return entry
|