interlockd 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.
Files changed (84) hide show
  1. interlockd/__init__.py +3 -0
  2. interlockd/__main__.py +7 -0
  3. interlockd/aibom.py +136 -0
  4. interlockd/api.py +307 -0
  5. interlockd/auth.py +38 -0
  6. interlockd/calibration.py +260 -0
  7. interlockd/cli.py +1920 -0
  8. interlockd/compose.py +84 -0
  9. interlockd/config.py +80 -0
  10. interlockd/corpus.py +505 -0
  11. interlockd/data/packs/_corpus.yaml +7 -0
  12. interlockd/data/packs/egress.jsonl +38 -0
  13. interlockd/data/packs/fileops.jsonl +20 -0
  14. interlockd/data/packs/git.jsonl +51 -0
  15. interlockd/data/packs/packages.jsonl +24 -0
  16. interlockd/data/packs/persistence.jsonl +31 -0
  17. interlockd/data/packs/secrets.jsonl +22 -0
  18. interlockd/data/packs/shell.jsonl +75 -0
  19. interlockd/det/__init__.py +53 -0
  20. interlockd/det/describe.py +66 -0
  21. interlockd/det/engine.py +153 -0
  22. interlockd/det/flow.py +106 -0
  23. interlockd/det/mandate.py +108 -0
  24. interlockd/det/mcp.py +70 -0
  25. interlockd/det/memory.py +50 -0
  26. interlockd/det/packs.py +531 -0
  27. interlockd/det/shellparse.py +438 -0
  28. interlockd/egress.py +124 -0
  29. interlockd/gateway.py +350 -0
  30. interlockd/ids.py +25 -0
  31. interlockd/insights.py +316 -0
  32. interlockd/integrations/__init__.py +2 -0
  33. interlockd/integrations/client.py +85 -0
  34. interlockd/integrations/langchain.py +59 -0
  35. interlockd/integrations/openai_agents.py +72 -0
  36. interlockd/integrations/strands.py +69 -0
  37. interlockd/judges/__init__.py +107 -0
  38. interlockd/judges/guardrails.py +94 -0
  39. interlockd/judges/local_probe.py +192 -0
  40. interlockd/judges/projector.py +82 -0
  41. interlockd/judges/strands_decider.py +160 -0
  42. interlockd/ledger.py +62 -0
  43. interlockd/mcp_http.py +216 -0
  44. interlockd/mcp_proxy.py +315 -0
  45. interlockd/mcp_server.py +211 -0
  46. interlockd/models.py +98 -0
  47. interlockd/normalize/__init__.py +1 -0
  48. interlockd/normalize/claude_code.py +89 -0
  49. interlockd/normalize/cursor.py +59 -0
  50. interlockd/normalize/gemini_cli.py +55 -0
  51. interlockd/normalize/kiro.py +65 -0
  52. interlockd/normalize/more.py +98 -0
  53. interlockd/ogr.py +542 -0
  54. interlockd/onboard.py +300 -0
  55. interlockd/otel.py +139 -0
  56. interlockd/paths.py +63 -0
  57. interlockd/pipeline.py +370 -0
  58. interlockd/policy/__init__.py +220 -0
  59. interlockd/policy_lint.py +76 -0
  60. interlockd/render.py +142 -0
  61. interlockd/sandbox.py +87 -0
  62. interlockd/scan.py +405 -0
  63. interlockd/schema/verdict.schema.json +68 -0
  64. interlockd/selftest.py +101 -0
  65. interlockd/service.py +247 -0
  66. interlockd/shims/claude-code/interlock-hook.sh +113 -0
  67. interlockd/shims/kiro/interlock-hook.mjs +101 -0
  68. interlockd/shims/kiro/interlock-hook.sh +82 -0
  69. interlockd/shims/opencode/interlock.js +60 -0
  70. interlockd/store/__init__.py +22 -0
  71. interlockd/store/drainer.py +74 -0
  72. interlockd/store/dynamodb.py +144 -0
  73. interlockd/store/memory.py +28 -0
  74. interlockd/store/spool.py +159 -0
  75. interlockd/testkit.py +401 -0
  76. interlockd/tiers.py +95 -0
  77. interlockd/transcript/__init__.py +1 -0
  78. interlockd/transcript/claude_code.py +73 -0
  79. interlockd/transcript/kiro.py +102 -0
  80. interlockd-0.1.0.dist-info/METADATA +125 -0
  81. interlockd-0.1.0.dist-info/RECORD +84 -0
  82. interlockd-0.1.0.dist-info/WHEEL +4 -0
  83. interlockd-0.1.0.dist-info/entry_points.txt +2 -0
  84. interlockd-0.1.0.dist-info/licenses/LICENSE +201 -0
interlockd/pipeline.py ADDED
@@ -0,0 +1,370 @@
1
+ """The decide() pipeline (design §4.2). M0 scope: authenticate at the API, normalize, ledger, tier route,
2
+ record, return. The deterministic engine (M1) and the judge (M2) plug in between steps 4 and 10.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import logging
10
+ import time
11
+ from datetime import UTC, datetime
12
+ from typing import Any
13
+
14
+ from . import __version__
15
+ from .compose import compose
16
+ from .config import Settings
17
+ from .det.engine import evaluate as det_evaluate
18
+ from .det.flow import check as flow_check
19
+ from .det.flow import observe as flow_observe
20
+ from .det.mandate import evaluate_mandate
21
+ from .ids import ulid
22
+ from .judges import QUESTIONS_V1, QUESTIONS_VERSION, BackendError
23
+ from .judges.projector import project_confirmation, project_intent, project_probe, user_context_text
24
+ from .ledger import Ledger
25
+ from .models import InterlockEvent, Verdict
26
+ from .policy import Policy
27
+ from .store import Repository
28
+ from .store.spool import Spool
29
+ from .tiers import route
30
+ from .transcript.claude_code import ClaudeTranscript
31
+ from .transcript.kiro import KiroTranscript
32
+
33
+ log = logging.getLogger("interlockd.pipeline")
34
+
35
+ RESULT_HEAD_TAIL = 2000
36
+
37
+
38
+ def _now_iso() -> str:
39
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
40
+
41
+
42
+ class Daemon:
43
+ def __init__(self, settings: Settings, *, spool: Spool, repo: Repository | None = None, policy: Policy | None = None):
44
+ self.settings = settings
45
+ self.spool = spool
46
+ self.repo = repo
47
+ self.ledger = Ledger()
48
+ self.transcript = KiroTranscript(settings.kiro_sessions_dir)
49
+ self.claude_transcript = ClaudeTranscript()
50
+ self.policy = policy or Policy()
51
+ if not self.policy.trust.workspace_roots and settings.workspace_roots:
52
+ self.policy.trust.workspace_roots = list(settings.workspace_roots)
53
+ self.mode = self.policy.mode if policy is not None else settings.mode
54
+ self.backend = None # DecisionBackend, set by the CLI from policy.judge.primary
55
+ self.probe = None # BedrockGuardrailsProbe, set by the CLI when policy.judge.probe.enabled
56
+ self.otel = None
57
+ if settings.otlp_endpoint:
58
+ from .otel import Exporter
59
+
60
+ self.otel = Exporter(settings.otlp_endpoint, settings.otlp_headers)
61
+ self.local_probe = None # LocalInjectionProbe: no AWS, no network; on unless policy.judge.probe.local.enabled is false
62
+ lp = self.policy.judge.probe.local
63
+ if lp.enabled:
64
+ from .judges.local_probe import LocalInjectionProbe
65
+
66
+ self.local_probe = LocalInjectionProbe(threshold=lp.threshold, classifier_model=lp.classifier_model,
67
+ classifier_threshold=lp.classifier_threshold)
68
+
69
+ # ---- judge stage ----------------------------------------------------------------------------------
70
+ def _judge_pre(self, event, det, session, composed):
71
+ """Ask the judge about a gated pre-action call. Returns (disposition, findings_added, answers_rows, unjudged_add)."""
72
+ from .models import Finding
73
+
74
+ b = self.policy.judge.bands
75
+ qids = list(det.questions) or ["intent_authorized"]
76
+ if any(f.rule and f.rule.startswith("egress.") for f in det.findings) and "exfil_risk" not in qids:
77
+ qids.append("exfil_risk")
78
+ if not event.context.user_messages:
79
+ return composed.disposition, [], [], ["judge:no_user_messages"]
80
+ questions = {q: QUESTIONS_V1[q] for q in qids if q in QUESTIONS_V1}
81
+ state = project_intent(event, normalized=det.normalized, prior_calls=session.get("recent_calls", [])[:-1],
82
+ max_tokens=b.max_intent_state_tokens)
83
+ try:
84
+ res = self.backend.ask(state, questions, timeout_ms=self.policy.judge.primary.timeout_ms)
85
+ except BackendError as e:
86
+ log.warning("judge unavailable: %s", e)
87
+ return composed.disposition, [], [], ["judge"]
88
+ rows = [{"backend": self.backend.name, "model_snapshot": res.model_snapshot, "question_id": q, "questions_version": QUESTIONS_VERSION,
89
+ "answer": a, "latency_ms": res.latency_ms, "pass": 1} for q, a in res.answers.items()]
90
+ intent = res.answers.get("intent_authorized", {}).get("noul")
91
+ exfil = res.answers.get("exfil_risk", {}).get("noul")
92
+ disp = composed.disposition
93
+ findings = []
94
+ if intent is not None:
95
+ findings.append(Finding(category="x.interlock.intent", severity="medium" if intent < b.intent_ask_below else "low",
96
+ detector=f"interlock.judge/{res.model_snapshot}", path="tool_call", score=round(1 - intent, 3),
97
+ subject=f"P(user authorized)={intent:.2f}"))
98
+ if disp == "allow":
99
+ if intent < b.intent_block_below and det.harm >= b.harm_block_min:
100
+ disp = "block"
101
+ elif intent < b.intent_ask_below:
102
+ disp = "ask"
103
+ if exfil is not None and exfil >= b.exfil_ask_at and disp == "allow":
104
+ findings.append(Finding(category="security.data_exfiltration", severity="high", detector=f"interlock.judge/{res.model_snapshot}",
105
+ path="tool_call", score=round(exfil, 3), subject=f"P(exfiltration)={exfil:.2f}"))
106
+ disp = "ask"
107
+ return disp, findings, rows, []
108
+
109
+ def _confirm_via_transcript(self, event, det, session, findings):
110
+ """Harnesses without a native ask (Kiro) cannot carry the user's confirmation back to the hook. The notice tells
111
+ the agent to ask the user; the user's answer lands in the session transcript, which the daemon reads. If the judge
112
+ finds the latest user messages authorize this exact action, the ask resolves to allow, with the evidence recorded.
113
+ The agent cannot forge this channel without writing into the harness's own transcript, which the persistence pack
114
+ treats as agent-config tampering."""
115
+ from .models import Finding
116
+
117
+ b = self.policy.judge.bands
118
+ if self.backend is None or not event.context.user_messages:
119
+ return "ask", [], [], ["confirmation:no_judge" if self.backend is None else "confirmation:no_user_messages"]
120
+ reason = "; ".join(f.subject or f.category for f in findings[:3]) or None
121
+ state = project_confirmation(event, normalized=det.normalized, reason=reason)
122
+ try:
123
+ res = self.backend.ask(state, {"confirmation_given": QUESTIONS_V1["confirmation_given"]},
124
+ timeout_ms=self.policy.judge.primary.timeout_ms)
125
+ except BackendError as e:
126
+ log.warning("confirmation judge unavailable: %s", e)
127
+ return "ask", [], [], ["confirmation:judge"]
128
+ intent = res.answers.get("confirmation_given", {}).get("noul")
129
+ rows = [{"backend": self.backend.name, "model_snapshot": res.model_snapshot, "question_id": "confirmation_given",
130
+ "questions_version": QUESTIONS_VERSION, "answer": res.answers.get("confirmation_given"), "latency_ms": res.latency_ms, "pass": 1,
131
+ "purpose": "confirmation"}]
132
+ if intent is not None and intent >= b.confirm_allow_at:
133
+ f = Finding(category="x.interlock.confirmed_via_transcript", severity="low", detector=f"interlock.judge/{res.model_snapshot}",
134
+ path="tool_call", score=round(intent, 3),
135
+ subject=f"ask resolved: the user confirmed this action, P={intent:.2f} (last: {event.context.user_messages[-1].text[:80]!r})")
136
+ return "allow", [f], rows, []
137
+ return "ask", [], rows, []
138
+
139
+ def _probe_post(self, event, composed):
140
+ """Post-action: Guardrails and the judge on the tool result. Returns (disposition, findings_added, answers_rows, unjudged, guard_scores)."""
141
+ from .models import Finding
142
+
143
+ b = self.policy.judge.bands
144
+ findings, rows, unjudged, scores = [], [], [], {}
145
+ disp = composed.disposition
146
+ content = event.result.content if event.result else ""
147
+ if not content.strip():
148
+ return disp, findings, rows, unjudged, scores
149
+ max_sev = 0.0
150
+ if self.local_probe is not None:
151
+ lr = self.local_probe.check(untrusted=content[: b.max_untrusted_tokens * 4])
152
+ if lr.error: # the classifier went dark; the heuristic result still stands
153
+ unjudged.append("probe:local_classifier")
154
+ scores.update(lr.scores)
155
+ findings.extend(lr.findings)
156
+ # a local score below its own threshold is not a signal: the bands are tuned for calibrated detectors
157
+ max_sev = max([f.score or 0.0 for f in lr.findings] or [0.0])
158
+ rows.append({"backend": "local_probe", "model_snapshot": self.local_probe.classifier_model or "heuristics-v1",
159
+ "question_id": "prompt_attack", "answer": {"scores": lr.scores}, "latency_ms": lr.latency_ms, "pass": 1})
160
+ if self.probe is not None:
161
+ g = self.probe.check(untrusted=content[: b.max_untrusted_tokens * 4], user_context=user_context_text(event),
162
+ timeout_ms=self.policy.judge.probe.timeout_ms)
163
+ if g.error:
164
+ unjudged.append("probe:guardrails")
165
+ log.warning("guardrails probe failed: %s", g.error)
166
+ else:
167
+ scores.update(g.scores)
168
+ findings.extend(g.findings)
169
+ max_sev = max([max_sev] + [v for k, v in g.scores.items() if not k.startswith("PII:")])
170
+ rows.append({"backend": "bedrock_guardrails", "model_snapshot": "bedrock-guardrails", "question_id": "prompt_attack",
171
+ "answer": {"scores": g.scores, "text_units": g.text_units}, "latency_ms": g.latency_ms, "pass": 1})
172
+ p_inj = None
173
+ if self.backend is not None:
174
+ state, truncated = project_probe(event, max_untrusted_tokens=b.max_untrusted_tokens)
175
+ try:
176
+ res = self.backend.ask(state, {"injection_present": QUESTIONS_V1["injection_present"]},
177
+ timeout_ms=self.policy.judge.primary.timeout_ms)
178
+ p_inj = res.answers.get("injection_present", {}).get("noul")
179
+ rows.append({"backend": self.backend.name, "model_snapshot": res.model_snapshot, "question_id": "injection_present",
180
+ "questions_version": QUESTIONS_VERSION, "answer": res.answers.get("injection_present"), "latency_ms": res.latency_ms, "pass": 1})
181
+ if truncated:
182
+ findings.append(Finding(category="x.interlock.truncated_untrusted", severity="low", detector="interlock.projector",
183
+ path="result.content", score=1.0, subject="content judged on head and tail only"))
184
+ except BackendError as e:
185
+ unjudged.append("probe:judge")
186
+ log.warning("judge probe unavailable: %s", e)
187
+ signal = max(max_sev, p_inj or 0.0)
188
+ if p_inj is not None and p_inj >= b.injection_warn_at:
189
+ findings.append(Finding(category="security.prompt_injection", severity="high" if p_inj < b.injection_withhold_at else "critical",
190
+ detector="interlock.judge", path="result.content", score=round(p_inj, 3), subject=f"P(injection)={p_inj:.2f}"))
191
+ if signal >= b.injection_withhold_at and event.capabilities.withhold:
192
+ disp = "withhold"
193
+ elif signal >= b.injection_warn_at:
194
+ disp = "warn"
195
+ return disp, findings, rows, unjudged, scores
196
+
197
+ @property
198
+ def policy_sha256(self) -> str:
199
+ return self.policy.sha256 or "default"
200
+
201
+ # ---- helpers -------------------------------------------------------------------------------------
202
+ def _fill_user_messages(self, event: InterlockEvent) -> int:
203
+ """Hooks carry no user messages; read them from the harness transcript. Returns the turn number."""
204
+ if event.adapter.name in ("claude-code", "codex"):
205
+ path = event.context.transcript_path
206
+ if event.context.user_messages is None and path:
207
+ event.context.user_messages = self.claude_transcript.user_messages(path, self.settings.user_messages_n)
208
+ return self.claude_transcript.turn_count(path) if path else 0
209
+ if event.adapter.name != "kiro" or not event.session_hint:
210
+ return len(event.context.user_messages or []) and 1 or 0
211
+ if event.context.user_messages is None:
212
+ event.context.user_messages = self.transcript.user_messages(event.session_hint, self.settings.user_messages_n)
213
+ if event.context.permission_mode is None:
214
+ meta = self.transcript.session_meta(event.session_hint)
215
+ if meta:
216
+ event.context.permission_mode = "autopilot" if meta.get("autopilot") else "supervised"
217
+ return self.transcript.turn_count(event.session_hint)
218
+
219
+ def _record(self, event: InterlockEvent, verdict: Verdict, *, received_at: str, session: dict[str, Any], turn_no: int,
220
+ normalized: dict[str, Any] | None = None, answers: list | None = None, guardrails: dict | None = None) -> dict[str, Any]:
221
+ state = {
222
+ "user_messages": [m.model_dump() for m in (event.context.user_messages or [])],
223
+ "proposed_action": {"tool": event.tool_call.name, "arguments": event.tool_call.arguments},
224
+ }
225
+ state_json = json.dumps(state, sort_keys=True, separators=(",", ":"))
226
+ rec: dict[str, Any] = {
227
+ "event_id": verdict.event_id,
228
+ "received_at": received_at,
229
+ "kind": event.kind,
230
+ "adapter": event.adapter.name, "adapter_version": event.adapter.version,
231
+ "agent_id": event.identity.agent_id, "agent_type": event.identity.agent_type,
232
+ "agent_workspace": event.identity.agent_workspace, "agent_user": event.identity.agent_user,
233
+ "session_id": event.session_hint or "unknown", "turn_no": turn_no,
234
+ "tool_call_id": event.tool_call.id, "tool_name": event.tool_call.name,
235
+ "arguments": event.tool_call.arguments, "cwd": event.context.cwd,
236
+ "normalized": {"action_class": verdict.action_class, **(normalized or {})},
237
+ "tier": verdict.tier,
238
+ "state": state, "state_sha256": hashlib.sha256(state_json.encode()).hexdigest(),
239
+ "disposition": verdict.disposition, "mode": verdict.mode,
240
+ "reason": verdict.reason, "notice": verdict.notice,
241
+ "unjudged": verdict.unjudged,
242
+ "findings": [f.model_dump() for f in verdict.findings],
243
+ "answers": list(answers or []),
244
+ "guardrails": dict(guardrails or {}),
245
+ "timing_ms": verdict.timing_ms,
246
+ "cost_usd": 0.0,
247
+ "policy_sha256": self.policy_sha256,
248
+ "daemon_version": __version__,
249
+ "capabilities": event.capabilities.model_dump(),
250
+ "permission_mode": event.context.permission_mode,
251
+ }
252
+ if event.result is not None:
253
+ content = event.result.content
254
+ rec["result_sha256"] = hashlib.sha256(content.encode("utf-8", "replace")).hexdigest()
255
+ rec["result_bytes"] = len(content)
256
+ rec["result_head"] = content[:RESULT_HEAD_TAIL]
257
+ rec["result_tail"] = content[-RESULT_HEAD_TAIL:] if len(content) > RESULT_HEAD_TAIL else ""
258
+ rec["evidence"] = event.result.evidence
259
+ return rec
260
+
261
+ # ---- the pipeline ----------------------------------------------------------------------------------
262
+ def decide(self, event: InterlockEvent) -> Verdict:
263
+ t0 = time.perf_counter()
264
+ received_at = _now_iso()
265
+ event_id = ulid()
266
+
267
+ turn_no = self._fill_user_messages(event)
268
+ t_ctx = time.perf_counter()
269
+
270
+ pol = self.policy
271
+ tier = route(
272
+ event,
273
+ workspace_roots=pol.trust.workspace_roots,
274
+ protected=pol.trust.protected_paths,
275
+ sensitive=pol.trust.sensitive_paths,
276
+ )
277
+ session = self.ledger.touch(event, turn_no=turn_no, action_class=tier.action_class)
278
+ t_tier = time.perf_counter()
279
+
280
+ findings: list = []
281
+ unjudged: list[str] = []
282
+ det = None
283
+ answers_rows: list = []
284
+ guard_scores: dict = {}
285
+ if event.kind == "post_action":
286
+ det = det_evaluate(event, pol, tier)
287
+ composed = compose(det, evaluate_mandate(pol, det, agent_id="", workspace="", session_counters={}), pol,
288
+ session=session, kind="post_action")
289
+ disposition, reason, notice, findings, unjudged = composed.disposition, composed.reason, None, composed.findings, []
290
+ action_class = det.action_class
291
+ if self.backend is not None or self.probe is not None or self.local_probe is not None:
292
+ disposition, extra, rows, more_unjudged, scores = self._probe_post(event, composed)
293
+ findings = findings + extra
294
+ unjudged = unjudged + more_unjudged
295
+ answers_rows, guard_scores = rows, scores
296
+ if extra:
297
+ reason = "; ".join(f.subject or f.category for f in extra)
298
+ if disposition == "withhold":
299
+ notice = ("[interlock] This tool result was withheld by a security policy because it appears to contain "
300
+ "instructions addressed to the agent that the user did not give. Its contents are recorded in the audit store.")
301
+ elif tier.tier == 0:
302
+ disposition, reason, notice, action_class = "allow", tier.reason, None, tier.action_class
303
+ else:
304
+ # tier 1 runs the secrets and persistence packs on the content; tier 2 runs everything the policy enables
305
+ det = det_evaluate(event, pol, tier)
306
+ flow_check(event, det, session, pol)
307
+ mandate = evaluate_mandate(pol, det, agent_id=event.identity.agent_id, workspace=event.identity.agent_workspace,
308
+ session_counters=session.get("counters", {}))
309
+ composed = compose(det, mandate, pol, session=session, kind="pre_action")
310
+ disposition, reason, notice, findings, unjudged = (composed.disposition, composed.reason, composed.notice,
311
+ composed.findings, composed.unjudged)
312
+ action_class = det.action_class
313
+ if composed.needs_judge and self.backend is not None and self.policy.judge.ask_intent_for_model_rules:
314
+ disposition, extra, rows, more_unjudged = self._judge_pre(event, det, session, composed)
315
+ findings = findings + extra
316
+ unjudged = unjudged + more_unjudged
317
+ answers_rows = rows
318
+ if disposition in ("ask", "block") and not notice:
319
+ from .compose import render_notice
320
+ reason = "; ".join(f.subject or f.category for f in extra) or reason
321
+ notice = render_notice(disposition, findings, reason=reason)
322
+ elif composed.needs_judge:
323
+ unjudged = unjudged + ["judge"] # no backend configured: a model-routed rule is recorded, not enforced
324
+ if disposition == "ask" and not event.capabilities.ask and self.mode == "enforce":
325
+ disposition, extra, rows, more_unjudged = self._confirm_via_transcript(event, det, session, findings)
326
+ findings = findings + extra
327
+ unjudged = unjudged + more_unjudged
328
+ answers_rows = answers_rows + rows
329
+ if disposition == "allow":
330
+ notice = None
331
+
332
+ verdict = Verdict(
333
+ event_id=event_id,
334
+ disposition=disposition, # type: ignore[arg-type]
335
+ reason=reason,
336
+ notice=notice,
337
+ findings=findings,
338
+ mode=self.mode, # type: ignore[arg-type]
339
+ tier=tier.tier,
340
+ action_class=action_class,
341
+ unjudged=unjudged,
342
+ timing_ms={},
343
+ )
344
+ self.ledger.record_disposition(event.session_hint, disposition if self.mode == "enforce" else "allow")
345
+ flow_observe(event, session, pol, findings)
346
+
347
+ t_dec = time.perf_counter()
348
+ verdict.timing_ms = {
349
+ "context": round((t_ctx - t0) * 1000, 2),
350
+ "tier": round((t_tier - t_ctx) * 1000, 2),
351
+ "decide": round((t_dec - t_tier) * 1000, 2),
352
+ "total": round((t_dec - t0) * 1000, 2), # pipeline time up to the store write
353
+ }
354
+ rec = self._record(event, verdict, received_at=received_at, session=session, turn_no=turn_no,
355
+ normalized=({**det.normalized, "harm": det.harm} if det is not None else None),
356
+ answers=answers_rows, guardrails=guard_scores)
357
+ rec["timing_ms"] = dict(verdict.timing_ms) # the stored copy must not alias the live verdict
358
+ self.spool.append(rec)
359
+ if self.otel is not None:
360
+ self.otel.submit(rec)
361
+ if self.repo is not None and not hasattr(self.repo, "put_events"):
362
+ # a local repository (memory) is written inline; remote repositories are fed by the drainer
363
+ self.repo.put_event(rec)
364
+ summary = self.ledger.summary(event.session_hint)
365
+ if summary:
366
+ self.repo.put_session(summary)
367
+ t_store = time.perf_counter()
368
+ verdict.timing_ms["store"] = round((t_store - t_dec) * 1000, 2)
369
+ verdict.timing_ms["total_with_store"] = round((t_store - t0) * 1000, 2)
370
+ return verdict
@@ -0,0 +1,220 @@
1
+ """Policy: the YAML document in .interlock/policy.yaml (design §6), validated by pydantic, with defaults.
2
+
3
+ Resolution: built-in defaults <- ~/.interlock/policy.yaml (user) <- <workspace>/.interlock/policy.yaml.
4
+ Lists are replaced, mappings are merged one level deep, which is enough for `packs.overrides` and `trust`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+
13
+ import yaml
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ RuleAction = Literal["block", "ask", "flag", "allow", "model"]
17
+
18
+
19
+ class Strict(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+
22
+
23
+ class Trust(Strict):
24
+ workspace_roots: list[str] = Field(default_factory=list)
25
+ protected_paths: list[str] = Field(default_factory=lambda: [
26
+ ".git/hooks/**", ".kiro/**", ".claude/**", ".codex/**", ".github/workflows/**", ".interlock/**",
27
+ ])
28
+ sensitive_paths: list[str] = Field(default_factory=lambda: [
29
+ "~/.ssh/**", "~/.aws/**", "~/.config/gh/**", "~/.interlock/token", "~/.netrc", "~/.npmrc", "~/.pypirc",
30
+ "~/.docker/config.json", "~/.kube/config", "**/.env", "**/.env.*", "**/*.pem", "**/id_rsa*", "**/id_ed25519*",
31
+ "**/*.key", "**/credentials", "**/credentials.json", "**/serviceAccountKey.json",
32
+ ])
33
+ allowed_hosts: list[str] = Field(default_factory=lambda: [
34
+ "github.com", "api.github.com", "raw.githubusercontent.com", "objects.githubusercontent.com", "codeload.github.com",
35
+ "registry.npmjs.org", "pypi.org", "files.pythonhosted.org", "crates.io", "static.crates.io", "proxy.golang.org",
36
+ "sum.golang.org", "repo.maven.apache.org", "localhost", "127.0.0.1",
37
+ ])
38
+ allowed_git_remotes: list[str] = Field(default_factory=list)
39
+ protected_branches: list[str] = Field(default_factory=lambda: ["main", "master", "release/*", "prod", "production"])
40
+ declared_packages_only: bool = True
41
+ allowed_package_indexes: list[str] = Field(default_factory=lambda: [
42
+ "https://pypi.org/simple", "https://registry.npmjs.org", "https://registry.yarnpkg.com",
43
+ ])
44
+
45
+
46
+ class RuleOverride(Strict):
47
+ action: RuleAction | None = None
48
+ enabled: bool = True
49
+ questions: list[str] = Field(default_factory=list)
50
+
51
+
52
+ class Packs(Strict):
53
+ enabled: list[str] = Field(default_factory=lambda: ["shell", "git", "egress", "persistence", "secrets", "packages"])
54
+ overrides: dict[str, RuleOverride] = Field(default_factory=dict)
55
+
56
+
57
+ class McpPolicy(Strict):
58
+ """Per MCP server / tool actions. Keys are `server/tool` glob patterns (`github/*`, `*/delete_*`, `github`);
59
+ the most specific match wins (a tool-level pattern beats a server-wide one, exact beats glob, longer beats
60
+ shorter). `default` applies when nothing matches."""
61
+ tools: dict[str, Literal["allow", "ask", "block"]] = Field(default_factory=dict)
62
+ default: Literal["allow", "ask", "block"] = "allow"
63
+
64
+
65
+ class FlowPolicy(Strict):
66
+ """Toxic-flow detection (det/flow.py): private data + untrusted content + an exfiltration-capable call in one session.
67
+ Source and sink patterns are `server/tool` globs over MCP tools; built-in tools are classified by the engine."""
68
+ enabled: bool = True
69
+ action: Literal["block", "ask", "flag"] = "ask"
70
+ private_sources: list[str] = Field(default_factory=lambda: [
71
+ "*mail*/*", "*gmail*/*", "*drive*/*", "*postgres*/*", "*mysql*/*", "*sql*/query*", "*notion*/*", "*slack*/read*",
72
+ "*/get_secret*", "*secrets*/*", "*vault*/*"])
73
+ untrusted_sources: list[str] = Field(default_factory=lambda: [
74
+ "*fetch*/*", "*browser*/*", "*puppeteer*/*", "*playwright*/*", "*search*/*", "*web*/*", "*/fetch*", "*/scrape*",
75
+ "github/get_issue*", "github/list_issues*", "github/get_pull_request*", "*/read_email*", "*mail*/get*"])
76
+ sinks: list[str] = Field(default_factory=lambda: [
77
+ "*/send*", "*/post*", "*/create*", "*/upload*", "*/comment*", "*/reply*", "*/email*", "*/message*", "*/push*",
78
+ "*/publish*", "*/share*", "*/write*", "*/update*", "*fetch*/*"])
79
+
80
+
81
+ class Mandate(Strict):
82
+ cedar: str | None = None # path to a .cedar file, relative to the policy file
83
+ mode: Literal["off", "log_only", "enforce"] = "off"
84
+ inline: str | None = None # Cedar text, for tests and small policies
85
+
86
+
87
+ class JudgeBackend(Strict):
88
+ type: Literal["strands_decider", "replay", "none"] = "none"
89
+ transport: Literal["http", "sagemaker"] = "http"
90
+ base_url: str = "http://127.0.0.1:8000"
91
+ checkpoint: str = "StrandsAgents/strands-decider-2B-hobson-v21"
92
+ sagemaker_endpoint: str | None = None
93
+ timeout_ms: int = 700
94
+ local_command: list[str] = Field(default_factory=list) # daemon starts and supervises the server when set
95
+
96
+
97
+ class LocalProbeConfig(Strict):
98
+ """Vendor-neutral injection detector (judges/local_probe.py): heuristics always, a classifier when configured."""
99
+ enabled: bool = True
100
+ threshold: float = 0.6
101
+ classifier_model: str | None = None # e.g. meta-llama/Llama-Prompt-Guard-2-86M; needs the `promptguard` extra
102
+ classifier_threshold: float = 0.8
103
+
104
+
105
+ class ProbeConfig(Strict):
106
+ enabled: bool = False # Amazon Bedrock Guardrails (needs AWS); the local detector is `local`
107
+ local: LocalProbeConfig = Field(default_factory=LocalProbeConfig)
108
+ prompt_attack: list[str] = Field(default_factory=lambda: ["PROMPT_INJECTION", "JAILBREAK"])
109
+ entities: list[str] = Field(default_factory=lambda: ["AWS_ACCESS_KEY", "AWS_SECRET_KEY", "PASSWORD"])
110
+ injection_threshold: float = 0.6
111
+ timeout_ms: int = 1500
112
+
113
+
114
+ class Bands(Strict):
115
+ """Thresholds on judge answers (design §4.7). Wide ask band until recalibration on the M3 corpus."""
116
+ intent_block_below: float = 0.20 # intent_authorized below this, with harm >= harm_block_min -> block
117
+ harm_block_min: int = 2
118
+ intent_ask_below: float = 0.65 # intent_authorized below this -> ask
119
+ intent_allow_at: float = 0.65
120
+ exfil_ask_at: float = 0.50
121
+ confirm_allow_at: float = 0.80 # harnesses without a native ask: the user's confirmation in the transcript, judged
122
+ injection_warn_at: float = 0.50 # post-action: Decider injection_present or Guardrails severity
123
+ injection_withhold_at: float = 0.90
124
+ max_intent_state_tokens: int = 1000
125
+ max_untrusted_tokens: int = 3000
126
+
127
+
128
+ class Judge(Strict):
129
+ primary: JudgeBackend = Field(default_factory=JudgeBackend)
130
+ probe: ProbeConfig = Field(default_factory=ProbeConfig)
131
+ bands: Bands = Field(default_factory=Bands)
132
+ ask_intent_for_model_rules: bool = True # rules routed to `model` consult the judge when a backend is configured
133
+
134
+
135
+ class DenialBudget(Strict):
136
+ consecutive: int = 3
137
+ total: int = 20
138
+ then: Literal["ask", "block"] = "ask"
139
+
140
+
141
+ class TestThresholds(Strict):
142
+ """Gates for `interlock test` (the deterministic corpus runner).
143
+
144
+ FPR = benign cases wrongly escalated/blocked; FNR = attack cases wrongly allowed.
145
+ `categories` overrides the global gate per OGR category (longest-prefix match), e.g.
146
+ `security.malicious_command: {max_fnr: 0.0}` to forbid any miss on malicious commands.
147
+ """
148
+ max_fpr: float = 0.01
149
+ max_fnr: float = 0.10
150
+ categories: dict[str, dict[str, float]] = Field(default_factory=dict)
151
+
152
+
153
+ class Policy(Strict):
154
+ version: int = 1
155
+ workspace: str = ""
156
+ mode: Literal["shadow", "enforce"] = "shadow"
157
+ profile: Literal["laptop", "team"] = "laptop"
158
+ trust: Trust = Field(default_factory=Trust)
159
+ packs: Packs = Field(default_factory=Packs)
160
+ mcp: McpPolicy = Field(default_factory=McpPolicy)
161
+ flow: FlowPolicy = Field(default_factory=FlowPolicy)
162
+ mandate: Mandate = Field(default_factory=Mandate)
163
+ denial_budget: DenialBudget = Field(default_factory=DenialBudget)
164
+ judge: Judge = Field(default_factory=Judge)
165
+ tests: TestThresholds = Field(default_factory=TestThresholds)
166
+ fail_mode: dict[str, str] = Field(default_factory=lambda: {
167
+ "security.data_exfiltration": "closed", "security.malicious_command": "closed", "security.persistence": "closed",
168
+ "security.secret_leak.*": "closed", "security.privilege_escalation": "closed", "default": "open",
169
+ })
170
+ source_paths: list[str] = Field(default_factory=list) # filled by the loader, not user-set
171
+ sha256: str = "" # filled by the loader
172
+
173
+ def rule_action(self, rule_id: str, default: RuleAction) -> RuleAction | None:
174
+ """None means the rule is disabled."""
175
+ ov = self.packs.overrides.get(rule_id)
176
+ if ov is None:
177
+ return default
178
+ if not ov.enabled:
179
+ return None
180
+ return ov.action or default
181
+
182
+
183
+ def _merge(base: dict[str, Any], over: dict[str, Any]) -> dict[str, Any]:
184
+ out = dict(base)
185
+ for k, v in over.items():
186
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
187
+ out[k] = {**out[k], **v}
188
+ else:
189
+ out[k] = v
190
+ return out
191
+
192
+
193
+ def load_policy(workspace_root: str | Path | None, home: Path | None = None) -> Policy:
194
+ layers: list[tuple[Path, dict[str, Any]]] = []
195
+ candidates = []
196
+ if home is not None:
197
+ candidates.append(Path(home).expanduser() / "policy.yaml")
198
+ if workspace_root is not None:
199
+ candidates.append(Path(workspace_root).expanduser() / ".interlock" / "policy.yaml")
200
+ merged: dict[str, Any] = {}
201
+ for p in candidates:
202
+ if p.exists():
203
+ data = yaml.safe_load(p.read_text()) or {}
204
+ if not isinstance(data, dict):
205
+ raise ValueError(f"{p}: policy must be a mapping")
206
+ data.pop("source_paths", None)
207
+ data.pop("sha256", None)
208
+ layers.append((p, data))
209
+ merged = _merge(merged, data)
210
+ pol = Policy.model_validate(merged)
211
+ pol.source_paths = [str(p) for p, _ in layers]
212
+ if pol.mandate.cedar and layers:
213
+ base_dir = layers[-1][0].parent
214
+ pol.mandate.cedar = str((base_dir / pol.mandate.cedar).resolve())
215
+ h = hashlib.sha256()
216
+ h.update(pol.model_dump_json(exclude={"source_paths", "sha256"}).encode())
217
+ if pol.mandate.cedar and Path(pol.mandate.cedar).exists():
218
+ h.update(Path(pol.mandate.cedar).read_bytes())
219
+ pol.sha256 = h.hexdigest()
220
+ return pol