diff --git a/skill/scripts/hook-before-edit.mjs b/skill/scripts/hook-before-edit.mjs index cfe7f6ba8..5b8b13087 100644 --- a/skill/scripts/hook-before-edit.mjs +++ b/skill/scripts/hook-before-edit.mjs @@ -16,6 +16,7 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, @@ -348,13 +349,26 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { - const rendered = renderTemplate(findings, filePath, config, { cwd, footer: footerMode, reserveChars }); - const blocked = rendered.replace( + const limits = config?.limits || DEFAULT_CONFIG.limits; + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT - BLOCK_PREFIX.length, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 260854a44..4768c7c87 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -1702,14 +1702,15 @@ export function designNoteReserve(scanOptions, cache, sessionId) { // emissions and Cursor denials share the session flag (`footerShown`), so a // session pays the full policy exactly once however it first fires. The mode // is a peek: the clamp can downgrade a requested full footer under a tight -// budget, so the flag commits only when the full policy actually reached the -// output (commitFooterShown, matched on the footer's opening words). +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. export function footerModeForSession(cache, sessionId) { return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; } export function commitFooterShown(cache, sessionId, text) { - if (!text || !text.includes(FULL_FOOTER_SENTINEL)) return; + if (!text || !text.includes(directiveFooter())) return; const session = ensureSession(cache, sessionId); if (session.footerShown) return; session.footerShown = true; @@ -1718,10 +1719,6 @@ export function commitFooterShown(cache, sessionId, text) { const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; -// Opening words of the full footer; commitFooterShown matches on it to tell -// whether the full policy survived the clamp. -const FULL_FOOTER_SENTINEL = 'Triage each finding'; - // The directive footer is the part of the hook output that steers model // behavior. Intentional moves, in order: // 1. **Imperative, not advisory.** "Triage each finding..." beats @@ -1749,7 +1746,7 @@ function directiveFooter(opts = {}) { return 'Triage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `hook-admin.mjs ignore-value` and disclose them in your reply; unsure, ask in one line.'; } return [ - `${FULL_FOOTER_SENTINEL}, then state in your reply what you fixed, what you suppressed, and what you left standing:`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', '- Real design problem: fix it. Keep intentional design as designed.', `- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run \`${HOOK_ADMIN_COMMAND} ignore-value "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, '- Unsure: leave it as is and ask the user in one line.', diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 02aa454c7..150297fd0 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -50,6 +50,7 @@ import { coLocatedStylesheets, runHook, runStopHook, + commitFooterShown, IMMEDIATE_TIER_RULES, splitFindingsByTier, perEditTieringActive, @@ -2305,6 +2306,22 @@ describe('runHook() — session-scoped notices', () => { const cache = readCache(cwd); assert.ok(!cache.sessions['sid-1'].footerShown, 'a downgraded footer does not spend the session flag'); }); + + it('commits the footer flag only for the complete full policy, not its opening words', () => { + const cache = { version: 1, sessions: {} }; + const full = renderTemplate( + [finding('tiny-text', 1, { name: 'Tiny text' })], + '/x/a.css', DEFAULT_CONFIG, { cwd: '/x' }, + ); + + // A tail truncation can spare "Triage each finding" while cutting the + // policy body. That must not count as delivered. + commitFooterShown(cache, 'sid-1', full.slice(0, full.length - 40)); + assert.ok(!cache.sessions['sid-1']?.footerShown, 'a truncated policy must not spend the flag'); + + commitFooterShown(cache, 'sid-1', full); + assert.ok(cache.sessions['sid-1'].footerShown, 'the intact policy commits the flag'); + }); }); describe('runHook() — clean-ack noise', () => { @@ -3113,6 +3130,8 @@ describe('Cursor hook scripts', () => { assert.match(payload.user_message, /blocked this write/); assert.match(payload.user_message, /side-tab/); assert.match(payload.agent_message, /Triage each finding/); + assert.match(payload.agent_message, /Full suppression ladder/, 'the deny message carries the complete policy, not a truncated head'); + assert.ok(payload.agent_message.length <= 4000, 'the deny message respects the Cursor cap'); const entries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').map((line) => JSON.parse(line)); assert.equal(entries[0].event, 'preToolUse');