@sanctuary-framework/mcp-server 1.2.3 → 1.2.5
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 +2329 -136
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +2329 -136
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +2009 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +689 -7
- package/dist/index.d.ts +689 -7
- package/dist/index.js +2009 -133
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.cjs
CHANGED
|
@@ -4457,9 +4457,35 @@ function validatePolicy(raw) {
|
|
|
4457
4457
|
};
|
|
4458
4458
|
delete merged.auto_deny;
|
|
4459
4459
|
return merged;
|
|
4460
|
-
})()
|
|
4460
|
+
})(),
|
|
4461
|
+
approval_redirect: parseApprovalRedirect(raw.approval_redirect)
|
|
4461
4462
|
};
|
|
4462
4463
|
}
|
|
4464
|
+
function parseApprovalRedirect(raw) {
|
|
4465
|
+
if (raw === void 0 || raw === null) {
|
|
4466
|
+
return { ...DEFAULT_APPROVAL_REDIRECT };
|
|
4467
|
+
}
|
|
4468
|
+
if (typeof raw !== "object") {
|
|
4469
|
+
return { ...DEFAULT_APPROVAL_REDIRECT };
|
|
4470
|
+
}
|
|
4471
|
+
const obj = raw;
|
|
4472
|
+
const enabled = typeof obj.enabled === "boolean" ? obj.enabled : DEFAULT_APPROVAL_REDIRECT.enabled;
|
|
4473
|
+
const modeRaw = obj.mode;
|
|
4474
|
+
let mode = DEFAULT_APPROVAL_REDIRECT.mode;
|
|
4475
|
+
if (modeRaw !== void 0) {
|
|
4476
|
+
if (modeRaw !== "replace" && modeRaw !== "notify") {
|
|
4477
|
+
throw new Error(
|
|
4478
|
+
`approval_redirect.mode must be "replace" or "notify" (got ${JSON.stringify(modeRaw)})`
|
|
4479
|
+
);
|
|
4480
|
+
}
|
|
4481
|
+
mode = modeRaw;
|
|
4482
|
+
}
|
|
4483
|
+
const result = { enabled, mode };
|
|
4484
|
+
if (obj.per_agent !== void 0 && typeof obj.per_agent === "object" && obj.per_agent !== null) {
|
|
4485
|
+
result.per_agent = obj.per_agent;
|
|
4486
|
+
}
|
|
4487
|
+
return result;
|
|
4488
|
+
}
|
|
4463
4489
|
function generateDefaultPolicyYaml() {
|
|
4464
4490
|
return `# Sanctuary Principal Policy v1
|
|
4465
4491
|
# This file controls what your agent can do without asking.
|
|
@@ -4538,6 +4564,7 @@ tier3_always_allow:
|
|
|
4538
4564
|
- handshake_status
|
|
4539
4565
|
- handshake_exchange
|
|
4540
4566
|
- handshake_verify_attestation
|
|
4567
|
+
- handshake_abort
|
|
4541
4568
|
- reputation_query_weighted
|
|
4542
4569
|
- federation_peers
|
|
4543
4570
|
- federation_trust_evaluate
|
|
@@ -4572,25 +4599,58 @@ tier3_always_allow:
|
|
|
4572
4599
|
approval_channel:
|
|
4573
4600
|
type: stderr
|
|
4574
4601
|
timeout_seconds: 300
|
|
4602
|
+
|
|
4603
|
+
# \u2500\u2500\u2500 Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4604
|
+
# Cross-harness approval-inbox redirect. When enabled, Tier 1/2 approvals
|
|
4605
|
+
# resolve via the unified approval inbox at /api/approval-inbox/* instead
|
|
4606
|
+
# of (or in addition to) the configured approval_channel above.
|
|
4607
|
+
#
|
|
4608
|
+
# mode:
|
|
4609
|
+
# replace: bypass the approval_channel entirely; the gate awaits a
|
|
4610
|
+
# decision from the inbox (default once enabled).
|
|
4611
|
+
# notify: fire BOTH the approval_channel and the inbox; first decision
|
|
4612
|
+
# wins. Right shape for harnesses that cannot fully suppress
|
|
4613
|
+
# their local approval prompt (e.g. Mastra-class).
|
|
4614
|
+
approval_redirect:
|
|
4615
|
+
enabled: false
|
|
4616
|
+
mode: replace
|
|
4575
4617
|
`;
|
|
4576
4618
|
}
|
|
4577
4619
|
async function loadPrincipalPolicy(storagePath) {
|
|
4578
4620
|
const policyPath = path.join(storagePath, "principal-policy.yaml");
|
|
4621
|
+
let content;
|
|
4622
|
+
try {
|
|
4623
|
+
content = await promises.readFile(policyPath, "utf-8");
|
|
4624
|
+
} catch (err) {
|
|
4625
|
+
const code = err?.code;
|
|
4626
|
+
if (code === "ENOENT") {
|
|
4627
|
+
const defaultYaml = generateDefaultPolicyYaml();
|
|
4628
|
+
try {
|
|
4629
|
+
await promises.writeFile(policyPath, defaultYaml, "utf-8");
|
|
4630
|
+
await promises.chmod(policyPath, 384);
|
|
4631
|
+
} catch (writeErr) {
|
|
4632
|
+
console.warn(
|
|
4633
|
+
`Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
|
|
4634
|
+
);
|
|
4635
|
+
}
|
|
4636
|
+
return Object.freeze({ ...DEFAULT_POLICY });
|
|
4637
|
+
}
|
|
4638
|
+
throw new MalformedPrincipalPolicyError(
|
|
4639
|
+
policyPath,
|
|
4640
|
+
`read failed: ${err.message}`
|
|
4641
|
+
);
|
|
4642
|
+
}
|
|
4579
4643
|
try {
|
|
4580
|
-
const content = await promises.readFile(policyPath, "utf-8");
|
|
4581
4644
|
const policy = parsePolicy(content);
|
|
4582
4645
|
return Object.freeze(policy);
|
|
4583
|
-
} catch {
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
} catch {
|
|
4589
|
-
}
|
|
4590
|
-
return Object.freeze({ ...DEFAULT_POLICY });
|
|
4646
|
+
} catch (parseErr) {
|
|
4647
|
+
throw new MalformedPrincipalPolicyError(
|
|
4648
|
+
policyPath,
|
|
4649
|
+
parseErr.message
|
|
4650
|
+
);
|
|
4591
4651
|
}
|
|
4592
4652
|
}
|
|
4593
|
-
var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY;
|
|
4653
|
+
var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_APPROVAL_REDIRECT, DEFAULT_POLICY, MalformedPrincipalPolicyError;
|
|
4594
4654
|
var init_loader = __esm({
|
|
4595
4655
|
"src/principal-policy/loader.ts"() {
|
|
4596
4656
|
DEFAULT_TIER2 = {
|
|
@@ -4607,6 +4667,10 @@ var init_loader = __esm({
|
|
|
4607
4667
|
// SEC-002: auto_deny is not configurable. Timeout always denies.
|
|
4608
4668
|
// Field omitted intentionally — all channels hardcode deny on timeout.
|
|
4609
4669
|
};
|
|
4670
|
+
DEFAULT_APPROVAL_REDIRECT = {
|
|
4671
|
+
enabled: false,
|
|
4672
|
+
mode: "replace"
|
|
4673
|
+
};
|
|
4610
4674
|
DEFAULT_POLICY = {
|
|
4611
4675
|
version: 1,
|
|
4612
4676
|
tier1_always_approve: [
|
|
@@ -4680,6 +4744,7 @@ var init_loader = __esm({
|
|
|
4680
4744
|
"handshake_status",
|
|
4681
4745
|
"handshake_exchange",
|
|
4682
4746
|
"handshake_verify_attestation",
|
|
4747
|
+
"handshake_abort",
|
|
4683
4748
|
"reputation_query_weighted",
|
|
4684
4749
|
"federation_peers",
|
|
4685
4750
|
"federation_trust_evaluate",
|
|
@@ -4721,7 +4786,22 @@ var init_loader = __esm({
|
|
|
4721
4786
|
"compliance_eu_ai_act_annex_iii_classify"
|
|
4722
4787
|
// Read-only; rule-based Annex III classifier
|
|
4723
4788
|
],
|
|
4724
|
-
approval_channel: DEFAULT_CHANNEL
|
|
4789
|
+
approval_channel: DEFAULT_CHANNEL,
|
|
4790
|
+
approval_redirect: DEFAULT_APPROVAL_REDIRECT
|
|
4791
|
+
};
|
|
4792
|
+
MalformedPrincipalPolicyError = class extends Error {
|
|
4793
|
+
constructor(policyPath, reason) {
|
|
4794
|
+
super(
|
|
4795
|
+
`Principal policy at ${policyPath} is malformed and cannot be loaded.
|
|
4796
|
+
Reason: ${reason}
|
|
4797
|
+
Sanctuary refuses to substitute a default policy when an existing file is present, to avoid silently overriding operator intent. Fix the file or delete it to regenerate the default.`
|
|
4798
|
+
);
|
|
4799
|
+
this.policyPath = policyPath;
|
|
4800
|
+
this.reason = reason;
|
|
4801
|
+
this.name = "MalformedPrincipalPolicyError";
|
|
4802
|
+
}
|
|
4803
|
+
policyPath;
|
|
4804
|
+
reason;
|
|
4725
4805
|
};
|
|
4726
4806
|
}
|
|
4727
4807
|
});
|
|
@@ -4942,7 +5022,7 @@ function deepSortKeys(obj) {
|
|
|
4942
5022
|
return sorted;
|
|
4943
5023
|
}
|
|
4944
5024
|
function canonicalizeForSigning(body) {
|
|
4945
|
-
return JSON.stringify(deepSortKeys(body));
|
|
5025
|
+
return JSON.stringify(deepSortKeys(body)).normalize("NFC");
|
|
4946
5026
|
}
|
|
4947
5027
|
var init_types = __esm({
|
|
4948
5028
|
"src/shr/types.ts"() {
|
|
@@ -12441,7 +12521,7 @@ var init_auth_middleware = __esm({
|
|
|
12441
12521
|
});
|
|
12442
12522
|
|
|
12443
12523
|
// src/hub/constants.ts
|
|
12444
|
-
var HUB_API_PREFIX, HUB_ROUTES, HUB_FORTRESS_AGENT_ID_SENTINEL, HUB_INBOX_ACTIONS, HUB_AGENT_CONTROL_ACTIONS, HUB_TIER_1_AGENT_CONTROL_ACTIONS, HUB_ACTIVITY_DEFAULT_LIMIT, HUB_ACTIVITY_MAX_LIMIT, HUB_INBOX_DEFAULT_LIMIT, HUB_INBOX_MAX_LIMIT, HUB_AGENTS_DEFAULT_LIMIT, HUB_AGENTS_MAX_LIMIT, HUB_MAX_REQUEST_BODY_BYTES, HUB_CHAT_MESSAGE_MAX_CHARS, HUB_INBOX_TEMPLATE_NAMESPACES, HUB_ACTIVITY_TEMPLATE_NAMESPACES;
|
|
12524
|
+
var HUB_API_PREFIX, HUB_ROUTES, HUB_FORTRESS_AGENT_ID_SENTINEL, HUB_INBOX_ACTIONS, HUB_AGENT_CONTROL_ACTIONS, HUB_TIER_1_AGENT_CONTROL_ACTIONS, HUB_ACTIVITY_DEFAULT_LIMIT, HUB_ACTIVITY_MAX_LIMIT, HUB_CHAT_THREADS_DEFAULT_LIMIT, HUB_CHAT_THREADS_MAX_LIMIT, HUB_CHAT_TURNS_DEFAULT_LIMIT, HUB_CHAT_TURNS_MAX_LIMIT, HUB_INBOX_DEFAULT_LIMIT, HUB_INBOX_MAX_LIMIT, HUB_AGENTS_DEFAULT_LIMIT, HUB_AGENTS_MAX_LIMIT, HUB_MAX_REQUEST_BODY_BYTES, HUB_CHAT_MESSAGE_MAX_CHARS, HUB_INBOX_TEMPLATE_NAMESPACES, HUB_ACTIVITY_TEMPLATE_NAMESPACES;
|
|
12445
12525
|
var init_constants3 = __esm({
|
|
12446
12526
|
"src/hub/constants.ts"() {
|
|
12447
12527
|
HUB_API_PREFIX = "/api/hub";
|
|
@@ -12469,6 +12549,16 @@ var init_constants3 = __esm({
|
|
|
12469
12549
|
*/
|
|
12470
12550
|
CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
|
|
12471
12551
|
CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
|
|
12552
|
+
/**
|
|
12553
|
+
* Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
|
|
12554
|
+
* scrollback, and operator-initiated thread delete. Distinct from the
|
|
12555
|
+
* v1.2 `/history` route, which surfaces the active in-session thread
|
|
12556
|
+
* shape; the new routes target persisted multi-thread memory used by
|
|
12557
|
+
* v1.3 conversational sovereignty depth.
|
|
12558
|
+
*/
|
|
12559
|
+
CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
|
|
12560
|
+
CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
|
|
12561
|
+
CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
|
|
12472
12562
|
/**
|
|
12473
12563
|
* Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
|
|
12474
12564
|
* recent activity feed, pending Tier 1 approvals routed through this
|
|
@@ -12492,6 +12582,10 @@ var init_constants3 = __esm({
|
|
|
12492
12582
|
];
|
|
12493
12583
|
HUB_ACTIVITY_DEFAULT_LIMIT = 50;
|
|
12494
12584
|
HUB_ACTIVITY_MAX_LIMIT = 500;
|
|
12585
|
+
HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
|
|
12586
|
+
HUB_CHAT_THREADS_MAX_LIMIT = 500;
|
|
12587
|
+
HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
|
|
12588
|
+
HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
|
|
12495
12589
|
HUB_INBOX_DEFAULT_LIMIT = 100;
|
|
12496
12590
|
HUB_INBOX_MAX_LIMIT = 500;
|
|
12497
12591
|
HUB_AGENTS_DEFAULT_LIMIT = 100;
|
|
@@ -12674,6 +12768,23 @@ function checkChatMessage(value) {
|
|
|
12674
12768
|
}
|
|
12675
12769
|
return trimmed;
|
|
12676
12770
|
}
|
|
12771
|
+
function matchConciergeThreadRoute(path) {
|
|
12772
|
+
const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
|
|
12773
|
+
if (!path.startsWith(prefix)) return null;
|
|
12774
|
+
const rest = path.slice(prefix.length);
|
|
12775
|
+
if (rest.length === 0 || rest.includes("/")) return null;
|
|
12776
|
+
const decoded = decodeURIComponent(rest);
|
|
12777
|
+
if (decoded.length === 0) return null;
|
|
12778
|
+
return { threadId: decoded };
|
|
12779
|
+
}
|
|
12780
|
+
function parseSince(raw) {
|
|
12781
|
+
if (raw === null || raw === "") return void 0;
|
|
12782
|
+
const parsed = Number.parseInt(raw, 10);
|
|
12783
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
12784
|
+
throw new HubValidationError("since must be a non-negative integer");
|
|
12785
|
+
}
|
|
12786
|
+
return parsed;
|
|
12787
|
+
}
|
|
12677
12788
|
function matchInboxRoute(path) {
|
|
12678
12789
|
const prefix = `${HUB_API_PREFIX}/inbox/`;
|
|
12679
12790
|
if (!path.startsWith(prefix)) return null;
|
|
@@ -12857,6 +12968,47 @@ async function handleHubRoute(deps, req, res) {
|
|
|
12857
12968
|
writeJSON2(res, 200, { ok: true, data: { messages } });
|
|
12858
12969
|
return true;
|
|
12859
12970
|
}
|
|
12971
|
+
if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
|
|
12972
|
+
const limit = parseLimit(
|
|
12973
|
+
url.searchParams.get("limit"),
|
|
12974
|
+
HUB_CHAT_THREADS_DEFAULT_LIMIT,
|
|
12975
|
+
HUB_CHAT_THREADS_MAX_LIMIT
|
|
12976
|
+
);
|
|
12977
|
+
const threads = await deps.service.listConciergeMemoryThreads({ limit });
|
|
12978
|
+
writeJSON2(res, 200, { ok: true, data: { threads } });
|
|
12979
|
+
return true;
|
|
12980
|
+
}
|
|
12981
|
+
{
|
|
12982
|
+
const threadMatch = matchConciergeThreadRoute(path);
|
|
12983
|
+
if (threadMatch) {
|
|
12984
|
+
if (method === "GET") {
|
|
12985
|
+
const since = parseSince(url.searchParams.get("since"));
|
|
12986
|
+
const limit = parseLimit(
|
|
12987
|
+
url.searchParams.get("limit"),
|
|
12988
|
+
HUB_CHAT_TURNS_DEFAULT_LIMIT,
|
|
12989
|
+
HUB_CHAT_TURNS_MAX_LIMIT
|
|
12990
|
+
);
|
|
12991
|
+
const readOpts = { limit };
|
|
12992
|
+
if (since !== void 0) readOpts.sinceTurnId = since;
|
|
12993
|
+
const turns = await deps.service.readConciergeMemoryThread(
|
|
12994
|
+
threadMatch.threadId,
|
|
12995
|
+
readOpts
|
|
12996
|
+
);
|
|
12997
|
+
writeJSON2(res, 200, { ok: true, data: { turns } });
|
|
12998
|
+
return true;
|
|
12999
|
+
}
|
|
13000
|
+
if (method === "DELETE") {
|
|
13001
|
+
const removed = await deps.service.deleteConciergeMemoryThread(
|
|
13002
|
+
threadMatch.threadId
|
|
13003
|
+
);
|
|
13004
|
+
writeJSON2(res, removed ? 200 : 404, {
|
|
13005
|
+
ok: removed,
|
|
13006
|
+
data: { thread_id: threadMatch.threadId, removed }
|
|
13007
|
+
});
|
|
13008
|
+
return true;
|
|
13009
|
+
}
|
|
13010
|
+
}
|
|
13011
|
+
}
|
|
12860
13012
|
writeJSON2(res, 404, { ok: false, error: "not_found", path });
|
|
12861
13013
|
return true;
|
|
12862
13014
|
} catch (err) {
|
|
@@ -16979,6 +17131,168 @@ var init_dispatch = __esm({
|
|
|
16979
17131
|
init_intelligence_api_router();
|
|
16980
17132
|
}
|
|
16981
17133
|
});
|
|
17134
|
+
|
|
17135
|
+
// src/principal-policy/approval-aggregator-routes.ts
|
|
17136
|
+
function writeJSON4(res, status, payload) {
|
|
17137
|
+
res.writeHead(status, {
|
|
17138
|
+
"Content-Type": "application/json",
|
|
17139
|
+
"Cache-Control": "no-store"
|
|
17140
|
+
});
|
|
17141
|
+
res.end(JSON.stringify(payload));
|
|
17142
|
+
}
|
|
17143
|
+
function parseLimit2(raw, defaultValue, max) {
|
|
17144
|
+
if (raw === null || raw === "") return defaultValue;
|
|
17145
|
+
const parsed = Number.parseInt(raw, 10);
|
|
17146
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
17147
|
+
return defaultValue;
|
|
17148
|
+
}
|
|
17149
|
+
return Math.min(parsed, max);
|
|
17150
|
+
}
|
|
17151
|
+
function isStatusFilter(value) {
|
|
17152
|
+
return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
|
|
17153
|
+
}
|
|
17154
|
+
function matchEntryRoute(path) {
|
|
17155
|
+
const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
|
|
17156
|
+
if (!path.startsWith(prefix)) return null;
|
|
17157
|
+
const rest = path.slice(prefix.length);
|
|
17158
|
+
if (rest.length === 0) return null;
|
|
17159
|
+
const slash = rest.indexOf("/");
|
|
17160
|
+
if (slash === -1) {
|
|
17161
|
+
return { aggregatorId: decodeURIComponent(rest), action: null };
|
|
17162
|
+
}
|
|
17163
|
+
return {
|
|
17164
|
+
aggregatorId: decodeURIComponent(rest.slice(0, slash)),
|
|
17165
|
+
action: rest.slice(slash + 1)
|
|
17166
|
+
};
|
|
17167
|
+
}
|
|
17168
|
+
async function handleStream2(deps, res) {
|
|
17169
|
+
res.writeHead(200, {
|
|
17170
|
+
"Content-Type": "text/event-stream",
|
|
17171
|
+
"Cache-Control": "no-cache, no-transform",
|
|
17172
|
+
Connection: "keep-alive",
|
|
17173
|
+
"X-Accel-Buffering": "no"
|
|
17174
|
+
});
|
|
17175
|
+
const initial = await deps.aggregator.list({ status: "pending" });
|
|
17176
|
+
res.write(
|
|
17177
|
+
`event: approval_inbox_snapshot
|
|
17178
|
+
data: ${JSON.stringify({ entries: initial })}
|
|
17179
|
+
|
|
17180
|
+
`
|
|
17181
|
+
);
|
|
17182
|
+
const unsubscribe = deps.aggregator.onEvent((event) => {
|
|
17183
|
+
try {
|
|
17184
|
+
res.write(
|
|
17185
|
+
`event: approval_inbox_${event.type}
|
|
17186
|
+
data: ${JSON.stringify(event.entry)}
|
|
17187
|
+
|
|
17188
|
+
`
|
|
17189
|
+
);
|
|
17190
|
+
} catch {
|
|
17191
|
+
}
|
|
17192
|
+
});
|
|
17193
|
+
const keepAlive = setInterval(() => {
|
|
17194
|
+
try {
|
|
17195
|
+
res.write(": keepalive\n\n");
|
|
17196
|
+
} catch {
|
|
17197
|
+
}
|
|
17198
|
+
}, 25e3);
|
|
17199
|
+
const cleanup = () => {
|
|
17200
|
+
clearInterval(keepAlive);
|
|
17201
|
+
unsubscribe();
|
|
17202
|
+
};
|
|
17203
|
+
res.on("close", cleanup);
|
|
17204
|
+
res.on("error", cleanup);
|
|
17205
|
+
}
|
|
17206
|
+
async function handleApprovalInboxRoute(deps, req, res) {
|
|
17207
|
+
const host = req.headers.host || "localhost";
|
|
17208
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
17209
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
17210
|
+
const path = url.pathname;
|
|
17211
|
+
if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
|
|
17212
|
+
return false;
|
|
17213
|
+
}
|
|
17214
|
+
const checkAuth = authMiddleware(deps.authConfig);
|
|
17215
|
+
if (!checkAuth(req, res, url)) return true;
|
|
17216
|
+
try {
|
|
17217
|
+
if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
|
|
17218
|
+
await handleStream2(deps, res);
|
|
17219
|
+
return true;
|
|
17220
|
+
}
|
|
17221
|
+
if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
|
|
17222
|
+
const limit = parseLimit2(
|
|
17223
|
+
url.searchParams.get("limit"),
|
|
17224
|
+
APPROVAL_INBOX_DEFAULT_LIMIT,
|
|
17225
|
+
APPROVAL_INBOX_MAX_LIMIT
|
|
17226
|
+
);
|
|
17227
|
+
const statusRaw = url.searchParams.get("status");
|
|
17228
|
+
const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
|
|
17229
|
+
const sinceTs = url.searchParams.get("since") ?? void 0;
|
|
17230
|
+
const entries = await deps.aggregator.list({
|
|
17231
|
+
status,
|
|
17232
|
+
limit,
|
|
17233
|
+
...sinceTs !== void 0 ? { sinceTs } : {}
|
|
17234
|
+
});
|
|
17235
|
+
writeJSON4(res, 200, { ok: true, data: { entries } });
|
|
17236
|
+
return true;
|
|
17237
|
+
}
|
|
17238
|
+
const entryMatch = matchEntryRoute(path);
|
|
17239
|
+
if (entryMatch === null) {
|
|
17240
|
+
writeJSON4(res, 404, { ok: false, error: "not_found", path });
|
|
17241
|
+
return true;
|
|
17242
|
+
}
|
|
17243
|
+
if (method === "GET" && entryMatch.action === null) {
|
|
17244
|
+
const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
|
|
17245
|
+
const entry = entries.find(
|
|
17246
|
+
(e) => e.aggregator_id === entryMatch.aggregatorId
|
|
17247
|
+
);
|
|
17248
|
+
if (!entry) {
|
|
17249
|
+
writeJSON4(res, 404, { ok: false, error: "not_found" });
|
|
17250
|
+
return true;
|
|
17251
|
+
}
|
|
17252
|
+
const payload = await deps.aggregator.getFullPayload(
|
|
17253
|
+
entryMatch.aggregatorId
|
|
17254
|
+
);
|
|
17255
|
+
writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
|
|
17256
|
+
return true;
|
|
17257
|
+
}
|
|
17258
|
+
if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
|
|
17259
|
+
const decision = entryMatch.action === "approve" ? "approved" : "denied";
|
|
17260
|
+
const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
|
|
17261
|
+
try {
|
|
17262
|
+
const entry = await deps.aggregator.resolve(
|
|
17263
|
+
entryMatch.aggregatorId,
|
|
17264
|
+
decision,
|
|
17265
|
+
operatorId
|
|
17266
|
+
);
|
|
17267
|
+
writeJSON4(res, 200, { ok: true, data: { entry } });
|
|
17268
|
+
} catch (err) {
|
|
17269
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17270
|
+
if (msg === "approval-aggregator: not_found") {
|
|
17271
|
+
writeJSON4(res, 404, { ok: false, error: "not_found" });
|
|
17272
|
+
} else {
|
|
17273
|
+
writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
|
|
17274
|
+
}
|
|
17275
|
+
}
|
|
17276
|
+
return true;
|
|
17277
|
+
}
|
|
17278
|
+
writeJSON4(res, 404, { ok: false, error: "not_found", path });
|
|
17279
|
+
return true;
|
|
17280
|
+
} catch (err) {
|
|
17281
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17282
|
+
writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
|
|
17283
|
+
return true;
|
|
17284
|
+
}
|
|
17285
|
+
}
|
|
17286
|
+
var APPROVAL_INBOX_API_PREFIX, APPROVAL_INBOX_OPERATOR_DEFAULT, APPROVAL_INBOX_DEFAULT_LIMIT, APPROVAL_INBOX_MAX_LIMIT;
|
|
17287
|
+
var init_approval_aggregator_routes = __esm({
|
|
17288
|
+
"src/principal-policy/approval-aggregator-routes.ts"() {
|
|
17289
|
+
init_auth_middleware();
|
|
17290
|
+
APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
|
|
17291
|
+
APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
|
|
17292
|
+
APPROVAL_INBOX_DEFAULT_LIMIT = 50;
|
|
17293
|
+
APPROVAL_INBOX_MAX_LIMIT = 200;
|
|
17294
|
+
}
|
|
17295
|
+
});
|
|
16982
17296
|
function isDashboardViewRoute(method, path) {
|
|
16983
17297
|
if (method !== "GET") return false;
|
|
16984
17298
|
return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
|
|
@@ -16992,6 +17306,7 @@ var init_dashboard = __esm({
|
|
|
16992
17306
|
init_fortress_view();
|
|
16993
17307
|
init_system_prompt_generator();
|
|
16994
17308
|
init_dispatch();
|
|
17309
|
+
init_approval_aggregator_routes();
|
|
16995
17310
|
SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
|
|
16996
17311
|
SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
|
|
16997
17312
|
MAX_SESSIONS = 1e3;
|
|
@@ -17051,6 +17366,14 @@ var init_dashboard = __esm({
|
|
|
17051
17366
|
* regardless. Default route flip is deferred to v1.2.
|
|
17052
17367
|
*/
|
|
17053
17368
|
v11Bindings = null;
|
|
17369
|
+
/**
|
|
17370
|
+
* v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
|
|
17371
|
+
* additively at `/api/approval-inbox/*` when set. Legacy approval
|
|
17372
|
+
* routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
|
|
17373
|
+
* aggregator is a passive subscriber to the gate; the routes here are
|
|
17374
|
+
* the operator-facing query / decision surface.
|
|
17375
|
+
*/
|
|
17376
|
+
approvalAggregator = null;
|
|
17054
17377
|
constructor(config) {
|
|
17055
17378
|
this.config = config;
|
|
17056
17379
|
this.authToken = config.auth_token;
|
|
@@ -17101,6 +17424,34 @@ var init_dashboard = __esm({
|
|
|
17101
17424
|
setV11Bindings(bindings) {
|
|
17102
17425
|
this.v11Bindings = bindings;
|
|
17103
17426
|
}
|
|
17427
|
+
/**
|
|
17428
|
+
* v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
|
|
17429
|
+
* aggregator. Once set, requests to `/api/approval-inbox/*` route
|
|
17430
|
+
* through `handleApprovalInboxRoute`. Pass `null` to detach (used by
|
|
17431
|
+
* tests + during shutdown).
|
|
17432
|
+
*/
|
|
17433
|
+
setApprovalAggregator(aggregator) {
|
|
17434
|
+
this.approvalAggregator = aggregator;
|
|
17435
|
+
}
|
|
17436
|
+
/**
|
|
17437
|
+
* v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
|
|
17438
|
+
* before the legacy approval route table. Returns true when served.
|
|
17439
|
+
*/
|
|
17440
|
+
async dispatchApprovalInbox(req, res) {
|
|
17441
|
+
if (!this.approvalAggregator) return false;
|
|
17442
|
+
return handleApprovalInboxRoute(
|
|
17443
|
+
{
|
|
17444
|
+
authConfig: {
|
|
17445
|
+
loopbackAutoAuth: this._autoAuthLocalhost,
|
|
17446
|
+
...this.authToken !== void 0 ? { authToken: this.authToken } : {}
|
|
17447
|
+
},
|
|
17448
|
+
aggregator: this.approvalAggregator,
|
|
17449
|
+
operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
|
|
17450
|
+
},
|
|
17451
|
+
req,
|
|
17452
|
+
res
|
|
17453
|
+
);
|
|
17454
|
+
}
|
|
17104
17455
|
/**
|
|
17105
17456
|
* v1.1 dispatch entry point. Called from `handleRequest` before the
|
|
17106
17457
|
* legacy route table. Returns true when the request was served by v1.1
|
|
@@ -17476,6 +17827,18 @@ var init_dashboard = __esm({
|
|
|
17476
17827
|
res.end();
|
|
17477
17828
|
return;
|
|
17478
17829
|
}
|
|
17830
|
+
if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
|
|
17831
|
+
this.dispatchApprovalInbox(req, res).then((handled) => {
|
|
17832
|
+
if (handled) return;
|
|
17833
|
+
this.handleLegacyRequest(req, res, url, method);
|
|
17834
|
+
}).catch(() => {
|
|
17835
|
+
if (!res.headersSent) {
|
|
17836
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
17837
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
17838
|
+
}
|
|
17839
|
+
});
|
|
17840
|
+
return;
|
|
17841
|
+
}
|
|
17479
17842
|
if (this.v11Bindings) {
|
|
17480
17843
|
this.dispatchV11(req, res, url, method).then((handled) => {
|
|
17481
17844
|
if (handled) return;
|
|
@@ -19519,14 +19882,25 @@ var init_gate = __esm({
|
|
|
19519
19882
|
auditLog;
|
|
19520
19883
|
injectionDetector;
|
|
19521
19884
|
onInjectionAlert;
|
|
19885
|
+
onApprovalEvent;
|
|
19522
19886
|
proxyTierResolver;
|
|
19523
|
-
constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
|
|
19887
|
+
constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
|
|
19524
19888
|
this.policy = policy;
|
|
19525
19889
|
this.baseline = baseline;
|
|
19526
19890
|
this.channel = channel;
|
|
19527
19891
|
this.auditLog = auditLog;
|
|
19528
19892
|
this.injectionDetector = injectionDetector ?? new InjectionDetector();
|
|
19529
19893
|
this.onInjectionAlert = onInjectionAlert;
|
|
19894
|
+
this.onApprovalEvent = onApprovalEvent;
|
|
19895
|
+
}
|
|
19896
|
+
/**
|
|
19897
|
+
* Set the approval-event callback after construction. Used by the
|
|
19898
|
+
* Upsilon-1 wire-up when the aggregator is constructed alongside the
|
|
19899
|
+
* gate. The aggregator subscribes through this setter rather than the
|
|
19900
|
+
* constructor so existing call sites continue to work unchanged.
|
|
19901
|
+
*/
|
|
19902
|
+
setApprovalEventCallback(cb) {
|
|
19903
|
+
this.onApprovalEvent = cb;
|
|
19530
19904
|
}
|
|
19531
19905
|
/**
|
|
19532
19906
|
* Set the proxy tier resolver. Called after the proxy router is initialized.
|
|
@@ -19760,21 +20134,105 @@ var init_gate = __esm({
|
|
|
19760
20134
|
}
|
|
19761
20135
|
/**
|
|
19762
20136
|
* Request approval from the human principal.
|
|
20137
|
+
*
|
|
20138
|
+
* Fail-closed contract (full-sweep #49): if the channel throws (network
|
|
20139
|
+
* down, callback unreachable, dashboard SSE peer dropped, webhook DNS
|
|
20140
|
+
* failure, etc.), the gate denies the operation and audit-logs the cause.
|
|
20141
|
+
* Channel-internal timeouts already resolve with decision: "deny" per
|
|
20142
|
+
* SEC-002; this catch covers the remaining "channel raised" path so an
|
|
20143
|
+
* unhandled rejection cannot turn into an indeterminate state at the gate.
|
|
19763
20144
|
*/
|
|
19764
20145
|
async requestApproval(operation, tier, reason, context) {
|
|
20146
|
+
const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
19765
20147
|
const request = {
|
|
19766
20148
|
operation,
|
|
19767
20149
|
tier,
|
|
19768
20150
|
reason,
|
|
19769
20151
|
context,
|
|
19770
|
-
timestamp:
|
|
20152
|
+
timestamp: requestTimestamp
|
|
19771
20153
|
};
|
|
19772
|
-
const
|
|
20154
|
+
const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
|
|
20155
|
+
if (this.onApprovalEvent) {
|
|
20156
|
+
try {
|
|
20157
|
+
this.onApprovalEvent({
|
|
20158
|
+
phase: "requested",
|
|
20159
|
+
operation,
|
|
20160
|
+
tier,
|
|
20161
|
+
reason,
|
|
20162
|
+
context,
|
|
20163
|
+
request_timestamp: requestTimestamp,
|
|
20164
|
+
correlation_id: correlationId
|
|
20165
|
+
});
|
|
20166
|
+
} catch {
|
|
20167
|
+
}
|
|
20168
|
+
}
|
|
20169
|
+
let response;
|
|
20170
|
+
try {
|
|
20171
|
+
response = await this.channel.requestApproval(request);
|
|
20172
|
+
} catch (err) {
|
|
20173
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
20174
|
+
const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
20175
|
+
this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
|
|
20176
|
+
tier,
|
|
20177
|
+
reason,
|
|
20178
|
+
decided_by: "channel_failure",
|
|
20179
|
+
channel_error: errMessage
|
|
20180
|
+
});
|
|
20181
|
+
if (this.onApprovalEvent) {
|
|
20182
|
+
try {
|
|
20183
|
+
this.onApprovalEvent({
|
|
20184
|
+
phase: "resolved",
|
|
20185
|
+
operation,
|
|
20186
|
+
tier,
|
|
20187
|
+
reason,
|
|
20188
|
+
context,
|
|
20189
|
+
request_timestamp: requestTimestamp,
|
|
20190
|
+
resolution: {
|
|
20191
|
+
decision: "deny",
|
|
20192
|
+
decided_at: decidedAt,
|
|
20193
|
+
decided_by: "channel_failure"
|
|
20194
|
+
},
|
|
20195
|
+
correlation_id: correlationId
|
|
20196
|
+
});
|
|
20197
|
+
} catch {
|
|
20198
|
+
}
|
|
20199
|
+
}
|
|
20200
|
+
return {
|
|
20201
|
+
allowed: false,
|
|
20202
|
+
tier,
|
|
20203
|
+
reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
|
|
20204
|
+
approval_required: true,
|
|
20205
|
+
approval_response: {
|
|
20206
|
+
decision: "deny",
|
|
20207
|
+
decided_at: decidedAt,
|
|
20208
|
+
decided_by: "channel_failure"
|
|
20209
|
+
}
|
|
20210
|
+
};
|
|
20211
|
+
}
|
|
19773
20212
|
this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
|
|
19774
20213
|
tier,
|
|
19775
20214
|
reason,
|
|
19776
20215
|
decided_by: response.decided_by
|
|
19777
20216
|
});
|
|
20217
|
+
if (this.onApprovalEvent) {
|
|
20218
|
+
try {
|
|
20219
|
+
this.onApprovalEvent({
|
|
20220
|
+
phase: "resolved",
|
|
20221
|
+
operation,
|
|
20222
|
+
tier,
|
|
20223
|
+
reason,
|
|
20224
|
+
context,
|
|
20225
|
+
request_timestamp: requestTimestamp,
|
|
20226
|
+
resolution: {
|
|
20227
|
+
decision: response.decision,
|
|
20228
|
+
decided_at: response.decided_at,
|
|
20229
|
+
decided_by: response.decided_by
|
|
20230
|
+
},
|
|
20231
|
+
correlation_id: correlationId
|
|
20232
|
+
});
|
|
20233
|
+
} catch {
|
|
20234
|
+
}
|
|
20235
|
+
}
|
|
19778
20236
|
return {
|
|
19779
20237
|
allowed: response.decision === "approve",
|
|
19780
20238
|
tier,
|
|
@@ -19809,6 +20267,524 @@ var init_gate = __esm({
|
|
|
19809
20267
|
};
|
|
19810
20268
|
}
|
|
19811
20269
|
});
|
|
20270
|
+
var APPROVAL_AGGREGATOR_NAMESPACE, APPROVAL_AGGREGATOR_HKDF_INFO, APPROVAL_AGGREGATOR_AUDIT_OPS, DEFAULT_PENDING_TTL_MS, DEFAULT_MAX_LIST_LIMIT, DEFAULT_LIST_PAGE_SIZE, ApprovalAggregator;
|
|
20271
|
+
var init_approval_aggregator = __esm({
|
|
20272
|
+
"src/principal-policy/approval-aggregator.ts"() {
|
|
20273
|
+
init_encryption();
|
|
20274
|
+
init_key_derivation();
|
|
20275
|
+
init_encoding();
|
|
20276
|
+
APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
|
|
20277
|
+
APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
|
|
20278
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS = {
|
|
20279
|
+
AGGREGATED: "cross_harness_approval_aggregated",
|
|
20280
|
+
RESOLVED: "cross_harness_approval_resolved",
|
|
20281
|
+
DEDUPED: "cross_harness_approval_deduped"
|
|
20282
|
+
};
|
|
20283
|
+
DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
|
|
20284
|
+
DEFAULT_MAX_LIST_LIMIT = 200;
|
|
20285
|
+
DEFAULT_LIST_PAGE_SIZE = 50;
|
|
20286
|
+
ApprovalAggregator = class {
|
|
20287
|
+
storage;
|
|
20288
|
+
encryptionKey;
|
|
20289
|
+
auditLog;
|
|
20290
|
+
identityId;
|
|
20291
|
+
fortressId;
|
|
20292
|
+
pendingTtlMs;
|
|
20293
|
+
maxListLimit;
|
|
20294
|
+
now;
|
|
20295
|
+
resolveSourceContext;
|
|
20296
|
+
resolveHubInboxItemId;
|
|
20297
|
+
/** Cached entries by `aggregator_id`. */
|
|
20298
|
+
entries = /* @__PURE__ */ new Map();
|
|
20299
|
+
/** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
|
|
20300
|
+
dedupIndex = /* @__PURE__ */ new Map();
|
|
20301
|
+
/** Correlation index: gate `correlation_id` -> aggregator_id. */
|
|
20302
|
+
correlationIndex = /* @__PURE__ */ new Map();
|
|
20303
|
+
/** Original request payloads kept in-memory for `getFullPayload()`. */
|
|
20304
|
+
fullPayloads = /* @__PURE__ */ new Map();
|
|
20305
|
+
/** Has the aggregator hydrated persisted entries on this process? */
|
|
20306
|
+
hydrated = false;
|
|
20307
|
+
/** Active SSE listeners. */
|
|
20308
|
+
listeners = /* @__PURE__ */ new Set();
|
|
20309
|
+
constructor(deps) {
|
|
20310
|
+
this.storage = deps.storage;
|
|
20311
|
+
this.encryptionKey = derivePurposeKey(
|
|
20312
|
+
deps.masterKey,
|
|
20313
|
+
APPROVAL_AGGREGATOR_HKDF_INFO
|
|
20314
|
+
);
|
|
20315
|
+
this.auditLog = deps.auditLog;
|
|
20316
|
+
this.identityId = deps.identityId;
|
|
20317
|
+
this.fortressId = deps.fortressId;
|
|
20318
|
+
this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
|
|
20319
|
+
this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
|
|
20320
|
+
this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
20321
|
+
this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
|
|
20322
|
+
source_harness: this.fortressId,
|
|
20323
|
+
source_agent_id: this.fortressId
|
|
20324
|
+
}));
|
|
20325
|
+
this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
|
|
20326
|
+
}
|
|
20327
|
+
/**
|
|
20328
|
+
* Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
|
|
20329
|
+
* use this to forward aggregator emissions to the dashboard.
|
|
20330
|
+
*/
|
|
20331
|
+
onEvent(listener) {
|
|
20332
|
+
this.listeners.add(listener);
|
|
20333
|
+
return () => this.listeners.delete(listener);
|
|
20334
|
+
}
|
|
20335
|
+
/**
|
|
20336
|
+
* Ingest a gate event. Returns the aggregator entry on first sight,
|
|
20337
|
+
* `null` when deduped. Resolution events update the existing record;
|
|
20338
|
+
* unmatched resolutions are dropped silently (caller's gate emitted a
|
|
20339
|
+
* resolved-without-requested pair, which the aggregator does not invent
|
|
20340
|
+
* a record for).
|
|
20341
|
+
*/
|
|
20342
|
+
async ingest(event) {
|
|
20343
|
+
await this.hydrate();
|
|
20344
|
+
if (event.phase === "requested") {
|
|
20345
|
+
return this.ingestRequested(event);
|
|
20346
|
+
}
|
|
20347
|
+
if (event.phase === "resolved") {
|
|
20348
|
+
return this.ingestResolved(event);
|
|
20349
|
+
}
|
|
20350
|
+
return null;
|
|
20351
|
+
}
|
|
20352
|
+
/**
|
|
20353
|
+
* List pending or recently resolved entries. Pending entries past TTL
|
|
20354
|
+
* are lazily transitioned to `expired` and persisted before the list
|
|
20355
|
+
* snapshot is returned.
|
|
20356
|
+
*/
|
|
20357
|
+
async list(opts) {
|
|
20358
|
+
await this.hydrate();
|
|
20359
|
+
await this.expireStale();
|
|
20360
|
+
const limit = Math.min(
|
|
20361
|
+
opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
|
|
20362
|
+
this.maxListLimit
|
|
20363
|
+
);
|
|
20364
|
+
const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
|
|
20365
|
+
const matching = [];
|
|
20366
|
+
for (const entry of this.entries.values()) {
|
|
20367
|
+
if (opts?.status && entry.status !== opts.status) continue;
|
|
20368
|
+
if (Date.parse(entry.created_at) < sinceMs) continue;
|
|
20369
|
+
matching.push(entry);
|
|
20370
|
+
}
|
|
20371
|
+
matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
20372
|
+
return matching.slice(0, limit);
|
|
20373
|
+
}
|
|
20374
|
+
/**
|
|
20375
|
+
* Return the original (unhashed) request payload for the entry. Returns
|
|
20376
|
+
* `null` when the entry is unknown or the payload was evicted (e.g. the
|
|
20377
|
+
* process restarted; payloads are in-memory only at v1.3 Upsilon-1).
|
|
20378
|
+
*/
|
|
20379
|
+
async getFullPayload(aggregatorId) {
|
|
20380
|
+
await this.hydrate();
|
|
20381
|
+
if (!this.entries.has(aggregatorId)) return null;
|
|
20382
|
+
return this.fullPayloads.get(aggregatorId) ?? null;
|
|
20383
|
+
}
|
|
20384
|
+
/**
|
|
20385
|
+
* Resolve an entry. Used by both:
|
|
20386
|
+
* 1. The gate wire-up on channel-decision return.
|
|
20387
|
+
* 2. The HTTP `approve`/`deny` routes when an operator clicks.
|
|
20388
|
+
*
|
|
20389
|
+
* Idempotent: resolving an already-resolved entry is a no-op (the record
|
|
20390
|
+
* keeps its first decision and the audit log is not double-fired).
|
|
20391
|
+
* Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
|
|
20392
|
+
* routes return 404.
|
|
20393
|
+
*/
|
|
20394
|
+
async resolve(aggregatorId, decision, operatorId) {
|
|
20395
|
+
await this.hydrate();
|
|
20396
|
+
const entry = this.entries.get(aggregatorId);
|
|
20397
|
+
if (!entry) {
|
|
20398
|
+
throw new Error("approval-aggregator: not_found");
|
|
20399
|
+
}
|
|
20400
|
+
if (entry.status !== "pending") {
|
|
20401
|
+
return entry;
|
|
20402
|
+
}
|
|
20403
|
+
entry.status = decision;
|
|
20404
|
+
entry.resolved_at = this.now().toISOString();
|
|
20405
|
+
entry.resolved_by = operatorId;
|
|
20406
|
+
await this.persist(entry);
|
|
20407
|
+
this.auditLog.append(
|
|
20408
|
+
"l2",
|
|
20409
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
|
|
20410
|
+
this.identityId,
|
|
20411
|
+
{
|
|
20412
|
+
aggregator_id: entry.aggregator_id,
|
|
20413
|
+
source_harness: entry.source_harness,
|
|
20414
|
+
source_agent_id: entry.source_agent_id,
|
|
20415
|
+
audit_log_entry_id: entry.audit_log_entry_id,
|
|
20416
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20417
|
+
decision,
|
|
20418
|
+
decided_by: operatorId,
|
|
20419
|
+
decided_at: entry.resolved_at
|
|
20420
|
+
}
|
|
20421
|
+
);
|
|
20422
|
+
this.emit({ type: "resolved", entry: { ...entry } });
|
|
20423
|
+
return entry;
|
|
20424
|
+
}
|
|
20425
|
+
// ── Internal: ingest paths ─────────────────────────────────────────────
|
|
20426
|
+
async ingestRequested(event) {
|
|
20427
|
+
const ctx = this.resolveSourceContext(event);
|
|
20428
|
+
const auditId = this.auditEntryIdForEvent(event);
|
|
20429
|
+
const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
|
|
20430
|
+
const existing = this.dedupIndex.get(dedupKey);
|
|
20431
|
+
if (existing) {
|
|
20432
|
+
const existingEntry = this.entries.get(existing);
|
|
20433
|
+
if (existingEntry) {
|
|
20434
|
+
this.correlationIndex.set(event.correlation_id, existing);
|
|
20435
|
+
this.auditLog.append(
|
|
20436
|
+
"l2",
|
|
20437
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
|
|
20438
|
+
this.identityId,
|
|
20439
|
+
{
|
|
20440
|
+
aggregator_id: existing,
|
|
20441
|
+
source_harness: ctx.source_harness,
|
|
20442
|
+
source_agent_id: ctx.source_agent_id,
|
|
20443
|
+
audit_log_entry_id: auditId,
|
|
20444
|
+
policy_rule_id: this.derivePolicyRuleId(event),
|
|
20445
|
+
correlation_id: event.correlation_id
|
|
20446
|
+
}
|
|
20447
|
+
);
|
|
20448
|
+
this.emit({ type: "deduped", entry: { ...existingEntry } });
|
|
20449
|
+
return null;
|
|
20450
|
+
}
|
|
20451
|
+
}
|
|
20452
|
+
const id = crypto.randomUUID();
|
|
20453
|
+
const now = this.now();
|
|
20454
|
+
const expires = new Date(now.getTime() + this.pendingTtlMs);
|
|
20455
|
+
const hubInboxId = this.resolveHubInboxItemId(event);
|
|
20456
|
+
const entry = {
|
|
20457
|
+
aggregator_id: id,
|
|
20458
|
+
source_harness: ctx.source_harness,
|
|
20459
|
+
source_agent_id: ctx.source_agent_id,
|
|
20460
|
+
audit_log_entry_id: auditId,
|
|
20461
|
+
policy_rule_id: this.derivePolicyRuleId(event),
|
|
20462
|
+
action_summary: this.deriveActionSummary(event),
|
|
20463
|
+
request_payload_hash: this.hashPayload(event.context),
|
|
20464
|
+
status: "pending",
|
|
20465
|
+
created_at: now.toISOString(),
|
|
20466
|
+
expires_at: expires.toISOString(),
|
|
20467
|
+
...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
|
|
20468
|
+
};
|
|
20469
|
+
this.entries.set(id, entry);
|
|
20470
|
+
this.dedupIndex.set(dedupKey, id);
|
|
20471
|
+
this.correlationIndex.set(event.correlation_id, id);
|
|
20472
|
+
this.fullPayloads.set(id, event.context);
|
|
20473
|
+
await this.persist(entry);
|
|
20474
|
+
this.auditLog.append(
|
|
20475
|
+
"l2",
|
|
20476
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
|
|
20477
|
+
this.identityId,
|
|
20478
|
+
{
|
|
20479
|
+
aggregator_id: id,
|
|
20480
|
+
source_harness: ctx.source_harness,
|
|
20481
|
+
source_agent_id: ctx.source_agent_id,
|
|
20482
|
+
audit_log_entry_id: auditId,
|
|
20483
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20484
|
+
...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
|
|
20485
|
+
}
|
|
20486
|
+
);
|
|
20487
|
+
this.emit({ type: "aggregated", entry: { ...entry } });
|
|
20488
|
+
return entry;
|
|
20489
|
+
}
|
|
20490
|
+
async ingestResolved(event) {
|
|
20491
|
+
const id = this.correlationIndex.get(event.correlation_id);
|
|
20492
|
+
if (!id) return null;
|
|
20493
|
+
const entry = this.entries.get(id);
|
|
20494
|
+
if (!entry) return null;
|
|
20495
|
+
if (entry.status !== "pending") return entry;
|
|
20496
|
+
if (!event.resolution) return entry;
|
|
20497
|
+
const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
|
|
20498
|
+
const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
|
|
20499
|
+
entry.status = status;
|
|
20500
|
+
entry.resolved_at = event.resolution.decided_at;
|
|
20501
|
+
entry.resolved_by = event.resolution.decided_by;
|
|
20502
|
+
await this.persist(entry);
|
|
20503
|
+
this.auditLog.append(
|
|
20504
|
+
"l2",
|
|
20505
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
|
|
20506
|
+
this.identityId,
|
|
20507
|
+
{
|
|
20508
|
+
aggregator_id: id,
|
|
20509
|
+
source_harness: entry.source_harness,
|
|
20510
|
+
source_agent_id: entry.source_agent_id,
|
|
20511
|
+
audit_log_entry_id: entry.audit_log_entry_id,
|
|
20512
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20513
|
+
decision: status,
|
|
20514
|
+
decided_by: entry.resolved_by,
|
|
20515
|
+
decided_at: entry.resolved_at,
|
|
20516
|
+
fail_closed: failClosed
|
|
20517
|
+
}
|
|
20518
|
+
);
|
|
20519
|
+
this.emit({ type: "resolved", entry: { ...entry } });
|
|
20520
|
+
return entry;
|
|
20521
|
+
}
|
|
20522
|
+
// ── Internal: helpers ──────────────────────────────────────────────────
|
|
20523
|
+
/**
|
|
20524
|
+
* Audit-log entry id for the dedup tuple. The audit log itself does not
|
|
20525
|
+
* surface a stable per-entry id (counter-prefixed keys are internal); the
|
|
20526
|
+
* aggregator uses the request timestamp + operation, which together pin
|
|
20527
|
+
* the audit entry the gate appended on the same call.
|
|
20528
|
+
*/
|
|
20529
|
+
auditEntryIdForEvent(event) {
|
|
20530
|
+
return `${event.request_timestamp}:${event.operation}`;
|
|
20531
|
+
}
|
|
20532
|
+
derivePolicyRuleId(event) {
|
|
20533
|
+
return `tier${event.tier}:${event.operation}`;
|
|
20534
|
+
}
|
|
20535
|
+
deriveActionSummary(event) {
|
|
20536
|
+
return `${event.operation} (tier ${event.tier})`;
|
|
20537
|
+
}
|
|
20538
|
+
/**
|
|
20539
|
+
* Canonical SHA-256 of the request context. Sorted-keys serialization so
|
|
20540
|
+
* identical payloads always hash the same, even when key insertion order
|
|
20541
|
+
* varies. Defends against payload-replay smuggling (the aggregator can
|
|
20542
|
+
* tell the same payload was seen twice without storing it cleartext).
|
|
20543
|
+
*/
|
|
20544
|
+
hashPayload(payload) {
|
|
20545
|
+
const canonical = JSON.stringify(payload, Object.keys(payload).sort());
|
|
20546
|
+
return crypto.createHash("sha256").update(canonical).digest("hex");
|
|
20547
|
+
}
|
|
20548
|
+
emit(event) {
|
|
20549
|
+
for (const listener of this.listeners) {
|
|
20550
|
+
try {
|
|
20551
|
+
listener(event);
|
|
20552
|
+
} catch {
|
|
20553
|
+
}
|
|
20554
|
+
}
|
|
20555
|
+
}
|
|
20556
|
+
async expireStale() {
|
|
20557
|
+
const nowMs = this.now().getTime();
|
|
20558
|
+
for (const entry of this.entries.values()) {
|
|
20559
|
+
if (entry.status !== "pending") continue;
|
|
20560
|
+
if (Date.parse(entry.expires_at) > nowMs) continue;
|
|
20561
|
+
entry.status = "expired";
|
|
20562
|
+
entry.resolved_at = this.now().toISOString();
|
|
20563
|
+
entry.resolved_by = "system_ttl";
|
|
20564
|
+
await this.persist(entry);
|
|
20565
|
+
this.auditLog.append(
|
|
20566
|
+
"l2",
|
|
20567
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
|
|
20568
|
+
this.identityId,
|
|
20569
|
+
{
|
|
20570
|
+
aggregator_id: entry.aggregator_id,
|
|
20571
|
+
source_harness: entry.source_harness,
|
|
20572
|
+
source_agent_id: entry.source_agent_id,
|
|
20573
|
+
audit_log_entry_id: entry.audit_log_entry_id,
|
|
20574
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20575
|
+
decision: "expired",
|
|
20576
|
+
decided_by: "system_ttl",
|
|
20577
|
+
decided_at: entry.resolved_at
|
|
20578
|
+
}
|
|
20579
|
+
);
|
|
20580
|
+
this.emit({ type: "resolved", entry: { ...entry } });
|
|
20581
|
+
}
|
|
20582
|
+
}
|
|
20583
|
+
async persist(entry) {
|
|
20584
|
+
const serialized = stringToBytes(JSON.stringify(entry));
|
|
20585
|
+
const encrypted = encrypt(serialized, this.encryptionKey);
|
|
20586
|
+
await this.storage.write(
|
|
20587
|
+
APPROVAL_AGGREGATOR_NAMESPACE,
|
|
20588
|
+
entry.aggregator_id,
|
|
20589
|
+
stringToBytes(JSON.stringify(encrypted))
|
|
20590
|
+
);
|
|
20591
|
+
}
|
|
20592
|
+
async hydrate() {
|
|
20593
|
+
if (this.hydrated) return;
|
|
20594
|
+
this.hydrated = true;
|
|
20595
|
+
try {
|
|
20596
|
+
const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
|
|
20597
|
+
for (const meta of metas) {
|
|
20598
|
+
const raw = await this.storage.read(
|
|
20599
|
+
APPROVAL_AGGREGATOR_NAMESPACE,
|
|
20600
|
+
meta.key
|
|
20601
|
+
);
|
|
20602
|
+
if (!raw) continue;
|
|
20603
|
+
try {
|
|
20604
|
+
const encrypted = JSON.parse(bytesToString(raw));
|
|
20605
|
+
const decrypted = decrypt(encrypted, this.encryptionKey);
|
|
20606
|
+
const entry = JSON.parse(bytesToString(decrypted));
|
|
20607
|
+
this.entries.set(entry.aggregator_id, entry);
|
|
20608
|
+
const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
|
|
20609
|
+
this.dedupIndex.set(dedupKey, entry.aggregator_id);
|
|
20610
|
+
} catch {
|
|
20611
|
+
}
|
|
20612
|
+
}
|
|
20613
|
+
} catch {
|
|
20614
|
+
this.hydrated = false;
|
|
20615
|
+
}
|
|
20616
|
+
}
|
|
20617
|
+
};
|
|
20618
|
+
}
|
|
20619
|
+
});
|
|
20620
|
+
|
|
20621
|
+
// src/principal-policy/channels/aggregator-backed-channel.ts
|
|
20622
|
+
function auditEntryIdFor(request) {
|
|
20623
|
+
return `${request.timestamp}:${request.operation}`;
|
|
20624
|
+
}
|
|
20625
|
+
function statusToDecision(entry) {
|
|
20626
|
+
switch (entry.status) {
|
|
20627
|
+
case "approved":
|
|
20628
|
+
return {
|
|
20629
|
+
decision: "approve",
|
|
20630
|
+
decided_by: "human"
|
|
20631
|
+
};
|
|
20632
|
+
case "denied":
|
|
20633
|
+
return {
|
|
20634
|
+
decision: "deny",
|
|
20635
|
+
decided_by: "human"
|
|
20636
|
+
};
|
|
20637
|
+
case "timeout":
|
|
20638
|
+
case "expired":
|
|
20639
|
+
return {
|
|
20640
|
+
decision: "deny",
|
|
20641
|
+
decided_by: "timeout"
|
|
20642
|
+
};
|
|
20643
|
+
default:
|
|
20644
|
+
return null;
|
|
20645
|
+
}
|
|
20646
|
+
}
|
|
20647
|
+
function makeRedirectResolverFromPolicySupplier(supplier) {
|
|
20648
|
+
return (_request) => {
|
|
20649
|
+
const cfg = supplier().approval_redirect;
|
|
20650
|
+
if (!cfg || cfg.enabled !== true) {
|
|
20651
|
+
return { enabled: false, mode: "replace" };
|
|
20652
|
+
}
|
|
20653
|
+
return {
|
|
20654
|
+
enabled: true,
|
|
20655
|
+
mode: cfg.mode === "notify" ? "notify" : "replace"
|
|
20656
|
+
};
|
|
20657
|
+
};
|
|
20658
|
+
}
|
|
20659
|
+
var DEFAULT_REPLACE_MODE_TIMEOUT_MS, AggregatorBackedChannel;
|
|
20660
|
+
var init_aggregator_backed_channel = __esm({
|
|
20661
|
+
"src/principal-policy/channels/aggregator-backed-channel.ts"() {
|
|
20662
|
+
DEFAULT_REPLACE_MODE_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
20663
|
+
AggregatorBackedChannel = class {
|
|
20664
|
+
underlying;
|
|
20665
|
+
aggregator;
|
|
20666
|
+
resolveRedirect;
|
|
20667
|
+
replaceModeTimeoutMs;
|
|
20668
|
+
now;
|
|
20669
|
+
constructor(opts) {
|
|
20670
|
+
this.underlying = opts.underlying;
|
|
20671
|
+
this.aggregator = opts.aggregator;
|
|
20672
|
+
this.resolveRedirect = opts.resolveRedirect;
|
|
20673
|
+
this.replaceModeTimeoutMs = opts.replaceModeTimeoutMs ?? DEFAULT_REPLACE_MODE_TIMEOUT_MS;
|
|
20674
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
20675
|
+
}
|
|
20676
|
+
/** Expose underlying for tests / wire-up reuse. */
|
|
20677
|
+
getUnderlying() {
|
|
20678
|
+
return this.underlying;
|
|
20679
|
+
}
|
|
20680
|
+
async requestApproval(request) {
|
|
20681
|
+
const cfg = this.resolveRedirect(request);
|
|
20682
|
+
if (!cfg.enabled) {
|
|
20683
|
+
return this.underlying.requestApproval(request);
|
|
20684
|
+
}
|
|
20685
|
+
if (cfg.mode === "replace") {
|
|
20686
|
+
return this.awaitAggregatorDecision(request);
|
|
20687
|
+
}
|
|
20688
|
+
return this.notifyMode(request);
|
|
20689
|
+
}
|
|
20690
|
+
/**
|
|
20691
|
+
* `replace` mode. Subscribe to the aggregator's event stream BEFORE
|
|
20692
|
+
* checking already-stored entries (avoids a race where the entry resolves
|
|
20693
|
+
* between list and subscribe). Match incoming events to this request by
|
|
20694
|
+
* audit_entry_id. Time out after `replaceModeTimeoutMs` to honor SEC-002.
|
|
20695
|
+
*/
|
|
20696
|
+
async awaitAggregatorDecision(request) {
|
|
20697
|
+
const auditId = auditEntryIdFor(request);
|
|
20698
|
+
return new Promise((resolveOuter) => {
|
|
20699
|
+
let settled = false;
|
|
20700
|
+
let unsubscribe = null;
|
|
20701
|
+
let timeoutHandle = null;
|
|
20702
|
+
const settle = (response) => {
|
|
20703
|
+
if (settled) return;
|
|
20704
|
+
settled = true;
|
|
20705
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
20706
|
+
if (unsubscribe) {
|
|
20707
|
+
try {
|
|
20708
|
+
unsubscribe();
|
|
20709
|
+
} catch {
|
|
20710
|
+
}
|
|
20711
|
+
}
|
|
20712
|
+
resolveOuter(response);
|
|
20713
|
+
};
|
|
20714
|
+
const onEvent = (emit) => {
|
|
20715
|
+
if (emit.type !== "resolved") return;
|
|
20716
|
+
if (emit.entry.audit_log_entry_id !== auditId) return;
|
|
20717
|
+
const mapped = statusToDecision(emit.entry);
|
|
20718
|
+
if (!mapped) return;
|
|
20719
|
+
settle({
|
|
20720
|
+
decision: mapped.decision,
|
|
20721
|
+
decided_at: emit.entry.resolved_at ?? this.now().toISOString(),
|
|
20722
|
+
decided_by: mapped.decided_by
|
|
20723
|
+
});
|
|
20724
|
+
};
|
|
20725
|
+
try {
|
|
20726
|
+
unsubscribe = this.aggregator.onEvent(onEvent);
|
|
20727
|
+
} catch (err) {
|
|
20728
|
+
settle({
|
|
20729
|
+
decision: "deny",
|
|
20730
|
+
decided_at: this.now().toISOString(),
|
|
20731
|
+
decided_by: "channel_failure"
|
|
20732
|
+
});
|
|
20733
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
20734
|
+
}
|
|
20735
|
+
void this.aggregator.list({ limit: 200 }).then((entries) => {
|
|
20736
|
+
for (const entry of entries) {
|
|
20737
|
+
if (entry.audit_log_entry_id !== auditId) continue;
|
|
20738
|
+
const mapped = statusToDecision(entry);
|
|
20739
|
+
if (!mapped) return;
|
|
20740
|
+
settle({
|
|
20741
|
+
decision: mapped.decision,
|
|
20742
|
+
decided_at: entry.resolved_at ?? this.now().toISOString(),
|
|
20743
|
+
decided_by: mapped.decided_by
|
|
20744
|
+
});
|
|
20745
|
+
return;
|
|
20746
|
+
}
|
|
20747
|
+
}).catch(() => {
|
|
20748
|
+
});
|
|
20749
|
+
timeoutHandle = setTimeout(() => {
|
|
20750
|
+
settle({
|
|
20751
|
+
decision: "deny",
|
|
20752
|
+
decided_at: this.now().toISOString(),
|
|
20753
|
+
decided_by: "timeout"
|
|
20754
|
+
});
|
|
20755
|
+
}, this.replaceModeTimeoutMs);
|
|
20756
|
+
});
|
|
20757
|
+
}
|
|
20758
|
+
/**
|
|
20759
|
+
* `notify` mode. Fire the underlying channel and listen on the
|
|
20760
|
+
* aggregator simultaneously; whichever resolves first wins. Both
|
|
20761
|
+
* paths produce identical `ApprovalResponse` shapes; the gate's
|
|
20762
|
+
* downstream audit logging is unchanged.
|
|
20763
|
+
*
|
|
20764
|
+
* On underlying-channel failure, fall through to the aggregator wait
|
|
20765
|
+
* (still bounded by `replaceModeTimeoutMs`). Operator can still
|
|
20766
|
+
* resolve from the inbox even if the dashboard/webhook is down.
|
|
20767
|
+
*/
|
|
20768
|
+
async notifyMode(request) {
|
|
20769
|
+
const aggregatorPromise = this.awaitAggregatorDecision(request);
|
|
20770
|
+
let underlyingPromise;
|
|
20771
|
+
try {
|
|
20772
|
+
underlyingPromise = this.underlying.requestApproval(request);
|
|
20773
|
+
} catch (err) {
|
|
20774
|
+
const response = await aggregatorPromise;
|
|
20775
|
+
return response;
|
|
20776
|
+
}
|
|
20777
|
+
return Promise.race([
|
|
20778
|
+
aggregatorPromise,
|
|
20779
|
+
underlyingPromise.catch(
|
|
20780
|
+
() => new Promise(() => {
|
|
20781
|
+
})
|
|
20782
|
+
)
|
|
20783
|
+
]);
|
|
20784
|
+
}
|
|
20785
|
+
};
|
|
20786
|
+
}
|
|
20787
|
+
});
|
|
19812
20788
|
|
|
19813
20789
|
// src/principal-policy/tools.ts
|
|
19814
20790
|
function createPrincipalPolicyTools(policy, baseline, auditLog) {
|
|
@@ -20713,6 +21689,76 @@ var init_attestation = __esm({
|
|
|
20713
21689
|
}
|
|
20714
21690
|
});
|
|
20715
21691
|
|
|
21692
|
+
// src/handshake/audit.ts
|
|
21693
|
+
function auditHandshakeInitiated(auditLog, ctx) {
|
|
21694
|
+
auditLog.append(
|
|
21695
|
+
"l4",
|
|
21696
|
+
HANDSHAKE_LIFECYCLE_OPS.INITIATED,
|
|
21697
|
+
ctx.identity_id,
|
|
21698
|
+
detailsFromContext(ctx),
|
|
21699
|
+
"success"
|
|
21700
|
+
);
|
|
21701
|
+
}
|
|
21702
|
+
function auditHandshakeCompleted(auditLog, ctx) {
|
|
21703
|
+
const details = detailsFromContext(ctx);
|
|
21704
|
+
if (ctx.trust_tier !== void 0) {
|
|
21705
|
+
details.trust_tier = ctx.trust_tier;
|
|
21706
|
+
}
|
|
21707
|
+
auditLog.append(
|
|
21708
|
+
"l4",
|
|
21709
|
+
HANDSHAKE_LIFECYCLE_OPS.COMPLETED,
|
|
21710
|
+
ctx.identity_id,
|
|
21711
|
+
details,
|
|
21712
|
+
"success"
|
|
21713
|
+
);
|
|
21714
|
+
}
|
|
21715
|
+
function auditHandshakeFailed(auditLog, ctx) {
|
|
21716
|
+
const details = detailsFromContext(ctx);
|
|
21717
|
+
details.reason = ctx.reason;
|
|
21718
|
+
if (ctx.error !== void 0) {
|
|
21719
|
+
details.error = ctx.error;
|
|
21720
|
+
}
|
|
21721
|
+
auditLog.append(
|
|
21722
|
+
"l4",
|
|
21723
|
+
HANDSHAKE_LIFECYCLE_OPS.FAILED,
|
|
21724
|
+
ctx.identity_id,
|
|
21725
|
+
details,
|
|
21726
|
+
"failure"
|
|
21727
|
+
);
|
|
21728
|
+
}
|
|
21729
|
+
function auditHandshakeAborted(auditLog, ctx) {
|
|
21730
|
+
const details = detailsFromContext(ctx);
|
|
21731
|
+
details.reason = ctx.reason;
|
|
21732
|
+
auditLog.append(
|
|
21733
|
+
"l4",
|
|
21734
|
+
HANDSHAKE_LIFECYCLE_OPS.ABORTED,
|
|
21735
|
+
ctx.identity_id,
|
|
21736
|
+
details,
|
|
21737
|
+
"failure"
|
|
21738
|
+
);
|
|
21739
|
+
}
|
|
21740
|
+
function detailsFromContext(ctx) {
|
|
21741
|
+
const details = {
|
|
21742
|
+
session_id: ctx.session_id,
|
|
21743
|
+
role: ctx.role
|
|
21744
|
+
};
|
|
21745
|
+
if (ctx.counterparty_id !== void 0) {
|
|
21746
|
+
details.counterparty_id = ctx.counterparty_id;
|
|
21747
|
+
}
|
|
21748
|
+
return details;
|
|
21749
|
+
}
|
|
21750
|
+
var HANDSHAKE_LIFECYCLE_OPS;
|
|
21751
|
+
var init_audit = __esm({
|
|
21752
|
+
"src/handshake/audit.ts"() {
|
|
21753
|
+
HANDSHAKE_LIFECYCLE_OPS = {
|
|
21754
|
+
INITIATED: "handshake_initiated",
|
|
21755
|
+
COMPLETED: "handshake_completed",
|
|
21756
|
+
FAILED: "handshake_failed",
|
|
21757
|
+
ABORTED: "handshake_aborted"
|
|
21758
|
+
};
|
|
21759
|
+
}
|
|
21760
|
+
});
|
|
21761
|
+
|
|
20716
21762
|
// src/handshake/tools.ts
|
|
20717
21763
|
function createHandshakeTools(config, identityManager, masterKey, auditLog, options) {
|
|
20718
21764
|
const autoPublishHandshakes = options?.autoPublishHandshakes ?? false;
|
|
@@ -20746,6 +21792,11 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20746
21792
|
const { challenge, session } = initiateHandshake(shr);
|
|
20747
21793
|
sessions.set(session.session_id, session);
|
|
20748
21794
|
auditLog.append("l4", "handshake_initiate", shr.body.instance_id);
|
|
21795
|
+
auditHandshakeInitiated(auditLog, {
|
|
21796
|
+
session_id: session.session_id,
|
|
21797
|
+
role: "initiator",
|
|
21798
|
+
identity_id: shr.body.instance_id
|
|
21799
|
+
});
|
|
20749
21800
|
return toolResult({
|
|
20750
21801
|
session_id: session.session_id,
|
|
20751
21802
|
challenge,
|
|
@@ -20785,10 +21836,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20785
21836
|
);
|
|
20786
21837
|
if ("error" in result) {
|
|
20787
21838
|
auditLog.append("l4", "handshake_respond", shr.body.instance_id, void 0, "failure");
|
|
21839
|
+
auditHandshakeFailed(auditLog, {
|
|
21840
|
+
session_id: "unknown",
|
|
21841
|
+
role: "responder",
|
|
21842
|
+
identity_id: shr.body.instance_id,
|
|
21843
|
+
reason: classifyRespondFailure(result.error),
|
|
21844
|
+
error: result.error
|
|
21845
|
+
});
|
|
20788
21846
|
return toolResult({ error: result.error });
|
|
20789
21847
|
}
|
|
20790
21848
|
sessions.set(result.session.session_id, result.session);
|
|
20791
21849
|
auditLog.append("l4", "handshake_respond", shr.body.instance_id);
|
|
21850
|
+
auditHandshakeInitiated(auditLog, {
|
|
21851
|
+
session_id: result.session.session_id,
|
|
21852
|
+
role: "responder",
|
|
21853
|
+
identity_id: shr.body.instance_id,
|
|
21854
|
+
counterparty_id: challenge.shr.body.instance_id
|
|
21855
|
+
});
|
|
20792
21856
|
let autoPublishResult;
|
|
20793
21857
|
if (autoPublishHandshakes) {
|
|
20794
21858
|
autoPublishResult = { attempted: true };
|
|
@@ -20896,9 +21960,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20896
21960
|
const response = args.response;
|
|
20897
21961
|
const session = sessions.get(sessionId);
|
|
20898
21962
|
if (!session) {
|
|
21963
|
+
auditHandshakeFailed(auditLog, {
|
|
21964
|
+
session_id: sessionId,
|
|
21965
|
+
role: "initiator",
|
|
21966
|
+
identity_id: "unknown",
|
|
21967
|
+
reason: "session_unknown",
|
|
21968
|
+
error: `No handshake session found: ${sessionId}`
|
|
21969
|
+
});
|
|
20899
21970
|
return toolResult({ error: `No handshake session found: ${sessionId}` });
|
|
20900
21971
|
}
|
|
20901
21972
|
if (session.state !== "initiated") {
|
|
21973
|
+
auditHandshakeFailed(auditLog, {
|
|
21974
|
+
session_id: sessionId,
|
|
21975
|
+
role: "initiator",
|
|
21976
|
+
identity_id: session.our_shr.body.instance_id,
|
|
21977
|
+
reason: "session_state_mismatch",
|
|
21978
|
+
error: `Session is in state '${session.state}', expected 'initiated'`
|
|
21979
|
+
});
|
|
20902
21980
|
return toolResult({
|
|
20903
21981
|
error: `Session is in state '${session.state}', expected 'initiated'`
|
|
20904
21982
|
});
|
|
@@ -20912,6 +21990,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20912
21990
|
if ("error" in result) {
|
|
20913
21991
|
session.state = "failed";
|
|
20914
21992
|
auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id, void 0, "failure");
|
|
21993
|
+
auditHandshakeFailed(auditLog, {
|
|
21994
|
+
session_id: sessionId,
|
|
21995
|
+
role: "initiator",
|
|
21996
|
+
identity_id: session.our_shr.body.instance_id,
|
|
21997
|
+
reason: classifyCompleteFailure(result.error),
|
|
21998
|
+
error: result.error
|
|
21999
|
+
});
|
|
20915
22000
|
return toolResult({ error: result.error });
|
|
20916
22001
|
}
|
|
20917
22002
|
session.state = "completed";
|
|
@@ -20920,6 +22005,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20920
22005
|
session.result = result.result;
|
|
20921
22006
|
handshakeResults.set(result.result.counterparty_id, result.result);
|
|
20922
22007
|
auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id);
|
|
22008
|
+
auditHandshakeCompleted(auditLog, {
|
|
22009
|
+
session_id: sessionId,
|
|
22010
|
+
role: "initiator",
|
|
22011
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22012
|
+
counterparty_id: result.result.counterparty_id,
|
|
22013
|
+
trust_tier: result.result.trust_tier
|
|
22014
|
+
});
|
|
20923
22015
|
return toolResult({
|
|
20924
22016
|
completion: result.completion,
|
|
20925
22017
|
result: result.result,
|
|
@@ -20967,6 +22059,24 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20967
22059
|
void 0,
|
|
20968
22060
|
result.verified ? "success" : "failure"
|
|
20969
22061
|
);
|
|
22062
|
+
if (result.verified) {
|
|
22063
|
+
auditHandshakeCompleted(auditLog, {
|
|
22064
|
+
session_id: session.session_id,
|
|
22065
|
+
role: "responder",
|
|
22066
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22067
|
+
counterparty_id: result.counterparty_id,
|
|
22068
|
+
trust_tier: result.trust_tier
|
|
22069
|
+
});
|
|
22070
|
+
} else {
|
|
22071
|
+
auditHandshakeFailed(auditLog, {
|
|
22072
|
+
session_id: session.session_id,
|
|
22073
|
+
role: "responder",
|
|
22074
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22075
|
+
counterparty_id: result.counterparty_id,
|
|
22076
|
+
reason: classifyCompleteFailure(result.errors.join("; ")),
|
|
22077
|
+
error: result.errors.join("; ")
|
|
22078
|
+
});
|
|
22079
|
+
}
|
|
20970
22080
|
return toolResult({ result });
|
|
20971
22081
|
}
|
|
20972
22082
|
return toolResult({
|
|
@@ -21074,10 +22184,74 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
21074
22184
|
_content_trust: "external"
|
|
21075
22185
|
});
|
|
21076
22186
|
}
|
|
22187
|
+
},
|
|
22188
|
+
{
|
|
22189
|
+
name: "handshake_abort",
|
|
22190
|
+
description: "Abort an in-flight handshake session. Drops the session record and appends a session-lifecycle audit entry (handshake_aborted) so the operator can distinguish operator-cancelled, timed-out, and dropped sessions from sessions that simply fell off the protocol path.",
|
|
22191
|
+
inputSchema: {
|
|
22192
|
+
type: "object",
|
|
22193
|
+
properties: {
|
|
22194
|
+
session_id: {
|
|
22195
|
+
type: "string",
|
|
22196
|
+
description: "Session ID returned from handshake_initiate / handshake_respond."
|
|
22197
|
+
},
|
|
22198
|
+
reason: {
|
|
22199
|
+
type: "string",
|
|
22200
|
+
enum: [
|
|
22201
|
+
"operator_cancelled",
|
|
22202
|
+
"session_timeout",
|
|
22203
|
+
"transport_dropped",
|
|
22204
|
+
"shutdown",
|
|
22205
|
+
"other"
|
|
22206
|
+
],
|
|
22207
|
+
description: "Why the session is being aborted. Defaults to 'operator_cancelled'."
|
|
22208
|
+
}
|
|
22209
|
+
},
|
|
22210
|
+
required: ["session_id"]
|
|
22211
|
+
},
|
|
22212
|
+
handler: async (args) => {
|
|
22213
|
+
const sessionId = args.session_id;
|
|
22214
|
+
const reason = args.reason ?? "operator_cancelled";
|
|
22215
|
+
const session = sessions.get(sessionId);
|
|
22216
|
+
if (!session) {
|
|
22217
|
+
return toolResult({ error: `No handshake session found: ${sessionId}` });
|
|
22218
|
+
}
|
|
22219
|
+
if (session.state === "completed") {
|
|
22220
|
+
return toolResult({
|
|
22221
|
+
error: `Session ${sessionId} already completed; abort is only valid for in-flight sessions`
|
|
22222
|
+
});
|
|
22223
|
+
}
|
|
22224
|
+
sessions.delete(sessionId);
|
|
22225
|
+
auditHandshakeAborted(auditLog, {
|
|
22226
|
+
session_id: sessionId,
|
|
22227
|
+
role: session.role,
|
|
22228
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22229
|
+
...session.their_shr ? { counterparty_id: session.their_shr.body.instance_id } : {},
|
|
22230
|
+
reason
|
|
22231
|
+
});
|
|
22232
|
+
return toolResult({
|
|
22233
|
+
aborted: true,
|
|
22234
|
+
session_id: sessionId,
|
|
22235
|
+
reason
|
|
22236
|
+
});
|
|
22237
|
+
}
|
|
21077
22238
|
}
|
|
21078
22239
|
];
|
|
21079
22240
|
return { tools, handshakeResults };
|
|
21080
22241
|
}
|
|
22242
|
+
function classifyRespondFailure(error) {
|
|
22243
|
+
if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
|
|
22244
|
+
if (error.includes("SHR verification failed")) return "shr_invalid";
|
|
22245
|
+
if (error.includes("No identity available")) return "no_signing_identity";
|
|
22246
|
+
return "other";
|
|
22247
|
+
}
|
|
22248
|
+
function classifyCompleteFailure(error) {
|
|
22249
|
+
if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
|
|
22250
|
+
if (error.includes("SHR verification failed") || error.includes("SHR")) return "shr_invalid";
|
|
22251
|
+
if (error.includes("nonce signature is invalid")) return "nonce_signature_invalid";
|
|
22252
|
+
if (error.includes("No identity available")) return "no_signing_identity";
|
|
22253
|
+
return "other";
|
|
22254
|
+
}
|
|
21081
22255
|
var init_tools6 = __esm({
|
|
21082
22256
|
"src/handshake/tools.ts"() {
|
|
21083
22257
|
init_router();
|
|
@@ -21087,6 +22261,7 @@ var init_tools6 = __esm({
|
|
|
21087
22261
|
init_encoding();
|
|
21088
22262
|
init_protocol();
|
|
21089
22263
|
init_attestation();
|
|
22264
|
+
init_audit();
|
|
21090
22265
|
init_verifier();
|
|
21091
22266
|
}
|
|
21092
22267
|
});
|
|
@@ -22726,6 +23901,12 @@ function typed(markerPath, lineNumber, field, expected) {
|
|
|
22726
23901
|
}
|
|
22727
23902
|
async function consumeResetHistoryMarker(options) {
|
|
22728
23903
|
const markerPath = path.join(options.storagePath, RESET_HISTORY_FILENAME);
|
|
23904
|
+
const consumedPath = markerPath + ".consumed";
|
|
23905
|
+
if (await fileExists3(consumedPath)) {
|
|
23906
|
+
await promises.rm(markerPath, { force: true });
|
|
23907
|
+
await promises.rm(consumedPath, { force: true });
|
|
23908
|
+
return { emitted: 0, markerPath };
|
|
23909
|
+
}
|
|
22729
23910
|
if (!await fileExists3(markerPath)) {
|
|
22730
23911
|
return { emitted: 0, markerPath };
|
|
22731
23912
|
}
|
|
@@ -22750,7 +23931,9 @@ async function consumeResetHistoryMarker(options) {
|
|
|
22750
23931
|
});
|
|
22751
23932
|
}
|
|
22752
23933
|
await options.auditLog.flush();
|
|
23934
|
+
await promises.writeFile(consumedPath, "", "utf-8");
|
|
22753
23935
|
await promises.rm(markerPath, { force: true });
|
|
23936
|
+
await promises.rm(consumedPath, { force: true });
|
|
22754
23937
|
return { emitted: markers.length, markerHash, markerPath };
|
|
22755
23938
|
}
|
|
22756
23939
|
async function fileExists3(path) {
|
|
@@ -32033,6 +33216,36 @@ var init_hub_service = __esm({
|
|
|
32033
33216
|
const chat = this.requireOperatorChat();
|
|
32034
33217
|
return chat.getConciergeHistory();
|
|
32035
33218
|
}
|
|
33219
|
+
// ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
|
|
33220
|
+
/**
|
|
33221
|
+
* Whether the operator-chat service has the WP-V1.3-9 memory store
|
|
33222
|
+
* wired. Routes use this to 503 cleanly when the foundation memory
|
|
33223
|
+
* surface is unavailable on a given fortress.
|
|
33224
|
+
*/
|
|
33225
|
+
hasConciergeMemory() {
|
|
33226
|
+
return Boolean(this.deps.operatorChat?.hasConciergeMemory());
|
|
33227
|
+
}
|
|
33228
|
+
async listConciergeMemoryThreads(opts) {
|
|
33229
|
+
const chat = this.requireOperatorChat();
|
|
33230
|
+
if (!chat.hasConciergeMemory()) {
|
|
33231
|
+
throw new HubCapabilityError("concierge_memory_not_wired");
|
|
33232
|
+
}
|
|
33233
|
+
return chat.listConciergeMemoryThreads(opts);
|
|
33234
|
+
}
|
|
33235
|
+
async readConciergeMemoryThread(threadId, opts) {
|
|
33236
|
+
const chat = this.requireOperatorChat();
|
|
33237
|
+
if (!chat.hasConciergeMemory()) {
|
|
33238
|
+
throw new HubCapabilityError("concierge_memory_not_wired");
|
|
33239
|
+
}
|
|
33240
|
+
return chat.readConciergeMemoryThread(threadId, opts);
|
|
33241
|
+
}
|
|
33242
|
+
async deleteConciergeMemoryThread(threadId) {
|
|
33243
|
+
const chat = this.requireOperatorChat();
|
|
33244
|
+
if (!chat.hasConciergeMemory()) {
|
|
33245
|
+
throw new HubCapabilityError("concierge_memory_not_wired");
|
|
33246
|
+
}
|
|
33247
|
+
return chat.deleteConciergeMemoryThread(threadId);
|
|
33248
|
+
}
|
|
32036
33249
|
/**
|
|
32037
33250
|
* Open the click-to-inspect/approve panel for a wrapped agent. The
|
|
32038
33251
|
* panel surfaces recent activity routed through this agent, pending
|
|
@@ -32171,7 +33384,27 @@ var init_operator_chat_audit_events = __esm({
|
|
|
32171
33384
|
* affordance now opens an inspect/approve panel (recent activity +
|
|
32172
33385
|
* pending approvals + policy summary) instead of a chat session.
|
|
32173
33386
|
*/
|
|
32174
|
-
AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened"
|
|
33387
|
+
AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened",
|
|
33388
|
+
/**
|
|
33389
|
+
* Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
|
|
33390
|
+
* when the operator hits the list-threads or read-thread route. Body
|
|
33391
|
+
* carries the thread_id (or `*` for the list endpoint) and a count;
|
|
33392
|
+
* raw turn content never crosses the audit surface.
|
|
33393
|
+
*/
|
|
33394
|
+
CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
|
|
33395
|
+
/**
|
|
33396
|
+
* Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
|
|
33397
|
+
* successful thread removal. Body carries thread_id + turn_count of
|
|
33398
|
+
* the deleted bundle.
|
|
33399
|
+
*/
|
|
33400
|
+
CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted",
|
|
33401
|
+
/**
|
|
33402
|
+
* Concierge memory fold-read failed (WP-V1.3-9 Tau-2). Emitted when
|
|
33403
|
+
* the multi-turn coherence fold cannot load the active thread's prior
|
|
33404
|
+
* turns; the concierge degrades to single-turn after emitting. Body
|
|
33405
|
+
* carries thread_id + a stable failure_reason enum.
|
|
33406
|
+
*/
|
|
33407
|
+
CONCIERGE_MEMORY_READ_FAILED: "operator_concierge_memory_read_failed"
|
|
32175
33408
|
};
|
|
32176
33409
|
}
|
|
32177
33410
|
});
|
|
@@ -32184,13 +33417,20 @@ var init_operator_chat_types = __esm({
|
|
|
32184
33417
|
CONCIERGE_THREAD_KEY = "_fortress";
|
|
32185
33418
|
}
|
|
32186
33419
|
});
|
|
33420
|
+
function approxTokenLen(text) {
|
|
33421
|
+
return Math.ceil(text.length / 4);
|
|
33422
|
+
}
|
|
32187
33423
|
function makeEventId(prefix) {
|
|
32188
33424
|
return `${prefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
|
32189
33425
|
}
|
|
33426
|
+
function formatPriorTurnLine(turn) {
|
|
33427
|
+
const label = turn.role === "user" ? "OPERATOR" : "CONCIERGE";
|
|
33428
|
+
return `${label}: ${turn.content}`;
|
|
33429
|
+
}
|
|
32190
33430
|
function hashOf(input) {
|
|
32191
33431
|
return hashToString(sha256.sha256(stringToBytes(input)));
|
|
32192
33432
|
}
|
|
32193
|
-
var DEFAULT_CONCIERGE_MAX_TOKENS, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
|
|
33433
|
+
var DEFAULT_CONCIERGE_MAX_TOKENS, DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS, DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS, DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET, DEFAULT_CONCIERGE_SESSION_TTL_MS, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
|
|
32194
33434
|
var init_operator_chat_service = __esm({
|
|
32195
33435
|
"src/chat/operator-chat-service.ts"() {
|
|
32196
33436
|
init_hashing();
|
|
@@ -32198,6 +33438,10 @@ var init_operator_chat_service = __esm({
|
|
|
32198
33438
|
init_operator_chat_audit_events();
|
|
32199
33439
|
init_operator_chat_types();
|
|
32200
33440
|
DEFAULT_CONCIERGE_MAX_TOKENS = 512;
|
|
33441
|
+
DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
|
|
33442
|
+
DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
|
|
33443
|
+
DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
|
|
33444
|
+
DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
32201
33445
|
SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
|
|
32202
33446
|
1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
|
|
32203
33447
|
2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
|
|
@@ -32233,6 +33477,27 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32233
33477
|
contextProviders;
|
|
32234
33478
|
piiFilter;
|
|
32235
33479
|
conciergeMaxTokens;
|
|
33480
|
+
memory;
|
|
33481
|
+
historyWindowTurns;
|
|
33482
|
+
historyFreshnessMs;
|
|
33483
|
+
historyTokenBudget;
|
|
33484
|
+
sessionTtlMs;
|
|
33485
|
+
clock;
|
|
33486
|
+
/**
|
|
33487
|
+
* In-memory thread_id assigned to the active concierge session.
|
|
33488
|
+
* The first sendConcierge call after construction allocates a fresh
|
|
33489
|
+
* UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
|
|
33490
|
+
* folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
|
|
33491
|
+
*/
|
|
33492
|
+
activeMemoryThreadId;
|
|
33493
|
+
/**
|
|
33494
|
+
* Wall-clock ms of the most recent sendConcierge that touched the
|
|
33495
|
+
* active session thread. Drives the WP-V1.3-9 Tau-2 session-TTL
|
|
33496
|
+
* check: a fresh sendConcierge after `sessionTtlMs` of quiet
|
|
33497
|
+
* allocates a new thread_id even though the prior one is still
|
|
33498
|
+
* readable from the memory store.
|
|
33499
|
+
*/
|
|
33500
|
+
lastInteractionAt;
|
|
32236
33501
|
constructor(deps) {
|
|
32237
33502
|
this.store = deps.store;
|
|
32238
33503
|
this.auditLog = deps.auditLog;
|
|
@@ -32243,6 +33508,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32243
33508
|
}
|
|
32244
33509
|
if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
|
|
32245
33510
|
this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
|
|
33511
|
+
if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
|
|
33512
|
+
this.historyWindowTurns = deps.conciergeHistoryWindowTurns !== void 0 && deps.conciergeHistoryWindowTurns > 0 ? deps.conciergeHistoryWindowTurns : DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS;
|
|
33513
|
+
this.historyFreshnessMs = deps.conciergeHistoryFreshnessMs !== void 0 && deps.conciergeHistoryFreshnessMs > 0 ? deps.conciergeHistoryFreshnessMs : DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS;
|
|
33514
|
+
this.historyTokenBudget = deps.conciergeHistoryTokenBudget !== void 0 && deps.conciergeHistoryTokenBudget > 0 ? deps.conciergeHistoryTokenBudget : DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET;
|
|
33515
|
+
this.sessionTtlMs = deps.conciergeSessionTtlMs !== void 0 && deps.conciergeSessionTtlMs > 0 ? deps.conciergeSessionTtlMs : DEFAULT_CONCIERGE_SESSION_TTL_MS;
|
|
33516
|
+
this.clock = deps.conciergeClock ?? (() => Date.now());
|
|
32246
33517
|
}
|
|
32247
33518
|
// ── Concierge ─────────────────────────────────────────────────────────
|
|
32248
33519
|
/**
|
|
@@ -32261,6 +33532,10 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32261
33532
|
throw new Error("concierge query must not be empty");
|
|
32262
33533
|
}
|
|
32263
33534
|
const filterResult = this.piiFilter ? this.piiFilter.filter(trimmed) : { filtered: trimmed, redactions: 0 };
|
|
33535
|
+
const nowMs = this.clock();
|
|
33536
|
+
if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
|
|
33537
|
+
this.activeMemoryThreadId = void 0;
|
|
33538
|
+
}
|
|
32264
33539
|
const operatorMessage = {
|
|
32265
33540
|
message_id: crypto.randomUUID(),
|
|
32266
33541
|
surface: "concierge",
|
|
@@ -32273,6 +33548,30 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32273
33548
|
CONCIERGE_THREAD_KEY,
|
|
32274
33549
|
operatorMessage
|
|
32275
33550
|
);
|
|
33551
|
+
let priorTurns = [];
|
|
33552
|
+
let memoryReadFailureReason = null;
|
|
33553
|
+
let activeThreadIdForRound;
|
|
33554
|
+
if (this.memory) {
|
|
33555
|
+
activeThreadIdForRound = this.ensureActiveMemoryThread();
|
|
33556
|
+
const result = await this.memory.readThreadStrict(activeThreadIdForRound).catch(() => ({ ok: false, reason: "io_failed" }));
|
|
33557
|
+
if (result.ok) {
|
|
33558
|
+
const cutoff = nowMs - this.historyFreshnessMs;
|
|
33559
|
+
const fresh = result.turns.filter((t) => {
|
|
33560
|
+
const ts = Date.parse(t.created_at);
|
|
33561
|
+
return Number.isFinite(ts) && ts >= cutoff;
|
|
33562
|
+
});
|
|
33563
|
+
const recent = fresh.length > this.historyWindowTurns ? fresh.slice(fresh.length - this.historyWindowTurns) : fresh;
|
|
33564
|
+
priorTurns = recent;
|
|
33565
|
+
} else {
|
|
33566
|
+
memoryReadFailureReason = result.reason;
|
|
33567
|
+
this.emitMemoryReadFailed(activeThreadIdForRound, result.reason);
|
|
33568
|
+
}
|
|
33569
|
+
}
|
|
33570
|
+
if (this.memory) {
|
|
33571
|
+
const threadId = this.ensureActiveMemoryThread();
|
|
33572
|
+
await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
|
|
33573
|
+
});
|
|
33574
|
+
}
|
|
32276
33575
|
const start = Date.now();
|
|
32277
33576
|
let conciergeBody;
|
|
32278
33577
|
let servedBy = "disabled";
|
|
@@ -32289,7 +33588,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32289
33588
|
conciergeBody = "Concierge unavailable. The chosen substrate does not support summarization. Pick a different substrate in the Policy center.";
|
|
32290
33589
|
outcome = "substrate_disabled";
|
|
32291
33590
|
} else {
|
|
32292
|
-
const context = await this.assembleConciergeContext();
|
|
33591
|
+
const context = await this.assembleConciergeContext(priorTurns);
|
|
32293
33592
|
const response = await this.substrateSelector.invokeSummarize(
|
|
32294
33593
|
"concierge",
|
|
32295
33594
|
{
|
|
@@ -32328,6 +33627,15 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32328
33627
|
CONCIERGE_THREAD_KEY,
|
|
32329
33628
|
responseMessage
|
|
32330
33629
|
);
|
|
33630
|
+
let assistantTurnId;
|
|
33631
|
+
if (this.memory) {
|
|
33632
|
+
const threadId = this.ensureActiveMemoryThread();
|
|
33633
|
+
const persisted = await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => void 0);
|
|
33634
|
+
if (persisted) assistantTurnId = persisted.turn_id;
|
|
33635
|
+
}
|
|
33636
|
+
if (this.memory && activeThreadIdForRound) {
|
|
33637
|
+
this.lastInteractionAt = nowMs;
|
|
33638
|
+
}
|
|
32331
33639
|
const payload = {
|
|
32332
33640
|
version: "1.2",
|
|
32333
33641
|
event_id: makeEventId("conc"),
|
|
@@ -32339,7 +33647,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32339
33647
|
response_hash: outcome === "ok" ? hashOf(conciergeBody) : null,
|
|
32340
33648
|
substrate: servedBy,
|
|
32341
33649
|
latency_ms: latencyMs,
|
|
32342
|
-
outcome
|
|
33650
|
+
outcome,
|
|
33651
|
+
...activeThreadIdForRound !== void 0 ? { thread_id: activeThreadIdForRound } : {},
|
|
33652
|
+
...assistantTurnId !== void 0 ? { turn_index: assistantTurnId } : {},
|
|
33653
|
+
...this.memory ? {
|
|
33654
|
+
prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
|
|
33655
|
+
} : {}
|
|
32343
33656
|
};
|
|
32344
33657
|
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
|
|
32345
33658
|
return {
|
|
@@ -32349,6 +33662,25 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32349
33662
|
outcome
|
|
32350
33663
|
};
|
|
32351
33664
|
}
|
|
33665
|
+
/**
|
|
33666
|
+
* Emit the WP-V1.3-9 Tau-2 graceful-degradation audit event. Pulled
|
|
33667
|
+
* out of `sendConcierge` so the read-fold path stays readable. Emits
|
|
33668
|
+
* with `result: "failure"` since the concierge fell back to
|
|
33669
|
+
* single-turn mode for this round-trip.
|
|
33670
|
+
*/
|
|
33671
|
+
emitMemoryReadFailed(threadId, reason) {
|
|
33672
|
+
const payload = {
|
|
33673
|
+
version: "1.2",
|
|
33674
|
+
event_id: makeEventId("conc-memfail"),
|
|
33675
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33676
|
+
identity_id: this.identityId,
|
|
33677
|
+
kind: "operator_concierge_memory_read_failed",
|
|
33678
|
+
surface: "concierge",
|
|
33679
|
+
thread_id: threadId,
|
|
33680
|
+
failure_reason: reason
|
|
33681
|
+
};
|
|
33682
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED, payload, "failure");
|
|
33683
|
+
}
|
|
32352
33684
|
/**
|
|
32353
33685
|
* Read the persisted concierge thread, oldest message first. Returns
|
|
32354
33686
|
* an empty array when no thread exists yet.
|
|
@@ -32360,6 +33692,105 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32360
33692
|
);
|
|
32361
33693
|
return thread ? thread.messages : [];
|
|
32362
33694
|
}
|
|
33695
|
+
// ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
|
|
33696
|
+
/**
|
|
33697
|
+
* Whether the foundation memory store is wired. Routes use this to
|
|
33698
|
+
* 503 cleanly when called against an unwired service.
|
|
33699
|
+
*/
|
|
33700
|
+
hasConciergeMemory() {
|
|
33701
|
+
return this.memory !== void 0;
|
|
33702
|
+
}
|
|
33703
|
+
/**
|
|
33704
|
+
* List concierge memory threads, newest-first. Emits the
|
|
33705
|
+
* `operator_concierge_history_read` audit event with `thread_id="*"`.
|
|
33706
|
+
*/
|
|
33707
|
+
async listConciergeMemoryThreads(opts) {
|
|
33708
|
+
if (!this.memory) {
|
|
33709
|
+
throw new Error("concierge memory store not configured");
|
|
33710
|
+
}
|
|
33711
|
+
const summaries = await this.memory.listThreads(opts);
|
|
33712
|
+
const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
|
|
33713
|
+
const payload = {
|
|
33714
|
+
version: "1.2",
|
|
33715
|
+
event_id: makeEventId("conc-hist"),
|
|
33716
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33717
|
+
identity_id: this.identityId,
|
|
33718
|
+
kind: "operator_concierge_history_read",
|
|
33719
|
+
surface: "concierge",
|
|
33720
|
+
thread_id: "*",
|
|
33721
|
+
turn_count: totalTurns
|
|
33722
|
+
};
|
|
33723
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
|
|
33724
|
+
return summaries;
|
|
33725
|
+
}
|
|
33726
|
+
/**
|
|
33727
|
+
* Read a concierge memory thread, oldest turn first. Emits the
|
|
33728
|
+
* `operator_concierge_history_read` audit event with the named
|
|
33729
|
+
* thread_id and the count of turns surfaced.
|
|
33730
|
+
*/
|
|
33731
|
+
async readConciergeMemoryThread(threadId, opts) {
|
|
33732
|
+
if (!this.memory) {
|
|
33733
|
+
throw new Error("concierge memory store not configured");
|
|
33734
|
+
}
|
|
33735
|
+
const turns = await this.memory.readThread(threadId, opts);
|
|
33736
|
+
const payload = {
|
|
33737
|
+
version: "1.2",
|
|
33738
|
+
event_id: makeEventId("conc-hist"),
|
|
33739
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33740
|
+
identity_id: this.identityId,
|
|
33741
|
+
kind: "operator_concierge_history_read",
|
|
33742
|
+
surface: "concierge",
|
|
33743
|
+
thread_id: threadId,
|
|
33744
|
+
turn_count: turns.length
|
|
33745
|
+
};
|
|
33746
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
|
|
33747
|
+
return turns;
|
|
33748
|
+
}
|
|
33749
|
+
/**
|
|
33750
|
+
* Delete a concierge memory thread. Emits
|
|
33751
|
+
* `operator_concierge_thread_deleted` only when a bundle was actually
|
|
33752
|
+
* removed; absent threads return false without an audit event.
|
|
33753
|
+
*/
|
|
33754
|
+
async deleteConciergeMemoryThread(threadId) {
|
|
33755
|
+
if (!this.memory) {
|
|
33756
|
+
throw new Error("concierge memory store not configured");
|
|
33757
|
+
}
|
|
33758
|
+
const turnsBefore = await this.memory.readThread(threadId);
|
|
33759
|
+
if (turnsBefore.length === 0) {
|
|
33760
|
+
return await this.memory.deleteThread(threadId);
|
|
33761
|
+
}
|
|
33762
|
+
const removed = await this.memory.deleteThread(threadId);
|
|
33763
|
+
if (!removed) return false;
|
|
33764
|
+
if (this.activeMemoryThreadId === threadId) {
|
|
33765
|
+
this.activeMemoryThreadId = void 0;
|
|
33766
|
+
}
|
|
33767
|
+
const payload = {
|
|
33768
|
+
version: "1.2",
|
|
33769
|
+
event_id: makeEventId("conc-del"),
|
|
33770
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33771
|
+
identity_id: this.identityId,
|
|
33772
|
+
kind: "operator_concierge_thread_deleted",
|
|
33773
|
+
surface: "concierge",
|
|
33774
|
+
thread_id: threadId,
|
|
33775
|
+
turn_count: turnsBefore.length
|
|
33776
|
+
};
|
|
33777
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
|
|
33778
|
+
return true;
|
|
33779
|
+
}
|
|
33780
|
+
/**
|
|
33781
|
+
* Reset the active session memory thread. Subsequent sendConcierge
|
|
33782
|
+
* calls allocate a fresh thread_id. Surfaced for tests + future "new
|
|
33783
|
+
* conversation" affordance; not currently called by the dashboard.
|
|
33784
|
+
*/
|
|
33785
|
+
resetConciergeMemoryThread() {
|
|
33786
|
+
this.activeMemoryThreadId = void 0;
|
|
33787
|
+
}
|
|
33788
|
+
ensureActiveMemoryThread() {
|
|
33789
|
+
if (!this.activeMemoryThreadId) {
|
|
33790
|
+
this.activeMemoryThreadId = crypto.randomUUID();
|
|
33791
|
+
}
|
|
33792
|
+
return this.activeMemoryThreadId;
|
|
33793
|
+
}
|
|
32363
33794
|
/**
|
|
32364
33795
|
* Stitch fortress state into a single context blob the substrate
|
|
32365
33796
|
* folds into its summarization prompt.
|
|
@@ -32372,6 +33803,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32372
33803
|
* ## Sanctuary reference
|
|
32373
33804
|
* <static domain reference block>
|
|
32374
33805
|
*
|
|
33806
|
+
* ## Prior conversation ← WP-V1.3-9 Tau-2, when present
|
|
33807
|
+
* OPERATOR: ...
|
|
33808
|
+
* CONCIERGE: ...
|
|
33809
|
+
* ---
|
|
33810
|
+
*
|
|
32375
33811
|
* ## Recent activity
|
|
32376
33812
|
* <recentActivity output>
|
|
32377
33813
|
*
|
|
@@ -32381,37 +33817,69 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32381
33817
|
* ## Open inbox
|
|
32382
33818
|
* <openInbox output>
|
|
32383
33819
|
* ```
|
|
33820
|
+
*
|
|
33821
|
+
* The substrate selector ships a `context: string` shape (not a
|
|
33822
|
+
* messages array), so multi-turn coherence is folded as a structured
|
|
33823
|
+
* prior-conversation section with explicit OPERATOR / CONCIERGE
|
|
33824
|
+
* boundaries. Coordinator-CTO guidance: prefer messages-array shape
|
|
33825
|
+
* if available; the v1.2 selector does not expose one, so structured
|
|
33826
|
+
* serialization is the canonical path for v1.3.
|
|
32384
33827
|
*/
|
|
32385
|
-
async assembleConciergeContext() {
|
|
33828
|
+
async assembleConciergeContext(priorTurns = []) {
|
|
32386
33829
|
const ref = `## Sanctuary reference
|
|
32387
33830
|
${SANCTUARY_DOMAIN_REFERENCE}`;
|
|
33831
|
+
const priorSection = this.formatPriorTurnsSection(priorTurns);
|
|
32388
33832
|
if (!this.contextProviders) {
|
|
32389
|
-
return
|
|
32390
|
-
|
|
32391
|
-
|
|
32392
|
-
(no providers wired)
|
|
32393
|
-
|
|
32394
|
-
##
|
|
32395
|
-
(
|
|
32396
|
-
|
|
32397
|
-
## Open inbox
|
|
32398
|
-
(no providers wired)`;
|
|
33833
|
+
return [
|
|
33834
|
+
ref,
|
|
33835
|
+
...priorSection ? [priorSection] : [],
|
|
33836
|
+
"## Recent activity\n(no providers wired)",
|
|
33837
|
+
"## Wrapped agents\n(no providers wired)",
|
|
33838
|
+
"## Open inbox\n(no providers wired)"
|
|
33839
|
+
].join("\n\n");
|
|
32399
33840
|
}
|
|
32400
33841
|
const [activity, agents, inbox] = await Promise.all([
|
|
32401
33842
|
this.contextProviders.recentActivity(),
|
|
32402
33843
|
this.contextProviders.agentInventory(),
|
|
32403
33844
|
this.contextProviders.openInbox()
|
|
32404
33845
|
]);
|
|
32405
|
-
return
|
|
32406
|
-
|
|
32407
|
-
|
|
32408
|
-
|
|
32409
|
-
|
|
32410
|
-
|
|
32411
|
-
${agents}
|
|
32412
|
-
|
|
32413
|
-
|
|
32414
|
-
|
|
33846
|
+
return [
|
|
33847
|
+
ref,
|
|
33848
|
+
...priorSection ? [priorSection] : [],
|
|
33849
|
+
`## Recent activity
|
|
33850
|
+
${activity}`,
|
|
33851
|
+
`## Wrapped agents
|
|
33852
|
+
${agents}`,
|
|
33853
|
+
`## Open inbox
|
|
33854
|
+
${inbox}`
|
|
33855
|
+
].join("\n\n");
|
|
33856
|
+
}
|
|
33857
|
+
/**
|
|
33858
|
+
* Render the prior-conversation section with token-budget enforcement
|
|
33859
|
+
* (WP-V1.3-9 Tau-2). Drops oldest turns first when the rendered
|
|
33860
|
+
* section exceeds `historyTokenBudget`. Returns an empty string when
|
|
33861
|
+
* the input is empty or when the budget excludes every turn.
|
|
33862
|
+
*/
|
|
33863
|
+
formatPriorTurnsSection(turns) {
|
|
33864
|
+
if (turns.length === 0) return "";
|
|
33865
|
+
const HEADER = "## Prior conversation";
|
|
33866
|
+
const lines = turns.map(formatPriorTurnLine);
|
|
33867
|
+
const headerTokens = approxTokenLen(`${HEADER}
|
|
33868
|
+
`);
|
|
33869
|
+
const sepTokens = approxTokenLen("\n");
|
|
33870
|
+
let runningTokens = headerTokens;
|
|
33871
|
+
let runningLines = [];
|
|
33872
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
33873
|
+
const line = lines[i];
|
|
33874
|
+
const tokens = approxTokenLen(line) + (runningLines.length > 0 ? sepTokens : 0);
|
|
33875
|
+
if (runningTokens + tokens > this.historyTokenBudget) break;
|
|
33876
|
+
runningTokens += tokens;
|
|
33877
|
+
runningLines.push(line);
|
|
33878
|
+
}
|
|
33879
|
+
if (runningLines.length === 0) return "";
|
|
33880
|
+
runningLines = runningLines.reverse();
|
|
33881
|
+
return `${HEADER}
|
|
33882
|
+
${runningLines.join("\n")}`;
|
|
32415
33883
|
}
|
|
32416
33884
|
// ── audit helpers ────────────────────────────────────────────────────
|
|
32417
33885
|
emit(operation, payload, result) {
|
|
@@ -32526,11 +33994,309 @@ var init_operator_chat_store = __esm({
|
|
|
32526
33994
|
}
|
|
32527
33995
|
});
|
|
32528
33996
|
|
|
33997
|
+
// src/chat/concierge-memory-store.ts
|
|
33998
|
+
function bundleKey(threadId) {
|
|
33999
|
+
return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
|
|
34000
|
+
}
|
|
34001
|
+
function stripKeyPrefix(key) {
|
|
34002
|
+
if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
|
|
34003
|
+
return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
|
|
34004
|
+
}
|
|
34005
|
+
function lastTurnId(bundle) {
|
|
34006
|
+
let max = 0;
|
|
34007
|
+
for (const t of bundle.turns) {
|
|
34008
|
+
if (t.turn_id > max) max = t.turn_id;
|
|
34009
|
+
}
|
|
34010
|
+
return max;
|
|
34011
|
+
}
|
|
34012
|
+
var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO2, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES2, ConciergeMemoryStore;
|
|
34013
|
+
var init_concierge_memory_store = __esm({
|
|
34014
|
+
"src/chat/concierge-memory-store.ts"() {
|
|
34015
|
+
init_encryption();
|
|
34016
|
+
init_key_derivation();
|
|
34017
|
+
init_encoding();
|
|
34018
|
+
CONCIERGE_MEMORY_NAMESPACE = "_chat";
|
|
34019
|
+
CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
|
|
34020
|
+
HKDF_INFO2 = "concierge-memory-store-v1";
|
|
34021
|
+
DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
|
|
34022
|
+
MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
|
|
34023
|
+
ConciergeMemoryStore = class {
|
|
34024
|
+
storage;
|
|
34025
|
+
encryptionKey;
|
|
34026
|
+
fortressId;
|
|
34027
|
+
retentionDays;
|
|
34028
|
+
locks;
|
|
34029
|
+
constructor(opts) {
|
|
34030
|
+
this.storage = opts.storage;
|
|
34031
|
+
this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
|
|
34032
|
+
this.fortressId = opts.fortressId;
|
|
34033
|
+
this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
|
|
34034
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
34035
|
+
}
|
|
34036
|
+
/**
|
|
34037
|
+
* Append a turn to the named thread, creating the bundle if no record
|
|
34038
|
+
* exists. Returns the persisted turn (with assigned turn_id +
|
|
34039
|
+
* retention_until). Per-thread serialisation guarantees turn_id
|
|
34040
|
+
* monotonicity even under concurrent callers.
|
|
34041
|
+
*/
|
|
34042
|
+
async appendTurn(threadId, role, content) {
|
|
34043
|
+
return this.withLock(threadId, async () => {
|
|
34044
|
+
const bundle = await this.loadBundle(threadId) ?? null;
|
|
34045
|
+
const now = /* @__PURE__ */ new Date();
|
|
34046
|
+
const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
|
|
34047
|
+
const retentionUntil = new Date(now.getTime() + retentionMs);
|
|
34048
|
+
const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
|
|
34049
|
+
const turn = {
|
|
34050
|
+
thread_id: threadId,
|
|
34051
|
+
fortress_id: this.fortressId,
|
|
34052
|
+
turn_id: nextTurnId,
|
|
34053
|
+
role,
|
|
34054
|
+
content,
|
|
34055
|
+
created_at: now.toISOString(),
|
|
34056
|
+
retention_until: retentionUntil.toISOString()
|
|
34057
|
+
};
|
|
34058
|
+
const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
|
|
34059
|
+
version: 1,
|
|
34060
|
+
thread_id: threadId,
|
|
34061
|
+
fortress_id: this.fortressId,
|
|
34062
|
+
created_at: now.toISOString(),
|
|
34063
|
+
turns: [turn]
|
|
34064
|
+
};
|
|
34065
|
+
await this.saveBundle(next);
|
|
34066
|
+
return turn;
|
|
34067
|
+
});
|
|
34068
|
+
}
|
|
34069
|
+
/**
|
|
34070
|
+
* Read turns from a thread, oldest-first. Returns an empty array if
|
|
34071
|
+
* the thread does not exist or its bundle is corrupt. Does not emit
|
|
34072
|
+
* audit events; the caller (HTTP route handler) owns audit semantics.
|
|
34073
|
+
*/
|
|
34074
|
+
async readThread(threadId, opts) {
|
|
34075
|
+
const bundle = await this.loadBundle(threadId);
|
|
34076
|
+
if (!bundle) return [];
|
|
34077
|
+
let turns = bundle.turns;
|
|
34078
|
+
if (opts?.sinceTurnId !== void 0) {
|
|
34079
|
+
const cutoff = opts.sinceTurnId;
|
|
34080
|
+
turns = turns.filter((t) => t.turn_id > cutoff);
|
|
34081
|
+
}
|
|
34082
|
+
if (opts?.limit !== void 0) {
|
|
34083
|
+
turns = turns.slice(0, opts.limit);
|
|
34084
|
+
}
|
|
34085
|
+
return turns;
|
|
34086
|
+
}
|
|
34087
|
+
/**
|
|
34088
|
+
* Read turns with explicit failure surfacing (WP-V1.3-9 Tau-2). Where
|
|
34089
|
+
* `readThread` collapses every failure mode to an empty array, this
|
|
34090
|
+
* variant returns a discriminated result so the multi-turn fold path
|
|
34091
|
+
* can degrade cleanly + emit `operator_concierge_memory_read_failed`
|
|
34092
|
+
* with a concrete cause.
|
|
34093
|
+
*
|
|
34094
|
+
* - No bundle on disk → `{ ok: true, turns: [] }` (a fresh thread).
|
|
34095
|
+
* - Bundle present, decode + decrypt + schema check pass → ok with turns.
|
|
34096
|
+
* - Bundle present, oversize → `{ ok: false, reason: "oversize_bundle" }`.
|
|
34097
|
+
* - Bundle present, decryption fails → `{ ok: false, reason: "decrypt_failed" }`.
|
|
34098
|
+
* - Bundle present, schema mismatch (version / thread_id) → `schema_mismatch`.
|
|
34099
|
+
* - Storage IO error → `io_failed`.
|
|
34100
|
+
*/
|
|
34101
|
+
async readThreadStrict(threadId, opts) {
|
|
34102
|
+
const key = bundleKey(threadId);
|
|
34103
|
+
let raw;
|
|
34104
|
+
try {
|
|
34105
|
+
raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
|
|
34106
|
+
} catch {
|
|
34107
|
+
return { ok: false, reason: "io_failed" };
|
|
34108
|
+
}
|
|
34109
|
+
if (!raw) return { ok: true, turns: [] };
|
|
34110
|
+
if (raw.length > MAX_BUNDLE_BYTES2) {
|
|
34111
|
+
return { ok: false, reason: "oversize_bundle" };
|
|
34112
|
+
}
|
|
34113
|
+
let envelope;
|
|
34114
|
+
try {
|
|
34115
|
+
envelope = JSON.parse(bytesToString(raw));
|
|
34116
|
+
} catch {
|
|
34117
|
+
return { ok: false, reason: "schema_mismatch" };
|
|
34118
|
+
}
|
|
34119
|
+
let plaintext;
|
|
34120
|
+
try {
|
|
34121
|
+
const aad = stringToBytes(threadId);
|
|
34122
|
+
plaintext = decrypt(envelope, this.encryptionKey, aad);
|
|
34123
|
+
} catch {
|
|
34124
|
+
return { ok: false, reason: "decrypt_failed" };
|
|
34125
|
+
}
|
|
34126
|
+
let parsed;
|
|
34127
|
+
try {
|
|
34128
|
+
parsed = JSON.parse(bytesToString(plaintext));
|
|
34129
|
+
} catch {
|
|
34130
|
+
return { ok: false, reason: "schema_mismatch" };
|
|
34131
|
+
}
|
|
34132
|
+
if (parsed.version !== 1) return { ok: false, reason: "schema_mismatch" };
|
|
34133
|
+
if (parsed.thread_id !== threadId) {
|
|
34134
|
+
return { ok: false, reason: "schema_mismatch" };
|
|
34135
|
+
}
|
|
34136
|
+
let turns = parsed.turns;
|
|
34137
|
+
if (opts?.sinceTurnId !== void 0) {
|
|
34138
|
+
const cutoff = opts.sinceTurnId;
|
|
34139
|
+
turns = turns.filter((t) => t.turn_id > cutoff);
|
|
34140
|
+
}
|
|
34141
|
+
if (opts?.limit !== void 0) {
|
|
34142
|
+
turns = turns.slice(0, opts.limit);
|
|
34143
|
+
}
|
|
34144
|
+
return { ok: true, turns };
|
|
34145
|
+
}
|
|
34146
|
+
/**
|
|
34147
|
+
* Enumerate concierge threads in this fortress with summary metadata.
|
|
34148
|
+
* Sorted newest-first by last_turn_at.
|
|
34149
|
+
*/
|
|
34150
|
+
async listThreads(opts) {
|
|
34151
|
+
const entries = await this.storage.list(
|
|
34152
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34153
|
+
CONCIERGE_MEMORY_KEY_PREFIX
|
|
34154
|
+
);
|
|
34155
|
+
const summaries = [];
|
|
34156
|
+
for (const meta of entries) {
|
|
34157
|
+
const threadId = stripKeyPrefix(meta.key);
|
|
34158
|
+
if (threadId === null) continue;
|
|
34159
|
+
const bundle = await this.loadBundle(threadId);
|
|
34160
|
+
if (!bundle || bundle.turns.length === 0) continue;
|
|
34161
|
+
const last = bundle.turns[bundle.turns.length - 1];
|
|
34162
|
+
summaries.push({
|
|
34163
|
+
thread_id: bundle.thread_id,
|
|
34164
|
+
created_at: bundle.created_at,
|
|
34165
|
+
last_turn_at: last ? last.created_at : bundle.created_at,
|
|
34166
|
+
turn_count: bundle.turns.length
|
|
34167
|
+
});
|
|
34168
|
+
}
|
|
34169
|
+
summaries.sort(
|
|
34170
|
+
(a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
|
|
34171
|
+
);
|
|
34172
|
+
if (opts?.limit !== void 0) {
|
|
34173
|
+
return summaries.slice(0, opts.limit);
|
|
34174
|
+
}
|
|
34175
|
+
return summaries;
|
|
34176
|
+
}
|
|
34177
|
+
/**
|
|
34178
|
+
* Delete a thread's bundle. Returns true if the bundle existed and
|
|
34179
|
+
* was removed; false if no bundle was present. Audit emission is the
|
|
34180
|
+
* caller's responsibility.
|
|
34181
|
+
*/
|
|
34182
|
+
async deleteThread(threadId) {
|
|
34183
|
+
const key = bundleKey(threadId);
|
|
34184
|
+
return this.withLock(threadId, async () => {
|
|
34185
|
+
const existed = await this.storage.exists(
|
|
34186
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34187
|
+
key
|
|
34188
|
+
);
|
|
34189
|
+
if (!existed) return false;
|
|
34190
|
+
try {
|
|
34191
|
+
await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
|
|
34192
|
+
} catch {
|
|
34193
|
+
return false;
|
|
34194
|
+
}
|
|
34195
|
+
return true;
|
|
34196
|
+
});
|
|
34197
|
+
}
|
|
34198
|
+
/**
|
|
34199
|
+
* Drop expired turns across all threads. Threads emptied by pruning
|
|
34200
|
+
* are removed entirely. Returns the count of turns pruned.
|
|
34201
|
+
*/
|
|
34202
|
+
async pruneExpired(now) {
|
|
34203
|
+
const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
34204
|
+
const entries = await this.storage.list(
|
|
34205
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34206
|
+
CONCIERGE_MEMORY_KEY_PREFIX
|
|
34207
|
+
);
|
|
34208
|
+
let pruned = 0;
|
|
34209
|
+
for (const meta of entries) {
|
|
34210
|
+
const threadId = stripKeyPrefix(meta.key);
|
|
34211
|
+
if (threadId === null) continue;
|
|
34212
|
+
pruned += await this.withLock(threadId, async () => {
|
|
34213
|
+
const bundle = await this.loadBundle(threadId);
|
|
34214
|
+
if (!bundle) return 0;
|
|
34215
|
+
const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
|
|
34216
|
+
const dropped = bundle.turns.length - kept.length;
|
|
34217
|
+
if (dropped === 0) return 0;
|
|
34218
|
+
if (kept.length === 0) {
|
|
34219
|
+
await this.storage.delete(
|
|
34220
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34221
|
+
bundleKey(threadId)
|
|
34222
|
+
);
|
|
34223
|
+
} else {
|
|
34224
|
+
await this.saveBundle({ ...bundle, turns: kept });
|
|
34225
|
+
}
|
|
34226
|
+
return dropped;
|
|
34227
|
+
});
|
|
34228
|
+
}
|
|
34229
|
+
return { pruned };
|
|
34230
|
+
}
|
|
34231
|
+
// ── internals ────────────────────────────────────────────────────────
|
|
34232
|
+
async loadBundle(threadId) {
|
|
34233
|
+
const key = bundleKey(threadId);
|
|
34234
|
+
let raw;
|
|
34235
|
+
try {
|
|
34236
|
+
raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
|
|
34237
|
+
} catch {
|
|
34238
|
+
return null;
|
|
34239
|
+
}
|
|
34240
|
+
if (!raw) return null;
|
|
34241
|
+
if (raw.length > MAX_BUNDLE_BYTES2) return null;
|
|
34242
|
+
try {
|
|
34243
|
+
const envelope = JSON.parse(bytesToString(raw));
|
|
34244
|
+
const aad = stringToBytes(threadId);
|
|
34245
|
+
const plaintext = decrypt(envelope, this.encryptionKey, aad);
|
|
34246
|
+
const parsed = JSON.parse(
|
|
34247
|
+
bytesToString(plaintext)
|
|
34248
|
+
);
|
|
34249
|
+
if (parsed.version !== 1) return null;
|
|
34250
|
+
if (parsed.thread_id !== threadId) return null;
|
|
34251
|
+
return parsed;
|
|
34252
|
+
} catch {
|
|
34253
|
+
return null;
|
|
34254
|
+
}
|
|
34255
|
+
}
|
|
34256
|
+
async saveBundle(bundle) {
|
|
34257
|
+
const key = bundleKey(bundle.thread_id);
|
|
34258
|
+
const aad = stringToBytes(bundle.thread_id);
|
|
34259
|
+
const plaintext = stringToBytes(JSON.stringify(bundle));
|
|
34260
|
+
const envelope = encrypt(plaintext, this.encryptionKey, aad);
|
|
34261
|
+
await this.storage.write(
|
|
34262
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34263
|
+
key,
|
|
34264
|
+
stringToBytes(JSON.stringify(envelope))
|
|
34265
|
+
);
|
|
34266
|
+
}
|
|
34267
|
+
/**
|
|
34268
|
+
* Run `task` while holding the per-thread async lock. Lock is released
|
|
34269
|
+
* once the task settles (success or failure). Generic helper so
|
|
34270
|
+
* appendTurn / deleteThread / pruneExpired share serialisation.
|
|
34271
|
+
*/
|
|
34272
|
+
async withLock(threadId, task) {
|
|
34273
|
+
const previous = this.locks.get(threadId) ?? Promise.resolve();
|
|
34274
|
+
let release;
|
|
34275
|
+
const next = new Promise((resolve8) => {
|
|
34276
|
+
release = resolve8;
|
|
34277
|
+
});
|
|
34278
|
+
const chained = previous.then(() => next);
|
|
34279
|
+
this.locks.set(threadId, chained);
|
|
34280
|
+
try {
|
|
34281
|
+
await previous;
|
|
34282
|
+
return await task();
|
|
34283
|
+
} finally {
|
|
34284
|
+
release();
|
|
34285
|
+
if (this.locks.get(threadId) === chained) {
|
|
34286
|
+
this.locks.delete(threadId);
|
|
34287
|
+
}
|
|
34288
|
+
}
|
|
34289
|
+
}
|
|
34290
|
+
};
|
|
34291
|
+
}
|
|
34292
|
+
});
|
|
34293
|
+
|
|
32529
34294
|
// src/chat/operator-chat-index.ts
|
|
32530
34295
|
var init_operator_chat_index = __esm({
|
|
32531
34296
|
"src/chat/operator-chat-index.ts"() {
|
|
32532
34297
|
init_operator_chat_service();
|
|
32533
34298
|
init_operator_chat_store();
|
|
34299
|
+
init_concierge_memory_store();
|
|
32534
34300
|
init_operator_chat_audit_events();
|
|
32535
34301
|
init_operator_chat_types();
|
|
32536
34302
|
}
|
|
@@ -32544,6 +34310,14 @@ function buildV11Bindings(inputs) {
|
|
|
32544
34310
|
let operatorChatService;
|
|
32545
34311
|
if (inputs.storage && inputs.masterKey) {
|
|
32546
34312
|
const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
|
|
34313
|
+
const conciergeMemory = new ConciergeMemoryStore({
|
|
34314
|
+
storage: inputs.storage,
|
|
34315
|
+
masterKey: inputs.masterKey,
|
|
34316
|
+
fortressId: inputs.fortressId,
|
|
34317
|
+
...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
|
|
34318
|
+
});
|
|
34319
|
+
void conciergeMemory.pruneExpired().catch(() => {
|
|
34320
|
+
});
|
|
32547
34321
|
operatorChatService = new OperatorChatService({
|
|
32548
34322
|
store: chatStore,
|
|
32549
34323
|
auditLog: inputs.auditLog,
|
|
@@ -32554,7 +34328,8 @@ function buildV11Bindings(inputs) {
|
|
|
32554
34328
|
identityId: inputs.identityId,
|
|
32555
34329
|
registry
|
|
32556
34330
|
}),
|
|
32557
|
-
conciergePiiFilter: buildConciergePiiFilter()
|
|
34331
|
+
conciergePiiFilter: buildConciergePiiFilter(),
|
|
34332
|
+
conciergeMemory
|
|
32558
34333
|
});
|
|
32559
34334
|
}
|
|
32560
34335
|
const hubService = new HubService({
|
|
@@ -32789,7 +34564,7 @@ var init_defaults = __esm({
|
|
|
32789
34564
|
});
|
|
32790
34565
|
|
|
32791
34566
|
// src/intelligence/policy-store.ts
|
|
32792
|
-
var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY,
|
|
34567
|
+
var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO3, IntelligenceConfigStore;
|
|
32793
34568
|
var init_policy_store = __esm({
|
|
32794
34569
|
"src/intelligence/policy-store.ts"() {
|
|
32795
34570
|
init_encryption();
|
|
@@ -32798,13 +34573,13 @@ var init_policy_store = __esm({
|
|
|
32798
34573
|
init_defaults();
|
|
32799
34574
|
INTELLIGENCE_NAMESPACE = "_intelligence";
|
|
32800
34575
|
SUBSTRATE_CONFIG_KEY = "substrate-config";
|
|
32801
|
-
|
|
34576
|
+
HKDF_INFO3 = "intelligence-substrate-config";
|
|
32802
34577
|
IntelligenceConfigStore = class {
|
|
32803
34578
|
storage;
|
|
32804
34579
|
encryptionKey;
|
|
32805
34580
|
constructor(storage, masterKey) {
|
|
32806
34581
|
this.storage = storage;
|
|
32807
|
-
this.encryptionKey = derivePurposeKey(masterKey,
|
|
34582
|
+
this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
|
|
32808
34583
|
}
|
|
32809
34584
|
/**
|
|
32810
34585
|
* Load the operator's substrate config from disk. Returns the config
|
|
@@ -34826,7 +36601,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
|
|
|
34826
36601
|
);
|
|
34827
36602
|
}
|
|
34828
36603
|
}
|
|
34829
|
-
const
|
|
36604
|
+
const reputationBundleFailed = reputation?.bundle_signature_valid === false;
|
|
36605
|
+
const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
|
|
36606
|
+
const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
|
|
34830
36607
|
const identityFailed = identity ? !identity.signature_valid : false;
|
|
34831
36608
|
const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
|
|
34832
36609
|
const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
|
|
@@ -34835,6 +36612,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
|
|
|
34835
36612
|
`${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
|
|
34836
36613
|
);
|
|
34837
36614
|
}
|
|
36615
|
+
let detailedFailureClass;
|
|
36616
|
+
if (identityFailed) {
|
|
36617
|
+
detailedFailureClass = "identity_signature_invalid";
|
|
36618
|
+
} else if (reputationBundleFailed) {
|
|
36619
|
+
detailedFailureClass = "reputation_bundle_signature_invalid";
|
|
36620
|
+
} else if (reputationAttestationFailed) {
|
|
36621
|
+
detailedFailureClass = "reputation_attestation_signature_invalid";
|
|
36622
|
+
} else if (unverifiableFailed) {
|
|
36623
|
+
detailedFailureClass = "reputation_unverifiable_attestations";
|
|
36624
|
+
}
|
|
34838
36625
|
return {
|
|
34839
36626
|
version: "1.1",
|
|
34840
36627
|
passed: !reputationFailed && !identityFailed && !unverifiableFailed,
|
|
@@ -34854,7 +36641,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
|
|
|
34854
36641
|
identity,
|
|
34855
36642
|
audit,
|
|
34856
36643
|
reputation,
|
|
34857
|
-
failure_class:
|
|
36644
|
+
failure_class: detailedFailureClass
|
|
34858
36645
|
};
|
|
34859
36646
|
}
|
|
34860
36647
|
var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
|
|
@@ -35253,7 +37040,7 @@ async function resolveSourceMasterKey(encryptedState, opts) {
|
|
|
35253
37040
|
}
|
|
35254
37041
|
return null;
|
|
35255
37042
|
}
|
|
35256
|
-
async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId) {
|
|
37043
|
+
async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId, importedRekeyEntries) {
|
|
35257
37044
|
const destinationSigner = opts.destinationSignerIdentityId ? opts.identityManager.get(opts.destinationSignerIdentityId) : opts.identityManager.getDefault();
|
|
35258
37045
|
if (!destinationSigner) {
|
|
35259
37046
|
return {
|
|
@@ -35315,8 +37102,9 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
|
|
|
35315
37102
|
}
|
|
35316
37103
|
}
|
|
35317
37104
|
}
|
|
37105
|
+
let plaintext;
|
|
35318
37106
|
try {
|
|
35319
|
-
|
|
37107
|
+
plaintext = decrypt(
|
|
35320
37108
|
item.entry.payload,
|
|
35321
37109
|
deriveNamespaceKey(sourceMasterKey, item.namespace)
|
|
35322
37110
|
);
|
|
@@ -35325,28 +37113,30 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
|
|
|
35325
37113
|
skipped++;
|
|
35326
37114
|
continue;
|
|
35327
37115
|
}
|
|
35328
|
-
await stateStore.write(
|
|
35329
|
-
item.namespace,
|
|
35330
|
-
item.key,
|
|
35331
|
-
bytesToString(plaintext),
|
|
35332
|
-
destinationSigner.identity_id,
|
|
35333
|
-
destinationSigner.encrypted_private_key,
|
|
35334
|
-
identityEncryptionKey,
|
|
35335
|
-
{
|
|
35336
|
-
content_type: item.entry.metadata.content_type,
|
|
35337
|
-
ttl_seconds: item.entry.metadata.ttl_seconds,
|
|
35338
|
-
tags: [
|
|
35339
|
-
...item.entry.metadata.tags ?? [],
|
|
35340
|
-
"exit-import",
|
|
35341
|
-
`source:${item.entry.kid}`
|
|
35342
|
-
]
|
|
35343
|
-
}
|
|
35344
|
-
);
|
|
35345
|
-
imported++;
|
|
35346
37116
|
} catch {
|
|
35347
37117
|
skippedInvalidSig++;
|
|
35348
37118
|
skipped++;
|
|
37119
|
+
continue;
|
|
35349
37120
|
}
|
|
37121
|
+
await stateStore.write(
|
|
37122
|
+
item.namespace,
|
|
37123
|
+
item.key,
|
|
37124
|
+
bytesToString(plaintext),
|
|
37125
|
+
destinationSigner.identity_id,
|
|
37126
|
+
destinationSigner.encrypted_private_key,
|
|
37127
|
+
identityEncryptionKey,
|
|
37128
|
+
{
|
|
37129
|
+
content_type: item.entry.metadata.content_type,
|
|
37130
|
+
ttl_seconds: item.entry.metadata.ttl_seconds,
|
|
37131
|
+
tags: [
|
|
37132
|
+
...item.entry.metadata.tags ?? [],
|
|
37133
|
+
"exit-import",
|
|
37134
|
+
`source:${item.entry.kid}`
|
|
37135
|
+
]
|
|
37136
|
+
}
|
|
37137
|
+
);
|
|
37138
|
+
imported++;
|
|
37139
|
+
importedRekeyEntries?.push({ namespace: item.namespace, key: item.key });
|
|
35350
37140
|
}
|
|
35351
37141
|
return {
|
|
35352
37142
|
status: "rekeyed",
|
|
@@ -35357,6 +37147,23 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
|
|
|
35357
37147
|
conflicts
|
|
35358
37148
|
};
|
|
35359
37149
|
}
|
|
37150
|
+
async function cleanupStagedPaths(storage, staged) {
|
|
37151
|
+
let removed = 0;
|
|
37152
|
+
const failed = [];
|
|
37153
|
+
for (const loc of staged) {
|
|
37154
|
+
try {
|
|
37155
|
+
const ok2 = await storage.delete(loc.namespace, loc.key);
|
|
37156
|
+
if (ok2) {
|
|
37157
|
+
removed++;
|
|
37158
|
+
} else {
|
|
37159
|
+
failed.push(loc);
|
|
37160
|
+
}
|
|
37161
|
+
} catch {
|
|
37162
|
+
failed.push(loc);
|
|
37163
|
+
}
|
|
37164
|
+
}
|
|
37165
|
+
return { removed, failed };
|
|
37166
|
+
}
|
|
35360
37167
|
async function stageArtifact(storage, namespace, key, value) {
|
|
35361
37168
|
await storage.write(namespace, key, jsonBytes(value));
|
|
35362
37169
|
}
|
|
@@ -35481,6 +37288,8 @@ async function importExitBundle(opts) {
|
|
|
35481
37288
|
}
|
|
35482
37289
|
const importId = importIdForManifest(manifest);
|
|
35483
37290
|
const stagedArtifacts = [];
|
|
37291
|
+
const stagedLocations = [];
|
|
37292
|
+
const importedRekeyEntries = [];
|
|
35484
37293
|
if (identityArtifact) {
|
|
35485
37294
|
await stageArtifact(
|
|
35486
37295
|
opts.storage,
|
|
@@ -35489,10 +37298,15 @@ async function importExitBundle(opts) {
|
|
|
35489
37298
|
identityArtifact.json
|
|
35490
37299
|
);
|
|
35491
37300
|
stagedArtifacts.push("public_identity");
|
|
37301
|
+
stagedLocations.push({
|
|
37302
|
+
namespace: EXIT_PUBLIC_IDENTITIES_NAMESPACE,
|
|
37303
|
+
key: identityArtifact.json.bundle.identity_id
|
|
37304
|
+
});
|
|
35492
37305
|
}
|
|
35493
37306
|
if (policySet) {
|
|
35494
37307
|
await stageArtifact(opts.storage, EXIT_POLICY_SETS_NAMESPACE, importId, policySet.json);
|
|
35495
37308
|
stagedArtifacts.push("policy_set");
|
|
37309
|
+
stagedLocations.push({ namespace: EXIT_POLICY_SETS_NAMESPACE, key: importId });
|
|
35496
37310
|
}
|
|
35497
37311
|
if (auditReceipts) {
|
|
35498
37312
|
await stageArtifact(
|
|
@@ -35502,10 +37316,12 @@ async function importExitBundle(opts) {
|
|
|
35502
37316
|
auditReceipts.json
|
|
35503
37317
|
);
|
|
35504
37318
|
stagedArtifacts.push("audit_receipts");
|
|
37319
|
+
stagedLocations.push({ namespace: EXIT_AUDIT_RECEIPTS_NAMESPACE, key: importId });
|
|
35505
37320
|
}
|
|
35506
37321
|
if (commitments) {
|
|
35507
37322
|
await stageArtifact(opts.storage, EXIT_COMMITMENTS_NAMESPACE, importId, commitments.json);
|
|
35508
37323
|
stagedArtifacts.push("commitments");
|
|
37324
|
+
stagedLocations.push({ namespace: EXIT_COMMITMENTS_NAMESPACE, key: importId });
|
|
35509
37325
|
}
|
|
35510
37326
|
if (placeholderMetadata) {
|
|
35511
37327
|
await stageArtifact(
|
|
@@ -35515,12 +37331,17 @@ async function importExitBundle(opts) {
|
|
|
35515
37331
|
placeholderMetadata.json
|
|
35516
37332
|
);
|
|
35517
37333
|
stagedArtifacts.push("placeholder_vault_metadata");
|
|
37334
|
+
stagedLocations.push({
|
|
37335
|
+
namespace: EXIT_PLACEHOLDER_METADATA_NAMESPACE,
|
|
37336
|
+
key: importId
|
|
37337
|
+
});
|
|
35518
37338
|
}
|
|
35519
37339
|
await stageArtifact(opts.storage, EXIT_IMPORT_NAMESPACE, importId, {
|
|
35520
37340
|
manifest: manifest.body,
|
|
35521
37341
|
verified_at: verification.verified_at,
|
|
35522
37342
|
activated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
35523
37343
|
});
|
|
37344
|
+
stagedLocations.push({ namespace: EXIT_IMPORT_NAMESPACE, key: importId });
|
|
35524
37345
|
const publicKeys = identityArtifact ? publicKeysFromIdentityArtifact(identityArtifact.json) : { byIdentityId: /* @__PURE__ */ new Map(), byDid: /* @__PURE__ */ new Map() };
|
|
35525
37346
|
let reputationResult = {
|
|
35526
37347
|
imported_attestations: 0,
|
|
@@ -35545,26 +37366,57 @@ async function importExitBundle(opts) {
|
|
|
35545
37366
|
encryptedState?.json ?? null,
|
|
35546
37367
|
opts
|
|
35547
37368
|
);
|
|
35548
|
-
|
|
35549
|
-
|
|
35550
|
-
|
|
35551
|
-
|
|
35552
|
-
|
|
35553
|
-
|
|
35554
|
-
|
|
35555
|
-
|
|
35556
|
-
|
|
35557
|
-
|
|
35558
|
-
|
|
35559
|
-
|
|
35560
|
-
|
|
35561
|
-
|
|
35562
|
-
|
|
35563
|
-
|
|
35564
|
-
|
|
35565
|
-
|
|
35566
|
-
|
|
35567
|
-
|
|
37369
|
+
let stateResult;
|
|
37370
|
+
try {
|
|
37371
|
+
stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
|
|
37372
|
+
encryptedState.json,
|
|
37373
|
+
opts,
|
|
37374
|
+
sourceMasterKey,
|
|
37375
|
+
publicKeys.byIdentityId,
|
|
37376
|
+
importedRekeyEntries
|
|
37377
|
+
) : {
|
|
37378
|
+
status: "staged_requires_source_key",
|
|
37379
|
+
imported_keys: 0,
|
|
37380
|
+
skipped_keys: encryptedState.json.entries.length,
|
|
37381
|
+
skipped_invalid_sig: 0,
|
|
37382
|
+
skipped_unknown_kid: 0,
|
|
37383
|
+
conflicts: conflicts.state_conflicts.length
|
|
37384
|
+
} : {
|
|
37385
|
+
status: "not_requested",
|
|
37386
|
+
imported_keys: 0,
|
|
37387
|
+
skipped_keys: 0,
|
|
37388
|
+
skipped_invalid_sig: 0,
|
|
37389
|
+
skipped_unknown_kid: 0,
|
|
37390
|
+
conflicts: 0
|
|
37391
|
+
};
|
|
37392
|
+
} catch (err) {
|
|
37393
|
+
const toCleanup = [
|
|
37394
|
+
...importedRekeyEntries,
|
|
37395
|
+
...stagedLocations
|
|
37396
|
+
];
|
|
37397
|
+
const cleanup = await cleanupStagedPaths(opts.storage, toCleanup);
|
|
37398
|
+
opts.auditLog.append(
|
|
37399
|
+
"l1",
|
|
37400
|
+
"exit_bundle_rekey_failed_cleanup",
|
|
37401
|
+
manifest.body.identity_binding.identity_id,
|
|
37402
|
+
{
|
|
37403
|
+
import_id: importId,
|
|
37404
|
+
manifest_version: manifest.body.manifest_version,
|
|
37405
|
+
rekey_entries_removed: importedRekeyEntries.length,
|
|
37406
|
+
staged_artifacts_removed: stagedLocations.length,
|
|
37407
|
+
removed_total: cleanup.removed,
|
|
37408
|
+
cleanup_failed_count: cleanup.failed.length,
|
|
37409
|
+
original_error: err instanceof Error ? err.message : String(err)
|
|
37410
|
+
},
|
|
37411
|
+
"failure"
|
|
37412
|
+
);
|
|
37413
|
+
await opts.auditLog.flush();
|
|
37414
|
+
const originalMessage = err instanceof Error ? err.message : String(err);
|
|
37415
|
+
throw new ExitBundleImportError(
|
|
37416
|
+
"REKEY_FAILED_AND_CLEANED",
|
|
37417
|
+
`Exit-bundle re-key failed: ${originalMessage}. Cleanup removed ${cleanup.removed} of ${toCleanup.length} staged paths (${importedRekeyEntries.length} re-keyed entries plus ${stagedLocations.length} staged artifacts; ${cleanup.failed.length} cleanup deletes failed).`
|
|
37418
|
+
);
|
|
37419
|
+
}
|
|
35568
37420
|
opts.auditLog.append("l1", "exit_bundle_import_activate", manifest.body.identity_binding.identity_id, {
|
|
35569
37421
|
import_id: importId,
|
|
35570
37422
|
manifest_version: manifest.body.manifest_version,
|
|
@@ -35822,7 +37674,19 @@ async function runExitCommand(args) {
|
|
|
35822
37674
|
}
|
|
35823
37675
|
const config = await loadConfig();
|
|
35824
37676
|
const ctx = await openExitContext(argv, env);
|
|
35825
|
-
|
|
37677
|
+
let policy;
|
|
37678
|
+
try {
|
|
37679
|
+
policy = await loadPrincipalPolicy(ctx.storagePath);
|
|
37680
|
+
} catch (policyErr) {
|
|
37681
|
+
if (policyErr instanceof MalformedPrincipalPolicyError) {
|
|
37682
|
+
write(err, `
|
|
37683
|
+
Sanctuary cannot proceed.
|
|
37684
|
+
${policyErr.message}
|
|
37685
|
+
`);
|
|
37686
|
+
return 1;
|
|
37687
|
+
}
|
|
37688
|
+
throw policyErr;
|
|
37689
|
+
}
|
|
35826
37690
|
const result = await exportExitBundle({
|
|
35827
37691
|
bundleDir: outDir,
|
|
35828
37692
|
storage: ctx.storage,
|
|
@@ -36522,7 +38386,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
|
|
|
36522
38386
|
const profileStore = new SovereigntyProfileStore(storage, masterKey);
|
|
36523
38387
|
await profileStore.load();
|
|
36524
38388
|
const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
|
|
36525
|
-
|
|
38389
|
+
let policy;
|
|
38390
|
+
try {
|
|
38391
|
+
policy = await loadPrincipalPolicy(config.storage_path);
|
|
38392
|
+
} catch (err) {
|
|
38393
|
+
if (err instanceof MalformedPrincipalPolicyError) {
|
|
38394
|
+
console.error(`
|
|
38395
|
+
Sanctuary cannot start.
|
|
38396
|
+
${err.message}
|
|
38397
|
+
`);
|
|
38398
|
+
process.exit(1);
|
|
38399
|
+
}
|
|
38400
|
+
throw err;
|
|
38401
|
+
}
|
|
36526
38402
|
const baseline = new BaselineTracker(storage, masterKey);
|
|
36527
38403
|
await baseline.load();
|
|
36528
38404
|
let approvalChannel;
|
|
@@ -36619,7 +38495,35 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
|
|
|
36619
38495
|
timestamp: alert.timestamp
|
|
36620
38496
|
});
|
|
36621
38497
|
} : void 0;
|
|
36622
|
-
const
|
|
38498
|
+
const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
|
|
38499
|
+
const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
|
|
38500
|
+
const approvalAggregator = new ApprovalAggregator({
|
|
38501
|
+
storage,
|
|
38502
|
+
masterKey,
|
|
38503
|
+
auditLog,
|
|
38504
|
+
identityId: aggregatorIdentityId,
|
|
38505
|
+
fortressId: fortressIdForAggregator
|
|
38506
|
+
});
|
|
38507
|
+
const wrappedApprovalChannel = new AggregatorBackedChannel({
|
|
38508
|
+
underlying: approvalChannel,
|
|
38509
|
+
aggregator: approvalAggregator,
|
|
38510
|
+
resolveRedirect: makeRedirectResolverFromPolicySupplier(() => policy),
|
|
38511
|
+
replaceModeTimeoutMs: policy.approval_channel.timeout_seconds * 1e3
|
|
38512
|
+
});
|
|
38513
|
+
const gate = new ApprovalGate(
|
|
38514
|
+
policy,
|
|
38515
|
+
baseline,
|
|
38516
|
+
wrappedApprovalChannel,
|
|
38517
|
+
auditLog,
|
|
38518
|
+
injectionDetector,
|
|
38519
|
+
onInjectionAlert
|
|
38520
|
+
);
|
|
38521
|
+
gate.setApprovalEventCallback((event) => {
|
|
38522
|
+
void approvalAggregator.ingest(event);
|
|
38523
|
+
});
|
|
38524
|
+
if (dashboard) {
|
|
38525
|
+
dashboard.setApprovalAggregator(approvalAggregator);
|
|
38526
|
+
}
|
|
36623
38527
|
const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
|
|
36624
38528
|
const { tools: sanctuaryMetaTools } = createSanctuaryTools({
|
|
36625
38529
|
config,
|
|
@@ -36810,6 +38714,8 @@ var init_src = __esm({
|
|
|
36810
38714
|
init_dashboard();
|
|
36811
38715
|
init_webhook();
|
|
36812
38716
|
init_gate();
|
|
38717
|
+
init_approval_aggregator();
|
|
38718
|
+
init_aggregator_backed_channel();
|
|
36813
38719
|
init_tools4();
|
|
36814
38720
|
init_router();
|
|
36815
38721
|
init_router();
|
|
@@ -39774,6 +41680,22 @@ var init_broker = __esm({
|
|
|
39774
41680
|
auditLog;
|
|
39775
41681
|
issuer;
|
|
39776
41682
|
principalIdentityId;
|
|
41683
|
+
/**
|
|
41684
|
+
* Per-secret-name mutex. Hardening wave 6 finding #64: two concurrent
|
|
41685
|
+
* addSecret() / rotateSecret() / deleteSecret() calls on the same name
|
|
41686
|
+
* MUST serialize cleanly. The keychain backend's `find-then-add` and
|
|
41687
|
+
* `find-then-delete-then-add` shapes (KeychainBackend.addSecret /
|
|
41688
|
+
* .rotateSecret) are not atomic against another caller racing the same
|
|
41689
|
+
* service-name; without serialization the second caller can observe a
|
|
41690
|
+
* stale "exists" check and either drop the new value or leave a
|
|
41691
|
+
* duplicate keychain entry.
|
|
41692
|
+
*
|
|
41693
|
+
* Implementation: an in-memory promise chain per name. Subsequent
|
|
41694
|
+
* callers `await` the chain tail and append their own work; failures
|
|
41695
|
+
* propagate to the failing caller without poisoning the chain for
|
|
41696
|
+
* later callers.
|
|
41697
|
+
*/
|
|
41698
|
+
nameLocks = /* @__PURE__ */ new Map();
|
|
39777
41699
|
constructor(opts) {
|
|
39778
41700
|
this.backend = opts.backend;
|
|
39779
41701
|
this.auditLog = opts.auditLog;
|
|
@@ -39784,6 +41706,40 @@ var init_broker = __esm({
|
|
|
39784
41706
|
grants: opts.grants
|
|
39785
41707
|
});
|
|
39786
41708
|
}
|
|
41709
|
+
/**
|
|
41710
|
+
* Serialize `op` against any other in-flight write to the same secret
|
|
41711
|
+
* `name`. Per-name fairness only, distinct names run in parallel.
|
|
41712
|
+
* The current chain tail is used as the acceptance gate; we then
|
|
41713
|
+
* publish a new tail that swallows the operation's outcome so a
|
|
41714
|
+
* thrown error does not poison the next caller's wait.
|
|
41715
|
+
*/
|
|
41716
|
+
async withNameLock(name, op) {
|
|
41717
|
+
const previous = this.nameLocks.get(name) ?? Promise.resolve();
|
|
41718
|
+
let release = () => {
|
|
41719
|
+
};
|
|
41720
|
+
const next = new Promise((resolve8) => {
|
|
41721
|
+
release = resolve8;
|
|
41722
|
+
});
|
|
41723
|
+
this.nameLocks.set(name, next);
|
|
41724
|
+
try {
|
|
41725
|
+
await previous.catch(() => {
|
|
41726
|
+
});
|
|
41727
|
+
return await op();
|
|
41728
|
+
} finally {
|
|
41729
|
+
release();
|
|
41730
|
+
if (this.nameLocks.get(name) === next) {
|
|
41731
|
+
this.nameLocks.delete(name);
|
|
41732
|
+
}
|
|
41733
|
+
}
|
|
41734
|
+
}
|
|
41735
|
+
/**
|
|
41736
|
+
* Diagnostic-only: visible for tests so they can assert that distinct
|
|
41737
|
+
* names do not contend on a shared lock. Not part of the public broker
|
|
41738
|
+
* contract; do not consume from production code.
|
|
41739
|
+
*/
|
|
41740
|
+
__nameLockCountForTests() {
|
|
41741
|
+
return this.nameLocks.size;
|
|
41742
|
+
}
|
|
39787
41743
|
/** Ensure backend is initialized and unlocked. Audits the unlock. */
|
|
39788
41744
|
async ensureUnlocked(passphrase) {
|
|
39789
41745
|
await this.backend.ensureInitialized(passphrase);
|
|
@@ -39796,31 +41752,37 @@ var init_broker = __esm({
|
|
|
39796
41752
|
);
|
|
39797
41753
|
}
|
|
39798
41754
|
async addSecret(name, value) {
|
|
39799
|
-
await this.
|
|
39800
|
-
|
|
39801
|
-
|
|
39802
|
-
|
|
39803
|
-
|
|
39804
|
-
|
|
39805
|
-
|
|
41755
|
+
await this.withNameLock(name, async () => {
|
|
41756
|
+
await this.backend.addSecret(name, value);
|
|
41757
|
+
this.auditLog.append(
|
|
41758
|
+
"l3",
|
|
41759
|
+
BROKER_OPS.SECRET_ADDED,
|
|
41760
|
+
this.principalIdentityId,
|
|
41761
|
+
{ secret: name }
|
|
41762
|
+
);
|
|
41763
|
+
});
|
|
39806
41764
|
}
|
|
39807
41765
|
async rotateSecret(name, newValue) {
|
|
39808
|
-
await this.
|
|
39809
|
-
|
|
39810
|
-
|
|
39811
|
-
|
|
39812
|
-
|
|
39813
|
-
|
|
39814
|
-
|
|
41766
|
+
await this.withNameLock(name, async () => {
|
|
41767
|
+
await this.backend.rotateSecret(name, newValue);
|
|
41768
|
+
this.auditLog.append(
|
|
41769
|
+
"l3",
|
|
41770
|
+
BROKER_OPS.SECRET_ROTATED,
|
|
41771
|
+
this.principalIdentityId,
|
|
41772
|
+
{ secret: name }
|
|
41773
|
+
);
|
|
41774
|
+
});
|
|
39815
41775
|
}
|
|
39816
41776
|
async deleteSecret(name) {
|
|
39817
|
-
await this.
|
|
39818
|
-
|
|
39819
|
-
|
|
39820
|
-
|
|
39821
|
-
|
|
39822
|
-
|
|
39823
|
-
|
|
41777
|
+
await this.withNameLock(name, async () => {
|
|
41778
|
+
await this.backend.deleteSecret(name);
|
|
41779
|
+
this.auditLog.append(
|
|
41780
|
+
"l3",
|
|
41781
|
+
BROKER_OPS.SECRET_DELETED,
|
|
41782
|
+
this.principalIdentityId,
|
|
41783
|
+
{ secret: name }
|
|
41784
|
+
);
|
|
41785
|
+
});
|
|
39824
41786
|
}
|
|
39825
41787
|
async listSecretNames() {
|
|
39826
41788
|
return this.backend.listSecretNames();
|
|
@@ -39860,6 +41822,19 @@ var init_broker = __esm({
|
|
|
39860
41822
|
liveTokenCount() {
|
|
39861
41823
|
return this.issuer.liveTokenCount();
|
|
39862
41824
|
}
|
|
41825
|
+
/**
|
|
41826
|
+
* Drop expired tokens from the in-memory issuer map. Hardening wave 6
|
|
41827
|
+
* finding #86: previously expiry pruning depended on opportunistic
|
|
41828
|
+
* `pruneExpired()` calls; now the cocoon-unlock initialization path
|
|
41829
|
+
* (openBroker -> after backend.ensureInitialized -> after Broker
|
|
41830
|
+
* construction) fires this once so each cocoon-unlock cycle drops
|
|
41831
|
+
* stale bindings before any operator interaction.
|
|
41832
|
+
*
|
|
41833
|
+
* Returns the number of tokens removed. Safe to call repeatedly; idempotent.
|
|
41834
|
+
*/
|
|
41835
|
+
pruneExpiredTokens() {
|
|
41836
|
+
return this.issuer.pruneExpired();
|
|
41837
|
+
}
|
|
39863
41838
|
/**
|
|
39864
41839
|
* Audit query restricted to broker-scoped operations. Returns entries
|
|
39865
41840
|
* with their timestamps, op, and result (never the secret value).
|
|
@@ -40018,6 +41993,7 @@ async function openBroker(opts = {}) {
|
|
|
40018
41993
|
grants,
|
|
40019
41994
|
principalIdentityId: opts.principalIdentityId ?? "sanctuary-broker"
|
|
40020
41995
|
});
|
|
41996
|
+
broker.pruneExpiredTokens();
|
|
40021
41997
|
return {
|
|
40022
41998
|
broker,
|
|
40023
41999
|
close: async () => {
|
|
@@ -40886,8 +42862,6 @@ var init_health = __esm({
|
|
|
40886
42862
|
DEFAULT_TIMEOUT_MS4 = 500;
|
|
40887
42863
|
}
|
|
40888
42864
|
});
|
|
40889
|
-
|
|
40890
|
-
// src/cli/agents/cli.ts
|
|
40891
42865
|
function resolveCtx(args) {
|
|
40892
42866
|
const env = args.env ?? process.env;
|
|
40893
42867
|
const discoverOpts = {
|
|
@@ -40925,6 +42899,8 @@ async function runAgentsCommand(args) {
|
|
|
40925
42899
|
return await cmdShow2(rest, ctx);
|
|
40926
42900
|
case "status":
|
|
40927
42901
|
return await cmdStatus(rest, ctx);
|
|
42902
|
+
case "config":
|
|
42903
|
+
return await cmdConfig(rest, ctx);
|
|
40928
42904
|
default:
|
|
40929
42905
|
ctx.err.write(`Unknown subcommand: ${sub}
|
|
40930
42906
|
`);
|
|
@@ -40940,10 +42916,18 @@ async function runAgentsCommand(args) {
|
|
|
40940
42916
|
}
|
|
40941
42917
|
function printUsage4(s) {
|
|
40942
42918
|
s.write(`Usage: sanctuary agents <command> [flags]
|
|
42919
|
+
sanctuary agent <command> [flags] (alias)
|
|
40943
42920
|
|
|
40944
42921
|
list [--json] List every tenant visible on this host.
|
|
40945
|
-
show <tenant> [--json] Show details for one tenant
|
|
42922
|
+
show <tenant> [--json] Show details for one tenant (includes
|
|
42923
|
+
approval-redirect state).
|
|
40946
42924
|
status [--json] One-line-per-tenant running/stopped summary.
|
|
42925
|
+
config <tenant> [opts] Write tenant principal-policy.yaml fields.
|
|
42926
|
+
--approval-redirect=<bool> Toggle cross-harness inbox redirect.
|
|
42927
|
+
--approval-redirect-mode=<replace|notify>
|
|
42928
|
+
Pick replace (bypass underlying channel)
|
|
42929
|
+
or notify (race both paths). Default
|
|
42930
|
+
replace when toggled on.
|
|
40947
42931
|
|
|
40948
42932
|
Options:
|
|
40949
42933
|
--fortress <path> Scope discovery to a specific storage path
|
|
@@ -41045,6 +43029,7 @@ async function cmdShow2(argv, ctx) {
|
|
|
41045
43029
|
return 1;
|
|
41046
43030
|
}
|
|
41047
43031
|
const probe = await ctx.probe(tenant);
|
|
43032
|
+
const approvalRedirect = await readApprovalRedirectState(tenant);
|
|
41048
43033
|
const payload = {
|
|
41049
43034
|
name: tenant.name,
|
|
41050
43035
|
storage_path: tenant.storage_path,
|
|
@@ -41058,7 +43043,8 @@ async function cmdShow2(argv, ctx) {
|
|
|
41058
43043
|
running: probe.running,
|
|
41059
43044
|
status: probe.status,
|
|
41060
43045
|
reason: probe.reason
|
|
41061
|
-
}
|
|
43046
|
+
},
|
|
43047
|
+
approval_redirect: approvalRedirect
|
|
41062
43048
|
};
|
|
41063
43049
|
if (hasJsonFlag(argv)) {
|
|
41064
43050
|
ctx.out.write(JSON.stringify(payload, null, 2) + "\n");
|
|
@@ -41104,8 +43090,171 @@ async function cmdShow2(argv, ctx) {
|
|
|
41104
43090
|
`probe: ${probe.running ? "running" : "not-running"}${probe.reason ? ` (${probe.reason})` : ""}
|
|
41105
43091
|
`
|
|
41106
43092
|
);
|
|
43093
|
+
ctx.out.write(
|
|
43094
|
+
`approval_redirect: ${approvalRedirect.enabled ? `on (${approvalRedirect.mode})` : "off"}
|
|
43095
|
+
`
|
|
43096
|
+
);
|
|
43097
|
+
return 0;
|
|
43098
|
+
}
|
|
43099
|
+
async function readApprovalRedirectState(tenant) {
|
|
43100
|
+
const policyPath = path.join(tenant.storage_path, "principal-policy.yaml");
|
|
43101
|
+
try {
|
|
43102
|
+
const content = await promises.readFile(policyPath, "utf-8");
|
|
43103
|
+
const parsed = parsePolicy(content);
|
|
43104
|
+
const cfg = parsed.approval_redirect;
|
|
43105
|
+
if (!cfg) return { enabled: false, mode: "replace" };
|
|
43106
|
+
return {
|
|
43107
|
+
enabled: !!cfg.enabled,
|
|
43108
|
+
mode: cfg.mode === "notify" ? "notify" : "replace"
|
|
43109
|
+
};
|
|
43110
|
+
} catch {
|
|
43111
|
+
return { enabled: false, mode: "replace" };
|
|
43112
|
+
}
|
|
43113
|
+
}
|
|
43114
|
+
function parseBoolFlag(raw) {
|
|
43115
|
+
if (raw === void 0) return null;
|
|
43116
|
+
const v = raw.toLowerCase();
|
|
43117
|
+
if (v === "true" || v === "yes" || v === "on" || v === "1") return true;
|
|
43118
|
+
if (v === "false" || v === "no" || v === "off" || v === "0") return false;
|
|
43119
|
+
return null;
|
|
43120
|
+
}
|
|
43121
|
+
function findFlagValue(argv, name) {
|
|
43122
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43123
|
+
const a = argv[i];
|
|
43124
|
+
if (a === name) {
|
|
43125
|
+
return argv[i + 1];
|
|
43126
|
+
}
|
|
43127
|
+
const eq = `${name}=`;
|
|
43128
|
+
if (a.startsWith(eq)) {
|
|
43129
|
+
return a.slice(eq.length);
|
|
43130
|
+
}
|
|
43131
|
+
}
|
|
43132
|
+
return void 0;
|
|
43133
|
+
}
|
|
43134
|
+
async function cmdConfig(argv, ctx) {
|
|
43135
|
+
const positional = argv.find((a) => !a.startsWith("--"));
|
|
43136
|
+
if (!positional) {
|
|
43137
|
+
ctx.err.write(
|
|
43138
|
+
"Missing tenant. Usage: sanctuary agents config <tenant> --approval-redirect=<bool>\n"
|
|
43139
|
+
);
|
|
43140
|
+
return 2;
|
|
43141
|
+
}
|
|
43142
|
+
const tenant = await findTenant(positional, ctx.discoverOpts);
|
|
43143
|
+
if (!tenant) {
|
|
43144
|
+
ctx.err.write(`sanctuary agents: unknown tenant "${positional}"
|
|
43145
|
+
`);
|
|
43146
|
+
return 1;
|
|
43147
|
+
}
|
|
43148
|
+
const redirectFlag = parseBoolFlag(
|
|
43149
|
+
findFlagValue(argv, "--approval-redirect")
|
|
43150
|
+
);
|
|
43151
|
+
const modeFlag = findFlagValue(argv, "--approval-redirect-mode");
|
|
43152
|
+
if (redirectFlag === null && modeFlag === void 0) {
|
|
43153
|
+
ctx.err.write(
|
|
43154
|
+
"sanctuary agents config: nothing to do. Pass --approval-redirect=<bool> or --approval-redirect-mode=<replace|notify>.\n"
|
|
43155
|
+
);
|
|
43156
|
+
return 2;
|
|
43157
|
+
}
|
|
43158
|
+
if (modeFlag !== void 0 && modeFlag !== "replace" && modeFlag !== "notify") {
|
|
43159
|
+
ctx.err.write(
|
|
43160
|
+
`sanctuary agents config: --approval-redirect-mode must be "replace" or "notify" (got "${modeFlag}")
|
|
43161
|
+
`
|
|
43162
|
+
);
|
|
43163
|
+
return 2;
|
|
43164
|
+
}
|
|
43165
|
+
const current = await readApprovalRedirectState(tenant);
|
|
43166
|
+
const next = {
|
|
43167
|
+
enabled: redirectFlag !== null ? redirectFlag : current.enabled,
|
|
43168
|
+
mode: modeFlag === "notify" || modeFlag === "replace" ? modeFlag : current.mode
|
|
43169
|
+
};
|
|
43170
|
+
await writeApprovalRedirectToPolicyFile(tenant.storage_path, next);
|
|
43171
|
+
if (hasJsonFlag(argv)) {
|
|
43172
|
+
ctx.out.write(
|
|
43173
|
+
JSON.stringify(
|
|
43174
|
+
{
|
|
43175
|
+
tenant: tenant.name,
|
|
43176
|
+
approval_redirect: next
|
|
43177
|
+
},
|
|
43178
|
+
null,
|
|
43179
|
+
2
|
|
43180
|
+
) + "\n"
|
|
43181
|
+
);
|
|
43182
|
+
} else {
|
|
43183
|
+
ctx.out.write(
|
|
43184
|
+
`sanctuary agents config: tenant "${tenant.name}" approval_redirect=${next.enabled ? `on (${next.mode})` : "off"}
|
|
43185
|
+
`
|
|
43186
|
+
);
|
|
43187
|
+
ctx.out.write(
|
|
43188
|
+
` Takes effect on the next gate request for the running server.
|
|
43189
|
+
`
|
|
43190
|
+
);
|
|
43191
|
+
}
|
|
41107
43192
|
return 0;
|
|
41108
43193
|
}
|
|
43194
|
+
async function writeApprovalRedirectToPolicyFile(storagePath, state) {
|
|
43195
|
+
const policyPath = path.join(storagePath, "principal-policy.yaml");
|
|
43196
|
+
let content;
|
|
43197
|
+
try {
|
|
43198
|
+
content = await promises.readFile(policyPath, "utf-8");
|
|
43199
|
+
} catch (err) {
|
|
43200
|
+
const code = err?.code;
|
|
43201
|
+
if (code !== "ENOENT") throw err;
|
|
43202
|
+
content = await defaultPolicyTextForBootstrap();
|
|
43203
|
+
}
|
|
43204
|
+
const block = renderApprovalRedirectBlock(state);
|
|
43205
|
+
const updated = upsertApprovalRedirectBlock(content, block);
|
|
43206
|
+
await promises.writeFile(policyPath, updated, "utf-8");
|
|
43207
|
+
await promises.chmod(policyPath, 384);
|
|
43208
|
+
}
|
|
43209
|
+
function renderApprovalRedirectBlock(state) {
|
|
43210
|
+
return [
|
|
43211
|
+
"# Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2)",
|
|
43212
|
+
"approval_redirect:",
|
|
43213
|
+
` enabled: ${state.enabled ? "true" : "false"}`,
|
|
43214
|
+
` mode: ${state.mode}`
|
|
43215
|
+
].join("\n");
|
|
43216
|
+
}
|
|
43217
|
+
function upsertApprovalRedirectBlock(content, block) {
|
|
43218
|
+
const lines = content.split("\n");
|
|
43219
|
+
const startIdx = lines.findIndex((l) => l.startsWith("approval_redirect:"));
|
|
43220
|
+
if (startIdx === -1) {
|
|
43221
|
+
const trimmed = content.endsWith("\n") ? content : content + "\n";
|
|
43222
|
+
return trimmed + "\n" + block + "\n";
|
|
43223
|
+
}
|
|
43224
|
+
let blockStart = startIdx;
|
|
43225
|
+
if (blockStart > 0 && lines[blockStart - 1] !== void 0 && lines[blockStart - 1].startsWith("# Approval Redirect")) {
|
|
43226
|
+
blockStart = blockStart - 1;
|
|
43227
|
+
}
|
|
43228
|
+
let blockEnd = startIdx + 1;
|
|
43229
|
+
while (blockEnd < lines.length) {
|
|
43230
|
+
const l = lines[blockEnd];
|
|
43231
|
+
if (l === "") {
|
|
43232
|
+
blockEnd++;
|
|
43233
|
+
continue;
|
|
43234
|
+
}
|
|
43235
|
+
if (/^[A-Za-z0-9#]/.test(l)) {
|
|
43236
|
+
break;
|
|
43237
|
+
}
|
|
43238
|
+
blockEnd++;
|
|
43239
|
+
}
|
|
43240
|
+
const before = lines.slice(0, blockStart);
|
|
43241
|
+
const after = lines.slice(blockEnd);
|
|
43242
|
+
const replaced = [...before, ...block.split("\n"), ...after].join("\n");
|
|
43243
|
+
return replaced.endsWith("\n") ? replaced : replaced + "\n";
|
|
43244
|
+
}
|
|
43245
|
+
async function defaultPolicyTextForBootstrap() {
|
|
43246
|
+
return [
|
|
43247
|
+
"version: 1",
|
|
43248
|
+
"tier1_always_approve:",
|
|
43249
|
+
" - state_export",
|
|
43250
|
+
" - state_import",
|
|
43251
|
+
" - state_delete",
|
|
43252
|
+
"approval_channel:",
|
|
43253
|
+
" type: stderr",
|
|
43254
|
+
" timeout_seconds: 300",
|
|
43255
|
+
""
|
|
43256
|
+
].join("\n");
|
|
43257
|
+
}
|
|
41109
43258
|
async function cmdStatus(argv, ctx) {
|
|
41110
43259
|
const tenants = await discoverTenants(ctx.discoverOpts);
|
|
41111
43260
|
const probes = await Promise.all(tenants.map((t) => ctx.probe(t)));
|
|
@@ -41146,6 +43295,7 @@ var init_cli5 = __esm({
|
|
|
41146
43295
|
"src/cli/agents/cli.ts"() {
|
|
41147
43296
|
init_discovery();
|
|
41148
43297
|
init_health();
|
|
43298
|
+
init_loader();
|
|
41149
43299
|
}
|
|
41150
43300
|
});
|
|
41151
43301
|
|
|
@@ -41173,7 +43323,8 @@ var init_agents = __esm({
|
|
|
41173
43323
|
// src/cli/reset-passphrase.ts
|
|
41174
43324
|
var reset_passphrase_exports = {};
|
|
41175
43325
|
__export(reset_passphrase_exports, {
|
|
41176
|
-
runResetPassphraseCommand: () => runResetPassphraseCommand
|
|
43326
|
+
runResetPassphraseCommand: () => runResetPassphraseCommand,
|
|
43327
|
+
zeroizeBuffers: () => zeroizeBuffers
|
|
41177
43328
|
});
|
|
41178
43329
|
async function runResetPassphraseCommand(args) {
|
|
41179
43330
|
const out = args.out ?? process.stdout;
|
|
@@ -41201,34 +43352,42 @@ Then re-run this command.
|
|
|
41201
43352
|
return 1;
|
|
41202
43353
|
}
|
|
41203
43354
|
const lines = new LineReader(stdin);
|
|
43355
|
+
let code = 1;
|
|
43356
|
+
let nukeSucceeded = false;
|
|
41204
43357
|
try {
|
|
41205
43358
|
const availability = await surveyAvailableModes(storagePath);
|
|
41206
43359
|
const mode = parsed.mode ?? await selectMode(lines, out, err, availability);
|
|
41207
43360
|
if (!mode) {
|
|
41208
43361
|
err.write("Aborted: no recovery mode selected.\n");
|
|
41209
|
-
|
|
41210
|
-
}
|
|
41211
|
-
|
|
41212
|
-
|
|
41213
|
-
|
|
41214
|
-
|
|
41215
|
-
|
|
43362
|
+
code = 1;
|
|
43363
|
+
} else if (mode === "shares") {
|
|
43364
|
+
code = await runSharesPath(out, err, availability);
|
|
43365
|
+
} else if (mode === "guardian") {
|
|
43366
|
+
code = await runGuardianPath(out, err, availability);
|
|
43367
|
+
} else {
|
|
43368
|
+
code = await runNukePath({
|
|
43369
|
+
out,
|
|
43370
|
+
err,
|
|
43371
|
+
lines,
|
|
43372
|
+
storagePath,
|
|
43373
|
+
home,
|
|
43374
|
+
plat,
|
|
43375
|
+
exec: args.exec ?? defaultExec2
|
|
43376
|
+
});
|
|
43377
|
+
nukeSucceeded = mode === "nuke" && code === 0;
|
|
41216
43378
|
}
|
|
41217
|
-
return await runNukePath({
|
|
41218
|
-
out,
|
|
41219
|
-
err,
|
|
41220
|
-
lines,
|
|
41221
|
-
storagePath,
|
|
41222
|
-
home,
|
|
41223
|
-
plat,
|
|
41224
|
-
exec: args.exec ?? defaultExec2
|
|
41225
|
-
});
|
|
41226
43379
|
} finally {
|
|
43380
|
+
zeroizeBuffers(args.keyMaterialToZeroize);
|
|
41227
43381
|
lines.close();
|
|
41228
43382
|
}
|
|
43383
|
+
if (parsed.exitOnCompletion && nukeSucceeded) {
|
|
43384
|
+
const doExit = args.exitProcess ?? ((c) => process.exit(c));
|
|
43385
|
+
doExit(0);
|
|
43386
|
+
}
|
|
43387
|
+
return code;
|
|
41229
43388
|
}
|
|
41230
43389
|
function parseArgs2(argv) {
|
|
41231
|
-
const out = { help: false };
|
|
43390
|
+
const out = { exitOnCompletion: false, help: false };
|
|
41232
43391
|
for (let i = 0; i < argv.length; i++) {
|
|
41233
43392
|
const a = argv[i];
|
|
41234
43393
|
if (a === "--help" || a === "-h") {
|
|
@@ -41245,6 +43404,8 @@ function parseArgs2(argv) {
|
|
|
41245
43404
|
out.storage = argv[++i];
|
|
41246
43405
|
} else if (a === "--fortress" && argv[i + 1]) {
|
|
41247
43406
|
out.fortress = argv[++i];
|
|
43407
|
+
} else if (a === "--exit-on-completion") {
|
|
43408
|
+
out.exitOnCompletion = true;
|
|
41248
43409
|
} else if (a && a.startsWith("--")) {
|
|
41249
43410
|
throw new Error(`Unknown flag: ${a}`);
|
|
41250
43411
|
}
|
|
@@ -41277,6 +43438,16 @@ Options:
|
|
|
41277
43438
|
--fortress <path> Override the fortress storage path.
|
|
41278
43439
|
Consistent with "sanctuary wrap --fortress".
|
|
41279
43440
|
--storage <path> Alias for --fortress.
|
|
43441
|
+
--exit-on-completion After a successful nuke, call process.exit(0)
|
|
43442
|
+
immediately so the post-wipe heap is reaped
|
|
43443
|
+
by the OS without re-entering the shell. Use
|
|
43444
|
+
on extreme-threat-model deployments where an
|
|
43445
|
+
attacker-on-host with heap-dump access could
|
|
43446
|
+
recover residual passphrase or key bytes
|
|
43447
|
+
between the wipe and the next operator
|
|
43448
|
+
command. JS strings cannot be explicitly
|
|
43449
|
+
zeroed; this flag is the supported way to
|
|
43450
|
+
bound the heap-dump window.
|
|
41280
43451
|
--help, -h Show this help.
|
|
41281
43452
|
|
|
41282
43453
|
Without --mode, the command surveys which paths are operationally available
|
|
@@ -41546,6 +43717,16 @@ async function prompt(lines, err, question) {
|
|
|
41546
43717
|
err.write(question);
|
|
41547
43718
|
return await lines.next();
|
|
41548
43719
|
}
|
|
43720
|
+
function zeroizeBuffers(buffers) {
|
|
43721
|
+
if (!buffers) return;
|
|
43722
|
+
for (const b of buffers) {
|
|
43723
|
+
if (!b) continue;
|
|
43724
|
+
try {
|
|
43725
|
+
b.fill(0);
|
|
43726
|
+
} catch {
|
|
43727
|
+
}
|
|
43728
|
+
}
|
|
43729
|
+
}
|
|
41549
43730
|
async function defaultExec2(cmd, args) {
|
|
41550
43731
|
return await new Promise((resolve8, reject) => {
|
|
41551
43732
|
const child = child_process.spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -42226,7 +44407,19 @@ Refusing to start the dashboard while the reset-history marker is unreadable.`
|
|
|
42226
44407
|
}
|
|
42227
44408
|
throw err;
|
|
42228
44409
|
}
|
|
42229
|
-
|
|
44410
|
+
let policy;
|
|
44411
|
+
try {
|
|
44412
|
+
policy = await loadPrincipalPolicy(config.storage_path);
|
|
44413
|
+
} catch (err) {
|
|
44414
|
+
if (err instanceof MalformedPrincipalPolicyError) {
|
|
44415
|
+
console.error(`
|
|
44416
|
+
Sanctuary cannot start.
|
|
44417
|
+
${err.message}
|
|
44418
|
+
`);
|
|
44419
|
+
process.exit(1);
|
|
44420
|
+
}
|
|
44421
|
+
throw err;
|
|
44422
|
+
}
|
|
42230
44423
|
const baseline = new BaselineTracker(storage, masterKey);
|
|
42231
44424
|
await baseline.load();
|
|
42232
44425
|
const dashboardPort = options.port ?? config.dashboard.port;
|
|
@@ -42531,7 +44724,7 @@ async function main() {
|
|
|
42531
44724
|
const code = await runIdentityCommand2({ argv: args.slice(1) });
|
|
42532
44725
|
process.exit(code);
|
|
42533
44726
|
}
|
|
42534
|
-
if (args[0] === "agents") {
|
|
44727
|
+
if (args[0] === "agents" || args[0] === "agent") {
|
|
42535
44728
|
const { runAgentsCommand: runAgentsCommand2 } = await Promise.resolve().then(() => (init_agents(), agents_exports));
|
|
42536
44729
|
const code = await runAgentsCommand2({ argv: args.slice(1) });
|
|
42537
44730
|
process.exit(code);
|