protect-mcp 0.9.7 → 0.11.0
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 +102 -0
- package/dist/{chunk-AUUAVWQM.mjs → chunk-3FSKOGGK.mjs} +28 -8
- package/dist/{chunk-MOXINIMB.mjs → chunk-BXZUS6HS.mjs} +2 -2
- package/dist/{chunk-G6X763MH.mjs → chunk-CIWIK6BT.mjs} +5 -251
- package/dist/{chunk-MWXDXYWH.mjs → chunk-FGCNKEEW.mjs} +86 -9
- package/dist/{chunk-UWB5ALVO.mjs → chunk-K7AEEQWW.mjs} +17 -94
- package/dist/{chunk-WWPQNIVF.mjs → chunk-KRKZ2YX7.mjs} +2 -7
- package/dist/{chunk-YM6SOJBR.mjs → chunk-NQOKPBSQ.mjs} +19 -6
- package/dist/{chunk-JQDVKZBN.mjs → chunk-SOJBLPPX.mjs} +7 -2
- package/dist/chunk-WMIWQ6TS.mjs +266 -0
- package/dist/chunk-XOP3PEBM.mjs +114 -0
- package/dist/{chunk-SETXVE2K.mjs → chunk-ZG6NAATA.mjs} +17 -14
- package/dist/{claim-RIXFOQM7.mjs → claim-MPXJRCFC.mjs} +4 -12
- package/dist/cli.js +1166 -3391
- package/dist/cli.mjs +131 -37
- package/dist/coverage-NB55FTYL.mjs +237 -0
- package/dist/demo-server.js +17 -14
- package/dist/demo-server.mjs +1 -1
- package/dist/hook-server.js +177 -436
- package/dist/hook-server.mjs +6 -7
- package/dist/{http-transport-VHD3YBT5.mjs → http-transport-BHHLPYL2.mjs} +8 -3
- package/dist/index.d.mts +98 -7
- package/dist/index.d.ts +98 -7
- package/dist/index.js +777 -4007
- package/dist/index.mjs +39 -1131
- package/dist/mcp-server.js +154 -14
- package/dist/mcp-server.mjs +24 -14
- package/dist/{report-5XCNW6FB.mjs → report-5B7EO3QB.mjs} +2 -1
- package/dist/{sample-6LRP73ZD.mjs → sample-O3KYZWOX.mjs} +4 -12
- package/dist/{signing-committed-TGWXSLAO.mjs → signing-committed-7VEKAJ4A.mjs} +1 -5
- package/package.json +5 -5
- package/dist/chunk-AYNQIEN7.mjs +0 -10
- package/dist/chunk-D733KAPG.mjs +0 -252
- package/dist/chunk-FFVJL3KQ.mjs +0 -2039
- package/dist/chunk-JIDDQUSQ.mjs +0 -568
- package/dist/ed25519-SQA3S2RV.mjs +0 -39
- package/dist/utils-6AYZFE5A.mjs +0 -77
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,107 @@
|
|
|
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
|
+
|
|
25
|
+
## 0.10.0: receipts migrate to the draft-02 Acta envelope
|
|
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
|
+
|
|
48
|
+
Receipts are now emitted in the envelope the IETF draft actually specifies
|
|
49
|
+
(draft-farley-acta-signed-receipts-02): a two-field
|
|
50
|
+
`{ payload, signature: { alg: "EdDSA", kid, sig } }` envelope, with the
|
|
51
|
+
access-decision fields from section 3.1 (`type: "protectmcp:decision"`,
|
|
52
|
+
`tool_name`, `decision`, `reason`, `policy_digest`), `issuer_id` equal to
|
|
53
|
+
`signature.kid` (section 2.2), the signature computed as PureEdDSA directly
|
|
54
|
+
over the JCS bytes of the payload with no pre-hash (section 5.6), and new
|
|
55
|
+
keys defaulting to the section 2.1.1 recommended
|
|
56
|
+
`sb:issuer:<base58-fingerprint>` kid format (existing key files keep their
|
|
57
|
+
explicit kid). Previously the package emitted an internal envelope shape
|
|
58
|
+
that predated the published draft.
|
|
59
|
+
|
|
60
|
+
Receipt logs are now hash-chained per section 5.7: each receipt's
|
|
61
|
+
`previousReceiptHash` is the bare-hex SHA-256 of the JCS bytes of the
|
|
62
|
+
previous log line exactly as written, signature included. The hook server
|
|
63
|
+
resumes the chain across restarts from the tail of the existing log, and
|
|
64
|
+
signing-failure tombstones participate in the chain so an unsigned gap
|
|
65
|
+
cannot be silently dropped.
|
|
66
|
+
|
|
67
|
+
Verification is dual-shape everywhere (the `verify_receipt` MCP tool, the
|
|
68
|
+
`serve --enforce` self-test, the embedded record viewer, and the exported
|
|
69
|
+
`verifyReceipt` API): receipts written by protect-mcp 0.9.x and earlier
|
|
70
|
+
(flat v1 artifacts and structured v2 envelopes with a top-level signature
|
|
71
|
+
string) continue to verify, and a mixed pre/post-migration log replays
|
|
72
|
+
cleanly, including the chain link that spans the boundary. Verified against
|
|
73
|
+
3,352 real receipts from a production gate log: all verify. The published
|
|
74
|
+
`@veritasacta/verify` CLI (0.9.2) verifies the new envelopes as-is via its
|
|
75
|
+
passport path; no verifier upgrade is required.
|
|
76
|
+
|
|
77
|
+
New module `acta-envelope` (exported from the package root):
|
|
78
|
+
`createReceiptEnvelope`, `verifyReceipt`, `receiptHash`, `receiptIdentity`,
|
|
79
|
+
`computeSbIssuerKid`. `signDecision` accepts an optional previous-chain-hash
|
|
80
|
+
argument and returns the emitted receipt's chain hash. `@noble/curves` and
|
|
81
|
+
`@noble/hashes` moved from optional to regular dependencies.
|
|
82
|
+
|
|
83
|
+
Fixed: importing the package as a library no longer attaches the demo
|
|
84
|
+
server's stdin JSON-RPC listener (it now only starts when demo-server is
|
|
85
|
+
the process entry point). Previously any `require("protect-mcp")` kept the
|
|
86
|
+
host process's event loop alive and answered JSON-RPC on stdout.
|
|
87
|
+
|
|
88
|
+
Note for strict draft conformance: `decision` may be "require_approval" for
|
|
89
|
+
held-for-co-sign flows, an extension value beyond the section 3.1 set
|
|
90
|
+
(allow, deny, rate_limit), pending a revision of the draft.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## 0.9.7: the gate as an MCP server
|
|
94
|
+
|
|
95
|
+
`protect-mcp mcp` boots a stdio MCP server exposing the gate itself as four
|
|
96
|
+
tools, so an agent can call the gate directly instead of only through the
|
|
97
|
+
PreToolUse/PostToolUse hooks: `evaluate_action` (Cedar decision, fail-closed),
|
|
98
|
+
`sign_decision` (Ed25519 receipt, byte-compatible with the runtime gate's),
|
|
99
|
+
`verify_receipt` (offline check), and `self_test` (proves a known-forbidden
|
|
100
|
+
action is denied and a signed receipt round-trips). All four are read-only
|
|
101
|
+
and annotated with what they return. A bare-spawnable bin,
|
|
102
|
+
`protect-mcp-mcp`, is included alongside the `mcp` subcommand so any MCP
|
|
103
|
+
host or registry can point at either.
|
|
104
|
+
|
|
3
105
|
## 0.9.6: policy you can see and change
|
|
4
106
|
|
|
5
107
|
The gate is default-deny and fail-closed, but a deny used to be a dead end: an
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildEnrichment
|
|
3
|
-
} from "./chunk-
|
|
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,12 +14,15 @@ 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
21
|
loadCedarPolicies
|
|
20
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-FGCNKEEW.mjs";
|
|
23
|
+
import {
|
|
24
|
+
receiptHash
|
|
25
|
+
} from "./chunk-XOP3PEBM.mjs";
|
|
21
26
|
|
|
22
27
|
// src/hook-server.ts
|
|
23
28
|
import { createServer } from "http";
|
|
@@ -181,6 +186,16 @@ var DEFAULT_PORT = 9377;
|
|
|
181
186
|
var LOG_FILE = ".protect-mcp-log.jsonl";
|
|
182
187
|
var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
|
|
183
188
|
var PAYLOAD_HASH_THRESHOLD = 1024;
|
|
189
|
+
function resumeReceiptChain(receiptFilePath) {
|
|
190
|
+
try {
|
|
191
|
+
if (!existsSync(receiptFilePath)) return null;
|
|
192
|
+
const lines = readFileSync(receiptFilePath, "utf-8").split("\n").filter((l) => l.trim());
|
|
193
|
+
if (lines.length === 0) return null;
|
|
194
|
+
return receiptHash(JSON.parse(lines[lines.length - 1]));
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
184
199
|
function detectSwarmContext() {
|
|
185
200
|
const teamName = process.env.CLAUDE_CODE_TEAM_NAME;
|
|
186
201
|
const agentId = process.env.CLAUDE_CODE_AGENT_ID;
|
|
@@ -683,10 +698,11 @@ function emitDecisionLog(state, entry) {
|
|
|
683
698
|
} catch {
|
|
684
699
|
}
|
|
685
700
|
if (isSigningEnabled()) {
|
|
686
|
-
const signed = signDecision(log);
|
|
701
|
+
const signed = signDecision(log, state.lastReceiptHash || void 0);
|
|
687
702
|
if (signed.signed) {
|
|
688
703
|
try {
|
|
689
704
|
appendFileSync(state.receiptFilePath, signed.signed + "\n");
|
|
705
|
+
if (signed.receipt_hash) state.lastReceiptHash = signed.receipt_hash;
|
|
690
706
|
} catch {
|
|
691
707
|
}
|
|
692
708
|
state.receiptBuffer.add(log.request_id, signed.signed);
|
|
@@ -701,16 +717,19 @@ function emitDecisionLog(state, entry) {
|
|
|
701
717
|
`);
|
|
702
718
|
}
|
|
703
719
|
} else if (signed.error) {
|
|
704
|
-
const
|
|
720
|
+
const tombstoneObj = {
|
|
705
721
|
type: "scopeblind.signing_failure.v1",
|
|
706
722
|
request_id: log.request_id,
|
|
707
723
|
tool: log.tool,
|
|
708
724
|
decision: log.decision,
|
|
709
725
|
error: signed.error,
|
|
710
|
-
at: new Date(log.timestamp).toISOString()
|
|
711
|
-
|
|
726
|
+
at: new Date(log.timestamp).toISOString(),
|
|
727
|
+
...state.lastReceiptHash ? { previousReceiptHash: state.lastReceiptHash } : {}
|
|
728
|
+
};
|
|
729
|
+
const tombstone = JSON.stringify(tombstoneObj);
|
|
712
730
|
try {
|
|
713
731
|
appendFileSync(state.receiptFilePath, tombstone + "\n");
|
|
732
|
+
state.lastReceiptHash = receiptHash(tombstoneObj);
|
|
714
733
|
} catch {
|
|
715
734
|
}
|
|
716
735
|
process.stderr.write(`[PROTECT_MCP_SIGNING_FAILURE] ${tombstone}
|
|
@@ -826,6 +845,7 @@ async function startHookServer(options = {}) {
|
|
|
826
845
|
policyDigest,
|
|
827
846
|
logFilePath: join(process.cwd(), LOG_FILE),
|
|
828
847
|
receiptFilePath: join(process.cwd(), RECEIPTS_FILE),
|
|
848
|
+
lastReceiptHash: resumeReceiptChain(join(process.cwd(), RECEIPTS_FILE)),
|
|
829
849
|
permissionSuggestions: /* @__PURE__ */ new Map(),
|
|
830
850
|
configAlerts: []
|
|
831
851
|
};
|
|
@@ -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,244 +1,6 @@
|
|
|
1
|
-
// src/policy.ts
|
|
2
|
-
import { createHash } from "crypto";
|
|
3
|
-
import { readFileSync } from "fs";
|
|
4
|
-
function loadPolicy(path) {
|
|
5
|
-
const raw = readFileSync(path, "utf-8");
|
|
6
|
-
const parsed = JSON.parse(raw);
|
|
7
|
-
if (!parsed.tools || typeof parsed.tools !== "object") {
|
|
8
|
-
throw new Error(`Invalid policy file: missing "tools" object in ${path}`);
|
|
9
|
-
}
|
|
10
|
-
const policy = {
|
|
11
|
-
tools: parsed.tools,
|
|
12
|
-
default_tier: parsed.default_tier || "unknown",
|
|
13
|
-
policy_engine: parsed.policy_engine || "built-in",
|
|
14
|
-
...parsed.external ? { external: parsed.external } : {}
|
|
15
|
-
};
|
|
16
|
-
const digest = computePolicyDigest(policy);
|
|
17
|
-
return {
|
|
18
|
-
policy,
|
|
19
|
-
digest,
|
|
20
|
-
credentials: parsed.credentials,
|
|
21
|
-
signing: parsed.signing
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
function computePolicyDigest(policy) {
|
|
25
|
-
const canonical = JSON.stringify(sortKeysDeep(policy));
|
|
26
|
-
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
27
|
-
}
|
|
28
|
-
function sortKeysDeep(obj) {
|
|
29
|
-
if (obj === null || typeof obj !== "object") return obj;
|
|
30
|
-
if (Array.isArray(obj)) return obj.map(sortKeysDeep);
|
|
31
|
-
const sorted = {};
|
|
32
|
-
for (const key of Object.keys(obj).sort()) {
|
|
33
|
-
sorted[key] = sortKeysDeep(obj[key]);
|
|
34
|
-
}
|
|
35
|
-
return sorted;
|
|
36
|
-
}
|
|
37
|
-
function getToolPolicy(toolName, policy) {
|
|
38
|
-
if (!policy) {
|
|
39
|
-
return { require: "any" };
|
|
40
|
-
}
|
|
41
|
-
if (policy.tools[toolName]) {
|
|
42
|
-
return policy.tools[toolName];
|
|
43
|
-
}
|
|
44
|
-
if (policy.tools["*"]) {
|
|
45
|
-
return policy.tools["*"];
|
|
46
|
-
}
|
|
47
|
-
return { require: "any" };
|
|
48
|
-
}
|
|
49
|
-
function parseRateLimit(spec) {
|
|
50
|
-
const match = spec.match(/^(\d+)\/(second|minute|hour|day)$/);
|
|
51
|
-
if (!match) {
|
|
52
|
-
throw new Error(`Invalid rate limit format: "${spec}". Expected "N/unit" (e.g. "5/hour")`);
|
|
53
|
-
}
|
|
54
|
-
const count = parseInt(match[1], 10);
|
|
55
|
-
const unit = match[2];
|
|
56
|
-
const windowMs = {
|
|
57
|
-
second: 1e3,
|
|
58
|
-
minute: 6e4,
|
|
59
|
-
hour: 36e5,
|
|
60
|
-
day: 864e5
|
|
61
|
-
};
|
|
62
|
-
return { count, windowMs: windowMs[unit] };
|
|
63
|
-
}
|
|
64
|
-
function checkRateLimit(key, limit, store) {
|
|
65
|
-
const now = Date.now();
|
|
66
|
-
const windowStart = now - limit.windowMs;
|
|
67
|
-
const timestamps = (store.get(key) || []).filter((t) => t > windowStart);
|
|
68
|
-
if (timestamps.length >= limit.count) {
|
|
69
|
-
store.set(key, timestamps);
|
|
70
|
-
return { allowed: false, remaining: 0 };
|
|
71
|
-
}
|
|
72
|
-
timestamps.push(now);
|
|
73
|
-
store.set(key, timestamps);
|
|
74
|
-
return { allowed: true, remaining: limit.count - timestamps.length };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// src/signing.ts
|
|
78
|
-
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
79
|
-
var signerState = null;
|
|
80
|
-
var artifactsModule = null;
|
|
81
|
-
var signingConfigured = false;
|
|
82
|
-
var signingInitError = null;
|
|
83
|
-
async function initSigning(config) {
|
|
84
|
-
const warnings = [];
|
|
85
|
-
signerState = null;
|
|
86
|
-
artifactsModule = null;
|
|
87
|
-
signingConfigured = Boolean(config && config.enabled !== false);
|
|
88
|
-
signingInitError = null;
|
|
89
|
-
if (!config || config.enabled === false) {
|
|
90
|
-
return warnings;
|
|
91
|
-
}
|
|
92
|
-
if (!config.key_path) {
|
|
93
|
-
signingInitError = "signing enabled but key_path is not configured";
|
|
94
|
-
warnings.push(`signing: ${signingInitError}`);
|
|
95
|
-
return warnings;
|
|
96
|
-
}
|
|
97
|
-
if (!existsSync(config.key_path)) {
|
|
98
|
-
signingInitError = `key file not found at ${config.key_path}`;
|
|
99
|
-
warnings.push(`signing: ${signingInitError} \u2014 run "protect-mcp init" to generate`);
|
|
100
|
-
return warnings;
|
|
101
|
-
}
|
|
102
|
-
let keyData;
|
|
103
|
-
try {
|
|
104
|
-
keyData = JSON.parse(readFileSync2(config.key_path, "utf-8"));
|
|
105
|
-
if (!keyData.privateKey || !keyData.publicKey) {
|
|
106
|
-
signingInitError = "key file missing privateKey or publicKey fields";
|
|
107
|
-
warnings.push(`signing: ${signingInitError}`);
|
|
108
|
-
return warnings;
|
|
109
|
-
}
|
|
110
|
-
} catch (err) {
|
|
111
|
-
signingInitError = `failed to load key file: ${err instanceof Error ? err.message : err}`;
|
|
112
|
-
warnings.push(`signing: ${signingInitError}`);
|
|
113
|
-
return warnings;
|
|
114
|
-
}
|
|
115
|
-
try {
|
|
116
|
-
const moduleName = "@veritasacta/artifacts";
|
|
117
|
-
artifactsModule = await import(
|
|
118
|
-
/* @vite-ignore */
|
|
119
|
-
moduleName
|
|
120
|
-
);
|
|
121
|
-
} catch {
|
|
122
|
-
signingInitError = "@veritasacta/artifacts not available";
|
|
123
|
-
warnings.push(`signing: ${signingInitError} \u2014 enforce mode will fail closed`);
|
|
124
|
-
return warnings;
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
signerState = {
|
|
128
|
-
privateKey: keyData.privateKey,
|
|
129
|
-
publicKey: keyData.publicKey,
|
|
130
|
-
kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
|
|
131
|
-
issuer: config.issuer || keyData.issuer || "protect-mcp"
|
|
132
|
-
};
|
|
133
|
-
} catch (err) {
|
|
134
|
-
signingInitError = `failed to initialize signer: ${err instanceof Error ? err.message : err}`;
|
|
135
|
-
artifactsModule = null;
|
|
136
|
-
warnings.push(`signing: ${signingInitError} \u2014 enforce mode will fail closed`);
|
|
137
|
-
}
|
|
138
|
-
return warnings;
|
|
139
|
-
}
|
|
140
|
-
function signDecision(entry) {
|
|
141
|
-
const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
|
|
142
|
-
if (signingConfigured && signingInitError) {
|
|
143
|
-
return {
|
|
144
|
-
ok: false,
|
|
145
|
-
signed: null,
|
|
146
|
-
artifact_type: artifactType,
|
|
147
|
-
warning: `signing initialization failed: ${signingInitError}`,
|
|
148
|
-
error: signingInitError
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
if (signingConfigured && (!signerState || !artifactsModule)) {
|
|
152
|
-
const error = "signing was configured but no signer is ready";
|
|
153
|
-
return {
|
|
154
|
-
ok: false,
|
|
155
|
-
signed: null,
|
|
156
|
-
artifact_type: artifactType,
|
|
157
|
-
warning: error,
|
|
158
|
-
error
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
if (!signerState || !artifactsModule) {
|
|
162
|
-
return { ok: false, signed: null, artifact_type: "none" };
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
const payload = {
|
|
166
|
-
tool: entry.tool,
|
|
167
|
-
decision: entry.decision,
|
|
168
|
-
reason_code: entry.reason_code,
|
|
169
|
-
policy_digest: entry.policy_digest,
|
|
170
|
-
scope: entry.request_id,
|
|
171
|
-
// request scope
|
|
172
|
-
mode: entry.mode,
|
|
173
|
-
request_id: entry.request_id,
|
|
174
|
-
// Spec version: ties every receipt to the IETF standard
|
|
175
|
-
spec: "draft-farley-acta-signed-receipts-01",
|
|
176
|
-
// Issuer certification: distinguishes VOPRF-backed receipts from self-signed ones
|
|
177
|
-
// - scopeblind:verified = issued via ScopeBlind VOPRF backend (paid tier)
|
|
178
|
-
// - self-signed = signed with local Ed25519 key (free tier, protect-mcp default)
|
|
179
|
-
// - uncertified = unsigned receipt (shadow mode, no signing configured)
|
|
180
|
-
issuer_certification: signerState ? "self-signed" : "uncertified",
|
|
181
|
-
// The signer's PUBLIC key, inside the signed payload, so a receipt is
|
|
182
|
-
// self-contained: any verifier (including the record viewer, in-browser)
|
|
183
|
-
// can check the signature without a side channel. Binding the key inside
|
|
184
|
-
// the signature means it cannot be swapped without breaking the signature;
|
|
185
|
-
// authenticity (that the key is YOUR gate's) still comes from pinning it.
|
|
186
|
-
public_key: signerState.publicKey
|
|
187
|
-
};
|
|
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 = artifactsModule.createSignedArtifact(
|
|
203
|
-
artifactType,
|
|
204
|
-
payload,
|
|
205
|
-
signerState.privateKey,
|
|
206
|
-
{
|
|
207
|
-
kid: signerState.kid,
|
|
208
|
-
issuer: signerState.issuer
|
|
209
|
-
}
|
|
210
|
-
);
|
|
211
|
-
return {
|
|
212
|
-
ok: true,
|
|
213
|
-
signed: JSON.stringify(result.artifact),
|
|
214
|
-
artifact_type: artifactType
|
|
215
|
-
};
|
|
216
|
-
} catch (err) {
|
|
217
|
-
const message = err instanceof Error ? err.message : "unknown error";
|
|
218
|
-
return {
|
|
219
|
-
ok: false,
|
|
220
|
-
signed: null,
|
|
221
|
-
artifact_type: artifactType,
|
|
222
|
-
warning: `signing failed: ${message}`,
|
|
223
|
-
error: message
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
function getSignerInfo() {
|
|
228
|
-
if (!signerState) return null;
|
|
229
|
-
return {
|
|
230
|
-
publicKey: signerState.publicKey,
|
|
231
|
-
kid: signerState.kid,
|
|
232
|
-
issuer: signerState.issuer
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
function isSigningEnabled() {
|
|
236
|
-
return signingConfigured && signingInitError === null && signerState !== null && artifactsModule !== null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
1
|
// src/http-server.ts
|
|
240
2
|
import { createServer } from "http";
|
|
241
|
-
import { readFileSync
|
|
3
|
+
import { readFileSync, existsSync } from "fs";
|
|
242
4
|
import { join } from "path";
|
|
243
5
|
var LOG_FILE = ".protect-mcp-log.jsonl";
|
|
244
6
|
var MAX_RECEIPTS = 100;
|
|
@@ -333,12 +95,12 @@ function handleHealth(res, startTime, config) {
|
|
|
333
95
|
}
|
|
334
96
|
function handleStatus(res, logDir) {
|
|
335
97
|
const logPath = join(logDir, LOG_FILE);
|
|
336
|
-
if (!
|
|
98
|
+
if (!existsSync(logPath)) {
|
|
337
99
|
res.writeHead(200);
|
|
338
100
|
res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
|
|
339
101
|
return;
|
|
340
102
|
}
|
|
341
|
-
const raw =
|
|
103
|
+
const raw = readFileSync(logPath, "utf-8");
|
|
342
104
|
const lines = raw.trim().split("\n").filter(Boolean);
|
|
343
105
|
const entries = [];
|
|
344
106
|
for (const line of lines) {
|
|
@@ -462,7 +224,7 @@ function handleListApprovals(res, approvalStore) {
|
|
|
462
224
|
}
|
|
463
225
|
|
|
464
226
|
// src/action-readback.ts
|
|
465
|
-
import { createHash
|
|
227
|
+
import { createHash } from "crypto";
|
|
466
228
|
var SECRET_KEY_RE = /(api[_-]?key|authorization|bearer|credential|password|secret|session|token|private[_-]?key)/i;
|
|
467
229
|
var DESTINATION_KEYS = [
|
|
468
230
|
"path",
|
|
@@ -542,7 +304,7 @@ function buildActionReadback(tool, input) {
|
|
|
542
304
|
action,
|
|
543
305
|
destination,
|
|
544
306
|
payload_preview: payloadPreview,
|
|
545
|
-
payload_hash:
|
|
307
|
+
payload_hash: createHash("sha256").update(canonical).digest("hex"),
|
|
546
308
|
payload_bytes: Buffer.byteLength(canonical, "utf-8"),
|
|
547
309
|
disclosed_fields: [...new Set(disclosedFields)].slice(0, 80),
|
|
548
310
|
redacted_fields: [...new Set(redactedFields)].slice(0, 80),
|
|
@@ -551,14 +313,6 @@ function buildActionReadback(tool, input) {
|
|
|
551
313
|
}
|
|
552
314
|
|
|
553
315
|
export {
|
|
554
|
-
loadPolicy,
|
|
555
|
-
getToolPolicy,
|
|
556
|
-
parseRateLimit,
|
|
557
|
-
checkRateLimit,
|
|
558
|
-
initSigning,
|
|
559
|
-
signDecision,
|
|
560
|
-
getSignerInfo,
|
|
561
|
-
isSigningEnabled,
|
|
562
316
|
ReceiptBuffer,
|
|
563
317
|
startStatusServer,
|
|
564
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,
|