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.
Files changed (36) hide show
  1. toolgovern/__init__.py +222 -0
  2. toolgovern/approval/__init__.py +25 -0
  3. toolgovern/approval/pending_registry.py +379 -0
  4. toolgovern/classifier/__init__.py +19 -0
  5. toolgovern/classifier/credential_access.py +180 -0
  6. toolgovern/classifier/cross_agent_inheritance.py +216 -0
  7. toolgovern/classifier/filesystem_scope.py +202 -0
  8. toolgovern/classifier/index.py +80 -0
  9. toolgovern/classifier/information_flow.py +154 -0
  10. toolgovern/classifier/network_egress.py +289 -0
  11. toolgovern/classifier/shell_risk.py +357 -0
  12. toolgovern/classifier/util.py +259 -0
  13. toolgovern/cli.py +298 -0
  14. toolgovern/mcp_trust/__init__.py +342 -0
  15. toolgovern/middleware/__init__.py +30 -0
  16. toolgovern/middleware/idempotency_cache.py +117 -0
  17. toolgovern/middleware/on_tool_call.py +538 -0
  18. toolgovern/policy/__init__.py +10 -0
  19. toolgovern/policy/load_policy.py +41 -0
  20. toolgovern/policy/validate_policy.py +106 -0
  21. toolgovern/py.typed +0 -0
  22. toolgovern/scoping/__init__.py +13 -0
  23. toolgovern/scoping/inheritance_enforcer.py +145 -0
  24. toolgovern/scoping/scope_declaration.py +84 -0
  25. toolgovern/shared/__init__.py +0 -0
  26. toolgovern/shared/paths.py +266 -0
  27. toolgovern/trace/__init__.py +33 -0
  28. toolgovern/trace/canonical_json.py +27 -0
  29. toolgovern/trace/trace_reader.py +206 -0
  30. toolgovern/trace/trace_writer.py +247 -0
  31. toolgovern/types.py +247 -0
  32. toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
  33. toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
  34. toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
  35. toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
  36. 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