Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion policy/tells.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 123 additions & 1 deletion src/decompose.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -34,4 +156,4 @@ function decompose(prompt) {
return clauses;
}

module.exports = { decompose };
module.exports = { decompose, overlapsRange, scanProtectedRanges };
9 changes: 7 additions & 2 deletions src/matcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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) => {
Expand All @@ -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
));
Expand All @@ -84,7 +90,6 @@ function matchPrompt(prompt, policy) {
continue;
}

const start = clause.start + localStart;
matches.push({
id: tell.id,
family: tell.family,
Expand Down
32 changes: 32 additions & 0 deletions test/claude-code.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
20 changes: 20 additions & 0 deletions test/decompose.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]);
}
});
46 changes: 46 additions & 0 deletions test/matcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
});
Loading