switchroom 0.14.27 → 0.14.28

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.
@@ -248,6 +248,54 @@ export function describeGrant(
248
248
  }
249
249
  }
250
250
 
251
+ /**
252
+ * Agent-voiced "I got your verdict and I'm continuing" message, posted as
253
+ * a *distinct* Telegram message the instant the operator answers a
254
+ * permission card (allow / deny / always / slash / free-text). The card
255
+ * edit + status reaction are easy to miss — a reaction lands on the turn's
256
+ * triggering message far up the chat, and the card footnote is a one-liner
257
+ * the operator scrolls past — so this is the legible signal that the tap
258
+ * landed and names the work being (re)started.
259
+ *
260
+ * Mirrors `formatPermissionCardBody`'s style ("🔐 <b>Gymbro</b> wants to
261
+ * edit: log.md" → "▶️ <b>Gymbro</b> — got it, continuing: edit: log.md").
262
+ * `action` is a phrase from {@link naturalAction} (already operator-facing,
263
+ * no tool ids). Output is HTML-escaped for `parse_mode: 'HTML'`.
264
+ *
265
+ * `timeoutMinutes` marks the TTL auto-deny variant (no operator tapped —
266
+ * the request aged out) so the wording reflects "no answer" rather than a
267
+ * deliberate denial.
268
+ */
269
+ export function formatPermissionResumeMessage(opts: {
270
+ agentName: string | null;
271
+ behavior: "allow" | "deny";
272
+ action: string;
273
+ timeoutMinutes?: number;
274
+ }): string {
275
+ const who =
276
+ opts.agentName && opts.agentName.length > 0
277
+ ? `<b>${escapeTgHtml(capFirst(opts.agentName))}</b>`
278
+ : `<b>Agent</b>`;
279
+ const act = (opts.action ?? "").trim();
280
+ const hasAction = act.length > 0;
281
+
282
+ if (opts.behavior === "allow") {
283
+ return hasAction
284
+ ? `▶️ ${who} — got it, continuing: <i>${escapeTgHtml(act)}</i>`
285
+ : `▶️ ${who} — got it, back to work.`;
286
+ }
287
+
288
+ // deny
289
+ if (opts.timeoutMinutes != null) {
290
+ return hasAction
291
+ ? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (<i>${escapeTgHtml(act)}</i>).`
292
+ : `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it.`;
293
+ }
294
+ return hasAction
295
+ ? `🚫 ${who} — noted, I won't ${escapeTgHtml(lowerFirst(act))}. Continuing without it.`
296
+ : `🚫 ${who} — noted, continuing without it.`;
297
+ }
298
+
251
299
  function resolveSkillName(input: Record<string, unknown>): string | null {
252
300
  return (
253
301
  readString(input, "skill") ??
@@ -54,6 +54,14 @@ export const ANCHORED_PATTERNS: PatternDef[] = [
54
54
  // Telegram bot tokens: with "bot" prefix or bare ID:token.
55
55
  { rule_id: 'telegram_bot_token_prefixed', regex: /\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b/g, captureIndex: 1, slugHint: 'telegram_bot_token' },
56
56
  { rule_id: 'telegram_bot_token', regex: /\b(\d{6,}:[A-Za-z0-9_-]{20,})\b/g, captureIndex: 1, slugHint: 'telegram_bot_token' },
57
+ // Laravel Sanctum / Coolify personal-access tokens. Shape: `<id>|<token>`
58
+ // where <id> is the integer PK and <token> is `Str::random(40)` — 40 base62
59
+ // chars. The `|` separator is what distinguishes this from a Telegram
60
+ // `id:token` (colon) or a JWT. Length floor 40 (the Sanctum default) keeps
61
+ // this off short pipe-joined chat like `1|foo` or markdown table cells.
62
+ // Incident 2026-06-01: a live `17|<40-char>` Coolify token pasted by a user
63
+ // slipped every existing pattern and persisted in plaintext.
64
+ { rule_id: 'laravel_sanctum_token', regex: /\b(\d+\|[A-Za-z0-9]{40,})\b/g, captureIndex: 1, slugHint: 'api_token' },
57
65
  { rule_id: 'aws_access_key', regex: /\b(AKIA[0-9A-Z]{16})\b/g, captureIndex: 1, slugHint: 'aws_access_key' },
58
66
  { rule_id: 'jwt', regex: /\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g, captureIndex: 1, slugHint: 'jwt' },
59
67
  ]
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `redact(text)` — sanitize text by replacing detected secrets and
3
+ * credential-bearing URL parts with `[REDACTED]` markers, in place.
4
+ *
5
+ * This is the shared mask-in-place chokepoint. Unlike the inbound
6
+ * vault-staging flow (which DELETES the Telegram message and offers to
7
+ * save the secret), `redact()` leaves the surrounding prose intact and
8
+ * only masks the secret byte-ranges — the right behavior when we must
9
+ * keep a record but never store/log/forward the raw value:
10
+ *
11
+ * - INBOUND + OUTBOUND history persistence — `history.ts`
12
+ * `recordInbound` / `recordOutbound` redact before the row hits
13
+ * SQLite, so no detected secret survives in the message store in
14
+ * either direction (defense-in-depth behind the inbound gate, and
15
+ * the only redaction on the agent→user direction).
16
+ * - `switchroom issues record` — `src/issues/store.ts:capDetail`.
17
+ * - `switchroom secret-detect redact --stdin` — bash-callable shim.
18
+ * - hostd — `src/host-control/server.ts`.
19
+ *
20
+ * Detection is delegated to `detectSecrets()` (same patterns, same
21
+ * suppressor, same engine as the inbound Telegram gate) so a pattern
22
+ * added once — e.g. the Laravel/Coolify Sanctum `<id>|<token>` shape —
23
+ * covers detection, inbound interception, and this redactor uniformly.
24
+ *
25
+ * Idempotence: for token-shape detections the marker doesn't re-match.
26
+ * For *structural* detectors (`cli_flag`, `json_secret_field`) a second
27
+ * pass may rewrite the tag, but the bytes stay redacted. Rely on "no
28
+ * detected secret bytes survive any number of passes", not on strict
29
+ * `redact(redact(x)) === redact(x)`.
30
+ */
31
+ import { detectSecrets, type Detection } from './index.js'
32
+ import { redactUrls } from './url-redact.js'
33
+
34
+ export const REDACTED_MARKER = '[REDACTED]'
35
+
36
+ /**
37
+ * Synchronous, fast redactor. Vendored pattern engine only (no async
38
+ * secretlint) so it is safe on hot paths (every stored message).
39
+ *
40
+ * Order matters:
41
+ * 1. URL credentials (`https://u:p@host` → `https://***@host`,
42
+ * sensitive query params → `?key=***`).
43
+ * 2. Token-shape detection over the URL-normalized text; matched byte
44
+ * ranges are replaced right-to-left so earlier offsets stay valid.
45
+ */
46
+ export function redact(text: string): string {
47
+ if (!text || text.length === 0) return text
48
+
49
+ // Step 1 — URL credentials and known-sensitive query params.
50
+ const urlScrubbed = redactUrls(text)
51
+
52
+ // Step 2 — token shape detection over the URL-scrubbed text.
53
+ const hits: Detection[] = detectSecrets(urlScrubbed)
54
+ if (hits.length === 0) return urlScrubbed
55
+
56
+ // Apply replacements right-to-left so byte offsets stay valid.
57
+ const sorted = [...hits].sort((a, b) => b.start - a.start)
58
+ let out = urlScrubbed
59
+ for (const h of sorted) {
60
+ out = out.slice(0, h.start) + redactedMarker(h.rule_id) + out.slice(h.end)
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * `[REDACTED:<rule_id>]` when the rule_id is informative,
67
+ * `[REDACTED]` otherwise. The rule_id is detector-emitted, so it never
68
+ * contains attacker-controlled bytes — safe to embed verbatim.
69
+ */
70
+ function redactedMarker(ruleId: string): string {
71
+ const trimmed = ruleId.replace(/^(kv|env)_/, '')
72
+ if (!trimmed || trimmed === 'key_value' || trimmed === 'kv_entropy') {
73
+ return REDACTED_MARKER
74
+ }
75
+ return `[REDACTED:${trimmed}]`
76
+ }
@@ -27,6 +27,22 @@ describe('stripMarkdown', () => {
27
27
  expect(stripMarkdown('2) second')).toBe('second')
28
28
  })
29
29
 
30
+ it('strips leading block markup on EVERY line, not just the string start', () => {
31
+ // The screenshot regression: a worker summary that opens with prose
32
+ // ("Done.") then a `## Summary` heading mid-string. Without the `gm`
33
+ // flags the heading marker leaked into the rendered card.
34
+ const headed = stripMarkdown('Done.\n\n## Summary\n\nFixed the bug')
35
+ expect(headed).not.toContain('##')
36
+ expect(headed).toContain('Done.')
37
+ expect(headed).toContain('Summary')
38
+ expect(headed).toContain('Fixed the bug')
39
+
40
+ const mixed = stripMarkdown('intro\n> quoted\n- item')
41
+ expect(mixed).not.toMatch(/(^|\n)\s*[>\-*]/)
42
+ expect(mixed).toContain('quoted')
43
+ expect(mixed).toContain('item')
44
+ })
45
+
30
46
  it('reduces a link to its label', () => {
31
47
  expect(stripMarkdown('see [the PR](https://x/y) here')).toBe('see the PR here')
32
48
  })
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ /**
5
+ * Structural test for the outbound secret-scrub (#2044).
6
+ *
7
+ * Outbound (agent→user) text previously had NO redaction — an agent that
8
+ * echoed a secret it read from a file/env/not-yet-vaulted value would send
9
+ * the raw bytes to Telegram, log a preview to stderr, and store them in
10
+ * history. This pins that `redactOutboundText()` runs at the ENTRY of each
11
+ * agent-free-text tool (reply / stream_reply / edit_message), before the
12
+ * stderr preview, the dedup key, the send, and the history record.
13
+ *
14
+ * Why structural: executeReply/executeStreamReply/executeEditMessage are
15
+ * not exported (same constraint as gateway-secret-detect.test.ts). The
16
+ * masking itself — that `redact()` covers the Sanctum shape and every
17
+ * provider token — is exercised behaviorally in secret-detect-sanctum.test.ts
18
+ * and the redact() unit tests; what's left to pin here is the wiring + slot.
19
+ */
20
+ describe('gateway outbound secret-scrub — structural wiring', () => {
21
+ const src = readFileSync(
22
+ new URL('../gateway/gateway.ts', import.meta.url),
23
+ 'utf8',
24
+ )
25
+
26
+ it('imports the shared redactor', () => {
27
+ expect(src).toMatch(/import \{ redact \} from '\.\.\/secret-detect\/redact\.js'/)
28
+ })
29
+
30
+ it('defines the redactOutboundText helper backed by redact()', () => {
31
+ const idx = src.indexOf('function redactOutboundText(')
32
+ expect(idx).toBeGreaterThan(0)
33
+ const body = src.slice(idx, idx + 400)
34
+ expect(body).toMatch(/redact\(text\)/)
35
+ })
36
+
37
+ it('reply: scrubs at entry, before the stderr preview log', () => {
38
+ const start = src.indexOf('async function executeReply(')
39
+ const redactIdx = src.indexOf(`redactOutboundText(text, 'reply')`, start)
40
+ const previewIdx = src.indexOf('reply: invoked chatId=', start)
41
+ expect(start).toBeGreaterThan(0)
42
+ expect(redactIdx).toBeGreaterThan(start)
43
+ expect(previewIdx).toBeGreaterThan(redactIdx) // mask BEFORE the preview is logged
44
+ })
45
+
46
+ it('stream_reply: scrubs at entry, before the voice scrub + dedup', () => {
47
+ const start = src.indexOf('async function executeStreamReply(')
48
+ const redactIdx = src.indexOf(`redactOutboundText(args.text as string, 'stream_reply')`, start)
49
+ const scrubIdx = src.indexOf(`site: 'stream_reply'`, start)
50
+ expect(start).toBeGreaterThan(0)
51
+ expect(redactIdx).toBeGreaterThan(start)
52
+ expect(scrubIdx).toBeGreaterThan(redactIdx)
53
+ })
54
+
55
+ it('edit_message: scrubs at entry, before the voice scrub + send', () => {
56
+ const start = src.indexOf('async function executeEditMessage(')
57
+ const redactIdx = src.indexOf(`redactOutboundText(editRawText, 'edit_message')`, start)
58
+ const scrubIdx = src.indexOf(`site: 'edit_message'`, start)
59
+ expect(start).toBeGreaterThan(0)
60
+ expect(redactIdx).toBeGreaterThan(start)
61
+ expect(scrubIdx).toBeGreaterThan(redactIdx)
62
+ })
63
+
64
+ it('turn-flush backstop: scrubs the model terminal prose before send', () => {
65
+ // Turn-flush delivers the model's answer when it skipped reply/stream_reply
66
+ // — arbitrary agent free-text that hits the wire + stderr preview.
67
+ const redactIdx = src.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
68
+ const scrubSiteIdx = src.indexOf(`site: 'turn_flush'`)
69
+ expect(redactIdx).toBeGreaterThan(0)
70
+ expect(scrubSiteIdx).toBeGreaterThan(redactIdx) // mask BEFORE the voice scrub + send
71
+ })
72
+
73
+ it('does not log the secret value when a mask fires', () => {
74
+ const idx = src.indexOf('function redactOutboundText(')
75
+ const body = src.slice(idx, idx + 400)
76
+ // The log line names the site, never the text/masked value.
77
+ expect(body).toMatch(/outbound secret masked site=\$\{site\}/)
78
+ expect(body).not.toMatch(/\$\{text\}|\$\{masked\}/)
79
+ })
80
+ })
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ /**
5
+ * Structural test for the `request_secret` tool (#2045) — the secure
6
+ * "agent asks the operator to PROVIDE a missing secret" flow. The operator
7
+ * taps [Provide securely], sends the value once, and the gateway deletes it
8
+ * + writes it straight to the vault; the raw value is never recorded,
9
+ * logged, or returned to the agent.
10
+ *
11
+ * Structural because the gateway handlers (handleInbound, executeToolCall,
12
+ * the callback router) aren't exported — same constraint as
13
+ * gateway-secret-detect.test.ts. The vault-write reuse + card UX are
14
+ * exercised end-to-end by the mtcute UAT (jtbd-request-secret-dm).
15
+ */
16
+ describe('request_secret — gateway wiring', () => {
17
+ const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
18
+ const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
19
+
20
+ it('declares the MCP tool with required {chat_id,key} and NO value arg', () => {
21
+ const idx = bridge.indexOf(`name: 'request_secret'`)
22
+ expect(idx).toBeGreaterThan(0)
23
+ const schema = bridge.slice(idx, idx + 3000)
24
+ expect(schema).toMatch(/required: \['chat_id', 'key'\]/)
25
+ // The whole point: the agent does NOT supply the value.
26
+ expect(schema).not.toMatch(/\bvalue:\s*\{/)
27
+ // Tells the agent never to ask for a chat paste.
28
+ expect(schema).toMatch(/NEVER ask the user to paste/i)
29
+ })
30
+
31
+ it('is allow-listed and dispatched', () => {
32
+ expect(gw).toMatch(/'request_secret',\n\]\)/)
33
+ expect(gw).toMatch(/case 'request_secret':\s*\n\s*return executeRequestSecret\(args\)/)
34
+ })
35
+
36
+ it('routes the vsp: callback', () => {
37
+ expect(gw).toMatch(/data\.startsWith\('vsp:'\)/)
38
+ expect(gw).toMatch(/handleSecretRequestCallback\(ctx, data\)/)
39
+ })
40
+
41
+ it('captures the provided value BEFORE recordInbound and the broadcast', () => {
42
+ const start = gw.indexOf('async function handleInbound(')
43
+ const captureIdx = gw.indexOf('captureProvidedSecret(ctx, chat_id', start)
44
+ const recordIdx = gw.indexOf('recordInbound(', captureIdx)
45
+ const broadcastIdx = gw.indexOf('ipcServer.broadcast(inboundMsg)', captureIdx)
46
+ expect(captureIdx).toBeGreaterThan(start)
47
+ expect(recordIdx).toBeGreaterThan(captureIdx)
48
+ expect(broadcastIdx).toBeGreaterThan(captureIdx)
49
+ })
50
+
51
+ it('the capture deletes the raw message and writes to the vault, then returns', () => {
52
+ const idx = gw.indexOf('async function captureProvidedSecret(')
53
+ expect(idx).toBeGreaterThan(0)
54
+ const body = gw.slice(idx, idx + 3600)
55
+ // delete the raw message before anything else
56
+ expect(body).toMatch(/deleteSensitiveMessage\(chat_id, msgId/)
57
+ // write via the posture/passphrase helper
58
+ expect(body).toMatch(/writeRequestedSecret\(armed\.key, value, chat_id\)/)
59
+ })
60
+
61
+ it('the agent-resume inbound carries the key but NOT the value', () => {
62
+ const idx = gw.indexOf('async function captureProvidedSecret(')
63
+ const body = gw.slice(idx, idx + 3600)
64
+ const syntheticIdx = body.indexOf("source: 'secret_provided'")
65
+ expect(syntheticIdx).toBeGreaterThan(0)
66
+ // The synthetic text references vault:<key>, never the raw `value`.
67
+ expect(body).toMatch(/vault:\$\{armed\.key\}/)
68
+ const textIdx = body.lastIndexOf('text:', syntheticIdx)
69
+ const metaIdx = body.indexOf('meta:', syntheticIdx)
70
+ expect(body.slice(textIdx, metaIdx)).not.toMatch(/\$\{value\}/)
71
+ })
72
+
73
+ it('dedupes to one open request per (chat,key)', () => {
74
+ const idx = gw.indexOf('async function executeRequestSecret(')
75
+ const body = gw.slice(idx, idx + 1800)
76
+ expect(body).toMatch(/p\.chat_id === chat_id && p\.key === key/)
77
+ })
78
+ })
@@ -362,3 +362,62 @@ describe('getRecentOutboundCount (backstop dedup helper)', () => {
362
362
  expect(getRecentOutboundCount('-200', 2)).toBe(1)
363
363
  })
364
364
  })
365
+
366
+ describe('secret redaction at persistence (both directions)', () => {
367
+ beforeEach(() => initHistory(stateDir, 30))
368
+
369
+ // Built by concatenation so the source never holds a contiguous
370
+ // secret-shaped literal (repo Push Protection / no-pii lint).
371
+ const SANCTUM = `19|${'qP4mN7rT2v'.repeat(4)}` // <id>|<40 base62> (Sanctum/Coolify)
372
+ const GH_PAT = `ghp_${'A1b2C3d4E5'.repeat(3)}` // ghp_<30 base62>
373
+
374
+ it('masks a user-pasted secret before it is stored (inbound)', () => {
375
+ recordInbound({
376
+ chat_id: '-100',
377
+ thread_id: null,
378
+ message_id: 1,
379
+ user: 'alice',
380
+ user_id: '111',
381
+ ts: 1000,
382
+ text: `the new coolify token is ${SANCTUM}, save it`,
383
+ })
384
+ const text = query({ chat_id: '-100' })[0]!.text as string
385
+ expect(text).not.toContain(SANCTUM)
386
+ expect(text).toContain('[REDACTED')
387
+ expect(text).toContain('the new coolify token is') // surrounding prose preserved
388
+ })
389
+
390
+ it('masks a secret echoed by the agent before it is stored (outbound)', () => {
391
+ recordOutbound({
392
+ chat_id: '-100',
393
+ thread_id: null,
394
+ message_ids: [2],
395
+ texts: [`sure — your key is ${GH_PAT}, keep it safe`],
396
+ ts: 2000,
397
+ })
398
+ const text = query({ chat_id: '-100' })[0]!.text as string
399
+ expect(text).not.toContain(GH_PAT)
400
+ expect(text).toContain('[REDACTED')
401
+ })
402
+
403
+ it('masks a secret introduced by an edit', () => {
404
+ recordOutbound({ chat_id: '-100', thread_id: null, message_ids: [3], texts: ['placeholder'], ts: 3000 })
405
+ recordEdit({ chat_id: '-100', message_id: 3, text: `token: ${SANCTUM}` })
406
+ const text = query({ chat_id: '-100' })[0]!.text as string
407
+ expect(text).not.toContain(SANCTUM)
408
+ expect(text).toContain('[REDACTED')
409
+ })
410
+
411
+ it('leaves ordinary prose untouched', () => {
412
+ recordInbound({
413
+ chat_id: '-100',
414
+ thread_id: null,
415
+ message_id: 4,
416
+ user: 'a',
417
+ user_id: '1',
418
+ ts: 4000,
419
+ text: 'hello, how are you?',
420
+ })
421
+ expect(query({ chat_id: '-100' })[0]!.text).toBe('hello, how are you?')
422
+ })
423
+ })
@@ -12,6 +12,7 @@ import {
12
12
  naturalAction,
13
13
  describeGrant,
14
14
  formatPermissionCardBody,
15
+ formatPermissionResumeMessage,
15
16
  } from '../permission-title.js'
16
17
  import type { ScopeOption } from '../permission-rule.js'
17
18
 
@@ -193,3 +194,70 @@ describe('describeGrant — phrased from the chosen scope', () => {
193
194
  )
194
195
  })
195
196
  })
197
+
198
+ describe('formatPermissionResumeMessage — agent-voiced verdict ack', () => {
199
+ test('allow names the work it is resuming', () => {
200
+ expect(
201
+ formatPermissionResumeMessage({
202
+ agentName: 'gymbro',
203
+ behavior: 'allow',
204
+ action: 'edit: supplement-log.md',
205
+ }),
206
+ ).toBe('▶️ <b>Gymbro</b> — got it, continuing: <i>edit: supplement-log.md</i>')
207
+ })
208
+
209
+ test('deny names what it will skip, lower-cased inline', () => {
210
+ expect(
211
+ formatPermissionResumeMessage({
212
+ agentName: 'ziggy',
213
+ behavior: 'deny',
214
+ action: 'Search the web',
215
+ }),
216
+ ).toBe("🚫 <b>Ziggy</b> — noted, I won't search the web. Continuing without it.")
217
+ })
218
+
219
+ test('TTL auto-deny variant reads as a timeout, not a tap', () => {
220
+ expect(
221
+ formatPermissionResumeMessage({
222
+ agentName: 'finn',
223
+ behavior: 'deny',
224
+ action: 'run: deploy.sh',
225
+ timeoutMinutes: 5,
226
+ }),
227
+ ).toBe('🚫 <b>Finn</b> — no answer in 5m, continuing without it (<i>run: deploy.sh</i>).')
228
+ })
229
+
230
+ test('HTML-escapes a hostile action phrase (no raw </>& injection)', () => {
231
+ const out = formatPermissionResumeMessage({
232
+ agentName: 'clerk',
233
+ behavior: 'allow',
234
+ action: 'run: echo <b>&"pwned"</b>',
235
+ })
236
+ expect(out).toContain('&lt;b&gt;&amp;')
237
+ expect(out).not.toContain('<b>&"pwned"')
238
+ })
239
+
240
+ test('cap-first on the agent name', () => {
241
+ const out = formatPermissionResumeMessage({
242
+ agentName: 'lawgpt',
243
+ behavior: 'allow',
244
+ action: 'read: contract.pdf',
245
+ })
246
+ expect(out).toContain('<b>Lawgpt</b>')
247
+ })
248
+
249
+ test('empty action falls back to a generic continue (no dangling phrase)', () => {
250
+ expect(
251
+ formatPermissionResumeMessage({ agentName: 'carrie', behavior: 'allow', action: '' }),
252
+ ).toBe('▶️ <b>Carrie</b> — got it, back to work.')
253
+ expect(
254
+ formatPermissionResumeMessage({ agentName: 'carrie', behavior: 'deny', action: ' ' }),
255
+ ).toBe('🚫 <b>Carrie</b> — noted, continuing without it.')
256
+ })
257
+
258
+ test('null agent name degrades to a neutral "Agent" label', () => {
259
+ expect(
260
+ formatPermissionResumeMessage({ agentName: null, behavior: 'allow', action: 'edit: x.md' }),
261
+ ).toBe('▶️ <b>Agent</b> — got it, continuing: <i>edit: x.md</i>')
262
+ })
263
+ })
@@ -22,6 +22,12 @@
22
22
  * This guard fails loudly if any `dispatchPermissionVerdict(...)`
23
23
  * callsite is not paired with a `resumeReactionAfterVerdict()` within a
24
24
  * few lines — i.e. a new (or refactored) verdict path drops the resume.
25
+ *
26
+ * Same pin applies to `postPermissionResumeMessage(...)`: the distinct
27
+ * agent-voiced "got it, continuing: <work>" message is the legible signal
28
+ * the operator actually sees (the reaction lands on a far-up message; the
29
+ * card edit is a one-liner). It rides the exact same 5-paths-drift hazard,
30
+ * so every verdict path must post it too.
25
31
  */
26
32
 
27
33
  import { describe, it, expect } from 'vitest'
@@ -83,4 +89,33 @@ describe('permission verdict → resume reaction wiring', () => {
83
89
  true,
84
90
  )
85
91
  })
92
+
93
+ // postPermissionResumeMessage rides ~1–2 lines after resumeReactionAfterVerdict
94
+ // on every path, so it can sit a touch further from the dispatch than the
95
+ // resume call — give it a little more headroom.
96
+ const POST_WINDOW = 20
97
+
98
+ it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage()', () => {
99
+ const unpaired: number[] = []
100
+ for (const idx of dispatchCallsites) {
101
+ const window = LINES.slice(idx, idx + POST_WINDOW + 1).join('\n')
102
+ if (!/\bpostPermissionResumeMessage\s*\(/.test(window)) {
103
+ unpaired.push(idx + 1)
104
+ }
105
+ }
106
+ expect(
107
+ unpaired,
108
+ `dispatchPermissionVerdict() at gateway.ts line(s) ` +
109
+ `${unpaired.join(', ')} has no postPermissionResumeMessage() within ` +
110
+ `${POST_WINDOW} lines — that verdict path resumes the turn silently, ` +
111
+ `so the operator never gets the "got it, continuing: <work>" message. ` +
112
+ `Add the post call next to resumeReactionAfterVerdict() (see sibling paths).`,
113
+ ).toEqual([])
114
+ })
115
+
116
+ it('the resume-message helper still exists', () => {
117
+ expect(
118
+ /function\s+postPermissionResumeMessage\s*\(/.test(GATEWAY_SRC),
119
+ ).toBe(true)
120
+ })
86
121
  })
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { detectSecrets } from '../secret-detect/index.js'
3
+ import { redact } from '../secret-detect/redact.js'
4
+
5
+ /**
6
+ * Regression for the 2026-06-01 incident: a live Laravel Sanctum / Coolify
7
+ * personal-access token (`<id>|<40-char base62>`, e.g. `17|aB3d…`) pasted by
8
+ * a USER into chat slipped every existing pattern and persisted in plaintext.
9
+ *
10
+ * Diagnosis: the inbound gate (gateway handleInbound) already runs
11
+ * `detectSecrets` at ingest, fail-closed, BEFORE recordInbound + IPC dispatch
12
+ * — so the leak was NOT an inbound bypass and NOT render-time masking. It was
13
+ * pure PATTERN COVERAGE: no rule matched the `<id>|<token>` shape. These tests
14
+ * pin the new `laravel_sanctum_token` rule and the redactor that backs both
15
+ * the history-store persistence (both directions) and the issues pipeline.
16
+ */
17
+
18
+ // Build token fixtures by concatenation so the source file never contains a
19
+ // contiguous secret-shaped literal (repo Push Protection / no-pii lint).
20
+ const ID = '17'
21
+ const BODY40 = 'aB3dE6fH9j'.repeat(4) // 40 base62 chars — Sanctum's Str::random(40)
22
+ const SANCTUM = `${ID}|${BODY40}` // e.g. 17|aB3dE6fH9j…
23
+
24
+ describe('laravel/coolify sanctum token detection', () => {
25
+ it('detects <id>|<40-char> as a high-confidence laravel_sanctum_token', () => {
26
+ const hits = detectSecrets(`here is the token: ${SANCTUM}`)
27
+ const hit = hits.find((d) => d.rule_id === 'laravel_sanctum_token')
28
+ expect(hit).toBeDefined()
29
+ expect(hit!.matched_text).toBe(SANCTUM)
30
+ expect(hit!.confidence).toBe('high')
31
+ expect(hit!.suppressed).toBe(false)
32
+ })
33
+
34
+ it('satisfies the exact inbound-gate predicate (high + not suppressed)', () => {
35
+ // This is verbatim the condition gateway handleInbound uses to decide to
36
+ // delete + defer-to-vault: if it is truthy, the pasted token is
37
+ // intercepted before recordInbound() and the IPC broadcast to the agent.
38
+ const detections = detectSecrets(SANCTUM)
39
+ const gateHit = detections.find((d) => d.confidence === 'high' && !d.suppressed)
40
+ expect(gateHit).toBeDefined()
41
+ })
42
+
43
+ it('redact() masks the token in place, preserving surrounding prose', () => {
44
+ const out = redact(`new token: ${SANCTUM}\nuse it for the deploy API`)
45
+ expect(out).not.toContain(SANCTUM)
46
+ expect(out).not.toContain(BODY40)
47
+ expect(out).toContain('[REDACTED:laravel_sanctum_token]')
48
+ // Surrounding prose is untouched.
49
+ expect(out).toContain('new token:')
50
+ expect(out).toContain('use it for the deploy API')
51
+ })
52
+
53
+ it('covers longer-than-40 token bodies', () => {
54
+ const longTok = `42|${'Xy7Zk2Qp9w'.repeat(6)}` // 60 base62 chars
55
+ const hits = detectSecrets(longTok)
56
+ expect(hits.find((d) => d.rule_id === 'laravel_sanctum_token')?.matched_text).toBe(longTok)
57
+ })
58
+
59
+ it('catches a token mid-sentence and masks only the token', () => {
60
+ const out = redact(`use ${SANCTUM} when deploying, ok?`)
61
+ expect(out).not.toContain(SANCTUM)
62
+ expect(out).toContain('use ')
63
+ expect(out).toContain(' when deploying, ok?')
64
+ })
65
+
66
+ it('catches multiple tokens in one message (redact right-to-left loop)', () => {
67
+ const second = `88|${'mK2pR7vT4x'.repeat(4)}` // distinct <id>|<40 base62>
68
+ const detected = detectSecrets(`old ${SANCTUM} new ${second}`)
69
+ .filter((d) => d.rule_id === 'laravel_sanctum_token')
70
+ expect(detected).toHaveLength(2)
71
+ const out = redact(`old ${SANCTUM} new ${second}`)
72
+ expect(out).not.toContain(SANCTUM)
73
+ expect(out).not.toContain(second)
74
+ expect(out).not.toContain(BODY40)
75
+ })
76
+
77
+ describe('false-positive guards', () => {
78
+ it('does NOT match a pipe-joined value under the 40-char floor', () => {
79
+ const short = `1|${'abcDEF123'}` // 9 chars after the pipe
80
+ expect(detectSecrets(short).some((d) => d.rule_id === 'laravel_sanctum_token')).toBe(false)
81
+ })
82
+
83
+ it('does NOT match exactly 39 base62 chars (one under the floor)', () => {
84
+ const justUnder = `17|${BODY40.slice(0, 39)}`
85
+ expect(
86
+ detectSecrets(justUnder).some((d) => d.rule_id === 'laravel_sanctum_token'),
87
+ ).toBe(false)
88
+ })
89
+
90
+ it('does NOT match a number with no pipe-delimited token', () => {
91
+ expect(
92
+ detectSecrets('order 17 shipped 40 items').some(
93
+ (d) => d.rule_id === 'laravel_sanctum_token',
94
+ ),
95
+ ).toBe(false)
96
+ })
97
+
98
+ it('does NOT match a spaced markdown table cell', () => {
99
+ // Table cells are `| value |` with spaces around the pipe; the Sanctum
100
+ // shape has the token immediately adjacent to the pipe.
101
+ const table = `| 17 | ${BODY40} |`
102
+ expect(
103
+ detectSecrets(table).some((d) => d.rule_id === 'laravel_sanctum_token'),
104
+ ).toBe(false)
105
+ })
106
+ })
107
+
108
+ it('suppresses a token sitting next to an example/fixture marker', () => {
109
+ // A documented example must not trigger a destructive delete+vault.
110
+ const hits = detectSecrets(`example token: ${SANCTUM}`)
111
+ const hit = hits.find((d) => d.rule_id === 'laravel_sanctum_token')
112
+ expect(hit).toBeDefined()
113
+ expect(hit!.suppressed).toBe(true)
114
+ })
115
+ })
@@ -187,6 +187,21 @@ describe('renderWorkerActivity', () => {
187
187
  expect(done).not.toMatch(/(^|\n)\s*-{3,}\s*(\n|$)/)
188
188
  })
189
189
 
190
+ it('renders a multi-line narrative entry as one clean step (no raw ## leak)', () => {
191
+ // Screenshot regression: a running-card step whose narrative entry is
192
+ // itself multi-line ("Done.\n\n## Summary\n…"). The heading marker must
193
+ // not leak and the step must collapse to a single visual line.
194
+ const out = renderWorkerActivity(
195
+ view({
196
+ narrativeLines: ['Done.\n\n## Summary\n\nFixed the bug where a bad password logged you out'],
197
+ latestSummary: '',
198
+ }),
199
+ )
200
+ expect(out).not.toContain('## Summary')
201
+ expect(out).not.toContain('\n\n')
202
+ expect(out).toContain('Done. Summary Fixed the bug where a bad password logged you out')
203
+ })
204
+
190
205
  it('escapes HTML inside narrative lines', () => {
191
206
  const out = renderWorkerActivity(view({ narrativeLines: ['a <b>x</b> & y'] }))
192
207
  expect(out).toContain('a &lt;b&gt;x&lt;/b&gt; &amp; y')