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