import { describe, it, expect } from 'vitest'; import { redactOptions, refusal, withinRoots } from '../mcpCommand.js'; /** * Story 7.3 — argument-dependent answers are tools, and they stay small. * * The bounded-answer and unsupported-connector ACs are held by Story 6.5's functions, which these tools WRAP rather * than re-implement — `retrieve.spec.ts` owns those assertions, and duplicating them here would be the second copy this * epic keeps removing. What is new, and tested here, is redaction and confinement. */ describe('⛔ an env reference survives, everything else is redacted (AC-4)', () => { it('returns a reference unresolved and redacts every other value', () => { /** * The threat is precise: connection options hold API keys, and an agent that reads one can put it in a log, a * commit, or a message to a model provider. The rule is not "hide secrets" — that needs knowing which values are * secret — it is REVEAL ONLY THE SHAPE. A reference tells an agent the option is wired up, which is what it needs * to answer "is this configured", while the value never leaves the machine. */ const redacted = redactOptions({ apiKey: 'sk-live-9f3a-REAL-SECRET', host: 'https://shop.example.com', token: '$env:SHOP_TOKEN', secret: '${env.SHOP_SECRET}', other: '{{ env.THING }}', empty: '', nested: { a: 1 }, count: 42, }); expect(redacted['token']).toBe('$env:SHOP_TOKEN'); expect(redacted['secret']).toBe('${env.SHOP_SECRET}'); expect(redacted['other']).toBe('{{ env.THING }}'); // ⛔ Everything else, including a value that looks harmless. A host name is not a secret, and it is still redacted: // deciding per-value which is safe is the mistake, because the decision is made by whoever named the option. expect(redacted['apiKey']).toBe('«redacted»'); expect(redacted['host']).toBe('«redacted»'); expect(redacted['empty']).toBe('«redacted»'); expect(redacted['nested']).toBe('«redacted»'); expect(redacted['count']).toBe('«redacted»'); // The whole point: no real value survives anywhere in the output. expect(JSON.stringify(redacted)).not.toContain('sk-live'); expect(JSON.stringify(redacted)).not.toContain('shop.example.com'); }); it('keeps the keys, because the shape is the useful part', () => { const redacted = redactOptions({ apiKey: 'x', host: 'y' }); expect(Object.keys(redacted).sort()).toEqual(['apiKey', 'host']); }); it('answers an empty object for anything that is not an options map', () => { for (const input of [null, undefined, 'string', 42, ['a']]) { expect(redactOptions(input)).toEqual({}); } }); }); describe('⛔ a path outside the declared roots is refused (AC-5)', () => { const roots = ['/ws/project']; it('accepts the root itself and anything inside it', () => { expect(withinRoots('/ws/project', roots)).toBe(true); expect(withinRoots('/ws/project/partials/main.yaml', roots)).toBe(true); }); it('refuses a sibling whose name merely starts the same', () => { // ⛔ The reason the check requires a separator: a prefix comparison lets `/ws/project-evil` pass as inside // `/ws/project`, which is a traversal that never uses `..` and so survives any pattern that looks for one. expect(withinRoots('/ws/project-evil/secrets.yaml', roots)).toBe(false); expect(withinRoots('/ws/projectile', roots)).toBe(false); }); it('refuses a traversal, resolved rather than pattern-matched', () => { expect(withinRoots('/ws/project/../../etc/passwd', roots)).toBe(false); expect(withinRoots('/ws/project/partials/../../../etc/shadow', roots)).toBe( false, ); // …and a traversal that comes back inside is fine, because it IS inside. expect(withinRoots('/ws/project/partials/../main.yaml', roots)).toBe(true); }); it('⛔ refuses everything when no root was declared', () => { // Fail closed. A client that declared nothing has granted nothing, and defaulting to "the whole disk" is how a // confinement rule becomes decoration. expect(withinRoots('/ws/project/main.yaml', [])).toBe(false); }); it('the refusal says what was refused and why, and that retrying will not help', () => { const message = refusal('/etc/passwd', roots); expect(message).toContain('/etc/passwd'); expect(message).toContain('/ws/project'); // An agent that reads "permissions error" retries; one that reads "confinement rule" stops and says so. expect(message).toMatch(/confinement rule, not a permissions error/); expect(message).toMatch(/no part of the path was read/); }); });