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
@@ -0,0 +1,74 @@
1
+ """Background drainer: moves undrained spool records to the repository in batches, off the decision path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from typing import Any
8
+
9
+ from . import Repository
10
+ from .spool import Spool
11
+
12
+ log = logging.getLogger("interlockd.drainer")
13
+
14
+
15
+ class Drainer:
16
+ def __init__(self, spool: Spool, repo: Repository, *, interval_s: float = 2.0, batch: int = 25):
17
+ self.spool, self.repo, self.interval, self.batch = spool, repo, interval_s, batch
18
+ self._stop = threading.Event()
19
+ self._thread: threading.Thread | None = None
20
+ self.drained_total = 0
21
+ self.last_error: str | None = None
22
+
23
+ def drain_once(self) -> int:
24
+ n = 0
25
+ pending: list[tuple[Any, int, dict[str, Any]]] = []
26
+
27
+ def flush() -> None:
28
+ nonlocal n
29
+ if not pending:
30
+ return
31
+ records = [r for _, _, r in pending]
32
+ if hasattr(self.repo, "put_events"):
33
+ self.repo.put_events(records) # type: ignore[attr-defined]
34
+ else:
35
+ for r in records:
36
+ self.repo.put_event(r)
37
+ seg, off, _ = pending[-1]
38
+ self.spool.mark_drained(seg, off)
39
+ n += len(pending)
40
+ pending.clear()
41
+
42
+ current_seg = None
43
+ for seg, off, rec in self.spool.undrained():
44
+ if current_seg is not None and seg != current_seg:
45
+ flush()
46
+ current_seg = seg
47
+ pending.append((seg, off, rec))
48
+ if len(pending) >= self.batch:
49
+ flush()
50
+ flush()
51
+ self.drained_total += n
52
+ return n
53
+
54
+ def _run(self) -> None:
55
+ backoff = self.interval
56
+ while not self._stop.is_set():
57
+ try:
58
+ self.drain_once()
59
+ self.last_error = None
60
+ backoff = self.interval
61
+ except Exception as e: # noqa: BLE001 boundary: network, credentials, throttling; keep the spool, retry
62
+ self.last_error = f"{type(e).__name__}: {e}"
63
+ log.warning("drain failed, will retry: %s", self.last_error)
64
+ backoff = min(backoff * 2, 60.0)
65
+ self._stop.wait(backoff)
66
+
67
+ def start(self) -> None:
68
+ self._thread = threading.Thread(target=self._run, name="interlock-drainer", daemon=True)
69
+ self._thread.start()
70
+
71
+ def stop(self) -> None:
72
+ self._stop.set()
73
+ if self._thread:
74
+ self._thread.join(timeout=5)
@@ -0,0 +1,144 @@
1
+ """DynamoDB single-table repository (design §7). boto3 is imported lazily so the daemon runs without it.
2
+
3
+ Items:
4
+ Event PK=EVT#<event_id> SK=EVT
5
+ Explanation PK=EVT#<event_id> SK=EXPL
6
+ Feedback PK=EVT#<event_id> SK=FDBK#<ulid>
7
+ Session summary PK=SES#<session_id> SK=META
8
+ Indexes:
9
+ GSI1-session session_id / event_id
10
+ GSI2-workspace-time GSI2PK (WS#<workspace>#<yyyy-mm-dd-hh>) / event_id
11
+ GSI3-review GSI3PK (DISP#<disposition>#<yyyy-mm-dd>) / event_id (sparse)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import UTC, datetime, timedelta
17
+ from decimal import Decimal
18
+ from typing import Any
19
+
20
+ from . import hour_bucket
21
+
22
+ REVIEW_DISPOSITIONS = {"ask", "block", "withhold", "warn"}
23
+
24
+
25
+ def _to_ddb(value: Any) -> Any:
26
+ if isinstance(value, float):
27
+ return Decimal(str(value))
28
+ if isinstance(value, dict):
29
+ return {k: _to_ddb(v) for k, v in value.items()}
30
+ if isinstance(value, list):
31
+ return [_to_ddb(v) for v in value]
32
+ return value
33
+
34
+
35
+ def _from_ddb(value: Any) -> Any:
36
+ if isinstance(value, Decimal):
37
+ return int(value) if value == value.to_integral_value() else float(value)
38
+ if isinstance(value, dict):
39
+ return {k: _from_ddb(v) for k, v in value.items()}
40
+ if isinstance(value, list):
41
+ return [_from_ddb(v) for v in value]
42
+ return value
43
+
44
+
45
+ TABLE_DEFINITION: dict[str, Any] = {
46
+ "AttributeDefinitions": [
47
+ {"AttributeName": "PK", "AttributeType": "S"},
48
+ {"AttributeName": "SK", "AttributeType": "S"},
49
+ {"AttributeName": "session_id", "AttributeType": "S"},
50
+ {"AttributeName": "event_id", "AttributeType": "S"},
51
+ {"AttributeName": "GSI2PK", "AttributeType": "S"},
52
+ {"AttributeName": "GSI3PK", "AttributeType": "S"},
53
+ ],
54
+ "KeySchema": [{"AttributeName": "PK", "KeyType": "HASH"}, {"AttributeName": "SK", "KeyType": "RANGE"}],
55
+ "BillingMode": "PAY_PER_REQUEST",
56
+ "GlobalSecondaryIndexes": [
57
+ {"IndexName": "GSI1-session", "Projection": {"ProjectionType": "ALL"},
58
+ "KeySchema": [{"AttributeName": "session_id", "KeyType": "HASH"}, {"AttributeName": "event_id", "KeyType": "RANGE"}]},
59
+ {"IndexName": "GSI2-workspace-time", "Projection": {"ProjectionType": "ALL"},
60
+ "KeySchema": [{"AttributeName": "GSI2PK", "KeyType": "HASH"}, {"AttributeName": "event_id", "KeyType": "RANGE"}]},
61
+ {"IndexName": "GSI3-review", "Projection": {"ProjectionType": "ALL"},
62
+ "KeySchema": [{"AttributeName": "GSI3PK", "KeyType": "HASH"}, {"AttributeName": "event_id", "KeyType": "RANGE"}]},
63
+ ],
64
+ }
65
+
66
+
67
+ class DynamoRepository:
68
+ def __init__(self, table_name: str, *, region: str | None = None, profile: str | None = None, retention_days: int = 90, resource=None):
69
+ import boto3 # lazy
70
+
71
+ self.ddb = resource or boto3.Session(profile_name=profile, region_name=region).resource("dynamodb")
72
+ self.table = self.ddb.Table(table_name)
73
+ self.table_name = table_name
74
+ self.retention_days = retention_days
75
+
76
+ @classmethod
77
+ def ensure_table(cls, table_name: str, *, region: str | None = None, resource=None) -> DynamoRepository:
78
+ import boto3 # lazy
79
+
80
+ ddb = resource or boto3.resource("dynamodb", region_name=region)
81
+ existing = [t.name for t in ddb.tables.all()]
82
+ if table_name not in existing:
83
+ t = ddb.create_table(TableName=table_name, **TABLE_DEFINITION)
84
+ t.wait_until_exists()
85
+ ddb.meta.client.update_time_to_live(
86
+ TableName=table_name, TimeToLiveSpecification={"Enabled": True, "AttributeName": "expires_at"}
87
+ )
88
+ return cls(table_name, region=region, resource=ddb)
89
+
90
+ def _event_item(self, record: dict[str, Any]) -> dict[str, Any]:
91
+ item = _to_ddb(dict(record))
92
+ item["PK"] = f"EVT#{record['event_id']}"
93
+ item["SK"] = "EVT"
94
+ ws = record.get("agent_workspace") or ""
95
+ item["GSI2PK"] = f"WS#{ws}#{hour_bucket(record['received_at'])}"
96
+ if record.get("disposition") in REVIEW_DISPOSITIONS:
97
+ item["GSI3PK"] = f"DISP#{record['disposition']}#{record['received_at'][:10]}"
98
+ if "expires_at" not in item:
99
+ exp = datetime.now(UTC) + timedelta(days=self.retention_days)
100
+ item["expires_at"] = int(exp.timestamp())
101
+ return item
102
+
103
+ def put_event(self, record: dict[str, Any]) -> None:
104
+ self.table.put_item(Item=self._event_item(record))
105
+
106
+ def put_events(self, records: list[dict[str, Any]]) -> None:
107
+ with self.table.batch_writer() as bw:
108
+ for r in records:
109
+ bw.put_item(Item=self._event_item(r))
110
+
111
+ def get_event(self, event_id: str) -> dict[str, Any] | None:
112
+ r = self.table.get_item(Key={"PK": f"EVT#{event_id}", "SK": "EVT"})
113
+ item = r.get("Item")
114
+ return _from_ddb(item) if item else None
115
+
116
+ def tail(self, workspace: str | None = None, limit: int = 50, *, hours_back: int = 48) -> list[dict[str, Any]]:
117
+ from boto3.dynamodb.conditions import Key # lazy
118
+
119
+ out: list[dict[str, Any]] = []
120
+ now = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)
121
+ ws = workspace or ""
122
+ for h in range(hours_back):
123
+ bucket = (now - timedelta(hours=h)).strftime("%Y-%m-%d-%H")
124
+ resp = self.table.query(
125
+ IndexName="GSI2-workspace-time",
126
+ KeyConditionExpression=Key("GSI2PK").eq(f"WS#{ws}#{bucket}"),
127
+ ScanIndexForward=False,
128
+ Limit=limit - len(out),
129
+ )
130
+ out.extend(_from_ddb(i) for i in resp.get("Items", []))
131
+ if len(out) >= limit:
132
+ break
133
+ return out[:limit]
134
+
135
+ def put_session(self, summary: dict[str, Any]) -> None:
136
+ item = _to_ddb(dict(summary))
137
+ item["PK"] = f"SES#{summary['session_id']}"
138
+ item["SK"] = "META"
139
+ self.table.put_item(Item=item)
140
+
141
+ def get_session(self, session_id: str) -> dict[str, Any] | None:
142
+ r = self.table.get_item(Key={"PK": f"SES#{session_id}", "SK": "META"})
143
+ item = r.get("Item")
144
+ return _from_ddb(item) if item else None
@@ -0,0 +1,28 @@
1
+ """In-memory repository for tests and the spool-only mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class MemoryRepository:
9
+ def __init__(self) -> None:
10
+ self.events: dict[str, dict[str, Any]] = {}
11
+ self.sessions: dict[str, dict[str, Any]] = {}
12
+
13
+ def put_event(self, record: dict[str, Any]) -> None:
14
+ self.events[record["event_id"]] = record
15
+
16
+ def get_event(self, event_id: str) -> dict[str, Any] | None:
17
+ return self.events.get(event_id)
18
+
19
+ def tail(self, workspace: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
20
+ rows = [r for r in self.events.values() if workspace is None or r.get("agent_workspace") == workspace]
21
+ rows.sort(key=lambda r: r["event_id"], reverse=True)
22
+ return rows[:limit]
23
+
24
+ def put_session(self, summary: dict[str, Any]) -> None:
25
+ self.sessions[summary["session_id"]] = summary
26
+
27
+ def get_session(self, session_id: str) -> dict[str, Any] | None:
28
+ return self.sessions.get(session_id)
@@ -0,0 +1,159 @@
1
+ """Append-only JSONL spool with per-segment drain offsets, hash-chained for tamper evidence.
2
+
3
+ Segments are one file per UTC day. `append` writes one line and fsyncs so the record is durable on the
4
+ host before the verdict is returned. A drainer reads undrained bytes and advances the offset file after
5
+ the remote write is confirmed.
6
+
7
+ Every record carries `prev_hash` and `hash = sha256(prev_hash + canonical JSON of the record without `hash`)`,
8
+ chained across segments, so editing, deleting, reordering, or inserting a record breaks `verify()`. Truncating
9
+ the newest records is only detectable against an anchored head (`interlock audit head`). Records written before
10
+ chaining existed are reported as legacy, not as breaks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import fcntl
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import threading
20
+ from collections.abc import Iterator
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ class Spool:
27
+ def __init__(self, directory: Path, *, fsync: bool = True):
28
+ self.dir = Path(directory)
29
+ self.dir.mkdir(parents=True, exist_ok=True)
30
+ self.fsync = fsync
31
+ self._lock = threading.Lock()
32
+
33
+ def _segment(self, received_at: str | None = None) -> Path:
34
+ day = (received_at or datetime.now(UTC).isoformat())[:10]
35
+ return self.dir / f"{day}.jsonl"
36
+
37
+ def append(self, record: dict[str, Any]) -> None:
38
+ seg = self._segment() # by write time, not received_at: the chain must follow file order
39
+ with self._lock:
40
+ lock_fd = os.open(self.dir / ".lock", os.O_WRONLY | os.O_CREAT, 0o600)
41
+ try:
42
+ fcntl.flock(lock_fd, fcntl.LOCK_EX) # one chain even with several writers on this spool
43
+ rec = {k: v for k, v in record.items() if k not in ("hash", "prev_hash")}
44
+ rec["prev_hash"] = self.head()
45
+ rec["hash"] = record_hash(rec)
46
+ line = json.dumps(rec, separators=(",", ":"), ensure_ascii=False) + "\n"
47
+ fd = os.open(seg, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
48
+ try:
49
+ os.write(fd, line.encode("utf-8"))
50
+ if self.fsync:
51
+ os.fsync(fd)
52
+ finally:
53
+ os.close(fd)
54
+ finally:
55
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
56
+ os.close(lock_fd)
57
+
58
+ def head(self) -> str:
59
+ """Hash of the newest chained record ("genesis" for an empty or legacy-only spool)."""
60
+ for seg in reversed(self.segments()):
61
+ last = _last_line(seg)
62
+ if last:
63
+ try:
64
+ return json.loads(last).get("hash") or GENESIS
65
+ except ValueError:
66
+ return GENESIS
67
+ return GENESIS
68
+
69
+ def verify(self) -> dict[str, Any]:
70
+ """Walk every segment in order. Returns {ok, records, legacy, head, break: {segment, line, reason} | None}."""
71
+ expected, n, legacy = None, 0, 0
72
+ for seg in self.segments():
73
+ for i, raw in enumerate(seg.read_text(encoding="utf-8").splitlines(), 1):
74
+ if not raw.strip():
75
+ continue
76
+ try:
77
+ rec = json.loads(raw)
78
+ except ValueError:
79
+ return _broken(seg, i, "not valid JSON", n, legacy)
80
+ if "hash" not in rec:
81
+ if expected is not None:
82
+ return _broken(seg, i, "unchained record after the chain started (inserted?)", n, legacy)
83
+ legacy += 1
84
+ continue
85
+ prev = rec.get("prev_hash")
86
+ if expected is None and prev != GENESIS and legacy == 0:
87
+ return _broken(seg, i, "first chained record does not start the chain (records removed before it?)", n, legacy)
88
+ if expected is not None and prev != expected:
89
+ return _broken(seg, i, "prev_hash does not match the previous record (removed or reordered?)", n, legacy)
90
+ if record_hash({k: v for k, v in rec.items() if k != "hash"}) != rec["hash"]:
91
+ return _broken(seg, i, "content does not match its hash (edited)", n, legacy)
92
+ expected, n = rec["hash"], n + 1
93
+ return {"ok": True, "records": n, "legacy": legacy, "head": expected or GENESIS, "break": None}
94
+
95
+ def segments(self) -> list[Path]:
96
+ return sorted(self.dir.glob("*.jsonl"))
97
+
98
+ def iter_records(self, *, limit: int | None = None, reverse: bool = True) -> Iterator[dict[str, Any]]:
99
+ n = 0
100
+ segs = self.segments()
101
+ if reverse:
102
+ segs = list(reversed(segs))
103
+ for seg in segs:
104
+ lines = seg.read_text(encoding="utf-8").splitlines()
105
+ if reverse:
106
+ lines = list(reversed(lines))
107
+ for line in lines:
108
+ if not line.strip():
109
+ continue
110
+ yield json.loads(line)
111
+ n += 1
112
+ if limit is not None and n >= limit:
113
+ return
114
+
115
+ def _offset_path(self, seg: Path) -> Path:
116
+ return seg.with_suffix(".drained")
117
+
118
+ def drained_offset(self, seg: Path) -> int:
119
+ p = self._offset_path(seg)
120
+ return int(p.read_text().strip() or 0) if p.exists() else 0
121
+
122
+ def undrained(self) -> Iterator[tuple[Path, int, dict[str, Any]]]:
123
+ """Yield (segment, end_offset_after_record, record) for every record not yet drained."""
124
+ for seg in self.segments():
125
+ start = self.drained_offset(seg)
126
+ with open(seg, "rb") as f:
127
+ f.seek(start)
128
+ pos = start
129
+ for raw in f:
130
+ pos += len(raw)
131
+ if raw.strip():
132
+ yield seg, pos, json.loads(raw.decode("utf-8"))
133
+
134
+ def mark_drained(self, seg: Path, offset: int) -> None:
135
+ tmp = self._offset_path(seg).with_suffix(".drained.tmp")
136
+ tmp.write_text(str(offset))
137
+ os.replace(tmp, self._offset_path(seg))
138
+
139
+
140
+ GENESIS = "genesis"
141
+
142
+
143
+ def record_hash(rec_without_hash: dict[str, Any]) -> str:
144
+ body = json.dumps(rec_without_hash, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
145
+ return hashlib.sha256(f"{rec_without_hash.get('prev_hash', GENESIS)}\n{body}".encode()).hexdigest()
146
+
147
+
148
+ def _last_line(path: Path) -> str | None:
149
+ with open(path, "rb") as f:
150
+ f.seek(0, os.SEEK_END)
151
+ size = f.tell()
152
+ block = min(size, 1 << 20)
153
+ f.seek(size - block)
154
+ lines = [ln for ln in f.read().splitlines() if ln.strip()]
155
+ return lines[-1].decode("utf-8") if lines else None
156
+
157
+
158
+ def _broken(seg: Path, line: int, reason: str, n: int, legacy: int) -> dict[str, Any]:
159
+ return {"ok": False, "records": n, "legacy": legacy, "head": None, "break": {"segment": seg.name, "line": line, "reason": reason}}