switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -4116,7 +4116,12 @@ var init_schema = __esm(() => {
4116
4116
  AgentSoulSchema = exports_external.object({
4117
4117
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
4118
4118
  style: exports_external.string().describe("Communication style description"),
4119
- boundaries: exports_external.string().optional().describe("Behavioral boundaries and disclaimers")
4119
+ creature: exports_external.string().optional().describe("Persona creature/form (e.g., 'owl', 'octopus')"),
4120
+ vibe: exports_external.string().optional().describe("One-line personality vibe (e.g., 'calm, precise')"),
4121
+ expertise: exports_external.string().optional().describe("Domain expertise summary rendered into the persona"),
4122
+ emoji: exports_external.string().optional().describe("Signature emoji for the persona (e.g., '\uD83E\uDD89')"),
4123
+ boundaries: exports_external.string().optional().describe("Behavioral boundaries and disclaimers"),
4124
+ shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).default("generalist").describe("Persona-shape discriminator the per-agent CLAUDE.md template can " + "branch on (e.g., 'you are an executive assistant' vs 'a senior " + "engineer'). No runtime behavior yet — template work lands in a " + "later issue. Cascade: override (per-agent wins over default).")
4120
4125
  }).optional();
4121
4126
  AgentToolsSchema = exports_external.object({
4122
4127
  allow: exports_external.array(exports_external.string()).default([]).describe("Allowed tools (use ['all'] for unrestricted)"),
@@ -4281,6 +4286,11 @@ var init_schema = __esm(() => {
4281
4286
  safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
4282
4287
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
4283
4288
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
4289
+ button_choice_confirmation: exports_external.object({
4290
+ enabled: exports_external.boolean().default(false).describe("When true, tapping an agent-emitted inline_keyboard button annotates " + "the source message body with a '✅ You chose: <label> · HH:MM' line so " + "the chat surface is self-documenting. Only applies to single-use " + "keyboards (re-tappable keyboards are never annotated). Requires the " + "agent's parseMode to be the default 'html'. Opt-in (default false). " + "Limitation: the annotated body is rebuilt from the message's plain " + "text, so any formatting entities (bold, links, code, ...) on the " + "original message are lost when the annotation is applied."),
4291
+ format: exports_external.string().default("✅ You chose: {label} · {time}").describe("Template for the annotation line. `{label}` is replaced with the " + "tapped button's text (newlines stripped, HTML-escaped, trimmed to 60 " + "chars) and `{time}` with HH:MM in the chosen timezone. Note: retap " + "dedup (replacing a prior annotation instead of appending another " + "line) only recognizes the DEFAULT format shape — a custom format " + "accumulates one line per retap."),
4292
+ timezone: exports_external.enum(["gateway", "utc"]).default("gateway").describe("Timezone for the {time} placeholder. 'gateway' uses the gateway " + "process's local time; 'utc' uses UTC. Per-user timezones are out of " + "scope for v1.")
4293
+ }).optional().describe("Auto-confirm 'You chose: X' annotation on inline_keyboard taps (#789). " + "Cascades from defaults.channels.telegram.button_choice_confirmation."),
4284
4294
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic", "linear"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token, " + "'linear' = Linear-Signature bare-hex HMAC-SHA256 of the raw body). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
4285
4295
  webhook_dispatch: exports_external.object({
4286
4296
  github: exports_external.array(webhookDispatchRule).optional(),
@@ -4429,7 +4439,12 @@ var init_schema = __esm(() => {
4429
4439
  soul: exports_external.object({
4430
4440
  name: exports_external.string().optional(),
4431
4441
  style: exports_external.string().optional(),
4432
- boundaries: exports_external.string().optional()
4442
+ creature: exports_external.string().optional(),
4443
+ vibe: exports_external.string().optional(),
4444
+ expertise: exports_external.string().optional(),
4445
+ emoji: exports_external.string().optional(),
4446
+ boundaries: exports_external.string().optional(),
4447
+ shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).optional()
4433
4448
  }).optional(),
4434
4449
  tools: exports_external.object({
4435
4450
  allow: exports_external.array(exports_external.string()).optional(),
@@ -4626,7 +4641,8 @@ var init_schema = __esm(() => {
4626
4641
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
4627
4642
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
4628
4643
  postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
4629
- adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
4644
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload)."),
4645
+ auditFailClosed: exports_external.boolean().default(false).describe("sec WS10-F3 (#1420) — fail-CLOSED on an audit-append failure. When " + "`false` (default, backward-compatible) the broker fails OPEN: if a " + "vault-audit.log append fails it logs to stderr, bumps the durable " + "fail-open counter, and STILL releases the secret. When `true`, a " + "failed audit append DENIES the secret release with " + "`AUDIT_UNAVAILABLE` — no secret leaves the broker without a durable " + "audit row. The env var `SWITCHROOM_VAULT_AUDIT_FAIL_CLOSED` " + "(`1`/`true` or `0`/`false`) overrides this at runtime. Either way " + "the fail-open counter is surfaced by `switchroom doctor`. Takes " + "effect on broker restart.")
4630
4646
  }).default({}).superRefine((broker, ctx) => {
4631
4647
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
4632
4648
  ctx.addIssue({
@@ -4665,7 +4681,9 @@ var init_schema = __esm(() => {
4665
4681
  });
4666
4682
  HostdConfigSchema = exports_external.object({
4667
4683
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
4668
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat.")
4684
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat."),
4685
+ operator_attest_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the operator-passphrase 2nd factor on hostd's " + "mutating verbs (#1841, RFC host-control-daemon.md §5.4). Default " + "false — attestation is ACCEPTED-and-audited whenever present, but " + "no verb REQUIRES it (behaviour is byte-identical to today, so " + "existing fleets and the Telegram approval-card flow are unchanged). " + "When true, the verbs in `operator_attest_required_verbs` demand a " + "valid operator-passphrase attestation IN ADDITION to admin: hostd " + "forwards the passphrase to the vault broker over its own admin-client " + "connection (`/run/switchroom/broker/hostd/sock`) and treats a broker " + "DENIED as a gate failure. hostd never holds the passphrase. Leave " + "this off until BOTH the gateway passphrase-forward and the " + "cross-compose broker socket are deployed, or gated verbs will fail."),
4686
+ operator_attest_required_verbs: exports_external.array(exports_external.string().min(1)).default(["update_apply", "apply", "rollout"]).describe("Which mutating hostd verbs REQUIRE a valid operator-passphrase " + "attestation when `operator_attest_enabled` is true (#1841). Default " + "is the RFC §5.4 fleet-mutation set (`update_apply`, `apply`, " + "`rollout`). Verbs NOT in this list still accept-and-audit an " + "attestation when one is supplied, but never require it. Ignored " + "entirely when `operator_attest_enabled` is false.")
4669
4687
  });
4670
4688
  CronEgressSchema = exports_external.object({
4671
4689
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -4760,6 +4778,13 @@ var init_schema = __esm(() => {
4760
4778
  });
4761
4779
  });
4762
4780
 
4781
+ // src/util/atomic.ts
4782
+ import { closeSync, constants, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
4783
+ var TMP_OPEN_FLAGS;
4784
+ var init_atomic = __esm(() => {
4785
+ TMP_OPEN_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
4786
+ });
4787
+
4763
4788
  // src/config/merge.ts
4764
4789
  function dedupe(items) {
4765
4790
  const seen = new Set;
@@ -4897,6 +4922,11 @@ function mergeAgentConfig(defaultsIn, agentIn) {
4897
4922
  if (v !== undefined)
4898
4923
  combined[k] = v;
4899
4924
  }
4925
+ const baseSoul = base;
4926
+ const overrideSoul = override;
4927
+ if (baseSoul.shape !== undefined && (overrideSoul.shape === undefined || overrideSoul.shape === "generalist")) {
4928
+ combined.shape = baseSoul.shape;
4929
+ }
4900
4930
  merged.soul = combined;
4901
4931
  }
4902
4932
  if (defaults.memory || merged.memory) {
@@ -18064,7 +18094,7 @@ var require_lib = __commonJS((exports, module) => {
18064
18094
 
18065
18095
  // src/vault/broker/server.ts
18066
18096
  import * as net from "node:net";
18067
- import { mkdirSync as mkdirSync6, chmodSync as chmodSync4, chownSync, existsSync as existsSync11, readFileSync as readFileSync9, readdirSync as readdirSync4, statSync as statSync6, unlinkSync as unlinkSync6, writeFileSync as writeFileSync2, renameSync as renameSync6 } from "node:fs";
18097
+ import { mkdirSync as mkdirSync6, chmodSync as chmodSync4, chownSync, existsSync as existsSync11, readFileSync as readFileSync10, readdirSync as readdirSync4, statSync as statSync6, unlinkSync as unlinkSync6, writeFileSync as writeFileSync3, renameSync as renameSync7 } from "node:fs";
18068
18098
 
18069
18099
  // src/agents/compose.ts
18070
18100
  import { createHash } from "node:crypto";
@@ -18072,6 +18102,7 @@ init_schema();
18072
18102
 
18073
18103
  // src/agents/scaffold.ts
18074
18104
  import { join as join4, resolve as resolve6 } from "node:path";
18105
+ init_atomic();
18075
18106
  init_schema();
18076
18107
 
18077
18108
  // src/config/users.ts
@@ -18154,22 +18185,6 @@ function isWebkiteCredentialKeyForAgent(agentConfig, key) {
18154
18185
  return false;
18155
18186
  return true;
18156
18187
  }
18157
- function parseCronUnit(unitName) {
18158
- const m = unitName.match(/^switchroom-([a-zA-Z0-9_-]+)-cron-(\d+)\.service$/);
18159
- if (!m)
18160
- return null;
18161
- const agentName = m[1];
18162
- const index = parseInt(m[2], 10);
18163
- if (!agentName)
18164
- return null;
18165
- return { agentName, index };
18166
- }
18167
- function agentSlugFromPeer(peer) {
18168
- if (peer.systemdUnit === null)
18169
- return null;
18170
- const parsed = parseCronUnit(peer.systemdUnit);
18171
- return parsed?.agentName ?? null;
18172
- }
18173
18188
  function checkEntryScope(scope, agentSlug) {
18174
18189
  if (scope === undefined || scope === null) {
18175
18190
  return { allow: true };
@@ -18192,42 +18207,6 @@ function checkEntryScope(scope, agentSlug) {
18192
18207
  }
18193
18208
  return { allow: true };
18194
18209
  }
18195
- function checkAcl(peer, config, key) {
18196
- if (peer.systemdUnit !== null) {
18197
- const parsed = parseCronUnit(peer.systemdUnit);
18198
- if (parsed === null) {
18199
- return {
18200
- allow: false,
18201
- reason: `systemd unit '${peer.systemdUnit}' does not match switchroom cron unit naming convention`
18202
- };
18203
- }
18204
- const { agentName, index } = parsed;
18205
- const agentConfig = config.agents?.[agentName];
18206
- if (!agentConfig) {
18207
- return { allow: false, reason: `agent '${agentName}' not found in config` };
18208
- }
18209
- const schedule = agentConfig.schedule ?? [];
18210
- if (index >= schedule.length || index < 0) {
18211
- return {
18212
- allow: false,
18213
- reason: `schedule index ${index} out of range for agent '${agentName}' (${schedule.length} entries)`
18214
- };
18215
- }
18216
- const entry = schedule[index];
18217
- const allowedKeys = entry.secrets ?? [];
18218
- if (!allowedKeys.includes(key)) {
18219
- return {
18220
- allow: false,
18221
- reason: `key '${key}' not in ACL for ${agentName}/schedule[${index}]`
18222
- };
18223
- }
18224
- return { allow: true };
18225
- }
18226
- return {
18227
- allow: false,
18228
- reason: "caller is not a switchroom cron unit; use 'switchroom vault get --no-broker' for interactive access"
18229
- };
18230
- }
18231
18210
  function checkAclByAgent(config, agentName, key) {
18232
18211
  if (!agentName) {
18233
18212
  return { allow: false, reason: "agent name unresolved" };
@@ -18361,18 +18340,22 @@ var tails = new Map;
18361
18340
 
18362
18341
  // src/agents/inject.ts
18363
18342
  var INJECT_COMMANDS = new Map([
18364
- ["/cost", { description: "Show session cost", expectsOutput: true, dialog: true }],
18365
- ["/status", { description: "Show session status", expectsOutput: true, dialog: true }],
18366
- ["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true }],
18367
- ["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true }],
18368
- ["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true }],
18369
- ["/model", { description: "Open model picker", expectsOutput: true }],
18343
+ ["/cost", { description: "Show session cost", expectsOutput: true, dialog: true, argsAllowed: false }],
18344
+ ["/status", { description: "Show session status", expectsOutput: true, dialog: true, argsAllowed: false }],
18345
+ ["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true, argsAllowed: false }],
18346
+ ["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true, argsAllowed: false }],
18347
+ ["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true, argsAllowed: false }],
18348
+ ["/help", { description: "Show help / command discovery", expectsOutput: true, dialog: true, argsAllowed: false }],
18349
+ ["/context", { description: "Show context window usage", expectsOutput: true, argsAllowed: false }],
18350
+ ["/release-notes", { description: "Show release-notes version list", expectsOutput: true, dialog: true, argsAllowed: false }],
18351
+ ["/model", { description: "Open model picker", expectsOutput: true, argsAllowed: true }],
18370
18352
  [
18371
18353
  "/clear",
18372
18354
  {
18373
18355
  description: "Clear session screen",
18374
18356
  expectsOutput: false,
18375
- silentNote: "context cleared — fresh slate"
18357
+ silentNote: "context cleared — fresh slate",
18358
+ argsAllowed: false
18376
18359
  }
18377
18360
  ],
18378
18361
  [
@@ -18380,7 +18363,8 @@ var INJECT_COMMANDS = new Map([
18380
18363
  {
18381
18364
  description: "Compact conversation history",
18382
18365
  expectsOutput: false,
18383
- silentNote: "compaction runs silently"
18366
+ silentNote: "compaction runs silently",
18367
+ argsAllowed: false
18384
18368
  }
18385
18369
  ]
18386
18370
  ]);
@@ -18390,6 +18374,15 @@ var INJECT_BLOCKED = new Map([
18390
18374
  ["/logout", { reason: "would terminate the agent's auth session" }],
18391
18375
  ["/exit", { reason: "would kill the agent process" }],
18392
18376
  ["/quit", { reason: "would kill the agent process" }],
18377
+ ["/upgrade", { reason: "mutates the Claude Code installation" }],
18378
+ ["/init", { reason: "generates/overwrites CLAUDE.md and runs a model turn" }],
18379
+ ["/mcp", { reason: "opens an interactive MCP server management dialog" }],
18380
+ ["/permissions", { reason: "opens an interactive permissions editor that mutates tool policy" }],
18381
+ ["/install-github-app", { reason: "runs a network/OAuth install flow" }],
18382
+ ["/add-dir", { reason: "mutates the session's working-directory set" }],
18383
+ ["/terminal-setup", { reason: "mutates terminal keybinding configuration" }],
18384
+ ["/privacy-settings", { reason: "opens an interactive privacy-settings dialog" }],
18385
+ ["/bug", { reason: "submits a bug report over the network" }],
18393
18386
  [
18394
18387
  "/effort",
18395
18388
  {
@@ -18407,6 +18400,7 @@ var HINDSIGHT_CONSUMER_NAME = "hindsight";
18407
18400
  var HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight";
18408
18401
  var HINDSIGHT_IMAGE = `${HINDSIGHT_IMAGE_REPO}:latest`;
18409
18402
  var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`;
18403
+ var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
18410
18404
  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)';
18411
18405
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
18412
18406
 
@@ -18438,14 +18432,14 @@ init_loader();
18438
18432
  import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
18439
18433
  import {
18440
18434
  readFileSync as readFileSync5,
18441
- writeSync as writeSync2,
18435
+ writeSync as writeSync3,
18442
18436
  existsSync as existsSync6,
18443
- renameSync,
18437
+ renameSync as renameSync2,
18444
18438
  mkdirSync as mkdirSync2,
18445
18439
  unlinkSync as unlinkSync2,
18446
- fsyncSync as fsyncSync2,
18447
- openSync as openSync2,
18448
- closeSync as closeSync2,
18440
+ fsyncSync as fsyncSync3,
18441
+ openSync as openSync3,
18442
+ closeSync as closeSync3,
18449
18443
  lstatSync,
18450
18444
  realpathSync as realpathSync2
18451
18445
  } from "node:fs";
@@ -18454,10 +18448,10 @@ import { dirname, basename as basename2, resolve as resolve5 } from "node:path";
18454
18448
  // src/vault/flock.ts
18455
18449
  import {
18456
18450
  existsSync as existsSync5,
18457
- openSync,
18458
- closeSync,
18459
- writeSync,
18460
- fsyncSync,
18451
+ openSync as openSync2,
18452
+ closeSync as closeSync2,
18453
+ writeSync as writeSync2,
18454
+ fsyncSync as fsyncSync2,
18461
18455
  unlinkSync,
18462
18456
  readFileSync as readFileSync4,
18463
18457
  readdirSync as readdirSync3,
@@ -18582,7 +18576,7 @@ function acquireLock(vaultPath, options = {}) {
18582
18576
  while (true) {
18583
18577
  let fd = null;
18584
18578
  try {
18585
- fd = openSync(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 384);
18579
+ fd = openSync2(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 384);
18586
18580
  } catch (err) {
18587
18581
  const code = err?.code ?? "";
18588
18582
  if (code === "EEXIST") {
@@ -18618,11 +18612,11 @@ function acquireLock(vaultPath, options = {}) {
18618
18612
  ${Date.now()}
18619
18613
  ${process.argv[1] ?? ""}
18620
18614
  `;
18621
- writeSync(fd, meta);
18622
- fsyncSync(fd);
18615
+ writeSync2(fd, meta);
18616
+ fsyncSync2(fd);
18623
18617
  } catch {
18624
18618
  try {
18625
- closeSync(fd);
18619
+ closeSync2(fd);
18626
18620
  } catch {}
18627
18621
  try {
18628
18622
  unlinkSync(lockPath);
@@ -18633,7 +18627,7 @@ ${process.argv[1] ?? ""}
18633
18627
  return {
18634
18628
  release: () => {
18635
18629
  try {
18636
- closeSync(ownedFd);
18630
+ closeSync2(ownedFd);
18637
18631
  } catch {}
18638
18632
  try {
18639
18633
  unlinkSync(lockPath);
@@ -18689,20 +18683,20 @@ function atomicWriteFileSync(path, data, mode) {
18689
18683
  const dir = dirname(resolve5(effectivePath));
18690
18684
  const tmp = resolve5(dir, `.${basename2(effectivePath)}.${process.pid}.${Date.now()}.tmp`);
18691
18685
  try {
18692
- const fd = openSync2(tmp, "wx", mode);
18686
+ const fd = openSync3(tmp, "wx", mode);
18693
18687
  try {
18694
- writeSync2(fd, data);
18695
- fsyncSync2(fd);
18688
+ writeSync3(fd, data);
18689
+ fsyncSync3(fd);
18696
18690
  } finally {
18697
- closeSync2(fd);
18691
+ closeSync3(fd);
18698
18692
  }
18699
- renameSync(tmp, effectivePath);
18693
+ renameSync2(tmp, effectivePath);
18700
18694
  try {
18701
- const dirFd = openSync2(dir, "r");
18695
+ const dirFd = openSync3(dir, "r");
18702
18696
  try {
18703
- fsyncSync2(dirFd);
18697
+ fsyncSync3(dirFd);
18704
18698
  } finally {
18705
- closeSync2(dirFd);
18699
+ closeSync3(dirFd);
18706
18700
  }
18707
18701
  } catch {}
18708
18702
  } catch (err) {
@@ -19278,6 +19272,13 @@ var ApprovalLookupRequestSchema = exports_external.object({
19278
19272
  action: exports_external.string().min(1),
19279
19273
  current_approver_set: exports_external.array(exports_external.string())
19280
19274
  });
19275
+ var ApprovalLookupByRequestRequestSchema = exports_external.object({
19276
+ v: exports_external.literal(1),
19277
+ op: exports_external.literal("approval_lookup_by_request"),
19278
+ agent_unit: exports_external.string().min(1),
19279
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/),
19280
+ current_approver_set: exports_external.array(exports_external.string())
19281
+ });
19281
19282
  var ApprovalConsumeRequestSchema = exports_external.object({
19282
19283
  v: exports_external.literal(1),
19283
19284
  op: exports_external.literal("approval_consume"),
@@ -19332,6 +19333,7 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
19332
19333
  RevokeGrantRequestSchema,
19333
19334
  ApprovalRequestRequestSchema,
19334
19335
  ApprovalLookupRequestSchema,
19336
+ ApprovalLookupByRequestRequestSchema,
19335
19337
  ApprovalConsumeRequestSchema,
19336
19338
  ApprovalRevokeRequestSchema,
19337
19339
  ApprovalListRequestSchema,
@@ -19354,7 +19356,8 @@ var ErrorCode = exports_external.enum([
19354
19356
  "DENIED",
19355
19357
  "UNKNOWN_KEY",
19356
19358
  "BAD_REQUEST",
19357
- "INTERNAL"
19359
+ "INTERNAL",
19360
+ "AUDIT_UNAVAILABLE"
19358
19361
  ]);
19359
19362
  var OkEntryResponseSchema = exports_external.object({
19360
19363
  ok: exports_external.literal(true),
@@ -19542,6 +19545,7 @@ init_paths();
19542
19545
 
19543
19546
  // src/agents/scaffold.ts
19544
19547
  var REPO_ROOT = resolve6(import.meta.dirname, "../..");
19548
+ 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.";
19545
19549
  var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
19546
19550
  "permissions",
19547
19551
  "mcpServers",
@@ -19582,13 +19586,13 @@ import {
19582
19586
  copyFileSync as copyFileSync2,
19583
19587
  chmodSync,
19584
19588
  existsSync as existsSync7,
19585
- fsyncSync as fsyncSync3,
19589
+ fsyncSync as fsyncSync4,
19586
19590
  lstatSync as lstatSync2,
19587
19591
  mkdirSync as mkdirSync3,
19588
- openSync as openSync3,
19589
- closeSync as closeSync3,
19592
+ openSync as openSync4,
19593
+ closeSync as closeSync4,
19590
19594
  readFileSync as readFileSync7,
19591
- renameSync as renameSync2,
19595
+ renameSync as renameSync3,
19592
19596
  statSync as statSync4,
19593
19597
  symlinkSync,
19594
19598
  unlinkSync as unlinkSync3
@@ -19654,7 +19658,7 @@ function runMigration(home2, opts) {
19654
19658
  copyFileSync2(oldPath, tempNew);
19655
19659
  chmodSync(tempNew, 384);
19656
19660
  fsyncFile(tempNew);
19657
- renameSync2(tempNew, newPath);
19661
+ renameSync3(tempNew, newPath);
19658
19662
  fsyncDir(parent);
19659
19663
  atomicReplaceWithSymlink(oldPath, "vault/vault.enc");
19660
19664
  fsyncDir(switchroomRoot);
@@ -19688,22 +19692,22 @@ function atomicReplaceWithSymlink(target, linkTarget) {
19688
19692
  } catch {}
19689
19693
  }
19690
19694
  symlinkSync(linkTarget, tmp);
19691
- renameSync2(tmp, target);
19695
+ renameSync3(tmp, target);
19692
19696
  }
19693
19697
  function fsyncFile(path) {
19694
- const fd = openSync3(path, "r+");
19698
+ const fd = openSync4(path, "r+");
19695
19699
  try {
19696
- fsyncSync3(fd);
19700
+ fsyncSync4(fd);
19697
19701
  } finally {
19698
- closeSync3(fd);
19702
+ closeSync4(fd);
19699
19703
  }
19700
19704
  }
19701
19705
  function fsyncDir(path) {
19702
- const fd = openSync3(path, "r");
19706
+ const fd = openSync4(path, "r");
19703
19707
  try {
19704
- fsyncSync3(fd);
19708
+ fsyncSync4(fd);
19705
19709
  } finally {
19706
- closeSync3(fd);
19710
+ closeSync4(fd);
19707
19711
  }
19708
19712
  }
19709
19713
 
@@ -19714,15 +19718,15 @@ init_loader();
19714
19718
  import { createHmac, randomBytes as randomBytes2, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2 } from "node:crypto";
19715
19719
  import {
19716
19720
  chmodSync as chmodSync2,
19717
- closeSync as closeSync4,
19721
+ closeSync as closeSync5,
19718
19722
  existsSync as existsSync8,
19719
- fsyncSync as fsyncSync4,
19723
+ fsyncSync as fsyncSync5,
19720
19724
  mkdirSync as mkdirSync4,
19721
- openSync as openSync4,
19725
+ openSync as openSync5,
19722
19726
  readFileSync as readFileSync8,
19723
- renameSync as renameSync3,
19727
+ renameSync as renameSync4,
19724
19728
  unlinkSync as unlinkSync4,
19725
- writeSync as writeSync3
19729
+ writeSync as writeSync4
19726
19730
  } from "node:fs";
19727
19731
  var FORMAT_VERSION = 1;
19728
19732
  var SALT_LEN = 16;
@@ -19815,7 +19819,7 @@ import * as path from "node:path";
19815
19819
 
19816
19820
  // src/util/audit-hashchain.ts
19817
19821
  import { createHash as createHash4 } from "node:crypto";
19818
- import { openSync as openSync5, readSync, fstatSync as fstatSync2, closeSync as closeSync5 } from "node:fs";
19822
+ import { openSync as openSync6, readSync, fstatSync as fstatSync2, closeSync as closeSync6 } from "node:fs";
19819
19823
  var CHAIN_GENESIS = "GENESIS";
19820
19824
  var SEP = "\x00";
19821
19825
  function rowHash(prev, seq, rowJsonNoChain) {
@@ -19836,7 +19840,7 @@ function chainRow(state, entry) {
19836
19840
  function readTail(path, tailBytes = 64 * 1024) {
19837
19841
  let fd;
19838
19842
  try {
19839
- fd = openSync5(path, "r");
19843
+ fd = openSync6(path, "r");
19840
19844
  } catch {
19841
19845
  return null;
19842
19846
  }
@@ -19852,7 +19856,7 @@ function readTail(path, tailBytes = 64 * 1024) {
19852
19856
  return null;
19853
19857
  } finally {
19854
19858
  try {
19855
- closeSync5(fd);
19859
+ closeSync6(fd);
19856
19860
  } catch {}
19857
19861
  }
19858
19862
  }
@@ -19929,6 +19933,23 @@ function safeAuditLogPath(requestedPath) {
19929
19933
  }
19930
19934
 
19931
19935
  // src/vault/broker/audit-log.ts
19936
+ function failOpenStatePath(logPath) {
19937
+ return `${logPath}.failopen`;
19938
+ }
19939
+ function readFailOpenState(statePath) {
19940
+ try {
19941
+ const raw = fs.readFileSync(statePath, "utf8");
19942
+ const parsed = JSON.parse(raw);
19943
+ const count = Number(parsed.failOpenCount);
19944
+ return {
19945
+ failOpenCount: Number.isFinite(count) && count > 0 ? count : 0,
19946
+ lastFailureTs: typeof parsed.lastFailureTs === "string" ? parsed.lastFailureTs : undefined,
19947
+ lastError: typeof parsed.lastError === "string" ? parsed.lastError : undefined
19948
+ };
19949
+ } catch {
19950
+ return { failOpenCount: 0 };
19951
+ }
19952
+ }
19932
19953
  var DEFAULT_AUDIT_MAX_BYTES = 32 * 1024 * 1024;
19933
19954
  var DEFAULT_AUDIT_MAX_FILES = 5;
19934
19955
  function resolveRotation(opts) {
@@ -20009,7 +20030,27 @@ function createAuditLogger(opts = {}) {
20009
20030
  const logPath = safeAuditLogPath(opts.path ?? defaultAuditLogPath());
20010
20031
  let chain = seedChain(logPath);
20011
20032
  const rotation = resolveRotation(opts);
20033
+ const statePath = opts.statePath ?? failOpenStatePath(logPath);
20034
+ let failOpen = readFailOpenState(statePath).failOpenCount;
20035
+ const recordFailOpen = (err) => {
20036
+ failOpen += 1;
20037
+ const state = {
20038
+ failOpenCount: failOpen,
20039
+ lastFailureTs: new Date().toISOString(),
20040
+ lastError: String(err.message).slice(0, 300)
20041
+ };
20042
+ try {
20043
+ fs.writeFileSync(statePath, `${JSON.stringify(state)}
20044
+ `, { mode: 384 });
20045
+ } catch (persistErr) {
20046
+ process.stderr.write(`[vault-audit] ERROR: could not persist fail-open counter ${statePath}: ${persistErr.message}
20047
+ `);
20048
+ }
20049
+ };
20012
20050
  return {
20051
+ failOpenCount() {
20052
+ return failOpen;
20053
+ },
20013
20054
  write(entry) {
20014
20055
  if (rotation.maxBytes > 0) {
20015
20056
  try {
@@ -20026,14 +20067,18 @@ function createAuditLogger(opts = {}) {
20026
20067
  } catch (err) {
20027
20068
  process.stderr.write(`[vault-audit] ERROR: could not open audit log ${logPath}: ${err.message}
20028
20069
  `);
20029
- return;
20070
+ recordFailOpen(err);
20071
+ return false;
20030
20072
  }
20073
+ let durable = false;
20031
20074
  try {
20032
20075
  fs.writeSync(fd, line);
20033
20076
  chain = next;
20077
+ durable = true;
20034
20078
  } catch (err) {
20035
20079
  process.stderr.write(`[vault-audit] ERROR: could not write to audit log ${logPath}: ${err.message}
20036
20080
  `);
20081
+ recordFailOpen(err);
20037
20082
  } finally {
20038
20083
  try {
20039
20084
  fs.closeSync(fd);
@@ -20042,6 +20087,7 @@ function createAuditLogger(opts = {}) {
20042
20087
  `);
20043
20088
  }
20044
20089
  }
20090
+ return durable;
20045
20091
  }
20046
20092
  };
20047
20093
  }
@@ -22166,6 +22212,9 @@ function lookupDecision(db, query, opts = {}, now = Date.now()) {
22166
22212
  ORDER BY granted_at DESC LIMIT 1`).get(query.agent_unit, query.scope, query.action);
22167
22213
  if (!row)
22168
22214
  return { state: "no_decision" };
22215
+ return evaluateDecisionRow(db, row, currentCanonical, opts, now);
22216
+ }
22217
+ function evaluateDecisionRow(db, row, currentCanonical, opts, now) {
22169
22218
  const decision = rowToDecision(row);
22170
22219
  if (decision.decision === "deny_perm") {
22171
22220
  return { state: "denied", decision };
@@ -22375,6 +22424,21 @@ class VaultBroker {
22375
22424
  this.grantsDb = openGrantsDb();
22376
22425
  }
22377
22426
  }
22427
+ auditFailClosedEnabled() {
22428
+ const env = process.env.SWITCHROOM_VAULT_AUDIT_FAIL_CLOSED;
22429
+ if (env !== undefined && env !== "") {
22430
+ return env === "1" || env.toLowerCase() === "true";
22431
+ }
22432
+ return this.config?.vault?.broker?.auditFailClosed === true;
22433
+ }
22434
+ auditedReleaseOk(socket, writeAudit, entry) {
22435
+ const durable = writeAudit(entry);
22436
+ if (!durable && this.auditFailClosedEnabled()) {
22437
+ socket.write(encodeResponse(errorResponse("AUDIT_UNAVAILABLE", "secret release denied: audit append failed and broker is in fail-closed mode (vault.broker.auditFailClosed)")));
22438
+ return false;
22439
+ }
22440
+ return true;
22441
+ }
22378
22442
  async start(socketPath, configPath, vaultPath) {
22379
22443
  if (process.platform !== "linux" && process.env.SWITCHROOM_BROKER_ALLOW_NON_LINUX !== "1") {
22380
22444
  throw new Error(`vault-broker is Linux-only (running on ${process.platform}). ` + `The broker's ACL relies on cgroup-based systemd unit identification, ` + `which is not available on this platform. ` + `Use 'switchroom vault get --no-broker' for direct vault access. ` + `If you need to run the broker for development on this platform, ` + `set SWITCHROOM_BROKER_ALLOW_NON_LINUX=1 — but understand that the ` + `broker will accept any same-user caller without per-cron ACL enforcement.`);
@@ -22442,7 +22506,7 @@ class VaultBroker {
22442
22506
  return;
22443
22507
  try {
22444
22508
  if (ready) {
22445
- writeFileSync2(p, "", { mode: 384 });
22509
+ writeFileSync3(p, "", { mode: 384 });
22446
22510
  } else if (existsSync11(p)) {
22447
22511
  unlinkSync6(p);
22448
22512
  }
@@ -22741,7 +22805,7 @@ class VaultBroker {
22741
22805
  const auditPeerUid = peer?.uid;
22742
22806
  const auditAgentName = agentName ?? undefined;
22743
22807
  const writeAudit = (entry) => {
22744
- this.auditLogger.write({
22808
+ return this.auditLogger.write({
22745
22809
  ...entry,
22746
22810
  peer_uid: entry.peer_uid ?? auditPeerUid,
22747
22811
  agent_name: entry.agent_name ?? auditAgentName
@@ -22850,8 +22914,8 @@ class VaultBroker {
22850
22914
  return;
22851
22915
  }
22852
22916
  }
22853
- if (!isOperator && agentName === null && process.platform === "linux" && peer === null) {
22854
- const reason = "Unable to identify caller (peercred unavailable); denying on Linux";
22917
+ if (!isOperator && agentName === null && process.platform === "linux") {
22918
+ const reason = "Unable to identify caller (no per-agent socket identity); denying on Linux";
22855
22919
  this.auditLogger.write({
22856
22920
  ts: new Date().toISOString(),
22857
22921
  op: "list",
@@ -22863,14 +22927,12 @@ class VaultBroker {
22863
22927
  socket.write(encodeResponse(errorResponse("DENIED", reason)));
22864
22928
  return;
22865
22929
  }
22866
- const listAgentSlug = agentName ?? (peer !== null ? agentSlugFromPeer(peer) : null);
22930
+ const listAgentSlug = agentName;
22867
22931
  let visibleKeys;
22868
22932
  if (isOperator) {
22869
22933
  visibleKeys = Object.entries(this.secrets).filter(([, entry]) => checkEntryScope(entry.scope, "operator").allow).map(([k]) => k);
22870
22934
  } else if (agentName !== null && this.config !== null) {
22871
22935
  visibleKeys = Object.entries(this.secrets).filter(([key, entry]) => checkAclByAgent(this.config, agentName, key).allow && checkEntryScope(entry.scope, agentName).allow).map(([k]) => k);
22872
- } else if (peer !== null && this.config !== null) {
22873
- visibleKeys = Object.entries(this.secrets).filter(([key, entry]) => checkAcl(peer, this.config, key).allow && checkEntryScope(entry.scope, listAgentSlug).allow).map(([k]) => k);
22874
22936
  } else {
22875
22937
  visibleKeys = Object.entries(this.secrets).filter(([, entry]) => checkEntryScope(entry.scope, listAgentSlug).allow).map(([k]) => k);
22876
22938
  }
@@ -22894,6 +22956,21 @@ class VaultBroker {
22894
22956
  const grantResult = await validateGrant(this.grantsDb, req.token, req.key);
22895
22957
  if (grantResult.ok) {
22896
22958
  const grantId = grantResult.grant.id;
22959
+ if (agentName !== null && grantResult.grant.agent_slug !== agentName) {
22960
+ this.auditLogger.write({
22961
+ ts: new Date().toISOString(),
22962
+ op: "get",
22963
+ key: req.key,
22964
+ caller: auditCaller,
22965
+ pid: auditPid,
22966
+ cgroup: auditCgroup,
22967
+ result: "denied:grant-identity-mismatch",
22968
+ method: "grant",
22969
+ grant_id: grantId
22970
+ });
22971
+ socket.write(encodeResponse(errorResponse("DENIED", "grant-identity-mismatch")));
22972
+ return;
22973
+ }
22897
22974
  const entry2 = this.secrets[req.key];
22898
22975
  if (entry2 === undefined) {
22899
22976
  this.auditLogger.write({
@@ -22910,7 +22987,23 @@ class VaultBroker {
22910
22987
  socket.write(encodeResponse(errorResponse("UNKNOWN_KEY", `Key not found: ${req.key}`)));
22911
22988
  return;
22912
22989
  }
22913
- this.auditLogger.write({
22990
+ const tokenScope = checkEntryScope(entry2.scope, grantResult.grant.agent_slug);
22991
+ if (!tokenScope.allow) {
22992
+ this.auditLogger.write({
22993
+ ts: new Date().toISOString(),
22994
+ op: "get",
22995
+ key: req.key,
22996
+ caller: auditCaller,
22997
+ pid: auditPid,
22998
+ cgroup: auditCgroup,
22999
+ result: `denied:${tokenScope.reason}`,
23000
+ method: "grant",
23001
+ grant_id: grantId
23002
+ });
23003
+ socket.write(encodeResponse(errorResponse("DENIED", tokenScope.reason)));
23004
+ return;
23005
+ }
23006
+ if (!this.auditedReleaseOk(socket, writeAudit, {
22914
23007
  ts: new Date().toISOString(),
22915
23008
  op: "get",
22916
23009
  key: req.key,
@@ -22920,7 +23013,9 @@ class VaultBroker {
22920
23013
  result: "allowed",
22921
23014
  method: "grant",
22922
23015
  grant_id: grantId
22923
- });
23016
+ })) {
23017
+ return;
23018
+ }
22924
23019
  socket.write(encodeResponse(entryResponse(entry2)));
22925
23020
  return;
22926
23021
  } else if (grantResult.reason === "grant-revoked") {
@@ -22974,23 +23069,8 @@ class VaultBroker {
22974
23069
  socket.write(encodeResponse(errorResponse("DENIED", aclResult.reason)));
22975
23070
  return;
22976
23071
  }
22977
- } else if (peer !== null && this.config !== null) {
22978
- const aclResult = checkAcl(peer, this.config, req.key);
22979
- if (!aclResult.allow) {
22980
- writeAudit({
22981
- ts: new Date().toISOString(),
22982
- op: "get",
22983
- key: req.key,
22984
- caller: auditCaller,
22985
- pid: auditPid,
22986
- cgroup: auditCgroup,
22987
- result: `denied:${aclResult.reason}`
22988
- });
22989
- socket.write(encodeResponse(errorResponse("DENIED", aclResult.reason)));
22990
- return;
22991
- }
22992
- } else if (process.platform === "linux" && peer === null) {
22993
- const reason = "Unable to identify caller (peercred unavailable); denying on Linux";
23072
+ } else if (process.platform === "linux" && agentName === null) {
23073
+ const reason = "Unable to identify caller (no per-agent socket identity); denying on Linux";
22994
23074
  this.auditLogger.write({
22995
23075
  ts: new Date().toISOString(),
22996
23076
  op: "get",
@@ -23017,7 +23097,7 @@ class VaultBroker {
23017
23097
  socket.write(encodeResponse(errorResponse("UNKNOWN_KEY", `Key not found: ${req.key}`)));
23018
23098
  return;
23019
23099
  }
23020
- const getAgentSlug = agentName ?? (peer !== null ? agentSlugFromPeer(peer) : null);
23100
+ const getAgentSlug = agentName;
23021
23101
  const scopeResult = checkEntryScope(entry.scope, getAgentSlug);
23022
23102
  if (!scopeResult.allow) {
23023
23103
  this.auditLogger.write({
@@ -23032,7 +23112,7 @@ class VaultBroker {
23032
23112
  socket.write(encodeResponse(errorResponse("DENIED", scopeResult.reason)));
23033
23113
  return;
23034
23114
  }
23035
- this.auditLogger.write({
23115
+ if (!this.auditedReleaseOk(socket, writeAudit, {
23036
23116
  ts: new Date().toISOString(),
23037
23117
  op: "get",
23038
23118
  key: req.key,
@@ -23040,7 +23120,9 @@ class VaultBroker {
23040
23120
  pid: auditPid,
23041
23121
  cgroup: auditCgroup,
23042
23122
  result: "allowed"
23043
- });
23123
+ })) {
23124
+ return;
23125
+ }
23044
23126
  socket.write(encodeResponse(entryResponse(entry)));
23045
23127
  return;
23046
23128
  }
@@ -23140,6 +23222,41 @@ class VaultBroker {
23140
23222
  if (req.token !== undefined && req.token !== "") {
23141
23223
  const v = await validateGrantForWrite(this.grantsDb, req.token, req.key);
23142
23224
  if (v.ok) {
23225
+ const writeGrantAgentSlug = v.grant.agent_slug;
23226
+ if (agentName !== null && writeGrantAgentSlug !== agentName) {
23227
+ this.auditLogger.write({
23228
+ ts: new Date().toISOString(),
23229
+ op: "put",
23230
+ key: req.key,
23231
+ caller: auditCaller,
23232
+ pid: auditPid,
23233
+ cgroup: auditCgroup,
23234
+ result: "denied:grant-identity-mismatch",
23235
+ method: "grant",
23236
+ grant_id: v.grant.id
23237
+ });
23238
+ socket.write(encodeResponse(errorResponse("DENIED", "grant-identity-mismatch")));
23239
+ return;
23240
+ }
23241
+ const existingForScope = this.secrets[req.key];
23242
+ if (existingForScope !== undefined) {
23243
+ const writeScope = checkEntryScope(existingForScope.scope, writeGrantAgentSlug);
23244
+ if (!writeScope.allow) {
23245
+ this.auditLogger.write({
23246
+ ts: new Date().toISOString(),
23247
+ op: "put",
23248
+ key: req.key,
23249
+ caller: auditCaller,
23250
+ pid: auditPid,
23251
+ cgroup: auditCgroup,
23252
+ result: `denied:${writeScope.reason}`,
23253
+ method: "grant",
23254
+ grant_id: v.grant.id
23255
+ });
23256
+ socket.write(encodeResponse(errorResponse("DENIED", writeScope.reason)));
23257
+ return;
23258
+ }
23259
+ }
23143
23260
  writeGrantId = v.grant.id;
23144
23261
  } else if (v.reason === "grant-expired" || v.reason === "grant-revoked") {
23145
23262
  this.auditLogger.write({
@@ -23448,21 +23565,6 @@ class VaultBroker {
23448
23565
  socket.write(encodeResponse(errorResponse("DENIED", "peercred unavailable; cannot verify operator identity")));
23449
23566
  return;
23450
23567
  }
23451
- if (peer !== null && peer.systemdUnit !== null) {
23452
- const parsed = parseCronUnit(peer.systemdUnit);
23453
- if (parsed !== null) {
23454
- this.auditLogger.write({
23455
- ts: new Date().toISOString(),
23456
- op: req.op,
23457
- caller: auditCaller,
23458
- pid: auditPid,
23459
- cgroup: auditCgroup,
23460
- result: "denied:cron-cannot-manage-grants"
23461
- });
23462
- socket.write(encodeResponse(errorResponse("DENIED", "Grant management ops are operator-only; cron units cannot mint, list, or revoke grants")));
23463
- return;
23464
- }
23465
- }
23466
23568
  }
23467
23569
  }
23468
23570
  if (req.op === "mint_grant") {
@@ -23501,8 +23603,8 @@ class VaultBroker {
23501
23603
  mkdirSync6(tokenDir, { recursive: true });
23502
23604
  const tokenPath = path3.join(tokenDir, ".vault-token");
23503
23605
  const tmpPath = `${tokenPath}.tmp.${process.pid}`;
23504
- writeFileSync2(tmpPath, mintResult.token, { mode: 384 });
23505
- renameSync6(tmpPath, tokenPath);
23606
+ writeFileSync3(tmpPath, mintResult.token, { mode: 384 });
23607
+ renameSync7(tmpPath, tokenPath);
23506
23608
  try {
23507
23609
  const uid = allocateAgentUid(agent);
23508
23610
  chownSync(tokenPath, uid, uid);
@@ -23788,7 +23890,7 @@ class VaultBroker {
23788
23890
  _writePidFile() {
23789
23891
  try {
23790
23892
  const pidPath = resolvePath(PID_FILE_DEFAULT);
23791
- writeFileSync2(pidPath, String(process.pid) + `
23893
+ writeFileSync3(pidPath, String(process.pid) + `
23792
23894
  `, { mode: 384 });
23793
23895
  } catch {}
23794
23896
  }
@@ -23844,7 +23946,7 @@ class VaultBroker {
23844
23946
  const credPath = `${dir}/vault-passphrase`;
23845
23947
  let passphrase;
23846
23948
  try {
23847
- passphrase = readFileSync9(credPath, "utf8").replace(/\n+$/, "");
23949
+ passphrase = readFileSync10(credPath, "utf8").replace(/\n+$/, "");
23848
23950
  } catch (err) {
23849
23951
  const code = err.code;
23850
23952
  if (code === "ENOENT") {