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.
- package/README.md +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- 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, '&')
|
|
394
|
+
.replace(/"/g, '"')
|
|
395
|
+
.replace(/</g, '<')
|
|
396
|
+
.replace(/>/g, '>');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function escapeBody(s: string): string {
|
|
400
|
+
return s.replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|