protect-mcp 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1: HTTP gateway mode enforces Cedar and emits chained receipts
4
+
5
+ Three fixes found by running the cloud-gateway path end to end before
6
+ documenting it:
7
+
8
+ - Fail-open fixed: in `--http` mode, loaded Cedar policies were never
9
+ attached to the gateway (the stdio path attached them after the HTTP
10
+ branch returned), so a remote gate started with `--enforce` and a Cedar
11
+ policy silently allowed everything. Cedar policies are now attached
12
+ before the child starts, and a forbidden tool call over HTTP is denied.
13
+ - Silent-unsigned fixed: a Cedar-mode gateway never read `signing` from
14
+ protect-mcp.json (only the JSON-policy branch did), so it emitted no
15
+ receipts while looking fully configured. Cedar mode now picks up signing
16
+ and credentials from protect-mcp.json when present and says so on stderr.
17
+ - Gateway receipts now chain: the wrapped-server path (stdio and HTTP)
18
+ threads previousReceiptHash per draft s5.7 and resumes the chain across
19
+ restarts from the log tail, matching the hook server. Verified end to
20
+ end: a cloud-style client over Streamable HTTP, one allow and one deny,
21
+ chained draft-02 receipts, replayed offline with the published
22
+ @veritasacta/verify with zero chain breaks.
23
+
24
+
3
25
  ## 0.10.0: receipts migrate to the draft-02 Acta envelope
4
26
 
27
+ Also in this release, the policy digest becomes a recomputable commitment
28
+ (prompted by public review of opaque policy identifiers):
29
+
30
+ - One normative construction for every engine, `acta-policy-digest-v1`:
31
+ per-file SHA-256s into a sorted `{construction, engine, files}` manifest,
32
+ JCS, SHA-256, emitted as `"sha256:<64 hex>"`. Replaces two divergent
33
+ code-defined preimages that concatenated sources (ambiguous file
34
+ boundaries) and truncated to 16 hex characters with no prefix. Receipts
35
+ now carry the full-strength digest; pre-0.10 digests should be treated
36
+ as opaque labels.
37
+ - `protect-mcp policy digest` prints the digest, the per-file hashes, and
38
+ the recompute rule (`--expect` for CI, `--json` for tooling).
39
+ - `protect-mcp policy publish` writes the `acta.policy-bundle.v1` bundle
40
+ (policy bytes plus preimage spec, addressed by the digest) ready to host
41
+ at `.well-known/acta-policies/<hex>.json`; a verifier confirms which
42
+ policy governed a receipt from the bundle bytes alone, with no
43
+ communication with the issuer.
44
+ - The construction is specified normatively in
45
+ draft-farley-acta-signed-receipts-03 (section 5.8), alongside
46
+ `require_approval` as a decision value.
47
+
5
48
  Receipts are now emitted in the envelope the IETF draft actually specifies
6
49
  (draft-farley-acta-signed-receipts-02): a two-field
7
50
  `{ payload, signature: { alg: "EdDSA", kid, sig } }` envelope, with the
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  meetsMinTier
3
- } from "./chunk-622KQ4KW.mjs";
3
+ } from "./chunk-NQOKPBSQ.mjs";
4
4
  import {
5
5
  checkRateLimit,
6
6
  getToolPolicy,
7
7
  parseRateLimit
8
- } from "./chunk-VWGRAUWI.mjs";
8
+ } from "./chunk-WMIWQ6TS.mjs";
9
9
 
10
10
  // src/simulate.ts
11
11
  import { readFileSync } from "fs";
@@ -1,242 +1,6 @@
1
- import {
2
- computeSbIssuerKid,
3
- createReceiptEnvelope
4
- } from "./chunk-3AVIMUNP.mjs";
5
-
6
- // src/policy.ts
7
- import { createHash } from "crypto";
8
- import { readFileSync } from "fs";
9
- function loadPolicy(path) {
10
- const raw = readFileSync(path, "utf-8");
11
- const parsed = JSON.parse(raw);
12
- if (!parsed.tools || typeof parsed.tools !== "object") {
13
- throw new Error(`Invalid policy file: missing "tools" object in ${path}`);
14
- }
15
- const policy = {
16
- tools: parsed.tools,
17
- default_tier: parsed.default_tier || "unknown",
18
- policy_engine: parsed.policy_engine || "built-in",
19
- ...parsed.external ? { external: parsed.external } : {}
20
- };
21
- const digest = computePolicyDigest(policy);
22
- return {
23
- policy,
24
- digest,
25
- credentials: parsed.credentials,
26
- signing: parsed.signing
27
- };
28
- }
29
- function computePolicyDigest(policy) {
30
- const canonical = JSON.stringify(sortKeysDeep(policy));
31
- return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
32
- }
33
- function sortKeysDeep(obj) {
34
- if (obj === null || typeof obj !== "object") return obj;
35
- if (Array.isArray(obj)) return obj.map(sortKeysDeep);
36
- const sorted = {};
37
- for (const key of Object.keys(obj).sort()) {
38
- sorted[key] = sortKeysDeep(obj[key]);
39
- }
40
- return sorted;
41
- }
42
- function getToolPolicy(toolName, policy) {
43
- if (!policy) {
44
- return { require: "any" };
45
- }
46
- if (policy.tools[toolName]) {
47
- return policy.tools[toolName];
48
- }
49
- if (policy.tools["*"]) {
50
- return policy.tools["*"];
51
- }
52
- return { require: "any" };
53
- }
54
- function parseRateLimit(spec) {
55
- const match = spec.match(/^(\d+)\/(second|minute|hour|day)$/);
56
- if (!match) {
57
- throw new Error(`Invalid rate limit format: "${spec}". Expected "N/unit" (e.g. "5/hour")`);
58
- }
59
- const count = parseInt(match[1], 10);
60
- const unit = match[2];
61
- const windowMs = {
62
- second: 1e3,
63
- minute: 6e4,
64
- hour: 36e5,
65
- day: 864e5
66
- };
67
- return { count, windowMs: windowMs[unit] };
68
- }
69
- function checkRateLimit(key, limit, store) {
70
- const now = Date.now();
71
- const windowStart = now - limit.windowMs;
72
- const timestamps = (store.get(key) || []).filter((t) => t > windowStart);
73
- if (timestamps.length >= limit.count) {
74
- store.set(key, timestamps);
75
- return { allowed: false, remaining: 0 };
76
- }
77
- timestamps.push(now);
78
- store.set(key, timestamps);
79
- return { allowed: true, remaining: limit.count - timestamps.length };
80
- }
81
-
82
- // src/signing.ts
83
- import { readFileSync as readFileSync2, existsSync } from "fs";
84
- var signerState = null;
85
- var signingConfigured = false;
86
- var signingInitError = null;
87
- async function initSigning(config) {
88
- const warnings = [];
89
- signerState = null;
90
- signingConfigured = Boolean(config && config.enabled !== false);
91
- signingInitError = null;
92
- if (!config || config.enabled === false) {
93
- return warnings;
94
- }
95
- if (!config.key_path) {
96
- signingInitError = "signing enabled but key_path is not configured";
97
- warnings.push(`signing: ${signingInitError}`);
98
- return warnings;
99
- }
100
- if (!existsSync(config.key_path)) {
101
- signingInitError = `key file not found at ${config.key_path}`;
102
- warnings.push(`signing: ${signingInitError} \u2014 run "protect-mcp init" to generate`);
103
- return warnings;
104
- }
105
- let keyData;
106
- try {
107
- keyData = JSON.parse(readFileSync2(config.key_path, "utf-8"));
108
- if (!keyData.privateKey || !keyData.publicKey) {
109
- signingInitError = "key file missing privateKey or publicKey fields";
110
- warnings.push(`signing: ${signingInitError}`);
111
- return warnings;
112
- }
113
- } catch (err) {
114
- signingInitError = `failed to load key file: ${err instanceof Error ? err.message : err}`;
115
- warnings.push(`signing: ${signingInitError}`);
116
- return warnings;
117
- }
118
- try {
119
- signerState = {
120
- privateKey: keyData.privateKey,
121
- publicKey: keyData.publicKey,
122
- // kid is opaque per draft-02; existing key files keep their explicit kid,
123
- // and keys without one get the s2.1.1 RECOMMENDED sb:issuer format.
124
- kid: keyData.kid || computeSbIssuerKid(keyData.publicKey),
125
- issuer: config.issuer || keyData.issuer || "protect-mcp"
126
- };
127
- } catch (err) {
128
- signingInitError = `failed to initialize signer: ${err instanceof Error ? err.message : err}`;
129
- warnings.push(`signing: ${signingInitError} \u2014 enforce mode will fail closed`);
130
- }
131
- return warnings;
132
- }
133
- function signDecision(entry, prevReceiptHash) {
134
- const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
135
- if (signingConfigured && signingInitError) {
136
- return {
137
- ok: false,
138
- signed: null,
139
- artifact_type: artifactType,
140
- warning: `signing initialization failed: ${signingInitError}`,
141
- error: signingInitError
142
- };
143
- }
144
- if (signingConfigured && !signerState) {
145
- const error = "signing was configured but no signer is ready";
146
- return {
147
- ok: false,
148
- signed: null,
149
- artifact_type: artifactType,
150
- warning: error,
151
- error
152
- };
153
- }
154
- if (!signerState) {
155
- return { ok: false, signed: null, artifact_type: "none" };
156
- }
157
- try {
158
- const payload = {
159
- // draft-02 s3.1 access-decision fields
160
- type: "protectmcp:decision",
161
- tool_name: entry.tool,
162
- decision: entry.decision,
163
- reason: entry.reason_code,
164
- policy_digest: entry.policy_digest,
165
- // Extension fields (signed alongside the s3.1 core)
166
- scope: entry.request_id,
167
- // request scope
168
- mode: entry.mode,
169
- request_id: entry.request_id,
170
- // Spec version: ties every receipt to the IETF standard
171
- spec: "draft-farley-acta-signed-receipts-02",
172
- // Issuer certification: distinguishes VOPRF-backed receipts from self-signed ones
173
- // - scopeblind:verified = issued via ScopeBlind VOPRF backend (paid tier)
174
- // - self-signed = signed with local Ed25519 key (free tier, protect-mcp default)
175
- // - uncertified = unsigned receipt (shadow mode, no signing configured)
176
- issuer_certification: signerState ? "self-signed" : "uncertified",
177
- // The signer's PUBLIC key, inside the signed payload, so a receipt is
178
- // self-contained: any verifier (including the record viewer, in-browser)
179
- // can check the signature without a side channel. Binding the key inside
180
- // the signature means it cannot be swapped without breaking the signature;
181
- // authenticity (that the key is YOUR gate's) still comes from pinning it.
182
- public_key: signerState.publicKey
183
- };
184
- if (signerState.issuer && signerState.issuer !== signerState.kid) {
185
- payload.issuer_name = signerState.issuer;
186
- }
187
- if (prevReceiptHash) payload.previousReceiptHash = prevReceiptHash;
188
- if (entry.tier) payload.tier = entry.tier;
189
- if (entry.credential_ref) payload.credential_ref = entry.credential_ref;
190
- if (entry.rate_limit_remaining !== void 0) {
191
- payload.rate_limit_remaining = entry.rate_limit_remaining;
192
- }
193
- if (entry.policy_engine) payload.policy_engine = entry.policy_engine;
194
- if (entry.hook_event) payload.hook_event = entry.hook_event;
195
- if (entry.sandbox_state) payload.sandbox_state = entry.sandbox_state;
196
- if (entry.timing) payload.timing = entry.timing;
197
- if (entry.swarm) payload.swarm = entry.swarm;
198
- if (entry.payload_digest) payload.payload_digest = entry.payload_digest;
199
- if (entry.enrichment) payload.enrichment = entry.enrichment;
200
- if (entry.action_readback) payload.action_readback = entry.action_readback;
201
- if (entry.deny_iteration) payload.deny_iteration = entry.deny_iteration;
202
- const result = createReceiptEnvelope(
203
- payload,
204
- signerState.privateKey,
205
- signerState.kid,
206
- Number.isFinite(entry.timestamp) ? new Date(entry.timestamp).toISOString() : void 0
207
- );
208
- return {
209
- ok: true,
210
- signed: JSON.stringify(result.envelope),
211
- artifact_type: artifactType,
212
- receipt_hash: result.hash
213
- };
214
- } catch (err) {
215
- const message = err instanceof Error ? err.message : "unknown error";
216
- return {
217
- ok: false,
218
- signed: null,
219
- artifact_type: artifactType,
220
- warning: `signing failed: ${message}`,
221
- error: message
222
- };
223
- }
224
- }
225
- function getSignerInfo() {
226
- if (!signerState) return null;
227
- return {
228
- publicKey: signerState.publicKey,
229
- kid: signerState.kid,
230
- issuer: signerState.issuer
231
- };
232
- }
233
- function isSigningEnabled() {
234
- return signingConfigured && signingInitError === null && signerState !== null;
235
- }
236
-
237
1
  // src/http-server.ts
238
2
  import { createServer } from "http";
239
- import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
3
+ import { readFileSync, existsSync } from "fs";
240
4
  import { join } from "path";
241
5
  var LOG_FILE = ".protect-mcp-log.jsonl";
242
6
  var MAX_RECEIPTS = 100;
@@ -331,12 +95,12 @@ function handleHealth(res, startTime, config) {
331
95
  }
332
96
  function handleStatus(res, logDir) {
333
97
  const logPath = join(logDir, LOG_FILE);
334
- if (!existsSync2(logPath)) {
98
+ if (!existsSync(logPath)) {
335
99
  res.writeHead(200);
336
100
  res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
337
101
  return;
338
102
  }
339
- const raw = readFileSync3(logPath, "utf-8");
103
+ const raw = readFileSync(logPath, "utf-8");
340
104
  const lines = raw.trim().split("\n").filter(Boolean);
341
105
  const entries = [];
342
106
  for (const line of lines) {
@@ -460,7 +224,7 @@ function handleListApprovals(res, approvalStore) {
460
224
  }
461
225
 
462
226
  // src/action-readback.ts
463
- import { createHash as createHash2 } from "crypto";
227
+ import { createHash } from "crypto";
464
228
  var SECRET_KEY_RE = /(api[_-]?key|authorization|bearer|credential|password|secret|session|token|private[_-]?key)/i;
465
229
  var DESTINATION_KEYS = [
466
230
  "path",
@@ -540,7 +304,7 @@ function buildActionReadback(tool, input) {
540
304
  action,
541
305
  destination,
542
306
  payload_preview: payloadPreview,
543
- payload_hash: createHash2("sha256").update(canonical).digest("hex"),
307
+ payload_hash: createHash("sha256").update(canonical).digest("hex"),
544
308
  payload_bytes: Buffer.byteLength(canonical, "utf-8"),
545
309
  disclosed_fields: [...new Set(disclosedFields)].slice(0, 80),
546
310
  redacted_fields: [...new Set(redactedFields)].slice(0, 80),
@@ -549,14 +313,6 @@ function buildActionReadback(tool, input) {
549
313
  }
550
314
 
551
315
  export {
552
- loadPolicy,
553
- getToolPolicy,
554
- parseRateLimit,
555
- checkRateLimit,
556
- initSigning,
557
- signDecision,
558
- getSignerInfo,
559
- isSigningEnabled,
560
316
  ReceiptBuffer,
561
317
  startStatusServer,
562
318
  buildActionReadback
@@ -1,7 +1,80 @@
1
- // src/cedar-evaluator.ts
1
+ import {
2
+ canonicalize
3
+ } from "./chunk-XOP3PEBM.mjs";
4
+
5
+ // src/policy-digest.ts
2
6
  import { createHash } from "crypto";
3
7
  import { readFileSync, readdirSync, existsSync } from "fs";
4
8
  import { join, extname } from "path";
9
+ var POLICY_DIGEST_CONSTRUCTION = "acta-policy-digest-v1";
10
+ var POLICY_BUNDLE_SCHEMA = "acta.policy-bundle.v1";
11
+ var sha256hex = (data) => createHash("sha256").update(data).digest("hex");
12
+ function digestPolicyFiles(engine, files) {
13
+ if (files.length === 0) throw new Error("policy digest requires at least one file");
14
+ const names = /* @__PURE__ */ new Set();
15
+ for (const f of files) {
16
+ if (!f.name) throw new Error("policy file entries require a name");
17
+ if (names.has(f.name)) throw new Error(`duplicate policy file name: ${f.name}`);
18
+ names.add(f.name);
19
+ }
20
+ const entries = files.map((f) => ({ name: f.name, sha256: sha256hex(Buffer.from(f.content, "utf-8")) })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
21
+ const manifest = { construction: POLICY_DIGEST_CONSTRUCTION, engine, files: entries };
22
+ return {
23
+ policy_digest: `sha256:${sha256hex(Buffer.from(canonicalize(manifest), "utf-8"))}`,
24
+ construction: POLICY_DIGEST_CONSTRUCTION,
25
+ engine,
26
+ files: entries
27
+ };
28
+ }
29
+ function digestCedarDir(dirPath) {
30
+ if (!existsSync(dirPath)) throw new Error(`Cedar policy directory not found: ${dirPath}`);
31
+ const names = readdirSync(dirPath).filter((f) => extname(f) === ".cedar").sort();
32
+ if (names.length === 0) throw new Error(`No .cedar files found in: ${dirPath}`);
33
+ const files = names.map((name) => ({ name, content: readFileSync(join(dirPath, name), "utf-8") }));
34
+ return { ...digestPolicyFiles("cedar", files), dir: dirPath };
35
+ }
36
+ function digestCedarSource(source) {
37
+ return digestPolicyFiles("cedar", [{ name: "policy.cedar", content: source }]);
38
+ }
39
+ function digestBuiltinPolicy(policy) {
40
+ return digestPolicyFiles("builtin", [{ name: "policy.json", content: canonicalize(policy) }]);
41
+ }
42
+ function shortPolicyLabel(result) {
43
+ return `${result.engine}:${result.policy_digest.replace(/^sha256:/, "").slice(0, 16)}`;
44
+ }
45
+ function buildPolicyBundle(engine, files, generatedAt) {
46
+ const d = digestPolicyFiles(engine, files);
47
+ const byName = new Map(files.map((f) => [f.name, f.content]));
48
+ return {
49
+ schema: POLICY_BUNDLE_SCHEMA,
50
+ construction: POLICY_DIGEST_CONSTRUCTION,
51
+ engine,
52
+ policy_digest: d.policy_digest,
53
+ files: d.files.map((e) => ({ ...e, content: byName.get(e.name) })),
54
+ generated_at: generatedAt || (/* @__PURE__ */ new Date()).toISOString()
55
+ };
56
+ }
57
+ function verifyPolicyBundle(bundle) {
58
+ try {
59
+ const b = bundle;
60
+ if (!b || b.schema !== POLICY_BUNDLE_SCHEMA) return { valid: false, error: "unknown_schema" };
61
+ if (b.construction !== POLICY_DIGEST_CONSTRUCTION) return { valid: false, error: "unknown_construction" };
62
+ if (!Array.isArray(b.files) || b.files.length === 0) return { valid: false, error: "missing_files" };
63
+ for (const f of b.files) {
64
+ if (sha256hex(Buffer.from(f.content, "utf-8")) !== f.sha256) {
65
+ return { valid: false, error: `file_hash_mismatch:${f.name}` };
66
+ }
67
+ }
68
+ const recomputed = digestPolicyFiles(b.engine, b.files.map((f) => ({ name: f.name, content: f.content }))).policy_digest;
69
+ return recomputed === b.policy_digest ? { valid: true, recomputed } : { valid: false, recomputed, error: "digest_mismatch" };
70
+ } catch (err) {
71
+ return { valid: false, error: `verify_error:${err instanceof Error ? err.message : "unknown"}` };
72
+ }
73
+ }
74
+
75
+ // src/cedar-evaluator.ts
76
+ import { readFileSync as readFileSync2, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
77
+ import { join as join2, extname as extname2 } from "path";
5
78
  var cedarWasm = null;
6
79
  var loadAttempted = false;
7
80
  async function ensureCedarWasm() {
@@ -20,20 +93,19 @@ async function ensureCedarWasm() {
20
93
  }
21
94
  }
22
95
  function loadCedarPolicies(dirPath) {
23
- if (!existsSync(dirPath)) {
96
+ if (!existsSync2(dirPath)) {
24
97
  throw new Error(`Cedar policy directory not found: ${dirPath}`);
25
98
  }
26
- const entries = readdirSync(dirPath).filter((f) => extname(f) === ".cedar").sort();
99
+ const entries = readdirSync2(dirPath).filter((f) => extname2(f) === ".cedar").sort();
27
100
  if (entries.length === 0) {
28
101
  throw new Error(`No .cedar files found in: ${dirPath}`);
29
102
  }
30
- const sources = [];
103
+ const files = [];
31
104
  for (const file of entries) {
32
- const content = readFileSync(join(dirPath, file), "utf-8");
33
- sources.push(content);
105
+ files.push({ name: file, content: readFileSync2(join2(dirPath, file), "utf-8") });
34
106
  }
35
- const concatenated = sources.join("\n\n");
36
- const digest = createHash("sha256").update(concatenated).digest("hex").slice(0, 16);
107
+ const concatenated = files.map((f) => f.content).join("\n\n");
108
+ const digest = digestPolicyFiles("cedar", files).policy_digest;
37
109
  return {
38
110
  source: concatenated,
39
111
  digest,
@@ -174,7 +246,7 @@ async function isCedarAvailable() {
174
246
  return ensureCedarWasm();
175
247
  }
176
248
  function policySetFromSource(source, name = "inline") {
177
- const digest = createHash("sha256").update(source).digest("hex").slice(0, 16);
249
+ const digest = digestCedarSource(source).policy_digest;
178
250
  return { source, digest, fileCount: 1, files: [name] };
179
251
  }
180
252
  async function runEvaluatorSelfTest() {
@@ -202,6 +274,11 @@ async function runEvaluatorSelfTest() {
202
274
  }
203
275
 
204
276
  export {
277
+ digestCedarDir,
278
+ digestBuiltinPolicy,
279
+ shortPolicyLabel,
280
+ buildPolicyBundle,
281
+ verifyPolicyBundle,
205
282
  loadCedarPolicies,
206
283
  evaluateCedar,
207
284
  isCedarAvailable,
@@ -1,16 +1,21 @@
1
1
  import {
2
2
  ReceiptBuffer,
3
3
  buildActionReadback,
4
+ startStatusServer
5
+ } from "./chunk-CIWIK6BT.mjs";
6
+ import {
4
7
  checkRateLimit,
5
8
  getToolPolicy,
6
9
  isSigningEnabled,
7
10
  parseRateLimit,
8
- signDecision,
9
- startStatusServer
10
- } from "./chunk-VWGRAUWI.mjs";
11
+ signDecision
12
+ } from "./chunk-WMIWQ6TS.mjs";
11
13
  import {
12
14
  evaluateCedar
13
- } from "./chunk-MWXDXYWH.mjs";
15
+ } from "./chunk-FGCNKEEW.mjs";
16
+ import {
17
+ receiptHash
18
+ } from "./chunk-XOP3PEBM.mjs";
14
19
 
15
20
  // src/evidence-store.ts
16
21
  import { readFileSync, writeFileSync, existsSync } from "fs";
@@ -589,7 +594,7 @@ function parseNotificationConfigFromEnv() {
589
594
  import { spawn } from "child_process";
590
595
  import { randomUUID, randomBytes } from "crypto";
591
596
  import { createInterface } from "readline";
592
- import { appendFileSync } from "fs";
597
+ import { appendFileSync, readFileSync as readFileSync2 } from "fs";
593
598
  import { join as join2 } from "path";
594
599
  var LOG_FILE = ".protect-mcp-log.jsonl";
595
600
  var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
@@ -600,6 +605,8 @@ var ProtectGateway = class {
600
605
  clientReader = null;
601
606
  logFilePath;
602
607
  receiptFilePath;
608
+ /** s5.7 hash of the last line appended to the receipt file (chain link) */
609
+ lastReceiptHash = null;
603
610
  evidenceStore;
604
611
  receiptBuffer;
605
612
  /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
@@ -619,6 +626,11 @@ var ProtectGateway = class {
619
626
  this.config = config;
620
627
  this.logFilePath = join2(process.cwd(), LOG_FILE);
621
628
  this.receiptFilePath = join2(process.cwd(), RECEIPTS_FILE);
629
+ try {
630
+ const existing = readFileSync2(this.receiptFilePath, "utf-8").split("\n").filter((l) => l.trim());
631
+ if (existing.length > 0) this.lastReceiptHash = receiptHash(JSON.parse(existing[existing.length - 1]));
632
+ } catch {
633
+ }
622
634
  this.evidenceStore = new EvidenceStore();
623
635
  this.receiptBuffer = new ReceiptBuffer();
624
636
  this.notificationConfig = parseNotificationConfigFromEnv();
@@ -965,12 +977,13 @@ var ProtectGateway = class {
965
977
  } catch {
966
978
  }
967
979
  if (isSigningEnabled()) {
968
- const signed = signDecision(log);
980
+ const signed = signDecision(log, this.lastReceiptHash || void 0);
969
981
  if (signed.signed) {
970
982
  process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
971
983
  `);
972
984
  try {
973
985
  appendFileSync(this.receiptFilePath, signed.signed + "\n");
986
+ if (signed.receipt_hash) this.lastReceiptHash = signed.receipt_hash;
974
987
  } catch {
975
988
  }
976
989
  this.receiptBuffer.add(log.request_id, signed.signed);
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  receiptIdentity
3
- } from "./chunk-3AVIMUNP.mjs";
3
+ } from "./chunk-XOP3PEBM.mjs";
4
4
 
5
5
  // src/report.ts
6
6
  import { readFileSync, existsSync } from "fs";
@@ -3,7 +3,9 @@ import {
3
3
  } from "./chunk-KRKZ2YX7.mjs";
4
4
  import {
5
5
  ReceiptBuffer,
6
- buildActionReadback,
6
+ buildActionReadback
7
+ } from "./chunk-CIWIK6BT.mjs";
8
+ import {
7
9
  checkRateLimit,
8
10
  getSignerInfo,
9
11
  getToolPolicy,
@@ -12,15 +14,16 @@ import {
12
14
  loadPolicy,
13
15
  parseRateLimit,
14
16
  signDecision
15
- } from "./chunk-VWGRAUWI.mjs";
17
+ } from "./chunk-WMIWQ6TS.mjs";
16
18
  import {
17
19
  evaluateCedar,
18
20
  isCedarAvailable,
19
- loadCedarPolicies
20
- } from "./chunk-MWXDXYWH.mjs";
21
+ loadCedarPolicies,
22
+ runEvaluatorSelfTest
23
+ } from "./chunk-FGCNKEEW.mjs";
21
24
  import {
22
25
  receiptHash
23
- } from "./chunk-3AVIMUNP.mjs";
26
+ } from "./chunk-XOP3PEBM.mjs";
24
27
 
25
28
  // src/hook-server.ts
26
29
  import { createServer } from "http";
@@ -824,6 +827,41 @@ async function startHookServer(options = {}) {
824
827
  }
825
828
  }
826
829
  }
830
+ if (enforce) {
831
+ const selfTest = await runEvaluatorSelfTest();
832
+ for (const c of selfTest.cases) {
833
+ if (!c.pass) {
834
+ process.stderr.write(
835
+ `[PROTECT_MCP] SELF-TEST FAIL: ${c.name} (expected ${c.expected}, got ${c.actual})
836
+ `
837
+ );
838
+ }
839
+ }
840
+ if (!selfTest.passed) {
841
+ throw new Error(
842
+ "enforce mode refused to start: the restraint self-test failed. A gate that cannot prove it denies must not arm."
843
+ );
844
+ }
845
+ const receiptProbe = signDecision({
846
+ v: 2,
847
+ tool: "__protect_mcp_startup_selftest__",
848
+ decision: "deny",
849
+ reason_code: "startup_selftest",
850
+ policy_digest: policyDigest,
851
+ request_id: `selftest-${Date.now()}`,
852
+ mode: "enforce",
853
+ timestamp: Date.now()
854
+ });
855
+ if (receiptProbe.error) {
856
+ throw new Error(
857
+ `enforce mode refused to start: signing is configured but a denial receipt could not be produced (${receiptProbe.error}). An enforcing gate that cannot evidence a denial must not arm.`
858
+ );
859
+ }
860
+ process.stderr.write(
861
+ `[PROTECT_MCP] Restraint self-test passed (${selfTest.cases.length} vectors${selfTest.wasmAvailable ? "" : "; Cedar WASM absent, fail-closed invariant verified"})${receiptProbe.ok ? "; denial receipt signing verified" : ""}. Arming enforce mode.
862
+ `
863
+ );
864
+ }
827
865
  const state = {
828
866
  cedarPolicies,
829
867
  cedarDir: cedarDir || null,