switchroom 0.19.17 → 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 (91) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +13 -4
  4. package/dist/auth-broker/index.js +32 -5
  5. package/dist/cli/drive-write-pretool.mjs +48 -5
  6. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  7. package/dist/cli/notion-write-pretool.mjs +13 -4
  8. package/dist/cli/switchroom.js +10614 -8104
  9. package/dist/host-control/main.js +12849 -11446
  10. package/dist/vault/approvals/kernel-server.js +90 -12
  11. package/dist/vault/broker/server.js +277 -94
  12. package/package.json +5 -3
  13. package/profiles/_base/start.sh.hbs +69 -5
  14. package/profiles/coding/CLAUDE.md.hbs +1 -1
  15. package/profiles/default/CLAUDE.md.hbs +3 -3
  16. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  17. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  18. package/skills/mental-model-curator/SKILL.md +8 -6
  19. package/telegram-plugin/bridge/bridge.ts +25 -19
  20. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +28 -20
  22. package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
  23. package/telegram-plugin/dist/server.js +32 -20
  24. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  25. package/telegram-plugin/gateway/boot-card.ts +5 -1
  26. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  27. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  28. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  30. package/telegram-plugin/gateway/gateway.ts +43 -1
  31. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  32. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  33. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  34. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  35. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  36. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  37. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  38. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  39. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  40. package/telegram-plugin/gateway/store-file.ts +244 -0
  41. package/telegram-plugin/gateway/stream-render.ts +24 -5
  42. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  43. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  44. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  45. package/telegram-plugin/registry/turns-schema.ts +40 -12
  46. package/telegram-plugin/runtime-metrics.ts +14 -0
  47. package/telegram-plugin/silence-poke.ts +138 -0
  48. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  49. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  51. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  52. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  53. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  54. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  56. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  57. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  58. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  59. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  60. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  61. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  62. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  63. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  64. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  66. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  67. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  68. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  69. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  70. package/telegram-plugin/tts-normalize.ts +6 -4
  71. package/telegram-plugin/voice-normalize-text.ts +168 -11
  72. package/telegram-plugin/worker-activity-feed.ts +51 -1
  73. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  74. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  75. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  76. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  77. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  78. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  79. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  81. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  82. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  84. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  85. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  87. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  89. package/vendor/hindsight-memory/settings.json +1 -1
  90. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  91. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -4013,7 +4013,7 @@ var init_zod = __esm(() => {
4013
4013
  });
4014
4014
 
4015
4015
  // src/config/schema.ts
4016
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
4016
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
4017
4017
  var init_schema = __esm(() => {
4018
4018
  init_zod();
4019
4019
  CodeRepoEntrySchema = exports_external.object({
@@ -4164,7 +4164,7 @@ var init_schema = __esm(() => {
4164
4164
  recall: exports_external.object({
4165
4165
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
4166
4166
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
4167
- min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.10–0.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
4167
+ min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt — NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default — current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall — on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
4168
4168
  types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
4169
4169
  additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
4170
4170
  sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
@@ -4476,9 +4476,16 @@ var init_schema = __esm(() => {
4476
4476
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
4477
4477
  emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
4478
4478
  }).optional();
4479
- ReleaseBlock = exports_external.object({
4479
+ releaseBlockFields = {
4480
4480
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
4481
4481
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
4482
+ };
4483
+ ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
4484
+ message: "release.channel and release.pin are mutually exclusive"
4485
+ });
4486
+ RootReleaseBlock = exports_external.object({
4487
+ ...releaseBlockFields,
4488
+ auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false — with it " + "unset/false, behaviour is unchanged.")
4482
4489
  }).strict().refine((r) => !(r.channel && r.pin), {
4483
4490
  message: "release.channel and release.pin are mutually exclusive"
4484
4491
  });
@@ -4722,6 +4729,7 @@ var init_schema = __esm(() => {
4722
4729
  enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
4723
4730
  interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
4724
4731
  apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
4732
+ notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
4725
4733
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
4726
4734
  });
4727
4735
  HostControlConfigSchema = exports_external.object({
@@ -4764,7 +4772,7 @@ var init_schema = __esm(() => {
4764
4772
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
4765
4773
  }),
4766
4774
  telegram: TelegramConfigSchema,
4767
- release: ReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely."),
4775
+ release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only — a fleet property read by " + "hostd's release watcher, never per-agent)."),
4768
4776
  memory: MemoryBackendConfigSchema.optional(),
4769
4777
  hindsight: HindsightConfigSchema.optional().describe("Fleet-singleton Hindsight (memory backend) configuration. Currently " + "just the LLM knob (provider + model) used for retain/reflect/" + "consolidation. Both fields optional; when unset the container falls " + "back to provider=claude-code + the hard-coded HINDSIGHT_DEFAULT_MODEL " + "so nothing changes for operators who don't set it. Read at container " + "launch by startHindsight() (src/setup/hindsight.ts) — takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
4770
4778
  vault: VaultConfigSchema.optional(),
@@ -18214,7 +18222,7 @@ var require_lib = __commonJS((exports, module) => {
18214
18222
 
18215
18223
  // src/vault/broker/server.ts
18216
18224
  import * as net from "node:net";
18217
- 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";
18218
18226
 
18219
18227
  // src/agents/compose.ts
18220
18228
  init_schema();
@@ -18823,9 +18831,26 @@ var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`
18823
18831
  var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
18824
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)';
18825
18833
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
18834
+ var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
18826
18835
 
18827
18836
  // src/memory/hindsight.ts
18828
- var DEFAULT_RETAIN_MISSION = "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made.";
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.
18838
+
18839
+ ` + `NEVER extract:
18840
+ ` + "- Agent tool-use traces or narration of what the assistant did (e.g. " + `"the assistant used X to query Y", "ran a search", "sent the message").
18841
+ ` + "- In-flight workflow/process narration (a sub-task started, paused, or is " + "still running) — retain the outcome only once the task completes or a " + `decision is made.
18842
+ ` + `- Operation, request, batch or session IDs, UUIDs, hashes, or error codes.
18843
+ ` + "- Hindsight's own errors, retries, backlogs, or internal state — the " + `memory system's self-reports are not memories.
18844
+ ` + `- Restatements of the user's current request or the task in progress.
18845
+ ` + "- Transient state (unread counts, build status, what is running right now) " + "unless the fact is explicitly dated, in which case record it as a dated " + `observation.
18846
+ ` + `- Greetings, acknowledgements, and routine operational chatter.
18847
+
18848
+ ` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list.";
18849
+ var SUPERSEDED_RETAIN_MISSIONS = [
18850
+ "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
18851
+ "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.",
18852
+ "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made."
18853
+ ];
18829
18854
  var PROFILE_MEMORY_DEFAULTS = {
18830
18855
  "health-coach": {
18831
18856
  disposition: { skepticism: 2, literalism: 2, empathy: 5 },
@@ -19522,7 +19547,8 @@ var ApprovalDecisionMetaSchema = exports_external.object({
19522
19547
  ttl_expires_at: exports_external.number().nullable(),
19523
19548
  last_used_at: exports_external.number().nullable(),
19524
19549
  revoked_at: exports_external.number().nullable(),
19525
- revoke_reason: exports_external.string().nullable()
19550
+ revoke_reason: exports_external.string().nullable(),
19551
+ origin: exports_external.enum(["agent", "operator"]).optional()
19526
19552
  });
19527
19553
  var OkApprovalLookupResponseSchema = exports_external.object({
19528
19554
  ok: exports_external.literal(true),
@@ -19634,6 +19660,7 @@ init_paths();
19634
19660
  // src/agents/scaffold.ts
19635
19661
  var REPO_ROOT = resolve6(import.meta.dirname, "../..");
19636
19662
  var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
19663
+ var pendingBankOps = new Set;
19637
19664
  var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
19638
19665
  "permissions",
19639
19666
  "mcpServers",
@@ -19720,9 +19747,9 @@ function migrateLegacyGrantsDbLocation(newDbPath = getGrantsDbPath(), deps) {
19720
19747
  var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
19721
19748
 
19722
19749
  // src/vault/broker/server.ts
19723
- 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";
19724
19751
  import * as os3 from "node:os";
19725
- import * as path4 from "node:path";
19752
+ import * as path5 from "node:path";
19726
19753
 
19727
19754
  // src/vault/migrate-layout.ts
19728
19755
  import {
@@ -19956,9 +19983,9 @@ function readAutoUnlockFile(filePath) {
19956
19983
  var DEFAULT_AUTO_UNLOCK_PATH = "~/.switchroom/vault-auto-unlock";
19957
19984
 
19958
19985
  // src/vault/broker/audit-log.ts
19959
- import * as fs2 from "node:fs";
19986
+ import * as fs3 from "node:fs";
19960
19987
  import * as os2 from "node:os";
19961
- import * as path2 from "node:path";
19988
+ import * as path3 from "node:path";
19962
19989
 
19963
19990
  // src/util/audit-hashchain.ts
19964
19991
  import { createHash as createHash4 } from "node:crypto";
@@ -20075,76 +20102,107 @@ function safeAuditLogPath(requestedPath) {
20075
20102
  return cachedTmpAuditLog;
20076
20103
  }
20077
20104
 
20078
- // src/vault/broker/audit-log.ts
20079
- function failOpenStatePath(logPath) {
20080
- return `${logPath}.failopen`;
20081
- }
20082
- 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";
20083
20110
  try {
20084
- const raw = fs2.readFileSync(statePath, "utf8");
20085
- const parsed = JSON.parse(raw);
20086
- const count = Number(parsed.failOpenCount);
20087
- return {
20088
- failOpenCount: Number.isFinite(count) && count > 0 ? count : 0,
20089
- lastFailureTs: typeof parsed.lastFailureTs === "string" ? parsed.lastFailureTs : undefined,
20090
- lastError: typeof parsed.lastError === "string" ? parsed.lastError : undefined
20091
- };
20092
- } catch {
20093
- 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;
20094
20126
  }
20095
20127
  }
20096
- var DEFAULT_AUDIT_MAX_BYTES = 32 * 1024 * 1024;
20097
- var DEFAULT_AUDIT_MAX_FILES = 5;
20098
- function resolveRotation(opts) {
20099
- const envBytes = Number(process.env.SWITCHROOM_VAULT_AUDIT_MAX_BYTES);
20100
- const envFiles = Number(process.env.SWITCHROOM_VAULT_AUDIT_MAX_FILES);
20101
- const maxBytes = opts.maxBytes !== undefined && opts.maxBytes !== 0 ? opts.maxBytes : Number.isFinite(envBytes) && envBytes !== 0 ? envBytes : DEFAULT_AUDIT_MAX_BYTES;
20102
- const maxFiles = opts.maxFiles !== undefined && opts.maxFiles > 0 ? opts.maxFiles : Number.isFinite(envFiles) && envFiles > 0 ? envFiles : DEFAULT_AUDIT_MAX_FILES;
20103
- return { maxBytes, maxFiles };
20104
- }
20105
- function rotateAuditLog(logPath, maxFiles) {
20106
- 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}`;
20107
20162
  if (fs2.existsSync(oldest)) {
20163
+ if (!held(`drop ${oldest}`))
20164
+ return false;
20165
+ if (!notRotatedUnderUs(`drop ${oldest}`))
20166
+ return false;
20108
20167
  try {
20109
20168
  fs2.unlinkSync(oldest);
20110
20169
  } catch (err) {
20111
- 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}
20112
20171
  `);
20113
20172
  }
20114
20173
  }
20115
- for (let n = maxFiles - 1;n >= 1; n--) {
20174
+ for (let n = keep - 1;n >= 1; n--) {
20116
20175
  const from = `${logPath}.${n}`;
20117
20176
  const to = `${logPath}.${n + 1}`;
20118
20177
  if (!fs2.existsSync(from))
20119
20178
  continue;
20179
+ if (!held(`shift ${from} → ${to}`))
20180
+ return false;
20181
+ if (!notRotatedUnderUs(`shift ${from} → ${to}`))
20182
+ return false;
20120
20183
  try {
20121
20184
  fs2.renameSync(from, to);
20122
20185
  } catch (err) {
20123
- 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}
20124
20187
  `);
20125
- return;
20188
+ return false;
20126
20189
  }
20127
20190
  }
20128
20191
  const snapshotPath = `${logPath}.1`;
20192
+ if (!held(`overwrite ${snapshotPath}`))
20193
+ return false;
20194
+ if (!notRotatedUnderUs(`overwrite ${snapshotPath}`))
20195
+ return false;
20129
20196
  try {
20130
20197
  fs2.copyFileSync(logPath, snapshotPath);
20131
20198
  } catch (err) {
20132
- process.stderr.write(`[vault-audit] ERROR: could not snapshot active audit log ${logPath} → ${snapshotPath}: ${err.message}
20133
- `);
20134
- return;
20135
- }
20136
- try {
20137
- const fd = fs2.openSync(snapshotPath, "r");
20138
- try {
20139
- fs2.fsyncSync(fd);
20140
- } finally {
20141
- fs2.closeSync(fd);
20142
- }
20143
- } catch (err) {
20144
- process.stderr.write(`[vault-audit] ERROR: could not fsync audit snapshot ${snapshotPath}; leaving active log intact to avoid data loss: ${err.message}
20199
+ process.stderr.write(`[${tag}] ERROR: could not snapshot active log ${logPath} → ${snapshotPath}: ${err.message}
20145
20200
  `);
20146
- return;
20201
+ return false;
20147
20202
  }
20203
+ const snapshotBytes = fsyncAndMeasure(snapshotPath, logPath, tag);
20204
+ if (snapshotBytes === null)
20205
+ return false;
20148
20206
  try {
20149
20207
  const dirFd = fs2.openSync(path2.dirname(snapshotPath), "r");
20150
20208
  try {
@@ -20153,15 +20211,78 @@ function rotateAuditLog(logPath, maxFiles) {
20153
20211
  fs2.closeSync(dirFd);
20154
20212
  }
20155
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
+ }
20156
20229
  try {
20157
20230
  fs2.truncateSync(logPath, 0);
20158
20231
  } catch (err) {
20159
- 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}
20160
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
20238
+ `);
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 };
20161
20267
  }
20162
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");
20283
+ }
20163
20284
  function defaultAuditLogPath() {
20164
- return path2.join(os2.homedir(), ".switchroom", "vault-audit.log");
20285
+ return path3.join(os2.homedir(), ".switchroom", "vault-audit.log");
20165
20286
  }
20166
20287
  function callerFromPeer(peer) {
20167
20288
  if (peer.systemdUnit !== null && peer.systemdUnit.length > 0) {
@@ -20183,7 +20304,7 @@ function createAuditLogger(opts = {}) {
20183
20304
  lastError: String(err.message).slice(0, 300)
20184
20305
  };
20185
20306
  try {
20186
- fs2.writeFileSync(statePath, `${JSON.stringify(state)}
20307
+ fs3.writeFileSync(statePath, `${JSON.stringify(state)}
20187
20308
  `, { mode: 384 });
20188
20309
  } catch (persistErr) {
20189
20310
  process.stderr.write(`[vault-audit] ERROR: could not persist fail-open counter ${statePath}: ${persistErr.message}
@@ -20197,7 +20318,7 @@ function createAuditLogger(opts = {}) {
20197
20318
  write(entry) {
20198
20319
  if (rotation.maxBytes > 0) {
20199
20320
  try {
20200
- const size = fs2.statSync(logPath).size;
20321
+ const size = fs3.statSync(logPath).size;
20201
20322
  if (size >= rotation.maxBytes) {
20202
20323
  rotateAuditLog(logPath, rotation.maxFiles);
20203
20324
  }
@@ -20206,7 +20327,7 @@ function createAuditLogger(opts = {}) {
20206
20327
  const { line, next } = chainRow(chain, entry);
20207
20328
  let fd;
20208
20329
  try {
20209
- fd = fs2.openSync(logPath, "a", 384);
20330
+ fd = fs3.openSync(logPath, "a", 384);
20210
20331
  } catch (err) {
20211
20332
  process.stderr.write(`[vault-audit] ERROR: could not open audit log ${logPath}: ${err.message}
20212
20333
  `);
@@ -20215,7 +20336,7 @@ function createAuditLogger(opts = {}) {
20215
20336
  }
20216
20337
  let durable = false;
20217
20338
  try {
20218
- fs2.writeSync(fd, line);
20339
+ fs3.writeSync(fd, line);
20219
20340
  chain = next;
20220
20341
  durable = true;
20221
20342
  } catch (err) {
@@ -20224,7 +20345,7 @@ function createAuditLogger(opts = {}) {
20224
20345
  recordFailOpen(err);
20225
20346
  } finally {
20226
20347
  try {
20227
- fs2.closeSync(fd);
20348
+ fs3.closeSync(fd);
20228
20349
  } catch (closeErr) {
20229
20350
  process.stderr.write(`[vault-audit] ERROR: could not close audit log fd: ${closeErr.message}
20230
20351
  `);
@@ -20236,7 +20357,7 @@ function createAuditLogger(opts = {}) {
20236
20357
  }
20237
20358
 
20238
20359
  // src/vault/grants.ts
20239
- import { randomBytes as randomBytes4 } from "node:crypto";
20360
+ import { createHash as createHash5, randomBytes as randomBytes4 } from "node:crypto";
20240
20361
 
20241
20362
  // node_modules/.bun/bcryptjs@3.0.3/node_modules/bcryptjs/index.js
20242
20363
  import nodeCrypto from "crypto";
@@ -21930,6 +22051,44 @@ function migrateGrantsSchema(db) {
21930
22051
  }
21931
22052
  }
21932
22053
  var BCRYPT_COST = 10;
22054
+ var COMPARE_MEMO_TTL_MS = 60000;
22055
+ var COMPARE_MEMO_MAX_ENTRIES = 512;
22056
+ var compareMemo = new Map;
22057
+ function memoKey(id, secretHash, secret) {
22058
+ return `${id}:${secretHash}:${createHash5("sha256").update(secret).digest("hex")}`;
22059
+ }
22060
+ async function compareSecret(id, secret, secretHash) {
22061
+ const k = memoKey(id, secretHash, secret);
22062
+ const now = Date.now();
22063
+ const expiry = compareMemo.get(k);
22064
+ if (expiry !== undefined) {
22065
+ if (expiry > now)
22066
+ return true;
22067
+ compareMemo.delete(k);
22068
+ }
22069
+ let hashMatches;
22070
+ try {
22071
+ hashMatches = await compare(secret, secretHash);
22072
+ } catch {
22073
+ hashMatches = false;
22074
+ }
22075
+ if (hashMatches) {
22076
+ if (compareMemo.size >= COMPARE_MEMO_MAX_ENTRIES) {
22077
+ for (const [ck, cexp] of compareMemo) {
22078
+ if (cexp <= now)
22079
+ compareMemo.delete(ck);
22080
+ }
22081
+ while (compareMemo.size >= COMPARE_MEMO_MAX_ENTRIES) {
22082
+ const oldest = compareMemo.keys().next();
22083
+ if (oldest.done)
22084
+ break;
22085
+ compareMemo.delete(oldest.value);
22086
+ }
22087
+ }
22088
+ compareMemo.set(k, now + COMPARE_MEMO_TTL_MS);
22089
+ }
22090
+ return hashMatches;
22091
+ }
21933
22092
  function generateId() {
21934
22093
  return "vg_" + randomBytes4(3).toString("hex");
21935
22094
  }
@@ -22012,12 +22171,7 @@ async function validateGrant(db, token, key) {
22012
22171
  return { ok: false, reason: "grant-invalid" };
22013
22172
  }
22014
22173
  const secretHash = row.secret_hash;
22015
- let hashMatches;
22016
- try {
22017
- hashMatches = await compare(secret, secretHash);
22018
- } catch {
22019
- hashMatches = false;
22020
- }
22174
+ const hashMatches = await compareSecret(id, secret, secretHash);
22021
22175
  if (!hashMatches) {
22022
22176
  return { ok: false, reason: "grant-invalid" };
22023
22177
  }
@@ -22049,12 +22203,7 @@ async function validateGrantForWrite(db, token, key) {
22049
22203
  if (!row)
22050
22204
  return { ok: false, reason: "grant-invalid" };
22051
22205
  const secretHash = row.secret_hash;
22052
- let hashMatches;
22053
- try {
22054
- hashMatches = await compare(secret, secretHash);
22055
- } catch {
22056
- hashMatches = false;
22057
- }
22206
+ const hashMatches = await compareSecret(id, secret, secretHash);
22058
22207
  if (!hashMatches)
22059
22208
  return { ok: false, reason: "grant-invalid" };
22060
22209
  const grant = rowToGrant(row);
@@ -22115,8 +22264,8 @@ function adminOnlyKeysBeingAdded(requestedKeys, existingKeys, patterns) {
22115
22264
  }
22116
22265
 
22117
22266
  // src/vault/grants-db.ts
22118
- import * as path3 from "node:path";
22119
- import * as fs3 from "node:fs";
22267
+ import * as path4 from "node:path";
22268
+ import * as fs4 from "node:fs";
22120
22269
  import { Database } from "bun:sqlite";
22121
22270
 
22122
22271
  // src/vault/approvals/schema.ts
@@ -22209,12 +22358,12 @@ function isGrantsDbCorruption(err) {
22209
22358
  function quarantineGrantsDb(dbPath) {
22210
22359
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
22211
22360
  const quarantinePath = `${dbPath}.corrupt-${stamp}`;
22212
- fs3.renameSync(dbPath, quarantinePath);
22361
+ fs4.renameSync(dbPath, quarantinePath);
22213
22362
  for (const suffix of WAL_SIDECAR_SUFFIXES2) {
22214
22363
  const sidecar = `${dbPath}${suffix}`;
22215
- if (fs3.existsSync(sidecar)) {
22364
+ if (fs4.existsSync(sidecar)) {
22216
22365
  try {
22217
- fs3.renameSync(sidecar, `${quarantinePath}${suffix}`);
22366
+ fs4.renameSync(sidecar, `${quarantinePath}${suffix}`);
22218
22367
  } catch {}
22219
22368
  }
22220
22369
  }
@@ -22224,7 +22373,7 @@ function openAndMigrate(dbPath) {
22224
22373
  const db = new Database(dbPath, { create: true });
22225
22374
  try {
22226
22375
  try {
22227
- fs3.chmodSync(dbPath, 384);
22376
+ fs4.chmodSync(dbPath, 384);
22228
22377
  } catch {}
22229
22378
  db.run("PRAGMA journal_mode=WAL");
22230
22379
  db.run("PRAGMA busy_timeout=5000");
@@ -22240,8 +22389,8 @@ function openAndMigrate(dbPath) {
22240
22389
  return db;
22241
22390
  }
22242
22391
  function openGrantsDb(dbPath = DEFAULT_GRANTS_DB_PATH, opener = openAndMigrate) {
22243
- const dir = path3.dirname(dbPath);
22244
- fs3.mkdirSync(dir, { recursive: true });
22392
+ const dir = path4.dirname(dbPath);
22393
+ fs4.mkdirSync(dir, { recursive: true });
22245
22394
  migrateLegacyGrantsDbLocation(dbPath);
22246
22395
  try {
22247
22396
  return opener(dbPath);
@@ -22265,6 +22414,7 @@ function canonicalizeApproverSet(approvers) {
22265
22414
  }
22266
22415
 
22267
22416
  // src/vault/approvals/kernel.ts
22417
+ var ALLOW_ONCE_CONSUMED_REASON = "allow_once_consumed";
22268
22418
  function generateRequestId() {
22269
22419
  return randomBytes5(16).toString("hex");
22270
22420
  }
@@ -22391,6 +22541,38 @@ function evaluateDecisionRow(db, row, currentCanonical, opts, now) {
22391
22541
  if (decision.decision === "deny") {
22392
22542
  return { state: "denied", decision };
22393
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
+ }
22394
22576
  if (decision.decision === "allow_ttl") {
22395
22577
  const maxLifetime = opts.max_ttl_lifetime_ms ?? DEFAULT_MAX_TTL_LIFETIME_MS;
22396
22578
  const hardCap = decision.granted_at + maxLifetime;
@@ -22459,7 +22641,8 @@ function consumeNonce(db, request_id, now = Date.now()) {
22459
22641
  }
22460
22642
  function recordDecision(db, input, now = Date.now()) {
22461
22643
  const id = randomUUID();
22462
- 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;
22463
22646
  const canonical = canonicalizeApproverSet(input.approver_set);
22464
22647
  db.run(`INSERT INTO approval_decisions
22465
22648
  (id, agent_unit, scope, action, decision,
@@ -23774,10 +23957,10 @@ class VaultBroker {
23774
23957
  return;
23775
23958
  }
23776
23959
  try {
23777
- const agentsDir = this.config ? resolveAgentsDir(this.config) : path4.join(os3.homedir(), ".switchroom", "agents");
23778
- 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);
23779
23962
  mkdirSync7(tokenDir, { recursive: true });
23780
- const tokenPath = path4.join(tokenDir, ".vault-token");
23963
+ const tokenPath = path5.join(tokenDir, ".vault-token");
23781
23964
  const tmpPath = `${tokenPath}.tmp.${process.pid}`;
23782
23965
  writeFileSync3(tmpPath, mintResult.token, { mode: 384 });
23783
23966
  renameSync8(tmpPath, tokenPath);
@@ -23839,8 +24022,8 @@ class VaultBroker {
23839
24022
  try {
23840
24023
  const row = this.grantsDb.query("SELECT agent_slug FROM vault_grants WHERE id = ?").get(id);
23841
24024
  if (row && AgentNameSchema.safeParse(row.agent_slug).success) {
23842
- const agentsDir = this.config ? resolveAgentsDir(this.config) : path4.join(os3.homedir(), ".switchroom", "agents");
23843
- 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");
23844
24027
  if (existsSync12(tokenPath)) {
23845
24028
  try {
23846
24029
  unlinkSync6(tokenPath);
@@ -24082,7 +24265,7 @@ class VaultBroker {
24082
24265
  if (!existsSync12(filePath))
24083
24266
  return false;
24084
24267
  try {
24085
- if (statSync6(filePath).size === 0)
24268
+ if (statSync7(filePath).size === 0)
24086
24269
  return false;
24087
24270
  } catch {
24088
24271
  return false;
@@ -24161,12 +24344,12 @@ class VaultBroker {
24161
24344
  }
24162
24345
  function detectVaultLayoutDrift(vaultPath) {
24163
24346
  const dir = dirname7(vaultPath);
24164
- if (basename4(dir) !== "vault")
24347
+ if (basename5(dir) !== "vault")
24165
24348
  return;
24166
- if (basename4(vaultPath) !== "vault.enc")
24349
+ if (basename5(vaultPath) !== "vault.enc")
24167
24350
  return;
24168
24351
  const switchroomDir = dirname7(dir);
24169
- if (basename4(switchroomDir) !== ".switchroom")
24352
+ if (basename5(switchroomDir) !== ".switchroom")
24170
24353
  return;
24171
24354
  const home2 = dirname7(switchroomDir);
24172
24355
  const result = inspectVaultLayout(home2);
@@ -24195,7 +24378,7 @@ async function main() {
24195
24378
  let perAgentTargets = [];
24196
24379
  try {
24197
24380
  if (existsSync12(perAgentDir)) {
24198
- const entries = readdirSync4(perAgentDir, { withFileTypes: true });
24381
+ const entries = readdirSync5(perAgentDir, { withFileTypes: true });
24199
24382
  const flat = [];
24200
24383
  const subdirs = [];
24201
24384
  for (const e of entries) {