switchroom 0.14.26 → 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.
- package/dist/cli/switchroom.js +20 -4
- package/dist/host-control/main.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +15 -0
- package/telegram-plugin/card-format.ts +65 -0
- package/telegram-plugin/dist/bridge/bridge.js +14 -0
- package/telegram-plugin/dist/gateway/gateway.js +2201 -1748
- package/telegram-plugin/dist/server.js +14 -0
- package/telegram-plugin/gateway/gateway.ts +458 -13
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +1 -1
- package/telegram-plugin/history.ts +16 -4
- package/telegram-plugin/permission-title.ts +48 -0
- package/telegram-plugin/secret-detect/patterns.ts +8 -0
- package/telegram-plugin/secret-detect/redact.ts +76 -0
- package/telegram-plugin/tests/card-format.test.ts +96 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +80 -0
- package/telegram-plugin/tests/gateway-request-secret.test.ts +78 -0
- package/telegram-plugin/tests/history.test.ts +59 -0
- package/telegram-plugin/tests/permission-title.test.ts +68 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +35 -0
- package/telegram-plugin/tests/secret-detect-sanctum.test.ts +115 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +110 -51
- package/telegram-plugin/uat/assertions.ts +8 -6
- package/telegram-plugin/uat/feed-matcher.test.ts +14 -8
- package/telegram-plugin/uat/scenarios/jtbd-request-secret-dm.test.ts +101 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-dm.test.ts +17 -6
- package/telegram-plugin/worker-activity-feed.ts +84 -46
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { chmodSync, mkdirSync } from 'fs'
|
|
29
29
|
import { join } from 'path'
|
|
30
|
+
import { redact } from './secret-detect/redact.js'
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* `bun:sqlite` is a Bun built-in — Vite/Node loaders can't resolve it
|
|
@@ -300,6 +301,10 @@ export function recordInbound(args: RecordInboundArgs): void {
|
|
|
300
301
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text)
|
|
301
302
|
VALUES (?, ?, ?, 'user', ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
302
303
|
`)
|
|
304
|
+
// Defense-in-depth: never persist a detected secret to the message store.
|
|
305
|
+
// The inbound gate (server.ts handleInbound) already deletes + vaults a
|
|
306
|
+
// high-confidence hit before reaching here, so for caught secrets this is
|
|
307
|
+
// a no-op; it's the backstop for any shape the gate's pattern set misses.
|
|
303
308
|
stmt.run(
|
|
304
309
|
args.chat_id,
|
|
305
310
|
args.thread_id ?? null,
|
|
@@ -307,10 +312,10 @@ export function recordInbound(args: RecordInboundArgs): void {
|
|
|
307
312
|
args.user ?? null,
|
|
308
313
|
args.user_id ?? null,
|
|
309
314
|
args.ts,
|
|
310
|
-
args.text,
|
|
315
|
+
redact(args.text),
|
|
311
316
|
args.attachment_kind ?? null,
|
|
312
317
|
args.reply_to_message_id ?? null,
|
|
313
|
-
args.reply_to_text ?? null,
|
|
318
|
+
args.reply_to_text != null ? redact(args.reply_to_text) : (args.reply_to_text ?? null),
|
|
314
319
|
)
|
|
315
320
|
}
|
|
316
321
|
|
|
@@ -356,9 +361,14 @@ export function recordOutbound(args: RecordOutboundArgs): void {
|
|
|
356
361
|
)
|
|
357
362
|
}
|
|
358
363
|
}) as (...args: unknown[]) => unknown)
|
|
364
|
+
// Outbound redaction: the agent→user direction has no other secret
|
|
365
|
+
// scrub, so this is the chokepoint that keeps an agent-echoed secret out
|
|
366
|
+
// of the message store (e.g. an agent quoting a token it read from a file
|
|
367
|
+
// or a not-yet-vaulted value). Masks the secret bytes in place; the
|
|
368
|
+
// surrounding reply text is preserved.
|
|
359
369
|
const rows: Array<[number, string, string | null]> = args.message_ids.map((id, i) => [
|
|
360
370
|
id,
|
|
361
|
-
args.texts[i] ?? '',
|
|
371
|
+
redact(args.texts[i] ?? ''),
|
|
362
372
|
args.attachment_kinds?.[i] ?? null,
|
|
363
373
|
])
|
|
364
374
|
tx(rows)
|
|
@@ -387,7 +397,9 @@ export function recordEdit(args: RecordEditArgs): void {
|
|
|
387
397
|
SET text = ?
|
|
388
398
|
WHERE chat_id = ? AND message_id = ?
|
|
389
399
|
`)
|
|
390
|
-
|
|
400
|
+
// Same outbound chokepoint as recordOutbound — an edit must not
|
|
401
|
+
// reintroduce a raw secret into the stored row.
|
|
402
|
+
.run(redact(args.text), args.chat_id, args.message_id)
|
|
391
403
|
}
|
|
392
404
|
|
|
393
405
|
export interface RecordReactionArgs {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
cleanWorkerResultParagraph,
|
|
4
|
+
escapeHtml,
|
|
5
|
+
formatDuration,
|
|
6
|
+
stripMarkdown,
|
|
7
|
+
truncate,
|
|
8
|
+
} from '../card-format.js'
|
|
9
|
+
|
|
10
|
+
describe('stripMarkdown', () => {
|
|
11
|
+
it('strips paired bold and emphasis', () => {
|
|
12
|
+
expect(stripMarkdown('a **bold** and *em* and __b__ and _e_')).toBe(
|
|
13
|
+
'a bold and em and b and e',
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('strips inline code spans', () => {
|
|
18
|
+
expect(stripMarkdown('run `git push` now')).toBe('run git push now')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('strips leading headings, blockquotes, bullets, and ordered items', () => {
|
|
22
|
+
expect(stripMarkdown('### Heading')).toBe('Heading')
|
|
23
|
+
expect(stripMarkdown('> quoted')).toBe('quoted')
|
|
24
|
+
expect(stripMarkdown('- a bullet')).toBe('a bullet')
|
|
25
|
+
expect(stripMarkdown('* star bullet')).toBe('star bullet')
|
|
26
|
+
expect(stripMarkdown('1. first')).toBe('first')
|
|
27
|
+
expect(stripMarkdown('2) second')).toBe('second')
|
|
28
|
+
})
|
|
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
|
+
|
|
46
|
+
it('reduces a link to its label', () => {
|
|
47
|
+
expect(stripMarkdown('see [the PR](https://x/y) here')).toBe('see the PR here')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('removes residual doubled markers but keeps a lone asterisk (math)', () => {
|
|
51
|
+
expect(stripMarkdown('**dangling')).toBe('dangling')
|
|
52
|
+
expect(stripMarkdown('3 * 4 = 12')).toBe('3 * 4 = 12')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('does not touch HTML-significant characters (escaping stays separate)', () => {
|
|
56
|
+
expect(stripMarkdown('a < b & c > d')).toBe('a < b & c > d')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('cleanWorkerResultParagraph', () => {
|
|
61
|
+
it('collapses multi-line Markdown into one plain paragraph', () => {
|
|
62
|
+
const input = '## Done\n\n**PR #21** opened\n\n- merged\n- pushed'
|
|
63
|
+
expect(cleanWorkerResultParagraph(input)).toBe('Done PR #21 opened merged pushed')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('drops fenced code blocks entirely', () => {
|
|
67
|
+
const input = 'before\n```ts\nconst x = 1\n```\nafter'
|
|
68
|
+
expect(cleanWorkerResultParagraph(input)).toBe('before after')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('drops horizontal rules', () => {
|
|
72
|
+
expect(cleanWorkerResultParagraph('a\n---\nb\n***\nc')).toBe('a b c')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns empty for whitespace/markup-only input', () => {
|
|
76
|
+
expect(cleanWorkerResultParagraph(' \n---\n')).toBe('')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('formatDuration', () => {
|
|
81
|
+
it('renders sub-second as ms and seconds/minutes as MM:SS', () => {
|
|
82
|
+
expect(formatDuration(500)).toBe('500ms')
|
|
83
|
+
expect(formatDuration(1000)).toBe('00:01')
|
|
84
|
+
expect(formatDuration(60_000)).toBe('01:00')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('escapeHtml / truncate', () => {
|
|
89
|
+
it('escapes the three HTML-significant characters', () => {
|
|
90
|
+
expect(escapeHtml('a <b> & c')).toBe('a <b> & c')
|
|
91
|
+
})
|
|
92
|
+
it('truncates with an ellipsis', () => {
|
|
93
|
+
expect(truncate('abcdef', 4)).toBe('abc…')
|
|
94
|
+
expect(truncate('abc', 4)).toBe('abc')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -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('<b>&')
|
|
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
|
})
|