import assert from 'node:assert/strict' import { test } from 'node:test' import { displayPath, facetContextText, facetRuleText, isWideningCall, outsideReason, pathArgumentsFor, widenReason, } from '../lib/scope.js' test('pathArgumentsFor reads the path argument of every guarded tool', () => { assert.deepEqual(pathArgumentsFor('read', { file_path: 'src/a.ts' }), ['src/a.ts']) assert.deepEqual(pathArgumentsFor('write', { file_path: 'src/a.ts', content: 'x' }), ['src/a.ts']) assert.deepEqual(pathArgumentsFor('edit', { file_path: 'src/a.ts' }), ['src/a.ts']) assert.deepEqual(pathArgumentsFor('read_image', { file_path: 'a.png' }), ['a.png']) assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x', path: 'src' }), ['src']) assert.deepEqual(pathArgumentsFor('glob', { pattern: '*.ts', path: 'src' }), ['src']) assert.deepEqual(pathArgumentsFor('str_replace_editor', { path: 'src/a.ts' }), ['src/a.ts']) assert.deepEqual(pathArgumentsFor('recall', { path: 'src', focus: 'src/controllers' }), [ 'src', 'src/controllers', ]) }) test('pathArgumentsFor has no opinion where it cannot have one', () => { // The deliberate hole: a shell command cannot be judged from argv. assert.deepEqual(pathArgumentsFor('bash', { command: 'cat /etc/passwd' }), []) // Unknown tools are not guessed at. assert.deepEqual(pathArgumentsFor('mcp_something', { path: '/etc' }), []) // An omitted path means the session directory, which is already in scope. assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x' }), []) // Unvalidated JSON must never make the guard throw. assert.deepEqual(pathArgumentsFor('read', null), []) assert.deepEqual(pathArgumentsFor('read', 'nonsense'), []) assert.deepEqual(pathArgumentsFor('read', { file_path: 42 }), []) assert.deepEqual(pathArgumentsFor('read', { file_path: ' ' }), []) assert.deepEqual(pathArgumentsFor('grep', { path: { nested: '/etc' } }), []) }) test('isWideningCall is true only for the one act that needs the user', () => { assert.equal(isWideningCall('facet', { action: 'add', paths: ['x'] }), true) // Narrowing and reading cannot show the agent anything new. assert.equal(isWideningCall('facet', { action: 'remove', paths: ['x'] }), false) assert.equal(isWideningCall('facet', { action: 'reset' }), false) assert.equal(isWideningCall('facet', { action: 'status' }), false) assert.equal(isWideningCall('read', { action: 'add' }), false) assert.equal(isWideningCall('facet', null), false) }) test('facetRuleText states the rule and never the binding', () => { const rule = facetRuleText() // Mode-neutral by construction. A prompt section is registered once as a plain // string, and a `volatile()` edit updates the config reference without // re-applying the plugin — so a `mode` interpolated here would freeze at load // while the guard went on enforcing the new one. The mode-specific sentence // lives in the binding, which is a callback. assert.match(rule, /Work inside the facet\./) assert.doesNotMatch(rule, /refused|put to the user as a question/) assert.match(rule, /Never widen the scope on your own initiative/) // The rule must not name a path: a changing system prompt invalidates the // prompt cache for the whole conversation. assert.doesNotMatch(rule, /\/|src\//) assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '') }) test('facetContextText carries the binding that does change', () => { const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }) assert.equal( ask, 'facet: /w/src/room\n' + 'outside the facet: put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again', ) const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }) assert.equal( deny, 'facet: /w/src/room\n' + 'granted outside it: src/shared\n' + 'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope', ) // The binding is the only half that can follow a live edit, so the two modes // have to read differently here — they no longer differ in the rule. assert.notEqual(ask, deny) // Unscoped sessions contribute nothing rather than an empty heading. assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '') assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '') }) test('a session with no approval channel says so instead of promising a question', () => { // `approval: never` refuses every ask before an answerer is consulted, so the // mode stops mattering: `ask` and `deny` both end in a refusal, and // `facet action=add` cannot be approved either. `canAsk: false` is the one // input that collapses them, and it has to name the mechanism that still // works — `extraRoots` is configuration, not a per-conversation grant. for (const mode of ['ask', 'deny']) { const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false }) assert.match(text, /outside the facet: refused — approval prompts are off in this session/) assert.match(text, /`facet action=add` cannot be approved either/) assert.match(text, /`extraRoots`/) assert.doesNotMatch(text, /put to the user as a question/) } // The default stays a promise: a caller that knows nothing about approval // channels must not silently change what the binding says. assert.match( facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }), /put to the user as a question/, ) }) test('displayPath prefers the shortest unambiguous form', () => { assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts') assert.equal(displayPath('/w', '/w'), '.') assert.equal(displayPath('/w', '/other/a.ts'), '/other/a.ts') assert.equal(displayPath('', '/w/a.ts'), '/w/a.ts') }) test('outsideReason names the path, the boundary and the way forward', () => { const reason = outsideReason({ cwd: '/w/src/controllers/room', path: '/w/src/shared/types.ts', root: '/w/src/controllers/room', mode: 'ask', }) assert.match(reason, /src\/shared\/types\.ts/) assert.match(reason, /src\/controllers\/room/) // A denial that does not say how to proceed is one the agent routes around. assert.match(reason, /facet action=add/) }) test('widenReason survives an add with no usable paths', () => { assert.match(widenReason(['src/shared']), /src\/shared/) assert.match(widenReason([]), /nothing/) })