switchroom 0.19.18 → 0.19.19

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.
Files changed (38) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +3392 -1569
  7. package/dist/host-control/main.js +12209 -11396
  8. package/dist/vault/approvals/kernel-server.js +60 -7
  9. package/dist/vault/broker/server.js +206 -76
  10. package/package.json +4 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/telegram-plugin/bridge/bridge.ts +14 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
  15. package/telegram-plugin/dist/server.js +13 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  17. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  18. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  19. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  20. package/telegram-plugin/gateway/store-file.ts +244 -0
  21. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  22. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  23. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  24. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  25. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  26. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  27. package/telegram-plugin/worker-activity-feed.ts +51 -1
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  29. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  30. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  31. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  32. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  37. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  38. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -18546,7 +18546,8 @@ var ApprovalDecisionMetaSchema = exports_external.object({
18546
18546
  ttl_expires_at: exports_external.number().nullable(),
18547
18547
  last_used_at: exports_external.number().nullable(),
18548
18548
  revoked_at: exports_external.number().nullable(),
18549
- revoke_reason: exports_external.string().nullable()
18549
+ revoke_reason: exports_external.string().nullable(),
18550
+ origin: exports_external.enum(["agent", "operator"]).optional()
18550
18551
  });
18551
18552
  var OkApprovalLookupResponseSchema = exports_external.object({
18552
18553
  ok: exports_external.literal(true),
@@ -18710,6 +18711,7 @@ function migrateApprovalSchema(db) {
18710
18711
  var DEFAULT_MAX_TTL_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
18711
18712
 
18712
18713
  // src/vault/approvals/kernel.ts
18714
+ var ALLOW_ONCE_CONSUMED_REASON = "allow_once_consumed";
18713
18715
  function generateRequestId() {
18714
18716
  return randomBytes(16).toString("hex");
18715
18717
  }
@@ -18857,6 +18859,38 @@ function evaluateDecisionRow(db, row, currentCanonical, opts, now) {
18857
18859
  if (decision.decision === "deny") {
18858
18860
  return { state: "denied", decision };
18859
18861
  }
18862
+ if (decision.decision === "allow_once") {
18863
+ const burn = db.run(`UPDATE approval_decisions
18864
+ SET last_used_at = ?, revoked_at = ?, revoke_reason = ?
18865
+ WHERE id = ? AND revoked_at IS NULL`, [now, now, ALLOW_ONCE_CONSUMED_REASON, decision.id]);
18866
+ if ((burn.changes ?? 0) === 0) {
18867
+ audit(db, "deny", {
18868
+ agent_unit: decision.agent_unit,
18869
+ scope: decision.scope,
18870
+ action: decision.action,
18871
+ decision_id: decision.id,
18872
+ context: { reason: ALLOW_ONCE_CONSUMED_REASON }
18873
+ });
18874
+ return { state: "denied", decision };
18875
+ }
18876
+ decision.last_used_at = now;
18877
+ decision.revoked_at = now;
18878
+ decision.revoke_reason = ALLOW_ONCE_CONSUMED_REASON;
18879
+ audit(db, "match", {
18880
+ agent_unit: decision.agent_unit,
18881
+ scope: decision.scope,
18882
+ action: decision.action,
18883
+ decision_id: decision.id
18884
+ });
18885
+ audit(db, "revoke", {
18886
+ agent_unit: decision.agent_unit,
18887
+ scope: decision.scope,
18888
+ action: decision.action,
18889
+ decision_id: decision.id,
18890
+ context: { actor: "kernel", reason: ALLOW_ONCE_CONSUMED_REASON }
18891
+ });
18892
+ return { state: "granted", decision };
18893
+ }
18860
18894
  if (decision.decision === "allow_ttl") {
18861
18895
  const maxLifetime = opts.max_ttl_lifetime_ms ?? DEFAULT_MAX_TTL_LIFETIME_MS;
18862
18896
  const hardCap = decision.granted_at + maxLifetime;
@@ -18925,7 +18959,8 @@ function consumeNonce(db, request_id, now = Date.now()) {
18925
18959
  }
18926
18960
  function recordDecision(db, input, now = Date.now()) {
18927
18961
  const id = randomUUID();
18928
- const ttl_expires_at = input.decision === "allow_ttl" && input.ttl_ms ? now + input.ttl_ms : null;
18962
+ const maxLifetime = input.max_ttl_lifetime_ms ?? DEFAULT_MAX_TTL_LIFETIME_MS;
18963
+ const ttl_expires_at = input.decision === "allow_ttl" && input.ttl_ms ? now + Math.min(input.ttl_ms, maxLifetime) : null;
18929
18964
  const canonical = canonicalizeApproverSet(input.approver_set);
18930
18965
  db.run(`INSERT INTO approval_decisions
18931
18966
  (id, agent_unit, scope, action, decision,
@@ -19123,6 +19158,7 @@ var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`
19123
19158
  var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
19124
19159
  var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
19125
19160
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
19161
+ var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
19126
19162
 
19127
19163
  // src/memory/hindsight.ts
19128
19164
  var DEFAULT_RETAIN_MISSION = "Extract durable facts that will still be true and useful weeks from now: " + "user preferences and standing rules, ongoing projects and recurring " + "commitments, technical and architectural decisions with their rationale, " + "and people/tool relationships. A preference revealed by a request is " + "durable — record the preference (what the user likes, wants, or always " + `does), not the request itself.
@@ -19385,6 +19421,9 @@ function handleConnection(socket, agent, db, isOperator = false) {
19385
19421
  }
19386
19422
  var KERNEL_OPERATOR_NAME = "operator";
19387
19423
  var OPERATOR_ALLOWED_OPS = new Set(["approval_list"]);
19424
+ function listenerOrigin(isOperator) {
19425
+ return isOperator ? "operator" : "agent";
19426
+ }
19388
19427
  function handleRequest(socket, req, agent, db, peerUid, isOperator = false) {
19389
19428
  if (isOperator && !OPERATOR_ALLOWED_OPS.has(req.op)) {
19390
19429
  socket.write(encodeResponse(errorResponse("DENIED", `kernel operator socket is read-only: '${req.op}' is not permitted (only ${[...OPERATOR_ALLOWED_OPS].join(", ")})`)));
@@ -19445,7 +19484,8 @@ function handleRequest(socket, req, agent, db, peerUid, isOperator = false) {
19445
19484
  ttl_expires_at: r.decision.ttl_expires_at,
19446
19485
  last_used_at: r.decision.last_used_at,
19447
19486
  revoked_at: r.decision.revoked_at,
19448
- revoke_reason: r.decision.revoke_reason
19487
+ revoke_reason: r.decision.revoke_reason,
19488
+ origin: r.decision.origin
19449
19489
  } : null;
19450
19490
  socket.write(encodeResponse({ ok: true, state: r.state, decision }));
19451
19491
  return;
@@ -19472,7 +19512,8 @@ function handleRequest(socket, req, agent, db, peerUid, isOperator = false) {
19472
19512
  ttl_expires_at: r.decision.ttl_expires_at,
19473
19513
  last_used_at: r.decision.last_used_at,
19474
19514
  revoked_at: r.decision.revoked_at,
19475
- revoke_reason: r.decision.revoke_reason
19515
+ revoke_reason: r.decision.revoke_reason,
19516
+ origin: r.decision.origin
19476
19517
  } : null;
19477
19518
  socket.write(encodeResponse({ ok: true, state: r.state, decision }));
19478
19519
  return;
@@ -19534,7 +19575,8 @@ function handleRequest(socket, req, agent, db, peerUid, isOperator = false) {
19534
19575
  decision: req.decision,
19535
19576
  approver_set: req.approver_set,
19536
19577
  granted_by_user_id: req.granted_by_user_id,
19537
- ttl_ms: req.ttl_ms ?? undefined
19578
+ ttl_ms: req.ttl_ms ?? undefined,
19579
+ origin: listenerOrigin(isOperator)
19538
19580
  });
19539
19581
  socket.write(encodeResponse({ ok: true, decision_id }));
19540
19582
  return;
@@ -19553,7 +19595,8 @@ function handleRequest(socket, req, agent, db, peerUid, isOperator = false) {
19553
19595
  decision: req.decision,
19554
19596
  approver_set: req.approver_set,
19555
19597
  granted_by_user_id: req.granted_by_user_id,
19556
- ttl_ms: req.ttl_ms ?? undefined
19598
+ ttl_ms: req.ttl_ms ?? undefined,
19599
+ origin: listenerOrigin(isOperator)
19557
19600
  });
19558
19601
  if (!res.consumed) {
19559
19602
  socket.write(encodeResponse({ ok: true, consumed: false }));
@@ -19571,7 +19614,16 @@ function handleRequest(socket, req, agent, db, peerUid, isOperator = false) {
19571
19614
  return;
19572
19615
  }
19573
19616
  if (req.op === "approval_list") {
19574
- const decisions = listDecisions(db, { agent_unit: req.agent_unit });
19617
+ let listFilter = { agent_unit: req.agent_unit };
19618
+ if (!isOperator) {
19619
+ const acl = checkApprovalAclByAgent(agent, req.agent_unit ?? agent);
19620
+ if (!acl.allow) {
19621
+ socket.write(encodeResponse(errorResponse("DENIED", acl.reason)));
19622
+ return;
19623
+ }
19624
+ listFilter = { agent_unit: agent };
19625
+ }
19626
+ const decisions = listDecisions(db, listFilter);
19575
19627
  const meta = decisions.map((d) => ({
19576
19628
  id: d.id,
19577
19629
  agent_unit: d.agent_unit,
@@ -19733,6 +19785,7 @@ if (import.meta.url === `file://${process.argv[1]}` && /(?:^|[/\\])(?:vault[/\\]
19733
19785
  export {
19734
19786
  openKernelDb,
19735
19787
  main,
19788
+ listenerOrigin,
19736
19789
  bootstrap,
19737
19790
  KERNEL_OPERATOR_NAME
19738
19791
  };
@@ -18222,7 +18222,7 @@ var require_lib = __commonJS((exports, module) => {
18222
18222
 
18223
18223
  // src/vault/broker/server.ts
18224
18224
  import * as net from "node:net";
18225
- import { mkdirSync as mkdirSync7, chmodSync as chmodSync5, chownSync, existsSync as existsSync12, readFileSync as readFileSync10, readdirSync as readdirSync4, statSync as statSync6, unlinkSync as unlinkSync6, writeFileSync as writeFileSync3, renameSync as renameSync8 } from "node:fs";
18225
+ import { mkdirSync as mkdirSync7, chmodSync as chmodSync5, chownSync, existsSync as existsSync12, readFileSync as readFileSync10, readdirSync as readdirSync5, statSync as statSync7, unlinkSync as unlinkSync6, writeFileSync as writeFileSync3, renameSync as renameSync8 } from "node:fs";
18226
18226
 
18227
18227
  // src/agents/compose.ts
18228
18228
  init_schema();
@@ -18831,6 +18831,7 @@ var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`
18831
18831
  var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
18832
18832
  var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
18833
18833
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
18834
+ var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
18834
18835
 
18835
18836
  // src/memory/hindsight.ts
18836
18837
  var DEFAULT_RETAIN_MISSION = "Extract durable facts that will still be true and useful weeks from now: " + "user preferences and standing rules, ongoing projects and recurring " + "commitments, technical and architectural decisions with their rationale, " + "and people/tool relationships. A preference revealed by a request is " + "durable — record the preference (what the user likes, wants, or always " + `does), not the request itself.
@@ -19546,7 +19547,8 @@ var ApprovalDecisionMetaSchema = exports_external.object({
19546
19547
  ttl_expires_at: exports_external.number().nullable(),
19547
19548
  last_used_at: exports_external.number().nullable(),
19548
19549
  revoked_at: exports_external.number().nullable(),
19549
- revoke_reason: exports_external.string().nullable()
19550
+ revoke_reason: exports_external.string().nullable(),
19551
+ origin: exports_external.enum(["agent", "operator"]).optional()
19550
19552
  });
19551
19553
  var OkApprovalLookupResponseSchema = exports_external.object({
19552
19554
  ok: exports_external.literal(true),
@@ -19745,9 +19747,9 @@ function migrateLegacyGrantsDbLocation(newDbPath = getGrantsDbPath(), deps) {
19745
19747
  var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
19746
19748
 
19747
19749
  // src/vault/broker/server.ts
19748
- import { dirname as dirname7, resolve as resolve9, basename as basename4 } from "node:path";
19750
+ import { dirname as dirname7, resolve as resolve9, basename as basename5 } from "node:path";
19749
19751
  import * as os3 from "node:os";
19750
- import * as path4 from "node:path";
19752
+ import * as path5 from "node:path";
19751
19753
 
19752
19754
  // src/vault/migrate-layout.ts
19753
19755
  import {
@@ -19981,9 +19983,9 @@ function readAutoUnlockFile(filePath) {
19981
19983
  var DEFAULT_AUTO_UNLOCK_PATH = "~/.switchroom/vault-auto-unlock";
19982
19984
 
19983
19985
  // src/vault/broker/audit-log.ts
19984
- import * as fs2 from "node:fs";
19986
+ import * as fs3 from "node:fs";
19985
19987
  import * as os2 from "node:os";
19986
- import * as path2 from "node:path";
19988
+ import * as path3 from "node:path";
19987
19989
 
19988
19990
  // src/util/audit-hashchain.ts
19989
19991
  import { createHash as createHash4 } from "node:crypto";
@@ -20100,76 +20102,107 @@ function safeAuditLogPath(requestedPath) {
20100
20102
  return cachedTmpAuditLog;
20101
20103
  }
20102
20104
 
20103
- // src/vault/broker/audit-log.ts
20104
- function failOpenStatePath(logPath) {
20105
- return `${logPath}.failopen`;
20106
- }
20107
- function readFailOpenState(statePath) {
20105
+ // src/util/log-rotation.ts
20106
+ import * as fs2 from "node:fs";
20107
+ import * as path2 from "node:path";
20108
+ function fsyncAndMeasure(snapshotPath, logPath, tag) {
20109
+ let step = "open";
20108
20110
  try {
20109
- const raw = fs2.readFileSync(statePath, "utf8");
20110
- const parsed = JSON.parse(raw);
20111
- const count = Number(parsed.failOpenCount);
20112
- return {
20113
- failOpenCount: Number.isFinite(count) && count > 0 ? count : 0,
20114
- lastFailureTs: typeof parsed.lastFailureTs === "string" ? parsed.lastFailureTs : undefined,
20115
- lastError: typeof parsed.lastError === "string" ? parsed.lastError : undefined
20116
- };
20117
- } catch {
20118
- return { failOpenCount: 0 };
20111
+ const fd = fs2.openSync(snapshotPath, "r");
20112
+ try {
20113
+ step = "fsync";
20114
+ fs2.fsyncSync(fd);
20115
+ step = "measure";
20116
+ const size = fs2.fstatSync(fd).size;
20117
+ step = "close";
20118
+ return size;
20119
+ } finally {
20120
+ fs2.closeSync(fd);
20121
+ }
20122
+ } catch (err) {
20123
+ process.stderr.write(`[${tag}] ERROR: could not ${step} snapshot ${snapshotPath}; leaving active log ${logPath} intact to avoid data loss: ${err.message}
20124
+ `);
20125
+ return null;
20119
20126
  }
20120
20127
  }
20121
- var DEFAULT_AUDIT_MAX_BYTES = 32 * 1024 * 1024;
20122
- var DEFAULT_AUDIT_MAX_FILES = 5;
20123
- function resolveRotation(opts) {
20124
- const envBytes = Number(process.env.SWITCHROOM_VAULT_AUDIT_MAX_BYTES);
20125
- const envFiles = Number(process.env.SWITCHROOM_VAULT_AUDIT_MAX_FILES);
20126
- const maxBytes = opts.maxBytes !== undefined && opts.maxBytes !== 0 ? opts.maxBytes : Number.isFinite(envBytes) && envBytes !== 0 ? envBytes : DEFAULT_AUDIT_MAX_BYTES;
20127
- const maxFiles = opts.maxFiles !== undefined && opts.maxFiles > 0 ? opts.maxFiles : Number.isFinite(envFiles) && envFiles > 0 ? envFiles : DEFAULT_AUDIT_MAX_FILES;
20128
- return { maxBytes, maxFiles };
20129
- }
20130
- function rotateAuditLog(logPath, maxFiles) {
20131
- const oldest = `${logPath}.${maxFiles}`;
20128
+ function rotateLogFile(logPath, maxFiles, tag, stillHeld) {
20129
+ const keep = Math.max(1, Math.floor(maxFiles));
20130
+ const held = (what) => {
20131
+ if (!stillHeld || stillHeld())
20132
+ return true;
20133
+ process.stderr.write(`[${tag}] WARN: rotation lock for ${logPath} was reclaimed while we were inside it; declining to ${what}
20134
+ `);
20135
+ return false;
20136
+ };
20137
+ let entryBytes;
20138
+ try {
20139
+ entryBytes = fs2.statSync(logPath).size;
20140
+ } catch (err) {
20141
+ process.stderr.write(`[${tag}] ERROR: could not stat active log ${logPath} before rotating it: ${err.message}
20142
+ `);
20143
+ return false;
20144
+ }
20145
+ const notRotatedUnderUs = (what) => {
20146
+ let live;
20147
+ try {
20148
+ live = fs2.statSync(logPath).size;
20149
+ } catch (err) {
20150
+ process.stderr.write(`[${tag}] ERROR: could not re-stat active log ${logPath}; declining to ${what}: ${err.message}
20151
+ `);
20152
+ return false;
20153
+ }
20154
+ if (live < entryBytes) {
20155
+ process.stderr.write(`[${tag}] WARN: active log ${logPath} shrank from ${entryBytes} to ${live} bytes while we were rotating it; another rotator snapshotted it and these generations are its, not ours — declining to ${what}
20156
+ `);
20157
+ return false;
20158
+ }
20159
+ return true;
20160
+ };
20161
+ const oldest = `${logPath}.${keep}`;
20132
20162
  if (fs2.existsSync(oldest)) {
20163
+ if (!held(`drop ${oldest}`))
20164
+ return false;
20165
+ if (!notRotatedUnderUs(`drop ${oldest}`))
20166
+ return false;
20133
20167
  try {
20134
20168
  fs2.unlinkSync(oldest);
20135
20169
  } catch (err) {
20136
- process.stderr.write(`[vault-audit] ERROR: could not drop oldest rotation ${oldest}: ${err.message}
20170
+ process.stderr.write(`[${tag}] ERROR: could not drop oldest rotation ${oldest}: ${err.message}
20137
20171
  `);
20138
20172
  }
20139
20173
  }
20140
- for (let n = maxFiles - 1;n >= 1; n--) {
20174
+ for (let n = keep - 1;n >= 1; n--) {
20141
20175
  const from = `${logPath}.${n}`;
20142
20176
  const to = `${logPath}.${n + 1}`;
20143
20177
  if (!fs2.existsSync(from))
20144
20178
  continue;
20179
+ if (!held(`shift ${from} → ${to}`))
20180
+ return false;
20181
+ if (!notRotatedUnderUs(`shift ${from} → ${to}`))
20182
+ return false;
20145
20183
  try {
20146
20184
  fs2.renameSync(from, to);
20147
20185
  } catch (err) {
20148
- process.stderr.write(`[vault-audit] ERROR: could not rotate ${from} → ${to}: ${err.message}
20186
+ process.stderr.write(`[${tag}] ERROR: could not rotate ${from} → ${to}: ${err.message}
20149
20187
  `);
20150
- return;
20188
+ return false;
20151
20189
  }
20152
20190
  }
20153
20191
  const snapshotPath = `${logPath}.1`;
20192
+ if (!held(`overwrite ${snapshotPath}`))
20193
+ return false;
20194
+ if (!notRotatedUnderUs(`overwrite ${snapshotPath}`))
20195
+ return false;
20154
20196
  try {
20155
20197
  fs2.copyFileSync(logPath, snapshotPath);
20156
20198
  } catch (err) {
20157
- process.stderr.write(`[vault-audit] ERROR: could not snapshot active audit log ${logPath} → ${snapshotPath}: ${err.message}
20199
+ process.stderr.write(`[${tag}] ERROR: could not snapshot active log ${logPath} → ${snapshotPath}: ${err.message}
20158
20200
  `);
20159
- return;
20160
- }
20161
- try {
20162
- const fd = fs2.openSync(snapshotPath, "r");
20163
- try {
20164
- fs2.fsyncSync(fd);
20165
- } finally {
20166
- fs2.closeSync(fd);
20167
- }
20168
- } catch (err) {
20169
- process.stderr.write(`[vault-audit] ERROR: could not fsync audit snapshot ${snapshotPath}; leaving active log intact to avoid data loss: ${err.message}
20170
- `);
20171
- return;
20201
+ return false;
20172
20202
  }
20203
+ const snapshotBytes = fsyncAndMeasure(snapshotPath, logPath, tag);
20204
+ if (snapshotBytes === null)
20205
+ return false;
20173
20206
  try {
20174
20207
  const dirFd = fs2.openSync(path2.dirname(snapshotPath), "r");
20175
20208
  try {
@@ -20178,15 +20211,78 @@ function rotateAuditLog(logPath, maxFiles) {
20178
20211
  fs2.closeSync(dirFd);
20179
20212
  }
20180
20213
  } catch {}
20214
+ if (!held(`truncate ${logPath}`))
20215
+ return false;
20216
+ let liveBytes;
20217
+ try {
20218
+ liveBytes = fs2.statSync(logPath).size;
20219
+ } catch (err) {
20220
+ process.stderr.write(`[${tag}] ERROR: could not re-stat active log ${logPath} before truncating; leaving it intact to avoid data loss: ${err.message}
20221
+ `);
20222
+ return false;
20223
+ }
20224
+ if (liveBytes < snapshotBytes) {
20225
+ process.stderr.write(`[${tag}] WARN: active log ${logPath} shrank from ${snapshotBytes} to ${liveBytes} bytes after we snapshotted it; another rotator truncated it and these rows are its, not ours — declining to truncate
20226
+ `);
20227
+ return false;
20228
+ }
20181
20229
  try {
20182
20230
  fs2.truncateSync(logPath, 0);
20183
20231
  } catch (err) {
20184
- process.stderr.write(`[vault-audit] ERROR: could not truncate active audit log ${logPath}: ${err.message}
20232
+ process.stderr.write(`[${tag}] ERROR: could not truncate active log ${logPath}: ${err.message}
20233
+ `);
20234
+ return false;
20235
+ }
20236
+ if (stillHeld && !stillHeld()) {
20237
+ process.stderr.write(`[${tag}] ERROR: truncated ${logPath} after our rotation lock was reclaimed; rows another rotator wrote between our size check and our truncate are LOST
20185
20238
  `);
20186
20239
  }
20240
+ return true;
20241
+ }
20242
+ function resolveRotationConfig(args) {
20243
+ const env = args.env ?? process.env;
20244
+ const envBytes = Number(env[args.envBytesVar]);
20245
+ const envFiles = Number(env[args.envFilesVar]);
20246
+ const maxBytes = args.maxBytes !== undefined && args.maxBytes !== 0 ? args.maxBytes : Number.isFinite(envBytes) && envBytes !== 0 ? envBytes : args.defaultBytes;
20247
+ const maxFiles = args.maxFiles !== undefined && args.maxFiles > 0 ? args.maxFiles : Number.isFinite(envFiles) && envFiles > 0 ? envFiles : args.defaultFiles;
20248
+ return { maxBytes, maxFiles };
20249
+ }
20250
+
20251
+ // src/vault/broker/audit-log.ts
20252
+ function failOpenStatePath(logPath) {
20253
+ return `${logPath}.failopen`;
20254
+ }
20255
+ function readFailOpenState(statePath) {
20256
+ try {
20257
+ const raw = fs3.readFileSync(statePath, "utf8");
20258
+ const parsed = JSON.parse(raw);
20259
+ const count = Number(parsed.failOpenCount);
20260
+ return {
20261
+ failOpenCount: Number.isFinite(count) && count > 0 ? count : 0,
20262
+ lastFailureTs: typeof parsed.lastFailureTs === "string" ? parsed.lastFailureTs : undefined,
20263
+ lastError: typeof parsed.lastError === "string" ? parsed.lastError : undefined
20264
+ };
20265
+ } catch {
20266
+ return { failOpenCount: 0 };
20267
+ }
20268
+ }
20269
+ var DEFAULT_AUDIT_MAX_BYTES = 32 * 1024 * 1024;
20270
+ var DEFAULT_AUDIT_MAX_FILES = 5;
20271
+ function resolveRotation(opts) {
20272
+ return resolveRotationConfig({
20273
+ maxBytes: opts.maxBytes,
20274
+ maxFiles: opts.maxFiles,
20275
+ envBytesVar: "SWITCHROOM_VAULT_AUDIT_MAX_BYTES",
20276
+ envFilesVar: "SWITCHROOM_VAULT_AUDIT_MAX_FILES",
20277
+ defaultBytes: DEFAULT_AUDIT_MAX_BYTES,
20278
+ defaultFiles: DEFAULT_AUDIT_MAX_FILES
20279
+ });
20280
+ }
20281
+ function rotateAuditLog(logPath, maxFiles) {
20282
+ rotateLogFile(logPath, maxFiles, "vault-audit");
20187
20283
  }
20188
20284
  function defaultAuditLogPath() {
20189
- return path2.join(os2.homedir(), ".switchroom", "vault-audit.log");
20285
+ return path3.join(os2.homedir(), ".switchroom", "vault-audit.log");
20190
20286
  }
20191
20287
  function callerFromPeer(peer) {
20192
20288
  if (peer.systemdUnit !== null && peer.systemdUnit.length > 0) {
@@ -20208,7 +20304,7 @@ function createAuditLogger(opts = {}) {
20208
20304
  lastError: String(err.message).slice(0, 300)
20209
20305
  };
20210
20306
  try {
20211
- fs2.writeFileSync(statePath, `${JSON.stringify(state)}
20307
+ fs3.writeFileSync(statePath, `${JSON.stringify(state)}
20212
20308
  `, { mode: 384 });
20213
20309
  } catch (persistErr) {
20214
20310
  process.stderr.write(`[vault-audit] ERROR: could not persist fail-open counter ${statePath}: ${persistErr.message}
@@ -20222,7 +20318,7 @@ function createAuditLogger(opts = {}) {
20222
20318
  write(entry) {
20223
20319
  if (rotation.maxBytes > 0) {
20224
20320
  try {
20225
- const size = fs2.statSync(logPath).size;
20321
+ const size = fs3.statSync(logPath).size;
20226
20322
  if (size >= rotation.maxBytes) {
20227
20323
  rotateAuditLog(logPath, rotation.maxFiles);
20228
20324
  }
@@ -20231,7 +20327,7 @@ function createAuditLogger(opts = {}) {
20231
20327
  const { line, next } = chainRow(chain, entry);
20232
20328
  let fd;
20233
20329
  try {
20234
- fd = fs2.openSync(logPath, "a", 384);
20330
+ fd = fs3.openSync(logPath, "a", 384);
20235
20331
  } catch (err) {
20236
20332
  process.stderr.write(`[vault-audit] ERROR: could not open audit log ${logPath}: ${err.message}
20237
20333
  `);
@@ -20240,7 +20336,7 @@ function createAuditLogger(opts = {}) {
20240
20336
  }
20241
20337
  let durable = false;
20242
20338
  try {
20243
- fs2.writeSync(fd, line);
20339
+ fs3.writeSync(fd, line);
20244
20340
  chain = next;
20245
20341
  durable = true;
20246
20342
  } catch (err) {
@@ -20249,7 +20345,7 @@ function createAuditLogger(opts = {}) {
20249
20345
  recordFailOpen(err);
20250
20346
  } finally {
20251
20347
  try {
20252
- fs2.closeSync(fd);
20348
+ fs3.closeSync(fd);
20253
20349
  } catch (closeErr) {
20254
20350
  process.stderr.write(`[vault-audit] ERROR: could not close audit log fd: ${closeErr.message}
20255
20351
  `);
@@ -22168,8 +22264,8 @@ function adminOnlyKeysBeingAdded(requestedKeys, existingKeys, patterns) {
22168
22264
  }
22169
22265
 
22170
22266
  // src/vault/grants-db.ts
22171
- import * as path3 from "node:path";
22172
- import * as fs3 from "node:fs";
22267
+ import * as path4 from "node:path";
22268
+ import * as fs4 from "node:fs";
22173
22269
  import { Database } from "bun:sqlite";
22174
22270
 
22175
22271
  // src/vault/approvals/schema.ts
@@ -22262,12 +22358,12 @@ function isGrantsDbCorruption(err) {
22262
22358
  function quarantineGrantsDb(dbPath) {
22263
22359
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
22264
22360
  const quarantinePath = `${dbPath}.corrupt-${stamp}`;
22265
- fs3.renameSync(dbPath, quarantinePath);
22361
+ fs4.renameSync(dbPath, quarantinePath);
22266
22362
  for (const suffix of WAL_SIDECAR_SUFFIXES2) {
22267
22363
  const sidecar = `${dbPath}${suffix}`;
22268
- if (fs3.existsSync(sidecar)) {
22364
+ if (fs4.existsSync(sidecar)) {
22269
22365
  try {
22270
- fs3.renameSync(sidecar, `${quarantinePath}${suffix}`);
22366
+ fs4.renameSync(sidecar, `${quarantinePath}${suffix}`);
22271
22367
  } catch {}
22272
22368
  }
22273
22369
  }
@@ -22277,7 +22373,7 @@ function openAndMigrate(dbPath) {
22277
22373
  const db = new Database(dbPath, { create: true });
22278
22374
  try {
22279
22375
  try {
22280
- fs3.chmodSync(dbPath, 384);
22376
+ fs4.chmodSync(dbPath, 384);
22281
22377
  } catch {}
22282
22378
  db.run("PRAGMA journal_mode=WAL");
22283
22379
  db.run("PRAGMA busy_timeout=5000");
@@ -22293,8 +22389,8 @@ function openAndMigrate(dbPath) {
22293
22389
  return db;
22294
22390
  }
22295
22391
  function openGrantsDb(dbPath = DEFAULT_GRANTS_DB_PATH, opener = openAndMigrate) {
22296
- const dir = path3.dirname(dbPath);
22297
- fs3.mkdirSync(dir, { recursive: true });
22392
+ const dir = path4.dirname(dbPath);
22393
+ fs4.mkdirSync(dir, { recursive: true });
22298
22394
  migrateLegacyGrantsDbLocation(dbPath);
22299
22395
  try {
22300
22396
  return opener(dbPath);
@@ -22318,6 +22414,7 @@ function canonicalizeApproverSet(approvers) {
22318
22414
  }
22319
22415
 
22320
22416
  // src/vault/approvals/kernel.ts
22417
+ var ALLOW_ONCE_CONSUMED_REASON = "allow_once_consumed";
22321
22418
  function generateRequestId() {
22322
22419
  return randomBytes5(16).toString("hex");
22323
22420
  }
@@ -22444,6 +22541,38 @@ function evaluateDecisionRow(db, row, currentCanonical, opts, now) {
22444
22541
  if (decision.decision === "deny") {
22445
22542
  return { state: "denied", decision };
22446
22543
  }
22544
+ if (decision.decision === "allow_once") {
22545
+ const burn = db.run(`UPDATE approval_decisions
22546
+ SET last_used_at = ?, revoked_at = ?, revoke_reason = ?
22547
+ WHERE id = ? AND revoked_at IS NULL`, [now, now, ALLOW_ONCE_CONSUMED_REASON, decision.id]);
22548
+ if ((burn.changes ?? 0) === 0) {
22549
+ audit(db, "deny", {
22550
+ agent_unit: decision.agent_unit,
22551
+ scope: decision.scope,
22552
+ action: decision.action,
22553
+ decision_id: decision.id,
22554
+ context: { reason: ALLOW_ONCE_CONSUMED_REASON }
22555
+ });
22556
+ return { state: "denied", decision };
22557
+ }
22558
+ decision.last_used_at = now;
22559
+ decision.revoked_at = now;
22560
+ decision.revoke_reason = ALLOW_ONCE_CONSUMED_REASON;
22561
+ audit(db, "match", {
22562
+ agent_unit: decision.agent_unit,
22563
+ scope: decision.scope,
22564
+ action: decision.action,
22565
+ decision_id: decision.id
22566
+ });
22567
+ audit(db, "revoke", {
22568
+ agent_unit: decision.agent_unit,
22569
+ scope: decision.scope,
22570
+ action: decision.action,
22571
+ decision_id: decision.id,
22572
+ context: { actor: "kernel", reason: ALLOW_ONCE_CONSUMED_REASON }
22573
+ });
22574
+ return { state: "granted", decision };
22575
+ }
22447
22576
  if (decision.decision === "allow_ttl") {
22448
22577
  const maxLifetime = opts.max_ttl_lifetime_ms ?? DEFAULT_MAX_TTL_LIFETIME_MS;
22449
22578
  const hardCap = decision.granted_at + maxLifetime;
@@ -22512,7 +22641,8 @@ function consumeNonce(db, request_id, now = Date.now()) {
22512
22641
  }
22513
22642
  function recordDecision(db, input, now = Date.now()) {
22514
22643
  const id = randomUUID();
22515
- const ttl_expires_at = input.decision === "allow_ttl" && input.ttl_ms ? now + input.ttl_ms : null;
22644
+ const maxLifetime = input.max_ttl_lifetime_ms ?? DEFAULT_MAX_TTL_LIFETIME_MS;
22645
+ const ttl_expires_at = input.decision === "allow_ttl" && input.ttl_ms ? now + Math.min(input.ttl_ms, maxLifetime) : null;
22516
22646
  const canonical = canonicalizeApproverSet(input.approver_set);
22517
22647
  db.run(`INSERT INTO approval_decisions
22518
22648
  (id, agent_unit, scope, action, decision,
@@ -23827,10 +23957,10 @@ class VaultBroker {
23827
23957
  return;
23828
23958
  }
23829
23959
  try {
23830
- const agentsDir = this.config ? resolveAgentsDir(this.config) : path4.join(os3.homedir(), ".switchroom", "agents");
23831
- const tokenDir = path4.join(agentsDir, agent);
23960
+ const agentsDir = this.config ? resolveAgentsDir(this.config) : path5.join(os3.homedir(), ".switchroom", "agents");
23961
+ const tokenDir = path5.join(agentsDir, agent);
23832
23962
  mkdirSync7(tokenDir, { recursive: true });
23833
- const tokenPath = path4.join(tokenDir, ".vault-token");
23963
+ const tokenPath = path5.join(tokenDir, ".vault-token");
23834
23964
  const tmpPath = `${tokenPath}.tmp.${process.pid}`;
23835
23965
  writeFileSync3(tmpPath, mintResult.token, { mode: 384 });
23836
23966
  renameSync8(tmpPath, tokenPath);
@@ -23892,8 +24022,8 @@ class VaultBroker {
23892
24022
  try {
23893
24023
  const row = this.grantsDb.query("SELECT agent_slug FROM vault_grants WHERE id = ?").get(id);
23894
24024
  if (row && AgentNameSchema.safeParse(row.agent_slug).success) {
23895
- const agentsDir = this.config ? resolveAgentsDir(this.config) : path4.join(os3.homedir(), ".switchroom", "agents");
23896
- const tokenPath = path4.join(agentsDir, row.agent_slug, ".vault-token");
24025
+ const agentsDir = this.config ? resolveAgentsDir(this.config) : path5.join(os3.homedir(), ".switchroom", "agents");
24026
+ const tokenPath = path5.join(agentsDir, row.agent_slug, ".vault-token");
23897
24027
  if (existsSync12(tokenPath)) {
23898
24028
  try {
23899
24029
  unlinkSync6(tokenPath);
@@ -24135,7 +24265,7 @@ class VaultBroker {
24135
24265
  if (!existsSync12(filePath))
24136
24266
  return false;
24137
24267
  try {
24138
- if (statSync6(filePath).size === 0)
24268
+ if (statSync7(filePath).size === 0)
24139
24269
  return false;
24140
24270
  } catch {
24141
24271
  return false;
@@ -24214,12 +24344,12 @@ class VaultBroker {
24214
24344
  }
24215
24345
  function detectVaultLayoutDrift(vaultPath) {
24216
24346
  const dir = dirname7(vaultPath);
24217
- if (basename4(dir) !== "vault")
24347
+ if (basename5(dir) !== "vault")
24218
24348
  return;
24219
- if (basename4(vaultPath) !== "vault.enc")
24349
+ if (basename5(vaultPath) !== "vault.enc")
24220
24350
  return;
24221
24351
  const switchroomDir = dirname7(dir);
24222
- if (basename4(switchroomDir) !== ".switchroom")
24352
+ if (basename5(switchroomDir) !== ".switchroom")
24223
24353
  return;
24224
24354
  const home2 = dirname7(switchroomDir);
24225
24355
  const result = inspectVaultLayout(home2);
@@ -24248,7 +24378,7 @@ async function main() {
24248
24378
  let perAgentTargets = [];
24249
24379
  try {
24250
24380
  if (existsSync12(perAgentDir)) {
24251
- const entries = readdirSync4(perAgentDir, { withFileTypes: true });
24381
+ const entries = readdirSync5(perAgentDir, { withFileTypes: true });
24252
24382
  const flat = [];
24253
24383
  const subdirs = [];
24254
24384
  for (const e of entries) {