@sanctuary-framework/mcp-server 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +1044 -86
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1045 -87
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1044 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +58 -15
- package/dist/index.d.ts +58 -15
- package/dist/index.js +1045 -87
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
import { randomBytes as randomBytes$1, createHmac } from 'crypto';
|
|
@@ -296,8 +296,13 @@ async function loadConfig(configPath) {
|
|
|
296
296
|
try {
|
|
297
297
|
const raw = await readFile(path, "utf-8");
|
|
298
298
|
const fileConfig = JSON.parse(raw);
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
}
|
|
301
306
|
return config;
|
|
302
307
|
}
|
|
303
308
|
}
|
|
@@ -305,6 +310,33 @@ async function saveConfig(config, configPath) {
|
|
|
305
310
|
const path = join(config.storage_path, "sanctuary.json");
|
|
306
311
|
await writeFile(path, JSON.stringify(config, null, 2), { mode: 384 });
|
|
307
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
|
+
}
|
|
308
340
|
function deepMerge(base, override) {
|
|
309
341
|
const result = { ...base };
|
|
310
342
|
for (const [key, value] of Object.entries(override)) {
|
|
@@ -648,7 +680,11 @@ var RESERVED_NAMESPACE_PREFIXES = [
|
|
|
648
680
|
"_commitments",
|
|
649
681
|
"_reputation",
|
|
650
682
|
"_escrow",
|
|
651
|
-
"_guarantees"
|
|
683
|
+
"_guarantees",
|
|
684
|
+
"_bridge",
|
|
685
|
+
"_federation",
|
|
686
|
+
"_handshake",
|
|
687
|
+
"_shr"
|
|
652
688
|
];
|
|
653
689
|
var StateStore = class {
|
|
654
690
|
storage;
|
|
@@ -915,12 +951,14 @@ var StateStore = class {
|
|
|
915
951
|
/**
|
|
916
952
|
* Import a previously exported state bundle.
|
|
917
953
|
*/
|
|
918
|
-
async import(bundleBase64, conflictResolution = "skip") {
|
|
954
|
+
async import(bundleBase64, conflictResolution = "skip", publicKeyResolver) {
|
|
919
955
|
const bundleBytes = fromBase64url(bundleBase64);
|
|
920
956
|
const bundleJson = bytesToString(bundleBytes);
|
|
921
957
|
const bundle = JSON.parse(bundleJson);
|
|
922
958
|
let importedKeys = 0;
|
|
923
959
|
let skippedKeys = 0;
|
|
960
|
+
let skippedInvalidSig = 0;
|
|
961
|
+
let skippedUnknownKid = 0;
|
|
924
962
|
let conflicts = 0;
|
|
925
963
|
const namespaces = [];
|
|
926
964
|
for (const [ns, entries] of Object.entries(
|
|
@@ -934,6 +972,26 @@ var StateStore = class {
|
|
|
934
972
|
}
|
|
935
973
|
namespaces.push(ns);
|
|
936
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
|
+
}
|
|
937
995
|
const exists = await this.storage.exists(ns, key);
|
|
938
996
|
if (exists) {
|
|
939
997
|
conflicts++;
|
|
@@ -969,6 +1027,8 @@ var StateStore = class {
|
|
|
969
1027
|
return {
|
|
970
1028
|
imported_keys: importedKeys,
|
|
971
1029
|
skipped_keys: skippedKeys,
|
|
1030
|
+
skipped_invalid_sig: skippedInvalidSig,
|
|
1031
|
+
skipped_unknown_kid: skippedUnknownKid,
|
|
972
1032
|
conflicts,
|
|
973
1033
|
namespaces,
|
|
974
1034
|
imported_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -1157,7 +1217,11 @@ var RESERVED_NAMESPACE_PREFIXES2 = [
|
|
|
1157
1217
|
"_commitments",
|
|
1158
1218
|
"_reputation",
|
|
1159
1219
|
"_escrow",
|
|
1160
|
-
"_guarantees"
|
|
1220
|
+
"_guarantees",
|
|
1221
|
+
"_bridge",
|
|
1222
|
+
"_federation",
|
|
1223
|
+
"_handshake",
|
|
1224
|
+
"_shr"
|
|
1161
1225
|
];
|
|
1162
1226
|
function getReservedNamespaceViolation(namespace) {
|
|
1163
1227
|
for (const prefix of RESERVED_NAMESPACE_PREFIXES2) {
|
|
@@ -1494,6 +1558,13 @@ function createL1Tools(stateStore, storage, masterKey, keyProtection, auditLog)
|
|
|
1494
1558
|
required: ["namespace", "key"]
|
|
1495
1559
|
},
|
|
1496
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
|
+
}
|
|
1497
1568
|
const result = await stateStore.read(
|
|
1498
1569
|
args.namespace,
|
|
1499
1570
|
args.key,
|
|
@@ -1530,6 +1601,13 @@ function createL1Tools(stateStore, storage, masterKey, keyProtection, auditLog)
|
|
|
1530
1601
|
required: ["namespace"]
|
|
1531
1602
|
},
|
|
1532
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
|
+
}
|
|
1533
1611
|
const result = await stateStore.list(
|
|
1534
1612
|
args.namespace,
|
|
1535
1613
|
args.prefix,
|
|
@@ -1608,9 +1686,15 @@ function createL1Tools(stateStore, storage, masterKey, keyProtection, auditLog)
|
|
|
1608
1686
|
required: ["bundle"]
|
|
1609
1687
|
},
|
|
1610
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
|
+
};
|
|
1611
1694
|
const result = await stateStore.import(
|
|
1612
1695
|
args.bundle,
|
|
1613
|
-
args.conflict_resolution ?? "skip"
|
|
1696
|
+
args.conflict_resolution ?? "skip",
|
|
1697
|
+
publicKeyResolver
|
|
1614
1698
|
);
|
|
1615
1699
|
auditLog?.append("l1", "state_import", "principal", {
|
|
1616
1700
|
imported_keys: result.imported_keys
|
|
@@ -2055,7 +2139,7 @@ function createRangeProof(value, blindingFactor, commitment, min, max) {
|
|
|
2055
2139
|
bitProofs.push(bitProof);
|
|
2056
2140
|
}
|
|
2057
2141
|
const sumBlinding = bitBlindings.reduce(
|
|
2058
|
-
(acc, bi, i) => mod(acc + mod(BigInt(1 << i)) * bi),
|
|
2142
|
+
(acc, bi, i) => mod(acc + mod(BigInt(1) << BigInt(i)) * bi),
|
|
2059
2143
|
0n
|
|
2060
2144
|
);
|
|
2061
2145
|
const blindingDiff = mod(b - sumBlinding);
|
|
@@ -2097,7 +2181,7 @@ function verifyRangeProof(proof) {
|
|
|
2097
2181
|
let reconstructed = RistrettoPoint.ZERO;
|
|
2098
2182
|
for (let i = 0; i < numBits; i++) {
|
|
2099
2183
|
const C_i = RistrettoPoint.fromHex(fromBase64url(proof.bit_commitments[i]));
|
|
2100
|
-
const weight = mod(BigInt(1 << i));
|
|
2184
|
+
const weight = mod(BigInt(1) << BigInt(i));
|
|
2101
2185
|
reconstructed = reconstructed.add(safeMultiply(C_i, weight));
|
|
2102
2186
|
}
|
|
2103
2187
|
const diff = C.subtract(safeMultiply(G, mod(BigInt(proof.min)))).subtract(reconstructed);
|
|
@@ -3152,7 +3236,9 @@ function createL4Tools(storage, masterKey, identityManager, auditLog, handshakeR
|
|
|
3152
3236
|
contexts: summary.contexts
|
|
3153
3237
|
});
|
|
3154
3238
|
return toolResult({
|
|
3155
|
-
summary
|
|
3239
|
+
summary,
|
|
3240
|
+
// SEC-ADD-03: Tag response as containing counterparty-generated attestation data
|
|
3241
|
+
_content_trust: "external"
|
|
3156
3242
|
});
|
|
3157
3243
|
}
|
|
3158
3244
|
},
|
|
@@ -3473,14 +3559,16 @@ var DEFAULT_TIER2 = {
|
|
|
3473
3559
|
};
|
|
3474
3560
|
var DEFAULT_CHANNEL = {
|
|
3475
3561
|
type: "stderr",
|
|
3476
|
-
timeout_seconds: 300
|
|
3477
|
-
|
|
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.
|
|
3478
3565
|
};
|
|
3479
3566
|
var DEFAULT_POLICY = {
|
|
3480
3567
|
version: 1,
|
|
3481
3568
|
tier1_always_approve: [
|
|
3482
3569
|
"state_export",
|
|
3483
3570
|
"state_import",
|
|
3571
|
+
"state_delete",
|
|
3484
3572
|
"identity_rotate",
|
|
3485
3573
|
"reputation_import",
|
|
3486
3574
|
"bootstrap_provide_guarantee"
|
|
@@ -3490,7 +3578,6 @@ var DEFAULT_POLICY = {
|
|
|
3490
3578
|
"state_read",
|
|
3491
3579
|
"state_write",
|
|
3492
3580
|
"state_list",
|
|
3493
|
-
"state_delete",
|
|
3494
3581
|
"identity_create",
|
|
3495
3582
|
"identity_list",
|
|
3496
3583
|
"identity_sign",
|
|
@@ -3598,10 +3685,14 @@ function validatePolicy(raw) {
|
|
|
3598
3685
|
...raw.tier2_anomaly ?? {}
|
|
3599
3686
|
},
|
|
3600
3687
|
tier3_always_allow: raw.tier3_always_allow ?? DEFAULT_POLICY.tier3_always_allow,
|
|
3601
|
-
approval_channel: {
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3688
|
+
approval_channel: (() => {
|
|
3689
|
+
const merged = {
|
|
3690
|
+
...DEFAULT_CHANNEL,
|
|
3691
|
+
...raw.approval_channel ?? {}
|
|
3692
|
+
};
|
|
3693
|
+
delete merged.auto_deny;
|
|
3694
|
+
return merged;
|
|
3695
|
+
})()
|
|
3605
3696
|
};
|
|
3606
3697
|
}
|
|
3607
3698
|
function generateDefaultPolicyYaml() {
|
|
@@ -3618,6 +3709,7 @@ version: 1
|
|
|
3618
3709
|
tier1_always_approve:
|
|
3619
3710
|
- state_export
|
|
3620
3711
|
- state_import
|
|
3712
|
+
- state_delete
|
|
3621
3713
|
- identity_rotate
|
|
3622
3714
|
- reputation_import
|
|
3623
3715
|
- bootstrap_provide_guarantee
|
|
@@ -3639,7 +3731,6 @@ tier3_always_allow:
|
|
|
3639
3731
|
- state_read
|
|
3640
3732
|
- state_write
|
|
3641
3733
|
- state_list
|
|
3642
|
-
- state_delete
|
|
3643
3734
|
- identity_create
|
|
3644
3735
|
- identity_list
|
|
3645
3736
|
- identity_sign
|
|
@@ -3676,10 +3767,10 @@ tier3_always_allow:
|
|
|
3676
3767
|
|
|
3677
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
|
|
3678
3769
|
# How Sanctuary reaches you when approval is needed.
|
|
3770
|
+
# NOTE: Timeout always results in denial. This is not configurable (SEC-002).
|
|
3679
3771
|
approval_channel:
|
|
3680
3772
|
type: stderr
|
|
3681
3773
|
timeout_seconds: 300
|
|
3682
|
-
auto_deny: true
|
|
3683
3774
|
`;
|
|
3684
3775
|
}
|
|
3685
3776
|
async function loadPrincipalPolicy(storagePath) {
|
|
@@ -3856,27 +3947,16 @@ var BaselineTracker = class {
|
|
|
3856
3947
|
|
|
3857
3948
|
// src/principal-policy/approval-channel.ts
|
|
3858
3949
|
var StderrApprovalChannel = class {
|
|
3859
|
-
|
|
3860
|
-
constructor(config) {
|
|
3861
|
-
this.config = config;
|
|
3950
|
+
constructor(_config) {
|
|
3862
3951
|
}
|
|
3863
3952
|
async requestApproval(request) {
|
|
3864
3953
|
const prompt = this.formatPrompt(request);
|
|
3865
3954
|
process.stderr.write(prompt + "\n");
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
decided_by: "timeout"
|
|
3872
|
-
};
|
|
3873
|
-
} else {
|
|
3874
|
-
return {
|
|
3875
|
-
decision: "approve",
|
|
3876
|
-
decided_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3877
|
-
decided_by: "auto"
|
|
3878
|
-
};
|
|
3879
|
-
}
|
|
3955
|
+
return {
|
|
3956
|
+
decision: "deny",
|
|
3957
|
+
decided_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3958
|
+
decided_by: "stderr:non-interactive"
|
|
3959
|
+
};
|
|
3880
3960
|
}
|
|
3881
3961
|
formatPrompt(request) {
|
|
3882
3962
|
const tierLabel = request.tier === 1 ? "Tier 1 \u2014 always requires approval" : "Tier 2 \u2014 behavioral anomaly detected";
|
|
@@ -3884,7 +3964,7 @@ var StderrApprovalChannel = class {
|
|
|
3884
3964
|
return [
|
|
3885
3965
|
"",
|
|
3886
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",
|
|
3887
|
-
"\u2551 SANCTUARY:
|
|
3967
|
+
"\u2551 SANCTUARY: Operation Denied (non-interactive channel) \u2551",
|
|
3888
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",
|
|
3889
3969
|
`\u2551 Operation: ${request.operation.padEnd(50)}\u2551`,
|
|
3890
3970
|
`\u2551 ${tierLabel.padEnd(62)}\u2551`,
|
|
@@ -3895,7 +3975,8 @@ var StderrApprovalChannel = class {
|
|
|
3895
3975
|
(line) => `\u2551 ${line.padEnd(60)}\u2551`
|
|
3896
3976
|
),
|
|
3897
3977
|
"\u2551 \u2551",
|
|
3898
|
-
|
|
3978
|
+
"\u2551 Denied: stderr channel cannot accept input (SEC-016) \u2551",
|
|
3979
|
+
"\u2551 Use dashboard or webhook channel for interactive approval. \u2551",
|
|
3899
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",
|
|
3900
3981
|
""
|
|
3901
3982
|
].join("\n");
|
|
@@ -4217,20 +4298,38 @@ function generateDashboardHTML(options) {
|
|
|
4217
4298
|
<script>
|
|
4218
4299
|
(function() {
|
|
4219
4300
|
const TIMEOUT = ${options.timeoutSeconds};
|
|
4220
|
-
|
|
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
|
|
4221
4305
|
const pending = new Map();
|
|
4222
4306
|
let auditCount = 0;
|
|
4223
4307
|
|
|
4224
|
-
// Auth helpers
|
|
4308
|
+
// Auth helpers \u2014 SEC-012: token goes in header, session goes in URL
|
|
4225
4309
|
function authHeaders() {
|
|
4226
4310
|
const h = { 'Content-Type': 'application/json' };
|
|
4227
4311
|
if (AUTH_TOKEN) h['Authorization'] = 'Bearer ' + AUTH_TOKEN;
|
|
4228
4312
|
return h;
|
|
4229
4313
|
}
|
|
4230
|
-
function
|
|
4231
|
-
if (!
|
|
4314
|
+
function sessionQuery(url) {
|
|
4315
|
+
if (!SESSION_ID) return url;
|
|
4232
4316
|
const sep = url.includes('?') ? '&' : '?';
|
|
4233
|
-
return url + sep + '
|
|
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 */ }
|
|
4234
4333
|
}
|
|
4235
4334
|
|
|
4236
4335
|
// Tab switching
|
|
@@ -4243,10 +4342,14 @@ function generateDashboardHTML(options) {
|
|
|
4243
4342
|
});
|
|
4244
4343
|
});
|
|
4245
4344
|
|
|
4246
|
-
// SSE Connection
|
|
4345
|
+
// SSE Connection \u2014 SEC-012: uses short-lived session token in URL, not auth token
|
|
4247
4346
|
let evtSource;
|
|
4347
|
+
function reconnectSSE() {
|
|
4348
|
+
if (evtSource) { evtSource.close(); }
|
|
4349
|
+
connect();
|
|
4350
|
+
}
|
|
4248
4351
|
function connect() {
|
|
4249
|
-
evtSource = new EventSource(
|
|
4352
|
+
evtSource = new EventSource(sessionQuery('/events'));
|
|
4250
4353
|
evtSource.onopen = () => {
|
|
4251
4354
|
document.getElementById('statusDot').classList.remove('disconnected');
|
|
4252
4355
|
document.getElementById('statusText').textContent = 'Connected';
|
|
@@ -4434,12 +4537,20 @@ function generateDashboardHTML(options) {
|
|
|
4434
4537
|
return d.innerHTML;
|
|
4435
4538
|
}
|
|
4436
4539
|
|
|
4437
|
-
// Init
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
if (
|
|
4441
|
-
if (
|
|
4442
|
-
|
|
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
|
+
})();
|
|
4443
4554
|
})();
|
|
4444
4555
|
</script>
|
|
4445
4556
|
</body>
|
|
@@ -4447,6 +4558,8 @@ function generateDashboardHTML(options) {
|
|
|
4447
4558
|
}
|
|
4448
4559
|
|
|
4449
4560
|
// src/principal-policy/dashboard.ts
|
|
4561
|
+
var SESSION_TTL_MS = 5 * 60 * 1e3;
|
|
4562
|
+
var MAX_SESSIONS = 1e3;
|
|
4450
4563
|
var DashboardApprovalChannel = class {
|
|
4451
4564
|
config;
|
|
4452
4565
|
pending = /* @__PURE__ */ new Map();
|
|
@@ -4458,6 +4571,9 @@ var DashboardApprovalChannel = class {
|
|
|
4458
4571
|
dashboardHTML;
|
|
4459
4572
|
authToken;
|
|
4460
4573
|
useTLS;
|
|
4574
|
+
/** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
|
|
4575
|
+
sessions = /* @__PURE__ */ new Map();
|
|
4576
|
+
sessionCleanupTimer = null;
|
|
4461
4577
|
constructor(config) {
|
|
4462
4578
|
this.config = config;
|
|
4463
4579
|
this.authToken = config.auth_token;
|
|
@@ -4467,6 +4583,7 @@ var DashboardApprovalChannel = class {
|
|
|
4467
4583
|
serverVersion: "0.3.0",
|
|
4468
4584
|
authToken: this.authToken
|
|
4469
4585
|
});
|
|
4586
|
+
this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
|
|
4470
4587
|
}
|
|
4471
4588
|
/**
|
|
4472
4589
|
* Inject dependencies after construction.
|
|
@@ -4496,13 +4613,14 @@ var DashboardApprovalChannel = class {
|
|
|
4496
4613
|
const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
|
|
4497
4614
|
this.httpServer.listen(this.config.port, this.config.host, () => {
|
|
4498
4615
|
if (this.authToken) {
|
|
4616
|
+
const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
|
|
4499
4617
|
process.stderr.write(
|
|
4500
4618
|
`
|
|
4501
|
-
Sanctuary Principal Dashboard: ${baseUrl}
|
|
4619
|
+
Sanctuary Principal Dashboard: ${baseUrl}
|
|
4502
4620
|
`
|
|
4503
4621
|
);
|
|
4504
4622
|
process.stderr.write(
|
|
4505
|
-
` Auth token: ${
|
|
4623
|
+
` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
|
|
4506
4624
|
|
|
4507
4625
|
`
|
|
4508
4626
|
);
|
|
@@ -4536,6 +4654,11 @@ var DashboardApprovalChannel = class {
|
|
|
4536
4654
|
client.end();
|
|
4537
4655
|
}
|
|
4538
4656
|
this.sseClients.clear();
|
|
4657
|
+
this.sessions.clear();
|
|
4658
|
+
if (this.sessionCleanupTimer) {
|
|
4659
|
+
clearInterval(this.sessionCleanupTimer);
|
|
4660
|
+
this.sessionCleanupTimer = null;
|
|
4661
|
+
}
|
|
4539
4662
|
if (this.httpServer) {
|
|
4540
4663
|
return new Promise((resolve) => {
|
|
4541
4664
|
this.httpServer.close(() => resolve());
|
|
@@ -4556,7 +4679,8 @@ var DashboardApprovalChannel = class {
|
|
|
4556
4679
|
const timer = setTimeout(() => {
|
|
4557
4680
|
this.pending.delete(id);
|
|
4558
4681
|
const response = {
|
|
4559
|
-
|
|
4682
|
+
// SEC-002: Timeout ALWAYS denies. No configuration can change this.
|
|
4683
|
+
decision: "deny",
|
|
4560
4684
|
decided_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4561
4685
|
decided_by: "timeout"
|
|
4562
4686
|
};
|
|
@@ -4588,7 +4712,12 @@ var DashboardApprovalChannel = class {
|
|
|
4588
4712
|
// ── Authentication ──────────────────────────────────────────────────
|
|
4589
4713
|
/**
|
|
4590
4714
|
* Verify bearer token authentication.
|
|
4591
|
-
*
|
|
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
|
+
*
|
|
4592
4721
|
* Returns true if auth passes, false if blocked (response already sent).
|
|
4593
4722
|
*/
|
|
4594
4723
|
checkAuth(req, url, res) {
|
|
@@ -4600,19 +4729,71 @@ var DashboardApprovalChannel = class {
|
|
|
4600
4729
|
return true;
|
|
4601
4730
|
}
|
|
4602
4731
|
}
|
|
4603
|
-
const
|
|
4604
|
-
if (
|
|
4732
|
+
const sessionId = url.searchParams.get("session");
|
|
4733
|
+
if (sessionId && this.validateSession(sessionId)) {
|
|
4605
4734
|
return true;
|
|
4606
4735
|
}
|
|
4607
4736
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
4608
|
-
res.end(JSON.stringify({ error: "Unauthorized \u2014
|
|
4737
|
+
res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
|
|
4609
4738
|
return false;
|
|
4610
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]);
|
|
4753
|
+
}
|
|
4754
|
+
}
|
|
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
|
+
}
|
|
4611
4787
|
// ── HTTP Request Handler ────────────────────────────────────────────
|
|
4612
4788
|
handleRequest(req, res) {
|
|
4613
4789
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
4614
4790
|
const method = req.method ?? "GET";
|
|
4615
|
-
|
|
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
|
+
}
|
|
4616
4797
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
4617
4798
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
4618
4799
|
if (method === "OPTIONS") {
|
|
@@ -4622,6 +4803,10 @@ var DashboardApprovalChannel = class {
|
|
|
4622
4803
|
}
|
|
4623
4804
|
if (!this.checkAuth(req, url, res)) return;
|
|
4624
4805
|
try {
|
|
4806
|
+
if (method === "POST" && url.pathname === "/auth/session") {
|
|
4807
|
+
this.handleSessionExchange(req, res);
|
|
4808
|
+
return;
|
|
4809
|
+
}
|
|
4625
4810
|
if (method === "GET" && url.pathname === "/") {
|
|
4626
4811
|
this.serveDashboard(res);
|
|
4627
4812
|
} else if (method === "GET" && url.pathname === "/events") {
|
|
@@ -4648,6 +4833,40 @@ var DashboardApprovalChannel = class {
|
|
|
4648
4833
|
}
|
|
4649
4834
|
}
|
|
4650
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
|
+
}
|
|
4651
4870
|
serveDashboard(res) {
|
|
4652
4871
|
res.writeHead(200, {
|
|
4653
4872
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -4673,7 +4892,8 @@ var DashboardApprovalChannel = class {
|
|
|
4673
4892
|
approval_channel: {
|
|
4674
4893
|
type: this.policy.approval_channel.type,
|
|
4675
4894
|
timeout_seconds: this.policy.approval_channel.timeout_seconds,
|
|
4676
|
-
auto_deny:
|
|
4895
|
+
auto_deny: true
|
|
4896
|
+
// SEC-002: hardcoded, not configurable
|
|
4677
4897
|
}
|
|
4678
4898
|
};
|
|
4679
4899
|
}
|
|
@@ -4714,7 +4934,8 @@ data: ${JSON.stringify(initData)}
|
|
|
4714
4934
|
approval_channel: {
|
|
4715
4935
|
type: this.policy.approval_channel.type,
|
|
4716
4936
|
timeout_seconds: this.policy.approval_channel.timeout_seconds,
|
|
4717
|
-
auto_deny:
|
|
4937
|
+
auto_deny: true
|
|
4938
|
+
// SEC-002: hardcoded, not configurable
|
|
4718
4939
|
}
|
|
4719
4940
|
};
|
|
4720
4941
|
}
|
|
@@ -4887,7 +5108,8 @@ var WebhookApprovalChannel = class {
|
|
|
4887
5108
|
const timer = setTimeout(() => {
|
|
4888
5109
|
this.pending.delete(id);
|
|
4889
5110
|
const response = {
|
|
4890
|
-
|
|
5111
|
+
// SEC-002: Timeout ALWAYS denies. No configuration can change this.
|
|
5112
|
+
decision: "deny",
|
|
4891
5113
|
decided_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4892
5114
|
decided_by: "timeout"
|
|
4893
5115
|
};
|
|
@@ -5075,16 +5297,29 @@ var ApprovalGate = class {
|
|
|
5075
5297
|
if (anomaly) {
|
|
5076
5298
|
return this.requestApproval(operation, 2, anomaly.reason, anomaly.context);
|
|
5077
5299
|
}
|
|
5078
|
-
this.
|
|
5079
|
-
|
|
5080
|
-
|
|
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)"
|
|
5081
5316
|
});
|
|
5082
|
-
return
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
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
|
+
);
|
|
5088
5323
|
}
|
|
5089
5324
|
/**
|
|
5090
5325
|
* Detect Tier 2 behavioral anomalies.
|
|
@@ -5257,7 +5492,8 @@ function createPrincipalPolicyTools(policy, baseline, auditLog) {
|
|
|
5257
5492
|
approval_channel: {
|
|
5258
5493
|
type: policy.approval_channel.type,
|
|
5259
5494
|
timeout_seconds: policy.approval_channel.timeout_seconds,
|
|
5260
|
-
auto_deny:
|
|
5495
|
+
auto_deny: true
|
|
5496
|
+
// SEC-002: hardcoded, not configurable
|
|
5261
5497
|
}
|
|
5262
5498
|
};
|
|
5263
5499
|
if (includeDefaults) {
|
|
@@ -5788,7 +6024,9 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog) {
|
|
|
5788
6024
|
return toolResult({
|
|
5789
6025
|
session_id: result.session.session_id,
|
|
5790
6026
|
response: result.response,
|
|
5791
|
-
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"
|
|
5792
6030
|
});
|
|
5793
6031
|
}
|
|
5794
6032
|
},
|
|
@@ -5841,7 +6079,9 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog) {
|
|
|
5841
6079
|
return toolResult({
|
|
5842
6080
|
completion: result.completion,
|
|
5843
6081
|
result: result.result,
|
|
5844
|
-
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"
|
|
5845
6085
|
});
|
|
5846
6086
|
}
|
|
5847
6087
|
},
|
|
@@ -6266,7 +6506,21 @@ function canonicalize(outcome) {
|
|
|
6266
6506
|
return stringToBytes(stableStringify(outcome));
|
|
6267
6507
|
}
|
|
6268
6508
|
function stableStringify(value) {
|
|
6269
|
-
if (value === null
|
|
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
|
+
}
|
|
6270
6524
|
if (typeof value !== "object") return JSON.stringify(value);
|
|
6271
6525
|
if (Array.isArray(value)) {
|
|
6272
6526
|
return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
|
|
@@ -6294,11 +6548,12 @@ function createBridgeCommitment(outcome, identity, identityEncryptionKey, includ
|
|
|
6294
6548
|
bridge_commitment_id: commitmentId,
|
|
6295
6549
|
session_id: outcome.session_id,
|
|
6296
6550
|
sha256_commitment: sha2564.commitment,
|
|
6551
|
+
terms_hash: outcome.terms_hash,
|
|
6297
6552
|
committer_did: identity.did,
|
|
6298
6553
|
committed_at: now,
|
|
6299
6554
|
bridge_version: "sanctuary-concordia-bridge-v1"
|
|
6300
6555
|
};
|
|
6301
|
-
const payloadBytes = stringToBytes(
|
|
6556
|
+
const payloadBytes = stringToBytes(stableStringify(commitmentPayload));
|
|
6302
6557
|
const signature = sign(payloadBytes, identity.encrypted_private_key, identityEncryptionKey);
|
|
6303
6558
|
return {
|
|
6304
6559
|
bridge_commitment_id: commitmentId,
|
|
@@ -6324,11 +6579,12 @@ function verifyBridgeCommitment(commitment, outcome, committerPublicKey) {
|
|
|
6324
6579
|
bridge_commitment_id: commitment.bridge_commitment_id,
|
|
6325
6580
|
session_id: commitment.session_id,
|
|
6326
6581
|
sha256_commitment: commitment.sha256_commitment,
|
|
6582
|
+
terms_hash: outcome.terms_hash,
|
|
6327
6583
|
committer_did: commitment.committer_did,
|
|
6328
6584
|
committed_at: commitment.committed_at,
|
|
6329
6585
|
bridge_version: commitment.bridge_version
|
|
6330
6586
|
};
|
|
6331
|
-
const payloadBytes = stringToBytes(
|
|
6587
|
+
const payloadBytes = stringToBytes(stableStringify(commitmentPayload));
|
|
6332
6588
|
const sigBytes = fromBase64url(commitment.signature);
|
|
6333
6589
|
const signatureValid = verify(payloadBytes, sigBytes, committerPublicKey);
|
|
6334
6590
|
const sessionIdMatch = commitment.session_id === outcome.session_id;
|
|
@@ -6555,7 +6811,9 @@ function createBridgeTools(storage, masterKey, identityManager, auditLog, handsh
|
|
|
6555
6811
|
return toolResult({
|
|
6556
6812
|
...result,
|
|
6557
6813
|
session_id: storedCommitment.session_id,
|
|
6558
|
-
committer_did: storedCommitment.committer_did
|
|
6814
|
+
committer_did: storedCommitment.committer_did,
|
|
6815
|
+
// SEC-ADD-03: Tag response as containing counterparty-controlled data
|
|
6816
|
+
_content_trust: "external"
|
|
6559
6817
|
});
|
|
6560
6818
|
}
|
|
6561
6819
|
},
|
|
@@ -6649,6 +6907,668 @@ function createBridgeTools(storage, masterKey, identityManager, auditLog, handsh
|
|
|
6649
6907
|
];
|
|
6650
6908
|
return { tools };
|
|
6651
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
|
+
}
|
|
6652
7572
|
|
|
6653
7573
|
// src/index.ts
|
|
6654
7574
|
init_encoding();
|
|
@@ -6740,15 +7660,51 @@ async function createSanctuaryServer(options) {
|
|
|
6740
7660
|
}
|
|
6741
7661
|
} else {
|
|
6742
7662
|
keyProtection = "recovery-key";
|
|
6743
|
-
const
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
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;
|
|
6747
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
|
+
}
|
|
6748
7706
|
masterKey = generateRandomKey();
|
|
6749
7707
|
recoveryKey = toBase64url(masterKey);
|
|
6750
|
-
const { hashToString: hashToString2 } = await Promise.resolve().then(() => (init_hashing(), hashing_exports));
|
|
6751
|
-
const { stringToBytes: stringToBytes2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
|
|
6752
7708
|
const keyHash = hashToString2(masterKey);
|
|
6753
7709
|
await storage.write(
|
|
6754
7710
|
"_meta",
|
|
@@ -7014,6 +7970,7 @@ async function createSanctuaryServer(options) {
|
|
|
7014
7970
|
auditLog,
|
|
7015
7971
|
handshakeResults
|
|
7016
7972
|
);
|
|
7973
|
+
const { tools: auditTools } = createAuditTools(config);
|
|
7017
7974
|
const policy = await loadPrincipalPolicy(config.storage_path);
|
|
7018
7975
|
const baseline = new BaselineTracker(storage, masterKey);
|
|
7019
7976
|
await baseline.load();
|
|
@@ -7029,7 +7986,7 @@ async function createSanctuaryServer(options) {
|
|
|
7029
7986
|
port: config.dashboard.port,
|
|
7030
7987
|
host: config.dashboard.host,
|
|
7031
7988
|
timeout_seconds: policy.approval_channel.timeout_seconds,
|
|
7032
|
-
|
|
7989
|
+
// SEC-002: auto_deny removed — timeout always denies
|
|
7033
7990
|
auth_token: authToken,
|
|
7034
7991
|
tls: config.dashboard.tls
|
|
7035
7992
|
});
|
|
@@ -7042,8 +7999,8 @@ async function createSanctuaryServer(options) {
|
|
|
7042
7999
|
webhook_secret: config.webhook.secret,
|
|
7043
8000
|
callback_port: config.webhook.callback_port,
|
|
7044
8001
|
callback_host: config.webhook.callback_host,
|
|
7045
|
-
timeout_seconds: policy.approval_channel.timeout_seconds
|
|
7046
|
-
|
|
8002
|
+
timeout_seconds: policy.approval_channel.timeout_seconds
|
|
8003
|
+
// SEC-002: auto_deny removed — timeout always denies
|
|
7047
8004
|
});
|
|
7048
8005
|
await webhook.start();
|
|
7049
8006
|
approvalChannel = webhook;
|
|
@@ -7062,6 +8019,7 @@ async function createSanctuaryServer(options) {
|
|
|
7062
8019
|
...handshakeTools,
|
|
7063
8020
|
...federationTools,
|
|
7064
8021
|
...bridgeTools,
|
|
8022
|
+
...auditTools,
|
|
7065
8023
|
manifestTool
|
|
7066
8024
|
];
|
|
7067
8025
|
const server = createServer(allTools, { gate });
|