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.
- interlockd/__init__.py +3 -0
- interlockd/__main__.py +7 -0
- interlockd/aibom.py +136 -0
- interlockd/api.py +307 -0
- interlockd/auth.py +38 -0
- interlockd/calibration.py +260 -0
- interlockd/cli.py +1920 -0
- interlockd/compose.py +84 -0
- interlockd/config.py +80 -0
- interlockd/corpus.py +505 -0
- interlockd/data/packs/_corpus.yaml +7 -0
- interlockd/data/packs/egress.jsonl +38 -0
- interlockd/data/packs/fileops.jsonl +20 -0
- interlockd/data/packs/git.jsonl +51 -0
- interlockd/data/packs/packages.jsonl +24 -0
- interlockd/data/packs/persistence.jsonl +31 -0
- interlockd/data/packs/secrets.jsonl +22 -0
- interlockd/data/packs/shell.jsonl +75 -0
- interlockd/det/__init__.py +53 -0
- interlockd/det/describe.py +66 -0
- interlockd/det/engine.py +153 -0
- interlockd/det/flow.py +106 -0
- interlockd/det/mandate.py +108 -0
- interlockd/det/mcp.py +70 -0
- interlockd/det/memory.py +50 -0
- interlockd/det/packs.py +531 -0
- interlockd/det/shellparse.py +438 -0
- interlockd/egress.py +124 -0
- interlockd/gateway.py +350 -0
- interlockd/ids.py +25 -0
- interlockd/insights.py +316 -0
- interlockd/integrations/__init__.py +2 -0
- interlockd/integrations/client.py +85 -0
- interlockd/integrations/langchain.py +59 -0
- interlockd/integrations/openai_agents.py +72 -0
- interlockd/integrations/strands.py +69 -0
- interlockd/judges/__init__.py +107 -0
- interlockd/judges/guardrails.py +94 -0
- interlockd/judges/local_probe.py +192 -0
- interlockd/judges/projector.py +82 -0
- interlockd/judges/strands_decider.py +160 -0
- interlockd/ledger.py +62 -0
- interlockd/mcp_http.py +216 -0
- interlockd/mcp_proxy.py +315 -0
- interlockd/mcp_server.py +211 -0
- interlockd/models.py +98 -0
- interlockd/normalize/__init__.py +1 -0
- interlockd/normalize/claude_code.py +89 -0
- interlockd/normalize/cursor.py +59 -0
- interlockd/normalize/gemini_cli.py +55 -0
- interlockd/normalize/kiro.py +65 -0
- interlockd/normalize/more.py +98 -0
- interlockd/ogr.py +542 -0
- interlockd/onboard.py +300 -0
- interlockd/otel.py +139 -0
- interlockd/paths.py +63 -0
- interlockd/pipeline.py +370 -0
- interlockd/policy/__init__.py +220 -0
- interlockd/policy_lint.py +76 -0
- interlockd/render.py +142 -0
- interlockd/sandbox.py +87 -0
- interlockd/scan.py +405 -0
- interlockd/schema/verdict.schema.json +68 -0
- interlockd/selftest.py +101 -0
- interlockd/service.py +247 -0
- interlockd/shims/claude-code/interlock-hook.sh +113 -0
- interlockd/shims/kiro/interlock-hook.mjs +101 -0
- interlockd/shims/kiro/interlock-hook.sh +82 -0
- interlockd/shims/opencode/interlock.js +60 -0
- interlockd/store/__init__.py +22 -0
- interlockd/store/drainer.py +74 -0
- interlockd/store/dynamodb.py +144 -0
- interlockd/store/memory.py +28 -0
- interlockd/store/spool.py +159 -0
- interlockd/testkit.py +401 -0
- interlockd/tiers.py +95 -0
- interlockd/transcript/__init__.py +1 -0
- interlockd/transcript/claude_code.py +73 -0
- interlockd/transcript/kiro.py +102 -0
- interlockd-0.1.0.dist-info/METADATA +125 -0
- interlockd-0.1.0.dist-info/RECORD +84 -0
- interlockd-0.1.0.dist-info/WHEEL +4 -0
- interlockd-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|