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