@sanctuary-framework/mcp-server 0.2.0 → 0.3.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/dist/cli.cjs CHANGED
@@ -14,6 +14,9 @@ var hashWasm = require('hash-wasm');
14
14
  var hkdf = require('@noble/hashes/hkdf');
15
15
  var index_js = require('@modelcontextprotocol/sdk/server/index.js');
16
16
  var types_js = require('@modelcontextprotocol/sdk/types.js');
17
+ var http = require('http');
18
+ var https = require('https');
19
+ var fs = require('fs');
17
20
 
18
21
  var __defProp = Object.defineProperty;
19
22
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -205,7 +208,7 @@ var init_hashing = __esm({
205
208
  });
206
209
  function defaultConfig() {
207
210
  return {
208
- version: "0.2.0",
211
+ version: "0.3.0",
209
212
  storage_path: path.join(os.homedir(), ".sanctuary"),
210
213
  state: {
211
214
  encryption: "aes-256-gcm",
@@ -234,7 +237,19 @@ function defaultConfig() {
234
237
  service_endpoints: []
235
238
  },
236
239
  transport: "stdio",
237
- http_port: 3500
240
+ http_port: 3500,
241
+ dashboard: {
242
+ enabled: false,
243
+ port: 3501,
244
+ host: "127.0.0.1"
245
+ },
246
+ webhook: {
247
+ enabled: false,
248
+ url: "",
249
+ secret: "",
250
+ callback_port: 3502,
251
+ callback_host: "127.0.0.1"
252
+ }
238
253
  };
239
254
  }
240
255
  async function loadConfig(configPath) {
@@ -248,12 +263,50 @@ async function loadConfig(configPath) {
248
263
  if (process.env.SANCTUARY_HTTP_PORT) {
249
264
  config.http_port = parseInt(process.env.SANCTUARY_HTTP_PORT, 10);
250
265
  }
266
+ if (process.env.SANCTUARY_DASHBOARD_ENABLED === "true") {
267
+ config.dashboard.enabled = true;
268
+ }
269
+ if (process.env.SANCTUARY_DASHBOARD_PORT) {
270
+ config.dashboard.port = parseInt(process.env.SANCTUARY_DASHBOARD_PORT, 10);
271
+ }
272
+ if (process.env.SANCTUARY_DASHBOARD_HOST) {
273
+ config.dashboard.host = process.env.SANCTUARY_DASHBOARD_HOST;
274
+ }
275
+ if (process.env.SANCTUARY_DASHBOARD_AUTH_TOKEN) {
276
+ config.dashboard.auth_token = process.env.SANCTUARY_DASHBOARD_AUTH_TOKEN;
277
+ }
278
+ if (process.env.SANCTUARY_DASHBOARD_TLS_CERT && process.env.SANCTUARY_DASHBOARD_TLS_KEY) {
279
+ config.dashboard.tls = {
280
+ cert_path: process.env.SANCTUARY_DASHBOARD_TLS_CERT,
281
+ key_path: process.env.SANCTUARY_DASHBOARD_TLS_KEY
282
+ };
283
+ }
284
+ if (process.env.SANCTUARY_WEBHOOK_ENABLED === "true") {
285
+ config.webhook.enabled = true;
286
+ }
287
+ if (process.env.SANCTUARY_WEBHOOK_URL) {
288
+ config.webhook.url = process.env.SANCTUARY_WEBHOOK_URL;
289
+ }
290
+ if (process.env.SANCTUARY_WEBHOOK_SECRET) {
291
+ config.webhook.secret = process.env.SANCTUARY_WEBHOOK_SECRET;
292
+ }
293
+ if (process.env.SANCTUARY_WEBHOOK_CALLBACK_PORT) {
294
+ config.webhook.callback_port = parseInt(process.env.SANCTUARY_WEBHOOK_CALLBACK_PORT, 10);
295
+ }
296
+ if (process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST) {
297
+ config.webhook.callback_host = process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST;
298
+ }
251
299
  const path$1 = configPath ?? path.join(config.storage_path, "sanctuary.json");
252
300
  try {
253
301
  const raw = await promises.readFile(path$1, "utf-8");
254
302
  const fileConfig = JSON.parse(raw);
255
- return deepMerge(config, fileConfig);
256
- } catch {
303
+ const merged = deepMerge(config, fileConfig);
304
+ validateConfig(merged);
305
+ return merged;
306
+ } catch (err) {
307
+ if (err instanceof Error && err.message.includes("unimplemented features")) {
308
+ throw err;
309
+ }
257
310
  return config;
258
311
  }
259
312
  }
@@ -261,6 +314,33 @@ async function saveConfig(config, configPath) {
261
314
  const path$1 = path.join(config.storage_path, "sanctuary.json");
262
315
  await promises.writeFile(path$1, JSON.stringify(config, null, 2), { mode: 384 });
263
316
  }
317
+ function validateConfig(config) {
318
+ const errors = [];
319
+ const implementedKeyProtection = /* @__PURE__ */ new Set(["passphrase", "none"]);
320
+ if (!implementedKeyProtection.has(config.state.key_protection)) {
321
+ errors.push(
322
+ `Unimplemented config value: state.key_protection = "${config.state.key_protection}". Only ${[...implementedKeyProtection].map((v) => `"${v}"`).join(", ")} are currently implemented. Using an unimplemented key protection mode would silently degrade security.`
323
+ );
324
+ }
325
+ const implementedEnvironment = /* @__PURE__ */ new Set(["local-process", "docker"]);
326
+ if (!implementedEnvironment.has(config.execution.environment)) {
327
+ errors.push(
328
+ `Unimplemented config value: execution.environment = "${config.execution.environment}". Only ${[...implementedEnvironment].map((v) => `"${v}"`).join(", ")} are currently implemented. Using an unimplemented environment would silently degrade security.`
329
+ );
330
+ }
331
+ const implementedProofSystem = /* @__PURE__ */ new Set(["commitment-only"]);
332
+ if (!implementedProofSystem.has(config.disclosure.proof_system)) {
333
+ errors.push(
334
+ `Unimplemented config value: disclosure.proof_system = "${config.disclosure.proof_system}". Only ${[...implementedProofSystem].map((v) => `"${v}"`).join(", ")} is currently implemented. Using an unimplemented proof system would silently degrade security.`
335
+ );
336
+ }
337
+ if (errors.length > 0) {
338
+ throw new Error(
339
+ `Sanctuary configuration references unimplemented features:
340
+ ${errors.join("\n")}`
341
+ );
342
+ }
343
+ }
264
344
  function deepMerge(base, override) {
265
345
  const result = { ...base };
266
346
  for (const [key, value] of Object.entries(override)) {
@@ -604,7 +684,11 @@ var RESERVED_NAMESPACE_PREFIXES = [
604
684
  "_commitments",
605
685
  "_reputation",
606
686
  "_escrow",
607
- "_guarantees"
687
+ "_guarantees",
688
+ "_bridge",
689
+ "_federation",
690
+ "_handshake",
691
+ "_shr"
608
692
  ];
609
693
  var StateStore = class {
610
694
  storage;
@@ -871,12 +955,14 @@ var StateStore = class {
871
955
  /**
872
956
  * Import a previously exported state bundle.
873
957
  */
874
- async import(bundleBase64, conflictResolution = "skip") {
958
+ async import(bundleBase64, conflictResolution = "skip", publicKeyResolver) {
875
959
  const bundleBytes = fromBase64url(bundleBase64);
876
960
  const bundleJson = bytesToString(bundleBytes);
877
961
  const bundle = JSON.parse(bundleJson);
878
962
  let importedKeys = 0;
879
963
  let skippedKeys = 0;
964
+ let skippedInvalidSig = 0;
965
+ let skippedUnknownKid = 0;
880
966
  let conflicts = 0;
881
967
  const namespaces = [];
882
968
  for (const [ns, entries] of Object.entries(
@@ -890,6 +976,26 @@ var StateStore = class {
890
976
  }
891
977
  namespaces.push(ns);
892
978
  for (const { key, entry } of entries) {
979
+ const signerPublicKey = publicKeyResolver(entry.kid);
980
+ if (!signerPublicKey) {
981
+ skippedUnknownKid++;
982
+ skippedKeys++;
983
+ continue;
984
+ }
985
+ try {
986
+ const ciphertextBytes = fromBase64url(entry.payload.ct);
987
+ const signatureBytes = fromBase64url(entry.sig);
988
+ const sigValid = verify(ciphertextBytes, signatureBytes, signerPublicKey);
989
+ if (!sigValid) {
990
+ skippedInvalidSig++;
991
+ skippedKeys++;
992
+ continue;
993
+ }
994
+ } catch {
995
+ skippedInvalidSig++;
996
+ skippedKeys++;
997
+ continue;
998
+ }
893
999
  const exists = await this.storage.exists(ns, key);
894
1000
  if (exists) {
895
1001
  conflicts++;
@@ -925,6 +1031,8 @@ var StateStore = class {
925
1031
  return {
926
1032
  imported_keys: importedKeys,
927
1033
  skipped_keys: skippedKeys,
1034
+ skipped_invalid_sig: skippedInvalidSig,
1035
+ skipped_unknown_kid: skippedUnknownKid,
928
1036
  conflicts,
929
1037
  namespaces,
930
1038
  imported_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -1013,7 +1121,7 @@ function createServer(tools, options) {
1013
1121
  const server = new index_js.Server(
1014
1122
  {
1015
1123
  name: "sanctuary-mcp-server",
1016
- version: "0.2.0"
1124
+ version: "0.3.0"
1017
1125
  },
1018
1126
  {
1019
1127
  capabilities: {
@@ -1113,7 +1221,11 @@ var RESERVED_NAMESPACE_PREFIXES2 = [
1113
1221
  "_commitments",
1114
1222
  "_reputation",
1115
1223
  "_escrow",
1116
- "_guarantees"
1224
+ "_guarantees",
1225
+ "_bridge",
1226
+ "_federation",
1227
+ "_handshake",
1228
+ "_shr"
1117
1229
  ];
1118
1230
  function getReservedNamespaceViolation(namespace) {
1119
1231
  for (const prefix of RESERVED_NAMESPACE_PREFIXES2) {
@@ -1450,6 +1562,13 @@ function createL1Tools(stateStore, storage, masterKey, keyProtection, auditLog)
1450
1562
  required: ["namespace", "key"]
1451
1563
  },
1452
1564
  handler: async (args) => {
1565
+ const reservedViolation = getReservedNamespaceViolation(args.namespace);
1566
+ if (reservedViolation) {
1567
+ return toolResult({
1568
+ error: "namespace_reserved",
1569
+ message: `Namespace "${args.namespace}" is reserved for internal use (prefix: ${reservedViolation}). Cannot read from reserved namespaces.`
1570
+ });
1571
+ }
1453
1572
  const result = await stateStore.read(
1454
1573
  args.namespace,
1455
1574
  args.key,
@@ -1486,6 +1605,13 @@ function createL1Tools(stateStore, storage, masterKey, keyProtection, auditLog)
1486
1605
  required: ["namespace"]
1487
1606
  },
1488
1607
  handler: async (args) => {
1608
+ const reservedViolation = getReservedNamespaceViolation(args.namespace);
1609
+ if (reservedViolation) {
1610
+ return toolResult({
1611
+ error: "namespace_reserved",
1612
+ message: `Namespace "${args.namespace}" is reserved for internal use (prefix: ${reservedViolation}). Cannot list reserved namespaces.`
1613
+ });
1614
+ }
1489
1615
  const result = await stateStore.list(
1490
1616
  args.namespace,
1491
1617
  args.prefix,
@@ -1564,9 +1690,15 @@ function createL1Tools(stateStore, storage, masterKey, keyProtection, auditLog)
1564
1690
  required: ["bundle"]
1565
1691
  },
1566
1692
  handler: async (args) => {
1693
+ const publicKeyResolver = (kid) => {
1694
+ const identity = identityMgr.get(kid);
1695
+ if (!identity) return null;
1696
+ return fromBase64url(identity.public_key);
1697
+ };
1567
1698
  const result = await stateStore.import(
1568
1699
  args.bundle,
1569
- args.conflict_resolution ?? "skip"
1700
+ args.conflict_resolution ?? "skip",
1701
+ publicKeyResolver
1570
1702
  );
1571
1703
  auditLog?.append("l1", "state_import", "principal", {
1572
1704
  imported_keys: result.imported_keys
@@ -1887,6 +2019,267 @@ var PolicyStore = class {
1887
2019
  );
1888
2020
  }
1889
2021
  };
2022
+ init_encoding();
2023
+ var G = ed25519.RistrettoPoint.BASE;
2024
+ var H_INPUT = concatBytes(
2025
+ sha256.sha256(stringToBytes("sanctuary-pedersen-generator-H-v1-a")),
2026
+ sha256.sha256(stringToBytes("sanctuary-pedersen-generator-H-v1-b"))
2027
+ );
2028
+ var H = ed25519.RistrettoPoint.hashToCurve(H_INPUT);
2029
+ function bigintToBytes(n) {
2030
+ const hex = n.toString(16).padStart(64, "0");
2031
+ const bytes = new Uint8Array(32);
2032
+ for (let i = 0; i < 32; i++) {
2033
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
2034
+ }
2035
+ return bytes;
2036
+ }
2037
+ function bytesToBigint(bytes) {
2038
+ let hex = "";
2039
+ for (const b of bytes) {
2040
+ hex += b.toString(16).padStart(2, "0");
2041
+ }
2042
+ return BigInt("0x" + hex);
2043
+ }
2044
+ var ORDER = BigInt("7237005577332262213973186563042994240857116359379907606001950938285454250989");
2045
+ function mod(n) {
2046
+ return (n % ORDER + ORDER) % ORDER;
2047
+ }
2048
+ function safeMultiply(point, scalar) {
2049
+ const s = mod(scalar);
2050
+ if (s === 0n) return ed25519.RistrettoPoint.ZERO;
2051
+ return point.multiply(s);
2052
+ }
2053
+ function randomScalar() {
2054
+ const bytes = randomBytes(64);
2055
+ return mod(bytesToBigint(bytes));
2056
+ }
2057
+ function fiatShamirChallenge(domain, ...points) {
2058
+ const domainBytes = stringToBytes(domain);
2059
+ const combined = concatBytes(domainBytes, ...points);
2060
+ const hash2 = sha256.sha256(combined);
2061
+ return mod(bytesToBigint(hash2));
2062
+ }
2063
+ function createPedersenCommitment(value) {
2064
+ const v = mod(BigInt(value));
2065
+ const b = randomScalar();
2066
+ const C = safeMultiply(G, v).add(safeMultiply(H, b));
2067
+ return {
2068
+ commitment: toBase64url(C.toRawBytes()),
2069
+ blinding_factor: toBase64url(bigintToBytes(b)),
2070
+ committed_at: (/* @__PURE__ */ new Date()).toISOString()
2071
+ };
2072
+ }
2073
+ function verifyPedersenCommitment(commitment, value, blindingFactor) {
2074
+ try {
2075
+ const C = ed25519.RistrettoPoint.fromHex(fromBase64url(commitment));
2076
+ const v = mod(BigInt(value));
2077
+ const b = bytesToBigint(fromBase64url(blindingFactor));
2078
+ const expected = safeMultiply(G, v).add(safeMultiply(H, b));
2079
+ return C.equals(expected);
2080
+ } catch {
2081
+ return false;
2082
+ }
2083
+ }
2084
+ function createProofOfKnowledge(value, blindingFactor, commitment) {
2085
+ const v = mod(BigInt(value));
2086
+ const b = bytesToBigint(fromBase64url(blindingFactor));
2087
+ const r_v = randomScalar();
2088
+ const r_b = randomScalar();
2089
+ const R = safeMultiply(G, r_v).add(safeMultiply(H, r_b));
2090
+ const C_bytes = fromBase64url(commitment);
2091
+ const R_bytes = R.toRawBytes();
2092
+ const e = fiatShamirChallenge("sanctuary-zk-pok-v1", C_bytes, R_bytes);
2093
+ const s_v = mod(r_v + e * v);
2094
+ const s_b = mod(r_b + e * b);
2095
+ return {
2096
+ type: "schnorr-pedersen-ristretto255",
2097
+ commitment,
2098
+ announcement: toBase64url(R_bytes),
2099
+ response_v: toBase64url(bigintToBytes(s_v)),
2100
+ response_b: toBase64url(bigintToBytes(s_b)),
2101
+ generated_at: (/* @__PURE__ */ new Date()).toISOString()
2102
+ };
2103
+ }
2104
+ function verifyProofOfKnowledge(proof) {
2105
+ try {
2106
+ const C = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.commitment));
2107
+ const R = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.announcement));
2108
+ const s_v = bytesToBigint(fromBase64url(proof.response_v));
2109
+ const s_b = bytesToBigint(fromBase64url(proof.response_b));
2110
+ const e = fiatShamirChallenge(
2111
+ "sanctuary-zk-pok-v1",
2112
+ fromBase64url(proof.commitment),
2113
+ fromBase64url(proof.announcement)
2114
+ );
2115
+ const lhs = safeMultiply(G, s_v).add(safeMultiply(H, s_b));
2116
+ const rhs = R.add(safeMultiply(C, e));
2117
+ return lhs.equals(rhs);
2118
+ } catch {
2119
+ return false;
2120
+ }
2121
+ }
2122
+ function createRangeProof(value, blindingFactor, commitment, min, max) {
2123
+ if (value < min || value > max) {
2124
+ return { error: `Value ${value} is not in range [${min}, ${max}]` };
2125
+ }
2126
+ const range = max - min;
2127
+ const numBits = Math.ceil(Math.log2(range + 1));
2128
+ const shifted = value - min;
2129
+ const b = bytesToBigint(fromBase64url(blindingFactor));
2130
+ const bits = [];
2131
+ for (let i = 0; i < numBits; i++) {
2132
+ bits.push(shifted >> i & 1);
2133
+ }
2134
+ const bitBlindings = [];
2135
+ const bitCommitments = [];
2136
+ const bitProofs = [];
2137
+ for (let i = 0; i < numBits; i++) {
2138
+ const bit_b = randomScalar();
2139
+ bitBlindings.push(bit_b);
2140
+ const C_i = safeMultiply(G, mod(BigInt(bits[i]))).add(safeMultiply(H, bit_b));
2141
+ bitCommitments.push(toBase64url(C_i.toRawBytes()));
2142
+ const bitProof = createBitProof(bits[i], bit_b, C_i);
2143
+ bitProofs.push(bitProof);
2144
+ }
2145
+ const sumBlinding = bitBlindings.reduce(
2146
+ (acc, bi, i) => mod(acc + mod(BigInt(1) << BigInt(i)) * bi),
2147
+ 0n
2148
+ );
2149
+ const blindingDiff = mod(b - sumBlinding);
2150
+ const r_sum = randomScalar();
2151
+ const R_sum = safeMultiply(H, r_sum);
2152
+ const e_sum = fiatShamirChallenge(
2153
+ "sanctuary-zk-range-sum-v1",
2154
+ fromBase64url(commitment),
2155
+ R_sum.toRawBytes()
2156
+ );
2157
+ const s_sum = mod(r_sum + e_sum * blindingDiff);
2158
+ return {
2159
+ type: "range-pedersen-ristretto255",
2160
+ commitment,
2161
+ min,
2162
+ max,
2163
+ bit_commitments: bitCommitments,
2164
+ bit_proofs: bitProofs,
2165
+ sum_proof: {
2166
+ announcement: toBase64url(R_sum.toRawBytes()),
2167
+ response: toBase64url(bigintToBytes(s_sum))
2168
+ },
2169
+ generated_at: (/* @__PURE__ */ new Date()).toISOString()
2170
+ };
2171
+ }
2172
+ function verifyRangeProof(proof) {
2173
+ try {
2174
+ const C = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.commitment));
2175
+ const range = proof.max - proof.min;
2176
+ const numBits = Math.ceil(Math.log2(range + 1));
2177
+ if (proof.bit_commitments.length !== numBits) return false;
2178
+ if (proof.bit_proofs.length !== numBits) return false;
2179
+ for (let i = 0; i < numBits; i++) {
2180
+ const C_i = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.bit_commitments[i]));
2181
+ if (!verifyBitProof(proof.bit_proofs[i], C_i)) {
2182
+ return false;
2183
+ }
2184
+ }
2185
+ let reconstructed = ed25519.RistrettoPoint.ZERO;
2186
+ for (let i = 0; i < numBits; i++) {
2187
+ const C_i = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.bit_commitments[i]));
2188
+ const weight = mod(BigInt(1) << BigInt(i));
2189
+ reconstructed = reconstructed.add(safeMultiply(C_i, weight));
2190
+ }
2191
+ const diff = C.subtract(safeMultiply(G, mod(BigInt(proof.min)))).subtract(reconstructed);
2192
+ const R_sum = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.sum_proof.announcement));
2193
+ const s_sum = bytesToBigint(fromBase64url(proof.sum_proof.response));
2194
+ const e_sum = fiatShamirChallenge(
2195
+ "sanctuary-zk-range-sum-v1",
2196
+ fromBase64url(proof.commitment),
2197
+ fromBase64url(proof.sum_proof.announcement)
2198
+ );
2199
+ const lhs = safeMultiply(H, s_sum);
2200
+ const rhs = R_sum.add(safeMultiply(diff, e_sum));
2201
+ return lhs.equals(rhs);
2202
+ } catch {
2203
+ return false;
2204
+ }
2205
+ }
2206
+ function createBitProof(bit, blinding, commitment) {
2207
+ const C_bytes = commitment.toRawBytes();
2208
+ if (bit === 0) {
2209
+ const C_minus_G = commitment.subtract(G);
2210
+ const e_1 = randomScalar();
2211
+ const s_1 = randomScalar();
2212
+ const R_1 = safeMultiply(H, s_1).subtract(safeMultiply(C_minus_G, e_1));
2213
+ const r_0 = randomScalar();
2214
+ const R_0 = safeMultiply(H, r_0);
2215
+ const e = fiatShamirChallenge(
2216
+ "sanctuary-zk-bit-v1",
2217
+ C_bytes,
2218
+ R_0.toRawBytes(),
2219
+ R_1.toRawBytes()
2220
+ );
2221
+ const e_0 = mod(e - e_1);
2222
+ const s_0 = mod(r_0 + e_0 * blinding);
2223
+ return {
2224
+ announcement_0: toBase64url(R_0.toRawBytes()),
2225
+ announcement_1: toBase64url(R_1.toRawBytes()),
2226
+ challenge_0: toBase64url(bigintToBytes(e_0)),
2227
+ challenge_1: toBase64url(bigintToBytes(e_1)),
2228
+ response_0: toBase64url(bigintToBytes(s_0)),
2229
+ response_1: toBase64url(bigintToBytes(s_1))
2230
+ };
2231
+ } else {
2232
+ const e_0 = randomScalar();
2233
+ const s_0 = randomScalar();
2234
+ const R_0 = safeMultiply(H, s_0).subtract(safeMultiply(commitment, e_0));
2235
+ const r_1 = randomScalar();
2236
+ const R_1 = safeMultiply(H, r_1);
2237
+ const e = fiatShamirChallenge(
2238
+ "sanctuary-zk-bit-v1",
2239
+ C_bytes,
2240
+ R_0.toRawBytes(),
2241
+ R_1.toRawBytes()
2242
+ );
2243
+ const e_1 = mod(e - e_0);
2244
+ const s_1 = mod(r_1 + e_1 * blinding);
2245
+ return {
2246
+ announcement_0: toBase64url(R_0.toRawBytes()),
2247
+ announcement_1: toBase64url(R_1.toRawBytes()),
2248
+ challenge_0: toBase64url(bigintToBytes(e_0)),
2249
+ challenge_1: toBase64url(bigintToBytes(e_1)),
2250
+ response_0: toBase64url(bigintToBytes(s_0)),
2251
+ response_1: toBase64url(bigintToBytes(s_1))
2252
+ };
2253
+ }
2254
+ }
2255
+ function verifyBitProof(proof, commitment) {
2256
+ try {
2257
+ const C_bytes = commitment.toRawBytes();
2258
+ const R_0 = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.announcement_0));
2259
+ const R_1 = ed25519.RistrettoPoint.fromHex(fromBase64url(proof.announcement_1));
2260
+ const e_0 = bytesToBigint(fromBase64url(proof.challenge_0));
2261
+ const e_1 = bytesToBigint(fromBase64url(proof.challenge_1));
2262
+ const s_0 = bytesToBigint(fromBase64url(proof.response_0));
2263
+ const s_1 = bytesToBigint(fromBase64url(proof.response_1));
2264
+ const e = fiatShamirChallenge(
2265
+ "sanctuary-zk-bit-v1",
2266
+ C_bytes,
2267
+ R_0.toRawBytes(),
2268
+ R_1.toRawBytes()
2269
+ );
2270
+ if (mod(e_0 + e_1) !== e) return false;
2271
+ const lhs_0 = safeMultiply(H, s_0);
2272
+ const rhs_0 = R_0.add(safeMultiply(commitment, e_0));
2273
+ if (!lhs_0.equals(rhs_0)) return false;
2274
+ const C_minus_G = commitment.subtract(G);
2275
+ const lhs_1 = safeMultiply(H, s_1);
2276
+ const rhs_1 = R_1.add(safeMultiply(C_minus_G, e_1));
2277
+ if (!lhs_1.equals(rhs_1)) return false;
2278
+ return true;
2279
+ } catch {
2280
+ return false;
2281
+ }
2282
+ }
1890
2283
 
1891
2284
  // src/l3-disclosure/tools.ts
1892
2285
  function createL3Tools(storage, masterKey, auditLog) {
@@ -2116,6 +2509,187 @@ function createL3Tools(storage, masterKey, auditLog) {
2116
2509
  overall_recommendation: withholding > 0 ? `Withholding ${withholding} of ${requestedFields.length} requested fields per policy "${policy.policy_name}"` : `All ${requestedFields.length} fields may be disclosed per policy "${policy.policy_name}"`
2117
2510
  });
2118
2511
  }
2512
+ },
2513
+ // ─── ZK Proof Tools ───────────────────────────────────────────────────
2514
+ {
2515
+ name: "sanctuary/zk_commit",
2516
+ description: "Create a Pedersen commitment to a numeric value on Ristretto255. Unlike SHA-256 commitments, Pedersen commitments support zero-knowledge proofs: you can prove properties about the committed value without revealing it.",
2517
+ inputSchema: {
2518
+ type: "object",
2519
+ properties: {
2520
+ value: {
2521
+ type: "number",
2522
+ description: "The integer value to commit to"
2523
+ }
2524
+ },
2525
+ required: ["value"]
2526
+ },
2527
+ handler: async (args) => {
2528
+ const value = args.value;
2529
+ if (!Number.isInteger(value)) {
2530
+ return toolResult({ error: "Value must be an integer." });
2531
+ }
2532
+ const commitment = createPedersenCommitment(value);
2533
+ auditLog.append("l3", "zk_commit", "system", {
2534
+ commitment_hash: commitment.commitment.slice(0, 16) + "..."
2535
+ });
2536
+ return toolResult({
2537
+ commitment: commitment.commitment,
2538
+ blinding_factor: commitment.blinding_factor,
2539
+ committed_at: commitment.committed_at,
2540
+ proof_system: "pedersen-ristretto255",
2541
+ note: "Store the blinding_factor securely. Use zk_prove to create proofs about this commitment."
2542
+ });
2543
+ }
2544
+ },
2545
+ {
2546
+ name: "sanctuary/zk_prove",
2547
+ description: "Create a zero-knowledge proof of knowledge for a Pedersen commitment. Proves you know the value and blinding factor without revealing either. Uses a Schnorr sigma protocol with Fiat-Shamir transform.",
2548
+ inputSchema: {
2549
+ type: "object",
2550
+ properties: {
2551
+ value: {
2552
+ type: "number",
2553
+ description: "The committed value (integer)"
2554
+ },
2555
+ blinding_factor: {
2556
+ type: "string",
2557
+ description: "The blinding factor from zk_commit (base64url)"
2558
+ },
2559
+ commitment: {
2560
+ type: "string",
2561
+ description: "The Pedersen commitment (base64url)"
2562
+ }
2563
+ },
2564
+ required: ["value", "blinding_factor", "commitment"]
2565
+ },
2566
+ handler: async (args) => {
2567
+ const value = args.value;
2568
+ const blindingFactor = args.blinding_factor;
2569
+ const commitment = args.commitment;
2570
+ if (!verifyPedersenCommitment(commitment, value, blindingFactor)) {
2571
+ return toolResult({
2572
+ error: "The provided value and blinding factor do not match the commitment."
2573
+ });
2574
+ }
2575
+ const proof = createProofOfKnowledge(value, blindingFactor, commitment);
2576
+ auditLog.append("l3", "zk_prove", "system", {
2577
+ proof_type: proof.type,
2578
+ commitment: commitment.slice(0, 16) + "..."
2579
+ });
2580
+ return toolResult({
2581
+ proof,
2582
+ note: "This proof demonstrates knowledge of the commitment opening without revealing the value."
2583
+ });
2584
+ }
2585
+ },
2586
+ {
2587
+ name: "sanctuary/zk_verify",
2588
+ description: "Verify a zero-knowledge proof of knowledge for a Pedersen commitment. Checks that the prover knows the commitment's opening without learning anything.",
2589
+ inputSchema: {
2590
+ type: "object",
2591
+ properties: {
2592
+ proof: {
2593
+ type: "object",
2594
+ description: "The ZK proof object from zk_prove"
2595
+ }
2596
+ },
2597
+ required: ["proof"]
2598
+ },
2599
+ handler: async (args) => {
2600
+ const proof = args.proof;
2601
+ const valid = verifyProofOfKnowledge(proof);
2602
+ auditLog.append("l3", "zk_verify", "system", {
2603
+ proof_type: proof.type,
2604
+ valid
2605
+ });
2606
+ return toolResult({
2607
+ valid,
2608
+ proof_type: proof.type,
2609
+ commitment: proof.commitment,
2610
+ verified_at: (/* @__PURE__ */ new Date()).toISOString()
2611
+ });
2612
+ }
2613
+ },
2614
+ {
2615
+ name: "sanctuary/zk_range_prove",
2616
+ description: "Create a zero-knowledge range proof: prove that a committed value is within [min, max] without revealing the exact value. Uses bit-decomposition with OR-proofs on Ristretto255.",
2617
+ inputSchema: {
2618
+ type: "object",
2619
+ properties: {
2620
+ value: {
2621
+ type: "number",
2622
+ description: "The committed value (integer)"
2623
+ },
2624
+ blinding_factor: {
2625
+ type: "string",
2626
+ description: "The blinding factor from zk_commit (base64url)"
2627
+ },
2628
+ commitment: {
2629
+ type: "string",
2630
+ description: "The Pedersen commitment (base64url)"
2631
+ },
2632
+ min: {
2633
+ type: "number",
2634
+ description: "Minimum of the range (inclusive)"
2635
+ },
2636
+ max: {
2637
+ type: "number",
2638
+ description: "Maximum of the range (inclusive)"
2639
+ }
2640
+ },
2641
+ required: ["value", "blinding_factor", "commitment", "min", "max"]
2642
+ },
2643
+ handler: async (args) => {
2644
+ const value = args.value;
2645
+ const blindingFactor = args.blinding_factor;
2646
+ const commitment = args.commitment;
2647
+ const min = args.min;
2648
+ const max = args.max;
2649
+ const proof = createRangeProof(value, blindingFactor, commitment, min, max);
2650
+ if ("error" in proof) {
2651
+ return toolResult({ error: proof.error });
2652
+ }
2653
+ auditLog.append("l3", "zk_range_prove", "system", {
2654
+ proof_type: proof.type,
2655
+ range: `[${min}, ${max}]`,
2656
+ bits: proof.bit_commitments.length
2657
+ });
2658
+ return toolResult({
2659
+ proof,
2660
+ note: `This proof demonstrates the committed value is in [${min}, ${max}] without revealing it.`
2661
+ });
2662
+ }
2663
+ },
2664
+ {
2665
+ name: "sanctuary/zk_range_verify",
2666
+ description: "Verify a zero-knowledge range proof \u2014 confirms a committed value is within the claimed range without learning the value.",
2667
+ inputSchema: {
2668
+ type: "object",
2669
+ properties: {
2670
+ proof: {
2671
+ type: "object",
2672
+ description: "The range proof object from zk_range_prove"
2673
+ }
2674
+ },
2675
+ required: ["proof"]
2676
+ },
2677
+ handler: async (args) => {
2678
+ const proof = args.proof;
2679
+ const valid = verifyRangeProof(proof);
2680
+ auditLog.append("l3", "zk_range_verify", "system", {
2681
+ proof_type: proof.type,
2682
+ valid,
2683
+ range: `[${proof.min}, ${proof.max}]`
2684
+ });
2685
+ return toolResult({
2686
+ valid,
2687
+ proof_type: proof.type,
2688
+ range: { min: proof.min, max: proof.max },
2689
+ commitment: proof.commitment,
2690
+ verified_at: (/* @__PURE__ */ new Date()).toISOString()
2691
+ });
2692
+ }
2119
2693
  }
2120
2694
  ];
2121
2695
  return { tools, commitmentStore, policyStore };
@@ -2164,7 +2738,7 @@ var ReputationStore = class {
2164
2738
  /**
2165
2739
  * Record an interaction outcome as a signed attestation.
2166
2740
  */
2167
- async record(interactionId, counterpartyDid, outcome, context, identity, identityEncryptionKey, counterpartyAttestation) {
2741
+ async record(interactionId, counterpartyDid, outcome, context, identity, identityEncryptionKey, counterpartyAttestation, sovereigntyTier) {
2168
2742
  const attestationId = `att-${Date.now()}-${toBase64url(randomBytes(8))}`;
2169
2743
  const now = (/* @__PURE__ */ new Date()).toISOString();
2170
2744
  const attestationData = {
@@ -2175,7 +2749,8 @@ var ReputationStore = class {
2175
2749
  outcome_result: outcome.result,
2176
2750
  metrics: outcome.metrics ?? {},
2177
2751
  context,
2178
- timestamp: now
2752
+ timestamp: now,
2753
+ sovereignty_tier: sovereigntyTier
2179
2754
  };
2180
2755
  const dataBytes = stringToBytes(JSON.stringify(attestationData));
2181
2756
  const signature = sign(
@@ -2424,6 +2999,24 @@ var ReputationStore = class {
2424
2999
  );
2425
3000
  return guarantee;
2426
3001
  }
3002
+ // ─── Tier-Aware Access ───────────────────────────────────────────────
3003
+ /**
3004
+ * Load attestations for tier-weighted scoring.
3005
+ * Applies basic context/counterparty filtering, returns full StoredAttestations
3006
+ * so callers can access sovereignty_tier from attestation data.
3007
+ */
3008
+ async loadAllForTierScoring(options) {
3009
+ let all = await this.loadAll();
3010
+ if (options?.context) {
3011
+ all = all.filter((a) => a.attestation.data.context === options.context);
3012
+ }
3013
+ if (options?.counterparty_did) {
3014
+ all = all.filter(
3015
+ (a) => a.attestation.data.counterparty_did === options.counterparty_did
3016
+ );
3017
+ }
3018
+ return all;
3019
+ }
2427
3020
  // ─── Internal ─────────────────────────────────────────────────────────
2428
3021
  async loadAll() {
2429
3022
  const results = [];
@@ -2447,9 +3040,70 @@ var ReputationStore = class {
2447
3040
 
2448
3041
  // src/l4-reputation/tools.ts
2449
3042
  init_encoding();
2450
- function createL4Tools(storage, masterKey, identityManager, auditLog) {
3043
+
3044
+ // src/l4-reputation/tiers.ts
3045
+ var TIER_WEIGHTS = {
3046
+ "verified-sovereign": 1,
3047
+ "verified-degraded": 0.8,
3048
+ "self-attested": 0.5,
3049
+ "unverified": 0.2
3050
+ };
3051
+ function resolveTier(counterpartyId, handshakeResults, hasSanctuaryIdentity) {
3052
+ const handshake = handshakeResults.get(counterpartyId);
3053
+ if (handshake && handshake.verified) {
3054
+ const expiresAt = new Date(handshake.expires_at);
3055
+ if (expiresAt > /* @__PURE__ */ new Date()) {
3056
+ return {
3057
+ sovereignty_tier: handshake.trust_tier,
3058
+ handshake_completed_at: handshake.completed_at,
3059
+ verified_by: handshake.counterparty_id
3060
+ };
3061
+ }
3062
+ }
3063
+ if (hasSanctuaryIdentity) {
3064
+ return { sovereignty_tier: "self-attested" };
3065
+ }
3066
+ return { sovereignty_tier: "unverified" };
3067
+ }
3068
+ function trustTierToSovereigntyTier(trustTier) {
3069
+ switch (trustTier) {
3070
+ case "verified-sovereign":
3071
+ return "verified-sovereign";
3072
+ case "verified-degraded":
3073
+ return "verified-degraded";
3074
+ default:
3075
+ return "unverified";
3076
+ }
3077
+ }
3078
+ function computeWeightedScore(attestations) {
3079
+ if (attestations.length === 0) return null;
3080
+ let weightedSum = 0;
3081
+ let totalWeight = 0;
3082
+ for (const a of attestations) {
3083
+ const weight = TIER_WEIGHTS[a.tier];
3084
+ weightedSum += a.value * weight;
3085
+ totalWeight += weight;
3086
+ }
3087
+ return totalWeight > 0 ? weightedSum / totalWeight : null;
3088
+ }
3089
+ function tierDistribution(tiers) {
3090
+ const dist = {
3091
+ "verified-sovereign": 0,
3092
+ "verified-degraded": 0,
3093
+ "self-attested": 0,
3094
+ "unverified": 0
3095
+ };
3096
+ for (const tier of tiers) {
3097
+ dist[tier]++;
3098
+ }
3099
+ return dist;
3100
+ }
3101
+
3102
+ // src/l4-reputation/tools.ts
3103
+ function createL4Tools(storage, masterKey, identityManager, auditLog, handshakeResults) {
2451
3104
  const reputationStore = new ReputationStore(storage, masterKey);
2452
3105
  const identityEncryptionKey = derivePurposeKey(masterKey, "identity-encryption");
3106
+ const hsResults = handshakeResults ?? /* @__PURE__ */ new Map();
2453
3107
  const tools = [
2454
3108
  // ─── Reputation Recording ─────────────────────────────────────────
2455
3109
  {
@@ -2511,26 +3165,34 @@ function createL4Tools(storage, masterKey, identityManager, auditLog) {
2511
3165
  }
2512
3166
  const outcome = args.outcome;
2513
3167
  const context = args.context ?? "general";
3168
+ const counterpartyDid = args.counterparty_did;
3169
+ const hasSanctuaryIdentity = identityManager.list().some(
3170
+ (id) => identityManager.get(id.identity_id)?.did === counterpartyDid
3171
+ );
3172
+ const tierMeta = resolveTier(counterpartyDid, hsResults, hasSanctuaryIdentity);
2514
3173
  const stored = await reputationStore.record(
2515
3174
  args.interaction_id,
2516
- args.counterparty_did,
3175
+ counterpartyDid,
2517
3176
  outcome,
2518
3177
  context,
2519
3178
  identity,
2520
3179
  identityEncryptionKey,
2521
- args.counterparty_attestation
3180
+ args.counterparty_attestation,
3181
+ tierMeta.sovereignty_tier
2522
3182
  );
2523
3183
  auditLog.append("l4", "reputation_record", identity.identity_id, {
2524
3184
  interaction_id: args.interaction_id,
2525
3185
  outcome_type: outcome.type,
2526
3186
  outcome_result: outcome.result,
2527
- context
3187
+ context,
3188
+ sovereignty_tier: tierMeta.sovereignty_tier
2528
3189
  });
2529
3190
  return toolResult({
2530
3191
  attestation_id: stored.attestation.attestation_id,
2531
3192
  interaction_id: stored.attestation.data.interaction_id,
2532
3193
  self_attestation: stored.attestation.signature,
2533
3194
  counterparty_confirmed: stored.counterparty_confirmed,
3195
+ sovereignty_tier: tierMeta.sovereignty_tier,
2534
3196
  context,
2535
3197
  recorded_at: stored.recorded_at
2536
3198
  });
@@ -2578,7 +3240,9 @@ function createL4Tools(storage, masterKey, identityManager, auditLog) {
2578
3240
  contexts: summary.contexts
2579
3241
  });
2580
3242
  return toolResult({
2581
- summary
3243
+ summary,
3244
+ // SEC-ADD-03: Tag response as containing counterparty-generated attestation data
3245
+ _content_trust: "external"
2582
3246
  });
2583
3247
  }
2584
3248
  },
@@ -2693,20 +3357,76 @@ function createL4Tools(storage, masterKey, identityManager, auditLog) {
2693
3357
  });
2694
3358
  }
2695
3359
  },
2696
- // ─── Trust Bootstrap: Escrow ──────────────────────────────────────
3360
+ // ─── Sovereignty-Weighted Query ──────────────────────────────────
2697
3361
  {
2698
- name: "sanctuary/bootstrap_create_escrow",
2699
- description: "Create an escrow record for trust bootstrapping. Allows new participants with no reputation to transact safely.",
3362
+ name: "sanctuary/reputation_query_weighted",
3363
+ description: "Query reputation with sovereignty-weighted scoring. Attestations from verified-sovereign agents carry full weight (1.0); unverified attestations carry reduced weight (0.2). Returns both the weighted score and tier distribution.",
2700
3364
  inputSchema: {
2701
3365
  type: "object",
2702
3366
  properties: {
2703
- transaction_terms: {
3367
+ metric: {
2704
3368
  type: "string",
2705
- description: "Description of the transaction"
3369
+ description: "Which metric to compute the weighted score for"
2706
3370
  },
2707
- collateral_amount: {
2708
- type: "number",
2709
- description: "Optional stake/collateral amount"
3371
+ context: {
3372
+ type: "string",
3373
+ description: "Filter by context/domain"
3374
+ },
3375
+ counterparty_did: {
3376
+ type: "string",
3377
+ description: "Filter by counterparty"
3378
+ }
3379
+ },
3380
+ required: ["metric"]
3381
+ },
3382
+ handler: async (args) => {
3383
+ const summary = await reputationStore.query({
3384
+ context: args.context,
3385
+ counterparty_did: args.counterparty_did
3386
+ });
3387
+ const allAttestations = await reputationStore.loadAllForTierScoring({
3388
+ context: args.context,
3389
+ counterparty_did: args.counterparty_did
3390
+ });
3391
+ const metric = args.metric;
3392
+ const tieredAttestations = allAttestations.filter((a) => a.attestation.data.metrics[metric] !== void 0).map((a) => ({
3393
+ value: a.attestation.data.metrics[metric],
3394
+ tier: a.attestation.data.sovereignty_tier ?? "unverified"
3395
+ }));
3396
+ const weightedScore = computeWeightedScore(tieredAttestations);
3397
+ const tiers = allAttestations.map(
3398
+ (a) => a.attestation.data.sovereignty_tier ?? "unverified"
3399
+ );
3400
+ const dist = tierDistribution(tiers);
3401
+ auditLog.append("l4", "reputation_query_weighted", "system", {
3402
+ metric,
3403
+ attestation_count: tieredAttestations.length,
3404
+ weighted_score: weightedScore
3405
+ });
3406
+ return toolResult({
3407
+ metric,
3408
+ weighted_score: weightedScore,
3409
+ attestation_count: tieredAttestations.length,
3410
+ tier_distribution: dist,
3411
+ tier_weights: TIER_WEIGHTS,
3412
+ unweighted_summary: summary
3413
+ });
3414
+ }
3415
+ },
3416
+ // ─── Trust Bootstrap: Escrow ──────────────────────────────────────
3417
+ {
3418
+ name: "sanctuary/bootstrap_create_escrow",
3419
+ description: "Create an escrow record for trust bootstrapping. Allows new participants with no reputation to transact safely.",
3420
+ inputSchema: {
3421
+ type: "object",
3422
+ properties: {
3423
+ transaction_terms: {
3424
+ type: "string",
3425
+ description: "Description of the transaction"
3426
+ },
3427
+ collateral_amount: {
3428
+ type: "number",
3429
+ description: "Optional stake/collateral amount"
2710
3430
  },
2711
3431
  counterparty_did: {
2712
3432
  type: "string",
@@ -2843,14 +3563,16 @@ var DEFAULT_TIER2 = {
2843
3563
  };
2844
3564
  var DEFAULT_CHANNEL = {
2845
3565
  type: "stderr",
2846
- timeout_seconds: 300,
2847
- auto_deny: true
3566
+ timeout_seconds: 300
3567
+ // SEC-002: auto_deny is not configurable. Timeout always denies.
3568
+ // Field omitted intentionally — all channels hardcode deny on timeout.
2848
3569
  };
2849
3570
  var DEFAULT_POLICY = {
2850
3571
  version: 1,
2851
3572
  tier1_always_approve: [
2852
3573
  "state_export",
2853
3574
  "state_import",
3575
+ "state_delete",
2854
3576
  "identity_rotate",
2855
3577
  "reputation_import",
2856
3578
  "bootstrap_provide_guarantee"
@@ -2860,7 +3582,6 @@ var DEFAULT_POLICY = {
2860
3582
  "state_read",
2861
3583
  "state_write",
2862
3584
  "state_list",
2863
- "state_delete",
2864
3585
  "identity_create",
2865
3586
  "identity_list",
2866
3587
  "identity_sign",
@@ -2878,7 +3599,22 @@ var DEFAULT_POLICY = {
2878
3599
  "monitor_audit_log",
2879
3600
  "manifest",
2880
3601
  "principal_policy_view",
2881
- "principal_baseline_view"
3602
+ "principal_baseline_view",
3603
+ "shr_generate",
3604
+ "shr_verify",
3605
+ "handshake_initiate",
3606
+ "handshake_respond",
3607
+ "handshake_complete",
3608
+ "handshake_status",
3609
+ "reputation_query_weighted",
3610
+ "federation_peers",
3611
+ "federation_trust_evaluate",
3612
+ "federation_status",
3613
+ "zk_commit",
3614
+ "zk_prove",
3615
+ "zk_verify",
3616
+ "zk_range_prove",
3617
+ "zk_range_verify"
2882
3618
  ],
2883
3619
  approval_channel: DEFAULT_CHANNEL
2884
3620
  };
@@ -2953,10 +3689,14 @@ function validatePolicy(raw) {
2953
3689
  ...raw.tier2_anomaly ?? {}
2954
3690
  },
2955
3691
  tier3_always_allow: raw.tier3_always_allow ?? DEFAULT_POLICY.tier3_always_allow,
2956
- approval_channel: {
2957
- ...DEFAULT_CHANNEL,
2958
- ...raw.approval_channel ?? {}
2959
- }
3692
+ approval_channel: (() => {
3693
+ const merged = {
3694
+ ...DEFAULT_CHANNEL,
3695
+ ...raw.approval_channel ?? {}
3696
+ };
3697
+ delete merged.auto_deny;
3698
+ return merged;
3699
+ })()
2960
3700
  };
2961
3701
  }
2962
3702
  function generateDefaultPolicyYaml() {
@@ -2973,6 +3713,7 @@ version: 1
2973
3713
  tier1_always_approve:
2974
3714
  - state_export
2975
3715
  - state_import
3716
+ - state_delete
2976
3717
  - identity_rotate
2977
3718
  - reputation_import
2978
3719
  - bootstrap_provide_guarantee
@@ -2994,7 +3735,6 @@ tier3_always_allow:
2994
3735
  - state_read
2995
3736
  - state_write
2996
3737
  - state_list
2997
- - state_delete
2998
3738
  - identity_create
2999
3739
  - identity_list
3000
3740
  - identity_sign
@@ -3013,13 +3753,28 @@ tier3_always_allow:
3013
3753
  - manifest
3014
3754
  - principal_policy_view
3015
3755
  - principal_baseline_view
3756
+ - shr_generate
3757
+ - shr_verify
3758
+ - handshake_initiate
3759
+ - handshake_respond
3760
+ - handshake_complete
3761
+ - handshake_status
3762
+ - reputation_query_weighted
3763
+ - federation_peers
3764
+ - federation_trust_evaluate
3765
+ - federation_status
3766
+ - zk_commit
3767
+ - zk_prove
3768
+ - zk_verify
3769
+ - zk_range_prove
3770
+ - zk_range_verify
3016
3771
 
3017
3772
  # \u2500\u2500\u2500 Approval Channel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3018
3773
  # How Sanctuary reaches you when approval is needed.
3774
+ # NOTE: Timeout always results in denial. This is not configurable (SEC-002).
3019
3775
  approval_channel:
3020
3776
  type: stderr
3021
3777
  timeout_seconds: 300
3022
- auto_deny: true
3023
3778
  `;
3024
3779
  }
3025
3780
  async function loadPrincipalPolicy(storagePath) {
@@ -3196,27 +3951,16 @@ var BaselineTracker = class {
3196
3951
 
3197
3952
  // src/principal-policy/approval-channel.ts
3198
3953
  var StderrApprovalChannel = class {
3199
- config;
3200
- constructor(config) {
3201
- this.config = config;
3954
+ constructor(_config) {
3202
3955
  }
3203
3956
  async requestApproval(request) {
3204
3957
  const prompt = this.formatPrompt(request);
3205
3958
  process.stderr.write(prompt + "\n");
3206
- await new Promise((resolve) => setTimeout(resolve, 100));
3207
- if (this.config.auto_deny) {
3208
- return {
3209
- decision: "deny",
3210
- decided_at: (/* @__PURE__ */ new Date()).toISOString(),
3211
- decided_by: "timeout"
3212
- };
3213
- } else {
3214
- return {
3215
- decision: "approve",
3216
- decided_at: (/* @__PURE__ */ new Date()).toISOString(),
3217
- decided_by: "auto"
3218
- };
3219
- }
3959
+ return {
3960
+ decision: "deny",
3961
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
3962
+ decided_by: "stderr:non-interactive"
3963
+ };
3220
3964
  }
3221
3965
  formatPrompt(request) {
3222
3966
  const tierLabel = request.tier === 1 ? "Tier 1 \u2014 always requires approval" : "Tier 2 \u2014 behavioral anomaly detected";
@@ -3224,7 +3968,7 @@ var StderrApprovalChannel = class {
3224
3968
  return [
3225
3969
  "",
3226
3970
  "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
3227
- "\u2551 SANCTUARY: Approval Required \u2551",
3971
+ "\u2551 SANCTUARY: Operation Denied (non-interactive channel) \u2551",
3228
3972
  "\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563",
3229
3973
  `\u2551 Operation: ${request.operation.padEnd(50)}\u2551`,
3230
3974
  `\u2551 ${tierLabel.padEnd(62)}\u2551`,
@@ -3235,113 +3979,1391 @@ var StderrApprovalChannel = class {
3235
3979
  (line) => `\u2551 ${line.padEnd(60)}\u2551`
3236
3980
  ),
3237
3981
  "\u2551 \u2551",
3238
- this.config.auto_deny ? "\u2551 Auto-denying (configure approval_channel.auto_deny to change) \u2551" : "\u2551 Auto-approving (informational mode) \u2551",
3982
+ "\u2551 Denied: stderr channel cannot accept input (SEC-016) \u2551",
3983
+ "\u2551 Use dashboard or webhook channel for interactive approval. \u2551",
3239
3984
  "\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
3240
3985
  ""
3241
3986
  ].join("\n");
3242
3987
  }
3243
3988
  };
3244
3989
 
3245
- // src/principal-policy/gate.ts
3246
- var ApprovalGate = class {
3247
- policy;
3248
- baseline;
3249
- channel;
3250
- auditLog;
3251
- constructor(policy, baseline, channel, auditLog) {
3252
- this.policy = policy;
3253
- this.baseline = baseline;
3254
- this.channel = channel;
3255
- this.auditLog = auditLog;
3990
+ // src/principal-policy/dashboard-html.ts
3991
+ function generateDashboardHTML(options) {
3992
+ return `<!DOCTYPE html>
3993
+ <html lang="en">
3994
+ <head>
3995
+ <meta charset="utf-8">
3996
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3997
+ <title>Sanctuary \u2014 Principal Dashboard</title>
3998
+ <style>
3999
+ :root {
4000
+ --bg: #0f1117;
4001
+ --bg-surface: #1a1d27;
4002
+ --bg-elevated: #242736;
4003
+ --border: #2e3244;
4004
+ --text: #e4e6f0;
4005
+ --text-muted: #8b8fa3;
4006
+ --accent: #6c8aff;
4007
+ --accent-hover: #839dff;
4008
+ --approve: #3ecf8e;
4009
+ --approve-hover: #5dd9a3;
4010
+ --deny: #f87171;
4011
+ --deny-hover: #fca5a5;
4012
+ --warning: #fbbf24;
4013
+ --tier1: #f87171;
4014
+ --tier2: #fbbf24;
4015
+ --tier3: #3ecf8e;
4016
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
4017
+ --mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
4018
+ --radius: 8px;
4019
+ }
4020
+
4021
+ * { box-sizing: border-box; margin: 0; padding: 0; }
4022
+ body {
4023
+ font-family: var(--font);
4024
+ background: var(--bg);
4025
+ color: var(--text);
4026
+ line-height: 1.5;
4027
+ min-height: 100vh;
4028
+ }
4029
+
4030
+ /* Layout */
4031
+ .container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
4032
+
4033
+ header {
4034
+ display: flex; align-items: center; justify-content: space-between;
4035
+ padding-bottom: 20px; border-bottom: 1px solid var(--border);
4036
+ margin-bottom: 24px;
4037
+ }
4038
+ header h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
4039
+ header h1 span { color: var(--accent); }
4040
+ .status-badge {
4041
+ display: inline-flex; align-items: center; gap: 6px;
4042
+ font-size: 12px; color: var(--text-muted);
4043
+ padding: 4px 10px; border-radius: 12px;
4044
+ background: var(--bg-surface); border: 1px solid var(--border);
4045
+ }
4046
+ .status-dot {
4047
+ width: 8px; height: 8px; border-radius: 50%;
4048
+ background: var(--approve); animation: pulse 2s infinite;
4049
+ }
4050
+ .status-dot.disconnected { background: var(--deny); animation: none; }
4051
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
4052
+
4053
+ /* Tabs */
4054
+ .tabs {
4055
+ display: flex; gap: 2px; margin-bottom: 20px;
4056
+ background: var(--bg-surface); border-radius: var(--radius);
4057
+ padding: 3px; border: 1px solid var(--border);
4058
+ }
4059
+ .tab {
4060
+ flex: 1; padding: 8px 12px; text-align: center;
4061
+ font-size: 13px; font-weight: 500; cursor: pointer;
4062
+ border-radius: 6px; border: none; color: var(--text-muted);
4063
+ background: transparent; transition: all 0.15s;
4064
+ }
4065
+ .tab:hover { color: var(--text); }
4066
+ .tab.active { background: var(--bg-elevated); color: var(--text); }
4067
+ .tab .count {
4068
+ display: inline-flex; align-items: center; justify-content: center;
4069
+ min-width: 18px; height: 18px; padding: 0 5px;
4070
+ font-size: 11px; font-weight: 600; border-radius: 9px;
4071
+ margin-left: 6px;
4072
+ }
4073
+ .tab .count.alert { background: var(--deny); color: white; }
4074
+ .tab .count.muted { background: var(--border); color: var(--text-muted); }
4075
+
4076
+ /* Tab Content */
4077
+ .tab-content { display: none; }
4078
+ .tab-content.active { display: block; }
4079
+
4080
+ /* Pending Requests */
4081
+ .pending-empty {
4082
+ text-align: center; padding: 60px 20px; color: var(--text-muted);
4083
+ }
4084
+ .pending-empty .icon { font-size: 32px; margin-bottom: 12px; }
4085
+ .pending-empty p { font-size: 14px; }
4086
+
4087
+ .request-card {
4088
+ background: var(--bg-surface); border: 1px solid var(--border);
4089
+ border-radius: var(--radius); padding: 16px; margin-bottom: 12px;
4090
+ animation: slideIn 0.2s ease-out;
4091
+ }
4092
+ @keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
4093
+ .request-card.tier1 { border-left: 3px solid var(--tier1); }
4094
+ .request-card.tier2 { border-left: 3px solid var(--tier2); }
4095
+ .request-header {
4096
+ display: flex; align-items: center; justify-content: space-between;
4097
+ margin-bottom: 10px;
4098
+ }
4099
+ .request-op {
4100
+ font-family: var(--mono); font-size: 14px; font-weight: 600;
4101
+ }
4102
+ .tier-badge {
4103
+ font-size: 11px; font-weight: 600; padding: 2px 8px;
4104
+ border-radius: 4px; text-transform: uppercase;
4105
+ }
4106
+ .tier-badge.tier1 { background: rgba(248,113,113,0.15); color: var(--tier1); }
4107
+ .tier-badge.tier2 { background: rgba(251,191,36,0.15); color: var(--tier2); }
4108
+ .request-reason {
4109
+ font-size: 13px; color: var(--text-muted); margin-bottom: 12px;
4110
+ }
4111
+ .request-context {
4112
+ font-family: var(--mono); font-size: 12px; color: var(--text-muted);
4113
+ background: var(--bg); border-radius: 4px; padding: 8px 10px;
4114
+ margin-bottom: 14px; white-space: pre-wrap; word-break: break-all;
4115
+ max-height: 120px; overflow-y: auto;
4116
+ }
4117
+ .request-actions {
4118
+ display: flex; align-items: center; gap: 10px;
4119
+ }
4120
+ .btn {
4121
+ padding: 7px 16px; border-radius: 6px; font-size: 13px;
4122
+ font-weight: 600; border: none; cursor: pointer;
4123
+ transition: all 0.15s;
4124
+ }
4125
+ .btn-approve { background: var(--approve); color: #0f1117; }
4126
+ .btn-approve:hover { background: var(--approve-hover); }
4127
+ .btn-deny { background: var(--deny); color: white; }
4128
+ .btn-deny:hover { background: var(--deny-hover); }
4129
+ .countdown {
4130
+ margin-left: auto; font-size: 12px; color: var(--text-muted);
4131
+ font-family: var(--mono);
4132
+ }
4133
+ .countdown.urgent { color: var(--deny); font-weight: 600; }
4134
+
4135
+ /* Audit Log */
4136
+ .audit-table { width: 100%; border-collapse: collapse; }
4137
+ .audit-table th {
4138
+ text-align: left; font-size: 11px; font-weight: 600;
4139
+ text-transform: uppercase; letter-spacing: 0.5px;
4140
+ color: var(--text-muted); padding: 8px 10px;
4141
+ border-bottom: 1px solid var(--border);
4142
+ }
4143
+ .audit-table td {
4144
+ font-size: 13px; padding: 8px 10px;
4145
+ border-bottom: 1px solid var(--border);
4146
+ }
4147
+ .audit-table tr { transition: background 0.1s; }
4148
+ .audit-table tr:hover { background: var(--bg-elevated); }
4149
+ .audit-table tr.new { animation: highlight 1s ease-out; }
4150
+ @keyframes highlight { from { background: rgba(108,138,255,0.15); } to { background: transparent; } }
4151
+ .audit-time { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
4152
+ .audit-op { font-family: var(--mono); font-size: 12px; }
4153
+ .audit-layer {
4154
+ font-size: 11px; font-weight: 600; padding: 1px 6px;
4155
+ border-radius: 3px; text-transform: uppercase;
4156
+ }
4157
+ .audit-layer.l1 { background: rgba(108,138,255,0.15); color: var(--accent); }
4158
+ .audit-layer.l2 { background: rgba(251,191,36,0.15); color: var(--tier2); }
4159
+ .audit-layer.l3 { background: rgba(62,207,142,0.15); color: var(--tier3); }
4160
+ .audit-layer.l4 { background: rgba(168,85,247,0.15); color: #a855f7; }
4161
+
4162
+ /* Baseline & Policy */
4163
+ .info-section {
4164
+ background: var(--bg-surface); border: 1px solid var(--border);
4165
+ border-radius: var(--radius); padding: 16px; margin-bottom: 16px;
4166
+ }
4167
+ .info-section h3 {
4168
+ font-size: 13px; font-weight: 600; text-transform: uppercase;
4169
+ letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 12px;
4170
+ }
4171
+ .info-row {
4172
+ display: flex; justify-content: space-between; align-items: center;
4173
+ padding: 6px 0; font-size: 13px;
4174
+ }
4175
+ .info-label { color: var(--text-muted); }
4176
+ .info-value { font-family: var(--mono); font-size: 12px; }
4177
+ .tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
4178
+ .tag {
4179
+ font-family: var(--mono); font-size: 11px; padding: 2px 8px;
4180
+ background: var(--bg-elevated); border-radius: 4px;
4181
+ color: var(--text-muted); border: 1px solid var(--border);
4182
+ }
4183
+ .policy-op {
4184
+ font-family: var(--mono); font-size: 12px; padding: 3px 0;
4185
+ }
4186
+
4187
+ /* Footer */
4188
+ footer {
4189
+ margin-top: 32px; padding-top: 16px;
4190
+ border-top: 1px solid var(--border);
4191
+ font-size: 12px; color: var(--text-muted);
4192
+ text-align: center;
4193
+ }
4194
+ </style>
4195
+ </head>
4196
+ <body>
4197
+ <div class="container">
4198
+ <header>
4199
+ <h1><span>Sanctuary</span> Principal Dashboard</h1>
4200
+ <div class="status-badge">
4201
+ <div class="status-dot" id="statusDot"></div>
4202
+ <span id="statusText">Connected</span>
4203
+ </div>
4204
+ </header>
4205
+
4206
+ <div class="tabs">
4207
+ <button class="tab active" data-tab="pending">
4208
+ Pending<span class="count muted" id="pendingCount">0</span>
4209
+ </button>
4210
+ <button class="tab" data-tab="audit">
4211
+ Audit Log<span class="count muted" id="auditCount">0</span>
4212
+ </button>
4213
+ <button class="tab" data-tab="baseline">Baseline</button>
4214
+ <button class="tab" data-tab="policy">Policy</button>
4215
+ </div>
4216
+
4217
+ <!-- Pending Approvals -->
4218
+ <div class="tab-content active" id="tab-pending">
4219
+ <div class="pending-empty" id="pendingEmpty">
4220
+ <div class="icon">&#x2714;</div>
4221
+ <p>No pending approval requests.</p>
4222
+ <p style="font-size:12px; margin-top:4px;">Requests will appear here in real time.</p>
4223
+ </div>
4224
+ <div id="pendingList"></div>
4225
+ </div>
4226
+
4227
+ <!-- Audit Log -->
4228
+ <div class="tab-content" id="tab-audit">
4229
+ <table class="audit-table">
4230
+ <thead>
4231
+ <tr><th>Time</th><th>Layer</th><th>Operation</th><th>Identity</th></tr>
4232
+ </thead>
4233
+ <tbody id="auditBody"></tbody>
4234
+ </table>
4235
+ </div>
4236
+
4237
+ <!-- Baseline -->
4238
+ <div class="tab-content" id="tab-baseline">
4239
+ <div class="info-section">
4240
+ <h3>Session Info</h3>
4241
+ <div class="info-row"><span class="info-label">First session</span><span class="info-value" id="bFirstSession">\u2014</span></div>
4242
+ <div class="info-row"><span class="info-label">Started</span><span class="info-value" id="bStarted">\u2014</span></div>
4243
+ </div>
4244
+ <div class="info-section">
4245
+ <h3>Known Namespaces</h3>
4246
+ <div class="tag-list" id="bNamespaces"><span class="tag">\u2014</span></div>
4247
+ </div>
4248
+ <div class="info-section">
4249
+ <h3>Known Counterparties</h3>
4250
+ <div class="tag-list" id="bCounterparties"><span class="tag">\u2014</span></div>
4251
+ </div>
4252
+ <div class="info-section">
4253
+ <h3>Tool Call Counts</h3>
4254
+ <div id="bToolCalls"><span class="info-value">\u2014</span></div>
4255
+ </div>
4256
+ </div>
4257
+
4258
+ <!-- Policy -->
4259
+ <div class="tab-content" id="tab-policy">
4260
+ <div class="info-section">
4261
+ <h3>Tier 1 \u2014 Always Requires Approval</h3>
4262
+ <div id="pTier1"></div>
4263
+ </div>
4264
+ <div class="info-section">
4265
+ <h3>Tier 2 \u2014 Anomaly Detection</h3>
4266
+ <div id="pTier2"></div>
4267
+ </div>
4268
+ <div class="info-section">
4269
+ <h3>Tier 3 \u2014 Always Allowed</h3>
4270
+ <div class="info-row">
4271
+ <span class="info-label">Operations</span>
4272
+ <span class="info-value" id="pTier3Count">\u2014</span>
4273
+ </div>
4274
+ </div>
4275
+ <div class="info-section">
4276
+ <h3>Approval Channel</h3>
4277
+ <div id="pChannel"></div>
4278
+ </div>
4279
+ </div>
4280
+
4281
+ <footer>Sanctuary Framework v${options.serverVersion} \u2014 Principal Dashboard</footer>
4282
+ </div>
4283
+
4284
+ <script>
4285
+ (function() {
4286
+ const TIMEOUT = ${options.timeoutSeconds};
4287
+ // SEC-012: Auth token is passed via Authorization header only \u2014 never in URLs.
4288
+ // The token is provided by the server at generation time (embedded for initial auth).
4289
+ const AUTH_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
4290
+ let SESSION_ID = null; // Short-lived session for SSE and URL-based requests
4291
+ const pending = new Map();
4292
+ let auditCount = 0;
4293
+
4294
+ // Auth helpers \u2014 SEC-012: token goes in header, session goes in URL
4295
+ function authHeaders() {
4296
+ const h = { 'Content-Type': 'application/json' };
4297
+ if (AUTH_TOKEN) h['Authorization'] = 'Bearer ' + AUTH_TOKEN;
4298
+ return h;
4299
+ }
4300
+ function sessionQuery(url) {
4301
+ if (!SESSION_ID) return url;
4302
+ const sep = url.includes('?') ? '&' : '?';
4303
+ return url + sep + 'session=' + SESSION_ID;
4304
+ }
4305
+
4306
+ // SEC-012: Exchange the long-lived token for a short-lived session
4307
+ async function exchangeSession() {
4308
+ if (!AUTH_TOKEN) return;
4309
+ try {
4310
+ const resp = await fetch('/auth/session', { method: 'POST', headers: authHeaders() });
4311
+ if (resp.ok) {
4312
+ const data = await resp.json();
4313
+ SESSION_ID = data.session_id;
4314
+ // Refresh session before expiry (at 80% of TTL)
4315
+ const refreshMs = (data.expires_in_seconds || 300) * 800;
4316
+ setTimeout(async () => { await exchangeSession(); reconnectSSE(); }, refreshMs);
4317
+ }
4318
+ } catch(e) { /* will retry on next connect */ }
4319
+ }
4320
+
4321
+ // Tab switching
4322
+ document.querySelectorAll('.tab').forEach(tab => {
4323
+ tab.addEventListener('click', () => {
4324
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
4325
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
4326
+ tab.classList.add('active');
4327
+ document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
4328
+ });
4329
+ });
4330
+
4331
+ // SSE Connection \u2014 SEC-012: uses short-lived session token in URL, not auth token
4332
+ let evtSource;
4333
+ function reconnectSSE() {
4334
+ if (evtSource) { evtSource.close(); }
4335
+ connect();
4336
+ }
4337
+ function connect() {
4338
+ evtSource = new EventSource(sessionQuery('/events'));
4339
+ evtSource.onopen = () => {
4340
+ document.getElementById('statusDot').classList.remove('disconnected');
4341
+ document.getElementById('statusText').textContent = 'Connected';
4342
+ };
4343
+ evtSource.onerror = () => {
4344
+ document.getElementById('statusDot').classList.add('disconnected');
4345
+ document.getElementById('statusText').textContent = 'Reconnecting...';
4346
+ };
4347
+ evtSource.addEventListener('pending-request', (e) => {
4348
+ const data = JSON.parse(e.data);
4349
+ addPendingRequest(data);
4350
+ });
4351
+ evtSource.addEventListener('request-resolved', (e) => {
4352
+ const data = JSON.parse(e.data);
4353
+ removePendingRequest(data.request_id);
4354
+ });
4355
+ evtSource.addEventListener('audit-entry', (e) => {
4356
+ const data = JSON.parse(e.data);
4357
+ addAuditEntry(data);
4358
+ });
4359
+ evtSource.addEventListener('baseline-update', (e) => {
4360
+ const data = JSON.parse(e.data);
4361
+ updateBaseline(data);
4362
+ });
4363
+ evtSource.addEventListener('policy-update', (e) => {
4364
+ const data = JSON.parse(e.data);
4365
+ updatePolicy(data);
4366
+ });
4367
+ evtSource.addEventListener('init', (e) => {
4368
+ const data = JSON.parse(e.data);
4369
+ if (data.baseline) updateBaseline(data.baseline);
4370
+ if (data.policy) updatePolicy(data.policy);
4371
+ if (data.pending) data.pending.forEach(addPendingRequest);
4372
+ if (data.audit) data.audit.forEach(addAuditEntry);
4373
+ });
4374
+ }
4375
+
4376
+ // Pending requests
4377
+ function addPendingRequest(req) {
4378
+ pending.set(req.request_id, { ...req, remaining: TIMEOUT });
4379
+ renderPending();
4380
+ updatePendingCount();
4381
+ flashTab('pending');
4382
+ }
4383
+
4384
+ function removePendingRequest(id) {
4385
+ pending.delete(id);
4386
+ renderPending();
4387
+ updatePendingCount();
4388
+ }
4389
+
4390
+ function renderPending() {
4391
+ const list = document.getElementById('pendingList');
4392
+ const empty = document.getElementById('pendingEmpty');
4393
+ if (pending.size === 0) {
4394
+ list.innerHTML = '';
4395
+ empty.style.display = 'block';
4396
+ return;
4397
+ }
4398
+ empty.style.display = 'none';
4399
+ list.innerHTML = '';
4400
+ for (const [id, req] of pending) {
4401
+ const card = document.createElement('div');
4402
+ card.className = 'request-card tier' + req.tier;
4403
+ card.id = 'req-' + id;
4404
+ const ctx = typeof req.context === 'string' ? req.context : JSON.stringify(req.context, null, 2);
4405
+ card.innerHTML =
4406
+ '<div class="request-header">' +
4407
+ '<span class="request-op">' + esc(req.operation) + '</span>' +
4408
+ '<span class="tier-badge tier' + req.tier + '">Tier ' + req.tier + '</span>' +
4409
+ '</div>' +
4410
+ '<div class="request-reason">' + esc(req.reason) + '</div>' +
4411
+ '<div class="request-context">' + esc(ctx) + '</div>' +
4412
+ '<div class="request-actions">' +
4413
+ '<button class="btn btn-approve" onclick="handleApprove(\\'' + id + '\\')">Approve</button>' +
4414
+ '<button class="btn btn-deny" onclick="handleDeny(\\'' + id + '\\')">Deny</button>' +
4415
+ '<span class="countdown" id="cd-' + id + '">' + req.remaining + 's</span>' +
4416
+ '</div>';
4417
+ list.appendChild(card);
4418
+ }
4419
+ }
4420
+
4421
+ function updatePendingCount() {
4422
+ const el = document.getElementById('pendingCount');
4423
+ el.textContent = pending.size;
4424
+ el.className = pending.size > 0 ? 'count alert' : 'count muted';
4425
+ }
4426
+
4427
+ function flashTab(name) {
4428
+ const tab = document.querySelector('[data-tab="' + name + '"]');
4429
+ if (!tab.classList.contains('active')) {
4430
+ tab.style.background = 'rgba(248,113,113,0.15)';
4431
+ setTimeout(() => { tab.style.background = ''; }, 1500);
4432
+ }
4433
+ }
4434
+
4435
+ // Countdown timer
4436
+ setInterval(() => {
4437
+ for (const [id, req] of pending) {
4438
+ req.remaining = Math.max(0, req.remaining - 1);
4439
+ const el = document.getElementById('cd-' + id);
4440
+ if (el) {
4441
+ el.textContent = req.remaining + 's';
4442
+ el.className = req.remaining <= 30 ? 'countdown urgent' : 'countdown';
4443
+ }
4444
+ }
4445
+ }, 1000);
4446
+
4447
+ // Approve / Deny handlers (global scope)
4448
+ window.handleApprove = function(id) {
4449
+ fetch('/api/approve/' + id, { method: 'POST', headers: authHeaders() }).then(() => {
4450
+ removePendingRequest(id);
4451
+ });
4452
+ };
4453
+ window.handleDeny = function(id) {
4454
+ fetch('/api/deny/' + id, { method: 'POST', headers: authHeaders() }).then(() => {
4455
+ removePendingRequest(id);
4456
+ });
4457
+ };
4458
+
4459
+ // Audit log
4460
+ function addAuditEntry(entry) {
4461
+ auditCount++;
4462
+ document.getElementById('auditCount').textContent = auditCount;
4463
+ const tbody = document.getElementById('auditBody');
4464
+ const tr = document.createElement('tr');
4465
+ tr.className = 'new';
4466
+ const time = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '\u2014';
4467
+ const layer = entry.layer || '\u2014';
4468
+ tr.innerHTML =
4469
+ '<td class="audit-time">' + esc(time) + '</td>' +
4470
+ '<td><span class="audit-layer ' + layer + '">' + esc(layer) + '</span></td>' +
4471
+ '<td class="audit-op">' + esc(entry.operation || '\u2014') + '</td>' +
4472
+ '<td style="font-size:12px;color:var(--text-muted)">' + esc(entry.identity_id || '\u2014') + '</td>';
4473
+ tbody.insertBefore(tr, tbody.firstChild);
4474
+ // Keep last 100 entries
4475
+ while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild);
4476
+ }
4477
+
4478
+ // Baseline
4479
+ function updateBaseline(b) {
4480
+ if (!b) return;
4481
+ document.getElementById('bFirstSession').textContent = b.is_first_session ? 'Yes' : 'No';
4482
+ document.getElementById('bStarted').textContent = b.started_at ? new Date(b.started_at).toLocaleString() : '\u2014';
4483
+ const ns = document.getElementById('bNamespaces');
4484
+ ns.innerHTML = (b.known_namespaces || []).length > 0
4485
+ ? (b.known_namespaces || []).map(n => '<span class="tag">' + esc(n) + '</span>').join('')
4486
+ : '<span class="tag">none</span>';
4487
+ const cp = document.getElementById('bCounterparties');
4488
+ cp.innerHTML = (b.known_counterparties || []).length > 0
4489
+ ? (b.known_counterparties || []).map(c => '<span class="tag">' + esc(c.slice(0,16)) + '...</span>').join('')
4490
+ : '<span class="tag">none</span>';
4491
+ const tc = document.getElementById('bToolCalls');
4492
+ const counts = b.tool_call_counts || {};
4493
+ const entries = Object.entries(counts).sort((a,b) => b[1] - a[1]);
4494
+ tc.innerHTML = entries.length > 0
4495
+ ? entries.map(([k,v]) => '<div class="info-row"><span class="info-label">' + esc(k) + '</span><span class="info-value">' + v + '</span></div>').join('')
4496
+ : '<span class="info-value">no calls yet</span>';
4497
+ }
4498
+
4499
+ // Policy
4500
+ function updatePolicy(p) {
4501
+ if (!p) return;
4502
+ const t1 = document.getElementById('pTier1');
4503
+ t1.innerHTML = (p.tier1_always_approve || []).map(op =>
4504
+ '<div class="policy-op">' + esc(op) + '</div>'
4505
+ ).join('');
4506
+ const t2 = document.getElementById('pTier2');
4507
+ const cfg = p.tier2_anomaly || {};
4508
+ t2.innerHTML = Object.entries(cfg).map(([k,v]) =>
4509
+ '<div class="info-row"><span class="info-label">' + esc(k) + '</span><span class="info-value">' + esc(String(v)) + '</span></div>'
4510
+ ).join('');
4511
+ document.getElementById('pTier3Count').textContent = (p.tier3_always_allow || []).length + ' operations';
4512
+ const ch = document.getElementById('pChannel');
4513
+ const chan = p.approval_channel || {};
4514
+ ch.innerHTML = Object.entries(chan).filter(([k]) => k !== 'webhook_secret').map(([k,v]) =>
4515
+ '<div class="info-row"><span class="info-label">' + esc(k) + '</span><span class="info-value">' + esc(String(v)) + '</span></div>'
4516
+ ).join('');
4517
+ }
4518
+
4519
+ function esc(s) {
4520
+ if (!s) return '';
4521
+ const d = document.createElement('div');
4522
+ d.textContent = String(s);
4523
+ return d.innerHTML;
4524
+ }
4525
+
4526
+ // Init \u2014 SEC-012: exchange token for session before connecting SSE
4527
+ (async function init() {
4528
+ await exchangeSession();
4529
+ // Clean token from URL if present (legacy bookmarks)
4530
+ if (window.location.search.includes('token=')) {
4531
+ const clean = window.location.pathname;
4532
+ window.history.replaceState({}, '', clean);
4533
+ }
4534
+ connect();
4535
+ fetch('/api/status', { headers: authHeaders() }).then(r => r.json()).then(data => {
4536
+ if (data.baseline) updateBaseline(data.baseline);
4537
+ if (data.policy) updatePolicy(data.policy);
4538
+ }).catch(() => {});
4539
+ })();
4540
+ })();
4541
+ </script>
4542
+ </body>
4543
+ </html>`;
4544
+ }
4545
+
4546
+ // src/principal-policy/dashboard.ts
4547
+ var SESSION_TTL_MS = 5 * 60 * 1e3;
4548
+ var MAX_SESSIONS = 1e3;
4549
+ var DashboardApprovalChannel = class {
4550
+ config;
4551
+ pending = /* @__PURE__ */ new Map();
4552
+ sseClients = /* @__PURE__ */ new Set();
4553
+ httpServer = null;
4554
+ policy = null;
4555
+ baseline = null;
4556
+ auditLog = null;
4557
+ dashboardHTML;
4558
+ authToken;
4559
+ useTLS;
4560
+ /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
4561
+ sessions = /* @__PURE__ */ new Map();
4562
+ sessionCleanupTimer = null;
4563
+ constructor(config) {
4564
+ this.config = config;
4565
+ this.authToken = config.auth_token;
4566
+ this.useTLS = !!(config.tls?.cert_path && config.tls?.key_path);
4567
+ this.dashboardHTML = generateDashboardHTML({
4568
+ timeoutSeconds: config.timeout_seconds,
4569
+ serverVersion: "0.3.0",
4570
+ authToken: this.authToken
4571
+ });
4572
+ this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
3256
4573
  }
3257
4574
  /**
3258
- * Evaluate a tool call against the Principal Policy.
3259
- *
3260
- * @param toolName - Full MCP tool name (e.g., "sanctuary/state_export")
3261
- * @param args - Tool call arguments (for context extraction)
3262
- * @returns GateResult indicating whether the call is allowed
4575
+ * Inject dependencies after construction.
4576
+ * Called from index.ts after all components are initialized.
3263
4577
  */
3264
- async evaluate(toolName, args) {
3265
- const operation = extractOperationName(toolName);
3266
- this.baseline.recordToolCall(operation);
3267
- if (this.policy.tier1_always_approve.includes(operation)) {
3268
- return this.requestApproval(operation, 1, `"${operation}" is a Tier 1 operation (always requires approval)`, {
3269
- operation,
3270
- args_summary: this.summarizeArgs(args)
4578
+ setDependencies(deps) {
4579
+ this.policy = deps.policy;
4580
+ this.baseline = deps.baseline;
4581
+ this.auditLog = deps.auditLog;
4582
+ }
4583
+ /**
4584
+ * Start the HTTP(S) server for the dashboard.
4585
+ */
4586
+ async start() {
4587
+ return new Promise((resolve, reject) => {
4588
+ const handler = (req, res) => this.handleRequest(req, res);
4589
+ if (this.useTLS && this.config.tls) {
4590
+ const tlsOpts = {
4591
+ cert: fs.readFileSync(this.config.tls.cert_path),
4592
+ key: fs.readFileSync(this.config.tls.key_path)
4593
+ };
4594
+ this.httpServer = https.createServer(tlsOpts, handler);
4595
+ } else {
4596
+ this.httpServer = http.createServer(handler);
4597
+ }
4598
+ const protocol = this.useTLS ? "https" : "http";
4599
+ const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
4600
+ this.httpServer.listen(this.config.port, this.config.host, () => {
4601
+ if (this.authToken) {
4602
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
4603
+ process.stderr.write(
4604
+ `
4605
+ Sanctuary Principal Dashboard: ${baseUrl}
4606
+ `
4607
+ );
4608
+ process.stderr.write(
4609
+ ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
4610
+
4611
+ `
4612
+ );
4613
+ } else {
4614
+ process.stderr.write(
4615
+ `
4616
+ Sanctuary Principal Dashboard: ${baseUrl}
4617
+
4618
+ `
4619
+ );
4620
+ }
4621
+ resolve();
4622
+ });
4623
+ this.httpServer.on("error", reject);
4624
+ });
4625
+ }
4626
+ /**
4627
+ * Stop the HTTP server and clean up.
4628
+ */
4629
+ async stop() {
4630
+ for (const [, pending] of this.pending) {
4631
+ clearTimeout(pending.timer);
4632
+ pending.resolve({
4633
+ decision: "deny",
4634
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
4635
+ decided_by: "auto"
3271
4636
  });
3272
4637
  }
3273
- const anomaly = this.detectAnomaly(operation, args);
3274
- if (anomaly) {
3275
- return this.requestApproval(operation, 2, anomaly.reason, anomaly.context);
4638
+ this.pending.clear();
4639
+ for (const client of this.sseClients) {
4640
+ client.end();
4641
+ }
4642
+ this.sseClients.clear();
4643
+ this.sessions.clear();
4644
+ if (this.sessionCleanupTimer) {
4645
+ clearInterval(this.sessionCleanupTimer);
4646
+ this.sessionCleanupTimer = null;
4647
+ }
4648
+ if (this.httpServer) {
4649
+ return new Promise((resolve) => {
4650
+ this.httpServer.close(() => resolve());
4651
+ });
3276
4652
  }
3277
- this.auditLog.append("l2", `gate_allow:${operation}`, "system", {
3278
- tier: 3,
3279
- operation
3280
- });
3281
- return {
3282
- allowed: true,
3283
- tier: 3,
3284
- reason: "Operation allowed (Tier 3)",
3285
- approval_required: false
3286
- };
3287
4653
  }
3288
4654
  /**
3289
- * Detect Tier 2 behavioral anomalies.
4655
+ * Request approval from the human via the dashboard.
4656
+ * Blocks until the human approves/denies or timeout occurs.
3290
4657
  */
3291
- detectAnomaly(operation, args) {
3292
- const config = this.policy.tier2_anomaly;
3293
- if (this.baseline.isFirstSession && config.first_session_policy === "approve") {
3294
- if (!this.policy.tier3_always_allow.includes(operation)) {
3295
- return {
3296
- reason: `First session: "${operation}" has no established baseline`,
3297
- context: { operation, is_first_session: true }
4658
+ async requestApproval(request) {
4659
+ const id = crypto.randomBytes(8).toString("hex");
4660
+ process.stderr.write(
4661
+ `[Sanctuary] Approval required: ${request.operation} (Tier ${request.tier}) \u2014 open dashboard to respond
4662
+ `
4663
+ );
4664
+ return new Promise((resolve) => {
4665
+ const timer = setTimeout(() => {
4666
+ this.pending.delete(id);
4667
+ const response = {
4668
+ // SEC-002: Timeout ALWAYS denies. No configuration can change this.
4669
+ decision: "deny",
4670
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
4671
+ decided_by: "timeout"
3298
4672
  };
4673
+ this.broadcastSSE("request-resolved", {
4674
+ request_id: id,
4675
+ decision: response.decision,
4676
+ decided_by: "timeout"
4677
+ });
4678
+ resolve(response);
4679
+ }, this.config.timeout_seconds * 1e3);
4680
+ const pending = {
4681
+ id,
4682
+ request,
4683
+ resolve,
4684
+ timer,
4685
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
4686
+ };
4687
+ this.pending.set(id, pending);
4688
+ this.broadcastSSE("pending-request", {
4689
+ request_id: id,
4690
+ operation: request.operation,
4691
+ tier: request.tier,
4692
+ reason: request.reason,
4693
+ context: request.context,
4694
+ timestamp: request.timestamp
4695
+ });
4696
+ });
4697
+ }
4698
+ // ── Authentication ──────────────────────────────────────────────────
4699
+ /**
4700
+ * Verify bearer token authentication.
4701
+ *
4702
+ * SEC-012: The long-lived auth token is ONLY accepted via the Authorization
4703
+ * header — never in URL query strings. For SSE and page loads that cannot
4704
+ * set headers, a short-lived session token (obtained via POST /auth/session)
4705
+ * is accepted via ?session= query parameter.
4706
+ *
4707
+ * Returns true if auth passes, false if blocked (response already sent).
4708
+ */
4709
+ checkAuth(req, url, res) {
4710
+ if (!this.authToken) return true;
4711
+ const authHeader = req.headers.authorization;
4712
+ if (authHeader) {
4713
+ const parts = authHeader.split(" ");
4714
+ if (parts.length === 2 && parts[0] === "Bearer" && parts[1] === this.authToken) {
4715
+ return true;
3299
4716
  }
3300
4717
  }
3301
- if (config.new_namespace_access === "approve") {
3302
- const namespace = args.namespace;
3303
- if (namespace) {
3304
- const isNew = this.baseline.recordNamespaceAccess(namespace);
3305
- if (isNew) {
3306
- return {
3307
- reason: `First access to namespace "${namespace}" (not in session baseline)`,
3308
- context: {
3309
- operation,
3310
- namespace,
3311
- known_namespaces: this.baseline.getProfile().known_namespaces
3312
- }
3313
- };
3314
- }
3315
- }
3316
- } else if (config.new_namespace_access === "log") {
3317
- const namespace = args.namespace;
3318
- if (namespace) {
3319
- this.baseline.recordNamespaceAccess(namespace);
3320
- }
4718
+ const sessionId = url.searchParams.get("session");
4719
+ if (sessionId && this.validateSession(sessionId)) {
4720
+ return true;
3321
4721
  }
3322
- if (config.new_counterparty === "approve") {
3323
- const counterpartyDid = args.counterparty_did ?? args.agent_identity_id;
3324
- if (counterpartyDid) {
3325
- const isNew = this.baseline.recordCounterparty(counterpartyDid);
3326
- if (isNew) {
3327
- return {
3328
- reason: `First interaction with counterparty "${counterpartyDid}"`,
3329
- context: {
3330
- operation,
3331
- counterparty_did: counterpartyDid,
3332
- known_counterparties: this.baseline.getProfile().known_counterparties
3333
- }
3334
- };
3335
- }
3336
- }
3337
- } else if (config.new_counterparty === "log") {
3338
- const counterpartyDid = args.counterparty_did;
3339
- if (counterpartyDid) {
3340
- this.baseline.recordCounterparty(counterpartyDid);
4722
+ res.writeHead(401, { "Content-Type": "application/json" });
4723
+ res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
4724
+ return false;
4725
+ }
4726
+ // ── Session Management (SEC-012) ──────────────────────────────────
4727
+ /**
4728
+ * Create a short-lived session by exchanging the long-lived auth token
4729
+ * (provided in the Authorization header) for a session ID.
4730
+ */
4731
+ createSession() {
4732
+ if (this.sessions.size >= MAX_SESSIONS) {
4733
+ this.cleanupSessions();
4734
+ if (this.sessions.size >= MAX_SESSIONS) {
4735
+ const oldest = [...this.sessions.entries()].sort(
4736
+ (a, b) => a[1].created_at - b[1].created_at
4737
+ )[0];
4738
+ if (oldest) this.sessions.delete(oldest[0]);
3341
4739
  }
3342
4740
  }
3343
- if (operation === "identity_sign") {
3344
- const signCount = this.baseline.recordSign();
4741
+ const id = crypto.randomBytes(32).toString("hex");
4742
+ const now = Date.now();
4743
+ this.sessions.set(id, {
4744
+ id,
4745
+ created_at: now,
4746
+ expires_at: now + SESSION_TTL_MS
4747
+ });
4748
+ return id;
4749
+ }
4750
+ /**
4751
+ * Validate a session ID — must exist and not be expired.
4752
+ */
4753
+ validateSession(sessionId) {
4754
+ const session = this.sessions.get(sessionId);
4755
+ if (!session) return false;
4756
+ if (Date.now() > session.expires_at) {
4757
+ this.sessions.delete(sessionId);
4758
+ return false;
4759
+ }
4760
+ return true;
4761
+ }
4762
+ /**
4763
+ * Remove all expired sessions.
4764
+ */
4765
+ cleanupSessions() {
4766
+ const now = Date.now();
4767
+ for (const [id, session] of this.sessions) {
4768
+ if (now > session.expires_at) {
4769
+ this.sessions.delete(id);
4770
+ }
4771
+ }
4772
+ }
4773
+ // ── HTTP Request Handler ────────────────────────────────────────────
4774
+ handleRequest(req, res) {
4775
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
4776
+ const method = req.method ?? "GET";
4777
+ const origin = req.headers.origin;
4778
+ const protocol = this.useTLS ? "https" : "http";
4779
+ const selfOrigin = `${protocol}://${this.config.host}:${this.config.port}`;
4780
+ if (origin === selfOrigin) {
4781
+ res.setHeader("Access-Control-Allow-Origin", origin);
4782
+ }
4783
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
4784
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
4785
+ if (method === "OPTIONS") {
4786
+ res.writeHead(204);
4787
+ res.end();
4788
+ return;
4789
+ }
4790
+ if (!this.checkAuth(req, url, res)) return;
4791
+ try {
4792
+ if (method === "POST" && url.pathname === "/auth/session") {
4793
+ this.handleSessionExchange(req, res);
4794
+ return;
4795
+ }
4796
+ if (method === "GET" && url.pathname === "/") {
4797
+ this.serveDashboard(res);
4798
+ } else if (method === "GET" && url.pathname === "/events") {
4799
+ this.handleSSE(req, res);
4800
+ } else if (method === "GET" && url.pathname === "/api/status") {
4801
+ this.handleStatus(res);
4802
+ } else if (method === "GET" && url.pathname === "/api/pending") {
4803
+ this.handlePendingList(res);
4804
+ } else if (method === "GET" && url.pathname === "/api/audit-log") {
4805
+ this.handleAuditLog(url, res);
4806
+ } else if (method === "POST" && url.pathname.startsWith("/api/approve/")) {
4807
+ const id = url.pathname.slice("/api/approve/".length);
4808
+ this.handleDecision(id, "approve", res);
4809
+ } else if (method === "POST" && url.pathname.startsWith("/api/deny/")) {
4810
+ const id = url.pathname.slice("/api/deny/".length);
4811
+ this.handleDecision(id, "deny", res);
4812
+ } else {
4813
+ res.writeHead(404, { "Content-Type": "application/json" });
4814
+ res.end(JSON.stringify({ error: "Not found" }));
4815
+ }
4816
+ } catch (err) {
4817
+ res.writeHead(500, { "Content-Type": "application/json" });
4818
+ res.end(JSON.stringify({ error: "Internal server error" }));
4819
+ }
4820
+ }
4821
+ // ── Route Handlers ──────────────────────────────────────────────────
4822
+ /**
4823
+ * SEC-012: Exchange a long-lived auth token (in Authorization header)
4824
+ * for a short-lived session ID. The session ID can be used in URL
4825
+ * query parameters without exposing the long-lived credential.
4826
+ *
4827
+ * This endpoint performs its OWN auth check (header-only) because it
4828
+ * must reject query-parameter tokens and is called before the
4829
+ * normal checkAuth flow.
4830
+ */
4831
+ handleSessionExchange(req, res) {
4832
+ if (!this.authToken) {
4833
+ res.writeHead(200, { "Content-Type": "application/json" });
4834
+ res.end(JSON.stringify({ session_id: "no-auth" }));
4835
+ return;
4836
+ }
4837
+ const authHeader = req.headers.authorization;
4838
+ if (!authHeader) {
4839
+ res.writeHead(401, { "Content-Type": "application/json" });
4840
+ res.end(JSON.stringify({ error: "Authorization header required" }));
4841
+ return;
4842
+ }
4843
+ const parts = authHeader.split(" ");
4844
+ if (parts.length !== 2 || parts[0] !== "Bearer" || parts[1] !== this.authToken) {
4845
+ res.writeHead(401, { "Content-Type": "application/json" });
4846
+ res.end(JSON.stringify({ error: "Invalid bearer token" }));
4847
+ return;
4848
+ }
4849
+ const sessionId = this.createSession();
4850
+ res.writeHead(200, { "Content-Type": "application/json" });
4851
+ res.end(JSON.stringify({
4852
+ session_id: sessionId,
4853
+ expires_in_seconds: SESSION_TTL_MS / 1e3
4854
+ }));
4855
+ }
4856
+ serveDashboard(res) {
4857
+ res.writeHead(200, {
4858
+ "Content-Type": "text/html; charset=utf-8",
4859
+ "Cache-Control": "no-cache"
4860
+ });
4861
+ res.end(this.dashboardHTML);
4862
+ }
4863
+ handleSSE(req, res) {
4864
+ res.writeHead(200, {
4865
+ "Content-Type": "text/event-stream",
4866
+ "Cache-Control": "no-cache",
4867
+ "Connection": "keep-alive"
4868
+ });
4869
+ const initData = {};
4870
+ if (this.baseline) {
4871
+ initData.baseline = this.baseline.getProfile();
4872
+ }
4873
+ if (this.policy) {
4874
+ initData.policy = {
4875
+ tier1_always_approve: this.policy.tier1_always_approve,
4876
+ tier2_anomaly: this.policy.tier2_anomaly,
4877
+ tier3_always_allow: this.policy.tier3_always_allow,
4878
+ approval_channel: {
4879
+ type: this.policy.approval_channel.type,
4880
+ timeout_seconds: this.policy.approval_channel.timeout_seconds,
4881
+ auto_deny: true
4882
+ // SEC-002: hardcoded, not configurable
4883
+ }
4884
+ };
4885
+ }
4886
+ const pendingList = Array.from(this.pending.values()).map((p) => ({
4887
+ request_id: p.id,
4888
+ operation: p.request.operation,
4889
+ tier: p.request.tier,
4890
+ reason: p.request.reason,
4891
+ context: p.request.context,
4892
+ timestamp: p.request.timestamp
4893
+ }));
4894
+ if (pendingList.length > 0) {
4895
+ initData.pending = pendingList;
4896
+ }
4897
+ res.write(`event: init
4898
+ data: ${JSON.stringify(initData)}
4899
+
4900
+ `);
4901
+ this.sseClients.add(res);
4902
+ req.on("close", () => {
4903
+ this.sseClients.delete(res);
4904
+ });
4905
+ }
4906
+ handleStatus(res) {
4907
+ const status = {
4908
+ pending_count: this.pending.size,
4909
+ connected_clients: this.sseClients.size
4910
+ };
4911
+ if (this.baseline) {
4912
+ status.baseline = this.baseline.getProfile();
4913
+ }
4914
+ if (this.policy) {
4915
+ status.policy = {
4916
+ version: this.policy.version,
4917
+ tier1_always_approve: this.policy.tier1_always_approve,
4918
+ tier2_anomaly: this.policy.tier2_anomaly,
4919
+ tier3_always_allow: this.policy.tier3_always_allow,
4920
+ approval_channel: {
4921
+ type: this.policy.approval_channel.type,
4922
+ timeout_seconds: this.policy.approval_channel.timeout_seconds,
4923
+ auto_deny: true
4924
+ // SEC-002: hardcoded, not configurable
4925
+ }
4926
+ };
4927
+ }
4928
+ res.writeHead(200, { "Content-Type": "application/json" });
4929
+ res.end(JSON.stringify(status));
4930
+ }
4931
+ handlePendingList(res) {
4932
+ const list = Array.from(this.pending.values()).map((p) => ({
4933
+ id: p.id,
4934
+ operation: p.request.operation,
4935
+ tier: p.request.tier,
4936
+ reason: p.request.reason,
4937
+ context: p.request.context,
4938
+ timestamp: p.request.timestamp,
4939
+ created_at: p.created_at
4940
+ }));
4941
+ res.writeHead(200, { "Content-Type": "application/json" });
4942
+ res.end(JSON.stringify(list));
4943
+ }
4944
+ handleAuditLog(url, res) {
4945
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
4946
+ if (this.auditLog) {
4947
+ this.auditLog.query({ limit }).then((entries) => {
4948
+ res.writeHead(200, { "Content-Type": "application/json" });
4949
+ res.end(JSON.stringify(entries));
4950
+ }).catch(() => {
4951
+ res.writeHead(200, { "Content-Type": "application/json" });
4952
+ res.end(JSON.stringify([]));
4953
+ });
4954
+ } else {
4955
+ res.writeHead(200, { "Content-Type": "application/json" });
4956
+ res.end(JSON.stringify([]));
4957
+ }
4958
+ }
4959
+ handleDecision(id, decision, res) {
4960
+ const pending = this.pending.get(id);
4961
+ if (!pending) {
4962
+ res.writeHead(404, { "Content-Type": "application/json" });
4963
+ res.end(JSON.stringify({ error: "Request not found or already resolved" }));
4964
+ return;
4965
+ }
4966
+ clearTimeout(pending.timer);
4967
+ this.pending.delete(id);
4968
+ const response = {
4969
+ decision,
4970
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
4971
+ decided_by: "human"
4972
+ };
4973
+ this.broadcastSSE("request-resolved", {
4974
+ request_id: id,
4975
+ decision,
4976
+ decided_by: "human"
4977
+ });
4978
+ pending.resolve(response);
4979
+ res.writeHead(200, { "Content-Type": "application/json" });
4980
+ res.end(JSON.stringify({ success: true, decision }));
4981
+ }
4982
+ // ── SSE Broadcasting ────────────────────────────────────────────────
4983
+ broadcastSSE(event, data) {
4984
+ const message = `event: ${event}
4985
+ data: ${JSON.stringify(data)}
4986
+
4987
+ `;
4988
+ for (const client of this.sseClients) {
4989
+ try {
4990
+ client.write(message);
4991
+ } catch {
4992
+ this.sseClients.delete(client);
4993
+ }
4994
+ }
4995
+ }
4996
+ /**
4997
+ * Broadcast an audit entry to connected dashboards.
4998
+ * Called externally when audit events happen.
4999
+ */
5000
+ broadcastAuditEntry(entry) {
5001
+ this.broadcastSSE("audit-entry", entry);
5002
+ }
5003
+ /**
5004
+ * Broadcast a baseline update to connected dashboards.
5005
+ * Called externally after baseline changes.
5006
+ */
5007
+ broadcastBaselineUpdate() {
5008
+ if (this.baseline) {
5009
+ this.broadcastSSE("baseline-update", this.baseline.getProfile());
5010
+ }
5011
+ }
5012
+ /** Get the number of pending requests */
5013
+ get pendingCount() {
5014
+ return this.pending.size;
5015
+ }
5016
+ /** Get the number of connected SSE clients */
5017
+ get clientCount() {
5018
+ return this.sseClients.size;
5019
+ }
5020
+ };
5021
+ function signPayload(body, secret) {
5022
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
5023
+ }
5024
+ function verifySignature(body, signature, secret) {
5025
+ const expected = signPayload(body, secret);
5026
+ if (expected.length !== signature.length) return false;
5027
+ let mismatch = 0;
5028
+ for (let i = 0; i < expected.length; i++) {
5029
+ mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
5030
+ }
5031
+ return mismatch === 0;
5032
+ }
5033
+ var WebhookApprovalChannel = class {
5034
+ config;
5035
+ pending = /* @__PURE__ */ new Map();
5036
+ callbackServer = null;
5037
+ constructor(config) {
5038
+ this.config = config;
5039
+ }
5040
+ /**
5041
+ * Start the callback listener server.
5042
+ */
5043
+ async start() {
5044
+ return new Promise((resolve, reject) => {
5045
+ this.callbackServer = http.createServer(
5046
+ (req, res) => this.handleCallback(req, res)
5047
+ );
5048
+ this.callbackServer.listen(
5049
+ this.config.callback_port,
5050
+ this.config.callback_host,
5051
+ () => {
5052
+ process.stderr.write(
5053
+ `
5054
+ Sanctuary Webhook Callback: http://${this.config.callback_host}:${this.config.callback_port}
5055
+ Webhook target: ${this.config.webhook_url}
5056
+
5057
+ `
5058
+ );
5059
+ resolve();
5060
+ }
5061
+ );
5062
+ this.callbackServer.on("error", reject);
5063
+ });
5064
+ }
5065
+ /**
5066
+ * Stop the callback server and clean up pending requests.
5067
+ */
5068
+ async stop() {
5069
+ for (const [, pending] of this.pending) {
5070
+ clearTimeout(pending.timer);
5071
+ pending.resolve({
5072
+ decision: "deny",
5073
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
5074
+ decided_by: "auto"
5075
+ });
5076
+ }
5077
+ this.pending.clear();
5078
+ if (this.callbackServer) {
5079
+ return new Promise((resolve) => {
5080
+ this.callbackServer.close(() => resolve());
5081
+ });
5082
+ }
5083
+ }
5084
+ /**
5085
+ * Request approval by POSTing to the webhook and waiting for a callback.
5086
+ */
5087
+ async requestApproval(request) {
5088
+ const id = crypto.randomBytes(8).toString("hex");
5089
+ process.stderr.write(
5090
+ `[Sanctuary] Webhook approval sent: ${request.operation} (Tier ${request.tier}) \u2014 awaiting callback
5091
+ `
5092
+ );
5093
+ return new Promise((resolve) => {
5094
+ const timer = setTimeout(() => {
5095
+ this.pending.delete(id);
5096
+ const response = {
5097
+ // SEC-002: Timeout ALWAYS denies. No configuration can change this.
5098
+ decision: "deny",
5099
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
5100
+ decided_by: "timeout"
5101
+ };
5102
+ resolve(response);
5103
+ }, this.config.timeout_seconds * 1e3);
5104
+ const pending = {
5105
+ id,
5106
+ request,
5107
+ resolve,
5108
+ timer,
5109
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
5110
+ };
5111
+ this.pending.set(id, pending);
5112
+ const callbackUrl = `http://${this.config.callback_host}:${this.config.callback_port}/webhook/respond/${id}`;
5113
+ const payload = {
5114
+ request_id: id,
5115
+ operation: request.operation,
5116
+ tier: request.tier,
5117
+ reason: request.reason,
5118
+ context: request.context,
5119
+ timestamp: request.timestamp,
5120
+ callback_url: callbackUrl,
5121
+ timeout_seconds: this.config.timeout_seconds
5122
+ };
5123
+ this.sendWebhook(payload).catch((err) => {
5124
+ process.stderr.write(
5125
+ `[Sanctuary] Webhook delivery failed: ${err instanceof Error ? err.message : String(err)}
5126
+ `
5127
+ );
5128
+ });
5129
+ });
5130
+ }
5131
+ // ── Outbound Webhook ──────────────────────────────────────────────────
5132
+ async sendWebhook(payload) {
5133
+ const body = JSON.stringify(payload);
5134
+ const signature = signPayload(body, this.config.webhook_secret);
5135
+ const response = await fetch(this.config.webhook_url, {
5136
+ method: "POST",
5137
+ headers: {
5138
+ "Content-Type": "application/json",
5139
+ "X-Sanctuary-Signature": signature,
5140
+ "X-Sanctuary-Request-Id": payload.request_id
5141
+ },
5142
+ body
5143
+ });
5144
+ if (!response.ok) {
5145
+ throw new Error(
5146
+ `Webhook returned ${response.status}: ${await response.text().catch(() => "")}`
5147
+ );
5148
+ }
5149
+ }
5150
+ // ── Inbound Callback Handler ──────────────────────────────────────────
5151
+ handleCallback(req, res) {
5152
+ const url = new URL(
5153
+ req.url ?? "/",
5154
+ `http://${req.headers.host ?? "localhost"}`
5155
+ );
5156
+ const method = req.method ?? "GET";
5157
+ res.setHeader("Access-Control-Allow-Origin", "*");
5158
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
5159
+ res.setHeader(
5160
+ "Access-Control-Allow-Headers",
5161
+ "Content-Type, X-Sanctuary-Signature"
5162
+ );
5163
+ if (method === "OPTIONS") {
5164
+ res.writeHead(204);
5165
+ res.end();
5166
+ return;
5167
+ }
5168
+ if (method === "GET" && url.pathname === "/health") {
5169
+ res.writeHead(200, { "Content-Type": "application/json" });
5170
+ res.end(
5171
+ JSON.stringify({
5172
+ status: "ok",
5173
+ pending_count: this.pending.size
5174
+ })
5175
+ );
5176
+ return;
5177
+ }
5178
+ const match = url.pathname.match(/^\/webhook\/respond\/([a-f0-9]+)$/);
5179
+ if (method !== "POST" || !match) {
5180
+ res.writeHead(404, { "Content-Type": "application/json" });
5181
+ res.end(JSON.stringify({ error: "Not found" }));
5182
+ return;
5183
+ }
5184
+ const requestId = match[1];
5185
+ let bodyChunks = [];
5186
+ req.on("data", (chunk) => bodyChunks.push(chunk));
5187
+ req.on("end", () => {
5188
+ const body = Buffer.concat(bodyChunks).toString("utf-8");
5189
+ const signature = req.headers["x-sanctuary-signature"];
5190
+ if (typeof signature !== "string" || !verifySignature(body, signature, this.config.webhook_secret)) {
5191
+ res.writeHead(401, { "Content-Type": "application/json" });
5192
+ res.end(
5193
+ JSON.stringify({ error: "Invalid signature" })
5194
+ );
5195
+ return;
5196
+ }
5197
+ let callbackPayload;
5198
+ try {
5199
+ callbackPayload = JSON.parse(body);
5200
+ } catch {
5201
+ res.writeHead(400, { "Content-Type": "application/json" });
5202
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
5203
+ return;
5204
+ }
5205
+ if (callbackPayload.decision !== "approve" && callbackPayload.decision !== "deny") {
5206
+ res.writeHead(400, { "Content-Type": "application/json" });
5207
+ res.end(
5208
+ JSON.stringify({
5209
+ error: 'Decision must be "approve" or "deny"'
5210
+ })
5211
+ );
5212
+ return;
5213
+ }
5214
+ if (callbackPayload.request_id !== requestId) {
5215
+ res.writeHead(400, { "Content-Type": "application/json" });
5216
+ res.end(
5217
+ JSON.stringify({ error: "Request ID mismatch" })
5218
+ );
5219
+ return;
5220
+ }
5221
+ const pending = this.pending.get(requestId);
5222
+ if (!pending) {
5223
+ res.writeHead(404, { "Content-Type": "application/json" });
5224
+ res.end(
5225
+ JSON.stringify({
5226
+ error: "Request not found or already resolved"
5227
+ })
5228
+ );
5229
+ return;
5230
+ }
5231
+ clearTimeout(pending.timer);
5232
+ this.pending.delete(requestId);
5233
+ const response = {
5234
+ decision: callbackPayload.decision,
5235
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
5236
+ decided_by: "human"
5237
+ };
5238
+ pending.resolve(response);
5239
+ res.writeHead(200, { "Content-Type": "application/json" });
5240
+ res.end(
5241
+ JSON.stringify({
5242
+ success: true,
5243
+ decision: callbackPayload.decision
5244
+ })
5245
+ );
5246
+ });
5247
+ }
5248
+ /** Get the number of pending requests */
5249
+ get pendingCount() {
5250
+ return this.pending.size;
5251
+ }
5252
+ };
5253
+
5254
+ // src/principal-policy/gate.ts
5255
+ var ApprovalGate = class {
5256
+ policy;
5257
+ baseline;
5258
+ channel;
5259
+ auditLog;
5260
+ constructor(policy, baseline, channel, auditLog) {
5261
+ this.policy = policy;
5262
+ this.baseline = baseline;
5263
+ this.channel = channel;
5264
+ this.auditLog = auditLog;
5265
+ }
5266
+ /**
5267
+ * Evaluate a tool call against the Principal Policy.
5268
+ *
5269
+ * @param toolName - Full MCP tool name (e.g., "sanctuary/state_export")
5270
+ * @param args - Tool call arguments (for context extraction)
5271
+ * @returns GateResult indicating whether the call is allowed
5272
+ */
5273
+ async evaluate(toolName, args) {
5274
+ const operation = extractOperationName(toolName);
5275
+ this.baseline.recordToolCall(operation);
5276
+ if (this.policy.tier1_always_approve.includes(operation)) {
5277
+ return this.requestApproval(operation, 1, `"${operation}" is a Tier 1 operation (always requires approval)`, {
5278
+ operation,
5279
+ args_summary: this.summarizeArgs(args)
5280
+ });
5281
+ }
5282
+ const anomaly = this.detectAnomaly(operation, args);
5283
+ if (anomaly) {
5284
+ return this.requestApproval(operation, 2, anomaly.reason, anomaly.context);
5285
+ }
5286
+ if (this.policy.tier3_always_allow.includes(operation)) {
5287
+ this.auditLog.append("l2", `gate_allow:${operation}`, "system", {
5288
+ tier: 3,
5289
+ operation
5290
+ });
5291
+ return {
5292
+ allowed: true,
5293
+ tier: 3,
5294
+ reason: "Operation allowed (Tier 3)",
5295
+ approval_required: false
5296
+ };
5297
+ }
5298
+ this.auditLog.append("l2", `gate_unclassified:${operation}`, "system", {
5299
+ tier: 1,
5300
+ operation,
5301
+ warning: "Operation is not classified in any policy tier \u2014 defaulting to Tier 1 (require approval)"
5302
+ });
5303
+ return this.requestApproval(
5304
+ operation,
5305
+ 1,
5306
+ `"${operation}" is not classified in any policy tier \u2014 requires approval (SEC-011 safe default)`,
5307
+ { operation, unclassified: true }
5308
+ );
5309
+ }
5310
+ /**
5311
+ * Detect Tier 2 behavioral anomalies.
5312
+ */
5313
+ detectAnomaly(operation, args) {
5314
+ const config = this.policy.tier2_anomaly;
5315
+ if (this.baseline.isFirstSession && config.first_session_policy === "approve") {
5316
+ if (!this.policy.tier3_always_allow.includes(operation)) {
5317
+ return {
5318
+ reason: `First session: "${operation}" has no established baseline`,
5319
+ context: { operation, is_first_session: true }
5320
+ };
5321
+ }
5322
+ }
5323
+ if (config.new_namespace_access === "approve") {
5324
+ const namespace = args.namespace;
5325
+ if (namespace) {
5326
+ const isNew = this.baseline.recordNamespaceAccess(namespace);
5327
+ if (isNew) {
5328
+ return {
5329
+ reason: `First access to namespace "${namespace}" (not in session baseline)`,
5330
+ context: {
5331
+ operation,
5332
+ namespace,
5333
+ known_namespaces: this.baseline.getProfile().known_namespaces
5334
+ }
5335
+ };
5336
+ }
5337
+ }
5338
+ } else if (config.new_namespace_access === "log") {
5339
+ const namespace = args.namespace;
5340
+ if (namespace) {
5341
+ this.baseline.recordNamespaceAccess(namespace);
5342
+ }
5343
+ }
5344
+ if (config.new_counterparty === "approve") {
5345
+ const counterpartyDid = args.counterparty_did ?? args.agent_identity_id;
5346
+ if (counterpartyDid) {
5347
+ const isNew = this.baseline.recordCounterparty(counterpartyDid);
5348
+ if (isNew) {
5349
+ return {
5350
+ reason: `First interaction with counterparty "${counterpartyDid}"`,
5351
+ context: {
5352
+ operation,
5353
+ counterparty_did: counterpartyDid,
5354
+ known_counterparties: this.baseline.getProfile().known_counterparties
5355
+ }
5356
+ };
5357
+ }
5358
+ }
5359
+ } else if (config.new_counterparty === "log") {
5360
+ const counterpartyDid = args.counterparty_did;
5361
+ if (counterpartyDid) {
5362
+ this.baseline.recordCounterparty(counterpartyDid);
5363
+ }
5364
+ }
5365
+ if (operation === "identity_sign") {
5366
+ const signCount = this.baseline.recordSign();
3345
5367
  if (signCount > config.max_signs_per_minute) {
3346
5368
  return {
3347
5369
  reason: `Signing frequency (${signCount}/min) exceeds limit (${config.max_signs_per_minute}/min)`,
@@ -3456,7 +5478,8 @@ function createPrincipalPolicyTools(policy, baseline, auditLog) {
3456
5478
  approval_channel: {
3457
5479
  type: policy.approval_channel.type,
3458
5480
  timeout_seconds: policy.approval_channel.timeout_seconds,
3459
- auto_deny: policy.approval_channel.auto_deny
5481
+ auto_deny: true
5482
+ // SEC-002: hardcoded, not configurable
3460
5483
  }
3461
5484
  };
3462
5485
  if (includeDefaults) {
@@ -3914,6 +5937,7 @@ function deriveTrustTier(level) {
3914
5937
  // src/handshake/tools.ts
3915
5938
  function createHandshakeTools(config, identityManager, masterKey, auditLog) {
3916
5939
  const sessions = /* @__PURE__ */ new Map();
5940
+ const handshakeResults = /* @__PURE__ */ new Map();
3917
5941
  const shrOpts = {
3918
5942
  config,
3919
5943
  identityManager,
@@ -3986,7 +6010,9 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog) {
3986
6010
  return toolResult({
3987
6011
  session_id: result.session.session_id,
3988
6012
  response: result.response,
3989
- instructions: "Send the 'response' object back to the initiator. When you receive their completion, pass it to sanctuary/handshake_status with this session_id."
6013
+ instructions: "Send the 'response' object back to the initiator. When you receive their completion, pass it to sanctuary/handshake_status with this session_id.",
6014
+ // SEC-ADD-03: Tag response — contains SHR data that will be sent to counterparty
6015
+ _content_trust: "external"
3990
6016
  });
3991
6017
  }
3992
6018
  },
@@ -4034,11 +6060,14 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog) {
4034
6060
  session.their_shr = response.shr;
4035
6061
  session.their_nonce = response.responder_nonce;
4036
6062
  session.result = result.result;
6063
+ handshakeResults.set(result.result.counterparty_id, result.result);
4037
6064
  auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id);
4038
6065
  return toolResult({
4039
6066
  completion: result.completion,
4040
6067
  result: result.result,
4041
- instructions: "Send the 'completion' object to the responder so they can verify the handshake. The 'result' object contains the verified counterparty status and trust tier."
6068
+ instructions: "Send the 'completion' object to the responder so they can verify the handshake. The 'result' object contains the verified counterparty status and trust tier.",
6069
+ // SEC-ADD-03: Tag response as containing counterparty-controlled SHR data
6070
+ _content_trust: "external"
4042
6071
  });
4043
6072
  }
4044
6073
  },
@@ -4070,6 +6099,9 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog) {
4070
6099
  const result = verifyCompletion(completion, session);
4071
6100
  session.state = result.verified ? "completed" : "failed";
4072
6101
  session.result = result;
6102
+ if (result.verified) {
6103
+ handshakeResults.set(result.counterparty_id, result);
6104
+ }
4073
6105
  auditLog.append(
4074
6106
  "l4",
4075
6107
  "handshake_verify_completion",
@@ -4089,18 +6121,1450 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog) {
4089
6121
  }
4090
6122
  }
4091
6123
  ];
4092
- return { tools };
6124
+ return { tools, handshakeResults };
4093
6125
  }
4094
6126
 
4095
- // src/index.ts
4096
- init_encoding();
4097
- async function createSanctuaryServer(options) {
4098
- const config = await loadConfig(options?.configPath);
4099
- await promises.mkdir(config.storage_path, { recursive: true, mode: 448 });
4100
- const storage = options?.storage ?? new FilesystemStorage(
4101
- `${config.storage_path}/state`
4102
- );
4103
- let masterKey;
6127
+ // src/federation/registry.ts
6128
+ var DEFAULT_CAPABILITIES = {
6129
+ reputation_exchange: true,
6130
+ mutual_attestation: true,
6131
+ encrypted_channel: false,
6132
+ attestation_formats: ["sanctuary-interaction-v1"]
6133
+ };
6134
+ var FederationRegistry = class {
6135
+ peers = /* @__PURE__ */ new Map();
6136
+ /**
6137
+ * Register or update a peer from a completed handshake.
6138
+ * This is the ONLY way peers enter the registry.
6139
+ */
6140
+ registerFromHandshake(result, peerDid, capabilities) {
6141
+ const existing = this.peers.get(result.counterparty_id);
6142
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6143
+ const peer = {
6144
+ peer_id: result.counterparty_id,
6145
+ peer_did: peerDid,
6146
+ first_seen: existing?.first_seen ?? now,
6147
+ last_handshake: result.completed_at,
6148
+ trust_tier: trustTierToSovereigntyTier(result.trust_tier),
6149
+ handshake_result: result,
6150
+ capabilities: {
6151
+ ...DEFAULT_CAPABILITIES,
6152
+ ...existing?.capabilities ?? {},
6153
+ ...capabilities ?? {}
6154
+ },
6155
+ active: result.verified && new Date(result.expires_at) > /* @__PURE__ */ new Date()
6156
+ };
6157
+ if (!peer.active) {
6158
+ peer.trust_tier = "self-attested";
6159
+ }
6160
+ this.peers.set(result.counterparty_id, peer);
6161
+ return peer;
6162
+ }
6163
+ /**
6164
+ * Get a peer by instance ID.
6165
+ * Automatically updates active status based on handshake expiry.
6166
+ */
6167
+ getPeer(peerId) {
6168
+ const peer = this.peers.get(peerId);
6169
+ if (!peer) return null;
6170
+ if (peer.active && new Date(peer.handshake_result.expires_at) <= /* @__PURE__ */ new Date()) {
6171
+ peer.active = false;
6172
+ peer.trust_tier = "self-attested";
6173
+ }
6174
+ return peer;
6175
+ }
6176
+ /**
6177
+ * List all known peers, optionally filtered by status.
6178
+ */
6179
+ listPeers(filter) {
6180
+ const peers = Array.from(this.peers.values());
6181
+ for (const peer of peers) {
6182
+ if (peer.active && new Date(peer.handshake_result.expires_at) <= /* @__PURE__ */ new Date()) {
6183
+ peer.active = false;
6184
+ peer.trust_tier = "self-attested";
6185
+ }
6186
+ }
6187
+ if (filter?.active_only) {
6188
+ return peers.filter((p) => p.active);
6189
+ }
6190
+ return peers;
6191
+ }
6192
+ /**
6193
+ * Evaluate trust for a federation peer.
6194
+ *
6195
+ * Trust assessment considers:
6196
+ * - Handshake status (current vs expired)
6197
+ * - Sovereignty tier (verified-sovereign vs degraded vs unverified)
6198
+ * - Reputation data (if available)
6199
+ * - Mutual attestation history
6200
+ */
6201
+ evaluateTrust(peerId, mutualAttestationCount = 0, reputationScore) {
6202
+ const peer = this.getPeer(peerId);
6203
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6204
+ if (!peer) {
6205
+ return {
6206
+ peer_id: peerId,
6207
+ sovereignty_tier: "unverified",
6208
+ handshake_current: false,
6209
+ mutual_attestation_count: 0,
6210
+ trust_level: "none",
6211
+ factors: ["Peer not found in federation registry"],
6212
+ evaluated_at: now
6213
+ };
6214
+ }
6215
+ const factors = [];
6216
+ let score = 0;
6217
+ if (peer.active) {
6218
+ factors.push("Active handshake (trust current)");
6219
+ score += 3;
6220
+ } else {
6221
+ factors.push("Handshake expired (trust degraded)");
6222
+ score += 1;
6223
+ }
6224
+ switch (peer.trust_tier) {
6225
+ case "verified-sovereign":
6226
+ factors.push("Verified sovereign \u2014 full sovereignty posture");
6227
+ score += 4;
6228
+ break;
6229
+ case "verified-degraded":
6230
+ factors.push("Verified degraded \u2014 sovereignty with known limitations");
6231
+ score += 3;
6232
+ break;
6233
+ case "self-attested":
6234
+ factors.push("Self-attested \u2014 claims not independently verified");
6235
+ score += 1;
6236
+ break;
6237
+ case "unverified":
6238
+ factors.push("Unverified \u2014 no sovereignty proof");
6239
+ score += 0;
6240
+ break;
6241
+ }
6242
+ if (mutualAttestationCount > 10) {
6243
+ factors.push(`Strong attestation history (${mutualAttestationCount} mutual attestations)`);
6244
+ score += 3;
6245
+ } else if (mutualAttestationCount > 0) {
6246
+ factors.push(`Some attestation history (${mutualAttestationCount} mutual attestations)`);
6247
+ score += 1;
6248
+ } else {
6249
+ factors.push("No mutual attestation history");
6250
+ }
6251
+ if (reputationScore !== void 0) {
6252
+ if (reputationScore >= 80) {
6253
+ factors.push(`High reputation score (${reputationScore})`);
6254
+ score += 2;
6255
+ } else if (reputationScore >= 50) {
6256
+ factors.push(`Moderate reputation score (${reputationScore})`);
6257
+ score += 1;
6258
+ } else {
6259
+ factors.push(`Low reputation score (${reputationScore})`);
6260
+ }
6261
+ }
6262
+ let trust_level;
6263
+ if (score >= 9) trust_level = "high";
6264
+ else if (score >= 5) trust_level = "medium";
6265
+ else if (score >= 2) trust_level = "low";
6266
+ else trust_level = "none";
6267
+ return {
6268
+ peer_id: peerId,
6269
+ sovereignty_tier: peer.trust_tier,
6270
+ handshake_current: peer.active,
6271
+ reputation_score: reputationScore,
6272
+ mutual_attestation_count: mutualAttestationCount,
6273
+ trust_level,
6274
+ factors,
6275
+ evaluated_at: now
6276
+ };
6277
+ }
6278
+ /**
6279
+ * Remove a peer from the registry.
6280
+ */
6281
+ removePeer(peerId) {
6282
+ return this.peers.delete(peerId);
6283
+ }
6284
+ /**
6285
+ * Get the handshake results map (for tier resolution integration).
6286
+ */
6287
+ getHandshakeResults() {
6288
+ const results = /* @__PURE__ */ new Map();
6289
+ for (const [id, peer] of this.peers) {
6290
+ if (peer.active) {
6291
+ results.set(id, peer.handshake_result);
6292
+ }
6293
+ }
6294
+ return results;
6295
+ }
6296
+ };
6297
+
6298
+ // src/federation/tools.ts
6299
+ function createFederationTools(auditLog, handshakeResults) {
6300
+ const registry = new FederationRegistry();
6301
+ const tools = [
6302
+ // ─── Peer Management ──────────────────────────────────────────────
6303
+ {
6304
+ name: "sanctuary/federation_peers",
6305
+ description: "List known federation peers, register a peer from a completed handshake, or remove a peer. Every peer MUST enter through a verified handshake \u2014 no self-registration allowed.",
6306
+ inputSchema: {
6307
+ type: "object",
6308
+ properties: {
6309
+ action: {
6310
+ type: "string",
6311
+ enum: ["list", "register", "remove"],
6312
+ description: "Operation to perform on the peer registry"
6313
+ },
6314
+ peer_id: {
6315
+ type: "string",
6316
+ description: "Peer instance ID (required for register/remove)"
6317
+ },
6318
+ peer_did: {
6319
+ type: "string",
6320
+ description: "Peer DID (required for register)"
6321
+ },
6322
+ active_only: {
6323
+ type: "boolean",
6324
+ description: "When listing, only show peers with active handshakes"
6325
+ }
6326
+ },
6327
+ required: ["action"]
6328
+ },
6329
+ handler: async (args) => {
6330
+ const action = args.action;
6331
+ switch (action) {
6332
+ case "list": {
6333
+ const peers = registry.listPeers({
6334
+ active_only: args.active_only
6335
+ });
6336
+ auditLog.append("l4", "federation_peers_list", "system", {
6337
+ peer_count: peers.length
6338
+ });
6339
+ return toolResult({
6340
+ peers: peers.map((p) => ({
6341
+ peer_id: p.peer_id,
6342
+ peer_did: p.peer_did,
6343
+ trust_tier: p.trust_tier,
6344
+ active: p.active,
6345
+ first_seen: p.first_seen,
6346
+ last_handshake: p.last_handshake,
6347
+ capabilities: p.capabilities
6348
+ })),
6349
+ total: peers.length
6350
+ });
6351
+ }
6352
+ case "register": {
6353
+ const peerId = args.peer_id;
6354
+ const peerDid = args.peer_did;
6355
+ if (!peerId || !peerDid) {
6356
+ return toolResult({
6357
+ error: "Both peer_id and peer_did are required for registration."
6358
+ });
6359
+ }
6360
+ const hsResult = handshakeResults.get(peerId);
6361
+ if (!hsResult) {
6362
+ return toolResult({
6363
+ error: `No completed handshake found for peer "${peerId}". Complete a sovereignty handshake first using handshake_initiate.`
6364
+ });
6365
+ }
6366
+ if (!hsResult.verified) {
6367
+ return toolResult({
6368
+ error: `Handshake with "${peerId}" was not verified. Only verified handshakes can establish federation.`
6369
+ });
6370
+ }
6371
+ const peer = registry.registerFromHandshake(hsResult, peerDid);
6372
+ auditLog.append("l4", "federation_peer_register", "system", {
6373
+ peer_id: peerId,
6374
+ peer_did: peerDid,
6375
+ trust_tier: peer.trust_tier
6376
+ });
6377
+ return toolResult({
6378
+ registered: true,
6379
+ peer_id: peer.peer_id,
6380
+ trust_tier: peer.trust_tier,
6381
+ active: peer.active,
6382
+ capabilities: peer.capabilities
6383
+ });
6384
+ }
6385
+ case "remove": {
6386
+ const peerId = args.peer_id;
6387
+ if (!peerId) {
6388
+ return toolResult({ error: "peer_id is required for removal." });
6389
+ }
6390
+ const removed = registry.removePeer(peerId);
6391
+ auditLog.append("l4", "federation_peer_remove", "system", {
6392
+ peer_id: peerId,
6393
+ removed
6394
+ });
6395
+ return toolResult({
6396
+ removed,
6397
+ peer_id: peerId
6398
+ });
6399
+ }
6400
+ default:
6401
+ return toolResult({ error: `Unknown action: ${action}` });
6402
+ }
6403
+ }
6404
+ },
6405
+ // ─── Trust Evaluation ─────────────────────────────────────────────
6406
+ {
6407
+ name: "sanctuary/federation_trust_evaluate",
6408
+ description: "Evaluate the trust level of a federation peer. Considers handshake status, sovereignty tier, reputation score, and mutual attestation history. Returns a composite trust assessment.",
6409
+ inputSchema: {
6410
+ type: "object",
6411
+ properties: {
6412
+ peer_id: {
6413
+ type: "string",
6414
+ description: "Peer instance ID to evaluate"
6415
+ },
6416
+ mutual_attestation_count: {
6417
+ type: "number",
6418
+ description: "Number of mutual attestations with this peer (0 if unknown)"
6419
+ },
6420
+ reputation_score: {
6421
+ type: "number",
6422
+ description: "Peer's weighted reputation score (from reputation_query_weighted)"
6423
+ }
6424
+ },
6425
+ required: ["peer_id"]
6426
+ },
6427
+ handler: async (args) => {
6428
+ const peerId = args.peer_id;
6429
+ const mutualCount = args.mutual_attestation_count ?? 0;
6430
+ const repScore = args.reputation_score;
6431
+ const evaluation = registry.evaluateTrust(peerId, mutualCount, repScore);
6432
+ auditLog.append("l4", "federation_trust_evaluate", "system", {
6433
+ peer_id: peerId,
6434
+ trust_level: evaluation.trust_level,
6435
+ sovereignty_tier: evaluation.sovereignty_tier
6436
+ });
6437
+ return toolResult(evaluation);
6438
+ }
6439
+ },
6440
+ // ─── Federation Status ────────────────────────────────────────────
6441
+ {
6442
+ name: "sanctuary/federation_status",
6443
+ description: "Overview of federation state: total peers, active connections, trust distribution, and readiness for cross-instance operations.",
6444
+ inputSchema: {
6445
+ type: "object",
6446
+ properties: {}
6447
+ },
6448
+ handler: async () => {
6449
+ const allPeers = registry.listPeers();
6450
+ const activePeers = registry.listPeers({ active_only: true });
6451
+ const tierCounts = {
6452
+ "verified-sovereign": 0,
6453
+ "verified-degraded": 0,
6454
+ "self-attested": 0,
6455
+ "unverified": 0
6456
+ };
6457
+ for (const peer of allPeers) {
6458
+ tierCounts[peer.trust_tier] = (tierCounts[peer.trust_tier] ?? 0) + 1;
6459
+ }
6460
+ const capCounts = {
6461
+ reputation_exchange: activePeers.filter((p) => p.capabilities.reputation_exchange).length,
6462
+ mutual_attestation: activePeers.filter((p) => p.capabilities.mutual_attestation).length,
6463
+ encrypted_channel: activePeers.filter((p) => p.capabilities.encrypted_channel).length
6464
+ };
6465
+ auditLog.append("l4", "federation_status", "system", {
6466
+ total_peers: allPeers.length,
6467
+ active_peers: activePeers.length
6468
+ });
6469
+ return toolResult({
6470
+ total_peers: allPeers.length,
6471
+ active_peers: activePeers.length,
6472
+ expired_peers: allPeers.length - activePeers.length,
6473
+ trust_distribution: tierCounts,
6474
+ capability_coverage: capCounts,
6475
+ federation_ready: activePeers.length > 0,
6476
+ checked_at: (/* @__PURE__ */ new Date()).toISOString()
6477
+ });
6478
+ }
6479
+ }
6480
+ ];
6481
+ return { tools, registry };
6482
+ }
6483
+
6484
+ // src/bridge/tools.ts
6485
+ init_encoding();
6486
+ init_encoding();
6487
+
6488
+ // src/bridge/bridge.ts
6489
+ init_encoding();
6490
+ init_hashing();
6491
+ function canonicalize(outcome) {
6492
+ return stringToBytes(stableStringify(outcome));
6493
+ }
6494
+ function stableStringify(value) {
6495
+ if (value === null) return "null";
6496
+ if (value === void 0) return "null";
6497
+ if (typeof value === "number") {
6498
+ if (!Number.isFinite(value)) {
6499
+ throw new Error(
6500
+ `Cannot canonicalize non-finite number: ${value}. NaN, Infinity, and -Infinity are not representable in JSON.`
6501
+ );
6502
+ }
6503
+ if (Object.is(value, -0)) {
6504
+ throw new Error(
6505
+ "Cannot canonicalize negative zero (-0). Use 0 instead for deterministic cross-language serialization."
6506
+ );
6507
+ }
6508
+ return JSON.stringify(value);
6509
+ }
6510
+ if (typeof value !== "object") return JSON.stringify(value);
6511
+ if (Array.isArray(value)) {
6512
+ return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
6513
+ }
6514
+ const obj = value;
6515
+ const keys = Object.keys(obj).sort();
6516
+ const pairs = keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k]));
6517
+ return "{" + pairs.join(",") + "}";
6518
+ }
6519
+ function createBridgeCommitment(outcome, identity, identityEncryptionKey, includePedersen = false) {
6520
+ const commitmentId = `bridge-${Date.now()}-${toBase64url(randomBytes(8))}`;
6521
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6522
+ const canonicalBytes = canonicalize(outcome);
6523
+ const canonicalString = new TextDecoder().decode(canonicalBytes);
6524
+ const sha2564 = createCommitment(canonicalString);
6525
+ let pedersenData;
6526
+ if (includePedersen && Number.isInteger(outcome.rounds) && outcome.rounds >= 0) {
6527
+ const pedersen = createPedersenCommitment(outcome.rounds);
6528
+ pedersenData = {
6529
+ commitment: pedersen.commitment,
6530
+ blinding_factor: pedersen.blinding_factor
6531
+ };
6532
+ }
6533
+ const commitmentPayload = {
6534
+ bridge_commitment_id: commitmentId,
6535
+ session_id: outcome.session_id,
6536
+ sha256_commitment: sha2564.commitment,
6537
+ terms_hash: outcome.terms_hash,
6538
+ committer_did: identity.did,
6539
+ committed_at: now,
6540
+ bridge_version: "sanctuary-concordia-bridge-v1"
6541
+ };
6542
+ const payloadBytes = stringToBytes(stableStringify(commitmentPayload));
6543
+ const signature = sign(payloadBytes, identity.encrypted_private_key, identityEncryptionKey);
6544
+ return {
6545
+ bridge_commitment_id: commitmentId,
6546
+ session_id: outcome.session_id,
6547
+ sha256_commitment: sha2564.commitment,
6548
+ blinding_factor: sha2564.blinding_factor,
6549
+ committer_did: identity.did,
6550
+ signature: toBase64url(signature),
6551
+ pedersen_commitment: pedersenData,
6552
+ committed_at: now,
6553
+ bridge_version: "sanctuary-concordia-bridge-v1"
6554
+ };
6555
+ }
6556
+ function verifyBridgeCommitment(commitment, outcome, committerPublicKey) {
6557
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6558
+ const canonicalString = new TextDecoder().decode(canonicalize(outcome));
6559
+ const sha256Match = verifyCommitment(
6560
+ commitment.sha256_commitment,
6561
+ canonicalString,
6562
+ commitment.blinding_factor
6563
+ );
6564
+ const commitmentPayload = {
6565
+ bridge_commitment_id: commitment.bridge_commitment_id,
6566
+ session_id: commitment.session_id,
6567
+ sha256_commitment: commitment.sha256_commitment,
6568
+ terms_hash: outcome.terms_hash,
6569
+ committer_did: commitment.committer_did,
6570
+ committed_at: commitment.committed_at,
6571
+ bridge_version: commitment.bridge_version
6572
+ };
6573
+ const payloadBytes = stringToBytes(stableStringify(commitmentPayload));
6574
+ const sigBytes = fromBase64url(commitment.signature);
6575
+ const signatureValid = verify(payloadBytes, sigBytes, committerPublicKey);
6576
+ const sessionIdMatch = commitment.session_id === outcome.session_id;
6577
+ const termsBytes = stringToBytes(stableStringify(outcome.terms));
6578
+ const computedTermsHash = toBase64url(hash(termsBytes));
6579
+ const termsHashMatch = computedTermsHash === outcome.terms_hash;
6580
+ let pedersenMatch;
6581
+ if (commitment.pedersen_commitment) {
6582
+ pedersenMatch = verifyPedersenCommitment(
6583
+ commitment.pedersen_commitment.commitment,
6584
+ outcome.rounds,
6585
+ commitment.pedersen_commitment.blinding_factor
6586
+ );
6587
+ }
6588
+ const valid = sha256Match && signatureValid && sessionIdMatch && termsHashMatch && (pedersenMatch === void 0 || pedersenMatch);
6589
+ return {
6590
+ valid,
6591
+ checks: {
6592
+ sha256_match: sha256Match,
6593
+ signature_valid: signatureValid,
6594
+ session_id_match: sessionIdMatch,
6595
+ terms_hash_match: termsHashMatch,
6596
+ pedersen_match: pedersenMatch
6597
+ },
6598
+ bridge_commitment_id: commitment.bridge_commitment_id,
6599
+ verified_at: now
6600
+ };
6601
+ }
6602
+
6603
+ // src/bridge/tools.ts
6604
+ var BridgeStore = class {
6605
+ storage;
6606
+ encryptionKey;
6607
+ constructor(storage, masterKey) {
6608
+ this.storage = storage;
6609
+ this.encryptionKey = derivePurposeKey(masterKey, "bridge-commitments");
6610
+ }
6611
+ async save(commitment, outcome) {
6612
+ const record = { commitment, outcome };
6613
+ const serialized = stringToBytes(JSON.stringify(record));
6614
+ const encrypted = encrypt(serialized, this.encryptionKey);
6615
+ await this.storage.write(
6616
+ "_bridge",
6617
+ commitment.bridge_commitment_id,
6618
+ stringToBytes(JSON.stringify(encrypted))
6619
+ );
6620
+ }
6621
+ async get(commitmentId) {
6622
+ const raw = await this.storage.read("_bridge", commitmentId);
6623
+ if (!raw) return null;
6624
+ try {
6625
+ const encrypted = JSON.parse(bytesToString(raw));
6626
+ const decrypted = decrypt(encrypted, this.encryptionKey);
6627
+ return JSON.parse(bytesToString(decrypted));
6628
+ } catch {
6629
+ return null;
6630
+ }
6631
+ }
6632
+ };
6633
+ function createBridgeTools(storage, masterKey, identityManager, auditLog, handshakeResults) {
6634
+ const bridgeStore = new BridgeStore(storage, masterKey);
6635
+ const reputationStore = new ReputationStore(storage, masterKey);
6636
+ const identityEncryptionKey = derivePurposeKey(masterKey, "identity-encryption");
6637
+ const hsResults = handshakeResults ?? /* @__PURE__ */ new Map();
6638
+ function resolveIdentity(identityId) {
6639
+ const id = identityId ? identityManager.get(identityId) : identityManager.getDefault();
6640
+ if (!id) {
6641
+ throw new Error(
6642
+ identityId ? `Identity "${identityId}" not found` : "No identity available. Create one with identity_create first."
6643
+ );
6644
+ }
6645
+ return id;
6646
+ }
6647
+ const tools = [
6648
+ // ─── bridge_commit ─────────────────────────────────────────────────
6649
+ {
6650
+ name: "sanctuary/bridge_commit",
6651
+ description: "Create a cryptographic commitment binding a Concordia negotiation outcome to Sanctuary's L3 proof layer. The commitment includes a SHA-256 hash of the canonical outcome (hiding + binding), an Ed25519 signature by the committer's identity, and an optional Pedersen commitment on the round count for zero-knowledge range proofs. This is the Sanctuary side of the Concordia bridge \u2014 call this when a Concordia `accept` fires.",
6652
+ inputSchema: {
6653
+ type: "object",
6654
+ properties: {
6655
+ session_id: {
6656
+ type: "string",
6657
+ description: "Concordia session identifier"
6658
+ },
6659
+ protocol_version: {
6660
+ type: "string",
6661
+ description: 'Concordia protocol version (e.g., "concordia-v1")'
6662
+ },
6663
+ proposer_did: {
6664
+ type: "string",
6665
+ description: "DID of the party who proposed the accepted terms"
6666
+ },
6667
+ acceptor_did: {
6668
+ type: "string",
6669
+ description: "DID of the party who accepted"
6670
+ },
6671
+ terms: {
6672
+ type: "object",
6673
+ description: "The accepted terms (opaque to Sanctuary, meaningful to Concordia)"
6674
+ },
6675
+ terms_hash: {
6676
+ type: "string",
6677
+ description: "SHA-256 hash of the canonical terms serialization (computed by Concordia)"
6678
+ },
6679
+ rounds: {
6680
+ type: "number",
6681
+ description: "Number of negotiation rounds (propose/counter cycles)"
6682
+ },
6683
+ accepted_at: {
6684
+ type: "string",
6685
+ description: "ISO 8601 timestamp when accept was issued"
6686
+ },
6687
+ session_receipt: {
6688
+ type: "string",
6689
+ description: "Optional: signed Concordia session receipt"
6690
+ },
6691
+ identity_id: {
6692
+ type: "string",
6693
+ description: "Sanctuary identity to sign the commitment (uses default if omitted)"
6694
+ },
6695
+ include_pedersen: {
6696
+ type: "boolean",
6697
+ description: "Include a Pedersen commitment on round count for ZK range proofs"
6698
+ }
6699
+ },
6700
+ required: [
6701
+ "session_id",
6702
+ "protocol_version",
6703
+ "proposer_did",
6704
+ "acceptor_did",
6705
+ "terms",
6706
+ "terms_hash",
6707
+ "rounds",
6708
+ "accepted_at"
6709
+ ]
6710
+ },
6711
+ handler: async (args) => {
6712
+ const outcome = {
6713
+ session_id: args.session_id,
6714
+ protocol_version: args.protocol_version,
6715
+ proposer_did: args.proposer_did,
6716
+ acceptor_did: args.acceptor_did,
6717
+ terms: args.terms,
6718
+ terms_hash: args.terms_hash,
6719
+ rounds: args.rounds,
6720
+ accepted_at: args.accepted_at,
6721
+ session_receipt: args.session_receipt
6722
+ };
6723
+ const identity = resolveIdentity(args.identity_id);
6724
+ const includePedersen = args.include_pedersen ?? false;
6725
+ const bridgeCommitment = createBridgeCommitment(
6726
+ outcome,
6727
+ identity,
6728
+ identityEncryptionKey,
6729
+ includePedersen
6730
+ );
6731
+ await bridgeStore.save(bridgeCommitment, outcome);
6732
+ auditLog.append("l3", "bridge_commit", identity.identity_id, {
6733
+ bridge_commitment_id: bridgeCommitment.bridge_commitment_id,
6734
+ session_id: outcome.session_id,
6735
+ counterparty: outcome.proposer_did === identity.did ? outcome.acceptor_did : outcome.proposer_did
6736
+ });
6737
+ return toolResult({
6738
+ bridge_commitment_id: bridgeCommitment.bridge_commitment_id,
6739
+ session_id: bridgeCommitment.session_id,
6740
+ sha256_commitment: bridgeCommitment.sha256_commitment,
6741
+ committer_did: bridgeCommitment.committer_did,
6742
+ signature: bridgeCommitment.signature,
6743
+ pedersen_commitment: bridgeCommitment.pedersen_commitment ? { commitment: bridgeCommitment.pedersen_commitment.commitment } : void 0,
6744
+ committed_at: bridgeCommitment.committed_at,
6745
+ bridge_version: bridgeCommitment.bridge_version,
6746
+ note: "Bridge commitment created. The blinding factor is stored encrypted. Use bridge_verify to verify the commitment against the revealed outcome. Use bridge_attest to link this negotiation to your reputation."
6747
+ });
6748
+ }
6749
+ },
6750
+ // ─── bridge_verify ───────────────────────────────────────────────────
6751
+ {
6752
+ name: "sanctuary/bridge_verify",
6753
+ description: "Verify a bridge commitment against a revealed Concordia negotiation outcome. Checks SHA-256 commitment validity, Ed25519 signature, session ID match, terms hash integrity, and Pedersen commitment (if present). Use this to confirm that a counterparty's claimed negotiation outcome matches what was cryptographically committed.",
6754
+ inputSchema: {
6755
+ type: "object",
6756
+ properties: {
6757
+ bridge_commitment_id: {
6758
+ type: "string",
6759
+ description: "The bridge commitment ID to verify"
6760
+ },
6761
+ committer_public_key: {
6762
+ type: "string",
6763
+ description: "The committer's Ed25519 public key (base64url). Required if verifying a counterparty's commitment. Omit to auto-resolve from local identities."
6764
+ }
6765
+ },
6766
+ required: ["bridge_commitment_id"]
6767
+ },
6768
+ handler: async (args) => {
6769
+ const commitmentId = args.bridge_commitment_id;
6770
+ const externalPublicKey = args.committer_public_key;
6771
+ const record = await bridgeStore.get(commitmentId);
6772
+ if (!record) {
6773
+ return toolResult({
6774
+ error: `Bridge commitment "${commitmentId}" not found`
6775
+ });
6776
+ }
6777
+ const { commitment: storedCommitment, outcome } = record;
6778
+ let publicKey;
6779
+ if (externalPublicKey) {
6780
+ publicKey = fromBase64url(externalPublicKey);
6781
+ } else {
6782
+ const localIdentities = identityManager.list();
6783
+ const match = localIdentities.find((i) => i.did === storedCommitment.committer_did);
6784
+ if (!match) {
6785
+ return toolResult({
6786
+ error: `Cannot resolve public key for committer "${storedCommitment.committer_did}". Provide committer_public_key for external verification.`
6787
+ });
6788
+ }
6789
+ publicKey = fromBase64url(match.public_key);
6790
+ }
6791
+ const result = verifyBridgeCommitment(storedCommitment, outcome, publicKey);
6792
+ auditLog.append("l3", "bridge_verify", "system", {
6793
+ bridge_commitment_id: commitmentId,
6794
+ session_id: storedCommitment.session_id,
6795
+ valid: result.valid
6796
+ });
6797
+ return toolResult({
6798
+ ...result,
6799
+ session_id: storedCommitment.session_id,
6800
+ committer_did: storedCommitment.committer_did,
6801
+ // SEC-ADD-03: Tag response as containing counterparty-controlled data
6802
+ _content_trust: "external"
6803
+ });
6804
+ }
6805
+ },
6806
+ // ─── bridge_attest ───────────────────────────────────────────────────
6807
+ {
6808
+ name: "sanctuary/bridge_attest",
6809
+ description: "Record a Concordia negotiation as a Sanctuary L4 reputation attestation, linked to a bridge commitment. This completes the bridge: the commitment (L3) proves the terms were agreed, and the attestation (L4) feeds the sovereignty-weighted reputation score. The attestation is automatically tagged with the counterparty's sovereignty tier from any completed handshake.",
6810
+ inputSchema: {
6811
+ type: "object",
6812
+ properties: {
6813
+ bridge_commitment_id: {
6814
+ type: "string",
6815
+ description: "The bridge commitment ID to link"
6816
+ },
6817
+ outcome_result: {
6818
+ type: "string",
6819
+ enum: ["completed", "partial", "failed", "disputed"],
6820
+ description: "Negotiation outcome for reputation scoring"
6821
+ },
6822
+ metrics: {
6823
+ type: "object",
6824
+ description: "Optional metrics (e.g., rounds, response_time_ms, terms_complexity)"
6825
+ },
6826
+ identity_id: {
6827
+ type: "string",
6828
+ description: "Identity to sign the attestation (uses default if omitted)"
6829
+ }
6830
+ },
6831
+ required: ["bridge_commitment_id", "outcome_result"]
6832
+ },
6833
+ handler: async (args) => {
6834
+ const commitmentId = args.bridge_commitment_id;
6835
+ const outcomeResult = args.outcome_result;
6836
+ const metrics = args.metrics ?? {};
6837
+ const identityId = args.identity_id;
6838
+ const record = await bridgeStore.get(commitmentId);
6839
+ if (!record) {
6840
+ return toolResult({
6841
+ error: `Bridge commitment "${commitmentId}" not found`
6842
+ });
6843
+ }
6844
+ const { outcome } = record;
6845
+ const identity = resolveIdentity(identityId);
6846
+ const counterpartyDid = outcome.proposer_did === identity.did ? outcome.acceptor_did : outcome.proposer_did;
6847
+ const hasSanctuaryIdentity = identityManager.list().some(
6848
+ (id) => identityManager.get(id.identity_id)?.did === counterpartyDid
6849
+ );
6850
+ const tierMeta = resolveTier(counterpartyDid, hsResults, hasSanctuaryIdentity);
6851
+ const tier = tierMeta.sovereignty_tier;
6852
+ const fullMetrics = {
6853
+ ...metrics,
6854
+ negotiation_rounds: outcome.rounds
6855
+ };
6856
+ const attestation = await reputationStore.record(
6857
+ outcome.session_id,
6858
+ // interaction_id = concordia session
6859
+ counterpartyDid,
6860
+ {
6861
+ type: "negotiation",
6862
+ result: outcomeResult,
6863
+ metrics: fullMetrics
6864
+ },
6865
+ "concordia-bridge",
6866
+ // context
6867
+ identity,
6868
+ identityEncryptionKey,
6869
+ void 0,
6870
+ // counterparty_attestation
6871
+ tier
6872
+ );
6873
+ auditLog.append("l4", "bridge_attest", identity.identity_id, {
6874
+ bridge_commitment_id: commitmentId,
6875
+ session_id: outcome.session_id,
6876
+ attestation_id: attestation.attestation.attestation_id,
6877
+ counterparty_did: counterpartyDid,
6878
+ sovereignty_tier: tier
6879
+ });
6880
+ const weight = TIER_WEIGHTS[tier];
6881
+ return toolResult({
6882
+ attestation_id: attestation.attestation.attestation_id,
6883
+ bridge_commitment_id: commitmentId,
6884
+ session_id: outcome.session_id,
6885
+ counterparty_did: counterpartyDid,
6886
+ outcome_result: outcomeResult,
6887
+ sovereignty_tier: tier,
6888
+ attested_at: attestation.recorded_at,
6889
+ note: `Negotiation recorded as reputation attestation. Counterparty sovereignty tier: ${tier} (weight: ${weight}).`
6890
+ });
6891
+ }
6892
+ }
6893
+ ];
6894
+ return { tools };
6895
+ }
6896
+ function lenientJsonParse(raw) {
6897
+ let cleaned = raw.replace(/\/\/[^\n]*/g, "");
6898
+ cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, "");
6899
+ cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
6900
+ return JSON.parse(cleaned);
6901
+ }
6902
+ async function fileExists(path) {
6903
+ try {
6904
+ await promises.access(path);
6905
+ return true;
6906
+ } catch {
6907
+ return false;
6908
+ }
6909
+ }
6910
+ async function safeReadFile(path) {
6911
+ try {
6912
+ return await promises.readFile(path, "utf-8");
6913
+ } catch {
6914
+ return null;
6915
+ }
6916
+ }
6917
+ async function detectEnvironment(config, deepScan) {
6918
+ const fingerprint = {
6919
+ sanctuary_installed: true,
6920
+ // We're running inside Sanctuary
6921
+ sanctuary_version: config.version,
6922
+ openclaw_detected: false,
6923
+ openclaw_version: null,
6924
+ openclaw_config: null,
6925
+ node_version: process.version,
6926
+ platform: `${process.platform}-${process.arch}`
6927
+ };
6928
+ if (!deepScan) {
6929
+ return fingerprint;
6930
+ }
6931
+ const home = os.homedir();
6932
+ const openclawConfigPath = path.join(home, ".openclaw", "openclaw.json");
6933
+ const openclawEnvPath = path.join(home, ".openclaw", ".env");
6934
+ const openclawMemoryPath = path.join(home, ".openclaw", "workspace", "MEMORY.md");
6935
+ const openclawMemoryDir = path.join(home, ".openclaw", "workspace", "memory");
6936
+ const configExists = await fileExists(openclawConfigPath);
6937
+ const envExists = await fileExists(openclawEnvPath);
6938
+ const memoryExists = await fileExists(openclawMemoryPath);
6939
+ const memoryDirExists = await fileExists(openclawMemoryDir);
6940
+ if (configExists || memoryExists || memoryDirExists) {
6941
+ fingerprint.openclaw_detected = true;
6942
+ fingerprint.openclaw_config = await auditOpenClawConfig(
6943
+ openclawConfigPath,
6944
+ openclawEnvPath,
6945
+ openclawMemoryPath,
6946
+ configExists,
6947
+ envExists,
6948
+ memoryExists
6949
+ );
6950
+ }
6951
+ return fingerprint;
6952
+ }
6953
+ async function auditOpenClawConfig(configPath, envPath, _memoryPath, configExists, envExists, memoryExists) {
6954
+ const audit = {
6955
+ config_path: configExists ? configPath : null,
6956
+ require_approval_enabled: false,
6957
+ sandbox_policy_active: false,
6958
+ sandbox_allow_list: [],
6959
+ sandbox_deny_list: [],
6960
+ memory_encrypted: false,
6961
+ // Stock OpenClaw never encrypts memory
6962
+ env_file_exposed: false,
6963
+ gateway_token_set: false,
6964
+ dm_pairing_enabled: false,
6965
+ mcp_bridge_active: false
6966
+ };
6967
+ if (configExists) {
6968
+ const raw = await safeReadFile(configPath);
6969
+ if (raw) {
6970
+ try {
6971
+ const parsed = lenientJsonParse(raw);
6972
+ const hooks = parsed.hooks;
6973
+ if (hooks) {
6974
+ const beforeToolCall = hooks.before_tool_call;
6975
+ if (beforeToolCall) {
6976
+ const hookStr = JSON.stringify(beforeToolCall);
6977
+ audit.require_approval_enabled = hookStr.includes("requireApproval");
6978
+ }
6979
+ }
6980
+ const tools = parsed.tools;
6981
+ if (tools) {
6982
+ const sandbox = tools.sandbox;
6983
+ if (sandbox) {
6984
+ const sandboxTools = sandbox.tools;
6985
+ if (sandboxTools) {
6986
+ audit.sandbox_policy_active = true;
6987
+ if (Array.isArray(sandboxTools.allow)) {
6988
+ audit.sandbox_allow_list = sandboxTools.allow.filter(
6989
+ (item) => typeof item === "string"
6990
+ );
6991
+ }
6992
+ if (Array.isArray(sandboxTools.alsoAllow)) {
6993
+ audit.sandbox_allow_list = [
6994
+ ...audit.sandbox_allow_list,
6995
+ ...sandboxTools.alsoAllow.filter(
6996
+ (item) => typeof item === "string"
6997
+ )
6998
+ ];
6999
+ }
7000
+ if (Array.isArray(sandboxTools.deny)) {
7001
+ audit.sandbox_deny_list = sandboxTools.deny.filter(
7002
+ (item) => typeof item === "string"
7003
+ );
7004
+ }
7005
+ }
7006
+ }
7007
+ }
7008
+ const mcpServers = parsed.mcpServers;
7009
+ if (mcpServers && Object.keys(mcpServers).length > 0) {
7010
+ audit.mcp_bridge_active = true;
7011
+ }
7012
+ } catch {
7013
+ }
7014
+ }
7015
+ }
7016
+ if (envExists) {
7017
+ const envContent = await safeReadFile(envPath);
7018
+ if (envContent) {
7019
+ const secretPatterns = [
7020
+ /[A-Z_]*API_KEY\s*=/,
7021
+ /[A-Z_]*TOKEN\s*=/,
7022
+ /[A-Z_]*SECRET\s*=/,
7023
+ /[A-Z_]*PASSWORD\s*=/,
7024
+ /[A-Z_]*PRIVATE_KEY\s*=/
7025
+ ];
7026
+ audit.env_file_exposed = secretPatterns.some((p) => p.test(envContent));
7027
+ audit.gateway_token_set = /OPENCLAW_GATEWAY_TOKEN\s*=/.test(envContent);
7028
+ }
7029
+ }
7030
+ if (memoryExists) {
7031
+ audit.memory_encrypted = false;
7032
+ }
7033
+ return audit;
7034
+ }
7035
+
7036
+ // src/audit/analyzer.ts
7037
+ var L1_ENCRYPTION_AT_REST = 10;
7038
+ var L1_IDENTITY_CRYPTOGRAPHIC = 10;
7039
+ var L1_INTEGRITY_VERIFICATION = 8;
7040
+ var L1_STATE_PORTABLE = 7;
7041
+ var L2_THREE_TIER_GATE = 10;
7042
+ var L2_BINARY_GATE = 3;
7043
+ var L2_ANOMALY_DETECTION = 7;
7044
+ var L2_ENCRYPTED_AUDIT = 5;
7045
+ var L2_TOOL_SANDBOXING = 3;
7046
+ var L3_COMMITMENT_SCHEME = 8;
7047
+ var L3_ZK_PROOFS = 7;
7048
+ var L3_DISCLOSURE_POLICIES = 5;
7049
+ var L4_PORTABLE_REPUTATION = 6;
7050
+ var L4_SIGNED_ATTESTATIONS = 6;
7051
+ var L4_SYBIL_DETECTION = 4;
7052
+ var L4_SOVEREIGNTY_GATED = 4;
7053
+ var SEVERITY_ORDER = {
7054
+ critical: 0,
7055
+ high: 1,
7056
+ medium: 2,
7057
+ low: 3
7058
+ };
7059
+ function analyzeSovereignty(env, config) {
7060
+ const l1 = assessL1(env, config);
7061
+ const l2 = assessL2(env);
7062
+ const l3 = assessL3(env);
7063
+ const l4 = assessL4(env);
7064
+ const l1Score = scoreL1(l1);
7065
+ const l2Score = scoreL2(l2);
7066
+ const l3Score = scoreL3(l3);
7067
+ const l4Score = scoreL4(l4);
7068
+ const overallScore = l1Score + l2Score + l3Score + l4Score;
7069
+ const sovereigntyLevel = overallScore >= 80 ? "full" : overallScore >= 50 ? "partial" : overallScore >= 20 ? "minimal" : "none";
7070
+ const gaps = generateGaps(env, l1, l2, l3, l4);
7071
+ gaps.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
7072
+ const recommendations = generateRecommendations(env, l1, l2, l3, l4);
7073
+ return {
7074
+ version: "1.0",
7075
+ audited_at: (/* @__PURE__ */ new Date()).toISOString(),
7076
+ environment: env,
7077
+ layers: {
7078
+ l1_cognitive: l1,
7079
+ l2_operational: l2,
7080
+ l3_selective_disclosure: l3,
7081
+ l4_reputation: l4
7082
+ },
7083
+ overall_score: overallScore,
7084
+ sovereignty_level: sovereigntyLevel,
7085
+ gaps,
7086
+ recommendations
7087
+ };
7088
+ }
7089
+ function assessL1(env, config) {
7090
+ const findings = [];
7091
+ const sanctuaryActive = env.sanctuary_installed;
7092
+ const encryptionAtRest = sanctuaryActive;
7093
+ const keyCustody = sanctuaryActive ? "self" : "none";
7094
+ const integrityVerification = sanctuaryActive;
7095
+ const identityCryptographic = sanctuaryActive;
7096
+ const statePortable = sanctuaryActive;
7097
+ if (sanctuaryActive) {
7098
+ findings.push("AES-256-GCM encryption active for all state");
7099
+ findings.push(`Key derivation: ${config.state.key_derivation}`);
7100
+ findings.push(`Identity provider: ${config.state.identity_provider}`);
7101
+ findings.push("Merkle integrity verification enabled");
7102
+ findings.push("State export/import available");
7103
+ }
7104
+ if (env.openclaw_detected && env.openclaw_config) {
7105
+ if (!env.openclaw_config.memory_encrypted) {
7106
+ findings.push("OpenClaw agent memory (MEMORY.md, daily notes) stored in plaintext");
7107
+ }
7108
+ if (env.openclaw_config.env_file_exposed) {
7109
+ findings.push("OpenClaw .env file contains plaintext API keys/tokens");
7110
+ }
7111
+ }
7112
+ const status = encryptionAtRest && identityCryptographic ? "active" : encryptionAtRest || identityCryptographic ? "partial" : "inactive";
7113
+ return {
7114
+ status,
7115
+ encryption_at_rest: encryptionAtRest,
7116
+ key_custody: keyCustody,
7117
+ integrity_verification: integrityVerification,
7118
+ identity_cryptographic: identityCryptographic,
7119
+ state_portable: statePortable,
7120
+ findings
7121
+ };
7122
+ }
7123
+ function assessL2(env, _config) {
7124
+ const findings = [];
7125
+ const sanctuaryActive = env.sanctuary_installed;
7126
+ let approvalGate = "none";
7127
+ let behavioralAnomalyDetection = false;
7128
+ let auditTrailEncrypted = false;
7129
+ let auditTrailExists = false;
7130
+ let toolSandboxing = "none";
7131
+ if (sanctuaryActive) {
7132
+ approvalGate = "three-tier";
7133
+ behavioralAnomalyDetection = true;
7134
+ auditTrailEncrypted = true;
7135
+ auditTrailExists = true;
7136
+ findings.push("Three-tier Principal Policy gate active");
7137
+ findings.push("Behavioral anomaly detection (BaselineTracker) enabled");
7138
+ findings.push("Encrypted audit trail active");
7139
+ }
7140
+ if (env.openclaw_detected && env.openclaw_config) {
7141
+ if (env.openclaw_config.require_approval_enabled) {
7142
+ if (!sanctuaryActive) {
7143
+ approvalGate = "binary";
7144
+ }
7145
+ findings.push("OpenClaw requireApproval hook enabled (binary approve/deny)");
7146
+ }
7147
+ if (env.openclaw_config.sandbox_policy_active) {
7148
+ if (!sanctuaryActive) {
7149
+ toolSandboxing = "basic";
7150
+ }
7151
+ findings.push(
7152
+ `OpenClaw sandbox policy active (${env.openclaw_config.sandbox_allow_list.length} allowed, ${env.openclaw_config.sandbox_deny_list.length} denied)`
7153
+ );
7154
+ }
7155
+ }
7156
+ const status = approvalGate === "three-tier" && auditTrailEncrypted ? "active" : approvalGate !== "none" || auditTrailExists ? "partial" : "inactive";
7157
+ return {
7158
+ status,
7159
+ approval_gate: approvalGate,
7160
+ behavioral_anomaly_detection: behavioralAnomalyDetection,
7161
+ audit_trail_encrypted: auditTrailEncrypted,
7162
+ audit_trail_exists: auditTrailExists,
7163
+ tool_sandboxing: sanctuaryActive ? "policy-enforced" : toolSandboxing,
7164
+ findings
7165
+ };
7166
+ }
7167
+ function assessL3(env, _config) {
7168
+ const findings = [];
7169
+ const sanctuaryActive = env.sanctuary_installed;
7170
+ let commitmentScheme = "none";
7171
+ let zkProofs = false;
7172
+ let selectiveDisclosurePolicy = false;
7173
+ if (sanctuaryActive) {
7174
+ commitmentScheme = "pedersen+sha256";
7175
+ zkProofs = true;
7176
+ selectiveDisclosurePolicy = true;
7177
+ findings.push("SHA-256 + Pedersen commitment schemes active");
7178
+ findings.push("Schnorr ZK proofs and range proofs available");
7179
+ findings.push("Selective disclosure policies configurable");
7180
+ }
7181
+ const status = commitmentScheme === "pedersen+sha256" && zkProofs ? "active" : commitmentScheme !== "none" ? "partial" : "inactive";
7182
+ return {
7183
+ status,
7184
+ commitment_scheme: commitmentScheme,
7185
+ zero_knowledge_proofs: zkProofs,
7186
+ selective_disclosure_policy: selectiveDisclosurePolicy,
7187
+ findings
7188
+ };
7189
+ }
7190
+ function assessL4(env, _config) {
7191
+ const findings = [];
7192
+ const sanctuaryActive = env.sanctuary_installed;
7193
+ const reputationPortable = sanctuaryActive;
7194
+ const reputationSigned = sanctuaryActive;
7195
+ const sybilDetection = sanctuaryActive;
7196
+ const sovereigntyGated = sanctuaryActive;
7197
+ if (sanctuaryActive) {
7198
+ findings.push("Signed EAS-compatible attestations active");
7199
+ findings.push("Reputation export/import available");
7200
+ findings.push("Sybil detection heuristics enabled");
7201
+ findings.push("Sovereignty-gated reputation tiers active");
7202
+ } else {
7203
+ findings.push("No portable reputation system detected");
7204
+ }
7205
+ const status = reputationPortable && reputationSigned && sovereigntyGated ? "active" : reputationPortable || reputationSigned ? "partial" : "inactive";
7206
+ return {
7207
+ status,
7208
+ reputation_portable: reputationPortable,
7209
+ reputation_signed: reputationSigned,
7210
+ reputation_sybil_detection: sybilDetection,
7211
+ sovereignty_gated_tiers: sovereigntyGated,
7212
+ findings
7213
+ };
7214
+ }
7215
+ function scoreL1(l1) {
7216
+ let score = 0;
7217
+ if (l1.encryption_at_rest) score += L1_ENCRYPTION_AT_REST;
7218
+ if (l1.identity_cryptographic) score += L1_IDENTITY_CRYPTOGRAPHIC;
7219
+ if (l1.integrity_verification) score += L1_INTEGRITY_VERIFICATION;
7220
+ if (l1.state_portable) score += L1_STATE_PORTABLE;
7221
+ return score;
7222
+ }
7223
+ function scoreL2(l2) {
7224
+ let score = 0;
7225
+ if (l2.approval_gate === "three-tier") score += L2_THREE_TIER_GATE;
7226
+ else if (l2.approval_gate === "binary") score += L2_BINARY_GATE;
7227
+ if (l2.behavioral_anomaly_detection) score += L2_ANOMALY_DETECTION;
7228
+ if (l2.audit_trail_encrypted) score += L2_ENCRYPTED_AUDIT;
7229
+ if (l2.tool_sandboxing === "policy-enforced") score += L2_TOOL_SANDBOXING;
7230
+ else if (l2.tool_sandboxing === "basic") score += 1;
7231
+ return score;
7232
+ }
7233
+ function scoreL3(l3) {
7234
+ let score = 0;
7235
+ if (l3.commitment_scheme === "pedersen+sha256") score += L3_COMMITMENT_SCHEME;
7236
+ else if (l3.commitment_scheme === "sha256-only") score += 4;
7237
+ if (l3.zero_knowledge_proofs) score += L3_ZK_PROOFS;
7238
+ if (l3.selective_disclosure_policy) score += L3_DISCLOSURE_POLICIES;
7239
+ return score;
7240
+ }
7241
+ function scoreL4(l4) {
7242
+ let score = 0;
7243
+ if (l4.reputation_portable) score += L4_PORTABLE_REPUTATION;
7244
+ if (l4.reputation_signed) score += L4_SIGNED_ATTESTATIONS;
7245
+ if (l4.reputation_sybil_detection) score += L4_SYBIL_DETECTION;
7246
+ if (l4.sovereignty_gated_tiers) score += L4_SOVEREIGNTY_GATED;
7247
+ return score;
7248
+ }
7249
+ function generateGaps(env, l1, l2, l3, l4) {
7250
+ const gaps = [];
7251
+ const oc = env.openclaw_config;
7252
+ if (oc && !oc.memory_encrypted) {
7253
+ gaps.push({
7254
+ id: "GAP-L1-001",
7255
+ layer: "L1",
7256
+ severity: "critical",
7257
+ title: "Agent memory stored in plaintext",
7258
+ description: "Your agent's memory (MEMORY.md, daily notes, SQLite index) is stored in plaintext at ~/.openclaw/workspace/. Any process with file access can read your agent's full context \u2014 preferences, decisions, conversation history.",
7259
+ openclaw_relevance: "Stock OpenClaw stores all agent memory in plaintext files. There is no built-in encryption for agent state.",
7260
+ sanctuary_solution: "Sanctuary encrypts all state at rest with AES-256-GCM using a key derived from Argon2id, making state opaque to any process that doesn't hold the master key. Use sanctuary/state_write to migrate sensitive state to the encrypted store."
7261
+ });
7262
+ }
7263
+ if (oc && oc.env_file_exposed) {
7264
+ gaps.push({
7265
+ id: "GAP-L1-002",
7266
+ layer: "L1",
7267
+ severity: "critical",
7268
+ title: "Plaintext API keys in .env file",
7269
+ description: "Your .env file contains plaintext API keys and tokens. These secrets are readable by any process with filesystem access.",
7270
+ openclaw_relevance: "OpenClaw stores API keys (LLM providers, gateway tokens) in a plaintext .env file.",
7271
+ sanctuary_solution: "Sanctuary's encrypted state store can hold secrets under the same AES-256-GCM envelope as all other state, tied to your self-custodied identity. Use sanctuary/state_write with namespace 'secrets'."
7272
+ });
7273
+ }
7274
+ if (!l1.identity_cryptographic) {
7275
+ gaps.push({
7276
+ id: "GAP-L1-003",
7277
+ layer: "L1",
7278
+ severity: "critical",
7279
+ title: "No cryptographic agent identity",
7280
+ description: "Your agent has no cryptographic identity. It cannot prove it is who it claims to be to any counterparty, sign messages, or participate in sovereignty handshakes.",
7281
+ openclaw_relevance: env.openclaw_detected ? "OpenClaw has no cryptographic agent identity. Agent identity is implicit (tied to the process/session), not cryptographically verifiable." : null,
7282
+ sanctuary_solution: "Sanctuary provides Ed25519 self-custodied identity with key rotation and delegation. Use sanctuary/identity_create to establish your cryptographic identity."
7283
+ });
7284
+ }
7285
+ if (l2.approval_gate === "binary" && !l2.behavioral_anomaly_detection) {
7286
+ gaps.push({
7287
+ id: "GAP-L2-001",
7288
+ layer: "L2",
7289
+ severity: "high",
7290
+ title: "Binary approval gate (no anomaly detection)",
7291
+ description: "Your approval gate provides binary approve/deny gating without behavioral anomaly detection. Routine operations require the same manual approval as sensitive ones.",
7292
+ openclaw_relevance: env.openclaw_detected ? "OpenClaw's requireApproval hook provides binary approve/deny gating. Sanctuary's three-tier Principal Policy adds behavioral anomaly detection (auto-escalation when agent behavior deviates from baseline), encrypted audit trails, and graduated approval tiers \u2014 so routine operations auto-proceed while sensitive operations require explicit consent." : null,
7293
+ sanctuary_solution: "Sanctuary's three-tier Principal Policy gate auto-allows routine operations (Tier 3), escalates anomalous behavior (Tier 2), and always requires human approval for irreversible operations (Tier 1). Use sanctuary/principal_policy_view to inspect."
7294
+ });
7295
+ } else if (l2.approval_gate === "none") {
7296
+ gaps.push({
7297
+ id: "GAP-L2-001",
7298
+ layer: "L2",
7299
+ severity: "critical",
7300
+ title: "No approval gate",
7301
+ description: "No approval gate is configured. All tool calls execute without oversight.",
7302
+ openclaw_relevance: null,
7303
+ sanctuary_solution: "Sanctuary's Principal Policy evaluates every tool call before execution. Enable it to get three-tier approval gating with behavioral anomaly detection."
7304
+ });
7305
+ }
7306
+ if (l2.tool_sandboxing === "basic") {
7307
+ gaps.push({
7308
+ id: "GAP-L2-002",
7309
+ layer: "L2",
7310
+ severity: "medium",
7311
+ title: "Basic tool sandboxing (no cryptographic attestation)",
7312
+ description: "Your tool sandbox enforces allow/deny lists but provides no cryptographic attestation of execution context.",
7313
+ openclaw_relevance: env.openclaw_detected ? "OpenClaw's sandbox tool policy (tools.sandbox.tools) enforces allow/deny lists. Sanctuary adds cryptographic attestation of execution context \u2014 a verifiable proof that an operation ran within policy, not just that a policy was configured." : null,
7314
+ sanctuary_solution: "Sanctuary provides cryptographic execution attestation via sanctuary/exec_attest and policy-enforced sandboxing with encrypted audit trails."
7315
+ });
7316
+ }
7317
+ if (!l2.audit_trail_exists) {
7318
+ gaps.push({
7319
+ id: "GAP-L2-003",
7320
+ layer: "L2",
7321
+ severity: "high",
7322
+ title: "No audit trail",
7323
+ description: "No audit trail exists for tool call history. There is no record of what operations were executed, when, or by whom.",
7324
+ openclaw_relevance: null,
7325
+ sanctuary_solution: "Sanctuary maintains an encrypted audit log of all operations, queryable via sanctuary/monitor_audit_log."
7326
+ });
7327
+ }
7328
+ if (l3.commitment_scheme === "none") {
7329
+ gaps.push({
7330
+ id: "GAP-L3-001",
7331
+ layer: "L3",
7332
+ severity: "high",
7333
+ title: "No selective disclosure capability",
7334
+ description: "Your agent has no way to prove facts about its state without revealing the state itself. Every disclosure is all-or-nothing.",
7335
+ openclaw_relevance: env.openclaw_detected ? "OpenClaw has no selective disclosure mechanism. When your agent shares information, it shares everything or nothing \u2014 there is no way to prove a claim without revealing the underlying data." : null,
7336
+ sanctuary_solution: "Sanctuary's L3 provides SHA-256 + Pedersen commitments and Schnorr zero-knowledge proofs. Your agent can prove it has a valid credential, sufficient reputation, or a completed transaction without exposing the underlying data. Use sanctuary/zk_commit and sanctuary/zk_prove."
7337
+ });
7338
+ }
7339
+ if (!l4.reputation_portable) {
7340
+ gaps.push({
7341
+ id: "GAP-L4-001",
7342
+ layer: "L4",
7343
+ severity: "high",
7344
+ title: "No portable reputation",
7345
+ description: "Your agent's reputation is platform-locked. If you move to a different harness or platform, your track record doesn't follow.",
7346
+ openclaw_relevance: env.openclaw_detected ? "OpenClaw has no reputation system. Your agent's track record exists only in conversation history, which is not structured, signed, or portable." : null,
7347
+ sanctuary_solution: "Sanctuary's L4 provides signed EAS-compatible attestations that are self-custodied, portable, and cryptographically verifiable. Your reputation is yours, not your platform's. Use sanctuary/reputation_record to start building portable reputation."
7348
+ });
7349
+ }
7350
+ return gaps;
7351
+ }
7352
+ function generateRecommendations(env, l1, l2, l3, l4) {
7353
+ const recs = [];
7354
+ if (!l1.identity_cryptographic) {
7355
+ recs.push({
7356
+ priority: 1,
7357
+ action: "Create a cryptographic identity \u2014 your agent's foundation for all sovereignty operations",
7358
+ tool: "sanctuary/identity_create",
7359
+ effort: "immediate",
7360
+ impact: "critical"
7361
+ });
7362
+ }
7363
+ if (!l1.encryption_at_rest || env.openclaw_config && !env.openclaw_config.memory_encrypted) {
7364
+ recs.push({
7365
+ priority: 2,
7366
+ action: "Migrate plaintext agent state to Sanctuary's encrypted store",
7367
+ tool: "sanctuary/state_write",
7368
+ effort: "minutes",
7369
+ impact: "critical"
7370
+ });
7371
+ }
7372
+ recs.push({
7373
+ priority: 3,
7374
+ action: "Generate a Sovereignty Health Report to present to counterparties",
7375
+ tool: "sanctuary/shr_generate",
7376
+ effort: "immediate",
7377
+ impact: "high"
7378
+ });
7379
+ if (l2.approval_gate !== "three-tier") {
7380
+ recs.push({
7381
+ priority: 4,
7382
+ action: "Enable the three-tier Principal Policy gate for graduated approval",
7383
+ tool: "sanctuary/principal_policy_view",
7384
+ effort: "minutes",
7385
+ impact: "high"
7386
+ });
7387
+ }
7388
+ if (!l4.reputation_signed) {
7389
+ recs.push({
7390
+ priority: 5,
7391
+ action: "Start recording reputation attestations from completed interactions",
7392
+ tool: "sanctuary/reputation_record",
7393
+ effort: "minutes",
7394
+ impact: "medium"
7395
+ });
7396
+ }
7397
+ if (!l3.selective_disclosure_policy) {
7398
+ recs.push({
7399
+ priority: 6,
7400
+ action: "Configure selective disclosure policies for data sharing",
7401
+ tool: "sanctuary/disclosure_set_policy",
7402
+ effort: "hours",
7403
+ impact: "medium"
7404
+ });
7405
+ }
7406
+ return recs;
7407
+ }
7408
+ function formatAuditReport(result) {
7409
+ const { environment: env, layers, overall_score, sovereignty_level, gaps, recommendations } = result;
7410
+ const scoreBar = formatScoreBar(overall_score);
7411
+ const levelLabel = sovereignty_level.toUpperCase();
7412
+ let report = "";
7413
+ report += "\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n";
7414
+ report += " SOVEREIGNTY AUDIT REPORT\n";
7415
+ report += ` Generated: ${result.audited_at}
7416
+ `;
7417
+ report += "\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n";
7418
+ report += "\n";
7419
+ report += ` Overall Score: ${overall_score} / 100 ${scoreBar} ${levelLabel}
7420
+ `;
7421
+ report += "\n";
7422
+ report += " Environment:\n";
7423
+ report += ` \u2022 Sanctuary v${env.sanctuary_version ?? "?"} ${padDots("Sanctuary v" + (env.sanctuary_version ?? "?"))} ${env.sanctuary_installed ? "\u2713 installed" : "\u2717 not found"}
7424
+ `;
7425
+ if (env.openclaw_detected) {
7426
+ report += ` \u2022 OpenClaw ${padDots("OpenClaw")} \u2713 detected
7427
+ `;
7428
+ if (env.openclaw_config) {
7429
+ report += ` \u2022 OpenClaw requireApproval ${padDots("OpenClaw requireApproval")} ${env.openclaw_config.require_approval_enabled ? "\u2713 enabled" : "\u2717 disabled"}
7430
+ `;
7431
+ report += ` \u2022 OpenClaw sandbox policy ${padDots("OpenClaw sandbox policy")} ${env.openclaw_config.sandbox_policy_active ? "\u2713 active" : "\u2717 inactive"}
7432
+ `;
7433
+ }
7434
+ }
7435
+ report += "\n";
7436
+ const l1Score = scoreL1(layers.l1_cognitive);
7437
+ const l2Score = scoreL2(layers.l2_operational);
7438
+ const l3Score = scoreL3(layers.l3_selective_disclosure);
7439
+ const l4Score = scoreL4(layers.l4_reputation);
7440
+ report += " Layer Assessment:\n";
7441
+ report += " \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n";
7442
+ report += " \u2502 Layer \u2502 Status \u2502 Score \u2502\n";
7443
+ report += " \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n";
7444
+ report += ` \u2502 L1 Cognitive Sovereignty \u2502 ${padStatus(layers.l1_cognitive.status)} \u2502 ${padScore(l1Score, 35)} \u2502
7445
+ `;
7446
+ report += ` \u2502 L2 Operational Isolation \u2502 ${padStatus(layers.l2_operational.status)} \u2502 ${padScore(l2Score, 25)} \u2502
7447
+ `;
7448
+ report += ` \u2502 L3 Selective Disclosure \u2502 ${padStatus(layers.l3_selective_disclosure.status)} \u2502 ${padScore(l3Score, 20)} \u2502
7449
+ `;
7450
+ report += ` \u2502 L4 Verifiable Reputation \u2502 ${padStatus(layers.l4_reputation.status)} \u2502 ${padScore(l4Score, 20)} \u2502
7451
+ `;
7452
+ report += " \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n";
7453
+ report += "\n";
7454
+ if (gaps.length > 0) {
7455
+ report += ` \u26A0 ${gaps.length} SOVEREIGNTY GAP${gaps.length !== 1 ? "S" : ""} FOUND
7456
+ `;
7457
+ report += "\n";
7458
+ for (const gap of gaps) {
7459
+ const severityLabel = `[${gap.severity.toUpperCase()}]`;
7460
+ report += ` ${severityLabel} ${gap.id}: ${gap.title}
7461
+ `;
7462
+ const descLines = wordWrap(gap.description, 66);
7463
+ for (const line of descLines) {
7464
+ report += ` ${line}
7465
+ `;
7466
+ }
7467
+ report += ` \u2192 Fix: ${gap.sanctuary_solution.split(".")[0]}.
7468
+ `;
7469
+ if (gap.openclaw_relevance) {
7470
+ report += ` \u2192 OpenClaw context: ${gap.openclaw_relevance.split(".")[0]}.
7471
+ `;
7472
+ }
7473
+ report += "\n";
7474
+ }
7475
+ } else {
7476
+ report += " \u2713 NO SOVEREIGNTY GAPS FOUND\n";
7477
+ report += "\n";
7478
+ }
7479
+ if (recommendations.length > 0) {
7480
+ report += " RECOMMENDED NEXT STEPS (in order):\n";
7481
+ for (const rec of recommendations) {
7482
+ const effortLabel = rec.effort === "immediate" ? "immediate" : rec.effort === "minutes" ? "5 min" : "30 min";
7483
+ report += ` ${rec.priority}. [${effortLabel}] ${rec.action}`;
7484
+ if (rec.tool) {
7485
+ report += `: ${rec.tool}`;
7486
+ }
7487
+ report += "\n";
7488
+ }
7489
+ report += "\n";
7490
+ }
7491
+ report += "\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n";
7492
+ return report;
7493
+ }
7494
+ function formatScoreBar(score) {
7495
+ const filled = Math.round(score / 10);
7496
+ return "[" + "\u25A0".repeat(filled) + "\u2591".repeat(10 - filled) + "]";
7497
+ }
7498
+ function padDots(label) {
7499
+ const totalWidth = 30;
7500
+ const dotsNeeded = Math.max(2, totalWidth - label.length - 4);
7501
+ return ".".repeat(dotsNeeded);
7502
+ }
7503
+ function padStatus(status) {
7504
+ const label = status.toUpperCase();
7505
+ return label + " ".repeat(Math.max(0, 8 - label.length));
7506
+ }
7507
+ function padScore(score, max) {
7508
+ const text = `${score}/${max}`;
7509
+ return " ".repeat(Math.max(0, 5 - text.length)) + text;
7510
+ }
7511
+ function wordWrap(text, maxWidth) {
7512
+ const words = text.split(" ");
7513
+ const lines = [];
7514
+ let current = "";
7515
+ for (const word of words) {
7516
+ if (current.length + word.length + 1 > maxWidth && current.length > 0) {
7517
+ lines.push(current);
7518
+ current = word;
7519
+ } else {
7520
+ current = current.length > 0 ? current + " " + word : word;
7521
+ }
7522
+ }
7523
+ if (current.length > 0) lines.push(current);
7524
+ return lines;
7525
+ }
7526
+
7527
+ // src/audit/tools.ts
7528
+ function createAuditTools(config) {
7529
+ const tools = [
7530
+ {
7531
+ name: "sanctuary/sovereignty_audit",
7532
+ description: "Audit your agent's sovereignty posture. Inspects the local environment for encryption, identity, approval gates, selective disclosure, and reputation \u2014 including OpenClaw-specific configurations. Returns a scored gap analysis with prioritized recommendations.",
7533
+ inputSchema: {
7534
+ type: "object",
7535
+ properties: {
7536
+ deep_scan: {
7537
+ type: "boolean",
7538
+ description: "If true (default), also scans for OpenClaw config, .env files, and memory files. Set to false for a Sanctuary-only assessment."
7539
+ }
7540
+ }
7541
+ },
7542
+ handler: async (args) => {
7543
+ const deepScan = args.deep_scan !== false;
7544
+ const env = await detectEnvironment(config, deepScan);
7545
+ const result = analyzeSovereignty(env, config);
7546
+ const report = formatAuditReport(result);
7547
+ return {
7548
+ content: [
7549
+ { type: "text", text: report },
7550
+ { type: "text", text: JSON.stringify(result, null, 2) }
7551
+ ]
7552
+ };
7553
+ }
7554
+ }
7555
+ ];
7556
+ return { tools };
7557
+ }
7558
+
7559
+ // src/index.ts
7560
+ init_encoding();
7561
+ async function createSanctuaryServer(options) {
7562
+ const config = await loadConfig(options?.configPath);
7563
+ await promises.mkdir(config.storage_path, { recursive: true, mode: 448 });
7564
+ const storage = options?.storage ?? new FilesystemStorage(
7565
+ `${config.storage_path}/state`
7566
+ );
7567
+ let masterKey;
4104
7568
  let keyProtection;
4105
7569
  let recoveryKey;
4106
7570
  const passphrase = options?.passphrase ?? process.env.SANCTUARY_PASSPHRASE;
@@ -4127,15 +7591,51 @@ async function createSanctuaryServer(options) {
4127
7591
  }
4128
7592
  } else {
4129
7593
  keyProtection = "recovery-key";
4130
- const existing = await storage.read("_meta", "recovery-key-hash");
4131
- if (existing) {
4132
- masterKey = generateRandomKey();
4133
- recoveryKey = toBase64url(masterKey);
7594
+ const { hashToString: hashToString2 } = await Promise.resolve().then(() => (init_hashing(), hashing_exports));
7595
+ const { stringToBytes: stringToBytes2, bytesToString: bytesToString2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
7596
+ const { fromBase64url: fromBase64url2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
7597
+ const { constantTimeEqual: constantTimeEqual2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
7598
+ const existingHash = await storage.read("_meta", "recovery-key-hash");
7599
+ if (existingHash) {
7600
+ const envRecoveryKey = process.env.SANCTUARY_RECOVERY_KEY;
7601
+ if (!envRecoveryKey) {
7602
+ throw new Error(
7603
+ "Sanctuary: Existing encrypted data found but no credentials provided.\nThis installation was previously set up with a recovery key.\n\nTo start the server, provide one of:\n - SANCTUARY_PASSPHRASE (if you later configured a passphrase)\n - SANCTUARY_RECOVERY_KEY (the recovery key shown at first run)\n\nWithout the correct credentials, encrypted state cannot be accessed.\nRefusing to start to prevent silent data loss."
7604
+ );
7605
+ }
7606
+ let recoveryKeyBytes;
7607
+ try {
7608
+ recoveryKeyBytes = fromBase64url2(envRecoveryKey);
7609
+ } catch {
7610
+ throw new Error(
7611
+ "Sanctuary: SANCTUARY_RECOVERY_KEY is not valid base64url. The recovery key should be the exact string shown at first run."
7612
+ );
7613
+ }
7614
+ if (recoveryKeyBytes.length !== 32) {
7615
+ throw new Error(
7616
+ "Sanctuary: SANCTUARY_RECOVERY_KEY has incorrect length. The recovery key should be the exact string shown at first run."
7617
+ );
7618
+ }
7619
+ const providedHash = hashToString2(recoveryKeyBytes);
7620
+ const storedHash = bytesToString2(existingHash);
7621
+ const providedHashBytes = stringToBytes2(providedHash);
7622
+ const storedHashBytes = stringToBytes2(storedHash);
7623
+ if (!constantTimeEqual2(providedHashBytes, storedHashBytes)) {
7624
+ throw new Error(
7625
+ "Sanctuary: Recovery key does not match the stored key hash.\nThe recovery key provided via SANCTUARY_RECOVERY_KEY is incorrect.\nUse the exact recovery key that was displayed at first run."
7626
+ );
7627
+ }
7628
+ masterKey = recoveryKeyBytes;
4134
7629
  } else {
7630
+ const existingNamespaces = await storage.list("_meta");
7631
+ const hasKeyParams = existingNamespaces.some((e) => e.key === "key-params");
7632
+ if (hasKeyParams) {
7633
+ throw new Error(
7634
+ "Sanctuary: Found existing key derivation parameters but no recovery key hash.\nThis indicates a corrupted or incomplete installation.\nIf you previously used a passphrase, set SANCTUARY_PASSPHRASE to start."
7635
+ );
7636
+ }
4135
7637
  masterKey = generateRandomKey();
4136
7638
  recoveryKey = toBase64url(masterKey);
4137
- const { hashToString: hashToString2 } = await Promise.resolve().then(() => (init_hashing(), hashing_exports));
4138
- const { stringToBytes: stringToBytes2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
4139
7639
  const keyHash = hashToString2(masterKey);
4140
7640
  await storage.write(
4141
7641
  "_meta",
@@ -4371,30 +7871,75 @@ async function createSanctuaryServer(options) {
4371
7871
  }
4372
7872
  };
4373
7873
  const { tools: l3Tools } = createL3Tools(storage, masterKey, auditLog);
4374
- const { tools: l4Tools } = createL4Tools(
4375
- storage,
4376
- masterKey,
4377
- identityManager,
4378
- auditLog
4379
- );
4380
- const policy = await loadPrincipalPolicy(config.storage_path);
4381
- const baseline = new BaselineTracker(storage, masterKey);
4382
- await baseline.load();
4383
- const approvalChannel = new StderrApprovalChannel(policy.approval_channel);
4384
- const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog);
4385
- const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
4386
7874
  const { tools: shrTools } = createSHRTools(
4387
7875
  config,
4388
7876
  identityManager,
4389
7877
  masterKey,
4390
7878
  auditLog
4391
7879
  );
4392
- const { tools: handshakeTools } = createHandshakeTools(
7880
+ const { tools: handshakeTools, handshakeResults } = createHandshakeTools(
4393
7881
  config,
4394
7882
  identityManager,
4395
7883
  masterKey,
4396
7884
  auditLog
4397
7885
  );
7886
+ const { tools: l4Tools } = createL4Tools(
7887
+ storage,
7888
+ masterKey,
7889
+ identityManager,
7890
+ auditLog,
7891
+ handshakeResults
7892
+ );
7893
+ const { tools: federationTools } = createFederationTools(
7894
+ auditLog,
7895
+ handshakeResults
7896
+ );
7897
+ const { tools: bridgeTools } = createBridgeTools(
7898
+ storage,
7899
+ masterKey,
7900
+ identityManager,
7901
+ auditLog,
7902
+ handshakeResults
7903
+ );
7904
+ const { tools: auditTools } = createAuditTools(config);
7905
+ const policy = await loadPrincipalPolicy(config.storage_path);
7906
+ const baseline = new BaselineTracker(storage, masterKey);
7907
+ await baseline.load();
7908
+ let approvalChannel;
7909
+ let dashboard;
7910
+ if (config.dashboard.enabled) {
7911
+ let authToken = config.dashboard.auth_token;
7912
+ if (authToken === "auto") {
7913
+ const { randomBytes: rb } = await import('crypto');
7914
+ authToken = rb(32).toString("hex");
7915
+ }
7916
+ dashboard = new DashboardApprovalChannel({
7917
+ port: config.dashboard.port,
7918
+ host: config.dashboard.host,
7919
+ timeout_seconds: policy.approval_channel.timeout_seconds,
7920
+ // SEC-002: auto_deny removed — timeout always denies
7921
+ auth_token: authToken,
7922
+ tls: config.dashboard.tls
7923
+ });
7924
+ dashboard.setDependencies({ policy, baseline, auditLog });
7925
+ await dashboard.start();
7926
+ approvalChannel = dashboard;
7927
+ } else if (config.webhook.enabled && config.webhook.url && config.webhook.secret) {
7928
+ const webhook = new WebhookApprovalChannel({
7929
+ webhook_url: config.webhook.url,
7930
+ webhook_secret: config.webhook.secret,
7931
+ callback_port: config.webhook.callback_port,
7932
+ callback_host: config.webhook.callback_host,
7933
+ timeout_seconds: policy.approval_channel.timeout_seconds
7934
+ // SEC-002: auto_deny removed — timeout always denies
7935
+ });
7936
+ await webhook.start();
7937
+ approvalChannel = webhook;
7938
+ } else {
7939
+ approvalChannel = new StderrApprovalChannel(policy.approval_channel);
7940
+ }
7941
+ const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog);
7942
+ const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
4398
7943
  const allTools = [
4399
7944
  ...l1Tools,
4400
7945
  ...l2Tools,
@@ -4403,6 +7948,9 @@ async function createSanctuaryServer(options) {
4403
7948
  ...policyTools,
4404
7949
  ...shrTools,
4405
7950
  ...handshakeTools,
7951
+ ...federationTools,
7952
+ ...bridgeTools,
7953
+ ...auditTools,
4406
7954
  manifestTool
4407
7955
  ];
4408
7956
  const server = createServer(allTools, { gate });
@@ -4430,7 +7978,21 @@ async function createSanctuaryServer(options) {
4430
7978
 
4431
7979
  // src/cli.ts
4432
7980
  async function main() {
4433
- const passphrase = process.env.SANCTUARY_PASSPHRASE;
7981
+ const args = process.argv.slice(2);
7982
+ let passphrase = process.env.SANCTUARY_PASSPHRASE;
7983
+ for (let i = 0; i < args.length; i++) {
7984
+ if (args[i] === "--dashboard") {
7985
+ process.env.SANCTUARY_DASHBOARD_ENABLED = "true";
7986
+ } else if (args[i] === "--passphrase" && args[i + 1]) {
7987
+ passphrase = args[++i];
7988
+ } else if (args[i] === "--help" || args[i] === "-h") {
7989
+ printHelp();
7990
+ process.exit(0);
7991
+ } else if (args[i] === "--version" || args[i] === "-v") {
7992
+ console.log("@sanctuary-framework/mcp-server 0.3.0");
7993
+ process.exit(0);
7994
+ }
7995
+ }
4434
7996
  const { server, config } = await createSanctuaryServer({ passphrase });
4435
7997
  if (config.transport === "stdio") {
4436
7998
  const transport = new stdio_js.StdioServerTransport();
@@ -4443,6 +8005,34 @@ async function main() {
4443
8005
  process.exit(1);
4444
8006
  }
4445
8007
  }
8008
+ function printHelp() {
8009
+ console.log(`
8010
+ @sanctuary-framework/mcp-server v0.3.0
8011
+
8012
+ Sovereignty infrastructure for agents in the agentic economy.
8013
+
8014
+ Usage:
8015
+ sanctuary-mcp-server [options]
8016
+
8017
+ Options:
8018
+ --dashboard Enable the Principal Dashboard (web UI)
8019
+ --passphrase <pass> Derive encryption key from passphrase
8020
+ --help, -h Show this help
8021
+ --version, -v Show version
8022
+
8023
+ Environment variables:
8024
+ SANCTUARY_STORAGE_PATH State directory (default: ~/.sanctuary)
8025
+ SANCTUARY_PASSPHRASE Key derivation passphrase
8026
+ SANCTUARY_DASHBOARD_ENABLED "true" to enable dashboard
8027
+ SANCTUARY_DASHBOARD_PORT Dashboard port (default: 3501)
8028
+ SANCTUARY_DASHBOARD_AUTH_TOKEN Bearer token or "auto"
8029
+ SANCTUARY_WEBHOOK_ENABLED "true" to enable webhook approvals
8030
+ SANCTUARY_WEBHOOK_URL Webhook target URL
8031
+ SANCTUARY_WEBHOOK_SECRET HMAC-SHA256 shared secret
8032
+
8033
+ For more info: https://github.com/eriknewton/sanctuary-framework
8034
+ `);
8035
+ }
4446
8036
  main().catch((err) => {
4447
8037
  console.error("Sanctuary MCP Server failed to start:", err);
4448
8038
  process.exit(1);