diff --git a/policy/tells.yaml b/policy/tells.yaml index aca45c7..a25c117 100644 --- a/policy/tells.yaml +++ b/policy/tells.yaml @@ -30,7 +30,7 @@ tells: - id: repo-bytes family: repository terminal: true - phrases: ["exact contents", "current contents", "show the file", "exact file", "exact implementation", "add a docstring", "what does line", "line * of", "exact line", "git diff", "show diff", "at commit", "literal value", "the bytes"] + phrases: ["exact contents", "current contents", "show the file", "exact file", "exact implementation", "add a docstring", "what does line", "line * of", "exact line", "show diff", "at commit", "literal value", "the bytes"] # ---- runtime: require a runtime noun or temporal/status qualifier ---- - id: runtime-state diff --git a/src/decompose.js b/src/decompose.js index 6fa0a5d..972574d 100644 --- a/src/decompose.js +++ b/src/decompose.js @@ -7,6 +7,126 @@ function trimSegment(prompt, start, end) { return { text: prompt.slice(start, end), start, end }; } +function isWord(character) { + return character !== undefined && /[\p{L}\p{N}_]/u.test(character); +} + +function mergeRanges(ranges) { + const merged = []; + for (const range of ranges.sort((a, b) => a.start - b.start || a.end - b.end)) { + const previous = merged[merged.length - 1]; + if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end); + else merged.push({ ...range }); + } + return merged; +} + +function delimiterRunLength(prompt, start, delimiter) { + let end = start; + while (prompt[end] === delimiter) end += 1; + return end - start; +} + +function findClosingDelimiter(prompt, delimiter, start, requiredLength, allowLonger) { + for (let index = start; index < prompt.length;) { + if (prompt[index] !== delimiter) { + index += 1; + continue; + } + const length = delimiterRunLength(prompt, index, delimiter); + if (length === requiredLength || (allowLonger && length > requiredLength)) return index + length; + index += length; + } + return prompt.length; +} + +function isEscaped(prompt, index) { + let slashes = 0; + for (let cursor = index - 1; cursor >= 0 && prompt[cursor] === '\\'; cursor -= 1) slashes += 1; + return slashes % 2 === 1; +} + +/** Mark data/code contexts whose bytes must not be interpreted as evidence requests. */ +function scanProtectedRanges(prompt) { + if (typeof prompt !== 'string') throw new TypeError('prompt must be a string'); + const lexicalRanges = []; + const quotePairs = { '"': '"', '“': '”', "'": "'", '‘': '’' }; + + for (let index = 0; index < prompt.length;) { + const delimiter = prompt[index]; + const delimiterLength = delimiter === '`' || delimiter === '~' + ? delimiterRunLength(prompt, index, delimiter) : 0; + const isCodeDelimiter = delimiter === '`' || (delimiter === '~' && delimiterLength >= 3); + if (isCodeDelimiter) { + const end = findClosingDelimiter( + prompt, delimiter, index + delimiterLength, delimiterLength, delimiterLength >= 3, + ); + lexicalRanges.push({ start: index, end }); + index = end; + continue; + } + + const closeQuote = quotePairs[prompt[index]]; + const isSingle = prompt[index] === "'" || prompt[index] === '‘'; + const canOpen = closeQuote + && (!isSingle || (!isWord(prompt[index - 1]) && !/\s/u.test(prompt[index + 1] || ''))); + if (canOpen) { + let close = index + 1; + while ((close = prompt.indexOf(closeQuote, close)) !== -1) { + if (isEscaped(prompt, close)) { + close += closeQuote.length; + continue; + } + if (!isSingle || (!/\s/u.test(prompt[close - 1] || '') && !isWord(prompt[close + 1]))) break; + close += closeQuote.length; + } + const end = close === -1 ? prompt.length : close + closeQuote.length; + lexicalRanges.push({ start: index, end }); + index = end; + continue; + } + index += 1; + } + + const ranges = mergeRanges(lexicalRanges); + const stack = []; + let rangeIndex = 0; + for (let index = 0; index < prompt.length; index += 1) { + while (rangeIndex < ranges.length && index >= ranges[rangeIndex].end) rangeIndex += 1; + if (rangeIndex < ranges.length && index >= ranges[rangeIndex].start) { + index = ranges[rangeIndex].end - 1; + continue; + } + + if (prompt[index] === '(') stack.push({ start: index, hasOperator: false }); + else if (stack.length > 0 && ( + prompt[index] === ';' + || (prompt[index] === '&' && prompt[index + 1] === '&') + || (prompt[index] === '|' && prompt[index + 1] === '|') + )) stack[stack.length - 1].hasOperator = true; + else if (prompt[index] === ')' && stack.length > 0) { + const group = stack.pop(); + if (group.hasOperator) { + ranges.push({ start: group.start, end: index + 1 }); + if (stack.length > 0) stack[stack.length - 1].hasOperator = true; + } + } + } + + return mergeRanges(ranges); +} + +function overlapsRange(start, end, ranges) { + let low = 0; + let high = ranges.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (ranges[middle].end <= start) low = middle + 1; + else high = middle; + } + return low < ranges.length && ranges[low].start < end; +} + /** * Deterministically split explicit boundaries and high-precision "and + new * question" boundaries. Other bare conjunctions stay intact: under-splitting is @@ -19,11 +139,13 @@ function decompose(prompt) { // Whitespace runs are bounded to keep the scan linear (CodeQL js/polynomial-redos); // a boundary buried in 9+ spaces stays unsplit, which under-splits — the safe direction. const boundaries = /,?\s{1,8}(?:and\s{1,8})?then\s{1,8}|,\s{0,8}(?:and|but)\s{1,8}|;\s{0,8}|\s{1,8}and\s{1,8}(?=(?:is|are|was|were|what|where|who|show|find|check|did|does|do|has|have|can|will)\b)/giu; + const protectedRanges = scanProtectedRanges(prompt); const clauses = []; let cursor = 0; let match; while ((match = boundaries.exec(prompt)) !== null) { + if (overlapsRange(match.index, match.index + match[0].length, protectedRanges)) continue; const clause = trimSegment(prompt, cursor, match.index); if (clause) clauses.push(clause); cursor = match.index + match[0].length; @@ -34,4 +156,4 @@ function decompose(prompt) { return clauses; } -module.exports = { decompose }; +module.exports = { decompose, overlapsRange, scanProtectedRanges }; diff --git a/src/matcher.js b/src/matcher.js index a6db494..aaae48e 100644 --- a/src/matcher.js +++ b/src/matcher.js @@ -2,7 +2,7 @@ const fs = require('node:fs'); const YAML = require('yaml'); -const { decompose } = require('./decompose'); +const { decompose, overlapsRange, scanProtectedRanges } = require('./decompose'); function loadPolicy(filePath) { const parsed = YAML.parse(fs.readFileSync(filePath, 'utf8')); @@ -65,6 +65,7 @@ function isClauseFullyExcluded(clause, policy) { function matchPrompt(prompt, policy) { if (typeof prompt !== 'string') throw new TypeError('prompt must be a string'); const clauses = decompose(prompt); + const protectedRanges = scanProtectedRanges(prompt); const matches = []; clauses.forEach((clause, clauseIndex) => { @@ -76,6 +77,11 @@ function matchPrompt(prompt, policy) { while ((match = expression.exec(clause.text)) !== null) { const localStart = match.index; const localEnd = localStart + match[0].length; + const start = clause.start + localStart; + if (overlapsRange(start, start + match[0].length, protectedRanges)) { + if (match[0].length === 0) expression.lastIndex += 1; + continue; + } const isExcluded = excludedRanges.some((range) => ( range.family === tell.family && localStart < range.end && localEnd > range.start )); @@ -84,7 +90,6 @@ function matchPrompt(prompt, policy) { continue; } - const start = clause.start + localStart; matches.push({ id: tell.id, family: tell.family, diff --git a/test/claude-code.test.js b/test/claude-code.test.js index ddce9fd..f55f37c 100644 --- a/test/claude-code.test.js +++ b/test/claude-code.test.js @@ -133,6 +133,38 @@ test('UserPromptSubmit injects a compact route block and stays silent without ob assert.deepEqual(handleClaudeHook(event(target, 'UserPromptSubmit', { prompt: 'Make it better' }), { projectDir: target, packageRoot: root }), {}); }); +test('procedural and protected-data prompts create no hook obligation in advisory or enforce mode', () => { + const prompts = [ + 'byte-review (git fetch; git diff base..head), verify hashes, then merge', + 'Rewrite `literal `` who calls fake` without executing it', + ]; + + for (const mode of ['advisory', 'enforce']) { + for (const prompt of prompts) { + const target = project(); + installClaudeCode({ projectDir: target, packageRoot: root, mode }); + assert.deepEqual( + handleClaudeHook(event(target, 'UserPromptSubmit', { prompt }), { projectDir: target, packageRoot: root }), + {}, + ); + assert.deepEqual( + handleClaudeHook(event(target, 'PreToolUse', { + tool_name: 'Grep', tool_input: { pattern: 'unrelated' }, tool_use_id: `${mode}-grep`, + }), { projectDir: target, packageRoot: root }), + {}, + ); + assert.deepEqual( + handleClaudeHook(event(target, 'Stop', { + stop_hook_active: false, last_assistant_message: 'Finished the requested edits.', + }), { projectDir: target, packageRoot: root }), + {}, + ); + const state = JSON.parse(fs.readFileSync(path.join(target, '.leadline', 'state', 'session-live-1.json'), 'utf8')); + assert.deepEqual(state.contract.steps, []); + } + } +}); + test('protected operation denial escalates to an executable human approval fallback', () => { const target = project(); installClaudeCode({ projectDir: target, packageRoot: root, mode: 'enforce' }); diff --git a/test/decompose.test.js b/test/decompose.test.js index beea724..eaa49f4 100644 --- a/test/decompose.test.js +++ b/test/decompose.test.js @@ -61,3 +61,23 @@ test('bounds boundary whitespace at eight and safely under-splits longer runs', const nine = 'Did we ship the graph fix and is it live?'; assert.deepEqual(decompose(nine), [{ text: nine, start: 0, end: nine.length }]); }); + +test('does not decompose delimiters inside protected shell groups or code spans', () => { + const shell = 'byte-review (git fetch; git diff base..head), then merge'; + assert.deepEqual(decompose(shell), [ + { text: 'byte-review (git fetch; git diff base..head)', start: 0, end: shell.indexOf(', then') }, + { text: 'merge', start: shell.indexOf('merge'), end: shell.length }, + ]); + + for (const prompt of [ + 'Rewrite `who calls alpha; then show beta` without executing it', + 'Rewrite `literal `` who calls alpha; then show beta` without executing it', + 'Rewrite "who calls alpha; then show beta" without executing it', + 'Rewrite "say \\"who calls alpha; then show beta\\" now" without executing it', + 'Rewrite "who calls alpha; then show beta', + 'Example:\n```sh\nwho calls alpha; then show beta\n```\nwithout executing it', + 'Example:\n````md\n```\nwho calls alpha; then show beta\n```\n````\nwithout executing it', + ]) { + assert.deepEqual(decompose(prompt), [{ text: prompt, start: 0, end: prompt.length }]); + } +}); diff --git a/test/matcher.test.js b/test/matcher.test.js index 987ff69..61a8d61 100644 --- a/test/matcher.test.js +++ b/test/matcher.test.js @@ -78,3 +78,49 @@ test('covers high-precision development variants without semantic inference', () ['repository'], ); }); + +test('ignores tells inside quoted data, code, and shell procedure groups', () => { + const policy = loadPolicy(policyPath); + const prompts = [ + 'Rewrite `who calls performSync` without running it', + 'Rewrite `literal `` who calls performSync` without running it', + 'The example says "who calls performSync"; rewrite it', + 'Rewrite "say \\"who calls performSync\\" now" without running it', + 'Rewrite "who calls performSync; then merge', + 'Example:\n```sh\nwho calls performSync\n```\nRewrite the example.', + 'Example:\n````md\n```\nwho calls performSync\n```\n````\nRewrite the example.', + 'byte-review (git fetch; git diff base..head), verify hashes, then merge', + ]; + + for (const prompt of prompts) assert.deepEqual(matchPrompt(prompt, policy), []); +}); + +test('removes naked executable mentions without suppressing evidence requests', () => { + const policy = loadPolicy(policyPath); + + assert.deepEqual(matchPrompt('git diff base..head, then merge', policy), []); + assert.deepEqual( + matchPrompt('show diff for auth.js', policy).map((match) => match.family), + ['repository'], + ); + assert.deepEqual( + matchPrompt('check whether port 443 is open', policy).map((match) => match.family), + ['runtime'], + ); +}); + +test('retains an eligible evidence clause beside protected quoted data', () => { + const policy = loadPolicy(policyPath); + for (const prompt of [ + 'Rewrite "who calls fake", and who calls performSync?', + 'Rewrite "say \\"who calls fake\\" now" and who calls performSync?', + ]) { + const matches = matchPrompt(prompt, policy); + + assert.deepEqual(matches.map((match) => [match.family, match.text]), [['structural', 'who calls']]); + assert.deepEqual(matches[0].span, { + start: prompt.lastIndexOf('who calls'), + end: prompt.lastIndexOf('who calls') + 'who calls'.length, + }); + } +});