switchroom 0.7.15 → 0.10.0

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 (301) hide show
  1. package/README.md +51 -59
  2. package/bin/run-hook.sh +27 -11
  3. package/bin/timezone-hook.sh +9 -7
  4. package/dist/agent-scheduler/index.js +410 -133
  5. package/dist/auth-broker/index.js +13932 -0
  6. package/dist/cli/switchroom.js +26937 -5601
  7. package/dist/host-control/main.js +12702 -0
  8. package/dist/vault/approvals/kernel-server.js +467 -184
  9. package/dist/vault/broker/server.js +1430 -724
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +7 -4
  16. package/profiles/_base/settings.json.hbs +20 -5
  17. package/profiles/_base/start.sh.hbs +16 -3
  18. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  19. package/profiles/_shared/telegram-style.md.hbs +20 -90
  20. package/profiles/_shared/vault-protocol.md.hbs +68 -0
  21. package/profiles/default/CLAUDE.md +50 -96
  22. package/profiles/default/CLAUDE.md.hbs +36 -6
  23. package/profiles/default/workspace/SOUL.md.hbs +12 -5
  24. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  25. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  26. package/skills/buildkite-api/SKILL.md +31 -8
  27. package/skills/buildkite-cli/SKILL.md +27 -9
  28. package/skills/buildkite-migration/SKILL.md +22 -9
  29. package/skills/buildkite-pipelines/SKILL.md +26 -9
  30. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  31. package/skills/buildkite-test-engine/SKILL.md +25 -8
  32. package/skills/docx/SKILL.md +1 -1
  33. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  34. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  35. package/skills/file-bug/SKILL.md +34 -6
  36. package/skills/humanizer/SKILL.md +15 -0
  37. package/skills/humanizer-calibrate/SKILL.md +7 -1
  38. package/skills/mcp-builder/SKILL.md +1 -1
  39. package/skills/pdf/SKILL.md +1 -1
  40. package/skills/pptx/SKILL.md +1 -1
  41. package/skills/skill-creator/SKILL.md +21 -1
  42. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  44. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  45. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  46. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  47. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  48. package/skills/switchroom-cli/SKILL.md +63 -64
  49. package/skills/switchroom-health/SKILL.md +23 -10
  50. package/skills/switchroom-install/SKILL.md +3 -3
  51. package/skills/switchroom-manage/SKILL.md +26 -19
  52. package/skills/switchroom-runtime/SKILL.md +191 -0
  53. package/skills/switchroom-status/SKILL.md +27 -2
  54. package/skills/telegram-test-harness/SKILL.md +3 -0
  55. package/skills/token-helpers/SKILL.md +24 -1
  56. package/skills/webapp-testing/SKILL.md +31 -1
  57. package/skills/xlsx/SKILL.md +1 -1
  58. package/telegram-plugin/admin-commands/index.ts +7 -5
  59. package/telegram-plugin/analytics-posthog.ts +191 -0
  60. package/telegram-plugin/bridge/bridge.ts +69 -0
  61. package/telegram-plugin/bridge/ipc-client.ts +4 -1
  62. package/telegram-plugin/dist/bridge/bridge.js +194 -119
  63. package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
  64. package/telegram-plugin/dist/server.js +245 -189
  65. package/telegram-plugin/first-paint.ts +3 -24
  66. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  67. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  68. package/telegram-plugin/gateway/auth-command.ts +794 -0
  69. package/telegram-plugin/gateway/auth-line.ts +123 -0
  70. package/telegram-plugin/gateway/boot-card.ts +169 -40
  71. package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
  72. package/telegram-plugin/gateway/boot-probes.ts +166 -123
  73. package/telegram-plugin/gateway/boot-reason.ts +41 -7
  74. package/telegram-plugin/gateway/boot-version.ts +66 -0
  75. package/telegram-plugin/gateway/gateway.ts +3499 -1885
  76. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  77. package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
  78. package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
  79. package/telegram-plugin/gateway/quarantine.ts +69 -0
  80. package/telegram-plugin/gateway/quota-cache.ts +9 -4
  81. package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
  82. package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
  83. package/telegram-plugin/gateway/recent-denials.ts +77 -0
  84. package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
  85. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
  86. package/telegram-plugin/history.ts +91 -0
  87. package/telegram-plugin/hooks/hooks.json +10 -0
  88. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
  89. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
  90. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
  91. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  92. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  93. package/telegram-plugin/inbound-classifier.ts +50 -0
  94. package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
  95. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  96. package/telegram-plugin/package.json +4 -2
  97. package/telegram-plugin/permission-rule.ts +51 -0
  98. package/telegram-plugin/permission-title.ts +56 -0
  99. package/telegram-plugin/quota-check.ts +19 -41
  100. package/telegram-plugin/registry/reaper.ts +223 -0
  101. package/telegram-plugin/retry-api-call.ts +80 -0
  102. package/telegram-plugin/runtime-metrics.ts +177 -0
  103. package/telegram-plugin/scripts/build.mjs +0 -1
  104. package/telegram-plugin/secret-detect/index.ts +24 -0
  105. package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
  106. package/telegram-plugin/secret-detect/vault-error.ts +78 -11
  107. package/telegram-plugin/secret-detect/vault-write.ts +14 -2
  108. package/telegram-plugin/server.js +41795 -0
  109. package/telegram-plugin/session-tail.ts +6 -1
  110. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  111. package/telegram-plugin/silence-poke.ts +420 -0
  112. package/telegram-plugin/silent-end.ts +174 -0
  113. package/telegram-plugin/stream-controller.ts +13 -0
  114. package/telegram-plugin/stream-reply-handler.ts +7 -0
  115. package/telegram-plugin/subagent-watcher.ts +213 -4
  116. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  117. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  118. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  119. package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
  120. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
  121. package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
  122. package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
  123. package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
  124. package/telegram-plugin/tests/boot-probes.test.ts +216 -10
  125. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  126. package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
  127. package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
  128. package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
  129. package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
  130. package/telegram-plugin/tests/history-reaper.test.ts +378 -0
  131. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  132. package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
  133. package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
  134. package/telegram-plugin/tests/issues-card.test.ts +49 -0
  135. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
  136. package/telegram-plugin/tests/permission-rule.test.ts +80 -1
  137. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  138. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  139. package/telegram-plugin/tests/races.test.ts +179 -0
  140. package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
  141. package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
  142. package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
  143. package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
  144. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
  145. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
  146. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
  147. package/telegram-plugin/tests/silence-poke.test.ts +493 -0
  148. package/telegram-plugin/tests/silent-end.test.ts +206 -0
  149. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
  150. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
  151. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
  152. package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
  153. package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
  154. package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
  155. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
  156. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
  157. package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
  158. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
  159. package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
  160. package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
  161. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
  162. package/telegram-plugin/turn-signal-tracker.ts +100 -24
  163. package/telegram-plugin/uat/SETUP.md +210 -35
  164. package/telegram-plugin/uat/assertions.ts +264 -37
  165. package/telegram-plugin/uat/driver-info.ts +57 -0
  166. package/telegram-plugin/uat/driver.ts +590 -51
  167. package/telegram-plugin/uat/harness.ts +140 -94
  168. package/telegram-plugin/uat/load-env.test.ts +72 -0
  169. package/telegram-plugin/uat/load-env.ts +48 -0
  170. package/telegram-plugin/uat/login.ts +96 -53
  171. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  172. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  173. package/telegram-plugin/uat/runners/report.ts +150 -0
  174. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  175. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  176. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  177. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  178. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  179. package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
  180. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
  181. package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
  182. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
  183. package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
  184. package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
  185. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
  186. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
  187. package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
  188. package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
  189. package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
  190. package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
  191. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
  192. package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
  193. package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
  194. package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
  195. package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
  196. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
  197. package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
  198. package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
  199. package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
  200. package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
  201. package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
  202. package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
  203. package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
  204. package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
  205. package/telegram-plugin/vault-approval-posture.ts +42 -0
  206. package/telegram-plugin/welcome-text.ts +1 -0
  207. package/telegram-plugin/active-pins-sweep.ts +0 -204
  208. package/telegram-plugin/active-pins.ts +0 -146
  209. package/telegram-plugin/auth-dashboard.ts +0 -1104
  210. package/telegram-plugin/auth-slot-parser.ts +0 -497
  211. package/telegram-plugin/card-event-log.ts +0 -138
  212. package/telegram-plugin/dist/foreman/foreman.js +0 -31106
  213. package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
  214. package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
  215. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  216. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  217. package/telegram-plugin/foreman/foreman.ts +0 -1165
  218. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  219. package/telegram-plugin/foreman/setup-state.ts +0 -239
  220. package/telegram-plugin/foreman/state.ts +0 -203
  221. package/telegram-plugin/pin-event-log.ts +0 -76
  222. package/telegram-plugin/progress-card-driver.ts +0 -2886
  223. package/telegram-plugin/progress-card-pin-manager.ts +0 -589
  224. package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
  225. package/telegram-plugin/progress-card.ts +0 -1409
  226. package/telegram-plugin/tests/HARNESS.md +0 -340
  227. package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
  228. package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
  229. package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
  230. package/telegram-plugin/tests/active-pins.test.ts +0 -187
  231. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  232. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  233. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  234. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  235. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  236. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  237. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
  238. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  239. package/telegram-plugin/tests/card-event-log.test.ts +0 -145
  240. package/telegram-plugin/tests/first-paint.test.ts +0 -257
  241. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  242. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  243. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  244. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  245. package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
  246. package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
  247. package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
  248. package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
  249. package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
  250. package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
  251. package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
  252. package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
  253. package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
  254. package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
  255. package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
  256. package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
  257. package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
  258. package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
  259. package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
  260. package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
  261. package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
  262. package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
  263. package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
  264. package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
  265. package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
  266. package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
  267. package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
  268. package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
  269. package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
  270. package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
  271. package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
  272. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  273. package/telegram-plugin/tests/setup-state.test.ts +0 -146
  274. package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
  275. package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
  276. package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
  277. package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
  278. package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
  279. package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
  280. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
  281. package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
  282. package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
  283. package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
  284. package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
  285. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
  286. package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
  287. package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
  288. package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
  289. package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
  290. package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
  291. package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
  292. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
  293. package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
  294. package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
  295. package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
  296. package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
  297. package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
  298. package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
  299. package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
  300. package/telegram-plugin/two-zone-card.ts +0 -269
  301. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Reaction-trigger predicate, per-chat hour cap, and debounce buffer.
3
+ *
4
+ * Issue: https://github.com/switchroom/switchroom/issues/1074
5
+ *
6
+ * Wires bot-message emoji reactions into the agent as synthetic
7
+ * `<channel source="reaction">` inbound turns. Mirrors the cron-fold-in
8
+ * dispatch path (`meta.source="cron"` → `meta.source="reaction"`).
9
+ *
10
+ * This module is gateway-internal pure logic — no Telegram API calls,
11
+ * no IPC. The gateway's `message_reaction` handler wires:
12
+ *
13
+ * 1. `evaluateTriggerCandidate(...)` — synchronous predicate.
14
+ * 2. (async, group only) admin-status lookup via getChatMember.
15
+ * 3. `HourCap.tryConsume(chatId)` — refuses past the per-hour limit.
16
+ * 4. `DebounceBuffer.enqueue(...)` — batches rapid reactions into a
17
+ * single delivered synthetic; the buffer's caller emits the
18
+ * InboundMessageWire.
19
+ *
20
+ * Defaults are baked here so the gateway can resolve them from a
21
+ * possibly-undefined cascade slice (`config.agents[name].reactions`).
22
+ *
23
+ * Trust model: same as cron-fold-in (`src/scheduler/dispatch.ts`).
24
+ * The synthesized inbound's `text` carries an `<channel
25
+ * source="reaction">` envelope plus the bot-side message preview
26
+ * (capped) — NO bot token, NO vault material.
27
+ */
28
+
29
+ export interface ReactionsResolvedConfig {
30
+ enabled: boolean;
31
+ triggerEmojis: ReadonlySet<string>;
32
+ debounceMs: number;
33
+ perHourCap: number;
34
+ groupAdminOnly: boolean;
35
+ }
36
+
37
+ /**
38
+ * Built-in defaults — applied when the cascade does not set a field.
39
+ * Documented in `docs/configuration.md` and stamped as the spec
40
+ * decision (Ken approved 2026-05-12).
41
+ */
42
+ export const REACTIONS_DEFAULTS: ReactionsResolvedConfig = Object.freeze({
43
+ enabled: true,
44
+ triggerEmojis: Object.freeze(new Set(['👎', '❌', '👍', '✅'])) as ReadonlySet<string>,
45
+ debounceMs: 30_000,
46
+ perHourCap: 10,
47
+ groupAdminOnly: true,
48
+ });
49
+
50
+ /**
51
+ * Cascade-resolved reactions slice as it appears on the agent config.
52
+ * Shape mirrors `ReactionsSchema` in `src/config/schema.ts`. We type
53
+ * the raw input loosely so this module can stay independent of the
54
+ * src/ side's zod schemas.
55
+ */
56
+ export interface ReactionsConfigInput {
57
+ enabled?: boolean;
58
+ trigger_emojis?: readonly string[];
59
+ debounce_ms?: number;
60
+ per_hour_cap?: number;
61
+ group_admin_only?: boolean;
62
+ }
63
+
64
+ /**
65
+ * Fold a raw cascade-resolved `reactions:` block into the runtime
66
+ * shape, filling in defaults for missing fields. A `null` or
67
+ * `undefined` raw input collapses to the built-in defaults.
68
+ */
69
+ export function resolveReactionsConfig(
70
+ raw: ReactionsConfigInput | null | undefined,
71
+ ): ReactionsResolvedConfig {
72
+ if (!raw) return REACTIONS_DEFAULTS;
73
+ return {
74
+ enabled: raw.enabled ?? REACTIONS_DEFAULTS.enabled,
75
+ triggerEmojis: raw.trigger_emojis !== undefined
76
+ ? new Set(raw.trigger_emojis)
77
+ : REACTIONS_DEFAULTS.triggerEmojis,
78
+ debounceMs: raw.debounce_ms ?? REACTIONS_DEFAULTS.debounceMs,
79
+ perHourCap: raw.per_hour_cap ?? REACTIONS_DEFAULTS.perHourCap,
80
+ groupAdminOnly: raw.group_admin_only ?? REACTIONS_DEFAULTS.groupAdminOnly,
81
+ };
82
+ }
83
+
84
+ // ─── Predicate ───────────────────────────────────────────────────────────
85
+
86
+ export interface TriggerCandidate {
87
+ /** Negative for groups/supergroups, positive for DMs (Bot API convention). */
88
+ chatId: number;
89
+ /** Telegram message_id the reaction was placed on. */
90
+ messageId: number;
91
+ /** Emoji string from the new_reaction; null when not a plain emoji. */
92
+ emoji: string | null;
93
+ /** 'add' | 'change' — 'remove' candidates are rejected pre-call. */
94
+ action: 'add' | 'change';
95
+ /** Whether the target message was authored by the bot (lookup). */
96
+ botAuthored: boolean;
97
+ }
98
+
99
+ export type TriggerDecision =
100
+ | { ok: true }
101
+ | { ok: false; reason:
102
+ | 'disabled'
103
+ | 'not_bot_authored'
104
+ | 'emoji_not_in_allowlist'
105
+ | 'no_emoji' };
106
+
107
+ /**
108
+ * Synchronous predicate — checks everything the gateway can decide
109
+ * without an API round-trip. Group-admin check and hour-cap consumption
110
+ * are layered above this by the gateway handler.
111
+ */
112
+ export function evaluateTriggerCandidate(
113
+ cfg: ReactionsResolvedConfig,
114
+ c: TriggerCandidate,
115
+ ): TriggerDecision {
116
+ if (!cfg.enabled) return { ok: false, reason: 'disabled' };
117
+ if (!c.botAuthored) return { ok: false, reason: 'not_bot_authored' };
118
+ if (c.emoji === null) return { ok: false, reason: 'no_emoji' };
119
+ if (!cfg.triggerEmojis.has(c.emoji)) {
120
+ return { ok: false, reason: 'emoji_not_in_allowlist' };
121
+ }
122
+ return { ok: true };
123
+ }
124
+
125
+ /** Group/supergroup chats use negative IDs in the Bot API. */
126
+ export function isGroupChat(chatId: number): boolean {
127
+ return chatId < 0;
128
+ }
129
+
130
+ // ─── Per-chat hour cap ───────────────────────────────────────────────────
131
+
132
+ const HOUR_MS = 60 * 60 * 1000;
133
+
134
+ /**
135
+ * In-memory rolling-1-hour counter per chat. Pure data structure —
136
+ * not exported to a singleton so tests can construct their own.
137
+ *
138
+ * The cap is enforced at point-of-consume. Refusals don't surface to
139
+ * the agent (the user may not even know they reacted past the cap);
140
+ * the gateway logs them to stderr.
141
+ */
142
+ export class HourCap {
143
+ private readonly stamps = new Map<string, number[]>();
144
+ constructor(
145
+ private readonly cap: number,
146
+ private readonly now: () => number = Date.now,
147
+ ) {}
148
+
149
+ /**
150
+ * Returns true if the caller may proceed (and records the timestamp).
151
+ * Returns false when the chat is at-or-past the cap in the trailing
152
+ * hour window. Cap=0 always refuses.
153
+ */
154
+ tryConsume(chatId: string): boolean {
155
+ if (this.cap <= 0) return false;
156
+ const t = this.now();
157
+ const cutoff = t - HOUR_MS;
158
+ const arr = this.stamps.get(chatId) ?? [];
159
+ // Prune in-place — cheap as long as cap stays small (<= 100s).
160
+ const pruned = arr.filter((s) => s > cutoff);
161
+ if (pruned.length >= this.cap) {
162
+ this.stamps.set(chatId, pruned);
163
+ return false;
164
+ }
165
+ pruned.push(t);
166
+ this.stamps.set(chatId, pruned);
167
+ return true;
168
+ }
169
+
170
+ /** Trailing-hour count for inspection / metrics. Test-only friendly. */
171
+ size(chatId: string): number {
172
+ const cutoff = this.now() - HOUR_MS;
173
+ return (this.stamps.get(chatId) ?? []).filter((s) => s > cutoff).length;
174
+ }
175
+ }
176
+
177
+ // ─── Debounce buffer ─────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * One pending reaction held in the buffer.
181
+ */
182
+ export interface PendingReaction {
183
+ /** Bot-side message id the user reacted to. */
184
+ targetMessageId: number;
185
+ /** Emoji from the new_reaction. */
186
+ emoji: string;
187
+ /** add | change. */
188
+ action: 'add' | 'change';
189
+ /** Acquired wall-clock ms. */
190
+ ts: number;
191
+ /** First ~200 chars of the bot message text (preview). */
192
+ preview: string;
193
+ /** Reacter user_id for the synthesized inbound's userId field. */
194
+ userId: number;
195
+ /** Display name of the reacter (first_name → username → string id). */
196
+ user: string;
197
+ /** Forum thread id if the reacted message was in a topic. */
198
+ threadId?: number;
199
+ }
200
+
201
+ /**
202
+ * Collapsed delivery payload — the buffer hands one of these to its
203
+ * sink when the debounce window elapses. `batched` carries N>=2
204
+ * entries; `single` carries exactly one.
205
+ */
206
+ export interface ReactionBatch {
207
+ /** Bot API chatId (number form — gateway stringifies for the wire). */
208
+ chatId: number;
209
+ reactions: PendingReaction[];
210
+ /** True when >1 reaction collapsed into this delivery. */
211
+ batched: boolean;
212
+ }
213
+
214
+ /** Maximum inline reactions named in a batched synthetic's text. */
215
+ export const BATCH_INLINE_LIMIT = 10;
216
+ /** Max preview length (chars) of the bot message the user reacted to. */
217
+ export const PREVIEW_MAX_CHARS = 200;
218
+
219
+ /**
220
+ * Truncate to PREVIEW_MAX_CHARS, marking trailing truncation with `…`.
221
+ * Returns "" for null/undefined input; safe to pass arbitrary strings.
222
+ */
223
+ export function truncatePreview(text: string | null | undefined): string {
224
+ if (!text) return '';
225
+ if (text.length <= PREVIEW_MAX_CHARS) return text;
226
+ return text.slice(0, PREVIEW_MAX_CHARS - 1) + '…';
227
+ }
228
+
229
+ /**
230
+ * Per-chat reaction debounce buffer.
231
+ *
232
+ * On `enqueue`, the buffer either starts a new timer (single pending)
233
+ * or appends to an existing one (batched). When the timer fires, the
234
+ * buffer hands the accumulated batch to `sink` and clears.
235
+ *
236
+ * Uses node's setTimeout under the hood via the injected `schedule`
237
+ * helper so tests can drive it with a fake clock.
238
+ *
239
+ * Each pending entry is bounded by the cap (default
240
+ * `BATCH_INLINE_LIMIT * 4 = 40`) — older entries beyond the cap are
241
+ * dropped silently to prevent unbounded growth under a reaction storm.
242
+ */
243
+ export class DebounceBuffer {
244
+ private readonly pending = new Map<number, PendingReaction[]>();
245
+ private readonly timers = new Map<number, ReturnType<typeof setTimeout>>();
246
+ private readonly maxPending: number;
247
+
248
+ constructor(
249
+ private readonly windowMs: number,
250
+ private readonly sink: (batch: ReactionBatch) => void,
251
+ opts?: {
252
+ maxPending?: number;
253
+ /** Test-only injection of timer functions. */
254
+ schedule?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
255
+ cancel?: (h: ReturnType<typeof setTimeout>) => void;
256
+ },
257
+ ) {
258
+ this.maxPending = opts?.maxPending ?? BATCH_INLINE_LIMIT * 4;
259
+ if (opts?.schedule) this.schedule = opts.schedule;
260
+ if (opts?.cancel) this.cancel = opts.cancel;
261
+ }
262
+
263
+ private schedule: (fn: () => void, ms: number) => ReturnType<typeof setTimeout> =
264
+ setTimeout;
265
+ private cancel: (h: ReturnType<typeof setTimeout>) => void = clearTimeout;
266
+
267
+ enqueue(chatId: number, entry: PendingReaction): void {
268
+ const existing = this.pending.get(chatId);
269
+ if (existing) {
270
+ if (existing.length < this.maxPending) existing.push(entry);
271
+ // else: storm — drop. Older entries are kept because they came first
272
+ // and are still informative; new ones add nothing past the cap.
273
+ return;
274
+ }
275
+ this.pending.set(chatId, [entry]);
276
+ const h = this.schedule(() => this.flush(chatId), this.windowMs);
277
+ this.timers.set(chatId, h);
278
+ }
279
+
280
+ /**
281
+ * Flush a chat's pending batch immediately. Used by tests and by
282
+ * shutdown drains. Idempotent — flushing an empty chat is a no-op.
283
+ */
284
+ flush(chatId: number): void {
285
+ const reactions = this.pending.get(chatId);
286
+ this.pending.delete(chatId);
287
+ const h = this.timers.get(chatId);
288
+ if (h) {
289
+ this.cancel(h);
290
+ this.timers.delete(chatId);
291
+ }
292
+ if (!reactions || reactions.length === 0) return;
293
+ this.sink({
294
+ chatId,
295
+ reactions,
296
+ batched: reactions.length > 1,
297
+ });
298
+ }
299
+
300
+ /** Test-only: number of chats with pending entries. */
301
+ pendingChatCount(): number {
302
+ return this.pending.size;
303
+ }
304
+
305
+ /** Drain all pending without firing the sink — used on shutdown. */
306
+ clear(): void {
307
+ for (const h of this.timers.values()) this.cancel(h);
308
+ this.timers.clear();
309
+ this.pending.clear();
310
+ }
311
+ }
312
+
313
+ // ─── Inbound text builder ────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Build the `text` field of the synthesized InboundMessage. The agent
317
+ * sees this as a turn — the `<channel source="reaction">` envelope
318
+ * signals the source. Group of helpers is exported so tests can pin
319
+ * the exact wire shape.
320
+ */
321
+ export function buildReactionInboundText(batch: ReactionBatch): string {
322
+ if (batch.reactions.length === 0) {
323
+ // Defensive — buildReactionInboundText should never see an empty
324
+ // batch since DebounceBuffer.flush early-returns on empty.
325
+ return '<channel source="reaction"/>';
326
+ }
327
+ if (batch.reactions.length === 1) {
328
+ const r = batch.reactions[0]!;
329
+ const safeEmoji = escapeAttr(r.emoji);
330
+ const safeAction = escapeAttr(r.action);
331
+ const safePreview = escapeBody(r.preview);
332
+ return (
333
+ `<channel source="reaction" emoji="${safeEmoji}" ` +
334
+ `action="${safeAction}" target_message_id="${r.targetMessageId}">` +
335
+ `User reacted ${r.emoji} to your message: "${safePreview}"` +
336
+ `</channel>`
337
+ );
338
+ }
339
+ // Batched
340
+ const total = batch.reactions.length;
341
+ const shown = batch.reactions.slice(0, BATCH_INLINE_LIMIT);
342
+ const more = total - shown.length;
343
+ const lines = shown.map(
344
+ (r) => `${r.emoji} on msg ${r.targetMessageId} ("${escapeBody(r.preview)}")`,
345
+ );
346
+ const trailer = more > 0 ? ` (+${more} more)` : '';
347
+ return (
348
+ `<channel source="reaction" batched="true" count="${total}">` +
349
+ `User reacted to your messages — ${total} new reactions: ` +
350
+ lines.join('; ') +
351
+ trailer +
352
+ `</channel>`
353
+ );
354
+ }
355
+
356
+ /**
357
+ * Build the `meta` map. Wire-format requires string values only.
358
+ */
359
+ export function buildReactionInboundMeta(batch: ReactionBatch): Record<string, string> {
360
+ const r = batch.reactions[0];
361
+ if (!r) {
362
+ return { source: 'reaction', batched: 'false', count: '0' };
363
+ }
364
+ if (!batch.batched) {
365
+ return {
366
+ source: 'reaction',
367
+ reaction_emoji: r.emoji,
368
+ reaction_action: r.action,
369
+ target_message_id: String(r.targetMessageId),
370
+ target_message_preview: r.preview,
371
+ batched: 'false',
372
+ count: '1',
373
+ };
374
+ }
375
+ return {
376
+ source: 'reaction',
377
+ batched: 'true',
378
+ count: String(batch.reactions.length),
379
+ // For batched deliveries we still expose the first reaction's
380
+ // discriminators — preserves the single-shape contract for
381
+ // downstream consumers that only care about "the most recent".
382
+ reaction_emoji: r.emoji,
383
+ reaction_action: r.action,
384
+ target_message_id: String(r.targetMessageId),
385
+ target_message_preview: r.preview,
386
+ };
387
+ }
388
+
389
+ // Minimal XML-attr escape; preview body uses a slightly looser escape
390
+ // because it lands inside the element body, not an attribute value.
391
+ function escapeAttr(s: string): string {
392
+ return s
393
+ .replace(/&/g, '&amp;')
394
+ .replace(/"/g, '&quot;')
395
+ .replace(/</g, '&lt;')
396
+ .replace(/>/g, '&gt;');
397
+ }
398
+
399
+ function escapeBody(s: string): string {
400
+ return s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
401
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tests for the recent-denial scanner (#969 P2b).
3
+ */
4
+
5
+ import { describe, it, expect } from "vitest";
6
+ import { recentDenialsFromAuditLog } from "./recent-denials.js";
7
+
8
+ // Anchor "now" in test runs so the windowing is reproducible.
9
+ const NOW_MS = Date.parse("2026-05-11T12:00:00Z");
10
+
11
+ function entry(o: Partial<{ ts: string; agent_name: string; key: string; result: string }>): string {
12
+ return JSON.stringify({
13
+ ts: o.ts,
14
+ op: "get",
15
+ caller: "pid:1234",
16
+ pid: 1234,
17
+ agent_name: o.agent_name,
18
+ key: o.key,
19
+ result: o.result ?? "denied:scope-allow",
20
+ });
21
+ }
22
+
23
+ describe("recentDenialsFromAuditLog", () => {
24
+ it("returns empty on empty log", () => {
25
+ expect(
26
+ recentDenialsFromAuditLog("", { agentName: "klanker", windowMs: 1000, limit: 5, nowMs: NOW_MS }),
27
+ ).toEqual([]);
28
+ });
29
+
30
+ it("filters to the target agent only", () => {
31
+ const log = [
32
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "k1", result: "denied:scope-allow" }),
33
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "OTHER", key: "k2", result: "denied:scope-allow" }),
34
+ ].join("\n");
35
+ const r = recentDenialsFromAuditLog(log, { agentName: "klanker", windowMs: 24 * 3600 * 1000, limit: 5, nowMs: NOW_MS });
36
+ expect(r).toHaveLength(1);
37
+ expect(r[0].key).toBe("k1");
38
+ });
39
+
40
+ it("filters to denied results only (drops allowed)", () => {
41
+ const log = [
42
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "k1", result: "allowed" }),
43
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "k2", result: "denied:scope-allow" }),
44
+ ].join("\n");
45
+ const r = recentDenialsFromAuditLog(log, { agentName: "klanker", windowMs: 24 * 3600 * 1000, limit: 5, nowMs: NOW_MS });
46
+ expect(r.map((x) => x.key)).toEqual(["k2"]);
47
+ });
48
+
49
+ it("groups multiple denials for the same key into a count", () => {
50
+ const log = [
51
+ entry({ ts: "2026-05-11T10:00:00Z", agent_name: "klanker", key: "openai", result: "denied:scope-allow" }),
52
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "openai", result: "denied:scope-allow" }),
53
+ entry({ ts: "2026-05-11T11:30:00Z", agent_name: "klanker", key: "openai", result: "denied:scope-allow" }),
54
+ ].join("\n");
55
+ const r = recentDenialsFromAuditLog(log, { agentName: "klanker", windowMs: 24 * 3600 * 1000, limit: 5, nowMs: NOW_MS });
56
+ expect(r).toHaveLength(1);
57
+ expect(r[0]).toMatchObject({ key: "openai", count: 3 });
58
+ expect(r[0].lastSeenMs).toBe(Date.parse("2026-05-11T11:30:00Z"));
59
+ });
60
+
61
+ it("excludes entries older than the window", () => {
62
+ const log = [
63
+ entry({ ts: "2026-05-01T00:00:00Z", agent_name: "klanker", key: "stale" }), // > 7 days
64
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "fresh" }),
65
+ ].join("\n");
66
+ const r = recentDenialsFromAuditLog(log, { agentName: "klanker", windowMs: 7 * 24 * 3600 * 1000, limit: 5, nowMs: NOW_MS });
67
+ expect(r.map((x) => x.key)).toEqual(["fresh"]);
68
+ });
69
+
70
+ it("sorts newest first and applies limit", () => {
71
+ const log = [
72
+ entry({ ts: "2026-05-11T09:00:00Z", agent_name: "klanker", key: "a" }),
73
+ entry({ ts: "2026-05-11T10:00:00Z", agent_name: "klanker", key: "b" }),
74
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "c" }),
75
+ entry({ ts: "2026-05-11T11:30:00Z", agent_name: "klanker", key: "d" }),
76
+ ].join("\n");
77
+ const r = recentDenialsFromAuditLog(log, { agentName: "klanker", windowMs: 24 * 3600 * 1000, limit: 2, nowMs: NOW_MS });
78
+ expect(r.map((x) => x.key)).toEqual(["d", "c"]);
79
+ });
80
+
81
+ it("drops keys that don't match the safe slug regex", () => {
82
+ // Defensive: a tampered log line should not surface a button with
83
+ // injection-shaped data, even though Telegram callback_data is
84
+ // sanitized at render time too.
85
+ const log = [
86
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "../etc/passwd" }),
87
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "good_key" }),
88
+ ].join("\n");
89
+ const r = recentDenialsFromAuditLog(log, { agentName: "klanker", windowMs: 24 * 3600 * 1000, limit: 5, nowMs: NOW_MS });
90
+ expect(r.map((x) => x.key)).toEqual(["good_key"]);
91
+ });
92
+
93
+ it("ignores malformed JSON lines silently", () => {
94
+ const log = [
95
+ "not json",
96
+ "{trailing comma,}",
97
+ entry({ ts: "2026-05-11T11:00:00Z", agent_name: "klanker", key: "valid" }),
98
+ ].join("\n");
99
+ const r = recentDenialsFromAuditLog(log, { agentName: "klanker", windowMs: 24 * 3600 * 1000, limit: 5, nowMs: NOW_MS });
100
+ expect(r).toHaveLength(1);
101
+ expect(r[0].key).toBe("valid");
102
+ });
103
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Recent-denial scanner (issue #969 P2b).
3
+ *
4
+ * Parses the vault broker's NDJSON audit log to surface keys an agent
5
+ * was recently denied access to. Used by the Telegram gateway's
6
+ * `/vault audit <agent>` to render a one-tap "always allow" affordance
7
+ * for each unique denial — closing the loop where a cron schedule
8
+ * silently fails because `schedule[i].secrets[]` didn't list the key
9
+ * the skill ended up needing.
10
+ *
11
+ * Extracted out of gateway.ts so the parse + filter + group logic is
12
+ * unit-testable without spinning up a Telegram bot context.
13
+ */
14
+
15
+ export interface RecentDenial {
16
+ /** The vault key that was denied. */
17
+ key: string;
18
+ /** How many times this (agent, key) tuple was denied in the window. */
19
+ count: number;
20
+ /** Most-recent denial timestamp, unix ms. */
21
+ lastSeenMs: number;
22
+ }
23
+
24
+ export interface RecentDenialsOpts {
25
+ agentName: string;
26
+ /** Time window, in ms, ending now. Entries older than this are dropped. */
27
+ windowMs: number;
28
+ /** Max number of unique-key denials to return (sorted newest-first). */
29
+ limit: number;
30
+ /** Optional "now" override for tests. */
31
+ nowMs?: number;
32
+ }
33
+
34
+ /**
35
+ * Parse a raw NDJSON audit log blob and return recent denials for one
36
+ * agent. Best-effort: bad lines are skipped silently.
37
+ *
38
+ * Pure-functional — caller does the file IO.
39
+ */
40
+ export function recentDenialsFromAuditLog(
41
+ rawAuditLog: string,
42
+ opts: RecentDenialsOpts,
43
+ ): RecentDenial[] {
44
+ const now = opts.nowMs ?? Date.now();
45
+ const cutoffMs = now - opts.windowMs;
46
+ const grouped = new Map<string, { count: number; lastMs: number }>();
47
+ for (const line of rawAuditLog.split("\n")) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed) continue;
50
+ let obj: Record<string, unknown>;
51
+ try {
52
+ obj = JSON.parse(trimmed) as Record<string, unknown>;
53
+ } catch {
54
+ continue;
55
+ }
56
+ if (typeof obj.agent_name !== "string" || obj.agent_name !== opts.agentName) continue;
57
+ if (typeof obj.result !== "string" || !obj.result.startsWith("denied")) continue;
58
+ if (typeof obj.key !== "string") continue;
59
+ const tsStr = typeof obj.ts === "string" ? obj.ts : null;
60
+ const tsMs = tsStr ? Date.parse(tsStr) : NaN;
61
+ if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
62
+ // Sanity-check the key shape — only the same charset accepted on
63
+ // the grant + audit flows. Defensive against a tampered log line.
64
+ if (!/^[A-Za-z0-9_.-]{1,200}$/.test(obj.key)) continue;
65
+ const prev = grouped.get(obj.key);
66
+ if (prev) {
67
+ prev.count += 1;
68
+ if (tsMs > prev.lastMs) prev.lastMs = tsMs;
69
+ } else {
70
+ grouped.set(obj.key, { count: 1, lastMs: tsMs });
71
+ }
72
+ }
73
+ return [...grouped.entries()]
74
+ .map(([key, v]) => ({ key, count: v.count, lastSeenMs: v.lastMs }))
75
+ .sort((a, b) => b.lastSeenMs - a.lastSeenMs)
76
+ .slice(0, opts.limit);
77
+ }