delimit-cli 4.4.0 → 4.5.1
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.
- package/CHANGELOG.md +70 -0
- package/adapters/cursor-rules.js +17 -4
- package/gateway/ai/backends/memory_bridge.py +218 -3
- package/gateway/ai/backends/tools_infra.py +10 -3
- package/gateway/ai/content_grounding/consume.py +1 -1
- package/gateway/ai/inbox_drafts/__init__.py +61 -0
- package/gateway/ai/inbox_drafts/registry.py +412 -0
- package/gateway/ai/inbox_drafts/schema.py +374 -0
- package/gateway/ai/inbox_executor.py +565 -0
- package/gateway/ai/ledger_manager.py +1474 -23
- package/gateway/ai/server.py +424 -9
- package/gateway/core/diff_engine_v2.py +45 -10
- package/gateway/core/zero_spec/express_extractor.py +1 -1
- package/lib/managed-section.js +92 -0
- package/lib/trust-page-engine.js +6 -2
- package/lib/wrap-engine.js +21 -4
- package/package.json +1 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Draft schema, canonicalization, and HMAC primitives — LED-1129 Phase 1.
|
|
2
|
+
|
|
3
|
+
This is the foundation layer. Other layers (registry, executor) build on top.
|
|
4
|
+
|
|
5
|
+
Key invariants:
|
|
6
|
+
|
|
7
|
+
- Every draft gets a ULID `draft_id` (sortable, unique, NOT derived from content).
|
|
8
|
+
- Every draft is signed with HMAC-SHA256 over a canonical byte representation
|
|
9
|
+
of (draft_id, draft_kind, target, payload, issued_at, key_version) — the HMAC
|
|
10
|
+
scope binds a signature to one concrete instance, so subject-line collisions
|
|
11
|
+
or thread reuse cannot replay an approval against a different draft.
|
|
12
|
+
- Canonical JSON form is RFC 8785 (sorted keys, no whitespace, UTF-8 NFC).
|
|
13
|
+
Written down here AND in docs/inbox_executor_v1.md so the daemon writer
|
|
14
|
+
and the executor reader cannot silently disagree on bytes.
|
|
15
|
+
- HMAC key lives at ~/.delimit/secrets/inbox-executor-hmac.key (mode 600).
|
|
16
|
+
Distinct from wrap-hmac.key — separation of concerns. Auto-generated on
|
|
17
|
+
first sign call if missing.
|
|
18
|
+
- Schema is versioned via `schema_version` field. v1 is the only version;
|
|
19
|
+
future migrations bump it.
|
|
20
|
+
- Keys are versioned via `key_version`. Rotation issues a new version side
|
|
21
|
+
by side; verifier tries the registered version first.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import enum
|
|
27
|
+
import hashlib
|
|
28
|
+
import hmac
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import secrets
|
|
32
|
+
import time
|
|
33
|
+
import unicodedata
|
|
34
|
+
import uuid
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Dict, Optional, Tuple
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SCHEMA_VERSION = "v1"
|
|
41
|
+
HMAC_KEY_PATH = Path.home() / ".delimit" / "secrets" / "inbox-executor-hmac.key"
|
|
42
|
+
DEFAULT_TTL_SECONDS = 24 * 60 * 60 # 24h per the LED-1129 deliberation
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DraftKind(str, enum.Enum):
|
|
46
|
+
"""Allowlisted action types the executor will dispatch on (Phase 2+).
|
|
47
|
+
|
|
48
|
+
Anything outside this enum terminates at `founder_directive_acked` and
|
|
49
|
+
requires a Claude session to execute. Adding a new kind is itself an
|
|
50
|
+
authority_class_expansion event under STR-183 — needs founder attestation.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
GITHUB_COMMENT = "github_comment"
|
|
54
|
+
SOCIAL_POST = "social_post"
|
|
55
|
+
LEDGER_DONE = "ledger_done"
|
|
56
|
+
NOTIFY_ROUTING_UPDATE = "notify_routing_update"
|
|
57
|
+
DEPLOY_PUBLISH_PREVALIDATED_ARTIFACT = "deploy_publish_prevalidated_artifact"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DraftStatus(str, enum.Enum):
|
|
61
|
+
"""State machine for a single draft.
|
|
62
|
+
|
|
63
|
+
Transitions (the only legal ones):
|
|
64
|
+
pending → approved (HMAC verified + founder Ship-it match)
|
|
65
|
+
pending → expired (TTL elapsed)
|
|
66
|
+
pending → cancelled (founder reply detected as negative/cancel)
|
|
67
|
+
approved → executing (executor takes the row, before side effect)
|
|
68
|
+
executing → completed (executor finished + recorded executed_url)
|
|
69
|
+
executing → completed_with_error (executor finished but action failed)
|
|
70
|
+
* → terminal_unrecoverable (only set by human reconciliation)
|
|
71
|
+
|
|
72
|
+
A row stuck at `executing` after a process restart surfaces for human
|
|
73
|
+
reconciliation — we do NOT auto-retry. That's at-most-once semantics.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
PENDING = "pending"
|
|
77
|
+
APPROVED = "approved"
|
|
78
|
+
EXECUTING = "executing"
|
|
79
|
+
COMPLETED = "completed"
|
|
80
|
+
COMPLETED_WITH_ERROR = "completed_with_error"
|
|
81
|
+
EXPIRED = "expired"
|
|
82
|
+
CANCELLED = "cancelled"
|
|
83
|
+
TERMINAL_UNRECOVERABLE = "terminal_unrecoverable"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── ULID ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
# A minimal monotonic ULID generator. We do not pull a third-party dep;
|
|
89
|
+
# the gateway has no ulid package and this is the only call site.
|
|
90
|
+
# 26 chars: 10 char timestamp (ms since epoch, base32) + 16 chars randomness.
|
|
91
|
+
# Sortable lexicographically by time. NOT content-derived (that would defeat
|
|
92
|
+
# the purpose — we want monotonic IDs for index locality, content goes in
|
|
93
|
+
# the HMAC scope separately).
|
|
94
|
+
_ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford's base32
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def new_draft_id() -> str:
|
|
98
|
+
"""Return a new ULID (26 chars, monotonic by time, urlsafe).
|
|
99
|
+
|
|
100
|
+
Not cryptographically derived from content — content binding lives in
|
|
101
|
+
the HMAC. The ID is just an instance identifier with index-locality
|
|
102
|
+
properties.
|
|
103
|
+
"""
|
|
104
|
+
ts_ms = int(time.time() * 1000)
|
|
105
|
+
ts_part = ""
|
|
106
|
+
n = ts_ms
|
|
107
|
+
for _ in range(10):
|
|
108
|
+
ts_part = _ULID_ALPHABET[n & 0x1F] + ts_part
|
|
109
|
+
n >>= 5
|
|
110
|
+
|
|
111
|
+
rand_bytes = secrets.token_bytes(10)
|
|
112
|
+
rand_part = ""
|
|
113
|
+
n = int.from_bytes(rand_bytes, "big")
|
|
114
|
+
for _ in range(16):
|
|
115
|
+
rand_part = _ULID_ALPHABET[n & 0x1F] + rand_part
|
|
116
|
+
n >>= 5
|
|
117
|
+
|
|
118
|
+
return ts_part + rand_part
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Canonical JSON (RFC 8785-style) ───────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def canonicalize(value: Any) -> bytes:
|
|
125
|
+
"""Return the canonical byte representation of `value`.
|
|
126
|
+
|
|
127
|
+
Per RFC 8785 / JSON Canonicalization Scheme, with simplifications:
|
|
128
|
+
- Object keys sorted lexicographically (string sort, not byte sort).
|
|
129
|
+
- No whitespace between tokens.
|
|
130
|
+
- Strings normalized to Unicode NFC before serialization (so the same
|
|
131
|
+
visual character always hashes the same regardless of input form).
|
|
132
|
+
- Numbers serialized via Python's `json.dumps` default; no scientific
|
|
133
|
+
notation reformatting (callers should pass ints/floats already in the
|
|
134
|
+
form they want signed).
|
|
135
|
+
- Booleans, None, lists pass through.
|
|
136
|
+
|
|
137
|
+
The function is deterministic: feeding the same Python value at any time
|
|
138
|
+
on any machine returns the same bytes. That's the only contract that
|
|
139
|
+
matters for HMAC parity between daemon and executor.
|
|
140
|
+
|
|
141
|
+
LIMITATION (documented, not fixed in v1): Python's float→str varies in
|
|
142
|
+
the lowest bits between platforms. Phase 1 callers MUST stringify floats
|
|
143
|
+
upstream if they need cross-host parity. We do not currently sign
|
|
144
|
+
payloads with floats, but the spec doc warns about it.
|
|
145
|
+
"""
|
|
146
|
+
return _canon(value).encode("utf-8")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _canon(v: Any) -> str:
|
|
150
|
+
if v is None:
|
|
151
|
+
return "null"
|
|
152
|
+
if v is True:
|
|
153
|
+
return "true"
|
|
154
|
+
if v is False:
|
|
155
|
+
return "false"
|
|
156
|
+
if isinstance(v, (int,)) and not isinstance(v, bool):
|
|
157
|
+
return str(v)
|
|
158
|
+
if isinstance(v, float):
|
|
159
|
+
# See LIMITATION above. We use repr to keep round-trip stable on
|
|
160
|
+
# the local host, but cross-host parity is not guaranteed for floats.
|
|
161
|
+
return repr(v)
|
|
162
|
+
if isinstance(v, str):
|
|
163
|
+
return json.dumps(unicodedata.normalize("NFC", v), ensure_ascii=False)
|
|
164
|
+
if isinstance(v, (list, tuple)):
|
|
165
|
+
return "[" + ",".join(_canon(x) for x in v) + "]"
|
|
166
|
+
if isinstance(v, dict):
|
|
167
|
+
items = sorted(v.items(), key=lambda kv: kv[0])
|
|
168
|
+
return "{" + ",".join(
|
|
169
|
+
json.dumps(unicodedata.normalize("NFC", k), ensure_ascii=False) + ":" + _canon(val)
|
|
170
|
+
for k, val in items
|
|
171
|
+
) + "}"
|
|
172
|
+
raise TypeError(f"canonicalize: unsupported type {type(v).__name__}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── Content hash ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def content_hash(
|
|
179
|
+
draft_id: str,
|
|
180
|
+
draft_kind: str,
|
|
181
|
+
target: Dict[str, Any],
|
|
182
|
+
payload: Any,
|
|
183
|
+
issued_at: int,
|
|
184
|
+
key_version: int = 1,
|
|
185
|
+
schema_version: str = SCHEMA_VERSION,
|
|
186
|
+
) -> str:
|
|
187
|
+
"""SHA-256 hex digest binding all signed inputs together.
|
|
188
|
+
|
|
189
|
+
Includes draft_id (instance binding), kind (semantic binding), target
|
|
190
|
+
(where the action lands), payload (what the action does), issued_at
|
|
191
|
+
(anti-replay anchor), and the key + schema versions (rotation safety).
|
|
192
|
+
|
|
193
|
+
Returned hex string is what the HMAC key signs.
|
|
194
|
+
"""
|
|
195
|
+
blob = canonicalize({
|
|
196
|
+
"draft_id": draft_id,
|
|
197
|
+
"draft_kind": draft_kind,
|
|
198
|
+
"target": target,
|
|
199
|
+
"payload": payload,
|
|
200
|
+
"issued_at": issued_at,
|
|
201
|
+
"key_version": key_version,
|
|
202
|
+
"schema_version": schema_version,
|
|
203
|
+
})
|
|
204
|
+
return hashlib.sha256(blob).hexdigest()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── HMAC key management ───────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _ensure_key(path: Optional[Path] = None) -> bytes:
|
|
211
|
+
"""Read the HMAC key, generating it on first call if missing.
|
|
212
|
+
|
|
213
|
+
Mode 600 is enforced so other system users can't read it. Key is 32
|
|
214
|
+
random bytes (256-bit), suitable for HMAC-SHA256.
|
|
215
|
+
|
|
216
|
+
Resolves the default at call time (not as a default-arg) so tests can
|
|
217
|
+
monkeypatch `ai.inbox_drafts.schema.HMAC_KEY_PATH` and have the change
|
|
218
|
+
propagate.
|
|
219
|
+
"""
|
|
220
|
+
if path is None:
|
|
221
|
+
# Re-read module attribute each call; tests can monkeypatch.
|
|
222
|
+
import ai.inbox_drafts.schema as _self
|
|
223
|
+
path = _self.HMAC_KEY_PATH
|
|
224
|
+
if path.exists():
|
|
225
|
+
return path.read_bytes()
|
|
226
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
key = secrets.token_bytes(32)
|
|
228
|
+
path.write_bytes(key)
|
|
229
|
+
try:
|
|
230
|
+
os.chmod(path, 0o600)
|
|
231
|
+
except OSError:
|
|
232
|
+
# Best-effort on filesystems that don't support chmod (Windows).
|
|
233
|
+
pass
|
|
234
|
+
return key
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── Draft sign / verify ───────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class SignedDraft:
|
|
242
|
+
"""The signed-draft record stored in the registry.
|
|
243
|
+
|
|
244
|
+
All fields are part of the HMAC scope (see content_hash). Mutating any
|
|
245
|
+
field after signing invalidates the signature; the verifier rejects.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
draft_id: str
|
|
249
|
+
draft_kind: str
|
|
250
|
+
target: Dict[str, Any]
|
|
251
|
+
payload: Any
|
|
252
|
+
issued_at: int
|
|
253
|
+
key_version: int
|
|
254
|
+
schema_version: str
|
|
255
|
+
content_hash: str
|
|
256
|
+
signature: str
|
|
257
|
+
|
|
258
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
259
|
+
return {
|
|
260
|
+
"draft_id": self.draft_id,
|
|
261
|
+
"draft_kind": self.draft_kind,
|
|
262
|
+
"target": self.target,
|
|
263
|
+
"payload": self.payload,
|
|
264
|
+
"issued_at": self.issued_at,
|
|
265
|
+
"key_version": self.key_version,
|
|
266
|
+
"schema_version": self.schema_version,
|
|
267
|
+
"content_hash": self.content_hash,
|
|
268
|
+
"signature": self.signature,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sign_draft(
|
|
273
|
+
draft_kind: str,
|
|
274
|
+
target: Dict[str, Any],
|
|
275
|
+
payload: Any,
|
|
276
|
+
*,
|
|
277
|
+
draft_id: Optional[str] = None,
|
|
278
|
+
issued_at: Optional[int] = None,
|
|
279
|
+
key_version: int = 1,
|
|
280
|
+
key_path: Optional[Path] = None,
|
|
281
|
+
) -> SignedDraft:
|
|
282
|
+
"""Create a SignedDraft for a new action proposal.
|
|
283
|
+
|
|
284
|
+
`draft_kind` should be one of DraftKind values; we don't enforce here
|
|
285
|
+
so the schema layer stays stringly-typed (the registry/executor enforce
|
|
286
|
+
the allowlist with proper errors at their boundaries).
|
|
287
|
+
"""
|
|
288
|
+
if draft_id is None:
|
|
289
|
+
draft_id = new_draft_id()
|
|
290
|
+
if issued_at is None:
|
|
291
|
+
issued_at = int(time.time())
|
|
292
|
+
|
|
293
|
+
digest = content_hash(
|
|
294
|
+
draft_id=draft_id,
|
|
295
|
+
draft_kind=draft_kind,
|
|
296
|
+
target=target,
|
|
297
|
+
payload=payload,
|
|
298
|
+
issued_at=issued_at,
|
|
299
|
+
key_version=key_version,
|
|
300
|
+
schema_version=SCHEMA_VERSION,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
key = _ensure_key(key_path) if key_path else _ensure_key()
|
|
304
|
+
sig = hmac.new(key, digest.encode("ascii"), hashlib.sha256).hexdigest()
|
|
305
|
+
|
|
306
|
+
return SignedDraft(
|
|
307
|
+
draft_id=draft_id,
|
|
308
|
+
draft_kind=draft_kind,
|
|
309
|
+
target=target,
|
|
310
|
+
payload=payload,
|
|
311
|
+
issued_at=issued_at,
|
|
312
|
+
key_version=key_version,
|
|
313
|
+
schema_version=SCHEMA_VERSION,
|
|
314
|
+
content_hash=digest,
|
|
315
|
+
signature=sig,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def verify_draft(
|
|
320
|
+
record: Dict[str, Any],
|
|
321
|
+
*,
|
|
322
|
+
now: Optional[int] = None,
|
|
323
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
324
|
+
key_path: Optional[Path] = None,
|
|
325
|
+
) -> Tuple[bool, str]:
|
|
326
|
+
"""Verify a stored draft record. Returns (ok, reason).
|
|
327
|
+
|
|
328
|
+
Checks (fail-closed; first failure short-circuits):
|
|
329
|
+
- schema_version is recognized
|
|
330
|
+
- all required fields present
|
|
331
|
+
- issued_at within TTL window (not stale, not in the future)
|
|
332
|
+
- content_hash matches recomputed hash from fields
|
|
333
|
+
- HMAC signature matches recomputed signature using key for key_version
|
|
334
|
+
|
|
335
|
+
Phase 1 only supports key_version=1 (the only key extant). Phase 2
|
|
336
|
+
adds the rotation lookup table.
|
|
337
|
+
"""
|
|
338
|
+
required = {"draft_id", "draft_kind", "target", "payload", "issued_at",
|
|
339
|
+
"key_version", "schema_version", "content_hash", "signature"}
|
|
340
|
+
missing = required - set(record.keys())
|
|
341
|
+
if missing:
|
|
342
|
+
return False, f"missing fields: {sorted(missing)}"
|
|
343
|
+
|
|
344
|
+
if record["schema_version"] != SCHEMA_VERSION:
|
|
345
|
+
return False, f"unsupported schema_version: {record['schema_version']}"
|
|
346
|
+
|
|
347
|
+
if record["key_version"] != 1:
|
|
348
|
+
return False, f"unknown key_version: {record['key_version']}"
|
|
349
|
+
|
|
350
|
+
if now is None:
|
|
351
|
+
now = int(time.time())
|
|
352
|
+
if record["issued_at"] > now + 60: # 60s clock skew tolerance
|
|
353
|
+
return False, "issued_at is in the future"
|
|
354
|
+
if now - record["issued_at"] > ttl_seconds:
|
|
355
|
+
return False, "draft expired (TTL elapsed)"
|
|
356
|
+
|
|
357
|
+
expected_hash = content_hash(
|
|
358
|
+
draft_id=record["draft_id"],
|
|
359
|
+
draft_kind=record["draft_kind"],
|
|
360
|
+
target=record["target"],
|
|
361
|
+
payload=record["payload"],
|
|
362
|
+
issued_at=record["issued_at"],
|
|
363
|
+
key_version=record["key_version"],
|
|
364
|
+
schema_version=record["schema_version"],
|
|
365
|
+
)
|
|
366
|
+
if not hmac.compare_digest(expected_hash, record["content_hash"]):
|
|
367
|
+
return False, "content_hash mismatch (record was tampered)"
|
|
368
|
+
|
|
369
|
+
key = _ensure_key(key_path) if key_path else _ensure_key()
|
|
370
|
+
expected_sig = hmac.new(key, expected_hash.encode("ascii"), hashlib.sha256).hexdigest()
|
|
371
|
+
if not hmac.compare_digest(expected_sig, record["signature"]):
|
|
372
|
+
return False, "signature mismatch (HMAC failed)"
|
|
373
|
+
|
|
374
|
+
return True, "ok"
|