@sanctuary-framework/mcp-server 1.2.2 → 1.2.4

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/index.cjs CHANGED
@@ -4443,13 +4443,23 @@ function parseScalar(value) {
4443
4443
  return value.replace(/^["']|["']$/g, "");
4444
4444
  }
4445
4445
  function validatePolicy(raw) {
4446
+ if (!("tier1_always_approve" in raw)) {
4447
+ throw new Error(
4448
+ "Policy file must include 'tier1_always_approve' as an explicit list (use [] for empty). Remove specific entries instead of removing the whole key."
4449
+ );
4450
+ }
4451
+ if (!("approval_channel" in raw)) {
4452
+ throw new Error(
4453
+ "Policy file must include 'approval_channel' as an explicit object (use {} for defaults). Remove specific entries instead of removing the whole key."
4454
+ );
4455
+ }
4446
4456
  const userTier3 = raw.tier3_always_allow ?? [];
4447
4457
  const mergedTier3 = [
4448
4458
  .../* @__PURE__ */ new Set([...userTier3, ...DEFAULT_POLICY.tier3_always_allow])
4449
4459
  ];
4450
4460
  return {
4451
4461
  version: raw.version ?? 1,
4452
- tier1_always_approve: raw.tier1_always_approve ?? DEFAULT_POLICY.tier1_always_approve,
4462
+ tier1_always_approve: raw.tier1_always_approve,
4453
4463
  tier2_anomaly: {
4454
4464
  ...DEFAULT_TIER2,
4455
4465
  ...raw.tier2_anomaly ?? {}
@@ -4470,6 +4480,11 @@ function generateDefaultPolicyYaml() {
4470
4480
  # This file controls what your agent can do without asking.
4471
4481
  # Edit this file directly. Your agent cannot modify it.
4472
4482
  # Changes take effect on server restart.
4483
+ #
4484
+ # Required keys (must be present; use [] or {} for empty):
4485
+ # tier1_always_approve, approval_channel
4486
+ # Optional keys (omit to use defaults; new defaults merge automatically):
4487
+ # tier2_anomaly, tier3_always_allow
4473
4488
 
4474
4489
  version: 1
4475
4490
 
@@ -4574,20 +4589,52 @@ approval_channel:
4574
4589
  timeout_seconds: 300
4575
4590
  `;
4576
4591
  }
4592
+ var MalformedPrincipalPolicyError = class extends Error {
4593
+ constructor(policyPath, reason) {
4594
+ super(
4595
+ `Principal policy at ${policyPath} is malformed and cannot be loaded.
4596
+ Reason: ${reason}
4597
+ 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.`
4598
+ );
4599
+ this.policyPath = policyPath;
4600
+ this.reason = reason;
4601
+ this.name = "MalformedPrincipalPolicyError";
4602
+ }
4603
+ policyPath;
4604
+ reason;
4605
+ };
4577
4606
  async function loadPrincipalPolicy(storagePath) {
4578
4607
  const policyPath = path.join(storagePath, "principal-policy.yaml");
4608
+ let content;
4609
+ try {
4610
+ content = await promises.readFile(policyPath, "utf-8");
4611
+ } catch (err) {
4612
+ const code = err?.code;
4613
+ if (code === "ENOENT") {
4614
+ const defaultYaml = generateDefaultPolicyYaml();
4615
+ try {
4616
+ await promises.writeFile(policyPath, defaultYaml, "utf-8");
4617
+ await promises.chmod(policyPath, 384);
4618
+ } catch (writeErr) {
4619
+ console.warn(
4620
+ `Sanctuary: could not write default principal policy to ${policyPath}: ${writeErr.message}. Continuing with in-memory default.`
4621
+ );
4622
+ }
4623
+ return Object.freeze({ ...DEFAULT_POLICY });
4624
+ }
4625
+ throw new MalformedPrincipalPolicyError(
4626
+ policyPath,
4627
+ `read failed: ${err.message}`
4628
+ );
4629
+ }
4579
4630
  try {
4580
- const content = await promises.readFile(policyPath, "utf-8");
4581
4631
  const policy = parsePolicy(content);
4582
4632
  return Object.freeze(policy);
4583
- } catch {
4584
- const defaultYaml = generateDefaultPolicyYaml();
4585
- try {
4586
- await promises.writeFile(policyPath, defaultYaml, "utf-8");
4587
- await promises.chmod(policyPath, 384);
4588
- } catch {
4589
- }
4590
- return Object.freeze({ ...DEFAULT_POLICY });
4633
+ } catch (parseErr) {
4634
+ throw new MalformedPrincipalPolicyError(
4635
+ policyPath,
4636
+ parseErr.message
4637
+ );
4591
4638
  }
4592
4639
  }
4593
4640
 
@@ -4814,7 +4861,7 @@ function deepSortKeys(obj) {
4814
4861
  return sorted;
4815
4862
  }
4816
4863
  function canonicalizeForSigning(body) {
4817
- return JSON.stringify(deepSortKeys(body));
4864
+ return JSON.stringify(deepSortKeys(body)).normalize("NFC");
4818
4865
  }
4819
4866
 
4820
4867
  // src/shr/generator.ts
@@ -11120,10 +11167,11 @@ function initTemplate(params) {
11120
11167
  var DEFAULT_STORAGE_DIR = ".sanctuary";
11121
11168
  var KEYCHAIN_SERVICE_DEFAULT = "sanctuary-passphrase";
11122
11169
  function keychainServiceFor(storagePath, home = os.homedir()) {
11123
- const defaultPath = path.join(home, DEFAULT_STORAGE_DIR);
11124
- if (storagePath === defaultPath) return KEYCHAIN_SERVICE_DEFAULT;
11125
- const digest = sha256.sha256(Buffer.from(storagePath, "utf-8"));
11126
- const suffix = Buffer.from(digest).toString("hex").slice(0, 12);
11170
+ const defaultPath = path.resolve(path.join(home, DEFAULT_STORAGE_DIR));
11171
+ const canonicalStorage = path.resolve(storagePath);
11172
+ if (canonicalStorage === defaultPath) return KEYCHAIN_SERVICE_DEFAULT;
11173
+ const digest = sha256.sha256(Buffer.from(canonicalStorage, "utf-8"));
11174
+ const suffix = Buffer.from(digest).toString("hex").slice(0, 16);
11127
11175
  return `${KEYCHAIN_SERVICE_DEFAULT}-${suffix}`;
11128
11176
  }
11129
11177
  var RUNTIME_FILE_NAME = "runtime.json";
@@ -11264,7 +11312,7 @@ async function discoverTenants(options = {}) {
11264
11312
  for (const child of children) {
11265
11313
  const childPath = path.join(root, child);
11266
11314
  if (child.startsWith(".")) continue;
11267
- if (child === "state" || child === "backup" || child === "config") continue;
11315
+ if (child === "state" || child === "backup" || child === "config" || child === "default") continue;
11268
11316
  const s = await promises.stat(childPath).catch(() => null);
11269
11317
  if (!s || !s.isDirectory()) continue;
11270
11318
  const desc = await describeTenant(child, childPath, home);
@@ -11276,6 +11324,17 @@ async function discoverTenants(options = {}) {
11276
11324
  const desc = await describeTenant(path.basename(extra), extra, home);
11277
11325
  if (desc) tenants.push(desc);
11278
11326
  }
11327
+ const seen = /* @__PURE__ */ new Map();
11328
+ for (const t of tenants) {
11329
+ seen.set(t.name, (seen.get(t.name) ?? 0) + 1);
11330
+ }
11331
+ for (const [name, count] of seen) {
11332
+ if (count > 1) {
11333
+ console.error(
11334
+ `[sanctuary] warning: ${count} tenants share the name "${name}". Use --tenant with a unique name or storage path to disambiguate.`
11335
+ );
11336
+ }
11337
+ }
11279
11338
  tenants.sort((a, b) => {
11280
11339
  if (a.name === "default") return -1;
11281
11340
  if (b.name === "default") return 1;
@@ -11624,6 +11683,16 @@ var HUB_ROUTES = {
11624
11683
  */
11625
11684
  CHAT_CONCIERGE_SEND: "/api/hub/chat/concierge",
11626
11685
  CHAT_CONCIERGE_HISTORY: "/api/hub/chat/concierge/history",
11686
+ /**
11687
+ * Concierge memory thread routes (WP-V1.3-9 Tau-1). Thread enumeration,
11688
+ * scrollback, and operator-initiated thread delete. Distinct from the
11689
+ * v1.2 `/history` route, which surfaces the active in-session thread
11690
+ * shape; the new routes target persisted multi-thread memory used by
11691
+ * v1.3 conversational sovereignty depth.
11692
+ */
11693
+ CHAT_CONCIERGE_THREADS_LIST: "/api/hub/chat/concierge/threads",
11694
+ CHAT_CONCIERGE_THREAD_READ: "/api/hub/chat/concierge/threads/:thread_id",
11695
+ CHAT_CONCIERGE_THREAD_DELETE: "/api/hub/chat/concierge/threads/:thread_id",
11627
11696
  /**
11628
11697
  * Click-to-inspect panel (WP-V1.2 reshape). Returns the agent's
11629
11698
  * recent activity feed, pending Tier 1 approvals routed through this
@@ -11647,6 +11716,10 @@ var HUB_TIER_1_AGENT_CONTROL_ACTIONS = [
11647
11716
  ];
11648
11717
  var HUB_ACTIVITY_DEFAULT_LIMIT = 50;
11649
11718
  var HUB_ACTIVITY_MAX_LIMIT = 500;
11719
+ var HUB_CHAT_THREADS_DEFAULT_LIMIT = 50;
11720
+ var HUB_CHAT_THREADS_MAX_LIMIT = 500;
11721
+ var HUB_CHAT_TURNS_DEFAULT_LIMIT = 200;
11722
+ var HUB_CHAT_TURNS_MAX_LIMIT = 1e3;
11650
11723
  var HUB_INBOX_DEFAULT_LIMIT = 100;
11651
11724
  var HUB_INBOX_MAX_LIMIT = 500;
11652
11725
  var HUB_AGENTS_DEFAULT_LIMIT = 100;
@@ -11818,6 +11891,23 @@ function checkChatMessage(value) {
11818
11891
  }
11819
11892
  return trimmed;
11820
11893
  }
11894
+ function matchConciergeThreadRoute(path) {
11895
+ const prefix = `${HUB_API_PREFIX}/chat/concierge/threads/`;
11896
+ if (!path.startsWith(prefix)) return null;
11897
+ const rest = path.slice(prefix.length);
11898
+ if (rest.length === 0 || rest.includes("/")) return null;
11899
+ const decoded = decodeURIComponent(rest);
11900
+ if (decoded.length === 0) return null;
11901
+ return { threadId: decoded };
11902
+ }
11903
+ function parseSince(raw) {
11904
+ if (raw === null || raw === "") return void 0;
11905
+ const parsed = Number.parseInt(raw, 10);
11906
+ if (Number.isNaN(parsed) || parsed < 0) {
11907
+ throw new HubValidationError("since must be a non-negative integer");
11908
+ }
11909
+ return parsed;
11910
+ }
11821
11911
  function matchInboxRoute(path) {
11822
11912
  const prefix = `${HUB_API_PREFIX}/inbox/`;
11823
11913
  if (!path.startsWith(prefix)) return null;
@@ -12001,6 +12091,47 @@ async function handleHubRoute(deps, req, res) {
12001
12091
  writeJSON2(res, 200, { ok: true, data: { messages } });
12002
12092
  return true;
12003
12093
  }
12094
+ if (method === "GET" && path === HUB_ROUTES.CHAT_CONCIERGE_THREADS_LIST) {
12095
+ const limit = parseLimit(
12096
+ url.searchParams.get("limit"),
12097
+ HUB_CHAT_THREADS_DEFAULT_LIMIT,
12098
+ HUB_CHAT_THREADS_MAX_LIMIT
12099
+ );
12100
+ const threads = await deps.service.listConciergeMemoryThreads({ limit });
12101
+ writeJSON2(res, 200, { ok: true, data: { threads } });
12102
+ return true;
12103
+ }
12104
+ {
12105
+ const threadMatch = matchConciergeThreadRoute(path);
12106
+ if (threadMatch) {
12107
+ if (method === "GET") {
12108
+ const since = parseSince(url.searchParams.get("since"));
12109
+ const limit = parseLimit(
12110
+ url.searchParams.get("limit"),
12111
+ HUB_CHAT_TURNS_DEFAULT_LIMIT,
12112
+ HUB_CHAT_TURNS_MAX_LIMIT
12113
+ );
12114
+ const readOpts = { limit };
12115
+ if (since !== void 0) readOpts.sinceTurnId = since;
12116
+ const turns = await deps.service.readConciergeMemoryThread(
12117
+ threadMatch.threadId,
12118
+ readOpts
12119
+ );
12120
+ writeJSON2(res, 200, { ok: true, data: { turns } });
12121
+ return true;
12122
+ }
12123
+ if (method === "DELETE") {
12124
+ const removed = await deps.service.deleteConciergeMemoryThread(
12125
+ threadMatch.threadId
12126
+ );
12127
+ writeJSON2(res, removed ? 200 : 404, {
12128
+ ok: removed,
12129
+ data: { thread_id: threadMatch.threadId, removed }
12130
+ });
12131
+ return true;
12132
+ }
12133
+ }
12134
+ }
12004
12135
  writeJSON2(res, 404, { ok: false, error: "not_found", path });
12005
12136
  return true;
12006
12137
  } catch (err) {
@@ -15644,6 +15775,8 @@ var IntelligenceRouterError = class extends Error {
15644
15775
  this.code = code;
15645
15776
  this.name = "IntelligenceRouterError";
15646
15777
  }
15778
+ statusCode;
15779
+ code;
15647
15780
  };
15648
15781
  function writeJSON3(res, status, payload) {
15649
15782
  res.writeHead(status, {
@@ -16048,6 +16181,162 @@ async function dispatchV11Request(inputs, req, res, url, method) {
16048
16181
  return false;
16049
16182
  }
16050
16183
 
16184
+ // src/principal-policy/approval-aggregator-routes.ts
16185
+ var APPROVAL_INBOX_API_PREFIX = "/api/approval-inbox";
16186
+ var APPROVAL_INBOX_OPERATOR_DEFAULT = "operator_dashboard";
16187
+ var APPROVAL_INBOX_DEFAULT_LIMIT = 50;
16188
+ var APPROVAL_INBOX_MAX_LIMIT = 200;
16189
+ function writeJSON4(res, status, payload) {
16190
+ res.writeHead(status, {
16191
+ "Content-Type": "application/json",
16192
+ "Cache-Control": "no-store"
16193
+ });
16194
+ res.end(JSON.stringify(payload));
16195
+ }
16196
+ function parseLimit2(raw, defaultValue, max) {
16197
+ if (raw === null || raw === "") return defaultValue;
16198
+ const parsed = Number.parseInt(raw, 10);
16199
+ if (Number.isNaN(parsed) || parsed < 0) {
16200
+ return defaultValue;
16201
+ }
16202
+ return Math.min(parsed, max);
16203
+ }
16204
+ function isStatusFilter(value) {
16205
+ return value === "pending" || value === "approved" || value === "denied" || value === "timeout" || value === "expired";
16206
+ }
16207
+ function matchEntryRoute(path) {
16208
+ const prefix = `${APPROVAL_INBOX_API_PREFIX}/`;
16209
+ if (!path.startsWith(prefix)) return null;
16210
+ const rest = path.slice(prefix.length);
16211
+ if (rest.length === 0) return null;
16212
+ const slash = rest.indexOf("/");
16213
+ if (slash === -1) {
16214
+ return { aggregatorId: decodeURIComponent(rest), action: null };
16215
+ }
16216
+ return {
16217
+ aggregatorId: decodeURIComponent(rest.slice(0, slash)),
16218
+ action: rest.slice(slash + 1)
16219
+ };
16220
+ }
16221
+ async function handleStream2(deps, res) {
16222
+ res.writeHead(200, {
16223
+ "Content-Type": "text/event-stream",
16224
+ "Cache-Control": "no-cache, no-transform",
16225
+ Connection: "keep-alive",
16226
+ "X-Accel-Buffering": "no"
16227
+ });
16228
+ const initial = await deps.aggregator.list({ status: "pending" });
16229
+ res.write(
16230
+ `event: approval_inbox_snapshot
16231
+ data: ${JSON.stringify({ entries: initial })}
16232
+
16233
+ `
16234
+ );
16235
+ const unsubscribe = deps.aggregator.onEvent((event) => {
16236
+ try {
16237
+ res.write(
16238
+ `event: approval_inbox_${event.type}
16239
+ data: ${JSON.stringify(event.entry)}
16240
+
16241
+ `
16242
+ );
16243
+ } catch {
16244
+ }
16245
+ });
16246
+ const keepAlive = setInterval(() => {
16247
+ try {
16248
+ res.write(": keepalive\n\n");
16249
+ } catch {
16250
+ }
16251
+ }, 25e3);
16252
+ const cleanup = () => {
16253
+ clearInterval(keepAlive);
16254
+ unsubscribe();
16255
+ };
16256
+ res.on("close", cleanup);
16257
+ res.on("error", cleanup);
16258
+ }
16259
+ async function handleApprovalInboxRoute(deps, req, res) {
16260
+ const host = req.headers.host || "localhost";
16261
+ const url = new URL(req.url ?? "/", `http://${host}`);
16262
+ const method = (req.method ?? "GET").toUpperCase();
16263
+ const path = url.pathname;
16264
+ if (path !== APPROVAL_INBOX_API_PREFIX && !path.startsWith(`${APPROVAL_INBOX_API_PREFIX}/`)) {
16265
+ return false;
16266
+ }
16267
+ const checkAuth = authMiddleware(deps.authConfig);
16268
+ if (!checkAuth(req, res, url)) return true;
16269
+ try {
16270
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/stream`) {
16271
+ await handleStream2(deps, res);
16272
+ return true;
16273
+ }
16274
+ if (method === "GET" && path === APPROVAL_INBOX_API_PREFIX) {
16275
+ const limit = parseLimit2(
16276
+ url.searchParams.get("limit"),
16277
+ APPROVAL_INBOX_DEFAULT_LIMIT,
16278
+ APPROVAL_INBOX_MAX_LIMIT
16279
+ );
16280
+ const statusRaw = url.searchParams.get("status");
16281
+ const status = statusRaw && isStatusFilter(statusRaw) ? statusRaw : "pending";
16282
+ const sinceTs = url.searchParams.get("since") ?? void 0;
16283
+ const entries = await deps.aggregator.list({
16284
+ status,
16285
+ limit,
16286
+ ...sinceTs !== void 0 ? { sinceTs } : {}
16287
+ });
16288
+ writeJSON4(res, 200, { ok: true, data: { entries } });
16289
+ return true;
16290
+ }
16291
+ const entryMatch = matchEntryRoute(path);
16292
+ if (entryMatch === null) {
16293
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
16294
+ return true;
16295
+ }
16296
+ if (method === "GET" && entryMatch.action === null) {
16297
+ const entries = await deps.aggregator.list({ limit: APPROVAL_INBOX_MAX_LIMIT });
16298
+ const entry = entries.find(
16299
+ (e) => e.aggregator_id === entryMatch.aggregatorId
16300
+ );
16301
+ if (!entry) {
16302
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
16303
+ return true;
16304
+ }
16305
+ const payload = await deps.aggregator.getFullPayload(
16306
+ entryMatch.aggregatorId
16307
+ );
16308
+ writeJSON4(res, 200, { ok: true, data: { entry, request_payload: payload } });
16309
+ return true;
16310
+ }
16311
+ if (method === "POST" && (entryMatch.action === "approve" || entryMatch.action === "deny")) {
16312
+ const decision = entryMatch.action === "approve" ? "approved" : "denied";
16313
+ const operatorId = deps.operatorId ?? APPROVAL_INBOX_OPERATOR_DEFAULT;
16314
+ try {
16315
+ const entry = await deps.aggregator.resolve(
16316
+ entryMatch.aggregatorId,
16317
+ decision,
16318
+ operatorId
16319
+ );
16320
+ writeJSON4(res, 200, { ok: true, data: { entry } });
16321
+ } catch (err) {
16322
+ const msg = err instanceof Error ? err.message : String(err);
16323
+ if (msg === "approval-aggregator: not_found") {
16324
+ writeJSON4(res, 404, { ok: false, error: "not_found" });
16325
+ } else {
16326
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
16327
+ }
16328
+ }
16329
+ return true;
16330
+ }
16331
+ writeJSON4(res, 404, { ok: false, error: "not_found", path });
16332
+ return true;
16333
+ } catch (err) {
16334
+ const msg = err instanceof Error ? err.message : String(err);
16335
+ writeJSON4(res, 500, { ok: false, error: "internal", detail: msg });
16336
+ return true;
16337
+ }
16338
+ }
16339
+
16051
16340
  // src/principal-policy/dashboard.ts
16052
16341
  var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16053
16342
  var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
@@ -16112,6 +16401,14 @@ var DashboardApprovalChannel = class {
16112
16401
  * regardless. Default route flip is deferred to v1.2.
16113
16402
  */
16114
16403
  v11Bindings = null;
16404
+ /**
16405
+ * v1.3 WP-V1.3-10 Cross-Harness Approval Inbox aggregator. Mounted
16406
+ * additively at `/api/approval-inbox/*` when set. Legacy approval
16407
+ * routes at `/api/approvals/:id/(allow|deny)` continue to serve. The
16408
+ * aggregator is a passive subscriber to the gate; the routes here are
16409
+ * the operator-facing query / decision surface.
16410
+ */
16411
+ approvalAggregator = null;
16115
16412
  constructor(config) {
16116
16413
  this.config = config;
16117
16414
  this.authToken = config.auth_token;
@@ -16162,6 +16459,34 @@ var DashboardApprovalChannel = class {
16162
16459
  setV11Bindings(bindings) {
16163
16460
  this.v11Bindings = bindings;
16164
16461
  }
16462
+ /**
16463
+ * v1.3 WP-V1.3-10 Upsilon-1: bind the cross-harness approval inbox
16464
+ * aggregator. Once set, requests to `/api/approval-inbox/*` route
16465
+ * through `handleApprovalInboxRoute`. Pass `null` to detach (used by
16466
+ * tests + during shutdown).
16467
+ */
16468
+ setApprovalAggregator(aggregator) {
16469
+ this.approvalAggregator = aggregator;
16470
+ }
16471
+ /**
16472
+ * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
16473
+ * before the legacy approval route table. Returns true when served.
16474
+ */
16475
+ async dispatchApprovalInbox(req, res) {
16476
+ if (!this.approvalAggregator) return false;
16477
+ return handleApprovalInboxRoute(
16478
+ {
16479
+ authConfig: {
16480
+ loopbackAutoAuth: this._autoAuthLocalhost,
16481
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
16482
+ },
16483
+ aggregator: this.approvalAggregator,
16484
+ operatorId: this.identityManager?.getPrimaryIdentityId() ?? void 0
16485
+ },
16486
+ req,
16487
+ res
16488
+ );
16489
+ }
16165
16490
  /**
16166
16491
  * v1.1 dispatch entry point. Called from `handleRequest` before the
16167
16492
  * legacy route table. Returns true when the request was served by v1.1
@@ -16227,7 +16552,7 @@ var DashboardApprovalChannel = class {
16227
16552
  server = http.createServer(handler);
16228
16553
  }
16229
16554
  this.httpServer = server;
16230
- return new Promise((resolve4, reject) => {
16555
+ return new Promise((resolve6, reject) => {
16231
16556
  const protocol = this.useTLS ? "https" : "http";
16232
16557
  const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
16233
16558
  server.listen(this.config.port, this.config.host, () => {
@@ -16252,7 +16577,7 @@ var DashboardApprovalChannel = class {
16252
16577
  if (shouldAutoOpen) {
16253
16578
  this.openInBrowser(sessionUrl);
16254
16579
  }
16255
- resolve4();
16580
+ resolve6();
16256
16581
  });
16257
16582
  server.on("error", (err) => {
16258
16583
  if (err.code === "EADDRINUSE") {
@@ -16298,8 +16623,8 @@ var DashboardApprovalChannel = class {
16298
16623
  }
16299
16624
  this.rateLimits.clear();
16300
16625
  if (this.httpServer) {
16301
- return new Promise((resolve4) => {
16302
- this.httpServer.close(() => resolve4());
16626
+ return new Promise((resolve6) => {
16627
+ this.httpServer.close(() => resolve6());
16303
16628
  });
16304
16629
  }
16305
16630
  }
@@ -16313,7 +16638,7 @@ var DashboardApprovalChannel = class {
16313
16638
  `[Sanctuary] Approval required: ${request.operation} (Tier ${request.tier}) \u2014 open dashboard to respond
16314
16639
  `
16315
16640
  );
16316
- return new Promise((resolve4) => {
16641
+ return new Promise((resolve6) => {
16317
16642
  const timer = setTimeout(() => {
16318
16643
  this.pending.delete(id);
16319
16644
  const response = {
@@ -16327,12 +16652,12 @@ var DashboardApprovalChannel = class {
16327
16652
  decision: response.decision,
16328
16653
  decided_by: "timeout"
16329
16654
  });
16330
- resolve4(response);
16655
+ resolve6(response);
16331
16656
  }, this.config.timeout_seconds * 1e3);
16332
16657
  const pending = {
16333
16658
  id,
16334
16659
  request,
16335
- resolve: resolve4,
16660
+ resolve: resolve6,
16336
16661
  timer,
16337
16662
  created_at: (/* @__PURE__ */ new Date()).toISOString()
16338
16663
  };
@@ -16537,6 +16862,18 @@ var DashboardApprovalChannel = class {
16537
16862
  res.end();
16538
16863
  return;
16539
16864
  }
16865
+ if (this.approvalAggregator && url.pathname.startsWith(APPROVAL_INBOX_API_PREFIX)) {
16866
+ this.dispatchApprovalInbox(req, res).then((handled) => {
16867
+ if (handled) return;
16868
+ this.handleLegacyRequest(req, res, url, method);
16869
+ }).catch(() => {
16870
+ if (!res.headersSent) {
16871
+ res.writeHead(500, { "Content-Type": "application/json" });
16872
+ res.end(JSON.stringify({ error: "Internal server error" }));
16873
+ }
16874
+ });
16875
+ return;
16876
+ }
16540
16877
  if (this.v11Bindings) {
16541
16878
  this.dispatchV11(req, res, url, method).then((handled) => {
16542
16879
  if (handled) return;
@@ -17193,7 +17530,7 @@ var WebhookApprovalChannel = class {
17193
17530
  * Start the callback listener server.
17194
17531
  */
17195
17532
  async start() {
17196
- return new Promise((resolve4, reject) => {
17533
+ return new Promise((resolve6, reject) => {
17197
17534
  this.callbackServer = http.createServer(
17198
17535
  (req, res) => this.handleCallback(req, res)
17199
17536
  );
@@ -17208,7 +17545,7 @@ var WebhookApprovalChannel = class {
17208
17545
 
17209
17546
  `
17210
17547
  );
17211
- resolve4();
17548
+ resolve6();
17212
17549
  }
17213
17550
  );
17214
17551
  this.callbackServer.on("error", reject);
@@ -17228,8 +17565,8 @@ var WebhookApprovalChannel = class {
17228
17565
  }
17229
17566
  this.pending.clear();
17230
17567
  if (this.callbackServer) {
17231
- return new Promise((resolve4) => {
17232
- this.callbackServer.close(() => resolve4());
17568
+ return new Promise((resolve6) => {
17569
+ this.callbackServer.close(() => resolve6());
17233
17570
  });
17234
17571
  }
17235
17572
  }
@@ -17242,7 +17579,7 @@ var WebhookApprovalChannel = class {
17242
17579
  `[Sanctuary] Webhook approval sent: ${request.operation} (Tier ${request.tier}) \u2014 awaiting callback
17243
17580
  `
17244
17581
  );
17245
- return new Promise((resolve4) => {
17582
+ return new Promise((resolve6) => {
17246
17583
  const timer = setTimeout(() => {
17247
17584
  this.pending.delete(id);
17248
17585
  const response = {
@@ -17251,12 +17588,12 @@ var WebhookApprovalChannel = class {
17251
17588
  decided_at: (/* @__PURE__ */ new Date()).toISOString(),
17252
17589
  decided_by: "timeout"
17253
17590
  };
17254
- resolve4(response);
17591
+ resolve6(response);
17255
17592
  }, this.config.timeout_seconds * 1e3);
17256
17593
  const pending = {
17257
17594
  id,
17258
17595
  request,
17259
- resolve: resolve4,
17596
+ resolve: resolve6,
17260
17597
  timer,
17261
17598
  created_at: (/* @__PURE__ */ new Date()).toISOString()
17262
17599
  };
@@ -18542,6 +18879,11 @@ var InjectionDetector = class {
18542
18879
  }
18543
18880
  };
18544
18881
 
18882
+ // src/principal-policy/deny-vocabulary.ts
18883
+ var AGENT_VISIBLE_DENY_REASONS = {
18884
+ REQUIRES_APPROVAL: "operation requires operator approval",
18885
+ NOT_PERMITTED: "operation not permitted"};
18886
+
18545
18887
  // src/principal-policy/gate.ts
18546
18888
  var ApprovalGate = class {
18547
18889
  policy;
@@ -18550,14 +18892,25 @@ var ApprovalGate = class {
18550
18892
  auditLog;
18551
18893
  injectionDetector;
18552
18894
  onInjectionAlert;
18895
+ onApprovalEvent;
18553
18896
  proxyTierResolver;
18554
- constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
18897
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert, onApprovalEvent) {
18555
18898
  this.policy = policy;
18556
18899
  this.baseline = baseline;
18557
18900
  this.channel = channel;
18558
18901
  this.auditLog = auditLog;
18559
18902
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
18560
18903
  this.onInjectionAlert = onInjectionAlert;
18904
+ this.onApprovalEvent = onApprovalEvent;
18905
+ }
18906
+ /**
18907
+ * Set the approval-event callback after construction. Used by the
18908
+ * Upsilon-1 wire-up when the aggregator is constructed alongside the
18909
+ * gate. The aggregator subscribes through this setter rather than the
18910
+ * constructor so existing call sites continue to work unchanged.
18911
+ */
18912
+ setApprovalEventCallback(cb) {
18913
+ this.onApprovalEvent = cb;
18561
18914
  }
18562
18915
  /**
18563
18916
  * Set the proxy tier resolver. Called after the proxy router is initialized.
@@ -18594,10 +18947,16 @@ var ApprovalGate = class {
18594
18947
  });
18595
18948
  }
18596
18949
  if (injectionResult.recommendation === "block") {
18950
+ this.auditLog.append("l2", `gate_injection_block:${operation}`, "system", {
18951
+ tier: 1,
18952
+ operation,
18953
+ injection_confidence: injectionResult.confidence,
18954
+ signal_count: injectionResult.signals.length
18955
+ });
18597
18956
  return {
18598
18957
  allowed: false,
18599
18958
  tier: 1,
18600
- reason: `Blocked: prompt injection detected in "${operation}" (confidence: ${(injectionResult.confidence * 100).toFixed(0)}%)`,
18959
+ reason: AGENT_VISIBLE_DENY_REASONS.NOT_PERMITTED,
18601
18960
  approval_required: false
18602
18961
  };
18603
18962
  }
@@ -18674,7 +19033,7 @@ var ApprovalGate = class {
18674
19033
  this.auditLog.append("l2", `gate_unclassified:${operation}`, "system", {
18675
19034
  tier: 1,
18676
19035
  operation,
18677
- warning: "Operation is not classified in any policy tier \u2014 defaulting to Tier 1 (require approval)"
19036
+ warning: "Operation is not classified in any policy tier, defaulting to Tier 1 (require approval)"
18678
19037
  });
18679
19038
  return this.requestApproval(
18680
19039
  operation,
@@ -18785,51 +19144,481 @@ var ApprovalGate = class {
18785
19144
  }
18786
19145
  /**
18787
19146
  * Request approval from the human principal.
19147
+ *
19148
+ * Fail-closed contract (full-sweep #49): if the channel throws (network
19149
+ * down, callback unreachable, dashboard SSE peer dropped, webhook DNS
19150
+ * failure, etc.), the gate denies the operation and audit-logs the cause.
19151
+ * Channel-internal timeouts already resolve with decision: "deny" per
19152
+ * SEC-002; this catch covers the remaining "channel raised" path so an
19153
+ * unhandled rejection cannot turn into an indeterminate state at the gate.
18788
19154
  */
18789
19155
  async requestApproval(operation, tier, reason, context) {
19156
+ const requestTimestamp = (/* @__PURE__ */ new Date()).toISOString();
18790
19157
  const request = {
18791
19158
  operation,
18792
19159
  tier,
18793
19160
  reason,
18794
19161
  context,
18795
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
19162
+ timestamp: requestTimestamp
18796
19163
  };
18797
- const response = await this.channel.requestApproval(request);
19164
+ const correlationId = `${requestTimestamp}:${operation}:${Math.random().toString(16).slice(2, 6)}`;
19165
+ if (this.onApprovalEvent) {
19166
+ try {
19167
+ this.onApprovalEvent({
19168
+ phase: "requested",
19169
+ operation,
19170
+ tier,
19171
+ reason,
19172
+ context,
19173
+ request_timestamp: requestTimestamp,
19174
+ correlation_id: correlationId
19175
+ });
19176
+ } catch {
19177
+ }
19178
+ }
19179
+ let response;
19180
+ try {
19181
+ response = await this.channel.requestApproval(request);
19182
+ } catch (err) {
19183
+ const errMessage = err instanceof Error ? err.message : String(err);
19184
+ const decidedAt = (/* @__PURE__ */ new Date()).toISOString();
19185
+ this.auditLog.append("l2", `gate_deny:${operation}`, "system", {
19186
+ tier,
19187
+ reason,
19188
+ decided_by: "channel_failure",
19189
+ channel_error: errMessage
19190
+ });
19191
+ if (this.onApprovalEvent) {
19192
+ try {
19193
+ this.onApprovalEvent({
19194
+ phase: "resolved",
19195
+ operation,
19196
+ tier,
19197
+ reason,
19198
+ context,
19199
+ request_timestamp: requestTimestamp,
19200
+ resolution: {
19201
+ decision: "deny",
19202
+ decided_at: decidedAt,
19203
+ decided_by: "channel_failure"
19204
+ },
19205
+ correlation_id: correlationId
19206
+ });
19207
+ } catch {
19208
+ }
19209
+ }
19210
+ return {
19211
+ allowed: false,
19212
+ tier,
19213
+ reason: AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
19214
+ approval_required: true,
19215
+ approval_response: {
19216
+ decision: "deny",
19217
+ decided_at: decidedAt,
19218
+ decided_by: "channel_failure"
19219
+ }
19220
+ };
19221
+ }
18798
19222
  this.auditLog.append("l2", `gate_${response.decision}:${operation}`, "system", {
18799
19223
  tier,
18800
19224
  reason,
18801
19225
  decided_by: response.decided_by
18802
19226
  });
19227
+ if (this.onApprovalEvent) {
19228
+ try {
19229
+ this.onApprovalEvent({
19230
+ phase: "resolved",
19231
+ operation,
19232
+ tier,
19233
+ reason,
19234
+ context,
19235
+ request_timestamp: requestTimestamp,
19236
+ resolution: {
19237
+ decision: response.decision,
19238
+ decided_at: response.decided_at,
19239
+ decided_by: response.decided_by
19240
+ },
19241
+ correlation_id: correlationId
19242
+ });
19243
+ } catch {
19244
+ }
19245
+ }
18803
19246
  return {
18804
19247
  allowed: response.decision === "approve",
18805
19248
  tier,
18806
- reason: response.decision === "approve" ? `Approved by ${response.decided_by}` : `Tier ${tier} operation requires approval`,
19249
+ reason: response.decision === "approve" ? `Approved by ${response.decided_by}` : AGENT_VISIBLE_DENY_REASONS.REQUIRES_APPROVAL,
18807
19250
  approval_required: true,
18808
19251
  approval_response: response
18809
19252
  };
18810
19253
  }
18811
19254
  /**
18812
- * Summarize tool arguments for the approval prompt.
18813
- * Strips potentially large values to keep the prompt readable.
19255
+ * Summarize tool arguments for the approval prompt.
19256
+ * Strips potentially large values to keep the prompt readable.
19257
+ */
19258
+ summarizeArgs(args) {
19259
+ const summary = {};
19260
+ for (const [key, value] of Object.entries(args)) {
19261
+ if (typeof value === "string" && value.length > 100) {
19262
+ summary[key] = value.slice(0, 100) + "...";
19263
+ } else {
19264
+ summary[key] = value;
19265
+ }
19266
+ }
19267
+ return summary;
19268
+ }
19269
+ /** Get the baseline tracker for saving at session end */
19270
+ getBaseline() {
19271
+ return this.baseline;
19272
+ }
19273
+ /** Get the injection detector for stats/configuration access */
19274
+ getInjectionDetector() {
19275
+ return this.injectionDetector;
19276
+ }
19277
+ };
19278
+
19279
+ // src/principal-policy/approval-aggregator.ts
19280
+ init_encryption();
19281
+ init_encoding();
19282
+ var APPROVAL_AGGREGATOR_NAMESPACE = "_approval_aggregator";
19283
+ var APPROVAL_AGGREGATOR_HKDF_INFO = "l2-approval-aggregator-v1";
19284
+ var APPROVAL_AGGREGATOR_AUDIT_OPS = {
19285
+ AGGREGATED: "cross_harness_approval_aggregated",
19286
+ RESOLVED: "cross_harness_approval_resolved",
19287
+ DEDUPED: "cross_harness_approval_deduped"
19288
+ };
19289
+ var DEFAULT_PENDING_TTL_MS = 5 * 60 * 1e3;
19290
+ var DEFAULT_MAX_LIST_LIMIT = 200;
19291
+ var DEFAULT_LIST_PAGE_SIZE = 50;
19292
+ var ApprovalAggregator = class {
19293
+ storage;
19294
+ encryptionKey;
19295
+ auditLog;
19296
+ identityId;
19297
+ fortressId;
19298
+ pendingTtlMs;
19299
+ maxListLimit;
19300
+ now;
19301
+ resolveSourceContext;
19302
+ resolveHubInboxItemId;
19303
+ /** Cached entries by `aggregator_id`. */
19304
+ entries = /* @__PURE__ */ new Map();
19305
+ /** Dedup index: `${harness}|${agent}|${audit_id}` -> aggregator_id. */
19306
+ dedupIndex = /* @__PURE__ */ new Map();
19307
+ /** Correlation index: gate `correlation_id` -> aggregator_id. */
19308
+ correlationIndex = /* @__PURE__ */ new Map();
19309
+ /** Original request payloads kept in-memory for `getFullPayload()`. */
19310
+ fullPayloads = /* @__PURE__ */ new Map();
19311
+ /** Has the aggregator hydrated persisted entries on this process? */
19312
+ hydrated = false;
19313
+ /** Active SSE listeners. */
19314
+ listeners = /* @__PURE__ */ new Set();
19315
+ constructor(deps) {
19316
+ this.storage = deps.storage;
19317
+ this.encryptionKey = derivePurposeKey(
19318
+ deps.masterKey,
19319
+ APPROVAL_AGGREGATOR_HKDF_INFO
19320
+ );
19321
+ this.auditLog = deps.auditLog;
19322
+ this.identityId = deps.identityId;
19323
+ this.fortressId = deps.fortressId;
19324
+ this.pendingTtlMs = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
19325
+ this.maxListLimit = deps.maxListLimit ?? DEFAULT_MAX_LIST_LIMIT;
19326
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
19327
+ this.resolveSourceContext = deps.resolveSourceContext ?? ((_event) => ({
19328
+ source_harness: this.fortressId,
19329
+ source_agent_id: this.fortressId
19330
+ }));
19331
+ this.resolveHubInboxItemId = deps.resolveHubInboxItemId ?? ((_event) => void 0);
19332
+ }
19333
+ /**
19334
+ * Subscribe an event listener. Returns an unsubscribe fn. SSE handlers
19335
+ * use this to forward aggregator emissions to the dashboard.
19336
+ */
19337
+ onEvent(listener) {
19338
+ this.listeners.add(listener);
19339
+ return () => this.listeners.delete(listener);
19340
+ }
19341
+ /**
19342
+ * Ingest a gate event. Returns the aggregator entry on first sight,
19343
+ * `null` when deduped. Resolution events update the existing record;
19344
+ * unmatched resolutions are dropped silently (caller's gate emitted a
19345
+ * resolved-without-requested pair, which the aggregator does not invent
19346
+ * a record for).
19347
+ */
19348
+ async ingest(event) {
19349
+ await this.hydrate();
19350
+ if (event.phase === "requested") {
19351
+ return this.ingestRequested(event);
19352
+ }
19353
+ if (event.phase === "resolved") {
19354
+ return this.ingestResolved(event);
19355
+ }
19356
+ return null;
19357
+ }
19358
+ /**
19359
+ * List pending or recently resolved entries. Pending entries past TTL
19360
+ * are lazily transitioned to `expired` and persisted before the list
19361
+ * snapshot is returned.
19362
+ */
19363
+ async list(opts) {
19364
+ await this.hydrate();
19365
+ await this.expireStale();
19366
+ const limit = Math.min(
19367
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
19368
+ this.maxListLimit
19369
+ );
19370
+ const sinceMs = opts?.sinceTs ? Date.parse(opts.sinceTs) : Number.NEGATIVE_INFINITY;
19371
+ const matching = [];
19372
+ for (const entry of this.entries.values()) {
19373
+ if (opts?.status && entry.status !== opts.status) continue;
19374
+ if (Date.parse(entry.created_at) < sinceMs) continue;
19375
+ matching.push(entry);
19376
+ }
19377
+ matching.sort((a, b) => b.created_at.localeCompare(a.created_at));
19378
+ return matching.slice(0, limit);
19379
+ }
19380
+ /**
19381
+ * Return the original (unhashed) request payload for the entry. Returns
19382
+ * `null` when the entry is unknown or the payload was evicted (e.g. the
19383
+ * process restarted; payloads are in-memory only at v1.3 Upsilon-1).
19384
+ */
19385
+ async getFullPayload(aggregatorId) {
19386
+ await this.hydrate();
19387
+ if (!this.entries.has(aggregatorId)) return null;
19388
+ return this.fullPayloads.get(aggregatorId) ?? null;
19389
+ }
19390
+ /**
19391
+ * Resolve an entry. Used by both:
19392
+ * 1. The gate wire-up on channel-decision return.
19393
+ * 2. The HTTP `approve`/`deny` routes when an operator clicks.
19394
+ *
19395
+ * Idempotent: resolving an already-resolved entry is a no-op (the record
19396
+ * keeps its first decision and the audit log is not double-fired).
19397
+ * Unknown ids throw `Error("approval-aggregator: not_found")` so HTTP
19398
+ * routes return 404.
19399
+ */
19400
+ async resolve(aggregatorId, decision, operatorId) {
19401
+ await this.hydrate();
19402
+ const entry = this.entries.get(aggregatorId);
19403
+ if (!entry) {
19404
+ throw new Error("approval-aggregator: not_found");
19405
+ }
19406
+ if (entry.status !== "pending") {
19407
+ return entry;
19408
+ }
19409
+ entry.status = decision;
19410
+ entry.resolved_at = this.now().toISOString();
19411
+ entry.resolved_by = operatorId;
19412
+ await this.persist(entry);
19413
+ this.auditLog.append(
19414
+ "l2",
19415
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19416
+ this.identityId,
19417
+ {
19418
+ aggregator_id: entry.aggregator_id,
19419
+ source_harness: entry.source_harness,
19420
+ source_agent_id: entry.source_agent_id,
19421
+ audit_log_entry_id: entry.audit_log_entry_id,
19422
+ policy_rule_id: entry.policy_rule_id,
19423
+ decision,
19424
+ decided_by: operatorId,
19425
+ decided_at: entry.resolved_at
19426
+ }
19427
+ );
19428
+ this.emit({ type: "resolved", entry: { ...entry } });
19429
+ return entry;
19430
+ }
19431
+ // ── Internal: ingest paths ─────────────────────────────────────────────
19432
+ async ingestRequested(event) {
19433
+ const ctx = this.resolveSourceContext(event);
19434
+ const auditId = this.auditEntryIdForEvent(event);
19435
+ const dedupKey = `${ctx.source_harness}|${ctx.source_agent_id}|${auditId}`;
19436
+ const existing = this.dedupIndex.get(dedupKey);
19437
+ if (existing) {
19438
+ const existingEntry = this.entries.get(existing);
19439
+ if (existingEntry) {
19440
+ this.correlationIndex.set(event.correlation_id, existing);
19441
+ this.auditLog.append(
19442
+ "l2",
19443
+ APPROVAL_AGGREGATOR_AUDIT_OPS.DEDUPED,
19444
+ this.identityId,
19445
+ {
19446
+ aggregator_id: existing,
19447
+ source_harness: ctx.source_harness,
19448
+ source_agent_id: ctx.source_agent_id,
19449
+ audit_log_entry_id: auditId,
19450
+ policy_rule_id: this.derivePolicyRuleId(event),
19451
+ correlation_id: event.correlation_id
19452
+ }
19453
+ );
19454
+ this.emit({ type: "deduped", entry: { ...existingEntry } });
19455
+ return null;
19456
+ }
19457
+ }
19458
+ const id = crypto.randomUUID();
19459
+ const now = this.now();
19460
+ const expires = new Date(now.getTime() + this.pendingTtlMs);
19461
+ const hubInboxId = this.resolveHubInboxItemId(event);
19462
+ const entry = {
19463
+ aggregator_id: id,
19464
+ source_harness: ctx.source_harness,
19465
+ source_agent_id: ctx.source_agent_id,
19466
+ audit_log_entry_id: auditId,
19467
+ policy_rule_id: this.derivePolicyRuleId(event),
19468
+ action_summary: this.deriveActionSummary(event),
19469
+ request_payload_hash: this.hashPayload(event.context),
19470
+ status: "pending",
19471
+ created_at: now.toISOString(),
19472
+ expires_at: expires.toISOString(),
19473
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
19474
+ };
19475
+ this.entries.set(id, entry);
19476
+ this.dedupIndex.set(dedupKey, id);
19477
+ this.correlationIndex.set(event.correlation_id, id);
19478
+ this.fullPayloads.set(id, event.context);
19479
+ await this.persist(entry);
19480
+ this.auditLog.append(
19481
+ "l2",
19482
+ APPROVAL_AGGREGATOR_AUDIT_OPS.AGGREGATED,
19483
+ this.identityId,
19484
+ {
19485
+ aggregator_id: id,
19486
+ source_harness: ctx.source_harness,
19487
+ source_agent_id: ctx.source_agent_id,
19488
+ audit_log_entry_id: auditId,
19489
+ policy_rule_id: entry.policy_rule_id,
19490
+ ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {}
19491
+ }
19492
+ );
19493
+ this.emit({ type: "aggregated", entry: { ...entry } });
19494
+ return entry;
19495
+ }
19496
+ async ingestResolved(event) {
19497
+ const id = this.correlationIndex.get(event.correlation_id);
19498
+ if (!id) return null;
19499
+ const entry = this.entries.get(id);
19500
+ if (!entry) return null;
19501
+ if (entry.status !== "pending") return entry;
19502
+ if (!event.resolution) return entry;
19503
+ const failClosed = event.resolution.decision === "deny" && event.resolution.decided_by === "channel_failure";
19504
+ const status = failClosed ? "timeout" : event.resolution.decision === "approve" ? "approved" : "denied";
19505
+ entry.status = status;
19506
+ entry.resolved_at = event.resolution.decided_at;
19507
+ entry.resolved_by = event.resolution.decided_by;
19508
+ await this.persist(entry);
19509
+ this.auditLog.append(
19510
+ "l2",
19511
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19512
+ this.identityId,
19513
+ {
19514
+ aggregator_id: id,
19515
+ source_harness: entry.source_harness,
19516
+ source_agent_id: entry.source_agent_id,
19517
+ audit_log_entry_id: entry.audit_log_entry_id,
19518
+ policy_rule_id: entry.policy_rule_id,
19519
+ decision: status,
19520
+ decided_by: entry.resolved_by,
19521
+ decided_at: entry.resolved_at,
19522
+ fail_closed: failClosed
19523
+ }
19524
+ );
19525
+ this.emit({ type: "resolved", entry: { ...entry } });
19526
+ return entry;
19527
+ }
19528
+ // ── Internal: helpers ──────────────────────────────────────────────────
19529
+ /**
19530
+ * Audit-log entry id for the dedup tuple. The audit log itself does not
19531
+ * surface a stable per-entry id (counter-prefixed keys are internal); the
19532
+ * aggregator uses the request timestamp + operation, which together pin
19533
+ * the audit entry the gate appended on the same call.
18814
19534
  */
18815
- summarizeArgs(args) {
18816
- const summary = {};
18817
- for (const [key, value] of Object.entries(args)) {
18818
- if (typeof value === "string" && value.length > 100) {
18819
- summary[key] = value.slice(0, 100) + "...";
18820
- } else {
18821
- summary[key] = value;
19535
+ auditEntryIdForEvent(event) {
19536
+ return `${event.request_timestamp}:${event.operation}`;
19537
+ }
19538
+ derivePolicyRuleId(event) {
19539
+ return `tier${event.tier}:${event.operation}`;
19540
+ }
19541
+ deriveActionSummary(event) {
19542
+ return `${event.operation} (tier ${event.tier})`;
19543
+ }
19544
+ /**
19545
+ * Canonical SHA-256 of the request context. Sorted-keys serialization so
19546
+ * identical payloads always hash the same, even when key insertion order
19547
+ * varies. Defends against payload-replay smuggling (the aggregator can
19548
+ * tell the same payload was seen twice without storing it cleartext).
19549
+ */
19550
+ hashPayload(payload) {
19551
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
19552
+ return crypto.createHash("sha256").update(canonical).digest("hex");
19553
+ }
19554
+ emit(event) {
19555
+ for (const listener of this.listeners) {
19556
+ try {
19557
+ listener(event);
19558
+ } catch {
18822
19559
  }
18823
19560
  }
18824
- return summary;
18825
19561
  }
18826
- /** Get the baseline tracker for saving at session end */
18827
- getBaseline() {
18828
- return this.baseline;
19562
+ async expireStale() {
19563
+ const nowMs = this.now().getTime();
19564
+ for (const entry of this.entries.values()) {
19565
+ if (entry.status !== "pending") continue;
19566
+ if (Date.parse(entry.expires_at) > nowMs) continue;
19567
+ entry.status = "expired";
19568
+ entry.resolved_at = this.now().toISOString();
19569
+ entry.resolved_by = "system_ttl";
19570
+ await this.persist(entry);
19571
+ this.auditLog.append(
19572
+ "l2",
19573
+ APPROVAL_AGGREGATOR_AUDIT_OPS.RESOLVED,
19574
+ this.identityId,
19575
+ {
19576
+ aggregator_id: entry.aggregator_id,
19577
+ source_harness: entry.source_harness,
19578
+ source_agent_id: entry.source_agent_id,
19579
+ audit_log_entry_id: entry.audit_log_entry_id,
19580
+ policy_rule_id: entry.policy_rule_id,
19581
+ decision: "expired",
19582
+ decided_by: "system_ttl",
19583
+ decided_at: entry.resolved_at
19584
+ }
19585
+ );
19586
+ this.emit({ type: "resolved", entry: { ...entry } });
19587
+ }
18829
19588
  }
18830
- /** Get the injection detector for stats/configuration access */
18831
- getInjectionDetector() {
18832
- return this.injectionDetector;
19589
+ async persist(entry) {
19590
+ const serialized = stringToBytes(JSON.stringify(entry));
19591
+ const encrypted = encrypt(serialized, this.encryptionKey);
19592
+ await this.storage.write(
19593
+ APPROVAL_AGGREGATOR_NAMESPACE,
19594
+ entry.aggregator_id,
19595
+ stringToBytes(JSON.stringify(encrypted))
19596
+ );
19597
+ }
19598
+ async hydrate() {
19599
+ if (this.hydrated) return;
19600
+ this.hydrated = true;
19601
+ try {
19602
+ const metas = await this.storage.list(APPROVAL_AGGREGATOR_NAMESPACE);
19603
+ for (const meta of metas) {
19604
+ const raw = await this.storage.read(
19605
+ APPROVAL_AGGREGATOR_NAMESPACE,
19606
+ meta.key
19607
+ );
19608
+ if (!raw) continue;
19609
+ try {
19610
+ const encrypted = JSON.parse(bytesToString(raw));
19611
+ const decrypted = decrypt(encrypted, this.encryptionKey);
19612
+ const entry = JSON.parse(bytesToString(decrypted));
19613
+ this.entries.set(entry.aggregator_id, entry);
19614
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
19615
+ this.dedupIndex.set(dedupKey, entry.aggregator_id);
19616
+ } catch {
19617
+ }
19618
+ }
19619
+ } catch {
19620
+ this.hydrated = false;
19621
+ }
18833
19622
  }
18834
19623
  };
18835
19624
 
@@ -19373,7 +20162,11 @@ init_identity();
19373
20162
  init_encoding();
19374
20163
  init_random();
19375
20164
  function generateNonce() {
19376
- return toBase64url(randomBytes(32));
20165
+ const nonce = randomBytes(32);
20166
+ if (!nonce || nonce.length !== 32) {
20167
+ throw new Error("Nonce generation failed: randomBytes returned unexpected length");
20168
+ }
20169
+ return toBase64url(nonce);
19377
20170
  }
19378
20171
  function initiateHandshake(ourSHR) {
19379
20172
  const nonce = generateNonce();
@@ -19484,6 +20277,18 @@ function completeHandshake(response, session, identityManager, masterKey, identi
19484
20277
  return { completion, result };
19485
20278
  }
19486
20279
  function verifyCompletion(completion, session) {
20280
+ if (completion.protocol_version !== "1.0") {
20281
+ return {
20282
+ counterparty_id: "unknown",
20283
+ counterparty_shr: session.our_shr,
20284
+ verified: false,
20285
+ sovereignty_level: "unverified",
20286
+ trust_tier: "unverified",
20287
+ completed_at: completion.completed_at,
20288
+ expires_at: (/* @__PURE__ */ new Date()).toISOString(),
20289
+ errors: [`Unsupported protocol version: ${completion.protocol_version}`]
20290
+ };
20291
+ }
19487
20292
  const errors = [];
19488
20293
  if (!session.their_shr) {
19489
20294
  return {
@@ -21585,6 +22390,9 @@ Inspect the file and either correct the JSON or delete it manually before re-run
21585
22390
  this.cause = cause;
21586
22391
  this.name = "ResetHistoryMalformedError";
21587
22392
  }
22393
+ markerPath;
22394
+ lineNumber;
22395
+ cause;
21588
22396
  };
21589
22397
  function parseResetHistory(content, markerPath) {
21590
22398
  const markerHash = hashToString(stringToBytes(content));
@@ -21666,6 +22474,12 @@ function typed(markerPath, lineNumber, field, expected) {
21666
22474
  }
21667
22475
  async function consumeResetHistoryMarker(options) {
21668
22476
  const markerPath = path.join(options.storagePath, RESET_HISTORY_FILENAME);
22477
+ const consumedPath = markerPath + ".consumed";
22478
+ if (await fileExists3(consumedPath)) {
22479
+ await promises.rm(markerPath, { force: true });
22480
+ await promises.rm(consumedPath, { force: true });
22481
+ return { emitted: 0, markerPath };
22482
+ }
21669
22483
  if (!await fileExists3(markerPath)) {
21670
22484
  return { emitted: 0, markerPath };
21671
22485
  }
@@ -21690,7 +22504,9 @@ async function consumeResetHistoryMarker(options) {
21690
22504
  });
21691
22505
  }
21692
22506
  await options.auditLog.flush();
22507
+ await promises.writeFile(consumedPath, "", "utf-8");
21693
22508
  await promises.rm(markerPath, { force: true });
22509
+ await promises.rm(consumedPath, { force: true });
21694
22510
  return { emitted: markers.length, markerHash, markerPath };
21695
22511
  }
21696
22512
  async function fileExists3(path) {
@@ -23826,7 +24642,7 @@ async function runOpenAIPrivacyFilter(text, config) {
23826
24642
  return parsed;
23827
24643
  }
23828
24644
  function runCommand(command, input, timeoutMs) {
23829
- return new Promise((resolve4, reject) => {
24645
+ return new Promise((resolve6, reject) => {
23830
24646
  const child = child_process.spawn(command, [], {
23831
24647
  stdio: ["pipe", "pipe", "pipe"],
23832
24648
  shell: false
@@ -23857,7 +24673,7 @@ function runCommand(command, input, timeoutMs) {
23857
24673
  ));
23858
24674
  return;
23859
24675
  }
23860
- resolve4(stdout);
24676
+ resolve6(stdout);
23861
24677
  });
23862
24678
  child.stdin.end(input);
23863
24679
  });
@@ -25678,13 +26494,13 @@ var ProxyRouter = class {
25678
26494
  * Call an upstream tool with a timeout.
25679
26495
  */
25680
26496
  async callWithTimeout(serverName, toolName, args, timeoutMs) {
25681
- return new Promise((resolve4, reject) => {
26497
+ return new Promise((resolve6, reject) => {
25682
26498
  const timer = setTimeout(() => {
25683
26499
  reject(new Error(`Upstream tool call timed out after ${timeoutMs}ms`));
25684
26500
  }, timeoutMs);
25685
26501
  this.clientManager.callTool(serverName, toolName, args).then((result) => {
25686
26502
  clearTimeout(timer);
25687
- resolve4(result);
26503
+ resolve6(result);
25688
26504
  }).catch((err) => {
25689
26505
  clearTimeout(timer);
25690
26506
  reject(err);
@@ -30636,6 +31452,36 @@ var HubService = class {
30636
31452
  const chat = this.requireOperatorChat();
30637
31453
  return chat.getConciergeHistory();
30638
31454
  }
31455
+ // ── Concierge memory threads (WP-V1.3-9 Tau-1) ─────────────────────
31456
+ /**
31457
+ * Whether the operator-chat service has the WP-V1.3-9 memory store
31458
+ * wired. Routes use this to 503 cleanly when the foundation memory
31459
+ * surface is unavailable on a given fortress.
31460
+ */
31461
+ hasConciergeMemory() {
31462
+ return Boolean(this.deps.operatorChat?.hasConciergeMemory());
31463
+ }
31464
+ async listConciergeMemoryThreads(opts) {
31465
+ const chat = this.requireOperatorChat();
31466
+ if (!chat.hasConciergeMemory()) {
31467
+ throw new HubCapabilityError("concierge_memory_not_wired");
31468
+ }
31469
+ return chat.listConciergeMemoryThreads(opts);
31470
+ }
31471
+ async readConciergeMemoryThread(threadId, opts) {
31472
+ const chat = this.requireOperatorChat();
31473
+ if (!chat.hasConciergeMemory()) {
31474
+ throw new HubCapabilityError("concierge_memory_not_wired");
31475
+ }
31476
+ return chat.readConciergeMemoryThread(threadId, opts);
31477
+ }
31478
+ async deleteConciergeMemoryThread(threadId) {
31479
+ const chat = this.requireOperatorChat();
31480
+ if (!chat.hasConciergeMemory()) {
31481
+ throw new HubCapabilityError("concierge_memory_not_wired");
31482
+ }
31483
+ return chat.deleteConciergeMemoryThread(threadId);
31484
+ }
30639
31485
  /**
30640
31486
  * Open the click-to-inspect/approve panel for a wrapped agent. The
30641
31487
  * panel surfaces recent activity routed through this agent, pending
@@ -30723,7 +31569,21 @@ init_encoding();
30723
31569
 
30724
31570
  // src/chat/operator-chat-audit-events.ts
30725
31571
  var OPERATOR_CHAT_OPS = {
30726
- CONCIERGE_CHAT: "operator_concierge_chat"};
31572
+ CONCIERGE_CHAT: "operator_concierge_chat",
31573
+ /**
31574
+ * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
31575
+ * when the operator hits the list-threads or read-thread route. Body
31576
+ * carries the thread_id (or `*` for the list endpoint) and a count;
31577
+ * raw turn content never crosses the audit surface.
31578
+ */
31579
+ CONCIERGE_HISTORY_READ: "operator_concierge_history_read",
31580
+ /**
31581
+ * Operator deleted a concierge thread (WP-V1.3-9 Tau-1). Emitted on
31582
+ * successful thread removal. Body carries thread_id + turn_count of
31583
+ * the deleted bundle.
31584
+ */
31585
+ CONCIERGE_THREAD_DELETED: "operator_concierge_thread_deleted"
31586
+ };
30727
31587
 
30728
31588
  // src/chat/operator-chat-types.ts
30729
31589
  var OPERATOR_CHAT_MAX_THREAD_LENGTH = 500;
@@ -30731,6 +31591,33 @@ var CONCIERGE_THREAD_KEY = "_fortress";
30731
31591
 
30732
31592
  // src/chat/operator-chat-service.ts
30733
31593
  var DEFAULT_CONCIERGE_MAX_TOKENS = 512;
31594
+ var SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
31595
+ 1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
31596
+ 2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
31597
+ 3. Charter (Cooperative MCP): the sovereignty surface for compliant agents. Policy gates, approval tiers, audit logging, and encrypted state all live here.
31598
+ 4. Heralds: Concordia receipts and Verascore reputation. Cross-fortress accountability after an action completes.
31599
+
31600
+ Five channel templates (canonical names):
31601
+ - request-approve-act: agent proposes an action, operator approves or denies before execution.
31602
+ - read-then-report: agent reads outputs from a data source and reports summaries to the operator.
31603
+ - scheduled-digest: agent runs on a schedule and delivers a periodic digest.
31604
+ - plan-draft-only: agent drafts plans; operator reviews before any execution step.
31605
+ - fortress-relay: agent relays messages between fortresses under operator-scoped policy.
31606
+
31607
+ Four canonical policy slots:
31608
+ - memory: governs what the agent may persist and retrieve from encrypted state.
31609
+ - credentials: governs access to secrets, API keys, and tokens held in the broker.
31610
+ - plans: governs the agent's ability to create, modify, or execute plans.
31611
+ - outputs: governs what the agent may emit to external surfaces (files, APIs, messages).
31612
+
31613
+ Key concepts:
31614
+ - Fortress: the operator-owned sovereignty harness. All state is encrypted at rest under the cocoon.
31615
+ - Cocoon: master-key-wrapped storage derived from the operator's passphrase via Argon2id.
31616
+ - Identity: Ed25519 keypair with a DID, owned by the operator. Private keys never leave the cocoon.
31617
+ - Audit log: append-only encrypted blobs, sequential, recording every gate decision and tool call.
31618
+ - Wrapped agent: any agent runtime that connects to Sanctuary as an MCP client. Tier A (native), Tier B (adapter-wrapped), Tier C (escape hatch).
31619
+
31620
+ Note: this is a static reference block (v1.2.x). Dynamic context injection (live template list, policy schema) ships in v1.3.`;
30734
31621
  var OperatorChatService = class {
30735
31622
  store;
30736
31623
  auditLog;
@@ -30739,6 +31626,14 @@ var OperatorChatService = class {
30739
31626
  contextProviders;
30740
31627
  piiFilter;
30741
31628
  conciergeMaxTokens;
31629
+ memory;
31630
+ /**
31631
+ * In-memory thread_id assigned to the active concierge session.
31632
+ * The first sendConcierge call after construction allocates a fresh
31633
+ * UUID; subsequent calls reuse it so multi-turn coherence (Tau-2)
31634
+ * folds the prior turns into context. Cleared by `resetConciergeMemoryThread`.
31635
+ */
31636
+ activeMemoryThreadId;
30742
31637
  constructor(deps) {
30743
31638
  this.store = deps.store;
30744
31639
  this.auditLog = deps.auditLog;
@@ -30749,6 +31644,7 @@ var OperatorChatService = class {
30749
31644
  }
30750
31645
  if (deps.conciergePiiFilter) this.piiFilter = deps.conciergePiiFilter;
30751
31646
  this.conciergeMaxTokens = deps.conciergeMaxTokens ?? DEFAULT_CONCIERGE_MAX_TOKENS;
31647
+ if (deps.conciergeMemory) this.memory = deps.conciergeMemory;
30752
31648
  }
30753
31649
  // ── Concierge ─────────────────────────────────────────────────────────
30754
31650
  /**
@@ -30779,6 +31675,11 @@ var OperatorChatService = class {
30779
31675
  CONCIERGE_THREAD_KEY,
30780
31676
  operatorMessage
30781
31677
  );
31678
+ if (this.memory) {
31679
+ const threadId = this.ensureActiveMemoryThread();
31680
+ await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
31681
+ });
31682
+ }
30782
31683
  const start = Date.now();
30783
31684
  let conciergeBody;
30784
31685
  let servedBy = "disabled";
@@ -30834,6 +31735,11 @@ var OperatorChatService = class {
30834
31735
  CONCIERGE_THREAD_KEY,
30835
31736
  responseMessage
30836
31737
  );
31738
+ if (this.memory) {
31739
+ const threadId = this.ensureActiveMemoryThread();
31740
+ await this.memory.appendTurn(threadId, "assistant", conciergeBody).catch(() => {
31741
+ });
31742
+ }
30837
31743
  const payload = {
30838
31744
  version: "1.2",
30839
31745
  event_id: makeEventId("conc"),
@@ -30866,6 +31772,105 @@ var OperatorChatService = class {
30866
31772
  );
30867
31773
  return thread ? thread.messages : [];
30868
31774
  }
31775
+ // ── WP-V1.3-9 Tau-1 memory accessors ─────────────────────────────────
31776
+ /**
31777
+ * Whether the foundation memory store is wired. Routes use this to
31778
+ * 503 cleanly when called against an unwired service.
31779
+ */
31780
+ hasConciergeMemory() {
31781
+ return this.memory !== void 0;
31782
+ }
31783
+ /**
31784
+ * List concierge memory threads, newest-first. Emits the
31785
+ * `operator_concierge_history_read` audit event with `thread_id="*"`.
31786
+ */
31787
+ async listConciergeMemoryThreads(opts) {
31788
+ if (!this.memory) {
31789
+ throw new Error("concierge memory store not configured");
31790
+ }
31791
+ const summaries = await this.memory.listThreads(opts);
31792
+ const totalTurns = summaries.reduce((acc, s) => acc + s.turn_count, 0);
31793
+ const payload = {
31794
+ version: "1.2",
31795
+ event_id: makeEventId("conc-hist"),
31796
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
31797
+ identity_id: this.identityId,
31798
+ kind: "operator_concierge_history_read",
31799
+ surface: "concierge",
31800
+ thread_id: "*",
31801
+ turn_count: totalTurns
31802
+ };
31803
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
31804
+ return summaries;
31805
+ }
31806
+ /**
31807
+ * Read a concierge memory thread, oldest turn first. Emits the
31808
+ * `operator_concierge_history_read` audit event with the named
31809
+ * thread_id and the count of turns surfaced.
31810
+ */
31811
+ async readConciergeMemoryThread(threadId, opts) {
31812
+ if (!this.memory) {
31813
+ throw new Error("concierge memory store not configured");
31814
+ }
31815
+ const turns = await this.memory.readThread(threadId, opts);
31816
+ const payload = {
31817
+ version: "1.2",
31818
+ event_id: makeEventId("conc-hist"),
31819
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
31820
+ identity_id: this.identityId,
31821
+ kind: "operator_concierge_history_read",
31822
+ surface: "concierge",
31823
+ thread_id: threadId,
31824
+ turn_count: turns.length
31825
+ };
31826
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ, payload, "success");
31827
+ return turns;
31828
+ }
31829
+ /**
31830
+ * Delete a concierge memory thread. Emits
31831
+ * `operator_concierge_thread_deleted` only when a bundle was actually
31832
+ * removed; absent threads return false without an audit event.
31833
+ */
31834
+ async deleteConciergeMemoryThread(threadId) {
31835
+ if (!this.memory) {
31836
+ throw new Error("concierge memory store not configured");
31837
+ }
31838
+ const turnsBefore = await this.memory.readThread(threadId);
31839
+ if (turnsBefore.length === 0) {
31840
+ return await this.memory.deleteThread(threadId);
31841
+ }
31842
+ const removed = await this.memory.deleteThread(threadId);
31843
+ if (!removed) return false;
31844
+ if (this.activeMemoryThreadId === threadId) {
31845
+ this.activeMemoryThreadId = void 0;
31846
+ }
31847
+ const payload = {
31848
+ version: "1.2",
31849
+ event_id: makeEventId("conc-del"),
31850
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
31851
+ identity_id: this.identityId,
31852
+ kind: "operator_concierge_thread_deleted",
31853
+ surface: "concierge",
31854
+ thread_id: threadId,
31855
+ turn_count: turnsBefore.length
31856
+ };
31857
+ this.emit(OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED, payload, "success");
31858
+ return true;
31859
+ }
31860
+ /**
31861
+ * Reset the active session memory thread. Subsequent sendConcierge
31862
+ * calls allocate a fresh thread_id. Surfaced for tests + future "new
31863
+ * conversation" affordance; not currently called by the dashboard.
31864
+ */
31865
+ resetConciergeMemoryThread() {
31866
+ this.activeMemoryThreadId = void 0;
31867
+ }
31868
+ ensureActiveMemoryThread() {
31869
+ if (!this.activeMemoryThreadId) {
31870
+ this.activeMemoryThreadId = crypto.randomUUID();
31871
+ }
31872
+ return this.activeMemoryThreadId;
31873
+ }
30869
31874
  /**
30870
31875
  * Stitch fortress state into a single context blob the substrate
30871
31876
  * folds into its summarization prompt.
@@ -30875,6 +31880,9 @@ var OperatorChatService = class {
30875
31880
  * than nested structures. Format:
30876
31881
  *
30877
31882
  * ```
31883
+ * ## Sanctuary reference
31884
+ * <static domain reference block>
31885
+ *
30878
31886
  * ## Recent activity
30879
31887
  * <recentActivity output>
30880
31888
  *
@@ -30886,15 +31894,28 @@ var OperatorChatService = class {
30886
31894
  * ```
30887
31895
  */
30888
31896
  async assembleConciergeContext() {
31897
+ const ref = `## Sanctuary reference
31898
+ ${SANCTUARY_DOMAIN_REFERENCE}`;
30889
31899
  if (!this.contextProviders) {
30890
- return "## Recent activity\n(no providers wired)\n\n## Wrapped agents\n(no providers wired)\n\n## Open inbox\n(no providers wired)";
31900
+ return `${ref}
31901
+
31902
+ ## Recent activity
31903
+ (no providers wired)
31904
+
31905
+ ## Wrapped agents
31906
+ (no providers wired)
31907
+
31908
+ ## Open inbox
31909
+ (no providers wired)`;
30891
31910
  }
30892
31911
  const [activity, agents, inbox] = await Promise.all([
30893
31912
  this.contextProviders.recentActivity(),
30894
31913
  this.contextProviders.agentInventory(),
30895
31914
  this.contextProviders.openInbox()
30896
31915
  ]);
30897
- return `## Recent activity
31916
+ return `${ref}
31917
+
31918
+ ## Recent activity
30898
31919
  ${activity}
30899
31920
 
30900
31921
  ## Wrapped agents
@@ -31013,6 +32034,238 @@ var OperatorChatStore = class {
31013
32034
  }
31014
32035
  };
31015
32036
 
32037
+ // src/chat/concierge-memory-store.ts
32038
+ init_encryption();
32039
+ init_encoding();
32040
+ var CONCIERGE_MEMORY_NAMESPACE = "_chat";
32041
+ var CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
32042
+ var HKDF_INFO2 = "concierge-memory-store-v1";
32043
+ var DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
32044
+ var MAX_BUNDLE_BYTES2 = 4 * 1024 * 1024;
32045
+ var ConciergeMemoryStore = class {
32046
+ storage;
32047
+ encryptionKey;
32048
+ fortressId;
32049
+ retentionDays;
32050
+ locks;
32051
+ constructor(opts) {
32052
+ this.storage = opts.storage;
32053
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
32054
+ this.fortressId = opts.fortressId;
32055
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
32056
+ this.locks = /* @__PURE__ */ new Map();
32057
+ }
32058
+ /**
32059
+ * Append a turn to the named thread, creating the bundle if no record
32060
+ * exists. Returns the persisted turn (with assigned turn_id +
32061
+ * retention_until). Per-thread serialisation guarantees turn_id
32062
+ * monotonicity even under concurrent callers.
32063
+ */
32064
+ async appendTurn(threadId, role, content) {
32065
+ return this.withLock(threadId, async () => {
32066
+ const bundle = await this.loadBundle(threadId) ?? null;
32067
+ const now = /* @__PURE__ */ new Date();
32068
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
32069
+ const retentionUntil = new Date(now.getTime() + retentionMs);
32070
+ const nextTurnId = bundle ? lastTurnId(bundle) + 1 : 1;
32071
+ const turn = {
32072
+ thread_id: threadId,
32073
+ fortress_id: this.fortressId,
32074
+ turn_id: nextTurnId,
32075
+ role,
32076
+ content,
32077
+ created_at: now.toISOString(),
32078
+ retention_until: retentionUntil.toISOString()
32079
+ };
32080
+ const next = bundle ? { ...bundle, turns: [...bundle.turns, turn] } : {
32081
+ version: 1,
32082
+ thread_id: threadId,
32083
+ fortress_id: this.fortressId,
32084
+ created_at: now.toISOString(),
32085
+ turns: [turn]
32086
+ };
32087
+ await this.saveBundle(next);
32088
+ return turn;
32089
+ });
32090
+ }
32091
+ /**
32092
+ * Read turns from a thread, oldest-first. Returns an empty array if
32093
+ * the thread does not exist or its bundle is corrupt. Does not emit
32094
+ * audit events; the caller (HTTP route handler) owns audit semantics.
32095
+ */
32096
+ async readThread(threadId, opts) {
32097
+ const bundle = await this.loadBundle(threadId);
32098
+ if (!bundle) return [];
32099
+ let turns = bundle.turns;
32100
+ if (opts?.sinceTurnId !== void 0) {
32101
+ const cutoff = opts.sinceTurnId;
32102
+ turns = turns.filter((t) => t.turn_id > cutoff);
32103
+ }
32104
+ if (opts?.limit !== void 0) {
32105
+ turns = turns.slice(0, opts.limit);
32106
+ }
32107
+ return turns;
32108
+ }
32109
+ /**
32110
+ * Enumerate concierge threads in this fortress with summary metadata.
32111
+ * Sorted newest-first by last_turn_at.
32112
+ */
32113
+ async listThreads(opts) {
32114
+ const entries = await this.storage.list(
32115
+ CONCIERGE_MEMORY_NAMESPACE,
32116
+ CONCIERGE_MEMORY_KEY_PREFIX
32117
+ );
32118
+ const summaries = [];
32119
+ for (const meta of entries) {
32120
+ const threadId = stripKeyPrefix(meta.key);
32121
+ if (threadId === null) continue;
32122
+ const bundle = await this.loadBundle(threadId);
32123
+ if (!bundle || bundle.turns.length === 0) continue;
32124
+ const last = bundle.turns[bundle.turns.length - 1];
32125
+ summaries.push({
32126
+ thread_id: bundle.thread_id,
32127
+ created_at: bundle.created_at,
32128
+ last_turn_at: last ? last.created_at : bundle.created_at,
32129
+ turn_count: bundle.turns.length
32130
+ });
32131
+ }
32132
+ summaries.sort(
32133
+ (a, b) => a.last_turn_at < b.last_turn_at ? 1 : a.last_turn_at > b.last_turn_at ? -1 : 0
32134
+ );
32135
+ if (opts?.limit !== void 0) {
32136
+ return summaries.slice(0, opts.limit);
32137
+ }
32138
+ return summaries;
32139
+ }
32140
+ /**
32141
+ * Delete a thread's bundle. Returns true if the bundle existed and
32142
+ * was removed; false if no bundle was present. Audit emission is the
32143
+ * caller's responsibility.
32144
+ */
32145
+ async deleteThread(threadId) {
32146
+ const key = bundleKey(threadId);
32147
+ return this.withLock(threadId, async () => {
32148
+ const existed = await this.storage.exists(
32149
+ CONCIERGE_MEMORY_NAMESPACE,
32150
+ key
32151
+ );
32152
+ if (!existed) return false;
32153
+ try {
32154
+ await this.storage.delete(CONCIERGE_MEMORY_NAMESPACE, key);
32155
+ } catch {
32156
+ return false;
32157
+ }
32158
+ return true;
32159
+ });
32160
+ }
32161
+ /**
32162
+ * Drop expired turns across all threads. Threads emptied by pruning
32163
+ * are removed entirely. Returns the count of turns pruned.
32164
+ */
32165
+ async pruneExpired(now) {
32166
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
32167
+ const entries = await this.storage.list(
32168
+ CONCIERGE_MEMORY_NAMESPACE,
32169
+ CONCIERGE_MEMORY_KEY_PREFIX
32170
+ );
32171
+ let pruned = 0;
32172
+ for (const meta of entries) {
32173
+ const threadId = stripKeyPrefix(meta.key);
32174
+ if (threadId === null) continue;
32175
+ pruned += await this.withLock(threadId, async () => {
32176
+ const bundle = await this.loadBundle(threadId);
32177
+ if (!bundle) return 0;
32178
+ const kept = bundle.turns.filter((t) => t.retention_until > cutoff);
32179
+ const dropped = bundle.turns.length - kept.length;
32180
+ if (dropped === 0) return 0;
32181
+ if (kept.length === 0) {
32182
+ await this.storage.delete(
32183
+ CONCIERGE_MEMORY_NAMESPACE,
32184
+ bundleKey(threadId)
32185
+ );
32186
+ } else {
32187
+ await this.saveBundle({ ...bundle, turns: kept });
32188
+ }
32189
+ return dropped;
32190
+ });
32191
+ }
32192
+ return { pruned };
32193
+ }
32194
+ // ── internals ────────────────────────────────────────────────────────
32195
+ async loadBundle(threadId) {
32196
+ const key = bundleKey(threadId);
32197
+ let raw;
32198
+ try {
32199
+ raw = await this.storage.read(CONCIERGE_MEMORY_NAMESPACE, key);
32200
+ } catch {
32201
+ return null;
32202
+ }
32203
+ if (!raw) return null;
32204
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
32205
+ try {
32206
+ const envelope = JSON.parse(bytesToString(raw));
32207
+ const aad = stringToBytes(threadId);
32208
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
32209
+ const parsed = JSON.parse(
32210
+ bytesToString(plaintext)
32211
+ );
32212
+ if (parsed.version !== 1) return null;
32213
+ if (parsed.thread_id !== threadId) return null;
32214
+ return parsed;
32215
+ } catch {
32216
+ return null;
32217
+ }
32218
+ }
32219
+ async saveBundle(bundle) {
32220
+ const key = bundleKey(bundle.thread_id);
32221
+ const aad = stringToBytes(bundle.thread_id);
32222
+ const plaintext = stringToBytes(JSON.stringify(bundle));
32223
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
32224
+ await this.storage.write(
32225
+ CONCIERGE_MEMORY_NAMESPACE,
32226
+ key,
32227
+ stringToBytes(JSON.stringify(envelope))
32228
+ );
32229
+ }
32230
+ /**
32231
+ * Run `task` while holding the per-thread async lock. Lock is released
32232
+ * once the task settles (success or failure). Generic helper so
32233
+ * appendTurn / deleteThread / pruneExpired share serialisation.
32234
+ */
32235
+ async withLock(threadId, task) {
32236
+ const previous = this.locks.get(threadId) ?? Promise.resolve();
32237
+ let release;
32238
+ const next = new Promise((resolve6) => {
32239
+ release = resolve6;
32240
+ });
32241
+ const chained = previous.then(() => next);
32242
+ this.locks.set(threadId, chained);
32243
+ try {
32244
+ await previous;
32245
+ return await task();
32246
+ } finally {
32247
+ release();
32248
+ if (this.locks.get(threadId) === chained) {
32249
+ this.locks.delete(threadId);
32250
+ }
32251
+ }
32252
+ }
32253
+ };
32254
+ function bundleKey(threadId) {
32255
+ return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
32256
+ }
32257
+ function stripKeyPrefix(key) {
32258
+ if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
32259
+ return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
32260
+ }
32261
+ function lastTurnId(bundle) {
32262
+ let max = 0;
32263
+ for (const t of bundle.turns) {
32264
+ if (t.turn_id > max) max = t.turn_id;
32265
+ }
32266
+ return max;
32267
+ }
32268
+
31016
32269
  // src/dashboard/v1_1/wiring.ts
31017
32270
  var CapabilityErrorAgentController = class {
31018
32271
  fail(action) {
@@ -31051,6 +32304,14 @@ function buildV11Bindings(inputs) {
31051
32304
  let operatorChatService;
31052
32305
  if (inputs.storage && inputs.masterKey) {
31053
32306
  const chatStore = new OperatorChatStore(inputs.storage, inputs.masterKey);
32307
+ const conciergeMemory = new ConciergeMemoryStore({
32308
+ storage: inputs.storage,
32309
+ masterKey: inputs.masterKey,
32310
+ fortressId: inputs.fortressId,
32311
+ ...inputs.conciergeMemoryRetentionDays !== void 0 ? { retentionDays: inputs.conciergeMemoryRetentionDays } : {}
32312
+ });
32313
+ void conciergeMemory.pruneExpired().catch(() => {
32314
+ });
31054
32315
  operatorChatService = new OperatorChatService({
31055
32316
  store: chatStore,
31056
32317
  auditLog: inputs.auditLog,
@@ -31061,7 +32322,8 @@ function buildV11Bindings(inputs) {
31061
32322
  identityId: inputs.identityId,
31062
32323
  registry
31063
32324
  }),
31064
- conciergePiiFilter: buildConciergePiiFilter()
32325
+ conciergePiiFilter: buildConciergePiiFilter(),
32326
+ conciergeMemory
31065
32327
  });
31066
32328
  }
31067
32329
  const hubService = new HubService({
@@ -31253,13 +32515,13 @@ init_encryption();
31253
32515
  init_encoding();
31254
32516
  var INTELLIGENCE_NAMESPACE = "_intelligence";
31255
32517
  var SUBSTRATE_CONFIG_KEY = "substrate-config";
31256
- var HKDF_INFO2 = "intelligence-substrate-config";
32518
+ var HKDF_INFO3 = "intelligence-substrate-config";
31257
32519
  var IntelligenceConfigStore = class {
31258
32520
  storage;
31259
32521
  encryptionKey;
31260
32522
  constructor(storage, masterKey) {
31261
32523
  this.storage = storage;
31262
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
32524
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
31263
32525
  }
31264
32526
  /**
31265
32527
  * Load the operator's substrate config from disk. Returns the config
@@ -33398,7 +34660,9 @@ async function verifyExitBundle(bundleDir, options = {}) {
33398
34660
  );
33399
34661
  }
33400
34662
  }
33401
- const reputationFailed = reputation?.bundle_signature_valid === false || (reputation?.invalid_attestations ?? 0) > 0;
34663
+ const reputationBundleFailed = reputation?.bundle_signature_valid === false;
34664
+ const reputationAttestationFailed = (reputation?.invalid_attestations ?? 0) > 0;
34665
+ const reputationFailed = reputationBundleFailed || reputationAttestationFailed;
33402
34666
  const identityFailed = identity ? !identity.signature_valid : false;
33403
34667
  const unverifiableCount = reputation?.unverifiable_attestations ?? 0;
33404
34668
  const unverifiableFailed = unverifiableCount > 0 && !options.acceptUnverifiableAttestations;
@@ -33407,6 +34671,16 @@ async function verifyExitBundle(bundleDir, options = {}) {
33407
34671
  `${unverifiableCount} reputation attestation(s) have unknown signer public keys; pass --accept-unverifiable-attestations to import anyway`
33408
34672
  );
33409
34673
  }
34674
+ let detailedFailureClass;
34675
+ if (identityFailed) {
34676
+ detailedFailureClass = "identity_signature_invalid";
34677
+ } else if (reputationBundleFailed) {
34678
+ detailedFailureClass = "reputation_bundle_signature_invalid";
34679
+ } else if (reputationAttestationFailed) {
34680
+ detailedFailureClass = "reputation_attestation_signature_invalid";
34681
+ } else if (unverifiableFailed) {
34682
+ detailedFailureClass = "reputation_unverifiable_attestations";
34683
+ }
33410
34684
  return {
33411
34685
  version: "1.1",
33412
34686
  passed: !reputationFailed && !identityFailed && !unverifiableFailed,
@@ -33426,7 +34700,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
33426
34700
  identity,
33427
34701
  audit,
33428
34702
  reputation,
33429
- failure_class: reputationFailed || identityFailed || unverifiableFailed ? "other" : void 0
34703
+ failure_class: detailedFailureClass
33430
34704
  };
33431
34705
  }
33432
34706
 
@@ -33999,6 +35273,9 @@ async function importExitBundle(opts) {
33999
35273
  reputationArtifact?.json ?? null,
34000
35274
  manifest
34001
35275
  );
35276
+ if (!conflicts.public_identity_exists && identityArtifact?.json && opts.identityManager.getPrimaryIdentityId() !== null && opts.identityManager.getPrimaryIdentityId() !== identityArtifact.json.bundle.identity_id) {
35277
+ conflicts.public_identity_exists = true;
35278
+ }
34002
35279
  if (!opts.activate) {
34003
35280
  return {
34004
35281
  verified: true,
@@ -34025,7 +35302,7 @@ async function importExitBundle(opts) {
34025
35302
  if (conflicts.public_identity_exists && !opts.forceRebind) {
34026
35303
  throw new ExitBundleImportError(
34027
35304
  "IDENTITY_OVERWRITE_REFUSED",
34028
- "Importing this bundle would overwrite an existing fortress public identity. Pass forceRebind: true (CLI: --force-rebind) to confirm explicit replacement."
35305
+ "Importing this exit bundle would overwrite an existing fortress public identity (either the same identity already imported, or a different identity is currently active). Pass forceRebind: true (CLI: --force-rebind) to confirm explicit replacement."
34029
35306
  );
34030
35307
  }
34031
35308
  if (conflicts.public_identity_exists && opts.forceRebind && identityArtifact) {
@@ -34350,7 +35627,19 @@ async function runExitCommand(args) {
34350
35627
  }
34351
35628
  const config = await loadConfig();
34352
35629
  const ctx = await openExitContext(argv, env);
34353
- const policy = await loadPrincipalPolicy(ctx.storagePath);
35630
+ let policy;
35631
+ try {
35632
+ policy = await loadPrincipalPolicy(ctx.storagePath);
35633
+ } catch (policyErr) {
35634
+ if (policyErr instanceof MalformedPrincipalPolicyError) {
35635
+ write(err, `
35636
+ Sanctuary cannot proceed.
35637
+ ${policyErr.message}
35638
+ `);
35639
+ return 1;
35640
+ }
35641
+ throw policyErr;
35642
+ }
34354
35643
  const result = await exportExitBundle({
34355
35644
  bundleDir: outDir,
34356
35645
  storage: ctx.storage,
@@ -34383,6 +35672,26 @@ async function runExitCommand(args) {
34383
35672
  write(err, "Usage: sanctuary exit import <dir> [--activate]\n");
34384
35673
  return 2;
34385
35674
  }
35675
+ const bundleRoot = path.resolve(dir);
35676
+ try {
35677
+ await promises.access(bundleRoot);
35678
+ } catch {
35679
+ write(err, `Error: bundle directory not found: ${bundleRoot}
35680
+ `);
35681
+ return 1;
35682
+ }
35683
+ const manifestPath = path.join(bundleRoot, "manifest.json");
35684
+ try {
35685
+ const raw = await promises.readFile(manifestPath, "utf8");
35686
+ JSON.parse(raw);
35687
+ } catch {
35688
+ write(
35689
+ err,
35690
+ `Error: bundle manifest missing or malformed at ${manifestPath}
35691
+ `
35692
+ );
35693
+ return 1;
35694
+ }
34386
35695
  const activate = hasFlag(argv, "--activate");
34387
35696
  const forceRebind = hasFlag(argv, "--force-rebind");
34388
35697
  const acceptUnverifiableAttestations = hasFlag(
@@ -34523,11 +35832,11 @@ async function startDashboardServer(options) {
34523
35832
  }
34524
35833
  }
34525
35834
  });
34526
- await new Promise((resolve4, reject) => {
35835
+ await new Promise((resolve6, reject) => {
34527
35836
  server.once("error", reject);
34528
35837
  server.listen(port, host, () => {
34529
35838
  server.off("error", reject);
34530
- resolve4();
35839
+ resolve6();
34531
35840
  });
34532
35841
  });
34533
35842
  const actualPort = (() => {
@@ -34540,8 +35849,8 @@ async function startDashboardServer(options) {
34540
35849
  url,
34541
35850
  port: actualPort,
34542
35851
  host,
34543
- stop: () => new Promise((resolve4, reject) => {
34544
- server.close((err) => err ? reject(err) : resolve4());
35852
+ stop: () => new Promise((resolve6, reject) => {
35853
+ server.close((err) => err ? reject(err) : resolve6());
34545
35854
  }),
34546
35855
  publish,
34547
35856
  publishActivity: (entry) => publish({ type: "activity", data: entry }),
@@ -34982,7 +36291,19 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
34982
36291
  const profileStore = new SovereigntyProfileStore(storage, masterKey);
34983
36292
  await profileStore.load();
34984
36293
  const { tools: profileTools } = createSovereigntyProfileTools(profileStore, auditLog);
34985
- const policy = await loadPrincipalPolicy(config.storage_path);
36294
+ let policy;
36295
+ try {
36296
+ policy = await loadPrincipalPolicy(config.storage_path);
36297
+ } catch (err) {
36298
+ if (err instanceof MalformedPrincipalPolicyError) {
36299
+ console.error(`
36300
+ Sanctuary cannot start.
36301
+ ${err.message}
36302
+ `);
36303
+ process.exit(1);
36304
+ }
36305
+ throw err;
36306
+ }
34986
36307
  const baseline = new BaselineTracker(storage, masterKey);
34987
36308
  await baseline.load();
34988
36309
  let approvalChannel;
@@ -35080,6 +36401,21 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
35080
36401
  });
35081
36402
  } : void 0;
35082
36403
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
36404
+ const fortressIdForAggregator = fortressIdFromStoragePath(config.storage_path);
36405
+ const aggregatorIdentityId = identityManager.getPrimaryIdentityId() ?? `fortress:${config.storage_path}`;
36406
+ const approvalAggregator = new ApprovalAggregator({
36407
+ storage,
36408
+ masterKey,
36409
+ auditLog,
36410
+ identityId: aggregatorIdentityId,
36411
+ fortressId: fortressIdForAggregator
36412
+ });
36413
+ gate.setApprovalEventCallback((event) => {
36414
+ void approvalAggregator.ingest(event);
36415
+ });
36416
+ if (dashboard) {
36417
+ dashboard.setApprovalAggregator(approvalAggregator);
36418
+ }
35083
36419
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
35084
36420
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
35085
36421
  config,
@@ -35199,7 +36535,7 @@ Refusing to start the cocoon while the reset-history marker is unreadable.`
35199
36535
  clientManager.configure(enabledServers).catch((err) => {
35200
36536
  console.error(`[Sanctuary] Failed to configure upstream servers: ${err instanceof Error ? err.message : "unknown error"}`);
35201
36537
  });
35202
- await new Promise((resolve4) => setTimeout(resolve4, 2e3));
36538
+ await new Promise((resolve6) => setTimeout(resolve6, 2e3));
35203
36539
  const proxiedTools = proxyRouter.getProxiedTools();
35204
36540
  if (proxiedTools.length > 0) {
35205
36541
  allTools.push(...proxiedTools);
@@ -35274,6 +36610,7 @@ exports.HERO_COPY = HERO_COPY;
35274
36610
  exports.InMemoryModelProvenanceStore = InMemoryModelProvenanceStore;
35275
36611
  exports.InjectionDetector = InjectionDetector;
35276
36612
  exports.MODEL_PRESETS = MODEL_PRESETS;
36613
+ exports.MalformedPrincipalPolicyError = MalformedPrincipalPolicyError;
35277
36614
  exports.MemoryStorage = MemoryStorage;
35278
36615
  exports.PolicyStore = PolicyStore;
35279
36616
  exports.ProxyRouter = ProxyRouter;