@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.js
CHANGED
|
@@ -4450,9 +4450,35 @@ function validatePolicy(raw) {
|
|
|
4450
4450
|
};
|
|
4451
4451
|
delete merged.auto_deny;
|
|
4452
4452
|
return merged;
|
|
4453
|
-
})()
|
|
4453
|
+
})(),
|
|
4454
|
+
approval_redirect: parseApprovalRedirect(raw.approval_redirect)
|
|
4454
4455
|
};
|
|
4455
4456
|
}
|
|
4457
|
+
function parseApprovalRedirect(raw) {
|
|
4458
|
+
if (raw === void 0 || raw === null) {
|
|
4459
|
+
return { ...DEFAULT_APPROVAL_REDIRECT };
|
|
4460
|
+
}
|
|
4461
|
+
if (typeof raw !== "object") {
|
|
4462
|
+
return { ...DEFAULT_APPROVAL_REDIRECT };
|
|
4463
|
+
}
|
|
4464
|
+
const obj = raw;
|
|
4465
|
+
const enabled = typeof obj.enabled === "boolean" ? obj.enabled : DEFAULT_APPROVAL_REDIRECT.enabled;
|
|
4466
|
+
const modeRaw = obj.mode;
|
|
4467
|
+
let mode = DEFAULT_APPROVAL_REDIRECT.mode;
|
|
4468
|
+
if (modeRaw !== void 0) {
|
|
4469
|
+
if (modeRaw !== "replace" && modeRaw !== "notify") {
|
|
4470
|
+
throw new Error(
|
|
4471
|
+
`approval_redirect.mode must be "replace" or "notify" (got ${JSON.stringify(modeRaw)})`
|
|
4472
|
+
);
|
|
4473
|
+
}
|
|
4474
|
+
mode = modeRaw;
|
|
4475
|
+
}
|
|
4476
|
+
const result = { enabled, mode };
|
|
4477
|
+
if (obj.per_agent !== void 0 && typeof obj.per_agent === "object" && obj.per_agent !== null) {
|
|
4478
|
+
result.per_agent = obj.per_agent;
|
|
4479
|
+
}
|
|
4480
|
+
return result;
|
|
4481
|
+
}
|
|
4456
4482
|
function generateDefaultPolicyYaml() {
|
|
4457
4483
|
return `# Sanctuary Principal Policy v1
|
|
4458
4484
|
# This file controls what your agent can do without asking.
|
|
@@ -4531,6 +4557,7 @@ tier3_always_allow:
|
|
|
4531
4557
|
- handshake_status
|
|
4532
4558
|
- handshake_exchange
|
|
4533
4559
|
- handshake_verify_attestation
|
|
4560
|
+
- handshake_abort
|
|
4534
4561
|
- reputation_query_weighted
|
|
4535
4562
|
- federation_peers
|
|
4536
4563
|
- federation_trust_evaluate
|
|
@@ -4565,25 +4592,58 @@ tier3_always_allow:
|
|
|
4565
4592
|
approval_channel:
|
|
4566
4593
|
type: stderr
|
|
4567
4594
|
timeout_seconds: 300
|
|
4595
|
+
|
|
4596
|
+
# \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
|
|
4597
|
+
# Cross-harness approval-inbox redirect. When enabled, Tier 1/2 approvals
|
|
4598
|
+
# resolve via the unified approval inbox at /api/approval-inbox/* instead
|
|
4599
|
+
# of (or in addition to) the configured approval_channel above.
|
|
4600
|
+
#
|
|
4601
|
+
# mode:
|
|
4602
|
+
# replace: bypass the approval_channel entirely; the gate awaits a
|
|
4603
|
+
# decision from the inbox (default once enabled).
|
|
4604
|
+
# notify: fire BOTH the approval_channel and the inbox; first decision
|
|
4605
|
+
# wins. Right shape for harnesses that cannot fully suppress
|
|
4606
|
+
# their local approval prompt (e.g. Mastra-class).
|
|
4607
|
+
approval_redirect:
|
|
4608
|
+
enabled: false
|
|
4609
|
+
mode: replace
|
|
4568
4610
|
`;
|
|
4569
4611
|
}
|
|
4570
4612
|
async function loadPrincipalPolicy(storagePath) {
|
|
4571
4613
|
const policyPath = join(storagePath, "principal-policy.yaml");
|
|
4614
|
+
let content;
|
|
4615
|
+
try {
|
|
4616
|
+
content = await readFile(policyPath, "utf-8");
|
|
4617
|
+
} catch (err) {
|
|
4618
|
+
const code = err?.code;
|
|
4619
|
+
if (code === "ENOENT") {
|
|
4620
|
+
const defaultYaml = generateDefaultPolicyYaml();
|
|
4621
|
+
try {
|
|
4622
|
+
await writeFile(policyPath, defaultYaml, "utf-8");
|
|
4623
|
+
await chmod(policyPath, 384);
|
|
4624
|
+
} catch (writeErr) {
|
|
4625
|
+
console.warn(
|
|
4626
|
+
`Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
|
|
4627
|
+
);
|
|
4628
|
+
}
|
|
4629
|
+
return Object.freeze({ ...DEFAULT_POLICY });
|
|
4630
|
+
}
|
|
4631
|
+
throw new MalformedPrincipalPolicyError(
|
|
4632
|
+
policyPath,
|
|
4633
|
+
`read failed: ${err.message}`
|
|
4634
|
+
);
|
|
4635
|
+
}
|
|
4572
4636
|
try {
|
|
4573
|
-
const content = await readFile(policyPath, "utf-8");
|
|
4574
4637
|
const policy = parsePolicy(content);
|
|
4575
4638
|
return Object.freeze(policy);
|
|
4576
|
-
} catch {
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
} catch {
|
|
4582
|
-
}
|
|
4583
|
-
return Object.freeze({ ...DEFAULT_POLICY });
|
|
4639
|
+
} catch (parseErr) {
|
|
4640
|
+
throw new MalformedPrincipalPolicyError(
|
|
4641
|
+
policyPath,
|
|
4642
|
+
parseErr.message
|
|
4643
|
+
);
|
|
4584
4644
|
}
|
|
4585
4645
|
}
|
|
4586
|
-
var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_POLICY;
|
|
4646
|
+
var DEFAULT_TIER2, DEFAULT_CHANNEL, DEFAULT_APPROVAL_REDIRECT, DEFAULT_POLICY, MalformedPrincipalPolicyError;
|
|
4587
4647
|
var init_loader = __esm({
|
|
4588
4648
|
"src/principal-policy/loader.ts"() {
|
|
4589
4649
|
DEFAULT_TIER2 = {
|
|
@@ -4600,6 +4660,10 @@ var init_loader = __esm({
|
|
|
4600
4660
|
// SEC-002: auto_deny is not configurable. Timeout always denies.
|
|
4601
4661
|
// Field omitted intentionally — all channels hardcode deny on timeout.
|
|
4602
4662
|
};
|
|
4663
|
+
DEFAULT_APPROVAL_REDIRECT = {
|
|
4664
|
+
enabled: false,
|
|
4665
|
+
mode: "replace"
|
|
4666
|
+
};
|
|
4603
4667
|
DEFAULT_POLICY = {
|
|
4604
4668
|
version: 1,
|
|
4605
4669
|
tier1_always_approve: [
|
|
@@ -4673,6 +4737,7 @@ var init_loader = __esm({
|
|
|
4673
4737
|
"handshake_status",
|
|
4674
4738
|
"handshake_exchange",
|
|
4675
4739
|
"handshake_verify_attestation",
|
|
4740
|
+
"handshake_abort",
|
|
4676
4741
|
"reputation_query_weighted",
|
|
4677
4742
|
"federation_peers",
|
|
4678
4743
|
"federation_trust_evaluate",
|
|
@@ -4714,7 +4779,22 @@ var init_loader = __esm({
|
|
|
4714
4779
|
"compliance_eu_ai_act_annex_iii_classify"
|
|
4715
4780
|
// Read-only; rule-based Annex III classifier
|
|
4716
4781
|
],
|
|
4717
|
-
approval_channel: DEFAULT_CHANNEL
|
|
4782
|
+
approval_channel: DEFAULT_CHANNEL,
|
|
4783
|
+
approval_redirect: DEFAULT_APPROVAL_REDIRECT
|
|
4784
|
+
};
|
|
4785
|
+
MalformedPrincipalPolicyError = class extends Error {
|
|
4786
|
+
constructor(policyPath, reason) {
|
|
4787
|
+
super(
|
|
4788
|
+
`Principal policy at ${policyPath} is malformed and cannot be loaded.
|
|
4789
|
+
Reason: ${reason}
|
|
4790
|
+
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.`
|
|
4791
|
+
);
|
|
4792
|
+
this.policyPath = policyPath;
|
|
4793
|
+
this.reason = reason;
|
|
4794
|
+
this.name = "MalformedPrincipalPolicyError";
|
|
4795
|
+
}
|
|
4796
|
+
policyPath;
|
|
4797
|
+
reason;
|
|
4718
4798
|
};
|
|
4719
4799
|
}
|
|
4720
4800
|
});
|
|
@@ -4935,7 +5015,7 @@ function deepSortKeys(obj) {
|
|
|
4935
5015
|
return sorted;
|
|
4936
5016
|
}
|
|
4937
5017
|
function canonicalizeForSigning(body) {
|
|
4938
|
-
return JSON.stringify(deepSortKeys(body));
|
|
5018
|
+
return JSON.stringify(deepSortKeys(body)).normalize("NFC");
|
|
4939
5019
|
}
|
|
4940
5020
|
var init_types = __esm({
|
|
4941
5021
|
"src/shr/types.ts"() {
|
|
@@ -12434,7 +12514,7 @@ var init_auth_middleware = __esm({
|
|
|
12434
12514
|
});
|
|
12435
12515
|
|
|
12436
12516
|
// src/hub/constants.ts
|
|
12437
|
-
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;
|
|
12517
|
+
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;
|
|
12438
12518
|
var init_constants3 = __esm({
|
|
12439
12519
|
"src/hub/constants.ts"() {
|
|
12440
12520
|
HUB_API_PREFIX = "/api/hub";
|
|
@@ -12462,6 +12542,16 @@ var init_constants3 = __esm({
|
|
|
12462
12542
|
*/
|
|
12463
12543
|
CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
|
|
12464
12544
|
CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
|
|
12545
|
+
/**
|
|
12546
|
+
* Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
|
|
12547
|
+
* scrollback, and operator-initiated thread delete. Distinct from the
|
|
12548
|
+
* v1.2 `/history` route, which surfaces the active in-session thread
|
|
12549
|
+
* shape; the new routes target persisted multi-thread memory used by
|
|
12550
|
+
* v1.3 conversational sovereignty depth.
|
|
12551
|
+
*/
|
|
12552
|
+
CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
|
|
12553
|
+
CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
|
|
12554
|
+
CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
|
|
12465
12555
|
/**
|
|
12466
12556
|
* Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
|
|
12467
12557
|
* recent activity feed, pending Tier 1 approvals routed through this
|
|
@@ -12485,6 +12575,10 @@ var init_constants3 = __esm({
|
|
|
12485
12575
|
];
|
|
12486
12576
|
HUB_ACTIVITY_DEFAULT_LIMIT = 50;
|
|
12487
12577
|
HUB_ACTIVITY_MAX_LIMIT = 500;
|
|
12578
|
+
HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
|
|
12579
|
+
HUB_CHAT_THREADS_MAX_LIMIT = 500;
|
|
12580
|
+
HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
|
|
12581
|
+
HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
|
|
12488
12582
|
HUB_INBOX_DEFAULT_LIMIT = 100;
|
|
12489
12583
|
HUB_INBOX_MAX_LIMIT = 500;
|
|
12490
12584
|
HUB_AGENTS_DEFAULT_LIMIT = 100;
|
|
@@ -12667,6 +12761,23 @@ function checkChatMessage(value) {
|
|
|
12667
12761
|
}
|
|
12668
12762
|
return trimmed;
|
|
12669
12763
|
}
|
|
12764
|
+
function matchConciergeThreadRoute(path) {
|
|
12765
|
+
const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
|
|
12766
|
+
if (!path.startsWith(prefix)) return null;
|
|
12767
|
+
const rest = path.slice(prefix.length);
|
|
12768
|
+
if (rest.length === 0 || rest.includes("/")) return null;
|
|
12769
|
+
const decoded = decodeURIComponent(rest);
|
|
12770
|
+
if (decoded.length === 0) return null;
|
|
12771
|
+
return { threadId: decoded };
|
|
12772
|
+
}
|
|
12773
|
+
function parseSince(raw) {
|
|
12774
|
+
if (raw === null || raw === "") return void 0;
|
|
12775
|
+
const parsed = Number.parseInt(raw, 10);
|
|
12776
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
12777
|
+
throw new HubValidationError("since must be a non-negative integer");
|
|
12778
|
+
}
|
|
12779
|
+
return parsed;
|
|
12780
|
+
}
|
|
12670
12781
|
function matchInboxRoute(path) {
|
|
12671
12782
|
const prefix = `${HUB_API_PREFIX}/inbox/`;
|
|
12672
12783
|
if (!path.startsWith(prefix)) return null;
|
|
@@ -12850,6 +12961,47 @@ async function handleHubRoute(deps, req, res) {
|
|
|
12850
12961
|
writeJSON2(res, 200, { ok: true, data: { messages } });
|
|
12851
12962
|
return true;
|
|
12852
12963
|
}
|
|
12964
|
+
if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
|
|
12965
|
+
const limit = parseLimit(
|
|
12966
|
+
url.searchParams.get("limit"),
|
|
12967
|
+
HUB_CHAT_THREADS_DEFAULT_LIMIT,
|
|
12968
|
+
HUB_CHAT_THREADS_MAX_LIMIT
|
|
12969
|
+
);
|
|
12970
|
+
const threads = await deps.service.listConciergeMemoryThreads({ limit });
|
|
12971
|
+
writeJSON2(res, 200, { ok: true, data: { threads } });
|
|
12972
|
+
return true;
|
|
12973
|
+
}
|
|
12974
|
+
{
|
|
12975
|
+
const threadMatch = matchConciergeThreadRoute(path);
|
|
12976
|
+
if (threadMatch) {
|
|
12977
|
+
if (method === "GET") {
|
|
12978
|
+
const since = parseSince(url.searchParams.get("since"));
|
|
12979
|
+
const limit = parseLimit(
|
|
12980
|
+
url.searchParams.get("limit"),
|
|
12981
|
+
HUB_CHAT_TURNS_DEFAULT_LIMIT,
|
|
12982
|
+
HUB_CHAT_TURNS_MAX_LIMIT
|
|
12983
|
+
);
|
|
12984
|
+
const readOpts = { limit };
|
|
12985
|
+
if (since !== void 0) readOpts.sinceTurnId = since;
|
|
12986
|
+
const turns = await deps.service.readConciergeMemoryThread(
|
|
12987
|
+
threadMatch.threadId,
|
|
12988
|
+
readOpts
|
|
12989
|
+
);
|
|
12990
|
+
writeJSON2(res, 200, { ok: true, data: { turns } });
|
|
12991
|
+
return true;
|
|
12992
|
+
}
|
|
12993
|
+
if (method === "DELETE") {
|
|
12994
|
+
const removed = await deps.service.deleteConciergeMemoryThread(
|
|
12995
|
+
threadMatch.threadId
|
|
12996
|
+
);
|
|
12997
|
+
writeJSON2(res, removed ? 200 : 404, {
|
|
12998
|
+
ok: removed,
|
|
12999
|
+
data: { thread_id: threadMatch.threadId, removed }
|
|
13000
|
+
});
|
|
13001
|
+
return true;
|
|
13002
|
+
}
|
|
13003
|
+
}
|
|
13004
|
+
}
|
|
12853
13005
|
writeJSON2(res, 404, { ok: false, error: "not_found", path });
|
|
12854
13006
|
return true;
|
|
12855
13007
|
} catch (err) {
|
|
@@ -16972,6 +17124,168 @@ var init_dispatch = __esm({
|
|
|
16972
17124
|
init_intelligence_api_router();
|
|
16973
17125
|
}
|
|
16974
17126
|
});
|
|
17127
|
+
|
|
17128
|
+
// src/principal-policy/approval-aggregator-routes.ts
|
|
17129
|
+
function writeJSON4(res, status, payload) {
|
|
17130
|
+
res.writeHead(status, {
|
|
17131
|
+
"Content-Type": "application/json",
|
|
17132
|
+
"Cache-Control": "no-store"
|
|
17133
|
+
});
|
|
17134
|
+
res.end(JSON.stringify(payload));
|
|
17135
|
+
}
|
|
17136
|
+
function parseLimit2(raw, defaultValue, max) {
|
|
17137
|
+
if (raw === null || raw === "") return defaultValue;
|
|
17138
|
+
const parsed = Number.parseInt(raw, 10);
|
|
17139
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
17140
|
+
return defaultValue;
|
|
17141
|
+
}
|
|
17142
|
+
return Math.min(parsed, max);
|
|
17143
|
+
}
|
|
17144
|
+
function isStatusFilter(value) {
|
|
17145
|
+
return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
|
|
17146
|
+
}
|
|
17147
|
+
function matchEntryRoute(path) {
|
|
17148
|
+
const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
|
|
17149
|
+
if (!path.startsWith(prefix)) return null;
|
|
17150
|
+
const rest = path.slice(prefix.length);
|
|
17151
|
+
if (rest.length === 0) return null;
|
|
17152
|
+
const slash = rest.indexOf("/");
|
|
17153
|
+
if (slash === -1) {
|
|
17154
|
+
return { aggregatorId: decodeURIComponent(rest), action: null };
|
|
17155
|
+
}
|
|
17156
|
+
return {
|
|
17157
|
+
aggregatorId: decodeURIComponent(rest.slice(0, slash)),
|
|
17158
|
+
action: rest.slice(slash + 1)
|
|
17159
|
+
};
|
|
17160
|
+
}
|
|
17161
|
+
async function handleStream2(deps, res) {
|
|
17162
|
+
res.writeHead(200, {
|
|
17163
|
+
"Content-Type": "text/event-stream",
|
|
17164
|
+
"Cache-Control": "no-cache, no-transform",
|
|
17165
|
+
Connection: "keep-alive",
|
|
17166
|
+
"X-Accel-Buffering": "no"
|
|
17167
|
+
});
|
|
17168
|
+
const initial = await deps.aggregator.list({ status: "pending" });
|
|
17169
|
+
res.write(
|
|
17170
|
+
`event: approval_inbox_snapshot
|
|
17171
|
+
data: ${JSON.stringify({ entries: initial })}
|
|
17172
|
+
|
|
17173
|
+
`
|
|
17174
|
+
);
|
|
17175
|
+
const unsubscribe = deps.aggregator.onEvent((event) => {
|
|
17176
|
+
try {
|
|
17177
|
+
res.write(
|
|
17178
|
+
`event: approval_inbox_${event.type}
|
|
17179
|
+
data: ${JSON.stringify(event.entry)}
|
|
17180
|
+
|
|
17181
|
+
`
|
|
17182
|
+
);
|
|
17183
|
+
} catch {
|
|
17184
|
+
}
|
|
17185
|
+
});
|
|
17186
|
+
const keepAlive = setInterval(() => {
|
|
17187
|
+
try {
|
|
17188
|
+
res.write(": keepalive\n\n");
|
|
17189
|
+
} catch {
|
|
17190
|
+
}
|
|
17191
|
+
}, 25e3);
|
|
17192
|
+
const cleanup = () => {
|
|
17193
|
+
clearInterval(keepAlive);
|
|
17194
|
+
unsubscribe();
|
|
17195
|
+
};
|
|
17196
|
+
res.on("close", cleanup);
|
|
17197
|
+
res.on("error", cleanup);
|
|
17198
|
+
}
|
|
17199
|
+
async function handleApprovalInboxRoute(deps, req, res) {
|
|
17200
|
+
const host = req.headers.host || "localhost";
|
|
17201
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
17202
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
17203
|
+
const path = url.pathname;
|
|
17204
|
+
if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
|
|
17205
|
+
return false;
|
|
17206
|
+
}
|
|
17207
|
+
const checkAuth = authMiddleware(deps.authConfig);
|
|
17208
|
+
if (!checkAuth(req, res, url)) return true;
|
|
17209
|
+
try {
|
|
17210
|
+
if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
|
|
17211
|
+
await handleStream2(deps, res);
|
|
17212
|
+
return true;
|
|
17213
|
+
}
|
|
17214
|
+
if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
|
|
17215
|
+
const limit = parseLimit2(
|
|
17216
|
+
url.searchParams.get("limit"),
|
|
17217
|
+
APPROVAL_INBOX_DEFAULT_LIMIT,
|
|
17218
|
+
APPROVAL_INBOX_MAX_LIMIT
|
|
17219
|
+
);
|
|
17220
|
+
const statusRaw = url.searchParams.get("status");
|
|
17221
|
+
const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
|
|
17222
|
+
const sinceTs = url.searchParams.get("since") ?? void 0;
|
|
17223
|
+
const entries = await deps.aggregator.list({
|
|
17224
|
+
status,
|
|
17225
|
+
limit,
|
|
17226
|
+
...sinceTs !== void 0 ? { sinceTs } : {}
|
|
17227
|
+
});
|
|
17228
|
+
writeJSON4(res, 200, { ok: true, data: { entries } });
|
|
17229
|
+
return true;
|
|
17230
|
+
}
|
|
17231
|
+
const entryMatch = matchEntryRoute(path);
|
|
17232
|
+
if (entryMatch === null) {
|
|
17233
|
+
writeJSON4(res, 404, { ok: false, error: "not_found", path });
|
|
17234
|
+
return true;
|
|
17235
|
+
}
|
|
17236
|
+
if (method === "GET" && entryMatch.action === null) {
|
|
17237
|
+
const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
|
|
17238
|
+
const entry = entries.find(
|
|
17239
|
+
(e) => e.aggregator_id === entryMatch.aggregatorId
|
|
17240
|
+
);
|
|
17241
|
+
if (!entry) {
|
|
17242
|
+
writeJSON4(res, 404, { ok: false, error: "not_found" });
|
|
17243
|
+
return true;
|
|
17244
|
+
}
|
|
17245
|
+
const payload = await deps.aggregator.getFullPayload(
|
|
17246
|
+
entryMatch.aggregatorId
|
|
17247
|
+
);
|
|
17248
|
+
writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
|
|
17249
|
+
return true;
|
|
17250
|
+
}
|
|
17251
|
+
if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
|
|
17252
|
+
const decision = entryMatch.action === "approve" ? "approved" : "denied";
|
|
17253
|
+
const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
|
|
17254
|
+
try {
|
|
17255
|
+
const entry = await deps.aggregator.resolve(
|
|
17256
|
+
entryMatch.aggregatorId,
|
|
17257
|
+
decision,
|
|
17258
|
+
operatorId
|
|
17259
|
+
);
|
|
17260
|
+
writeJSON4(res, 200, { ok: true, data: { entry } });
|
|
17261
|
+
} catch (err) {
|
|
17262
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17263
|
+
if (msg === "approval-aggregator: not_found") {
|
|
17264
|
+
writeJSON4(res, 404, { ok: false, error: "not_found" });
|
|
17265
|
+
} else {
|
|
17266
|
+
writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
|
|
17267
|
+
}
|
|
17268
|
+
}
|
|
17269
|
+
return true;
|
|
17270
|
+
}
|
|
17271
|
+
writeJSON4(res, 404, { ok: false, error: "not_found", path });
|
|
17272
|
+
return true;
|
|
17273
|
+
} catch (err) {
|
|
17274
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17275
|
+
writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
|
|
17276
|
+
return true;
|
|
17277
|
+
}
|
|
17278
|
+
}
|
|
17279
|
+
var APPROVAL_INBOX_API_PREFIX, APPROVAL_INBOX_OPERATOR_DEFAULT, APPROVAL_INBOX_DEFAULT_LIMIT, APPROVAL_INBOX_MAX_LIMIT;
|
|
17280
|
+
var init_approval_aggregator_routes = __esm({
|
|
17281
|
+
"src/principal-policy/approval-aggregator-routes.ts"() {
|
|
17282
|
+
init_auth_middleware();
|
|
17283
|
+
APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
|
|
17284
|
+
APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
|
|
17285
|
+
APPROVAL_INBOX_DEFAULT_LIMIT = 50;
|
|
17286
|
+
APPROVAL_INBOX_MAX_LIMIT = 200;
|
|
17287
|
+
}
|
|
17288
|
+
});
|
|
16975
17289
|
function isDashboardViewRoute(method, path) {
|
|
16976
17290
|
if (method !== "GET") return false;
|
|
16977
17291
|
return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
|
|
@@ -16985,6 +17299,7 @@ var init_dashboard = __esm({
|
|
|
16985
17299
|
init_fortress_view();
|
|
16986
17300
|
init_system_prompt_generator();
|
|
16987
17301
|
init_dispatch();
|
|
17302
|
+
init_approval_aggregator_routes();
|
|
16988
17303
|
SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
|
|
16989
17304
|
SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
|
|
16990
17305
|
MAX_SESSIONS = 1e3;
|
|
@@ -17044,6 +17359,14 @@ var init_dashboard = __esm({
|
|
|
17044
17359
|
* regardless. Default route flip is deferred to v1.2.
|
|
17045
17360
|
*/
|
|
17046
17361
|
v11Bindings = null;
|
|
17362
|
+
/**
|
|
17363
|
+
* v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
|
|
17364
|
+
* additively at `/api/approval-inbox/*` when set. Legacy approval
|
|
17365
|
+
* routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
|
|
17366
|
+
* aggregator is a passive subscriber to the gate; the routes here are
|
|
17367
|
+
* the operator-facing query / decision surface.
|
|
17368
|
+
*/
|
|
17369
|
+
approvalAggregator = null;
|
|
17047
17370
|
constructor(config) {
|
|
17048
17371
|
this.config = config;
|
|
17049
17372
|
this.authToken = config.auth_token;
|
|
@@ -17094,6 +17417,34 @@ var init_dashboard = __esm({
|
|
|
17094
17417
|
setV11Bindings(bindings) {
|
|
17095
17418
|
this.v11Bindings = bindings;
|
|
17096
17419
|
}
|
|
17420
|
+
/**
|
|
17421
|
+
* v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
|
|
17422
|
+
* aggregator. Once set, requests to `/api/approval-inbox/*` route
|
|
17423
|
+
* through `handleApprovalInboxRoute`. Pass `null` to detach (used by
|
|
17424
|
+
* tests + during shutdown).
|
|
17425
|
+
*/
|
|
17426
|
+
setApprovalAggregator(aggregator) {
|
|
17427
|
+
this.approvalAggregator = aggregator;
|
|
17428
|
+
}
|
|
17429
|
+
/**
|
|
17430
|
+
* v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
|
|
17431
|
+
* before the legacy approval route table. Returns true when served.
|
|
17432
|
+
*/
|
|
17433
|
+
async dispatchApprovalInbox(req, res) {
|
|
17434
|
+
if (!this.approvalAggregator) return false;
|
|
17435
|
+
return handleApprovalInboxRoute(
|
|
17436
|
+
{
|
|
17437
|
+
authConfig: {
|
|
17438
|
+
loopbackAutoAuth: this._autoAuthLocalhost,
|
|
17439
|
+
...this.authToken !== void 0 ? { authToken: this.authToken } : {}
|
|
17440
|
+
},
|
|
17441
|
+
aggregator: this.approvalAggregator,
|
|
17442
|
+
operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
|
|
17443
|
+
},
|
|
17444
|
+
req,
|
|
17445
|
+
res
|
|
17446
|
+
);
|
|
17447
|
+
}
|
|
17097
17448
|
/**
|
|
17098
17449
|
* v1.1 dispatch entry point. Called from `handleRequest` before the
|
|
17099
17450
|
* legacy route table. Returns true when the request was served by v1.1
|
|
@@ -17469,6 +17820,18 @@ var init_dashboard = __esm({
|
|
|
17469
17820
|
res.end();
|
|
17470
17821
|
return;
|
|
17471
17822
|
}
|
|
17823
|
+
if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
|
|
17824
|
+
this.dispatchApprovalInbox(req, res).then((handled) => {
|
|
17825
|
+
if (handled) return;
|
|
17826
|
+
this.handleLegacyRequest(req, res, url, method);
|
|
17827
|
+
}).catch(() => {
|
|
17828
|
+
if (!res.headersSent) {
|
|
17829
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
17830
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
17831
|
+
}
|
|
17832
|
+
});
|
|
17833
|
+
return;
|
|
17834
|
+
}
|
|
17472
17835
|
if (this.v11Bindings) {
|
|
17473
17836
|
this.dispatchV11(req, res, url, method).then((handled) => {
|
|
17474
17837
|
if (handled) return;
|
|
@@ -19512,14 +19875,25 @@ var init_gate = __esm({
|
|
|
19512
19875
|
auditLog;
|
|
19513
19876
|
injectionDetector;
|
|
19514
19877
|
onInjectionAlert;
|
|
19878
|
+
onApprovalEvent;
|
|
19515
19879
|
proxyTierResolver;
|
|
19516
|
-
constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
|
|
19880
|
+
constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
|
|
19517
19881
|
this.policy = policy;
|
|
19518
19882
|
this.baseline = baseline;
|
|
19519
19883
|
this.channel = channel;
|
|
19520
19884
|
this.auditLog = auditLog;
|
|
19521
19885
|
this.injectionDetector = injectionDetector ?? new InjectionDetector();
|
|
19522
19886
|
this.onInjectionAlert = onInjectionAlert;
|
|
19887
|
+
this.onApprovalEvent = onApprovalEvent;
|
|
19888
|
+
}
|
|
19889
|
+
/**
|
|
19890
|
+
* Set the approval-event callback after construction. Used by the
|
|
19891
|
+
* Upsilon-1 wire-up when the aggregator is constructed alongside the
|
|
19892
|
+
* gate. The aggregator subscribes through this setter rather than the
|
|
19893
|
+
* constructor so existing call sites continue to work unchanged.
|
|
19894
|
+
*/
|
|
19895
|
+
setApprovalEventCallback(cb) {
|
|
19896
|
+
this.onApprovalEvent = cb;
|
|
19523
19897
|
}
|
|
19524
19898
|
/**
|
|
19525
19899
|
* Set the proxy tier resolver. Called after the proxy router is initialized.
|
|
@@ -19753,21 +20127,105 @@ var init_gate = __esm({
|
|
|
19753
20127
|
}
|
|
19754
20128
|
/**
|
|
19755
20129
|
* Request approval from the human principal.
|
|
20130
|
+
*
|
|
20131
|
+
* Fail-closed contract (full-sweep #49): if the channel throws (network
|
|
20132
|
+
* down, callback unreachable, dashboard SSE peer dropped, webhook DNS
|
|
20133
|
+
* failure, etc.), the gate denies the operation and audit-logs the cause.
|
|
20134
|
+
* Channel-internal timeouts already resolve with decision: "deny" per
|
|
20135
|
+
* SEC-002; this catch covers the remaining "channel raised" path so an
|
|
20136
|
+
* unhandled rejection cannot turn into an indeterminate state at the gate.
|
|
19756
20137
|
*/
|
|
19757
20138
|
async requestApproval(operation, tier, reason, context) {
|
|
20139
|
+
const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
19758
20140
|
const request = {
|
|
19759
20141
|
operation,
|
|
19760
20142
|
tier,
|
|
19761
20143
|
reason,
|
|
19762
20144
|
context,
|
|
19763
|
-
timestamp:
|
|
20145
|
+
timestamp: requestTimestamp
|
|
19764
20146
|
};
|
|
19765
|
-
const
|
|
20147
|
+
const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
|
|
20148
|
+
if (this.onApprovalEvent) {
|
|
20149
|
+
try {
|
|
20150
|
+
this.onApprovalEvent({
|
|
20151
|
+
phase: "requested",
|
|
20152
|
+
operation,
|
|
20153
|
+
tier,
|
|
20154
|
+
reason,
|
|
20155
|
+
context,
|
|
20156
|
+
request_timestamp: requestTimestamp,
|
|
20157
|
+
correlation_id: correlationId
|
|
20158
|
+
});
|
|
20159
|
+
} catch {
|
|
20160
|
+
}
|
|
20161
|
+
}
|
|
20162
|
+
let response;
|
|
20163
|
+
try {
|
|
20164
|
+
response = await this.channel.requestApproval(request);
|
|
20165
|
+
} catch (err) {
|
|
20166
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
20167
|
+
const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
20168
|
+
this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
|
|
20169
|
+
tier,
|
|
20170
|
+
reason,
|
|
20171
|
+
decided_by: "channel_failure",
|
|
20172
|
+
channel_error: errMessage
|
|
20173
|
+
});
|
|
20174
|
+
if (this.onApprovalEvent) {
|
|
20175
|
+
try {
|
|
20176
|
+
this.onApprovalEvent({
|
|
20177
|
+
phase: "resolved",
|
|
20178
|
+
operation,
|
|
20179
|
+
tier,
|
|
20180
|
+
reason,
|
|
20181
|
+
context,
|
|
20182
|
+
request_timestamp: requestTimestamp,
|
|
20183
|
+
resolution: {
|
|
20184
|
+
decision: "deny",
|
|
20185
|
+
decided_at: decidedAt,
|
|
20186
|
+
decided_by: "channel_failure"
|
|
20187
|
+
},
|
|
20188
|
+
correlation_id: correlationId
|
|
20189
|
+
});
|
|
20190
|
+
} catch {
|
|
20191
|
+
}
|
|
20192
|
+
}
|
|
20193
|
+
return {
|
|
20194
|
+
allowed: false,
|
|
20195
|
+
tier,
|
|
20196
|
+
reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
|
|
20197
|
+
approval_required: true,
|
|
20198
|
+
approval_response: {
|
|
20199
|
+
decision: "deny",
|
|
20200
|
+
decided_at: decidedAt,
|
|
20201
|
+
decided_by: "channel_failure"
|
|
20202
|
+
}
|
|
20203
|
+
};
|
|
20204
|
+
}
|
|
19766
20205
|
this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
|
|
19767
20206
|
tier,
|
|
19768
20207
|
reason,
|
|
19769
20208
|
decided_by: response.decided_by
|
|
19770
20209
|
});
|
|
20210
|
+
if (this.onApprovalEvent) {
|
|
20211
|
+
try {
|
|
20212
|
+
this.onApprovalEvent({
|
|
20213
|
+
phase: "resolved",
|
|
20214
|
+
operation,
|
|
20215
|
+
tier,
|
|
20216
|
+
reason,
|
|
20217
|
+
context,
|
|
20218
|
+
request_timestamp: requestTimestamp,
|
|
20219
|
+
resolution: {
|
|
20220
|
+
decision: response.decision,
|
|
20221
|
+
decided_at: response.decided_at,
|
|
20222
|
+
decided_by: response.decided_by
|
|
20223
|
+
},
|
|
20224
|
+
correlation_id: correlationId
|
|
20225
|
+
});
|
|
20226
|
+
} catch {
|
|
20227
|
+
}
|
|
20228
|
+
}
|
|
19771
20229
|
return {
|
|
19772
20230
|
allowed: response.decision === "approve",
|
|
19773
20231
|
tier,
|
|
@@ -19802,6 +20260,524 @@ var init_gate = __esm({
|
|
|
19802
20260
|
};
|
|
19803
20261
|
}
|
|
19804
20262
|
});
|
|
20263
|
+
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;
|
|
20264
|
+
var init_approval_aggregator = __esm({
|
|
20265
|
+
"src/principal-policy/approval-aggregator.ts"() {
|
|
20266
|
+
init_encryption();
|
|
20267
|
+
init_key_derivation();
|
|
20268
|
+
init_encoding();
|
|
20269
|
+
APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
|
|
20270
|
+
APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
|
|
20271
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS = {
|
|
20272
|
+
AGGREGATED: "cross_harness_approval_aggregated",
|
|
20273
|
+
RESOLVED: "cross_harness_approval_resolved",
|
|
20274
|
+
DEDUPED: "cross_harness_approval_deduped"
|
|
20275
|
+
};
|
|
20276
|
+
DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
|
|
20277
|
+
DEFAULT_MAX_LIST_LIMIT = 200;
|
|
20278
|
+
DEFAULT_LIST_PAGE_SIZE = 50;
|
|
20279
|
+
ApprovalAggregator = class {
|
|
20280
|
+
storage;
|
|
20281
|
+
encryptionKey;
|
|
20282
|
+
auditLog;
|
|
20283
|
+
identityId;
|
|
20284
|
+
fortressId;
|
|
20285
|
+
pendingTtlMs;
|
|
20286
|
+
maxListLimit;
|
|
20287
|
+
now;
|
|
20288
|
+
resolveSourceContext;
|
|
20289
|
+
resolveHubInboxItemId;
|
|
20290
|
+
/** Cached entries by `aggregator_id`. */
|
|
20291
|
+
entries = /* @__PURE__ */ new Map();
|
|
20292
|
+
/** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
|
|
20293
|
+
dedupIndex = /* @__PURE__ */ new Map();
|
|
20294
|
+
/** Correlation index: gate `correlation_id` -> aggregator_id. */
|
|
20295
|
+
correlationIndex = /* @__PURE__ */ new Map();
|
|
20296
|
+
/** Original request payloads kept in-memory for `getFullPayload()`. */
|
|
20297
|
+
fullPayloads = /* @__PURE__ */ new Map();
|
|
20298
|
+
/** Has the aggregator hydrated persisted entries on this process? */
|
|
20299
|
+
hydrated = false;
|
|
20300
|
+
/** Active SSE listeners. */
|
|
20301
|
+
listeners = /* @__PURE__ */ new Set();
|
|
20302
|
+
constructor(deps) {
|
|
20303
|
+
this.storage = deps.storage;
|
|
20304
|
+
this.encryptionKey = derivePurposeKey(
|
|
20305
|
+
deps.masterKey,
|
|
20306
|
+
APPROVAL_AGGREGATOR_HKDF_INFO
|
|
20307
|
+
);
|
|
20308
|
+
this.auditLog = deps.auditLog;
|
|
20309
|
+
this.identityId = deps.identityId;
|
|
20310
|
+
this.fortressId = deps.fortressId;
|
|
20311
|
+
this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
|
|
20312
|
+
this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
|
|
20313
|
+
this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
20314
|
+
this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
|
|
20315
|
+
source_harness: this.fortressId,
|
|
20316
|
+
source_agent_id: this.fortressId
|
|
20317
|
+
}));
|
|
20318
|
+
this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
|
|
20319
|
+
}
|
|
20320
|
+
/**
|
|
20321
|
+
* Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
|
|
20322
|
+
* use this to forward aggregator emissions to the dashboard.
|
|
20323
|
+
*/
|
|
20324
|
+
onEvent(listener) {
|
|
20325
|
+
this.listeners.add(listener);
|
|
20326
|
+
return () => this.listeners.delete(listener);
|
|
20327
|
+
}
|
|
20328
|
+
/**
|
|
20329
|
+
* Ingest a gate event. Returns the aggregator entry on first sight,
|
|
20330
|
+
* `null` when deduped. Resolution events update the existing record;
|
|
20331
|
+
* unmatched resolutions are dropped silently (caller's gate emitted a
|
|
20332
|
+
* resolved-without-requested pair, which the aggregator does not invent
|
|
20333
|
+
* a record for).
|
|
20334
|
+
*/
|
|
20335
|
+
async ingest(event) {
|
|
20336
|
+
await this.hydrate();
|
|
20337
|
+
if (event.phase === "requested") {
|
|
20338
|
+
return this.ingestRequested(event);
|
|
20339
|
+
}
|
|
20340
|
+
if (event.phase === "resolved") {
|
|
20341
|
+
return this.ingestResolved(event);
|
|
20342
|
+
}
|
|
20343
|
+
return null;
|
|
20344
|
+
}
|
|
20345
|
+
/**
|
|
20346
|
+
* List pending or recently resolved entries. Pending entries past TTL
|
|
20347
|
+
* are lazily transitioned to `expired` and persisted before the list
|
|
20348
|
+
* snapshot is returned.
|
|
20349
|
+
*/
|
|
20350
|
+
async list(opts) {
|
|
20351
|
+
await this.hydrate();
|
|
20352
|
+
await this.expireStale();
|
|
20353
|
+
const limit = Math.min(
|
|
20354
|
+
opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
|
|
20355
|
+
this.maxListLimit
|
|
20356
|
+
);
|
|
20357
|
+
const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
|
|
20358
|
+
const matching = [];
|
|
20359
|
+
for (const entry of this.entries.values()) {
|
|
20360
|
+
if (opts?.status && entry.status !== opts.status) continue;
|
|
20361
|
+
if (Date.parse(entry.created_at) < sinceMs) continue;
|
|
20362
|
+
matching.push(entry);
|
|
20363
|
+
}
|
|
20364
|
+
matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
20365
|
+
return matching.slice(0, limit);
|
|
20366
|
+
}
|
|
20367
|
+
/**
|
|
20368
|
+
* Return the original (unhashed) request payload for the entry. Returns
|
|
20369
|
+
* `null` when the entry is unknown or the payload was evicted (e.g. the
|
|
20370
|
+
* process restarted; payloads are in-memory only at v1.3 Upsilon-1).
|
|
20371
|
+
*/
|
|
20372
|
+
async getFullPayload(aggregatorId) {
|
|
20373
|
+
await this.hydrate();
|
|
20374
|
+
if (!this.entries.has(aggregatorId)) return null;
|
|
20375
|
+
return this.fullPayloads.get(aggregatorId) ?? null;
|
|
20376
|
+
}
|
|
20377
|
+
/**
|
|
20378
|
+
* Resolve an entry. Used by both:
|
|
20379
|
+
* 1. The gate wire-up on channel-decision return.
|
|
20380
|
+
* 2. The HTTP `approve`/`deny` routes when an operator clicks.
|
|
20381
|
+
*
|
|
20382
|
+
* Idempotent: resolving an already-resolved entry is a no-op (the record
|
|
20383
|
+
* keeps its first decision and the audit log is not double-fired).
|
|
20384
|
+
* Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
|
|
20385
|
+
* routes return 404.
|
|
20386
|
+
*/
|
|
20387
|
+
async resolve(aggregatorId, decision, operatorId) {
|
|
20388
|
+
await this.hydrate();
|
|
20389
|
+
const entry = this.entries.get(aggregatorId);
|
|
20390
|
+
if (!entry) {
|
|
20391
|
+
throw new Error("approval-aggregator: not_found");
|
|
20392
|
+
}
|
|
20393
|
+
if (entry.status !== "pending") {
|
|
20394
|
+
return entry;
|
|
20395
|
+
}
|
|
20396
|
+
entry.status = decision;
|
|
20397
|
+
entry.resolved_at = this.now().toISOString();
|
|
20398
|
+
entry.resolved_by = operatorId;
|
|
20399
|
+
await this.persist(entry);
|
|
20400
|
+
this.auditLog.append(
|
|
20401
|
+
"l2",
|
|
20402
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
|
|
20403
|
+
this.identityId,
|
|
20404
|
+
{
|
|
20405
|
+
aggregator_id: entry.aggregator_id,
|
|
20406
|
+
source_harness: entry.source_harness,
|
|
20407
|
+
source_agent_id: entry.source_agent_id,
|
|
20408
|
+
audit_log_entry_id: entry.audit_log_entry_id,
|
|
20409
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20410
|
+
decision,
|
|
20411
|
+
decided_by: operatorId,
|
|
20412
|
+
decided_at: entry.resolved_at
|
|
20413
|
+
}
|
|
20414
|
+
);
|
|
20415
|
+
this.emit({ type: "resolved", entry: { ...entry } });
|
|
20416
|
+
return entry;
|
|
20417
|
+
}
|
|
20418
|
+
// ── Internal: ingest paths ─────────────────────────────────────────────
|
|
20419
|
+
async ingestRequested(event) {
|
|
20420
|
+
const ctx = this.resolveSourceContext(event);
|
|
20421
|
+
const auditId = this.auditEntryIdForEvent(event);
|
|
20422
|
+
const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
|
|
20423
|
+
const existing = this.dedupIndex.get(dedupKey);
|
|
20424
|
+
if (existing) {
|
|
20425
|
+
const existingEntry = this.entries.get(existing);
|
|
20426
|
+
if (existingEntry) {
|
|
20427
|
+
this.correlationIndex.set(event.correlation_id, existing);
|
|
20428
|
+
this.auditLog.append(
|
|
20429
|
+
"l2",
|
|
20430
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
|
|
20431
|
+
this.identityId,
|
|
20432
|
+
{
|
|
20433
|
+
aggregator_id: existing,
|
|
20434
|
+
source_harness: ctx.source_harness,
|
|
20435
|
+
source_agent_id: ctx.source_agent_id,
|
|
20436
|
+
audit_log_entry_id: auditId,
|
|
20437
|
+
policy_rule_id: this.derivePolicyRuleId(event),
|
|
20438
|
+
correlation_id: event.correlation_id
|
|
20439
|
+
}
|
|
20440
|
+
);
|
|
20441
|
+
this.emit({ type: "deduped", entry: { ...existingEntry } });
|
|
20442
|
+
return null;
|
|
20443
|
+
}
|
|
20444
|
+
}
|
|
20445
|
+
const id = randomUUID();
|
|
20446
|
+
const now = this.now();
|
|
20447
|
+
const expires = new Date(now.getTime() + this.pendingTtlMs);
|
|
20448
|
+
const hubInboxId = this.resolveHubInboxItemId(event);
|
|
20449
|
+
const entry = {
|
|
20450
|
+
aggregator_id: id,
|
|
20451
|
+
source_harness: ctx.source_harness,
|
|
20452
|
+
source_agent_id: ctx.source_agent_id,
|
|
20453
|
+
audit_log_entry_id: auditId,
|
|
20454
|
+
policy_rule_id: this.derivePolicyRuleId(event),
|
|
20455
|
+
action_summary: this.deriveActionSummary(event),
|
|
20456
|
+
request_payload_hash: this.hashPayload(event.context),
|
|
20457
|
+
status: "pending",
|
|
20458
|
+
created_at: now.toISOString(),
|
|
20459
|
+
expires_at: expires.toISOString(),
|
|
20460
|
+
...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
|
|
20461
|
+
};
|
|
20462
|
+
this.entries.set(id, entry);
|
|
20463
|
+
this.dedupIndex.set(dedupKey, id);
|
|
20464
|
+
this.correlationIndex.set(event.correlation_id, id);
|
|
20465
|
+
this.fullPayloads.set(id, event.context);
|
|
20466
|
+
await this.persist(entry);
|
|
20467
|
+
this.auditLog.append(
|
|
20468
|
+
"l2",
|
|
20469
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
|
|
20470
|
+
this.identityId,
|
|
20471
|
+
{
|
|
20472
|
+
aggregator_id: id,
|
|
20473
|
+
source_harness: ctx.source_harness,
|
|
20474
|
+
source_agent_id: ctx.source_agent_id,
|
|
20475
|
+
audit_log_entry_id: auditId,
|
|
20476
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20477
|
+
...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
|
|
20478
|
+
}
|
|
20479
|
+
);
|
|
20480
|
+
this.emit({ type: "aggregated", entry: { ...entry } });
|
|
20481
|
+
return entry;
|
|
20482
|
+
}
|
|
20483
|
+
async ingestResolved(event) {
|
|
20484
|
+
const id = this.correlationIndex.get(event.correlation_id);
|
|
20485
|
+
if (!id) return null;
|
|
20486
|
+
const entry = this.entries.get(id);
|
|
20487
|
+
if (!entry) return null;
|
|
20488
|
+
if (entry.status !== "pending") return entry;
|
|
20489
|
+
if (!event.resolution) return entry;
|
|
20490
|
+
const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
|
|
20491
|
+
const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
|
|
20492
|
+
entry.status = status;
|
|
20493
|
+
entry.resolved_at = event.resolution.decided_at;
|
|
20494
|
+
entry.resolved_by = event.resolution.decided_by;
|
|
20495
|
+
await this.persist(entry);
|
|
20496
|
+
this.auditLog.append(
|
|
20497
|
+
"l2",
|
|
20498
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
|
|
20499
|
+
this.identityId,
|
|
20500
|
+
{
|
|
20501
|
+
aggregator_id: id,
|
|
20502
|
+
source_harness: entry.source_harness,
|
|
20503
|
+
source_agent_id: entry.source_agent_id,
|
|
20504
|
+
audit_log_entry_id: entry.audit_log_entry_id,
|
|
20505
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20506
|
+
decision: status,
|
|
20507
|
+
decided_by: entry.resolved_by,
|
|
20508
|
+
decided_at: entry.resolved_at,
|
|
20509
|
+
fail_closed: failClosed
|
|
20510
|
+
}
|
|
20511
|
+
);
|
|
20512
|
+
this.emit({ type: "resolved", entry: { ...entry } });
|
|
20513
|
+
return entry;
|
|
20514
|
+
}
|
|
20515
|
+
// ── Internal: helpers ──────────────────────────────────────────────────
|
|
20516
|
+
/**
|
|
20517
|
+
* Audit-log entry id for the dedup tuple. The audit log itself does not
|
|
20518
|
+
* surface a stable per-entry id (counter-prefixed keys are internal); the
|
|
20519
|
+
* aggregator uses the request timestamp + operation, which together pin
|
|
20520
|
+
* the audit entry the gate appended on the same call.
|
|
20521
|
+
*/
|
|
20522
|
+
auditEntryIdForEvent(event) {
|
|
20523
|
+
return `${event.request_timestamp}:${event.operation}`;
|
|
20524
|
+
}
|
|
20525
|
+
derivePolicyRuleId(event) {
|
|
20526
|
+
return `tier${event.tier}:${event.operation}`;
|
|
20527
|
+
}
|
|
20528
|
+
deriveActionSummary(event) {
|
|
20529
|
+
return `${event.operation} (tier ${event.tier})`;
|
|
20530
|
+
}
|
|
20531
|
+
/**
|
|
20532
|
+
* Canonical SHA-256 of the request context. Sorted-keys serialization so
|
|
20533
|
+
* identical payloads always hash the same, even when key insertion order
|
|
20534
|
+
* varies. Defends against payload-replay smuggling (the aggregator can
|
|
20535
|
+
* tell the same payload was seen twice without storing it cleartext).
|
|
20536
|
+
*/
|
|
20537
|
+
hashPayload(payload) {
|
|
20538
|
+
const canonical = JSON.stringify(payload, Object.keys(payload).sort());
|
|
20539
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
20540
|
+
}
|
|
20541
|
+
emit(event) {
|
|
20542
|
+
for (const listener of this.listeners) {
|
|
20543
|
+
try {
|
|
20544
|
+
listener(event);
|
|
20545
|
+
} catch {
|
|
20546
|
+
}
|
|
20547
|
+
}
|
|
20548
|
+
}
|
|
20549
|
+
async expireStale() {
|
|
20550
|
+
const nowMs = this.now().getTime();
|
|
20551
|
+
for (const entry of this.entries.values()) {
|
|
20552
|
+
if (entry.status !== "pending") continue;
|
|
20553
|
+
if (Date.parse(entry.expires_at) > nowMs) continue;
|
|
20554
|
+
entry.status = "expired";
|
|
20555
|
+
entry.resolved_at = this.now().toISOString();
|
|
20556
|
+
entry.resolved_by = "system_ttl";
|
|
20557
|
+
await this.persist(entry);
|
|
20558
|
+
this.auditLog.append(
|
|
20559
|
+
"l2",
|
|
20560
|
+
APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
|
|
20561
|
+
this.identityId,
|
|
20562
|
+
{
|
|
20563
|
+
aggregator_id: entry.aggregator_id,
|
|
20564
|
+
source_harness: entry.source_harness,
|
|
20565
|
+
source_agent_id: entry.source_agent_id,
|
|
20566
|
+
audit_log_entry_id: entry.audit_log_entry_id,
|
|
20567
|
+
policy_rule_id: entry.policy_rule_id,
|
|
20568
|
+
decision: "expired",
|
|
20569
|
+
decided_by: "system_ttl",
|
|
20570
|
+
decided_at: entry.resolved_at
|
|
20571
|
+
}
|
|
20572
|
+
);
|
|
20573
|
+
this.emit({ type: "resolved", entry: { ...entry } });
|
|
20574
|
+
}
|
|
20575
|
+
}
|
|
20576
|
+
async persist(entry) {
|
|
20577
|
+
const serialized = stringToBytes(JSON.stringify(entry));
|
|
20578
|
+
const encrypted = encrypt(serialized, this.encryptionKey);
|
|
20579
|
+
await this.storage.write(
|
|
20580
|
+
APPROVAL_AGGREGATOR_NAMESPACE,
|
|
20581
|
+
entry.aggregator_id,
|
|
20582
|
+
stringToBytes(JSON.stringify(encrypted))
|
|
20583
|
+
);
|
|
20584
|
+
}
|
|
20585
|
+
async hydrate() {
|
|
20586
|
+
if (this.hydrated) return;
|
|
20587
|
+
this.hydrated = true;
|
|
20588
|
+
try {
|
|
20589
|
+
const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
|
|
20590
|
+
for (const meta of metas) {
|
|
20591
|
+
const raw = await this.storage.read(
|
|
20592
|
+
APPROVAL_AGGREGATOR_NAMESPACE,
|
|
20593
|
+
meta.key
|
|
20594
|
+
);
|
|
20595
|
+
if (!raw) continue;
|
|
20596
|
+
try {
|
|
20597
|
+
const encrypted = JSON.parse(bytesToString(raw));
|
|
20598
|
+
const decrypted = decrypt(encrypted, this.encryptionKey);
|
|
20599
|
+
const entry = JSON.parse(bytesToString(decrypted));
|
|
20600
|
+
this.entries.set(entry.aggregator_id, entry);
|
|
20601
|
+
const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
|
|
20602
|
+
this.dedupIndex.set(dedupKey, entry.aggregator_id);
|
|
20603
|
+
} catch {
|
|
20604
|
+
}
|
|
20605
|
+
}
|
|
20606
|
+
} catch {
|
|
20607
|
+
this.hydrated = false;
|
|
20608
|
+
}
|
|
20609
|
+
}
|
|
20610
|
+
};
|
|
20611
|
+
}
|
|
20612
|
+
});
|
|
20613
|
+
|
|
20614
|
+
// src/principal-policy/channels/aggregator-backed-channel.ts
|
|
20615
|
+
function auditEntryIdFor(request) {
|
|
20616
|
+
return `${request.timestamp}:${request.operation}`;
|
|
20617
|
+
}
|
|
20618
|
+
function statusToDecision(entry) {
|
|
20619
|
+
switch (entry.status) {
|
|
20620
|
+
case "approved":
|
|
20621
|
+
return {
|
|
20622
|
+
decision: "approve",
|
|
20623
|
+
decided_by: "human"
|
|
20624
|
+
};
|
|
20625
|
+
case "denied":
|
|
20626
|
+
return {
|
|
20627
|
+
decision: "deny",
|
|
20628
|
+
decided_by: "human"
|
|
20629
|
+
};
|
|
20630
|
+
case "timeout":
|
|
20631
|
+
case "expired":
|
|
20632
|
+
return {
|
|
20633
|
+
decision: "deny",
|
|
20634
|
+
decided_by: "timeout"
|
|
20635
|
+
};
|
|
20636
|
+
default:
|
|
20637
|
+
return null;
|
|
20638
|
+
}
|
|
20639
|
+
}
|
|
20640
|
+
function makeRedirectResolverFromPolicySupplier(supplier) {
|
|
20641
|
+
return (_request) => {
|
|
20642
|
+
const cfg = supplier().approval_redirect;
|
|
20643
|
+
if (!cfg || cfg.enabled !== true) {
|
|
20644
|
+
return { enabled: false, mode: "replace" };
|
|
20645
|
+
}
|
|
20646
|
+
return {
|
|
20647
|
+
enabled: true,
|
|
20648
|
+
mode: cfg.mode === "notify" ? "notify" : "replace"
|
|
20649
|
+
};
|
|
20650
|
+
};
|
|
20651
|
+
}
|
|
20652
|
+
var DEFAULT_REPLACE_MODE_TIMEOUT_MS, AggregatorBackedChannel;
|
|
20653
|
+
var init_aggregator_backed_channel = __esm({
|
|
20654
|
+
"src/principal-policy/channels/aggregator-backed-channel.ts"() {
|
|
20655
|
+
DEFAULT_REPLACE_MODE_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
20656
|
+
AggregatorBackedChannel = class {
|
|
20657
|
+
underlying;
|
|
20658
|
+
aggregator;
|
|
20659
|
+
resolveRedirect;
|
|
20660
|
+
replaceModeTimeoutMs;
|
|
20661
|
+
now;
|
|
20662
|
+
constructor(opts) {
|
|
20663
|
+
this.underlying = opts.underlying;
|
|
20664
|
+
this.aggregator = opts.aggregator;
|
|
20665
|
+
this.resolveRedirect = opts.resolveRedirect;
|
|
20666
|
+
this.replaceModeTimeoutMs = opts.replaceModeTimeoutMs ?? DEFAULT_REPLACE_MODE_TIMEOUT_MS;
|
|
20667
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
20668
|
+
}
|
|
20669
|
+
/** Expose underlying for tests / wire-up reuse. */
|
|
20670
|
+
getUnderlying() {
|
|
20671
|
+
return this.underlying;
|
|
20672
|
+
}
|
|
20673
|
+
async requestApproval(request) {
|
|
20674
|
+
const cfg = this.resolveRedirect(request);
|
|
20675
|
+
if (!cfg.enabled) {
|
|
20676
|
+
return this.underlying.requestApproval(request);
|
|
20677
|
+
}
|
|
20678
|
+
if (cfg.mode === "replace") {
|
|
20679
|
+
return this.awaitAggregatorDecision(request);
|
|
20680
|
+
}
|
|
20681
|
+
return this.notifyMode(request);
|
|
20682
|
+
}
|
|
20683
|
+
/**
|
|
20684
|
+
* `replace` mode. Subscribe to the aggregator's event stream BEFORE
|
|
20685
|
+
* checking already-stored entries (avoids a race where the entry resolves
|
|
20686
|
+
* between list and subscribe). Match incoming events to this request by
|
|
20687
|
+
* audit_entry_id. Time out after `replaceModeTimeoutMs` to honor SEC-002.
|
|
20688
|
+
*/
|
|
20689
|
+
async awaitAggregatorDecision(request) {
|
|
20690
|
+
const auditId = auditEntryIdFor(request);
|
|
20691
|
+
return new Promise((resolveOuter) => {
|
|
20692
|
+
let settled = false;
|
|
20693
|
+
let unsubscribe = null;
|
|
20694
|
+
let timeoutHandle = null;
|
|
20695
|
+
const settle = (response) => {
|
|
20696
|
+
if (settled) return;
|
|
20697
|
+
settled = true;
|
|
20698
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
20699
|
+
if (unsubscribe) {
|
|
20700
|
+
try {
|
|
20701
|
+
unsubscribe();
|
|
20702
|
+
} catch {
|
|
20703
|
+
}
|
|
20704
|
+
}
|
|
20705
|
+
resolveOuter(response);
|
|
20706
|
+
};
|
|
20707
|
+
const onEvent = (emit) => {
|
|
20708
|
+
if (emit.type !== "resolved") return;
|
|
20709
|
+
if (emit.entry.audit_log_entry_id !== auditId) return;
|
|
20710
|
+
const mapped = statusToDecision(emit.entry);
|
|
20711
|
+
if (!mapped) return;
|
|
20712
|
+
settle({
|
|
20713
|
+
decision: mapped.decision,
|
|
20714
|
+
decided_at: emit.entry.resolved_at ?? this.now().toISOString(),
|
|
20715
|
+
decided_by: mapped.decided_by
|
|
20716
|
+
});
|
|
20717
|
+
};
|
|
20718
|
+
try {
|
|
20719
|
+
unsubscribe = this.aggregator.onEvent(onEvent);
|
|
20720
|
+
} catch (err) {
|
|
20721
|
+
settle({
|
|
20722
|
+
decision: "deny",
|
|
20723
|
+
decided_at: this.now().toISOString(),
|
|
20724
|
+
decided_by: "channel_failure"
|
|
20725
|
+
});
|
|
20726
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
20727
|
+
}
|
|
20728
|
+
void this.aggregator.list({ limit: 200 }).then((entries) => {
|
|
20729
|
+
for (const entry of entries) {
|
|
20730
|
+
if (entry.audit_log_entry_id !== auditId) continue;
|
|
20731
|
+
const mapped = statusToDecision(entry);
|
|
20732
|
+
if (!mapped) return;
|
|
20733
|
+
settle({
|
|
20734
|
+
decision: mapped.decision,
|
|
20735
|
+
decided_at: entry.resolved_at ?? this.now().toISOString(),
|
|
20736
|
+
decided_by: mapped.decided_by
|
|
20737
|
+
});
|
|
20738
|
+
return;
|
|
20739
|
+
}
|
|
20740
|
+
}).catch(() => {
|
|
20741
|
+
});
|
|
20742
|
+
timeoutHandle = setTimeout(() => {
|
|
20743
|
+
settle({
|
|
20744
|
+
decision: "deny",
|
|
20745
|
+
decided_at: this.now().toISOString(),
|
|
20746
|
+
decided_by: "timeout"
|
|
20747
|
+
});
|
|
20748
|
+
}, this.replaceModeTimeoutMs);
|
|
20749
|
+
});
|
|
20750
|
+
}
|
|
20751
|
+
/**
|
|
20752
|
+
* `notify` mode. Fire the underlying channel and listen on the
|
|
20753
|
+
* aggregator simultaneously; whichever resolves first wins. Both
|
|
20754
|
+
* paths produce identical `ApprovalResponse` shapes; the gate's
|
|
20755
|
+
* downstream audit logging is unchanged.
|
|
20756
|
+
*
|
|
20757
|
+
* On underlying-channel failure, fall through to the aggregator wait
|
|
20758
|
+
* (still bounded by `replaceModeTimeoutMs`). Operator can still
|
|
20759
|
+
* resolve from the inbox even if the dashboard/webhook is down.
|
|
20760
|
+
*/
|
|
20761
|
+
async notifyMode(request) {
|
|
20762
|
+
const aggregatorPromise = this.awaitAggregatorDecision(request);
|
|
20763
|
+
let underlyingPromise;
|
|
20764
|
+
try {
|
|
20765
|
+
underlyingPromise = this.underlying.requestApproval(request);
|
|
20766
|
+
} catch (err) {
|
|
20767
|
+
const response = await aggregatorPromise;
|
|
20768
|
+
return response;
|
|
20769
|
+
}
|
|
20770
|
+
return Promise.race([
|
|
20771
|
+
aggregatorPromise,
|
|
20772
|
+
underlyingPromise.catch(
|
|
20773
|
+
() => new Promise(() => {
|
|
20774
|
+
})
|
|
20775
|
+
)
|
|
20776
|
+
]);
|
|
20777
|
+
}
|
|
20778
|
+
};
|
|
20779
|
+
}
|
|
20780
|
+
});
|
|
19805
20781
|
|
|
19806
20782
|
// src/principal-policy/tools.ts
|
|
19807
20783
|
function createPrincipalPolicyTools(policy, baseline, auditLog) {
|
|
@@ -20706,6 +21682,76 @@ var init_attestation = __esm({
|
|
|
20706
21682
|
}
|
|
20707
21683
|
});
|
|
20708
21684
|
|
|
21685
|
+
// src/handshake/audit.ts
|
|
21686
|
+
function auditHandshakeInitiated(auditLog, ctx) {
|
|
21687
|
+
auditLog.append(
|
|
21688
|
+
"l4",
|
|
21689
|
+
HANDSHAKE_LIFECYCLE_OPS.INITIATED,
|
|
21690
|
+
ctx.identity_id,
|
|
21691
|
+
detailsFromContext(ctx),
|
|
21692
|
+
"success"
|
|
21693
|
+
);
|
|
21694
|
+
}
|
|
21695
|
+
function auditHandshakeCompleted(auditLog, ctx) {
|
|
21696
|
+
const details = detailsFromContext(ctx);
|
|
21697
|
+
if (ctx.trust_tier !== void 0) {
|
|
21698
|
+
details.trust_tier = ctx.trust_tier;
|
|
21699
|
+
}
|
|
21700
|
+
auditLog.append(
|
|
21701
|
+
"l4",
|
|
21702
|
+
HANDSHAKE_LIFECYCLE_OPS.COMPLETED,
|
|
21703
|
+
ctx.identity_id,
|
|
21704
|
+
details,
|
|
21705
|
+
"success"
|
|
21706
|
+
);
|
|
21707
|
+
}
|
|
21708
|
+
function auditHandshakeFailed(auditLog, ctx) {
|
|
21709
|
+
const details = detailsFromContext(ctx);
|
|
21710
|
+
details.reason = ctx.reason;
|
|
21711
|
+
if (ctx.error !== void 0) {
|
|
21712
|
+
details.error = ctx.error;
|
|
21713
|
+
}
|
|
21714
|
+
auditLog.append(
|
|
21715
|
+
"l4",
|
|
21716
|
+
HANDSHAKE_LIFECYCLE_OPS.FAILED,
|
|
21717
|
+
ctx.identity_id,
|
|
21718
|
+
details,
|
|
21719
|
+
"failure"
|
|
21720
|
+
);
|
|
21721
|
+
}
|
|
21722
|
+
function auditHandshakeAborted(auditLog, ctx) {
|
|
21723
|
+
const details = detailsFromContext(ctx);
|
|
21724
|
+
details.reason = ctx.reason;
|
|
21725
|
+
auditLog.append(
|
|
21726
|
+
"l4",
|
|
21727
|
+
HANDSHAKE_LIFECYCLE_OPS.ABORTED,
|
|
21728
|
+
ctx.identity_id,
|
|
21729
|
+
details,
|
|
21730
|
+
"failure"
|
|
21731
|
+
);
|
|
21732
|
+
}
|
|
21733
|
+
function detailsFromContext(ctx) {
|
|
21734
|
+
const details = {
|
|
21735
|
+
session_id: ctx.session_id,
|
|
21736
|
+
role: ctx.role
|
|
21737
|
+
};
|
|
21738
|
+
if (ctx.counterparty_id !== void 0) {
|
|
21739
|
+
details.counterparty_id = ctx.counterparty_id;
|
|
21740
|
+
}
|
|
21741
|
+
return details;
|
|
21742
|
+
}
|
|
21743
|
+
var HANDSHAKE_LIFECYCLE_OPS;
|
|
21744
|
+
var init_audit = __esm({
|
|
21745
|
+
"src/handshake/audit.ts"() {
|
|
21746
|
+
HANDSHAKE_LIFECYCLE_OPS = {
|
|
21747
|
+
INITIATED: "handshake_initiated",
|
|
21748
|
+
COMPLETED: "handshake_completed",
|
|
21749
|
+
FAILED: "handshake_failed",
|
|
21750
|
+
ABORTED: "handshake_aborted"
|
|
21751
|
+
};
|
|
21752
|
+
}
|
|
21753
|
+
});
|
|
21754
|
+
|
|
20709
21755
|
// src/handshake/tools.ts
|
|
20710
21756
|
function createHandshakeTools(config, identityManager, masterKey, auditLog, options) {
|
|
20711
21757
|
const autoPublishHandshakes = options?.autoPublishHandshakes ?? false;
|
|
@@ -20739,6 +21785,11 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20739
21785
|
const { challenge, session } = initiateHandshake(shr);
|
|
20740
21786
|
sessions.set(session.session_id, session);
|
|
20741
21787
|
auditLog.append("l4", "handshake_initiate", shr.body.instance_id);
|
|
21788
|
+
auditHandshakeInitiated(auditLog, {
|
|
21789
|
+
session_id: session.session_id,
|
|
21790
|
+
role: "initiator",
|
|
21791
|
+
identity_id: shr.body.instance_id
|
|
21792
|
+
});
|
|
20742
21793
|
return toolResult({
|
|
20743
21794
|
session_id: session.session_id,
|
|
20744
21795
|
challenge,
|
|
@@ -20778,10 +21829,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20778
21829
|
);
|
|
20779
21830
|
if ("error" in result) {
|
|
20780
21831
|
auditLog.append("l4", "handshake_respond", shr.body.instance_id, void 0, "failure");
|
|
21832
|
+
auditHandshakeFailed(auditLog, {
|
|
21833
|
+
session_id: "unknown",
|
|
21834
|
+
role: "responder",
|
|
21835
|
+
identity_id: shr.body.instance_id,
|
|
21836
|
+
reason: classifyRespondFailure(result.error),
|
|
21837
|
+
error: result.error
|
|
21838
|
+
});
|
|
20781
21839
|
return toolResult({ error: result.error });
|
|
20782
21840
|
}
|
|
20783
21841
|
sessions.set(result.session.session_id, result.session);
|
|
20784
21842
|
auditLog.append("l4", "handshake_respond", shr.body.instance_id);
|
|
21843
|
+
auditHandshakeInitiated(auditLog, {
|
|
21844
|
+
session_id: result.session.session_id,
|
|
21845
|
+
role: "responder",
|
|
21846
|
+
identity_id: shr.body.instance_id,
|
|
21847
|
+
counterparty_id: challenge.shr.body.instance_id
|
|
21848
|
+
});
|
|
20785
21849
|
let autoPublishResult;
|
|
20786
21850
|
if (autoPublishHandshakes) {
|
|
20787
21851
|
autoPublishResult = { attempted: true };
|
|
@@ -20889,9 +21953,23 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20889
21953
|
const response = args.response;
|
|
20890
21954
|
const session = sessions.get(sessionId);
|
|
20891
21955
|
if (!session) {
|
|
21956
|
+
auditHandshakeFailed(auditLog, {
|
|
21957
|
+
session_id: sessionId,
|
|
21958
|
+
role: "initiator",
|
|
21959
|
+
identity_id: "unknown",
|
|
21960
|
+
reason: "session_unknown",
|
|
21961
|
+
error: `No handshake session found: ${sessionId}`
|
|
21962
|
+
});
|
|
20892
21963
|
return toolResult({ error: `No handshake session found: ${sessionId}` });
|
|
20893
21964
|
}
|
|
20894
21965
|
if (session.state !== "initiated") {
|
|
21966
|
+
auditHandshakeFailed(auditLog, {
|
|
21967
|
+
session_id: sessionId,
|
|
21968
|
+
role: "initiator",
|
|
21969
|
+
identity_id: session.our_shr.body.instance_id,
|
|
21970
|
+
reason: "session_state_mismatch",
|
|
21971
|
+
error: `Session is in state '${session.state}', expected 'initiated'`
|
|
21972
|
+
});
|
|
20895
21973
|
return toolResult({
|
|
20896
21974
|
error: `Session is in state '${session.state}', expected 'initiated'`
|
|
20897
21975
|
});
|
|
@@ -20905,6 +21983,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20905
21983
|
if ("error" in result) {
|
|
20906
21984
|
session.state = "failed";
|
|
20907
21985
|
auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id, void 0, "failure");
|
|
21986
|
+
auditHandshakeFailed(auditLog, {
|
|
21987
|
+
session_id: sessionId,
|
|
21988
|
+
role: "initiator",
|
|
21989
|
+
identity_id: session.our_shr.body.instance_id,
|
|
21990
|
+
reason: classifyCompleteFailure(result.error),
|
|
21991
|
+
error: result.error
|
|
21992
|
+
});
|
|
20908
21993
|
return toolResult({ error: result.error });
|
|
20909
21994
|
}
|
|
20910
21995
|
session.state = "completed";
|
|
@@ -20913,6 +21998,13 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20913
21998
|
session.result = result.result;
|
|
20914
21999
|
handshakeResults.set(result.result.counterparty_id, result.result);
|
|
20915
22000
|
auditLog.append("l4", "handshake_complete", session.our_shr.body.instance_id);
|
|
22001
|
+
auditHandshakeCompleted(auditLog, {
|
|
22002
|
+
session_id: sessionId,
|
|
22003
|
+
role: "initiator",
|
|
22004
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22005
|
+
counterparty_id: result.result.counterparty_id,
|
|
22006
|
+
trust_tier: result.result.trust_tier
|
|
22007
|
+
});
|
|
20916
22008
|
return toolResult({
|
|
20917
22009
|
completion: result.completion,
|
|
20918
22010
|
result: result.result,
|
|
@@ -20960,6 +22052,24 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
20960
22052
|
void 0,
|
|
20961
22053
|
result.verified ? "success" : "failure"
|
|
20962
22054
|
);
|
|
22055
|
+
if (result.verified) {
|
|
22056
|
+
auditHandshakeCompleted(auditLog, {
|
|
22057
|
+
session_id: session.session_id,
|
|
22058
|
+
role: "responder",
|
|
22059
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22060
|
+
counterparty_id: result.counterparty_id,
|
|
22061
|
+
trust_tier: result.trust_tier
|
|
22062
|
+
});
|
|
22063
|
+
} else {
|
|
22064
|
+
auditHandshakeFailed(auditLog, {
|
|
22065
|
+
session_id: session.session_id,
|
|
22066
|
+
role: "responder",
|
|
22067
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22068
|
+
counterparty_id: result.counterparty_id,
|
|
22069
|
+
reason: classifyCompleteFailure(result.errors.join("; ")),
|
|
22070
|
+
error: result.errors.join("; ")
|
|
22071
|
+
});
|
|
22072
|
+
}
|
|
20963
22073
|
return toolResult({ result });
|
|
20964
22074
|
}
|
|
20965
22075
|
return toolResult({
|
|
@@ -21067,10 +22177,74 @@ function createHandshakeTools(config, identityManager, masterKey, auditLog, opti
|
|
|
21067
22177
|
_content_trust: "external"
|
|
21068
22178
|
});
|
|
21069
22179
|
}
|
|
22180
|
+
},
|
|
22181
|
+
{
|
|
22182
|
+
name: "handshake_abort",
|
|
22183
|
+
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.",
|
|
22184
|
+
inputSchema: {
|
|
22185
|
+
type: "object",
|
|
22186
|
+
properties: {
|
|
22187
|
+
session_id: {
|
|
22188
|
+
type: "string",
|
|
22189
|
+
description: "Session ID returned from handshake_initiate / handshake_respond."
|
|
22190
|
+
},
|
|
22191
|
+
reason: {
|
|
22192
|
+
type: "string",
|
|
22193
|
+
enum: [
|
|
22194
|
+
"operator_cancelled",
|
|
22195
|
+
"session_timeout",
|
|
22196
|
+
"transport_dropped",
|
|
22197
|
+
"shutdown",
|
|
22198
|
+
"other"
|
|
22199
|
+
],
|
|
22200
|
+
description: "Why the session is being aborted. Defaults to 'operator_cancelled'."
|
|
22201
|
+
}
|
|
22202
|
+
},
|
|
22203
|
+
required: ["session_id"]
|
|
22204
|
+
},
|
|
22205
|
+
handler: async (args) => {
|
|
22206
|
+
const sessionId = args.session_id;
|
|
22207
|
+
const reason = args.reason ?? "operator_cancelled";
|
|
22208
|
+
const session = sessions.get(sessionId);
|
|
22209
|
+
if (!session) {
|
|
22210
|
+
return toolResult({ error: `No handshake session found: ${sessionId}` });
|
|
22211
|
+
}
|
|
22212
|
+
if (session.state === "completed") {
|
|
22213
|
+
return toolResult({
|
|
22214
|
+
error: `Session ${sessionId} already completed; abort is only valid for in-flight sessions`
|
|
22215
|
+
});
|
|
22216
|
+
}
|
|
22217
|
+
sessions.delete(sessionId);
|
|
22218
|
+
auditHandshakeAborted(auditLog, {
|
|
22219
|
+
session_id: sessionId,
|
|
22220
|
+
role: session.role,
|
|
22221
|
+
identity_id: session.our_shr.body.instance_id,
|
|
22222
|
+
...session.their_shr ? { counterparty_id: session.their_shr.body.instance_id } : {},
|
|
22223
|
+
reason
|
|
22224
|
+
});
|
|
22225
|
+
return toolResult({
|
|
22226
|
+
aborted: true,
|
|
22227
|
+
session_id: sessionId,
|
|
22228
|
+
reason
|
|
22229
|
+
});
|
|
22230
|
+
}
|
|
21070
22231
|
}
|
|
21071
22232
|
];
|
|
21072
22233
|
return { tools, handshakeResults };
|
|
21073
22234
|
}
|
|
22235
|
+
function classifyRespondFailure(error) {
|
|
22236
|
+
if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
|
|
22237
|
+
if (error.includes("SHR verification failed")) return "shr_invalid";
|
|
22238
|
+
if (error.includes("No identity available")) return "no_signing_identity";
|
|
22239
|
+
return "other";
|
|
22240
|
+
}
|
|
22241
|
+
function classifyCompleteFailure(error) {
|
|
22242
|
+
if (error.includes("Unsupported protocol version")) return "protocol_version_unsupported";
|
|
22243
|
+
if (error.includes("SHR verification failed") || error.includes("SHR")) return "shr_invalid";
|
|
22244
|
+
if (error.includes("nonce signature is invalid")) return "nonce_signature_invalid";
|
|
22245
|
+
if (error.includes("No identity available")) return "no_signing_identity";
|
|
22246
|
+
return "other";
|
|
22247
|
+
}
|
|
21074
22248
|
var init_tools6 = __esm({
|
|
21075
22249
|
"src/handshake/tools.ts"() {
|
|
21076
22250
|
init_router();
|
|
@@ -21080,6 +22254,7 @@ var init_tools6 = __esm({
|
|
|
21080
22254
|
init_encoding();
|
|
21081
22255
|
init_protocol();
|
|
21082
22256
|
init_attestation();
|
|
22257
|
+
init_audit();
|
|
21083
22258
|
init_verifier();
|
|
21084
22259
|
}
|
|
21085
22260
|
});
|
|
@@ -22719,6 +23894,12 @@ function typed(markerPath, lineNumber, field, expected) {
|
|
|
22719
23894
|
}
|
|
22720
23895
|
async function consumeResetHistoryMarker(options) {
|
|
22721
23896
|
const markerPath = join(options.storagePath, RESET_HISTORY_FILENAME);
|
|
23897
|
+
const consumedPath = markerPath + ".consumed";
|
|
23898
|
+
if (await fileExists3(consumedPath)) {
|
|
23899
|
+
await rm(markerPath, { force: true });
|
|
23900
|
+
await rm(consumedPath, { force: true });
|
|
23901
|
+
return { emitted: 0, markerPath };
|
|
23902
|
+
}
|
|
22722
23903
|
if (!await fileExists3(markerPath)) {
|
|
22723
23904
|
return { emitted: 0, markerPath };
|
|
22724
23905
|
}
|
|
@@ -22743,7 +23924,9 @@ async function consumeResetHistoryMarker(options) {
|
|
|
22743
23924
|
});
|
|
22744
23925
|
}
|
|
22745
23926
|
await options.auditLog.flush();
|
|
23927
|
+
await writeFile(consumedPath, "", "utf-8");
|
|
22746
23928
|
await rm(markerPath, { force: true });
|
|
23929
|
+
await rm(consumedPath, { force: true });
|
|
22747
23930
|
return { emitted: markers.length, markerHash, markerPath };
|
|
22748
23931
|
}
|
|
22749
23932
|
async function fileExists3(path) {
|
|
@@ -32026,6 +33209,36 @@ var init_hub_service = __esm({
|
|
|
32026
33209
|
const chat = this.requireOperatorChat();
|
|
32027
33210
|
return chat.getConciergeHistory();
|
|
32028
33211
|
}
|
|
33212
|
+
// ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
|
|
33213
|
+
/**
|
|
33214
|
+
* Whether the operator-chat service has the WP-V1.3-9 memory store
|
|
33215
|
+
* wired. Routes use this to 503 cleanly when the foundation memory
|
|
33216
|
+
* surface is unavailable on a given fortress.
|
|
33217
|
+
*/
|
|
33218
|
+
hasConciergeMemory() {
|
|
33219
|
+
return Boolean(this.deps.operatorChat?.hasConciergeMemory());
|
|
33220
|
+
}
|
|
33221
|
+
async listConciergeMemoryThreads(opts) {
|
|
33222
|
+
const chat = this.requireOperatorChat();
|
|
33223
|
+
if (!chat.hasConciergeMemory()) {
|
|
33224
|
+
throw new HubCapabilityError("concierge_memory_not_wired");
|
|
33225
|
+
}
|
|
33226
|
+
return chat.listConciergeMemoryThreads(opts);
|
|
33227
|
+
}
|
|
33228
|
+
async readConciergeMemoryThread(threadId, opts) {
|
|
33229
|
+
const chat = this.requireOperatorChat();
|
|
33230
|
+
if (!chat.hasConciergeMemory()) {
|
|
33231
|
+
throw new HubCapabilityError("concierge_memory_not_wired");
|
|
33232
|
+
}
|
|
33233
|
+
return chat.readConciergeMemoryThread(threadId, opts);
|
|
33234
|
+
}
|
|
33235
|
+
async deleteConciergeMemoryThread(threadId) {
|
|
33236
|
+
const chat = this.requireOperatorChat();
|
|
33237
|
+
if (!chat.hasConciergeMemory()) {
|
|
33238
|
+
throw new HubCapabilityError("concierge_memory_not_wired");
|
|
33239
|
+
}
|
|
33240
|
+
return chat.deleteConciergeMemoryThread(threadId);
|
|
33241
|
+
}
|
|
32029
33242
|
/**
|
|
32030
33243
|
* Open the click-to-inspect/approve panel for a wrapped agent. The
|
|
32031
33244
|
* panel surfaces recent activity routed through this agent, pending
|
|
@@ -32164,7 +33377,27 @@ var init_operator_chat_audit_events = __esm({
|
|
|
32164
33377
|
* affordance now opens an inspect/approve panel (recent activity +
|
|
32165
33378
|
* pending approvals + policy summary) instead of a chat session.
|
|
32166
33379
|
*/
|
|
32167
|
-
AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened"
|
|
33380
|
+
AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened",
|
|
33381
|
+
/**
|
|
33382
|
+
* Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
|
|
33383
|
+
* when the operator hits the list-threads or read-thread route. Body
|
|
33384
|
+
* carries the thread_id (or `*` for the list endpoint) and a count;
|
|
33385
|
+
* raw turn content never crosses the audit surface.
|
|
33386
|
+
*/
|
|
33387
|
+
CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
|
|
33388
|
+
/**
|
|
33389
|
+
* Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
|
|
33390
|
+
* successful thread removal. Body carries thread_id + turn_count of
|
|
33391
|
+
* the deleted bundle.
|
|
33392
|
+
*/
|
|
33393
|
+
CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted",
|
|
33394
|
+
/**
|
|
33395
|
+
* Concierge memory fold-read failed (WP-V1.3-9 Tau-2). Emitted when
|
|
33396
|
+
* the multi-turn coherence fold cannot load the active thread's prior
|
|
33397
|
+
* turns; the concierge degrades to single-turn after emitting. Body
|
|
33398
|
+
* carries thread_id + a stable failure_reason enum.
|
|
33399
|
+
*/
|
|
33400
|
+
CONCIERGE_MEMORY_READ_FAILED: "operator_concierge_memory_read_failed"
|
|
32168
33401
|
};
|
|
32169
33402
|
}
|
|
32170
33403
|
});
|
|
@@ -32177,13 +33410,20 @@ var init_operator_chat_types = __esm({
|
|
|
32177
33410
|
CONCIERGE_THREAD_KEY = "_fortress";
|
|
32178
33411
|
}
|
|
32179
33412
|
});
|
|
33413
|
+
function approxTokenLen(text) {
|
|
33414
|
+
return Math.ceil(text.length / 4);
|
|
33415
|
+
}
|
|
32180
33416
|
function makeEventId(prefix) {
|
|
32181
33417
|
return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
32182
33418
|
}
|
|
33419
|
+
function formatPriorTurnLine(turn) {
|
|
33420
|
+
const label = turn.role === "user" ? "OPERATOR" : "CONCIERGE";
|
|
33421
|
+
return `${label}: ${turn.content}`;
|
|
33422
|
+
}
|
|
32183
33423
|
function hashOf(input) {
|
|
32184
33424
|
return hashToString(sha256(stringToBytes(input)));
|
|
32185
33425
|
}
|
|
32186
|
-
var DEFAULT_CONCIERGE_MAX_TOKENS, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
|
|
33426
|
+
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;
|
|
32187
33427
|
var init_operator_chat_service = __esm({
|
|
32188
33428
|
"src/chat/operator-chat-service.ts"() {
|
|
32189
33429
|
init_hashing();
|
|
@@ -32191,6 +33431,10 @@ var init_operator_chat_service = __esm({
|
|
|
32191
33431
|
init_operator_chat_audit_events();
|
|
32192
33432
|
init_operator_chat_types();
|
|
32193
33433
|
DEFAULT_CONCIERGE_MAX_TOKENS = 512;
|
|
33434
|
+
DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
|
|
33435
|
+
DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
|
|
33436
|
+
DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
|
|
33437
|
+
DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
32194
33438
|
SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
|
|
32195
33439
|
1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
|
|
32196
33440
|
2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
|
|
@@ -32226,6 +33470,27 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32226
33470
|
contextProviders;
|
|
32227
33471
|
piiFilter;
|
|
32228
33472
|
conciergeMaxTokens;
|
|
33473
|
+
memory;
|
|
33474
|
+
historyWindowTurns;
|
|
33475
|
+
historyFreshnessMs;
|
|
33476
|
+
historyTokenBudget;
|
|
33477
|
+
sessionTtlMs;
|
|
33478
|
+
clock;
|
|
33479
|
+
/**
|
|
33480
|
+
* In-memory thread_id assigned to the active concierge session.
|
|
33481
|
+
* The first sendConcierge call after construction allocates a fresh
|
|
33482
|
+
* UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
|
|
33483
|
+
* folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
|
|
33484
|
+
*/
|
|
33485
|
+
activeMemoryThreadId;
|
|
33486
|
+
/**
|
|
33487
|
+
* Wall-clock ms of the most recent sendConcierge that touched the
|
|
33488
|
+
* active session thread. Drives the WP-V1.3-9 Tau-2 session-TTL
|
|
33489
|
+
* check: a fresh sendConcierge after `sessionTtlMs` of quiet
|
|
33490
|
+
* allocates a new thread_id even though the prior one is still
|
|
33491
|
+
* readable from the memory store.
|
|
33492
|
+
*/
|
|
33493
|
+
lastInteractionAt;
|
|
32229
33494
|
constructor(deps) {
|
|
32230
33495
|
this.store = deps.store;
|
|
32231
33496
|
this.auditLog = deps.auditLog;
|
|
@@ -32236,6 +33501,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32236
33501
|
}
|
|
32237
33502
|
if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
|
|
32238
33503
|
this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
|
|
33504
|
+
if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
|
|
33505
|
+
this.historyWindowTurns = deps.conciergeHistoryWindowTurns !== void 0 && deps.conciergeHistoryWindowTurns > 0 ? deps.conciergeHistoryWindowTurns : DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS;
|
|
33506
|
+
this.historyFreshnessMs = deps.conciergeHistoryFreshnessMs !== void 0 && deps.conciergeHistoryFreshnessMs > 0 ? deps.conciergeHistoryFreshnessMs : DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS;
|
|
33507
|
+
this.historyTokenBudget = deps.conciergeHistoryTokenBudget !== void 0 && deps.conciergeHistoryTokenBudget > 0 ? deps.conciergeHistoryTokenBudget : DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET;
|
|
33508
|
+
this.sessionTtlMs = deps.conciergeSessionTtlMs !== void 0 && deps.conciergeSessionTtlMs > 0 ? deps.conciergeSessionTtlMs : DEFAULT_CONCIERGE_SESSION_TTL_MS;
|
|
33509
|
+
this.clock = deps.conciergeClock ?? (() => Date.now());
|
|
32239
33510
|
}
|
|
32240
33511
|
// ── Concierge ─────────────────────────────────────────────────────────
|
|
32241
33512
|
/**
|
|
@@ -32254,6 +33525,10 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32254
33525
|
throw new Error("concierge query must not be empty");
|
|
32255
33526
|
}
|
|
32256
33527
|
const filterResult = this.piiFilter ? this.piiFilter.filter(trimmed) : { filtered: trimmed, redactions: 0 };
|
|
33528
|
+
const nowMs = this.clock();
|
|
33529
|
+
if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
|
|
33530
|
+
this.activeMemoryThreadId = void 0;
|
|
33531
|
+
}
|
|
32257
33532
|
const operatorMessage = {
|
|
32258
33533
|
message_id: randomUUID(),
|
|
32259
33534
|
surface: "concierge",
|
|
@@ -32266,6 +33541,30 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32266
33541
|
CONCIERGE_THREAD_KEY,
|
|
32267
33542
|
operatorMessage
|
|
32268
33543
|
);
|
|
33544
|
+
let priorTurns = [];
|
|
33545
|
+
let memoryReadFailureReason = null;
|
|
33546
|
+
let activeThreadIdForRound;
|
|
33547
|
+
if (this.memory) {
|
|
33548
|
+
activeThreadIdForRound = this.ensureActiveMemoryThread();
|
|
33549
|
+
const result = await this.memory.readThreadStrict(activeThreadIdForRound).catch(() => ({ ok: false, reason: "io_failed" }));
|
|
33550
|
+
if (result.ok) {
|
|
33551
|
+
const cutoff = nowMs - this.historyFreshnessMs;
|
|
33552
|
+
const fresh = result.turns.filter((t) => {
|
|
33553
|
+
const ts = Date.parse(t.created_at);
|
|
33554
|
+
return Number.isFinite(ts) && ts >= cutoff;
|
|
33555
|
+
});
|
|
33556
|
+
const recent = fresh.length > this.historyWindowTurns ? fresh.slice(fresh.length - this.historyWindowTurns) : fresh;
|
|
33557
|
+
priorTurns = recent;
|
|
33558
|
+
} else {
|
|
33559
|
+
memoryReadFailureReason = result.reason;
|
|
33560
|
+
this.emitMemoryReadFailed(activeThreadIdForRound, result.reason);
|
|
33561
|
+
}
|
|
33562
|
+
}
|
|
33563
|
+
if (this.memory) {
|
|
33564
|
+
const threadId = this.ensureActiveMemoryThread();
|
|
33565
|
+
await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
|
|
33566
|
+
});
|
|
33567
|
+
}
|
|
32269
33568
|
const start = Date.now();
|
|
32270
33569
|
let conciergeBody;
|
|
32271
33570
|
let servedBy = "disabled";
|
|
@@ -32282,7 +33581,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32282
33581
|
conciergeBody = "Concierge unavailable. The chosen substrate does not support summarization. Pick a different substrate in the Policy center.";
|
|
32283
33582
|
outcome = "substrate_disabled";
|
|
32284
33583
|
} else {
|
|
32285
|
-
const context = await this.assembleConciergeContext();
|
|
33584
|
+
const context = await this.assembleConciergeContext(priorTurns);
|
|
32286
33585
|
const response = await this.substrateSelector.invokeSummarize(
|
|
32287
33586
|
"concierge",
|
|
32288
33587
|
{
|
|
@@ -32321,6 +33620,15 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32321
33620
|
CONCIERGE_THREAD_KEY,
|
|
32322
33621
|
responseMessage
|
|
32323
33622
|
);
|
|
33623
|
+
let assistantTurnId;
|
|
33624
|
+
if (this.memory) {
|
|
33625
|
+
const threadId = this.ensureActiveMemoryThread();
|
|
33626
|
+
const persisted = await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => void 0);
|
|
33627
|
+
if (persisted) assistantTurnId = persisted.turn_id;
|
|
33628
|
+
}
|
|
33629
|
+
if (this.memory && activeThreadIdForRound) {
|
|
33630
|
+
this.lastInteractionAt = nowMs;
|
|
33631
|
+
}
|
|
32324
33632
|
const payload = {
|
|
32325
33633
|
version: "1.2",
|
|
32326
33634
|
event_id: makeEventId("conc"),
|
|
@@ -32332,7 +33640,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32332
33640
|
response_hash: outcome === "ok" ? hashOf(conciergeBody) : null,
|
|
32333
33641
|
substrate: servedBy,
|
|
32334
33642
|
latency_ms: latencyMs,
|
|
32335
|
-
outcome
|
|
33643
|
+
outcome,
|
|
33644
|
+
...activeThreadIdForRound !== void 0 ? { thread_id: activeThreadIdForRound } : {},
|
|
33645
|
+
...assistantTurnId !== void 0 ? { turn_index: assistantTurnId } : {},
|
|
33646
|
+
...this.memory ? {
|
|
33647
|
+
prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
|
|
33648
|
+
} : {}
|
|
32336
33649
|
};
|
|
32337
33650
|
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
|
|
32338
33651
|
return {
|
|
@@ -32342,6 +33655,25 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32342
33655
|
outcome
|
|
32343
33656
|
};
|
|
32344
33657
|
}
|
|
33658
|
+
/**
|
|
33659
|
+
* Emit the WP-V1.3-9 Tau-2 graceful-degradation audit event. Pulled
|
|
33660
|
+
* out of `sendConcierge` so the read-fold path stays readable. Emits
|
|
33661
|
+
* with `result: "failure"` since the concierge fell back to
|
|
33662
|
+
* single-turn mode for this round-trip.
|
|
33663
|
+
*/
|
|
33664
|
+
emitMemoryReadFailed(threadId, reason) {
|
|
33665
|
+
const payload = {
|
|
33666
|
+
version: "1.2",
|
|
33667
|
+
event_id: makeEventId("conc-memfail"),
|
|
33668
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33669
|
+
identity_id: this.identityId,
|
|
33670
|
+
kind: "operator_concierge_memory_read_failed",
|
|
33671
|
+
surface: "concierge",
|
|
33672
|
+
thread_id: threadId,
|
|
33673
|
+
failure_reason: reason
|
|
33674
|
+
};
|
|
33675
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED, payload, "failure");
|
|
33676
|
+
}
|
|
32345
33677
|
/**
|
|
32346
33678
|
* Read the persisted concierge thread, oldest message first. Returns
|
|
32347
33679
|
* an empty array when no thread exists yet.
|
|
@@ -32353,6 +33685,105 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32353
33685
|
);
|
|
32354
33686
|
return thread ? thread.messages : [];
|
|
32355
33687
|
}
|
|
33688
|
+
// ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
|
|
33689
|
+
/**
|
|
33690
|
+
* Whether the foundation memory store is wired. Routes use this to
|
|
33691
|
+
* 503 cleanly when called against an unwired service.
|
|
33692
|
+
*/
|
|
33693
|
+
hasConciergeMemory() {
|
|
33694
|
+
return this.memory !== void 0;
|
|
33695
|
+
}
|
|
33696
|
+
/**
|
|
33697
|
+
* List concierge memory threads, newest-first. Emits the
|
|
33698
|
+
* `operator_concierge_history_read` audit event with `thread_id="*"`.
|
|
33699
|
+
*/
|
|
33700
|
+
async listConciergeMemoryThreads(opts) {
|
|
33701
|
+
if (!this.memory) {
|
|
33702
|
+
throw new Error("concierge memory store not configured");
|
|
33703
|
+
}
|
|
33704
|
+
const summaries = await this.memory.listThreads(opts);
|
|
33705
|
+
const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
|
|
33706
|
+
const payload = {
|
|
33707
|
+
version: "1.2",
|
|
33708
|
+
event_id: makeEventId("conc-hist"),
|
|
33709
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33710
|
+
identity_id: this.identityId,
|
|
33711
|
+
kind: "operator_concierge_history_read",
|
|
33712
|
+
surface: "concierge",
|
|
33713
|
+
thread_id: "*",
|
|
33714
|
+
turn_count: totalTurns
|
|
33715
|
+
};
|
|
33716
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
|
|
33717
|
+
return summaries;
|
|
33718
|
+
}
|
|
33719
|
+
/**
|
|
33720
|
+
* Read a concierge memory thread, oldest turn first. Emits the
|
|
33721
|
+
* `operator_concierge_history_read` audit event with the named
|
|
33722
|
+
* thread_id and the count of turns surfaced.
|
|
33723
|
+
*/
|
|
33724
|
+
async readConciergeMemoryThread(threadId, opts) {
|
|
33725
|
+
if (!this.memory) {
|
|
33726
|
+
throw new Error("concierge memory store not configured");
|
|
33727
|
+
}
|
|
33728
|
+
const turns = await this.memory.readThread(threadId, opts);
|
|
33729
|
+
const payload = {
|
|
33730
|
+
version: "1.2",
|
|
33731
|
+
event_id: makeEventId("conc-hist"),
|
|
33732
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33733
|
+
identity_id: this.identityId,
|
|
33734
|
+
kind: "operator_concierge_history_read",
|
|
33735
|
+
surface: "concierge",
|
|
33736
|
+
thread_id: threadId,
|
|
33737
|
+
turn_count: turns.length
|
|
33738
|
+
};
|
|
33739
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
|
|
33740
|
+
return turns;
|
|
33741
|
+
}
|
|
33742
|
+
/**
|
|
33743
|
+
* Delete a concierge memory thread. Emits
|
|
33744
|
+
* `operator_concierge_thread_deleted` only when a bundle was actually
|
|
33745
|
+
* removed; absent threads return false without an audit event.
|
|
33746
|
+
*/
|
|
33747
|
+
async deleteConciergeMemoryThread(threadId) {
|
|
33748
|
+
if (!this.memory) {
|
|
33749
|
+
throw new Error("concierge memory store not configured");
|
|
33750
|
+
}
|
|
33751
|
+
const turnsBefore = await this.memory.readThread(threadId);
|
|
33752
|
+
if (turnsBefore.length === 0) {
|
|
33753
|
+
return await this.memory.deleteThread(threadId);
|
|
33754
|
+
}
|
|
33755
|
+
const removed = await this.memory.deleteThread(threadId);
|
|
33756
|
+
if (!removed) return false;
|
|
33757
|
+
if (this.activeMemoryThreadId === threadId) {
|
|
33758
|
+
this.activeMemoryThreadId = void 0;
|
|
33759
|
+
}
|
|
33760
|
+
const payload = {
|
|
33761
|
+
version: "1.2",
|
|
33762
|
+
event_id: makeEventId("conc-del"),
|
|
33763
|
+
emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33764
|
+
identity_id: this.identityId,
|
|
33765
|
+
kind: "operator_concierge_thread_deleted",
|
|
33766
|
+
surface: "concierge",
|
|
33767
|
+
thread_id: threadId,
|
|
33768
|
+
turn_count: turnsBefore.length
|
|
33769
|
+
};
|
|
33770
|
+
this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
|
|
33771
|
+
return true;
|
|
33772
|
+
}
|
|
33773
|
+
/**
|
|
33774
|
+
* Reset the active session memory thread. Subsequent sendConcierge
|
|
33775
|
+
* calls allocate a fresh thread_id. Surfaced for tests + future "new
|
|
33776
|
+
* conversation" affordance; not currently called by the dashboard.
|
|
33777
|
+
*/
|
|
33778
|
+
resetConciergeMemoryThread() {
|
|
33779
|
+
this.activeMemoryThreadId = void 0;
|
|
33780
|
+
}
|
|
33781
|
+
ensureActiveMemoryThread() {
|
|
33782
|
+
if (!this.activeMemoryThreadId) {
|
|
33783
|
+
this.activeMemoryThreadId = randomUUID();
|
|
33784
|
+
}
|
|
33785
|
+
return this.activeMemoryThreadId;
|
|
33786
|
+
}
|
|
32356
33787
|
/**
|
|
32357
33788
|
* Stitch fortress state into a single context blob the substrate
|
|
32358
33789
|
* folds into its summarization prompt.
|
|
@@ -32365,6 +33796,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32365
33796
|
* ## Sanctuary reference
|
|
32366
33797
|
* <static domain reference block>
|
|
32367
33798
|
*
|
|
33799
|
+
* ## Prior conversation ← WP-V1.3-9 Tau-2, when present
|
|
33800
|
+
* OPERATOR: ...
|
|
33801
|
+
* CONCIERGE: ...
|
|
33802
|
+
* ---
|
|
33803
|
+
*
|
|
32368
33804
|
* ## Recent activity
|
|
32369
33805
|
* <recentActivity output>
|
|
32370
33806
|
*
|
|
@@ -32374,37 +33810,69 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
|
|
|
32374
33810
|
* ## Open inbox
|
|
32375
33811
|
* <openInbox output>
|
|
32376
33812
|
* ```
|
|
33813
|
+
*
|
|
33814
|
+
* The substrate selector ships a `context: string` shape (not a
|
|
33815
|
+
* messages array), so multi-turn coherence is folded as a structured
|
|
33816
|
+
* prior-conversation section with explicit OPERATOR / CONCIERGE
|
|
33817
|
+
* boundaries. Coordinator-CTO guidance: prefer messages-array shape
|
|
33818
|
+
* if available; the v1.2 selector does not expose one, so structured
|
|
33819
|
+
* serialization is the canonical path for v1.3.
|
|
32377
33820
|
*/
|
|
32378
|
-
async assembleConciergeContext() {
|
|
33821
|
+
async assembleConciergeContext(priorTurns = []) {
|
|
32379
33822
|
const ref = `## Sanctuary reference
|
|
32380
33823
|
${SANCTUARY_DOMAIN_REFERENCE}`;
|
|
33824
|
+
const priorSection = this.formatPriorTurnsSection(priorTurns);
|
|
32381
33825
|
if (!this.contextProviders) {
|
|
32382
|
-
return
|
|
32383
|
-
|
|
32384
|
-
|
|
32385
|
-
(no providers wired)
|
|
32386
|
-
|
|
32387
|
-
##
|
|
32388
|
-
(
|
|
32389
|
-
|
|
32390
|
-
## Open inbox
|
|
32391
|
-
(no providers wired)`;
|
|
33826
|
+
return [
|
|
33827
|
+
ref,
|
|
33828
|
+
...priorSection ? [priorSection] : [],
|
|
33829
|
+
"## Recent activity\n(no providers wired)",
|
|
33830
|
+
"## Wrapped agents\n(no providers wired)",
|
|
33831
|
+
"## Open inbox\n(no providers wired)"
|
|
33832
|
+
].join("\n\n");
|
|
32392
33833
|
}
|
|
32393
33834
|
const [activity, agents, inbox] = await Promise.all([
|
|
32394
33835
|
this.contextProviders.recentActivity(),
|
|
32395
33836
|
this.contextProviders.agentInventory(),
|
|
32396
33837
|
this.contextProviders.openInbox()
|
|
32397
33838
|
]);
|
|
32398
|
-
return
|
|
32399
|
-
|
|
32400
|
-
|
|
32401
|
-
|
|
32402
|
-
|
|
32403
|
-
|
|
32404
|
-
${agents}
|
|
32405
|
-
|
|
32406
|
-
|
|
32407
|
-
|
|
33839
|
+
return [
|
|
33840
|
+
ref,
|
|
33841
|
+
...priorSection ? [priorSection] : [],
|
|
33842
|
+
`## Recent activity
|
|
33843
|
+
${activity}`,
|
|
33844
|
+
`## Wrapped agents
|
|
33845
|
+
${agents}`,
|
|
33846
|
+
`## Open inbox
|
|
33847
|
+
${inbox}`
|
|
33848
|
+
].join("\n\n");
|
|
33849
|
+
}
|
|
33850
|
+
/**
|
|
33851
|
+
* Render the prior-conversation section with token-budget enforcement
|
|
33852
|
+
* (WP-V1.3-9 Tau-2). Drops oldest turns first when the rendered
|
|
33853
|
+
* section exceeds `historyTokenBudget`. Returns an empty string when
|
|
33854
|
+
* the input is empty or when the budget excludes every turn.
|
|
33855
|
+
*/
|
|
33856
|
+
formatPriorTurnsSection(turns) {
|
|
33857
|
+
if (turns.length === 0) return "";
|
|
33858
|
+
const HEADER = "## Prior conversation";
|
|
33859
|
+
const lines = turns.map(formatPriorTurnLine);
|
|
33860
|
+
const headerTokens = approxTokenLen(`${HEADER}
|
|
33861
|
+
`);
|
|
33862
|
+
const sepTokens = approxTokenLen("\n");
|
|
33863
|
+
let runningTokens = headerTokens;
|
|
33864
|
+
let runningLines = [];
|
|
33865
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
33866
|
+
const line = lines[i];
|
|
33867
|
+
const tokens = approxTokenLen(line) + (runningLines.length > 0 ? sepTokens : 0);
|
|
33868
|
+
if (runningTokens + tokens > this.historyTokenBudget) break;
|
|
33869
|
+
runningTokens += tokens;
|
|
33870
|
+
runningLines.push(line);
|
|
33871
|
+
}
|
|
33872
|
+
if (runningLines.length === 0) return "";
|
|
33873
|
+
runningLines = runningLines.reverse();
|
|
33874
|
+
return `${HEADER}
|
|
33875
|
+
${runningLines.join("\n")}`;
|
|
32408
33876
|
}
|
|
32409
33877
|
// ── audit helpers ────────────────────────────────────────────────────
|
|
32410
33878
|
emit(operation, payload, result) {
|
|
@@ -32519,11 +33987,309 @@ var init_operator_chat_store = __esm({
|
|
|
32519
33987
|
}
|
|
32520
33988
|
});
|
|
32521
33989
|
|
|
33990
|
+
// src/chat/concierge-memory-store.ts
|
|
33991
|
+
function bundleKey(threadId) {
|
|
33992
|
+
return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
|
|
33993
|
+
}
|
|
33994
|
+
function stripKeyPrefix(key) {
|
|
33995
|
+
if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
|
|
33996
|
+
return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
|
|
33997
|
+
}
|
|
33998
|
+
function lastTurnId(bundle) {
|
|
33999
|
+
let max = 0;
|
|
34000
|
+
for (const t of bundle.turns) {
|
|
34001
|
+
if (t.turn_id > max) max = t.turn_id;
|
|
34002
|
+
}
|
|
34003
|
+
return max;
|
|
34004
|
+
}
|
|
34005
|
+
var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO2, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES2, ConciergeMemoryStore;
|
|
34006
|
+
var init_concierge_memory_store = __esm({
|
|
34007
|
+
"src/chat/concierge-memory-store.ts"() {
|
|
34008
|
+
init_encryption();
|
|
34009
|
+
init_key_derivation();
|
|
34010
|
+
init_encoding();
|
|
34011
|
+
CONCIERGE_MEMORY_NAMESPACE = "_chat";
|
|
34012
|
+
CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
|
|
34013
|
+
HKDF_INFO2 = "concierge-memory-store-v1";
|
|
34014
|
+
DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
|
|
34015
|
+
MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
|
|
34016
|
+
ConciergeMemoryStore = class {
|
|
34017
|
+
storage;
|
|
34018
|
+
encryptionKey;
|
|
34019
|
+
fortressId;
|
|
34020
|
+
retentionDays;
|
|
34021
|
+
locks;
|
|
34022
|
+
constructor(opts) {
|
|
34023
|
+
this.storage = opts.storage;
|
|
34024
|
+
this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
|
|
34025
|
+
this.fortressId = opts.fortressId;
|
|
34026
|
+
this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
|
|
34027
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
34028
|
+
}
|
|
34029
|
+
/**
|
|
34030
|
+
* Append a turn to the named thread, creating the bundle if no record
|
|
34031
|
+
* exists. Returns the persisted turn (with assigned turn_id +
|
|
34032
|
+
* retention_until). Per-thread serialisation guarantees turn_id
|
|
34033
|
+
* monotonicity even under concurrent callers.
|
|
34034
|
+
*/
|
|
34035
|
+
async appendTurn(threadId, role, content) {
|
|
34036
|
+
return this.withLock(threadId, async () => {
|
|
34037
|
+
const bundle = await this.loadBundle(threadId) ?? null;
|
|
34038
|
+
const now = /* @__PURE__ */ new Date();
|
|
34039
|
+
const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
|
|
34040
|
+
const retentionUntil = new Date(now.getTime() + retentionMs);
|
|
34041
|
+
const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
|
|
34042
|
+
const turn = {
|
|
34043
|
+
thread_id: threadId,
|
|
34044
|
+
fortress_id: this.fortressId,
|
|
34045
|
+
turn_id: nextTurnId,
|
|
34046
|
+
role,
|
|
34047
|
+
content,
|
|
34048
|
+
created_at: now.toISOString(),
|
|
34049
|
+
retention_until: retentionUntil.toISOString()
|
|
34050
|
+
};
|
|
34051
|
+
const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
|
|
34052
|
+
version: 1,
|
|
34053
|
+
thread_id: threadId,
|
|
34054
|
+
fortress_id: this.fortressId,
|
|
34055
|
+
created_at: now.toISOString(),
|
|
34056
|
+
turns: [turn]
|
|
34057
|
+
};
|
|
34058
|
+
await this.saveBundle(next);
|
|
34059
|
+
return turn;
|
|
34060
|
+
});
|
|
34061
|
+
}
|
|
34062
|
+
/**
|
|
34063
|
+
* Read turns from a thread, oldest-first. Returns an empty array if
|
|
34064
|
+
* the thread does not exist or its bundle is corrupt. Does not emit
|
|
34065
|
+
* audit events; the caller (HTTP route handler) owns audit semantics.
|
|
34066
|
+
*/
|
|
34067
|
+
async readThread(threadId, opts) {
|
|
34068
|
+
const bundle = await this.loadBundle(threadId);
|
|
34069
|
+
if (!bundle) return [];
|
|
34070
|
+
let turns = bundle.turns;
|
|
34071
|
+
if (opts?.sinceTurnId !== void 0) {
|
|
34072
|
+
const cutoff = opts.sinceTurnId;
|
|
34073
|
+
turns = turns.filter((t) => t.turn_id > cutoff);
|
|
34074
|
+
}
|
|
34075
|
+
if (opts?.limit !== void 0) {
|
|
34076
|
+
turns = turns.slice(0, opts.limit);
|
|
34077
|
+
}
|
|
34078
|
+
return turns;
|
|
34079
|
+
}
|
|
34080
|
+
/**
|
|
34081
|
+
* Read turns with explicit failure surfacing (WP-V1.3-9 Tau-2). Where
|
|
34082
|
+
* `readThread` collapses every failure mode to an empty array, this
|
|
34083
|
+
* variant returns a discriminated result so the multi-turn fold path
|
|
34084
|
+
* can degrade cleanly + emit `operator_concierge_memory_read_failed`
|
|
34085
|
+
* with a concrete cause.
|
|
34086
|
+
*
|
|
34087
|
+
* - No bundle on disk → `{ ok: true, turns: [] }` (a fresh thread).
|
|
34088
|
+
* - Bundle present, decode + decrypt + schema check pass → ok with turns.
|
|
34089
|
+
* - Bundle present, oversize → `{ ok: false, reason: "oversize_bundle" }`.
|
|
34090
|
+
* - Bundle present, decryption fails → `{ ok: false, reason: "decrypt_failed" }`.
|
|
34091
|
+
* - Bundle present, schema mismatch (version / thread_id) → `schema_mismatch`.
|
|
34092
|
+
* - Storage IO error → `io_failed`.
|
|
34093
|
+
*/
|
|
34094
|
+
async readThreadStrict(threadId, opts) {
|
|
34095
|
+
const key = bundleKey(threadId);
|
|
34096
|
+
let raw;
|
|
34097
|
+
try {
|
|
34098
|
+
raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
|
|
34099
|
+
} catch {
|
|
34100
|
+
return { ok: false, reason: "io_failed" };
|
|
34101
|
+
}
|
|
34102
|
+
if (!raw) return { ok: true, turns: [] };
|
|
34103
|
+
if (raw.length > MAX_BUNDLE_BYTES2) {
|
|
34104
|
+
return { ok: false, reason: "oversize_bundle" };
|
|
34105
|
+
}
|
|
34106
|
+
let envelope;
|
|
34107
|
+
try {
|
|
34108
|
+
envelope = JSON.parse(bytesToString(raw));
|
|
34109
|
+
} catch {
|
|
34110
|
+
return { ok: false, reason: "schema_mismatch" };
|
|
34111
|
+
}
|
|
34112
|
+
let plaintext;
|
|
34113
|
+
try {
|
|
34114
|
+
const aad = stringToBytes(threadId);
|
|
34115
|
+
plaintext = decrypt(envelope, this.encryptionKey, aad);
|
|
34116
|
+
} catch {
|
|
34117
|
+
return { ok: false, reason: "decrypt_failed" };
|
|
34118
|
+
}
|
|
34119
|
+
let parsed;
|
|
34120
|
+
try {
|
|
34121
|
+
parsed = JSON.parse(bytesToString(plaintext));
|
|
34122
|
+
} catch {
|
|
34123
|
+
return { ok: false, reason: "schema_mismatch" };
|
|
34124
|
+
}
|
|
34125
|
+
if (parsed.version !== 1) return { ok: false, reason: "schema_mismatch" };
|
|
34126
|
+
if (parsed.thread_id !== threadId) {
|
|
34127
|
+
return { ok: false, reason: "schema_mismatch" };
|
|
34128
|
+
}
|
|
34129
|
+
let turns = parsed.turns;
|
|
34130
|
+
if (opts?.sinceTurnId !== void 0) {
|
|
34131
|
+
const cutoff = opts.sinceTurnId;
|
|
34132
|
+
turns = turns.filter((t) => t.turn_id > cutoff);
|
|
34133
|
+
}
|
|
34134
|
+
if (opts?.limit !== void 0) {
|
|
34135
|
+
turns = turns.slice(0, opts.limit);
|
|
34136
|
+
}
|
|
34137
|
+
return { ok: true, turns };
|
|
34138
|
+
}
|
|
34139
|
+
/**
|
|
34140
|
+
* Enumerate concierge threads in this fortress with summary metadata.
|
|
34141
|
+
* Sorted newest-first by last_turn_at.
|
|
34142
|
+
*/
|
|
34143
|
+
async listThreads(opts) {
|
|
34144
|
+
const entries = await this.storage.list(
|
|
34145
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34146
|
+
CONCIERGE_MEMORY_KEY_PREFIX
|
|
34147
|
+
);
|
|
34148
|
+
const summaries = [];
|
|
34149
|
+
for (const meta of entries) {
|
|
34150
|
+
const threadId = stripKeyPrefix(meta.key);
|
|
34151
|
+
if (threadId === null) continue;
|
|
34152
|
+
const bundle = await this.loadBundle(threadId);
|
|
34153
|
+
if (!bundle || bundle.turns.length === 0) continue;
|
|
34154
|
+
const last = bundle.turns[bundle.turns.length - 1];
|
|
34155
|
+
summaries.push({
|
|
34156
|
+
thread_id: bundle.thread_id,
|
|
34157
|
+
created_at: bundle.created_at,
|
|
34158
|
+
last_turn_at: last ? last.created_at : bundle.created_at,
|
|
34159
|
+
turn_count: bundle.turns.length
|
|
34160
|
+
});
|
|
34161
|
+
}
|
|
34162
|
+
summaries.sort(
|
|
34163
|
+
(a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
|
|
34164
|
+
);
|
|
34165
|
+
if (opts?.limit !== void 0) {
|
|
34166
|
+
return summaries.slice(0, opts.limit);
|
|
34167
|
+
}
|
|
34168
|
+
return summaries;
|
|
34169
|
+
}
|
|
34170
|
+
/**
|
|
34171
|
+
* Delete a thread's bundle. Returns true if the bundle existed and
|
|
34172
|
+
* was removed; false if no bundle was present. Audit emission is the
|
|
34173
|
+
* caller's responsibility.
|
|
34174
|
+
*/
|
|
34175
|
+
async deleteThread(threadId) {
|
|
34176
|
+
const key = bundleKey(threadId);
|
|
34177
|
+
return this.withLock(threadId, async () => {
|
|
34178
|
+
const existed = await this.storage.exists(
|
|
34179
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34180
|
+
key
|
|
34181
|
+
);
|
|
34182
|
+
if (!existed) return false;
|
|
34183
|
+
try {
|
|
34184
|
+
await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
|
|
34185
|
+
} catch {
|
|
34186
|
+
return false;
|
|
34187
|
+
}
|
|
34188
|
+
return true;
|
|
34189
|
+
});
|
|
34190
|
+
}
|
|
34191
|
+
/**
|
|
34192
|
+
* Drop expired turns across all threads. Threads emptied by pruning
|
|
34193
|
+
* are removed entirely. Returns the count of turns pruned.
|
|
34194
|
+
*/
|
|
34195
|
+
async pruneExpired(now) {
|
|
34196
|
+
const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
34197
|
+
const entries = await this.storage.list(
|
|
34198
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34199
|
+
CONCIERGE_MEMORY_KEY_PREFIX
|
|
34200
|
+
);
|
|
34201
|
+
let pruned = 0;
|
|
34202
|
+
for (const meta of entries) {
|
|
34203
|
+
const threadId = stripKeyPrefix(meta.key);
|
|
34204
|
+
if (threadId === null) continue;
|
|
34205
|
+
pruned += await this.withLock(threadId, async () => {
|
|
34206
|
+
const bundle = await this.loadBundle(threadId);
|
|
34207
|
+
if (!bundle) return 0;
|
|
34208
|
+
const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
|
|
34209
|
+
const dropped = bundle.turns.length - kept.length;
|
|
34210
|
+
if (dropped === 0) return 0;
|
|
34211
|
+
if (kept.length === 0) {
|
|
34212
|
+
await this.storage.delete(
|
|
34213
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34214
|
+
bundleKey(threadId)
|
|
34215
|
+
);
|
|
34216
|
+
} else {
|
|
34217
|
+
await this.saveBundle({ ...bundle, turns: kept });
|
|
34218
|
+
}
|
|
34219
|
+
return dropped;
|
|
34220
|
+
});
|
|
34221
|
+
}
|
|
34222
|
+
return { pruned };
|
|
34223
|
+
}
|
|
34224
|
+
// ── internals ────────────────────────────────────────────────────────
|
|
34225
|
+
async loadBundle(threadId) {
|
|
34226
|
+
const key = bundleKey(threadId);
|
|
34227
|
+
let raw;
|
|
34228
|
+
try {
|
|
34229
|
+
raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
|
|
34230
|
+
} catch {
|
|
34231
|
+
return null;
|
|
34232
|
+
}
|
|
34233
|
+
if (!raw) return null;
|
|
34234
|
+
if (raw.length > MAX_BUNDLE_BYTES2) return null;
|
|
34235
|
+
try {
|
|
34236
|
+
const envelope = JSON.parse(bytesToString(raw));
|
|
34237
|
+
const aad = stringToBytes(threadId);
|
|
34238
|
+
const plaintext = decrypt(envelope, this.encryptionKey, aad);
|
|
34239
|
+
const parsed = JSON.parse(
|
|
34240
|
+
bytesToString(plaintext)
|
|
34241
|
+
);
|
|
34242
|
+
if (parsed.version !== 1) return null;
|
|
34243
|
+
if (parsed.thread_id !== threadId) return null;
|
|
34244
|
+
return parsed;
|
|
34245
|
+
} catch {
|
|
34246
|
+
return null;
|
|
34247
|
+
}
|
|
34248
|
+
}
|
|
34249
|
+
async saveBundle(bundle) {
|
|
34250
|
+
const key = bundleKey(bundle.thread_id);
|
|
34251
|
+
const aad = stringToBytes(bundle.thread_id);
|
|
34252
|
+
const plaintext = stringToBytes(JSON.stringify(bundle));
|
|
34253
|
+
const envelope = encrypt(plaintext, this.encryptionKey, aad);
|
|
34254
|
+
await this.storage.write(
|
|
34255
|
+
CONCIERGE_MEMORY_NAMESPACE,
|
|
34256
|
+
key,
|
|
34257
|
+
stringToBytes(JSON.stringify(envelope))
|
|
34258
|
+
);
|
|
34259
|
+
}
|
|
34260
|
+
/**
|
|
34261
|
+
* Run `task` while holding the per-thread async lock. Lock is released
|
|
34262
|
+
* once the task settles (success or failure). Generic helper so
|
|
34263
|
+
* appendTurn / deleteThread / pruneExpired share serialisation.
|
|
34264
|
+
*/
|
|
34265
|
+
async withLock(threadId, task) {
|
|
34266
|
+
const previous = this.locks.get(threadId) ?? Promise.resolve();
|
|
34267
|
+
let release;
|
|
34268
|
+
const next = new Promise((resolve8) => {
|
|
34269
|
+
release = resolve8;
|
|
34270
|
+
});
|
|
34271
|
+
const chained = previous.then(() => next);
|
|
34272
|
+
this.locks.set(threadId, chained);
|
|
34273
|
+
try {
|
|
34274
|
+
await previous;
|
|
34275
|
+
return await task();
|
|
34276
|
+
} finally {
|
|
34277
|
+
release();
|
|
34278
|
+
if (this.locks.get(threadId) === chained) {
|
|
34279
|
+
this.locks.delete(threadId);
|
|
34280
|
+
}
|
|
34281
|
+
}
|
|
34282
|
+
}
|
|
34283
|
+
};
|
|
34284
|
+
}
|
|
34285
|
+
});
|
|
34286
|
+
|
|
32522
34287
|
// src/chat/operator-chat-index.ts
|
|
32523
34288
|
var init_operator_chat_index = __esm({
|
|
32524
34289
|
"src/chat/operator-chat-index.ts"() {
|
|
32525
34290
|
init_operator_chat_service();
|
|
32526
34291
|
init_operator_chat_store();
|
|
34292
|
+
init_concierge_memory_store();
|
|
32527
34293
|
init_operator_chat_audit_events();
|
|
32528
34294
|
init_operator_chat_types();
|
|
32529
34295
|
}
|
|
@@ -32537,6 +34303,14 @@ function buildV11Bindings(inputs) {
|
|
|
32537
34303
|
let operatorChatService;
|
|
32538
34304
|
if (inputs.storage && inputs.masterKey) {
|
|
32539
34305
|
const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
|
|
34306
|
+
const conciergeMemory = new ConciergeMemoryStore({
|
|
34307
|
+
storage: inputs.storage,
|
|
34308
|
+
masterKey: inputs.masterKey,
|
|
34309
|
+
fortressId: inputs.fortressId,
|
|
34310
|
+
...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
|
|
34311
|
+
});
|
|
34312
|
+
void conciergeMemory.pruneExpired().catch(() => {
|
|
34313
|
+
});
|
|
32540
34314
|
operatorChatService = new OperatorChatService({
|
|
32541
34315
|
store: chatStore,
|
|
32542
34316
|
auditLog: inputs.auditLog,
|
|
@@ -32547,7 +34321,8 @@ function buildV11Bindings(inputs) {
|
|
|
32547
34321
|
identityId: inputs.identityId,
|
|
32548
34322
|
registry
|
|
32549
34323
|
}),
|
|
32550
|
-
conciergePiiFilter: buildConciergePiiFilter()
|
|
34324
|
+
conciergePiiFilter: buildConciergePiiFilter(),
|
|
34325
|
+
conciergeMemory
|
|
32551
34326
|
});
|
|
32552
34327
|
}
|
|
32553
34328
|
const hubService = new HubService({
|
|
@@ -32782,7 +34557,7 @@ var init_defaults = __esm({
|
|
|
32782
34557
|
});
|
|
32783
34558
|
|
|
32784
34559
|
// src/intelligence/policy-store.ts
|
|
32785
|
-
var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY,
|
|
34560
|
+
var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO3, IntelligenceConfigStore;
|
|
32786
34561
|
var init_policy_store = __esm({
|
|
32787
34562
|
"src/intelligence/policy-store.ts"() {
|
|
32788
34563
|
init_encryption();
|
|
@@ -32791,13 +34566,13 @@ var init_policy_store = __esm({
|
|
|
32791
34566
|
init_defaults();
|
|
32792
34567
|
INTELLIGENCE_NAMESPACE = "_intelligence";
|
|
32793
34568
|
SUBSTRATE_CONFIG_KEY = "substrate-config";
|
|
32794
|
-
|
|
34569
|
+
HKDF_INFO3 = "intelligence-substrate-config";
|
|
32795
34570
|
IntelligenceConfigStore = class {
|
|
32796
34571
|
storage;
|
|
32797
34572
|
encryptionKey;
|
|
32798
34573
|
constructor(storage, masterKey) {
|
|
32799
34574
|
this.storage = storage;
|
|
32800
|
-
this.encryptionKey = derivePurposeKey(masterKey,
|
|
34575
|
+
this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
|
|
32801
34576
|
}
|
|
32802
34577
|
/**
|
|
32803
34578
|
* Load the operator's substrate config from disk. Returns the config
|
|
@@ -34819,7 +36594,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
|
|
|
34819
36594
|
);
|
|
34820
36595
|
}
|
|
34821
36596
|
}
|
|
34822
|
-
const
|
|
36597
|
+
const reputationBundleFailed = reputation?.bundle_signature_valid === false;
|
|
36598
|
+
const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
|
|
36599
|
+
const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
|
|
34823
36600
|
const identityFailed = identity ? !identity.signature_valid : false;
|
|
34824
36601
|
const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
|
|
34825
36602
|
const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
|
|
@@ -34828,6 +36605,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
|
|
|
34828
36605
|
`${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
|
|
34829
36606
|
);
|
|
34830
36607
|
}
|
|
36608
|
+
let detailedFailureClass;
|
|
36609
|
+
if (identityFailed) {
|
|
36610
|
+
detailedFailureClass = "identity_signature_invalid";
|
|
36611
|
+
} else if (reputationBundleFailed) {
|
|
36612
|
+
detailedFailureClass = "reputation_bundle_signature_invalid";
|
|
36613
|
+
} else if (reputationAttestationFailed) {
|
|
36614
|
+
detailedFailureClass = "reputation_attestation_signature_invalid";
|
|
36615
|
+
} else if (unverifiableFailed) {
|
|
36616
|
+
detailedFailureClass = "reputation_unverifiable_attestations";
|
|
36617
|
+
}
|
|
34831
36618
|
return {
|
|
34832
36619
|
version: "1.1",
|
|
34833
36620
|
passed: !reputationFailed && !identityFailed && !unverifiableFailed,
|
|
@@ -34847,7 +36634,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
|
|
|
34847
36634
|
identity,
|
|
34848
36635
|
audit,
|
|
34849
36636
|
reputation,
|
|
34850
|
-
failure_class:
|
|
36637
|
+
failure_class: detailedFailureClass
|
|
34851
36638
|
};
|
|
34852
36639
|
}
|
|
34853
36640
|
var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
|
|
@@ -35246,7 +37033,7 @@ async function resolveSourceMasterKey(encryptedState, opts) {
|
|
|
35246
37033
|
}
|
|
35247
37034
|
return null;
|
|
35248
37035
|
}
|
|
35249
|
-
async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId) {
|
|
37036
|
+
async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIdentityId, importedRekeyEntries) {
|
|
35250
37037
|
const destinationSigner = opts.destinationSignerIdentityId ? opts.identityManager.get(opts.destinationSignerIdentityId) : opts.identityManager.getDefault();
|
|
35251
37038
|
if (!destinationSigner) {
|
|
35252
37039
|
return {
|
|
@@ -35308,8 +37095,9 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
|
|
|
35308
37095
|
}
|
|
35309
37096
|
}
|
|
35310
37097
|
}
|
|
37098
|
+
let plaintext;
|
|
35311
37099
|
try {
|
|
35312
|
-
|
|
37100
|
+
plaintext = decrypt(
|
|
35313
37101
|
item.entry.payload,
|
|
35314
37102
|
deriveNamespaceKey(sourceMasterKey, item.namespace)
|
|
35315
37103
|
);
|
|
@@ -35318,28 +37106,30 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
|
|
|
35318
37106
|
skipped++;
|
|
35319
37107
|
continue;
|
|
35320
37108
|
}
|
|
35321
|
-
await stateStore.write(
|
|
35322
|
-
item.namespace,
|
|
35323
|
-
item.key,
|
|
35324
|
-
bytesToString(plaintext),
|
|
35325
|
-
destinationSigner.identity_id,
|
|
35326
|
-
destinationSigner.encrypted_private_key,
|
|
35327
|
-
identityEncryptionKey,
|
|
35328
|
-
{
|
|
35329
|
-
content_type: item.entry.metadata.content_type,
|
|
35330
|
-
ttl_seconds: item.entry.metadata.ttl_seconds,
|
|
35331
|
-
tags: [
|
|
35332
|
-
...item.entry.metadata.tags ?? [],
|
|
35333
|
-
"exit-import",
|
|
35334
|
-
`source:${item.entry.kid}`
|
|
35335
|
-
]
|
|
35336
|
-
}
|
|
35337
|
-
);
|
|
35338
|
-
imported++;
|
|
35339
37109
|
} catch {
|
|
35340
37110
|
skippedInvalidSig++;
|
|
35341
37111
|
skipped++;
|
|
37112
|
+
continue;
|
|
35342
37113
|
}
|
|
37114
|
+
await stateStore.write(
|
|
37115
|
+
item.namespace,
|
|
37116
|
+
item.key,
|
|
37117
|
+
bytesToString(plaintext),
|
|
37118
|
+
destinationSigner.identity_id,
|
|
37119
|
+
destinationSigner.encrypted_private_key,
|
|
37120
|
+
identityEncryptionKey,
|
|
37121
|
+
{
|
|
37122
|
+
content_type: item.entry.metadata.content_type,
|
|
37123
|
+
ttl_seconds: item.entry.metadata.ttl_seconds,
|
|
37124
|
+
tags: [
|
|
37125
|
+
...item.entry.metadata.tags ?? [],
|
|
37126
|
+
"exit-import",
|
|
37127
|
+
`source:${item.entry.kid}`
|
|
37128
|
+
]
|
|
37129
|
+
}
|
|
37130
|
+
);
|
|
37131
|
+
imported++;
|
|
37132
|
+
importedRekeyEntries?.push({ namespace: item.namespace, key: item.key });
|
|
35343
37133
|
}
|
|
35344
37134
|
return {
|
|
35345
37135
|
status: "rekeyed",
|
|
@@ -35350,6 +37140,23 @@ async function rekeyState(encryptedState, opts, sourceMasterKey, publicKeysByIde
|
|
|
35350
37140
|
conflicts
|
|
35351
37141
|
};
|
|
35352
37142
|
}
|
|
37143
|
+
async function cleanupStagedPaths(storage, staged) {
|
|
37144
|
+
let removed = 0;
|
|
37145
|
+
const failed = [];
|
|
37146
|
+
for (const loc of staged) {
|
|
37147
|
+
try {
|
|
37148
|
+
const ok2 = await storage.delete(loc.namespace, loc.key);
|
|
37149
|
+
if (ok2) {
|
|
37150
|
+
removed++;
|
|
37151
|
+
} else {
|
|
37152
|
+
failed.push(loc);
|
|
37153
|
+
}
|
|
37154
|
+
} catch {
|
|
37155
|
+
failed.push(loc);
|
|
37156
|
+
}
|
|
37157
|
+
}
|
|
37158
|
+
return { removed, failed };
|
|
37159
|
+
}
|
|
35353
37160
|
async function stageArtifact(storage, namespace, key, value) {
|
|
35354
37161
|
await storage.write(namespace, key, jsonBytes(value));
|
|
35355
37162
|
}
|
|
@@ -35474,6 +37281,8 @@ async function importExitBundle(opts) {
|
|
|
35474
37281
|
}
|
|
35475
37282
|
const importId = importIdForManifest(manifest);
|
|
35476
37283
|
const stagedArtifacts = [];
|
|
37284
|
+
const stagedLocations = [];
|
|
37285
|
+
const importedRekeyEntries = [];
|
|
35477
37286
|
if (identityArtifact) {
|
|
35478
37287
|
await stageArtifact(
|
|
35479
37288
|
opts.storage,
|
|
@@ -35482,10 +37291,15 @@ async function importExitBundle(opts) {
|
|
|
35482
37291
|
identityArtifact.json
|
|
35483
37292
|
);
|
|
35484
37293
|
stagedArtifacts.push("public_identity");
|
|
37294
|
+
stagedLocations.push({
|
|
37295
|
+
namespace: EXIT_PUBLIC_IDENTITIES_NAMESPACE,
|
|
37296
|
+
key: identityArtifact.json.bundle.identity_id
|
|
37297
|
+
});
|
|
35485
37298
|
}
|
|
35486
37299
|
if (policySet) {
|
|
35487
37300
|
await stageArtifact(opts.storage, EXIT_POLICY_SETS_NAMESPACE, importId, policySet.json);
|
|
35488
37301
|
stagedArtifacts.push("policy_set");
|
|
37302
|
+
stagedLocations.push({ namespace: EXIT_POLICY_SETS_NAMESPACE, key: importId });
|
|
35489
37303
|
}
|
|
35490
37304
|
if (auditReceipts) {
|
|
35491
37305
|
await stageArtifact(
|
|
@@ -35495,10 +37309,12 @@ async function importExitBundle(opts) {
|
|
|
35495
37309
|
auditReceipts.json
|
|
35496
37310
|
);
|
|
35497
37311
|
stagedArtifacts.push("audit_receipts");
|
|
37312
|
+
stagedLocations.push({ namespace: EXIT_AUDIT_RECEIPTS_NAMESPACE, key: importId });
|
|
35498
37313
|
}
|
|
35499
37314
|
if (commitments) {
|
|
35500
37315
|
await stageArtifact(opts.storage, EXIT_COMMITMENTS_NAMESPACE, importId, commitments.json);
|
|
35501
37316
|
stagedArtifacts.push("commitments");
|
|
37317
|
+
stagedLocations.push({ namespace: EXIT_COMMITMENTS_NAMESPACE, key: importId });
|
|
35502
37318
|
}
|
|
35503
37319
|
if (placeholderMetadata) {
|
|
35504
37320
|
await stageArtifact(
|
|
@@ -35508,12 +37324,17 @@ async function importExitBundle(opts) {
|
|
|
35508
37324
|
placeholderMetadata.json
|
|
35509
37325
|
);
|
|
35510
37326
|
stagedArtifacts.push("placeholder_vault_metadata");
|
|
37327
|
+
stagedLocations.push({
|
|
37328
|
+
namespace: EXIT_PLACEHOLDER_METADATA_NAMESPACE,
|
|
37329
|
+
key: importId
|
|
37330
|
+
});
|
|
35511
37331
|
}
|
|
35512
37332
|
await stageArtifact(opts.storage, EXIT_IMPORT_NAMESPACE, importId, {
|
|
35513
37333
|
manifest: manifest.body,
|
|
35514
37334
|
verified_at: verification.verified_at,
|
|
35515
37335
|
activated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
35516
37336
|
});
|
|
37337
|
+
stagedLocations.push({ namespace: EXIT_IMPORT_NAMESPACE, key: importId });
|
|
35517
37338
|
const publicKeys = identityArtifact ? publicKeysFromIdentityArtifact(identityArtifact.json) : { byIdentityId: /* @__PURE__ */ new Map(), byDid: /* @__PURE__ */ new Map() };
|
|
35518
37339
|
let reputationResult = {
|
|
35519
37340
|
imported_attestations: 0,
|
|
@@ -35538,26 +37359,57 @@ async function importExitBundle(opts) {
|
|
|
35538
37359
|
encryptedState?.json ?? null,
|
|
35539
37360
|
opts
|
|
35540
37361
|
);
|
|
35541
|
-
|
|
35542
|
-
|
|
35543
|
-
|
|
35544
|
-
|
|
35545
|
-
|
|
35546
|
-
|
|
35547
|
-
|
|
35548
|
-
|
|
35549
|
-
|
|
35550
|
-
|
|
35551
|
-
|
|
35552
|
-
|
|
35553
|
-
|
|
35554
|
-
|
|
35555
|
-
|
|
35556
|
-
|
|
35557
|
-
|
|
35558
|
-
|
|
35559
|
-
|
|
35560
|
-
|
|
37362
|
+
let stateResult;
|
|
37363
|
+
try {
|
|
37364
|
+
stateResult = encryptedState && encryptedState.json.entries.length > 0 ? sourceMasterKey ? await rekeyState(
|
|
37365
|
+
encryptedState.json,
|
|
37366
|
+
opts,
|
|
37367
|
+
sourceMasterKey,
|
|
37368
|
+
publicKeys.byIdentityId,
|
|
37369
|
+
importedRekeyEntries
|
|
37370
|
+
) : {
|
|
37371
|
+
status: "staged_requires_source_key",
|
|
37372
|
+
imported_keys: 0,
|
|
37373
|
+
skipped_keys: encryptedState.json.entries.length,
|
|
37374
|
+
skipped_invalid_sig: 0,
|
|
37375
|
+
skipped_unknown_kid: 0,
|
|
37376
|
+
conflicts: conflicts.state_conflicts.length
|
|
37377
|
+
} : {
|
|
37378
|
+
status: "not_requested",
|
|
37379
|
+
imported_keys: 0,
|
|
37380
|
+
skipped_keys: 0,
|
|
37381
|
+
skipped_invalid_sig: 0,
|
|
37382
|
+
skipped_unknown_kid: 0,
|
|
37383
|
+
conflicts: 0
|
|
37384
|
+
};
|
|
37385
|
+
} catch (err) {
|
|
37386
|
+
const toCleanup = [
|
|
37387
|
+
...importedRekeyEntries,
|
|
37388
|
+
...stagedLocations
|
|
37389
|
+
];
|
|
37390
|
+
const cleanup = await cleanupStagedPaths(opts.storage, toCleanup);
|
|
37391
|
+
opts.auditLog.append(
|
|
37392
|
+
"l1",
|
|
37393
|
+
"exit_bundle_rekey_failed_cleanup",
|
|
37394
|
+
manifest.body.identity_binding.identity_id,
|
|
37395
|
+
{
|
|
37396
|
+
import_id: importId,
|
|
37397
|
+
manifest_version: manifest.body.manifest_version,
|
|
37398
|
+
rekey_entries_removed: importedRekeyEntries.length,
|
|
37399
|
+
staged_artifacts_removed: stagedLocations.length,
|
|
37400
|
+
removed_total: cleanup.removed,
|
|
37401
|
+
cleanup_failed_count: cleanup.failed.length,
|
|
37402
|
+
original_error: err instanceof Error ? err.message : String(err)
|
|
37403
|
+
},
|
|
37404
|
+
"failure"
|
|
37405
|
+
);
|
|
37406
|
+
await opts.auditLog.flush();
|
|
37407
|
+
const originalMessage = err instanceof Error ? err.message : String(err);
|
|
37408
|
+
throw new ExitBundleImportError(
|
|
37409
|
+
"REKEY_FAILED_AND_CLEANED",
|
|
37410
|
+
`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).`
|
|
37411
|
+
);
|
|
37412
|
+
}
|
|
35561
37413
|
opts.auditLog.append("l1", "exit_bundle_import_activate", manifest.body.identity_binding.identity_id, {
|
|
35562
37414
|
import_id: importId,
|
|
35563
37415
|
manifest_version: manifest.body.manifest_version,
|
|
@@ -35815,7 +37667,19 @@ async function runExitCommand(args) {
|
|
|
35815
37667
|
}
|
|
35816
37668
|
const config = await loadConfig();
|
|
35817
37669
|
const ctx = await openExitContext(argv, env);
|
|
35818
|
-
|
|
37670
|
+
let policy;
|
|
37671
|
+
try {
|
|
37672
|
+
policy = await loadPrincipalPolicy(ctx.storagePath);
|
|
37673
|
+
} catch (policyErr) {
|
|
37674
|
+
if (policyErr instanceof MalformedPrincipalPolicyError) {
|
|
37675
|
+
write(err, `
|
|
37676
|
+
Sanctuary cannot proceed.
|
|
37677
|
+
${policyErr.message}
|
|
37678
|
+
`);
|
|
37679
|
+
return 1;
|
|
37680
|
+
}
|
|
37681
|
+
throw policyErr;
|
|
37682
|
+
}
|
|
35819
37683
|
const result = await exportExitBundle({
|
|
35820
37684
|
bundleDir: outDir,
|
|
35821
37685
|
storage: ctx.storage,
|
|
@@ -36515,7 +38379,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
|
|
|
36515
38379
|
const profileStore = new SovereigntyProfileStore(storage, masterKey);
|
|
36516
38380
|
await profileStore.load();
|
|
36517
38381
|
const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
|
|
36518
|
-
|
|
38382
|
+
let policy;
|
|
38383
|
+
try {
|
|
38384
|
+
policy = await loadPrincipalPolicy(config.storage_path);
|
|
38385
|
+
} catch (err) {
|
|
38386
|
+
if (err instanceof MalformedPrincipalPolicyError) {
|
|
38387
|
+
console.error(`
|
|
38388
|
+
Sanctuary cannot start.
|
|
38389
|
+
${err.message}
|
|
38390
|
+
`);
|
|
38391
|
+
process.exit(1);
|
|
38392
|
+
}
|
|
38393
|
+
throw err;
|
|
38394
|
+
}
|
|
36519
38395
|
const baseline = new BaselineTracker(storage, masterKey);
|
|
36520
38396
|
await baseline.load();
|
|
36521
38397
|
let approvalChannel;
|
|
@@ -36612,7 +38488,35 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
|
|
|
36612
38488
|
timestamp: alert.timestamp
|
|
36613
38489
|
});
|
|
36614
38490
|
} : void 0;
|
|
36615
|
-
const
|
|
38491
|
+
const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
|
|
38492
|
+
const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
|
|
38493
|
+
const approvalAggregator = new ApprovalAggregator({
|
|
38494
|
+
storage,
|
|
38495
|
+
masterKey,
|
|
38496
|
+
auditLog,
|
|
38497
|
+
identityId: aggregatorIdentityId,
|
|
38498
|
+
fortressId: fortressIdForAggregator
|
|
38499
|
+
});
|
|
38500
|
+
const wrappedApprovalChannel = new AggregatorBackedChannel({
|
|
38501
|
+
underlying: approvalChannel,
|
|
38502
|
+
aggregator: approvalAggregator,
|
|
38503
|
+
resolveRedirect: makeRedirectResolverFromPolicySupplier(() => policy),
|
|
38504
|
+
replaceModeTimeoutMs: policy.approval_channel.timeout_seconds * 1e3
|
|
38505
|
+
});
|
|
38506
|
+
const gate = new ApprovalGate(
|
|
38507
|
+
policy,
|
|
38508
|
+
baseline,
|
|
38509
|
+
wrappedApprovalChannel,
|
|
38510
|
+
auditLog,
|
|
38511
|
+
injectionDetector,
|
|
38512
|
+
onInjectionAlert
|
|
38513
|
+
);
|
|
38514
|
+
gate.setApprovalEventCallback((event) => {
|
|
38515
|
+
void approvalAggregator.ingest(event);
|
|
38516
|
+
});
|
|
38517
|
+
if (dashboard) {
|
|
38518
|
+
dashboard.setApprovalAggregator(approvalAggregator);
|
|
38519
|
+
}
|
|
36616
38520
|
const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
|
|
36617
38521
|
const { tools: sanctuaryMetaTools } = createSanctuaryTools({
|
|
36618
38522
|
config,
|
|
@@ -36803,6 +38707,8 @@ var init_src = __esm({
|
|
|
36803
38707
|
init_dashboard();
|
|
36804
38708
|
init_webhook();
|
|
36805
38709
|
init_gate();
|
|
38710
|
+
init_approval_aggregator();
|
|
38711
|
+
init_aggregator_backed_channel();
|
|
36806
38712
|
init_tools4();
|
|
36807
38713
|
init_router();
|
|
36808
38714
|
init_router();
|
|
@@ -39767,6 +41673,22 @@ var init_broker = __esm({
|
|
|
39767
41673
|
auditLog;
|
|
39768
41674
|
issuer;
|
|
39769
41675
|
principalIdentityId;
|
|
41676
|
+
/**
|
|
41677
|
+
* Per-secret-name mutex. Hardening wave 6 finding #64: two concurrent
|
|
41678
|
+
* addSecret() / rotateSecret() / deleteSecret() calls on the same name
|
|
41679
|
+
* MUST serialize cleanly. The keychain backend's `find-then-add` and
|
|
41680
|
+
* `find-then-delete-then-add` shapes (KeychainBackend.addSecret /
|
|
41681
|
+
* .rotateSecret) are not atomic against another caller racing the same
|
|
41682
|
+
* service-name; without serialization the second caller can observe a
|
|
41683
|
+
* stale "exists" check and either drop the new value or leave a
|
|
41684
|
+
* duplicate keychain entry.
|
|
41685
|
+
*
|
|
41686
|
+
* Implementation: an in-memory promise chain per name. Subsequent
|
|
41687
|
+
* callers `await` the chain tail and append their own work; failures
|
|
41688
|
+
* propagate to the failing caller without poisoning the chain for
|
|
41689
|
+
* later callers.
|
|
41690
|
+
*/
|
|
41691
|
+
nameLocks = /* @__PURE__ */ new Map();
|
|
39770
41692
|
constructor(opts) {
|
|
39771
41693
|
this.backend = opts.backend;
|
|
39772
41694
|
this.auditLog = opts.auditLog;
|
|
@@ -39777,6 +41699,40 @@ var init_broker = __esm({
|
|
|
39777
41699
|
grants: opts.grants
|
|
39778
41700
|
});
|
|
39779
41701
|
}
|
|
41702
|
+
/**
|
|
41703
|
+
* Serialize `op` against any other in-flight write to the same secret
|
|
41704
|
+
* `name`. Per-name fairness only, distinct names run in parallel.
|
|
41705
|
+
* The current chain tail is used as the acceptance gate; we then
|
|
41706
|
+
* publish a new tail that swallows the operation's outcome so a
|
|
41707
|
+
* thrown error does not poison the next caller's wait.
|
|
41708
|
+
*/
|
|
41709
|
+
async withNameLock(name, op) {
|
|
41710
|
+
const previous = this.nameLocks.get(name) ?? Promise.resolve();
|
|
41711
|
+
let release = () => {
|
|
41712
|
+
};
|
|
41713
|
+
const next = new Promise((resolve8) => {
|
|
41714
|
+
release = resolve8;
|
|
41715
|
+
});
|
|
41716
|
+
this.nameLocks.set(name, next);
|
|
41717
|
+
try {
|
|
41718
|
+
await previous.catch(() => {
|
|
41719
|
+
});
|
|
41720
|
+
return await op();
|
|
41721
|
+
} finally {
|
|
41722
|
+
release();
|
|
41723
|
+
if (this.nameLocks.get(name) === next) {
|
|
41724
|
+
this.nameLocks.delete(name);
|
|
41725
|
+
}
|
|
41726
|
+
}
|
|
41727
|
+
}
|
|
41728
|
+
/**
|
|
41729
|
+
* Diagnostic-only: visible for tests so they can assert that distinct
|
|
41730
|
+
* names do not contend on a shared lock. Not part of the public broker
|
|
41731
|
+
* contract; do not consume from production code.
|
|
41732
|
+
*/
|
|
41733
|
+
__nameLockCountForTests() {
|
|
41734
|
+
return this.nameLocks.size;
|
|
41735
|
+
}
|
|
39780
41736
|
/** Ensure backend is initialized and unlocked. Audits the unlock. */
|
|
39781
41737
|
async ensureUnlocked(passphrase) {
|
|
39782
41738
|
await this.backend.ensureInitialized(passphrase);
|
|
@@ -39789,31 +41745,37 @@ var init_broker = __esm({
|
|
|
39789
41745
|
);
|
|
39790
41746
|
}
|
|
39791
41747
|
async addSecret(name, value) {
|
|
39792
|
-
await this.
|
|
39793
|
-
|
|
39794
|
-
|
|
39795
|
-
|
|
39796
|
-
|
|
39797
|
-
|
|
39798
|
-
|
|
41748
|
+
await this.withNameLock(name, async () => {
|
|
41749
|
+
await this.backend.addSecret(name, value);
|
|
41750
|
+
this.auditLog.append(
|
|
41751
|
+
"l3",
|
|
41752
|
+
BROKER_OPS.SECRET_ADDED,
|
|
41753
|
+
this.principalIdentityId,
|
|
41754
|
+
{ secret: name }
|
|
41755
|
+
);
|
|
41756
|
+
});
|
|
39799
41757
|
}
|
|
39800
41758
|
async rotateSecret(name, newValue) {
|
|
39801
|
-
await this.
|
|
39802
|
-
|
|
39803
|
-
|
|
39804
|
-
|
|
39805
|
-
|
|
39806
|
-
|
|
39807
|
-
|
|
41759
|
+
await this.withNameLock(name, async () => {
|
|
41760
|
+
await this.backend.rotateSecret(name, newValue);
|
|
41761
|
+
this.auditLog.append(
|
|
41762
|
+
"l3",
|
|
41763
|
+
BROKER_OPS.SECRET_ROTATED,
|
|
41764
|
+
this.principalIdentityId,
|
|
41765
|
+
{ secret: name }
|
|
41766
|
+
);
|
|
41767
|
+
});
|
|
39808
41768
|
}
|
|
39809
41769
|
async deleteSecret(name) {
|
|
39810
|
-
await this.
|
|
39811
|
-
|
|
39812
|
-
|
|
39813
|
-
|
|
39814
|
-
|
|
39815
|
-
|
|
39816
|
-
|
|
41770
|
+
await this.withNameLock(name, async () => {
|
|
41771
|
+
await this.backend.deleteSecret(name);
|
|
41772
|
+
this.auditLog.append(
|
|
41773
|
+
"l3",
|
|
41774
|
+
BROKER_OPS.SECRET_DELETED,
|
|
41775
|
+
this.principalIdentityId,
|
|
41776
|
+
{ secret: name }
|
|
41777
|
+
);
|
|
41778
|
+
});
|
|
39817
41779
|
}
|
|
39818
41780
|
async listSecretNames() {
|
|
39819
41781
|
return this.backend.listSecretNames();
|
|
@@ -39853,6 +41815,19 @@ var init_broker = __esm({
|
|
|
39853
41815
|
liveTokenCount() {
|
|
39854
41816
|
return this.issuer.liveTokenCount();
|
|
39855
41817
|
}
|
|
41818
|
+
/**
|
|
41819
|
+
* Drop expired tokens from the in-memory issuer map. Hardening wave 6
|
|
41820
|
+
* finding #86: previously expiry pruning depended on opportunistic
|
|
41821
|
+
* `pruneExpired()` calls; now the cocoon-unlock initialization path
|
|
41822
|
+
* (openBroker -> after backend.ensureInitialized -> after Broker
|
|
41823
|
+
* construction) fires this once so each cocoon-unlock cycle drops
|
|
41824
|
+
* stale bindings before any operator interaction.
|
|
41825
|
+
*
|
|
41826
|
+
* Returns the number of tokens removed. Safe to call repeatedly; idempotent.
|
|
41827
|
+
*/
|
|
41828
|
+
pruneExpiredTokens() {
|
|
41829
|
+
return this.issuer.pruneExpired();
|
|
41830
|
+
}
|
|
39856
41831
|
/**
|
|
39857
41832
|
* Audit query restricted to broker-scoped operations. Returns entries
|
|
39858
41833
|
* with their timestamps, op, and result (never the secret value).
|
|
@@ -40011,6 +41986,7 @@ async function openBroker(opts = {}) {
|
|
|
40011
41986
|
grants,
|
|
40012
41987
|
principalIdentityId: opts.principalIdentityId ?? "sanctuary-broker"
|
|
40013
41988
|
});
|
|
41989
|
+
broker.pruneExpiredTokens();
|
|
40014
41990
|
return {
|
|
40015
41991
|
broker,
|
|
40016
41992
|
close: async () => {
|
|
@@ -40879,8 +42855,6 @@ var init_health = __esm({
|
|
|
40879
42855
|
DEFAULT_TIMEOUT_MS4 = 500;
|
|
40880
42856
|
}
|
|
40881
42857
|
});
|
|
40882
|
-
|
|
40883
|
-
// src/cli/agents/cli.ts
|
|
40884
42858
|
function resolveCtx(args) {
|
|
40885
42859
|
const env = args.env ?? process.env;
|
|
40886
42860
|
const discoverOpts = {
|
|
@@ -40918,6 +42892,8 @@ async function runAgentsCommand(args) {
|
|
|
40918
42892
|
return await cmdShow2(rest, ctx);
|
|
40919
42893
|
case "status":
|
|
40920
42894
|
return await cmdStatus(rest, ctx);
|
|
42895
|
+
case "config":
|
|
42896
|
+
return await cmdConfig(rest, ctx);
|
|
40921
42897
|
default:
|
|
40922
42898
|
ctx.err.write(`Unknown subcommand: ${sub}
|
|
40923
42899
|
`);
|
|
@@ -40933,10 +42909,18 @@ async function runAgentsCommand(args) {
|
|
|
40933
42909
|
}
|
|
40934
42910
|
function printUsage4(s) {
|
|
40935
42911
|
s.write(`Usage: sanctuary agents <command> [flags]
|
|
42912
|
+
sanctuary agent <command> [flags] (alias)
|
|
40936
42913
|
|
|
40937
42914
|
list [--json] List every tenant visible on this host.
|
|
40938
|
-
show <tenant> [--json] Show details for one tenant
|
|
42915
|
+
show <tenant> [--json] Show details for one tenant (includes
|
|
42916
|
+
approval-redirect state).
|
|
40939
42917
|
status [--json] One-line-per-tenant running/stopped summary.
|
|
42918
|
+
config <tenant> [opts] Write tenant principal-policy.yaml fields.
|
|
42919
|
+
--approval-redirect=<bool> Toggle cross-harness inbox redirect.
|
|
42920
|
+
--approval-redirect-mode=<replace|notify>
|
|
42921
|
+
Pick replace (bypass underlying channel)
|
|
42922
|
+
or notify (race both paths). Default
|
|
42923
|
+
replace when toggled on.
|
|
40940
42924
|
|
|
40941
42925
|
Options:
|
|
40942
42926
|
--fortress <path> Scope discovery to a specific storage path
|
|
@@ -41038,6 +43022,7 @@ async function cmdShow2(argv, ctx) {
|
|
|
41038
43022
|
return 1;
|
|
41039
43023
|
}
|
|
41040
43024
|
const probe = await ctx.probe(tenant);
|
|
43025
|
+
const approvalRedirect = await readApprovalRedirectState(tenant);
|
|
41041
43026
|
const payload = {
|
|
41042
43027
|
name: tenant.name,
|
|
41043
43028
|
storage_path: tenant.storage_path,
|
|
@@ -41051,7 +43036,8 @@ async function cmdShow2(argv, ctx) {
|
|
|
41051
43036
|
running: probe.running,
|
|
41052
43037
|
status: probe.status,
|
|
41053
43038
|
reason: probe.reason
|
|
41054
|
-
}
|
|
43039
|
+
},
|
|
43040
|
+
approval_redirect: approvalRedirect
|
|
41055
43041
|
};
|
|
41056
43042
|
if (hasJsonFlag(argv)) {
|
|
41057
43043
|
ctx.out.write(JSON.stringify(payload, null, 2) + "\n");
|
|
@@ -41097,8 +43083,171 @@ async function cmdShow2(argv, ctx) {
|
|
|
41097
43083
|
`probe: ${probe.running ? "running" : "not-running"}${probe.reason ? ` (${probe.reason})` : ""}
|
|
41098
43084
|
`
|
|
41099
43085
|
);
|
|
43086
|
+
ctx.out.write(
|
|
43087
|
+
`approval_redirect: ${approvalRedirect.enabled ? `on (${approvalRedirect.mode})` : "off"}
|
|
43088
|
+
`
|
|
43089
|
+
);
|
|
43090
|
+
return 0;
|
|
43091
|
+
}
|
|
43092
|
+
async function readApprovalRedirectState(tenant) {
|
|
43093
|
+
const policyPath = join(tenant.storage_path, "principal-policy.yaml");
|
|
43094
|
+
try {
|
|
43095
|
+
const content = await readFile(policyPath, "utf-8");
|
|
43096
|
+
const parsed = parsePolicy(content);
|
|
43097
|
+
const cfg = parsed.approval_redirect;
|
|
43098
|
+
if (!cfg) return { enabled: false, mode: "replace" };
|
|
43099
|
+
return {
|
|
43100
|
+
enabled: !!cfg.enabled,
|
|
43101
|
+
mode: cfg.mode === "notify" ? "notify" : "replace"
|
|
43102
|
+
};
|
|
43103
|
+
} catch {
|
|
43104
|
+
return { enabled: false, mode: "replace" };
|
|
43105
|
+
}
|
|
43106
|
+
}
|
|
43107
|
+
function parseBoolFlag(raw) {
|
|
43108
|
+
if (raw === void 0) return null;
|
|
43109
|
+
const v = raw.toLowerCase();
|
|
43110
|
+
if (v === "true" || v === "yes" || v === "on" || v === "1") return true;
|
|
43111
|
+
if (v === "false" || v === "no" || v === "off" || v === "0") return false;
|
|
43112
|
+
return null;
|
|
43113
|
+
}
|
|
43114
|
+
function findFlagValue(argv, name) {
|
|
43115
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43116
|
+
const a = argv[i];
|
|
43117
|
+
if (a === name) {
|
|
43118
|
+
return argv[i + 1];
|
|
43119
|
+
}
|
|
43120
|
+
const eq = `${name}=`;
|
|
43121
|
+
if (a.startsWith(eq)) {
|
|
43122
|
+
return a.slice(eq.length);
|
|
43123
|
+
}
|
|
43124
|
+
}
|
|
43125
|
+
return void 0;
|
|
43126
|
+
}
|
|
43127
|
+
async function cmdConfig(argv, ctx) {
|
|
43128
|
+
const positional = argv.find((a) => !a.startsWith("--"));
|
|
43129
|
+
if (!positional) {
|
|
43130
|
+
ctx.err.write(
|
|
43131
|
+
"Missing tenant. Usage: sanctuary agents config <tenant> --approval-redirect=<bool>\n"
|
|
43132
|
+
);
|
|
43133
|
+
return 2;
|
|
43134
|
+
}
|
|
43135
|
+
const tenant = await findTenant(positional, ctx.discoverOpts);
|
|
43136
|
+
if (!tenant) {
|
|
43137
|
+
ctx.err.write(`sanctuary agents: unknown tenant "${positional}"
|
|
43138
|
+
`);
|
|
43139
|
+
return 1;
|
|
43140
|
+
}
|
|
43141
|
+
const redirectFlag = parseBoolFlag(
|
|
43142
|
+
findFlagValue(argv, "--approval-redirect")
|
|
43143
|
+
);
|
|
43144
|
+
const modeFlag = findFlagValue(argv, "--approval-redirect-mode");
|
|
43145
|
+
if (redirectFlag === null && modeFlag === void 0) {
|
|
43146
|
+
ctx.err.write(
|
|
43147
|
+
"sanctuary agents config: nothing to do. Pass --approval-redirect=<bool> or --approval-redirect-mode=<replace|notify>.\n"
|
|
43148
|
+
);
|
|
43149
|
+
return 2;
|
|
43150
|
+
}
|
|
43151
|
+
if (modeFlag !== void 0 && modeFlag !== "replace" && modeFlag !== "notify") {
|
|
43152
|
+
ctx.err.write(
|
|
43153
|
+
`sanctuary agents config: --approval-redirect-mode must be "replace" or "notify" (got "${modeFlag}")
|
|
43154
|
+
`
|
|
43155
|
+
);
|
|
43156
|
+
return 2;
|
|
43157
|
+
}
|
|
43158
|
+
const current = await readApprovalRedirectState(tenant);
|
|
43159
|
+
const next = {
|
|
43160
|
+
enabled: redirectFlag !== null ? redirectFlag : current.enabled,
|
|
43161
|
+
mode: modeFlag === "notify" || modeFlag === "replace" ? modeFlag : current.mode
|
|
43162
|
+
};
|
|
43163
|
+
await writeApprovalRedirectToPolicyFile(tenant.storage_path, next);
|
|
43164
|
+
if (hasJsonFlag(argv)) {
|
|
43165
|
+
ctx.out.write(
|
|
43166
|
+
JSON.stringify(
|
|
43167
|
+
{
|
|
43168
|
+
tenant: tenant.name,
|
|
43169
|
+
approval_redirect: next
|
|
43170
|
+
},
|
|
43171
|
+
null,
|
|
43172
|
+
2
|
|
43173
|
+
) + "\n"
|
|
43174
|
+
);
|
|
43175
|
+
} else {
|
|
43176
|
+
ctx.out.write(
|
|
43177
|
+
`sanctuary agents config: tenant "${tenant.name}" approval_redirect=${next.enabled ? `on (${next.mode})` : "off"}
|
|
43178
|
+
`
|
|
43179
|
+
);
|
|
43180
|
+
ctx.out.write(
|
|
43181
|
+
` Takes effect on the next gate request for the running server.
|
|
43182
|
+
`
|
|
43183
|
+
);
|
|
43184
|
+
}
|
|
41100
43185
|
return 0;
|
|
41101
43186
|
}
|
|
43187
|
+
async function writeApprovalRedirectToPolicyFile(storagePath, state) {
|
|
43188
|
+
const policyPath = join(storagePath, "principal-policy.yaml");
|
|
43189
|
+
let content;
|
|
43190
|
+
try {
|
|
43191
|
+
content = await readFile(policyPath, "utf-8");
|
|
43192
|
+
} catch (err) {
|
|
43193
|
+
const code = err?.code;
|
|
43194
|
+
if (code !== "ENOENT") throw err;
|
|
43195
|
+
content = await defaultPolicyTextForBootstrap();
|
|
43196
|
+
}
|
|
43197
|
+
const block = renderApprovalRedirectBlock(state);
|
|
43198
|
+
const updated = upsertApprovalRedirectBlock(content, block);
|
|
43199
|
+
await writeFile(policyPath, updated, "utf-8");
|
|
43200
|
+
await chmod(policyPath, 384);
|
|
43201
|
+
}
|
|
43202
|
+
function renderApprovalRedirectBlock(state) {
|
|
43203
|
+
return [
|
|
43204
|
+
"# Approval Redirect (v1.3 WP-V1.3-10 Upsilon-2)",
|
|
43205
|
+
"approval_redirect:",
|
|
43206
|
+
` enabled: ${state.enabled ? "true" : "false"}`,
|
|
43207
|
+
` mode: ${state.mode}`
|
|
43208
|
+
].join("\n");
|
|
43209
|
+
}
|
|
43210
|
+
function upsertApprovalRedirectBlock(content, block) {
|
|
43211
|
+
const lines = content.split("\n");
|
|
43212
|
+
const startIdx = lines.findIndex((l) => l.startsWith("approval_redirect:"));
|
|
43213
|
+
if (startIdx === -1) {
|
|
43214
|
+
const trimmed = content.endsWith("\n") ? content : content + "\n";
|
|
43215
|
+
return trimmed + "\n" + block + "\n";
|
|
43216
|
+
}
|
|
43217
|
+
let blockStart = startIdx;
|
|
43218
|
+
if (blockStart > 0 && lines[blockStart - 1] !== void 0 && lines[blockStart - 1].startsWith("# Approval Redirect")) {
|
|
43219
|
+
blockStart = blockStart - 1;
|
|
43220
|
+
}
|
|
43221
|
+
let blockEnd = startIdx + 1;
|
|
43222
|
+
while (blockEnd < lines.length) {
|
|
43223
|
+
const l = lines[blockEnd];
|
|
43224
|
+
if (l === "") {
|
|
43225
|
+
blockEnd++;
|
|
43226
|
+
continue;
|
|
43227
|
+
}
|
|
43228
|
+
if (/^[A-Za-z0-9#]/.test(l)) {
|
|
43229
|
+
break;
|
|
43230
|
+
}
|
|
43231
|
+
blockEnd++;
|
|
43232
|
+
}
|
|
43233
|
+
const before = lines.slice(0, blockStart);
|
|
43234
|
+
const after = lines.slice(blockEnd);
|
|
43235
|
+
const replaced = [...before, ...block.split("\n"), ...after].join("\n");
|
|
43236
|
+
return replaced.endsWith("\n") ? replaced : replaced + "\n";
|
|
43237
|
+
}
|
|
43238
|
+
async function defaultPolicyTextForBootstrap() {
|
|
43239
|
+
return [
|
|
43240
|
+
"version: 1",
|
|
43241
|
+
"tier1_always_approve:",
|
|
43242
|
+
" - state_export",
|
|
43243
|
+
" - state_import",
|
|
43244
|
+
" - state_delete",
|
|
43245
|
+
"approval_channel:",
|
|
43246
|
+
" type: stderr",
|
|
43247
|
+
" timeout_seconds: 300",
|
|
43248
|
+
""
|
|
43249
|
+
].join("\n");
|
|
43250
|
+
}
|
|
41102
43251
|
async function cmdStatus(argv, ctx) {
|
|
41103
43252
|
const tenants = await discoverTenants(ctx.discoverOpts);
|
|
41104
43253
|
const probes = await Promise.all(tenants.map((t) => ctx.probe(t)));
|
|
@@ -41139,6 +43288,7 @@ var init_cli5 = __esm({
|
|
|
41139
43288
|
"src/cli/agents/cli.ts"() {
|
|
41140
43289
|
init_discovery();
|
|
41141
43290
|
init_health();
|
|
43291
|
+
init_loader();
|
|
41142
43292
|
}
|
|
41143
43293
|
});
|
|
41144
43294
|
|
|
@@ -41166,7 +43316,8 @@ var init_agents = __esm({
|
|
|
41166
43316
|
// src/cli/reset-passphrase.ts
|
|
41167
43317
|
var reset_passphrase_exports = {};
|
|
41168
43318
|
__export(reset_passphrase_exports, {
|
|
41169
|
-
runResetPassphraseCommand: () => runResetPassphraseCommand
|
|
43319
|
+
runResetPassphraseCommand: () => runResetPassphraseCommand,
|
|
43320
|
+
zeroizeBuffers: () => zeroizeBuffers
|
|
41170
43321
|
});
|
|
41171
43322
|
async function runResetPassphraseCommand(args) {
|
|
41172
43323
|
const out = args.out ?? process.stdout;
|
|
@@ -41194,34 +43345,42 @@ Then re-run this command.
|
|
|
41194
43345
|
return 1;
|
|
41195
43346
|
}
|
|
41196
43347
|
const lines = new LineReader(stdin);
|
|
43348
|
+
let code = 1;
|
|
43349
|
+
let nukeSucceeded = false;
|
|
41197
43350
|
try {
|
|
41198
43351
|
const availability = await surveyAvailableModes(storagePath);
|
|
41199
43352
|
const mode = parsed.mode ?? await selectMode(lines, out, err, availability);
|
|
41200
43353
|
if (!mode) {
|
|
41201
43354
|
err.write("Aborted: no recovery mode selected.\n");
|
|
41202
|
-
|
|
41203
|
-
}
|
|
41204
|
-
|
|
41205
|
-
|
|
41206
|
-
|
|
41207
|
-
|
|
41208
|
-
|
|
43355
|
+
code = 1;
|
|
43356
|
+
} else if (mode === "shares") {
|
|
43357
|
+
code = await runSharesPath(out, err, availability);
|
|
43358
|
+
} else if (mode === "guardian") {
|
|
43359
|
+
code = await runGuardianPath(out, err, availability);
|
|
43360
|
+
} else {
|
|
43361
|
+
code = await runNukePath({
|
|
43362
|
+
out,
|
|
43363
|
+
err,
|
|
43364
|
+
lines,
|
|
43365
|
+
storagePath,
|
|
43366
|
+
home,
|
|
43367
|
+
plat,
|
|
43368
|
+
exec: args.exec ?? defaultExec2
|
|
43369
|
+
});
|
|
43370
|
+
nukeSucceeded = mode === "nuke" && code === 0;
|
|
41209
43371
|
}
|
|
41210
|
-
return await runNukePath({
|
|
41211
|
-
out,
|
|
41212
|
-
err,
|
|
41213
|
-
lines,
|
|
41214
|
-
storagePath,
|
|
41215
|
-
home,
|
|
41216
|
-
plat,
|
|
41217
|
-
exec: args.exec ?? defaultExec2
|
|
41218
|
-
});
|
|
41219
43372
|
} finally {
|
|
43373
|
+
zeroizeBuffers(args.keyMaterialToZeroize);
|
|
41220
43374
|
lines.close();
|
|
41221
43375
|
}
|
|
43376
|
+
if (parsed.exitOnCompletion && nukeSucceeded) {
|
|
43377
|
+
const doExit = args.exitProcess ?? ((c) => process.exit(c));
|
|
43378
|
+
doExit(0);
|
|
43379
|
+
}
|
|
43380
|
+
return code;
|
|
41222
43381
|
}
|
|
41223
43382
|
function parseArgs2(argv) {
|
|
41224
|
-
const out = { help: false };
|
|
43383
|
+
const out = { exitOnCompletion: false, help: false };
|
|
41225
43384
|
for (let i = 0; i < argv.length; i++) {
|
|
41226
43385
|
const a = argv[i];
|
|
41227
43386
|
if (a === "--help" || a === "-h") {
|
|
@@ -41238,6 +43397,8 @@ function parseArgs2(argv) {
|
|
|
41238
43397
|
out.storage = argv[++i];
|
|
41239
43398
|
} else if (a === "--fortress" && argv[i + 1]) {
|
|
41240
43399
|
out.fortress = argv[++i];
|
|
43400
|
+
} else if (a === "--exit-on-completion") {
|
|
43401
|
+
out.exitOnCompletion = true;
|
|
41241
43402
|
} else if (a && a.startsWith("--")) {
|
|
41242
43403
|
throw new Error(`Unknown flag: ${a}`);
|
|
41243
43404
|
}
|
|
@@ -41270,6 +43431,16 @@ Options:
|
|
|
41270
43431
|
--fortress <path> Override the fortress storage path.
|
|
41271
43432
|
Consistent with "sanctuary wrap --fortress".
|
|
41272
43433
|
--storage <path> Alias for --fortress.
|
|
43434
|
+
--exit-on-completion After a successful nuke, call process.exit(0)
|
|
43435
|
+
immediately so the post-wipe heap is reaped
|
|
43436
|
+
by the OS without re-entering the shell. Use
|
|
43437
|
+
on extreme-threat-model deployments where an
|
|
43438
|
+
attacker-on-host with heap-dump access could
|
|
43439
|
+
recover residual passphrase or key bytes
|
|
43440
|
+
between the wipe and the next operator
|
|
43441
|
+
command. JS strings cannot be explicitly
|
|
43442
|
+
zeroed; this flag is the supported way to
|
|
43443
|
+
bound the heap-dump window.
|
|
41273
43444
|
--help, -h Show this help.
|
|
41274
43445
|
|
|
41275
43446
|
Without --mode, the command surveys which paths are operationally available
|
|
@@ -41539,6 +43710,16 @@ async function prompt(lines, err, question) {
|
|
|
41539
43710
|
err.write(question);
|
|
41540
43711
|
return await lines.next();
|
|
41541
43712
|
}
|
|
43713
|
+
function zeroizeBuffers(buffers) {
|
|
43714
|
+
if (!buffers) return;
|
|
43715
|
+
for (const b of buffers) {
|
|
43716
|
+
if (!b) continue;
|
|
43717
|
+
try {
|
|
43718
|
+
b.fill(0);
|
|
43719
|
+
} catch {
|
|
43720
|
+
}
|
|
43721
|
+
}
|
|
43722
|
+
}
|
|
41542
43723
|
async function defaultExec2(cmd, args) {
|
|
41543
43724
|
return await new Promise((resolve8, reject) => {
|
|
41544
43725
|
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -42219,7 +44400,19 @@ Refusing to start the dashboard while the reset-history marker is unreadable.`
|
|
|
42219
44400
|
}
|
|
42220
44401
|
throw err;
|
|
42221
44402
|
}
|
|
42222
|
-
|
|
44403
|
+
let policy;
|
|
44404
|
+
try {
|
|
44405
|
+
policy = await loadPrincipalPolicy(config.storage_path);
|
|
44406
|
+
} catch (err) {
|
|
44407
|
+
if (err instanceof MalformedPrincipalPolicyError) {
|
|
44408
|
+
console.error(`
|
|
44409
|
+
Sanctuary cannot start.
|
|
44410
|
+
${err.message}
|
|
44411
|
+
`);
|
|
44412
|
+
process.exit(1);
|
|
44413
|
+
}
|
|
44414
|
+
throw err;
|
|
44415
|
+
}
|
|
42223
44416
|
const baseline = new BaselineTracker(storage, masterKey);
|
|
42224
44417
|
await baseline.load();
|
|
42225
44418
|
const dashboardPort = options.port ?? config.dashboard.port;
|
|
@@ -42524,7 +44717,7 @@ async function main() {
|
|
|
42524
44717
|
const code = await runIdentityCommand2({ argv: args.slice(1) });
|
|
42525
44718
|
process.exit(code);
|
|
42526
44719
|
}
|
|
42527
|
-
if (args[0] === "agents") {
|
|
44720
|
+
if (args[0] === "agents" || args[0] === "agent") {
|
|
42528
44721
|
const { runAgentsCommand: runAgentsCommand2 } = await Promise.resolve().then(() => (init_agents(), agents_exports));
|
|
42529
44722
|
const code = await runAgentsCommand2({ argv: args.slice(1) });
|
|
42530
44723
|
process.exit(code);
|