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 +43 -0
- package/dist/{chunk-SPDIFFGP.mjs → chunk-BXZUS6HS.mjs} +2 -2
- package/dist/{chunk-VWGRAUWI.mjs → chunk-CIWIK6BT.mjs} +5 -249
- package/dist/{chunk-MWXDXYWH.mjs → chunk-FGCNKEEW.mjs} +86 -9
- package/dist/{chunk-622KQ4KW.mjs → chunk-NQOKPBSQ.mjs} +19 -6
- package/dist/{chunk-HULPW5WR.mjs → chunk-SOJBLPPX.mjs} +1 -1
- package/dist/{chunk-ZLZPSUCY.mjs → chunk-UBTX3A3D.mjs} +43 -5
- package/dist/chunk-WMIWQ6TS.mjs +266 -0
- package/dist/{chunk-3AVIMUNP.mjs → chunk-XOP3PEBM.mjs} +1 -0
- package/dist/cli.js +1038 -574
- package/dist/cli.mjs +100 -12
- package/dist/coverage-IHN7MUBH.mjs +245 -0
- package/dist/hook-server.js +197 -116
- package/dist/hook-server.mjs +5 -4
- package/dist/{http-transport-JHGRFCYY.mjs → http-transport-BHHLPYL2.mjs} +8 -4
- package/dist/index.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +310 -248
- package/dist/index.mjs +8 -7
- package/dist/mcp-server.js +137 -107
- package/dist/mcp-server.mjs +2 -2
- package/dist/{report-N5LPPSPF.mjs → report-5B7EO3QB.mjs} +2 -2
- package/package.json +1 -1
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-
|
|
3
|
+
} from "./chunk-NQOKPBSQ.mjs";
|
|
4
4
|
import {
|
|
5
5
|
checkRateLimit,
|
|
6
6
|
getToolPolicy,
|
|
7
7
|
parseRateLimit
|
|
8
|
-
} from "./chunk-
|
|
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
|
|
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 (!
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 (!
|
|
96
|
+
if (!existsSync2(dirPath)) {
|
|
24
97
|
throw new Error(`Cedar policy directory not found: ${dirPath}`);
|
|
25
98
|
}
|
|
26
|
-
const entries =
|
|
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
|
|
103
|
+
const files = [];
|
|
31
104
|
for (const file of entries) {
|
|
32
|
-
|
|
33
|
-
sources.push(content);
|
|
105
|
+
files.push({ name: file, content: readFileSync2(join2(dirPath, file), "utf-8") });
|
|
34
106
|
}
|
|
35
|
-
const concatenated =
|
|
36
|
-
const digest =
|
|
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 =
|
|
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
|
-
|
|
10
|
-
} from "./chunk-VWGRAUWI.mjs";
|
|
11
|
+
signDecision
|
|
12
|
+
} from "./chunk-WMIWQ6TS.mjs";
|
|
11
13
|
import {
|
|
12
14
|
evaluateCedar
|
|
13
|
-
} from "./chunk-
|
|
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);
|
|
@@ -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-
|
|
17
|
+
} from "./chunk-WMIWQ6TS.mjs";
|
|
16
18
|
import {
|
|
17
19
|
evaluateCedar,
|
|
18
20
|
isCedarAvailable,
|
|
19
|
-
loadCedarPolicies
|
|
20
|
-
|
|
21
|
+
loadCedarPolicies,
|
|
22
|
+
runEvaluatorSelfTest
|
|
23
|
+
} from "./chunk-FGCNKEEW.mjs";
|
|
21
24
|
import {
|
|
22
25
|
receiptHash
|
|
23
|
-
} from "./chunk-
|
|
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,
|