switchroom 0.12.0 → 0.12.1
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 +26 -11
- package/dist/auth-broker/index.js +1 -1
- package/dist/cli/skill-validate-pretool.mjs +7209 -0
- package/dist/cli/switchroom.js +869 -430
- package/dist/vault/broker/server.js +31 -22
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +1 -1
- package/skills/skill-creator/SKILL.md +52 -0
- package/telegram-plugin/auth-snapshot-format.ts +5 -5
- package/telegram-plugin/dist/gateway/gateway.js +62 -8
- package/telegram-plugin/gateway/access-validator.test.ts +8 -8
- package/telegram-plugin/gateway/access-validator.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +43 -3
- package/telegram-plugin/gateway/gateway.ts +72 -0
- package/telegram-plugin/recent-outbound-dedup.ts +1 -1
- package/telegram-plugin/registry/turns-schema.ts +1 -1
- package/telegram-plugin/tests/auth-add-flow.test.ts +1 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
- package/telegram-plugin/tests/boot-probes.test.ts +37 -2
- package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
- package/telegram-plugin/tests/fleet-state.test.ts +3 -2
- package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
- package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
- package/telegram-plugin/tests/secret-detect.test.ts +8 -8
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
|
@@ -135,7 +135,7 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
135
135
|
// fixture. If the formatter changes shape, update these expectations.
|
|
136
136
|
const fixtureSnaps: AccountSnapshot[] = [
|
|
137
137
|
snap({
|
|
138
|
-
label: '
|
|
138
|
+
label: 'alice@example.com',
|
|
139
139
|
isActive: false,
|
|
140
140
|
quota: quota({
|
|
141
141
|
fiveHourUtilizationPct: 0,
|
|
@@ -146,7 +146,7 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
146
146
|
}),
|
|
147
147
|
}),
|
|
148
148
|
snap({
|
|
149
|
-
label: '
|
|
149
|
+
label: 'bob@example.com',
|
|
150
150
|
isActive: false,
|
|
151
151
|
quota: quota({
|
|
152
152
|
fiveHourUtilizationPct: 0,
|
|
@@ -157,7 +157,7 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
157
157
|
}),
|
|
158
158
|
}),
|
|
159
159
|
snap({
|
|
160
|
-
label: '
|
|
160
|
+
label: 'you@example.com',
|
|
161
161
|
isActive: true,
|
|
162
162
|
quota: quota({
|
|
163
163
|
fiveHourUtilizationPct: 8,
|
|
@@ -181,18 +181,18 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
181
181
|
|
|
182
182
|
it('marks the active account with ●', () => {
|
|
183
183
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
184
|
-
expect(out).toMatch(/●\s*<code>
|
|
184
|
+
expect(out).toMatch(/●\s*<code>you@example\.com<\/code>/);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
it('shows "back …" for blocked accounts with binding-window word', () => {
|
|
188
188
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
189
|
-
//
|
|
190
|
-
expect(out).toMatch(/
|
|
189
|
+
// bob@example is blocked on 7d, recovers Sun
|
|
190
|
+
expect(out).toMatch(/bob@example\.com[\s\S]*back .* 7-day cap/);
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
it('puts the imminent window first on healthy/throttling rows', () => {
|
|
194
194
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
195
|
-
//
|
|
195
|
+
// you: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
|
|
196
196
|
const pixRow = out.split('\n').find((l) => l.includes('5h refills') && l.includes('7d resets'));
|
|
197
197
|
expect(pixRow).toBeDefined();
|
|
198
198
|
expect(pixRow!.indexOf('5h refills')).toBeLessThan(pixRow!.indexOf('7d resets'));
|
|
@@ -245,7 +245,7 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
245
245
|
representativeClaim: 'five_hour',
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
const
|
|
248
|
+
const YOU_HEALTHY = quota({
|
|
249
249
|
fiveHourUtilizationPct: 8,
|
|
250
250
|
sevenDayUtilizationPct: 20,
|
|
251
251
|
fiveHourResetAt: new Date('2026-05-15T01:00:00Z'),
|
|
@@ -256,8 +256,8 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
256
256
|
const out5 = renderFallbackAnnouncement({
|
|
257
257
|
oldLabel: 'ken@x',
|
|
258
258
|
oldQuota: KEN_5H_BLOWN,
|
|
259
|
-
newLabel: '
|
|
260
|
-
newQuota:
|
|
259
|
+
newLabel: 'you@x',
|
|
260
|
+
newQuota: YOU_HEALTHY,
|
|
261
261
|
triggerAgent: 'carrie',
|
|
262
262
|
now: NOW,
|
|
263
263
|
tz: 'UTC',
|
|
@@ -272,8 +272,8 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
272
272
|
sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
|
|
273
273
|
representativeClaim: 'seven_day',
|
|
274
274
|
}),
|
|
275
|
-
newLabel: '
|
|
276
|
-
newQuota:
|
|
275
|
+
newLabel: 'you@x',
|
|
276
|
+
newQuota: YOU_HEALTHY,
|
|
277
277
|
triggerAgent: 'clerk',
|
|
278
278
|
now: NOW,
|
|
279
279
|
tz: 'UTC',
|
|
@@ -285,8 +285,8 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
285
285
|
const out = renderFallbackAnnouncement({
|
|
286
286
|
oldLabel: 'ken@x',
|
|
287
287
|
oldQuota: KEN_5H_BLOWN,
|
|
288
|
-
newLabel: '
|
|
289
|
-
newQuota:
|
|
288
|
+
newLabel: 'you@x',
|
|
289
|
+
newQuota: YOU_HEALTHY,
|
|
290
290
|
triggerAgent: 'carrie',
|
|
291
291
|
now: NOW,
|
|
292
292
|
tz: 'UTC',
|
|
@@ -299,8 +299,8 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
299
299
|
const happy = renderFallbackAnnouncement({
|
|
300
300
|
oldLabel: 'ken@x',
|
|
301
301
|
oldQuota: KEN_5H_BLOWN,
|
|
302
|
-
newLabel: '
|
|
303
|
-
newQuota:
|
|
302
|
+
newLabel: 'you@x',
|
|
303
|
+
newQuota: YOU_HEALTHY,
|
|
304
304
|
triggerAgent: 'carrie',
|
|
305
305
|
now: NOW,
|
|
306
306
|
tz: 'UTC',
|
|
@@ -310,7 +310,7 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
310
310
|
const tight = renderFallbackAnnouncement({
|
|
311
311
|
oldLabel: 'ken@x',
|
|
312
312
|
oldQuota: KEN_5H_BLOWN,
|
|
313
|
-
newLabel: '
|
|
313
|
+
newLabel: 'you@x',
|
|
314
314
|
newQuota: quota({ fiveHourUtilizationPct: 85 }),
|
|
315
315
|
triggerAgent: 'carrie',
|
|
316
316
|
now: NOW,
|
|
@@ -44,7 +44,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
44
44
|
fanned: ['alice', 'bob'],
|
|
45
45
|
}));
|
|
46
46
|
const out = await runFleetAutoFallback({
|
|
47
|
-
state: state('ken@x', ['ken@x', 'me@x', '
|
|
47
|
+
state: state('ken@x', ['ken@x', 'me@x', 'you@x']),
|
|
48
48
|
quotas: [
|
|
49
49
|
// ken: just blew 5h
|
|
50
50
|
qOk({
|
|
@@ -58,7 +58,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
58
58
|
sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
|
|
59
59
|
representativeClaim: 'seven_day',
|
|
60
60
|
}),
|
|
61
|
-
//
|
|
61
|
+
// you: healthy 5h/7d
|
|
62
62
|
qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
|
|
63
63
|
],
|
|
64
64
|
setActive,
|
|
@@ -69,10 +69,10 @@ describe('runFleetAutoFallback', () => {
|
|
|
69
69
|
|
|
70
70
|
expect(out.kind).toBe('switched');
|
|
71
71
|
expect(setActive).toHaveBeenCalledTimes(1);
|
|
72
|
-
expect(setActive).toHaveBeenCalledWith('
|
|
72
|
+
expect(setActive).toHaveBeenCalledWith('you@x');
|
|
73
73
|
if (out.kind === 'switched') {
|
|
74
74
|
expect(out.oldLabel).toBe('ken@x');
|
|
75
|
-
expect(out.newLabel).toBe('
|
|
75
|
+
expect(out.newLabel).toBe('you@x');
|
|
76
76
|
expect(out.announcement).toContain('5-hour limit on ken@x');
|
|
77
77
|
expect(out.announcement).toContain('Triggered by: agent <b>carrie</b>');
|
|
78
78
|
expect(out.announcement).toContain('plenty of headroom');
|
|
@@ -112,7 +112,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
112
112
|
it('idempotency: skips swap when active probes healthy (stale event)', async () => {
|
|
113
113
|
const setActive = vi.fn();
|
|
114
114
|
const out = await runFleetAutoFallback({
|
|
115
|
-
state: state('ken@x', ['ken@x', '
|
|
115
|
+
state: state('ken@x', ['ken@x', 'you@x']),
|
|
116
116
|
quotas: [
|
|
117
117
|
qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
118
118
|
qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
@@ -147,14 +147,14 @@ describe('runFleetAutoFallback', () => {
|
|
|
147
147
|
it('falls back to a throttling alternative when no healthy one exists', async () => {
|
|
148
148
|
const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
|
|
149
149
|
const out = await runFleetAutoFallback({
|
|
150
|
-
state: state('ken@x', ['ken@x', '
|
|
150
|
+
state: state('ken@x', ['ken@x', 'you@x']),
|
|
151
151
|
quotas: [
|
|
152
152
|
qOk({
|
|
153
153
|
fiveHourUtilizationPct: 100,
|
|
154
154
|
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
155
155
|
representativeClaim: 'five_hour',
|
|
156
156
|
}),
|
|
157
|
-
//
|
|
157
|
+
// you throttling at 85% but not blocked
|
|
158
158
|
qOk({ fiveHourUtilizationPct: 85, sevenDayUtilizationPct: 20 }),
|
|
159
159
|
],
|
|
160
160
|
setActive,
|
|
@@ -164,7 +164,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
expect(out.kind).toBe('switched');
|
|
167
|
-
expect(setActive).toHaveBeenCalledWith('
|
|
167
|
+
expect(setActive).toHaveBeenCalledWith('you@x');
|
|
168
168
|
if (out.kind === 'switched') {
|
|
169
169
|
expect(out.announcement).toContain('near limit — watch this');
|
|
170
170
|
}
|
|
@@ -173,7 +173,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
173
173
|
it('skips unknown-health (probe failed) when picking a target', async () => {
|
|
174
174
|
const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
|
|
175
175
|
const out = await runFleetAutoFallback({
|
|
176
|
-
state: state('ken@x', ['ken@x', 'broken@x', '
|
|
176
|
+
state: state('ken@x', ['ken@x', 'broken@x', 'you@x']),
|
|
177
177
|
quotas: [
|
|
178
178
|
qOk({ fiveHourUtilizationPct: 100, fiveHourResetAt: new Date('2026-05-15T05:50:00Z') }),
|
|
179
179
|
{ ok: false, reason: 'HTTP 401' },
|
|
@@ -186,7 +186,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
expect(out.kind).toBe('switched');
|
|
189
|
-
expect(setActive).toHaveBeenCalledWith('
|
|
189
|
+
expect(setActive).toHaveBeenCalledWith('you@x');
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -883,7 +883,7 @@ describe('probeSkills', () => {
|
|
|
883
883
|
expect(result.detail).toContain('no skills dir')
|
|
884
884
|
})
|
|
885
885
|
|
|
886
|
-
it('returns ok with
|
|
886
|
+
it('returns ok with every skill listed under Switchroom bucket when no overlay', async () => {
|
|
887
887
|
const fs = makeSkillsFs(
|
|
888
888
|
{ [skillsDir]: ['simplify', 'review'] },
|
|
889
889
|
new Set([
|
|
@@ -893,7 +893,40 @@ describe('probeSkills', () => {
|
|
|
893
893
|
)
|
|
894
894
|
const result = await probeSkills(agentDir, { fs })
|
|
895
895
|
expect(result.status).toBe('ok')
|
|
896
|
-
expect(result.detail).
|
|
896
|
+
expect(result.detail).toBe('Switchroom: review, simplify')
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it('buckets overlay-installed skills under Agent', async () => {
|
|
900
|
+
const overlayDir = '/state/agent/skills.d'
|
|
901
|
+
const fs = makeSkillsFs(
|
|
902
|
+
{
|
|
903
|
+
[skillsDir]: ['simplify', 'review', 'webapp-testing'],
|
|
904
|
+
[overlayDir]: ['webapp-testing.yaml'],
|
|
905
|
+
},
|
|
906
|
+
new Set([
|
|
907
|
+
`${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
|
|
908
|
+
`${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
|
|
909
|
+
`${skillsDir}/webapp-testing`, `${skillsDir}/webapp-testing/SKILL.md`,
|
|
910
|
+
overlayDir,
|
|
911
|
+
]),
|
|
912
|
+
)
|
|
913
|
+
const result = await probeSkills(agentDir, { fs })
|
|
914
|
+
expect(result.status).toBe('ok')
|
|
915
|
+
expect(result.detail).toBe('Switchroom: review, simplify · Agent: webapp-testing')
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
it('lists every skill without a +N more truncation in the healthy case', async () => {
|
|
919
|
+
const names = Array.from({ length: 12 }, (_, i) => `skill-${i.toString().padStart(2, '0')}`)
|
|
920
|
+
const fileSet = new Set<string>()
|
|
921
|
+
for (const n of names) {
|
|
922
|
+
fileSet.add(`${skillsDir}/${n}`)
|
|
923
|
+
fileSet.add(`${skillsDir}/${n}/SKILL.md`)
|
|
924
|
+
}
|
|
925
|
+
const fs = makeSkillsFs({ [skillsDir]: names }, fileSet)
|
|
926
|
+
const result = await probeSkills(agentDir, { fs })
|
|
927
|
+
expect(result.status).toBe('ok')
|
|
928
|
+
expect(result.detail).not.toContain('+')
|
|
929
|
+
for (const n of names) expect(result.detail).toContain(n)
|
|
897
930
|
})
|
|
898
931
|
|
|
899
932
|
it('degraded when at least one symlink dangles, names them up to cap', async () => {
|
|
@@ -912,6 +945,8 @@ describe('probeSkills', () => {
|
|
|
912
945
|
expect(result.detail).toContain('also-gone')
|
|
913
946
|
expect(result.detail).toContain('+1 more')
|
|
914
947
|
expect(result.detail).not.toContain('and-this')
|
|
948
|
+
// resolved skills still surface alongside the dangling summary
|
|
949
|
+
expect(result.detail).toContain('Switchroom: review, simplify')
|
|
915
950
|
})
|
|
916
951
|
|
|
917
952
|
it('returns ok when entries dir is empty', async () => {
|
|
@@ -155,8 +155,9 @@ describe('sanitiseToolArg', () => {
|
|
|
155
155
|
expect(sanitiseToolArg('Write', { file_path: '/tmp/out.json' })).toBe('out.json')
|
|
156
156
|
})
|
|
157
157
|
it('redacts bearer-token-like strings in Bash commands', () => {
|
|
158
|
-
const
|
|
159
|
-
|
|
158
|
+
const tok = ['sk-ant-', '1234567890abcdef'].join('')
|
|
159
|
+
const out = sanitiseToolArg('Bash', { command: `curl -H "Authorization: Bearer ${tok}" https://x` })
|
|
160
|
+
expect(out).not.toContain(tok)
|
|
160
161
|
expect(out.toLowerCase()).toContain('redacted')
|
|
161
162
|
})
|
|
162
163
|
it('returns empty string when no recognisable arg', () => {
|
|
@@ -12,7 +12,7 @@ describe('secret-detect audit log', () => {
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
it('emits a structured event with slug but never the raw value', () => {
|
|
15
|
-
const raw = 'sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
|
|
15
|
+
const raw = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
|
|
16
16
|
emitAudit({
|
|
17
17
|
chat_id: '-100',
|
|
18
18
|
message_id: 5,
|
|
@@ -27,7 +27,8 @@ describe('pipeline.runPipeline', () => {
|
|
|
27
27
|
|
|
28
28
|
it('stores a high-confidence hit and rewrites the prompt', () => {
|
|
29
29
|
const { write, list, store } = mkFakeVault()
|
|
30
|
-
const
|
|
30
|
+
const tok = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
|
|
31
|
+
const text = `hey here is my key: ${tok} thanks`
|
|
31
32
|
const res = runPipeline({
|
|
32
33
|
chat_id: '-100',
|
|
33
34
|
message_id: 5,
|
|
@@ -38,9 +39,9 @@ describe('pipeline.runPipeline', () => {
|
|
|
38
39
|
})
|
|
39
40
|
expect(res.stored).toHaveLength(1)
|
|
40
41
|
expect(res.rewritten_text).toContain('[secret stored as vault:')
|
|
41
|
-
expect(res.rewritten_text).not.toContain(
|
|
42
|
+
expect(res.rewritten_text).not.toContain(tok)
|
|
42
43
|
// The raw secret made it to the vault under the generated slug.
|
|
43
|
-
expect([...store.values()]).toContain(
|
|
44
|
+
expect([...store.values()]).toContain(tok)
|
|
44
45
|
// Audit emitted once with action=stored.
|
|
45
46
|
const storedLogs = captured.filter((l) => l.includes('"action":"stored"'))
|
|
46
47
|
expect(storedLogs).toHaveLength(1)
|
|
@@ -71,7 +72,7 @@ describe('pipeline.runPipeline', () => {
|
|
|
71
72
|
|
|
72
73
|
it('treats suppressed high-confidence hits as ambiguous', () => {
|
|
73
74
|
const { write, list, store } = mkFakeVault()
|
|
74
|
-
const text =
|
|
75
|
+
const text = `test ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
|
|
75
76
|
const res = runPipeline({
|
|
76
77
|
chat_id: 'c',
|
|
77
78
|
message_id: 1,
|
|
@@ -89,7 +90,7 @@ describe('pipeline.runPipeline', () => {
|
|
|
89
90
|
const { write, list, store } = mkFakeVault()
|
|
90
91
|
store.set('anthropic_api_key_20260423', 'preexisting')
|
|
91
92
|
const text =
|
|
92
|
-
|
|
93
|
+
`first: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')} second: ${['sk-ant-', 'BqZ13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
|
|
93
94
|
const res = runPipeline({
|
|
94
95
|
chat_id: 'c',
|
|
95
96
|
message_id: 2,
|
|
@@ -111,7 +112,7 @@ describe('pipeline.runPipeline', () => {
|
|
|
111
112
|
const res = runPipeline({
|
|
112
113
|
chat_id: 'c',
|
|
113
114
|
message_id: 3,
|
|
114
|
-
text:
|
|
115
|
+
text: `key is ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`,
|
|
115
116
|
passphrase: 'pw',
|
|
116
117
|
vaultWrite: failingWrite,
|
|
117
118
|
vaultList: list,
|
|
@@ -20,12 +20,13 @@ import type { VaultWriteFn, VaultListFn } from '../secret-detect/vault-write.js'
|
|
|
20
20
|
* this without breaking a test.
|
|
21
21
|
*/
|
|
22
22
|
describe('suppressor: never silent-allows on structured matches', () => {
|
|
23
|
+
const tok = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
|
|
23
24
|
const phrasings = [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
`this is a test, here is ${tok}`,
|
|
26
|
+
`mock token: ${tok}`,
|
|
27
|
+
`example: ${tok}`,
|
|
28
|
+
`dummy ${tok}`,
|
|
29
|
+
`fixture ${tok}`,
|
|
29
30
|
]
|
|
30
31
|
|
|
31
32
|
for (const text of phrasings) {
|
|
@@ -9,7 +9,7 @@ import { rewritePrompt } from '../secret-detect/rewrite.js'
|
|
|
9
9
|
|
|
10
10
|
describe('mask.maskToken', () => {
|
|
11
11
|
it('reveals first 6 + last 4 when length ≥ 18', () => {
|
|
12
|
-
const tok = 'sk-ant-abc123XYZdefGHI456789'
|
|
12
|
+
const tok = ['sk-ant-', 'abc123XYZdefGHI456789'].join('')
|
|
13
13
|
expect(maskToken(tok)).toBe(`${tok.slice(0, 6)}...${tok.slice(-4)}`)
|
|
14
14
|
})
|
|
15
15
|
it('returns *** for short inputs', () => {
|
|
@@ -96,7 +96,7 @@ describe('chunker.chunk', () => {
|
|
|
96
96
|
|
|
97
97
|
describe('suppressor.isSuppressed', () => {
|
|
98
98
|
it('demotes hits near test/mock/example/fixture/dummy', () => {
|
|
99
|
-
const text =
|
|
99
|
+
const text = `test: ${['sk-ant-', 'abc123defgh456789'].join('')}`
|
|
100
100
|
const start = text.indexOf('sk-ant-')
|
|
101
101
|
const end = text.length
|
|
102
102
|
expect(isSuppressed(text, start, end)).toBe(true)
|
|
@@ -104,13 +104,13 @@ describe('suppressor.isSuppressed', () => {
|
|
|
104
104
|
it('ignores markers more than 40 chars away', () => {
|
|
105
105
|
// 80 chars of filler between "test" and the secret
|
|
106
106
|
const filler = ' '.repeat(80)
|
|
107
|
-
const text = `test${filler}sk-ant-abc123defgh456789`
|
|
107
|
+
const text = `test${filler}${['sk-ant-', 'abc123defgh456789'].join('')}`
|
|
108
108
|
const start = text.indexOf('sk-ant-')
|
|
109
109
|
const end = text.length
|
|
110
110
|
expect(isSuppressed(text, start, end)).toBe(false)
|
|
111
111
|
})
|
|
112
112
|
it('whole-word only — "tested" does not trigger', () => {
|
|
113
|
-
const text =
|
|
113
|
+
const text = `untested ${['sk-ant-', 'abc123defgh456789'].join('')}`
|
|
114
114
|
const start = text.indexOf('sk-ant-')
|
|
115
115
|
const end = text.length
|
|
116
116
|
expect(isSuppressed(text, start, end)).toBe(false)
|
|
@@ -150,7 +150,7 @@ describe('rewrite.rewritePrompt', () => {
|
|
|
150
150
|
expect(out).toContain('[secret stored as vault:TOKEN]')
|
|
151
151
|
})
|
|
152
152
|
it('preserves non-secret substrings verbatim', () => {
|
|
153
|
-
const text =
|
|
153
|
+
const text = `please stash api key ANTHROPIC_API_KEY=${['sk-ant-', 'ABCDEFGHIJKLMNOP'].join('')} now`
|
|
154
154
|
const detections = detectSecrets(text)
|
|
155
155
|
expect(detections.length).toBeGreaterThan(0)
|
|
156
156
|
const targets = detections.map((d) => ({ detection: d, actual_slug: 'ANTHROPIC_API_KEY' }))
|
|
@@ -161,7 +161,7 @@ describe('rewrite.rewritePrompt', () => {
|
|
|
161
161
|
|
|
162
162
|
describe('detectSecrets — end-to-end', () => {
|
|
163
163
|
it('finds an anthropic key', () => {
|
|
164
|
-
const text =
|
|
164
|
+
const text = `here you go: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
|
|
165
165
|
const d = detectSecrets(text)
|
|
166
166
|
expect(d).toHaveLength(1)
|
|
167
167
|
expect(d[0]!.rule_id).toBe('anthropic_api_key')
|
|
@@ -175,7 +175,7 @@ describe('detectSecrets — end-to-end', () => {
|
|
|
175
175
|
expect(d.some((h) => h.rule_id === 'github_pat_classic')).toBe(true)
|
|
176
176
|
})
|
|
177
177
|
it('captures only the value for KEY=VALUE patterns', () => {
|
|
178
|
-
const text = '
|
|
178
|
+
const text = `ANTHROPIC_API_KEY=${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
|
|
179
179
|
const d = detectSecrets(text)
|
|
180
180
|
const envHit = d.find((h) => h.rule_id === 'env_key_value' || h.rule_id === 'anthropic_api_key')
|
|
181
181
|
expect(envHit).toBeDefined()
|
|
@@ -183,7 +183,7 @@ describe('detectSecrets — end-to-end', () => {
|
|
|
183
183
|
expect(envHit!.matched_text.startsWith('sk-ant-')).toBe(true)
|
|
184
184
|
})
|
|
185
185
|
it('flags suppressed on nearby "test"', () => {
|
|
186
|
-
const text =
|
|
186
|
+
const text = `test token: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
|
|
187
187
|
const d = detectSecrets(text)
|
|
188
188
|
expect(d.length).toBeGreaterThan(0)
|
|
189
189
|
expect(d[0]!.suppressed).toBe(true)
|
|
@@ -25,7 +25,7 @@ const CTX_READ: VaultGrantInboundContext = {
|
|
|
25
25
|
agent: 'gymbro',
|
|
26
26
|
key: 'fatsecret/credentials',
|
|
27
27
|
scope: 'read',
|
|
28
|
-
chat_id: '
|
|
28
|
+
chat_id: '12345',
|
|
29
29
|
ttl_seconds: 30 * 86400,
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -42,11 +42,11 @@ describe('buildVaultGrantApprovedInbound', () => {
|
|
|
42
42
|
ctx: CTX_READ,
|
|
43
43
|
grantId: 'vg_a1b2c3',
|
|
44
44
|
stageId: 'stage-001',
|
|
45
|
-
operatorId: '
|
|
45
|
+
operatorId: '12345',
|
|
46
46
|
nowMs: FIXED_NOW,
|
|
47
47
|
})
|
|
48
48
|
expect(msg.type).toBe('inbound')
|
|
49
|
-
expect(msg.chatId).toBe('
|
|
49
|
+
expect(msg.chatId).toBe('12345')
|
|
50
50
|
expect(msg.user).toBe('vault-broker')
|
|
51
51
|
expect(msg.userId).toBe(0)
|
|
52
52
|
expect(msg.ts).toBe(FIXED_NOW)
|
|
@@ -71,7 +71,7 @@ describe('buildVaultGrantApprovedInbound', () => {
|
|
|
71
71
|
ctx: CTX_READ,
|
|
72
72
|
grantId: 'vg_a1b2c3',
|
|
73
73
|
stageId: 'stage-001',
|
|
74
|
-
operatorId: '
|
|
74
|
+
operatorId: '12345',
|
|
75
75
|
})
|
|
76
76
|
expect(msg.meta).toEqual({
|
|
77
77
|
source: 'vault_grant_approved',
|
|
@@ -80,7 +80,7 @@ describe('buildVaultGrantApprovedInbound', () => {
|
|
|
80
80
|
scope: 'read',
|
|
81
81
|
grant_id: 'vg_a1b2c3',
|
|
82
82
|
stage_id: 'stage-001',
|
|
83
|
-
operator_id: '
|
|
83
|
+
operator_id: '12345',
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
86
|
|
|
@@ -151,7 +151,7 @@ describe('buildVaultGrantDeniedInbound', () => {
|
|
|
151
151
|
const msg = buildVaultGrantDeniedInbound({
|
|
152
152
|
ctx: CTX_READ,
|
|
153
153
|
stageId: 'stage-001',
|
|
154
|
-
operatorId: '
|
|
154
|
+
operatorId: '12345',
|
|
155
155
|
})
|
|
156
156
|
expect(msg.meta).toEqual({
|
|
157
157
|
source: 'vault_grant_denied',
|
|
@@ -159,7 +159,7 @@ describe('buildVaultGrantDeniedInbound', () => {
|
|
|
159
159
|
key: 'fatsecret/credentials',
|
|
160
160
|
scope: 'read',
|
|
161
161
|
stage_id: 'stage-001',
|
|
162
|
-
operator_id: '
|
|
162
|
+
operator_id: '12345',
|
|
163
163
|
})
|
|
164
164
|
expect((msg.meta as { grant_id?: string }).grant_id).toBeUndefined()
|
|
165
165
|
})
|
|
@@ -189,7 +189,7 @@ describe('buildVaultGrantDeniedInbound', () => {
|
|
|
189
189
|
expect(denied.type).toBe('inbound')
|
|
190
190
|
expect(denied.user).toBe('vault-broker')
|
|
191
191
|
expect(denied.userId).toBe(0)
|
|
192
|
-
expect(denied.chatId).toBe('
|
|
192
|
+
expect(denied.chatId).toBe('12345')
|
|
193
193
|
expect(denied.ts).toBe(FIXED_NOW)
|
|
194
194
|
expect(denied.messageId).toBe(FIXED_NOW)
|
|
195
195
|
})
|
|
@@ -112,3 +112,54 @@ describe('vault_request_access (#1012)', () => {
|
|
|
112
112
|
expect(handlerBlock).toMatch(/allowFrom\.includes/)
|
|
113
113
|
})
|
|
114
114
|
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fix B (#1487 follow-up): vault_request_access must NOT card/mint when
|
|
118
|
+
* the agent's STANDING ACL already covers the key — and must decide
|
|
119
|
+
* that by probing the BROKER as the agent (no-token listViaBroker over
|
|
120
|
+
* the per-agent socket — path-as-identity), never a gateway-side
|
|
121
|
+
* config/checkAclByAgent read (the gateway can see newer config than
|
|
122
|
+
* the broker has loaded → "covered here, denied there"). Read scope
|
|
123
|
+
* only; fail-open on probe error. Source-pattern assertions matching
|
|
124
|
+
* this file's established style (the flow has Telegram + module-state
|
|
125
|
+
* side effects that aren't behaviourally unit-testable here).
|
|
126
|
+
*/
|
|
127
|
+
describe('Fix B: vault_request_access standing-ACL-aware (#1487 follow-up)', () => {
|
|
128
|
+
const execBlock =
|
|
129
|
+
gatewaySrc.split('async function executeVaultRequestAccess')[1]?.split('\nasync function ')[0] ?? ''
|
|
130
|
+
const approveBlock =
|
|
131
|
+
gatewaySrc.split('async function performVaultAccessApproval')[1]?.split('\nasync function ')[0] ?? ''
|
|
132
|
+
|
|
133
|
+
it('request path: read-scope broker-probe short-circuits BEFORE the card is staged/sent', () => {
|
|
134
|
+
expect(execBlock).toContain('listViaBroker(')
|
|
135
|
+
expect(execBlock).toMatch(/scopeRaw === 'read'/)
|
|
136
|
+
const probeIdx = execBlock.indexOf('listViaBroker(')
|
|
137
|
+
const stageIdx = execBlock.indexOf('pendingVaultRequestAccesses.set(stageId')
|
|
138
|
+
expect(probeIdx).toBeGreaterThan(-1)
|
|
139
|
+
expect(stageIdx).toBeGreaterThan(-1)
|
|
140
|
+
expect(probeIdx).toBeLessThan(stageIdx)
|
|
141
|
+
expect(execBlock).toMatch(/ALREADY covered[\s\S]*?return\s*{/)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('request path: decides via the BROKER, not a gateway-side config/ACL read (B2 — no config drift)', () => {
|
|
145
|
+
expect(execBlock).not.toContain('checkAclByAgent(')
|
|
146
|
+
expect(execBlock).not.toContain('loadSwitchroomConfig(')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('operator-approve path: parallel guard short-circuits BEFORE mintGrantViaBroker', () => {
|
|
150
|
+
expect(approveBlock).toContain('listViaBroker(')
|
|
151
|
+
expect(approveBlock).toMatch(/pending\.scope === 'read'/)
|
|
152
|
+
const probeIdx = approveBlock.indexOf('listViaBroker(')
|
|
153
|
+
const mintIdx = approveBlock.indexOf('mintGrantViaBroker(mintArgs)')
|
|
154
|
+
expect(probeIdx).toBeGreaterThan(-1)
|
|
155
|
+
expect(mintIdx).toBeGreaterThan(-1)
|
|
156
|
+
expect(probeIdx).toBeLessThan(mintIdx)
|
|
157
|
+
expect(approveBlock).toMatch(/listViaBroker\([\s\S]*?pendingVaultRequestAccesses\.delete\(stageId\)[\s\S]*?return/)
|
|
158
|
+
expect(approveBlock).not.toContain('checkAclByAgent(')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('both guards are fail-open (probe error → normal card/mint flow)', () => {
|
|
162
|
+
expect(execBlock).toMatch(/try\s*{[\s\S]*?listViaBroker\([\s\S]*?}\s*catch/)
|
|
163
|
+
expect(approveBlock).toMatch(/try\s*{[\s\S]*?listViaBroker\([\s\S]*?}\s*catch/)
|
|
164
|
+
})
|
|
165
|
+
})
|