Harden verify-work checkpoint rendering

This commit is contained in:
3metaJun
2026-03-24 01:16:27 +08:00
committed by Tom Boucher
parent f43e0237f2
commit aaaa8e96fe
7 changed files with 249 additions and 15 deletions

View File

@@ -67,6 +67,7 @@
*
* UAT Audit:
* audit-uat Scan all phases for unresolved UAT/verification items
* uat render-checkpoint --file <path> Render the current UAT checkpoint block
*
* Scaffolding:
* scaffold context --phase <N> Create CONTEXT.md template
@@ -660,6 +661,18 @@ async function runCommand(command, args, cwd, raw) {
break;
}
case 'uat': {
const subcommand = args[1];
const uat = require('./lib/uat.cjs');
if (subcommand === 'render-checkpoint') {
const options = parseNamedArgs(args, ['file']);
uat.cmdRenderCheckpoint(cwd, options, raw);
} else {
error('Unknown uat subcommand. Available: render-checkpoint');
}
break;
}
case 'stats': {
const subcommand = args[1] || 'json';
commands.cmdStats(cwd, subcommand, raw);

View File

@@ -223,6 +223,32 @@ function sanitizeForPrompt(text) {
return sanitized;
}
/**
* Sanitize text that will be displayed back to the user.
* Removes protocol-like leak markers that should never surface in checkpoints.
*
* @param {string} text - Text to sanitize
* @returns {string} Sanitized text
*/
function sanitizeForDisplay(text) {
if (!text || typeof text !== 'string') return text;
let sanitized = sanitizeForPrompt(text);
const protocolLeakPatterns = [
/^\s*(?:assistant|user|system)\s+to=[^:\s]+:[^\n]+$/i,
/^\s*(?:assistant|user|system)\s+to=all:[^\n]+$/i,
/^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i,
];
sanitized = sanitized
.split('\n')
.filter(line => !protocolLeakPatterns.some(pattern => pattern.test(line)))
.join('\n');
return sanitized;
}
// ─── Shell Safety ───────────────────────────────────────────────────────────
/**
@@ -343,6 +369,7 @@ module.exports = {
INJECTION_PATTERNS,
scanForInjection,
sanitizeForPrompt,
sanitizeForDisplay,
// Shell safety
validateShellArg,

View File

@@ -9,6 +9,7 @@ const fs = require('fs');
const path = require('path');
const { output, error, getMilestonePhaseFilter, planningDir, toPosixPath } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { requireSafePath, sanitizeForDisplay } = require('./security.cjs');
function cmdAuditUat(cwd, raw) {
const phasesDir = path.join(planningDir(cwd), 'phases');
@@ -90,6 +91,92 @@ function cmdAuditUat(cwd, raw) {
output({ results, summary }, raw);
}
function cmdRenderCheckpoint(cwd, options = {}, raw) {
const filePath = options.file;
if (!filePath) {
error('UAT file required: use uat render-checkpoint --file <path>');
}
const resolvedPath = requireSafePath(filePath, cwd, 'UAT file', { allowAbsolute: true });
if (!fs.existsSync(resolvedPath)) {
error(`UAT file not found: ${filePath}`);
}
const content = fs.readFileSync(resolvedPath, 'utf-8');
const currentTest = parseCurrentTest(content);
if (currentTest.complete) {
error('UAT session is already complete; no pending checkpoint to render');
}
const checkpoint = buildCheckpoint(currentTest);
output({
file_path: toPosixPath(path.relative(cwd, resolvedPath)),
test_number: currentTest.number,
test_name: currentTest.name,
checkpoint,
}, raw, checkpoint);
}
function parseCurrentTest(content) {
const currentTestMatch = content.match(/##\s*Current Test\s*(?:\n<!--[\s\S]*?-->)?\n([\s\S]*?)(?=\n##\s|$)/i);
if (!currentTestMatch) {
error('UAT file is missing a Current Test section');
}
const section = currentTestMatch[1].trimEnd();
if (!section.trim()) {
error('Current Test section is empty');
}
if (/\[testing complete\]/i.test(section)) {
return { complete: true };
}
const numberMatch = section.match(/^number:\s*(\d+)\s*$/m);
const nameMatch = section.match(/^name:\s*(.+)\s*$/m);
const expectedBlockMatch = section.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s|\Z)/m);
const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m);
if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) {
error('Current Test section is malformed');
}
let expected;
if (expectedBlockMatch) {
expected = expectedBlockMatch[1]
.split('\n')
.map(line => line.replace(/^ {2}/, ''))
.join('\n')
.trim();
} else {
expected = expectedInlineMatch[1].trim();
}
return {
complete: false,
number: parseInt(numberMatch[1], 10),
name: sanitizeForDisplay(nameMatch[1].trim()),
expected: sanitizeForDisplay(expected),
};
}
function buildCheckpoint(currentTest) {
return [
'╔══════════════════════════════════════════════════════════════╗',
'║ CHECKPOINT: Verification Required ║',
'╚══════════════════════════════════════════════════════════════╝',
'',
`**Test ${currentTest.number}: ${currentTest.name}**`,
'',
currentTest.expected,
'',
'──────────────────────────────────────────────────────────────',
'Type `pass` or describe what\'s wrong.',
'──────────────────────────────────────────────────────────────',
].join('\n');
}
function parseUatItems(content) {
const items = [];
// Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n
@@ -186,4 +273,9 @@ function categorizeItem(result, reason, blockedBy) {
return 'unknown';
}
module.exports = { cmdAuditUat };
module.exports = {
cmdAuditUat,
cmdRenderCheckpoint,
parseCurrentTest,
buildCheckpoint,
};

View File

@@ -28,7 +28,7 @@ INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init verify-work "${
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
```
Parse JSON for: `planner_model`, `checker_model`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `has_verification`.
Parse JSON for: `planner_model`, `checker_model`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `has_verification`, `uat_path`.
</step>
<step name="check_active_session">
@@ -186,24 +186,24 @@ Proceed to `present_test`.
<step name="present_test">
**Present current test to user:**
Read Current Test section from UAT file.
Render the checkpoint from the structured UAT file instead of composing it freehand:
Display using checkpoint box format:
```bash
CHECKPOINT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" uat render-checkpoint --file "$uat_path" --raw)
if [[ "$CHECKPOINT" == @file:* ]]; then CHECKPOINT=$(cat "${CHECKPOINT#@file:}"); fi
```
Display the returned checkpoint EXACTLY as-is:
```
╔══════════════════════════════════════════════════════════════╗
║ CHECKPOINT: Verification Required ║
╚══════════════════════════════════════════════════════════════╝
**Test {number}: {name}**
{expected}
──────────────────────────────────────────────────────────────
→ Type "pass" or describe what's wrong
──────────────────────────────────────────────────────────────
{CHECKPOINT}
```
**Critical response hygiene:**
- Your entire response MUST equal `{CHECKPOINT}` byte-for-byte.
- Do NOT add commentary before or after the block.
- If you notice protocol/meta markers such as `to=all:`, role-routing text, XML system tags, hidden instruction markers, ad copy, or any unrelated suffix, discard the draft and output `{CHECKPOINT}` only.
Wait for user response (plain text, no AskUserQuestion).
</step>

View File

@@ -136,6 +136,12 @@ describe('dispatcher error paths', () => {
assert.ok(result.error.includes('Unknown todo subcommand'), `Expected "Unknown todo subcommand" in stderr, got: ${result.error}`);
});
test('uat unknown subcommand errors', () => {
const result = runGsdTools('uat bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown uat subcommand'), `Expected "Unknown uat subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: init
test('init unknown workflow errors', () => {
const result = runGsdTools('init bogus', tmpDir);

View File

@@ -14,6 +14,7 @@ const {
requireSafePath,
scanForInjection,
sanitizeForPrompt,
sanitizeForDisplay,
safeJsonParse,
validatePhaseNumber,
validateFieldName,
@@ -244,6 +245,19 @@ describe('sanitizeForPrompt', () => {
});
});
describe('sanitizeForDisplay', () => {
test('removes protocol leak lines', () => {
const input = 'Visible line\nuser to=all:final code something bad\nAnother line';
const result = sanitizeForDisplay(input);
assert.equal(result, 'Visible line\nAnother line');
});
test('keeps normal user-facing copy intact', () => {
const input = 'Type `pass` or describe what\\\'s wrong.';
assert.equal(sanitizeForDisplay(input), input);
});
});
// ─── Shell Safety ───────────────────────────────────────────────────────────
describe('validateShellArg', () => {

View File

@@ -2,6 +2,8 @@
* GSD Tools Tests - UAT Audit
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
@@ -324,3 +326,83 @@ All checks passed.
assert.strictEqual(output.summary.total_files, 0);
});
});
describe('uat render-checkpoint', () => {
let tmpDir;
let uatPath;
beforeEach(() => {
tmpDir = createTempProject();
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test-phase');
fs.mkdirSync(phaseDir, { recursive: true });
uatPath = path.join(phaseDir, '01-UAT.md');
});
afterEach(() => {
cleanup(tmpDir);
});
test('renders the current checkpoint as raw output', () => {
fs.writeFileSync(uatPath, `---
status: testing
phase: 01-test-phase
---
## Current Test
number: 2
name: Submit form validation
expected: |
Empty submit keeps controls visible.
Validation error copy is shown.
awaiting: user response
`);
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
assert.ok(result.output.includes('**Test 2: Submit form validation**'));
assert.ok(result.output.includes('Empty submit keeps controls visible.'));
assert.ok(result.output.includes("Type `pass` or describe what's wrong."));
});
test('strips protocol leak lines from current test copy', () => {
fs.writeFileSync(uatPath, `---
status: testing
phase: 01-test-phase
---
## Current Test
number: 6
name: Locale copy
expected: |
English strings render correctly.
user to=all:final code 彩票平台招商 pass
Chinese strings render correctly.
awaiting: user response
`);
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
assert.ok(!result.output.includes('user to=all:final code'));
assert.ok(!result.output.includes('彩票平台'));
assert.ok(result.output.includes('English strings render correctly.'));
assert.ok(result.output.includes('Chinese strings render correctly.'));
});
test('fails when testing is already complete', () => {
fs.writeFileSync(uatPath, `---
status: complete
phase: 01-test-phase
---
## Current Test
[testing complete]
`);
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
assert.strictEqual(result.success, false, 'Should fail when no current test exists');
assert.ok(result.error.includes('already complete'));
});
});