From e00fda0740e018a49b98c779d303c594cc0da26d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:20:58 +0000 Subject: [PATCH 1/4] fix: strip HTML comments before preview/routing; refuse apply when the sealed sidecar is gone Co-Authored-By: Subash Natarajan --- bin/fde.js | 17 +++++++++++-- test/fde-cli.test.js | 60 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/bin/fde.js b/bin/fde.js index 52a302a..cb90dcf 100755 --- a/bin/fde.js +++ b/bin/fde.js @@ -214,8 +214,12 @@ const PRIVATE_TAG = /<(\/)?private\b[^>]*>/gi // Depth-aware split of a markdown body into public text and sealed blocks. A // nested block seals to the outermost close, an unclosed one seals to EOF, and a // stray close is dropped - a regex pair cannot do any of those safely. +// HTML comments go first: template hints and pasted notes hide content there, and +// `clean` is what debrief/ingest preview to a human and route into memory. function splitPrivate(md) { const text = String(md || '') + .replace(//g, '') + .replace(//g, '')) + return stripControlChars(splitPrivate(md).clean) } // Read + redact in one step - the default way dashboard code should ever touch @@ -1340,13 +1344,17 @@ function writeProposal(eng, text) { const { clean, blocks } = splitPrivate(text) const proposePath = path.join(eng, DEBRIEF_PROPOSE) const privatePath = path.join(eng, DEBRIEF_PRIVATE) - withFileLock(proposePath, () => { atomicWriteFile(proposePath, clean) }) + // Seal first. A refused or failed sidecar write must not leave behind a + // proposal whose (private - redacted) marker has nothing left behind it. if (blocks.length) { + const blocked = refuseSymlinkWrite(privatePath, { soft: true }) + if (blocked) { console.error(blocked); process.exit(1) } withFileLock(privatePath, () => { atomicWriteFile(privatePath, `${blocks.join('\n')}\n`) }) try { fs.chmodSync(privatePath, 0o600) } catch (_) {} } else { try { fs.unlinkSync(privatePath) } catch (_) {} } + withFileLock(proposePath, () => { atomicWriteFile(proposePath, clean) }) return { proposePath, clean, blocks } } @@ -1441,6 +1449,11 @@ function cmdDebrief(args) { process.exit(1) } sealed = readSealedProposal(eng) + if (!sealed.length && input.includes(PRIVATE_MARKER)) { + console.error(`refused: the proposal seals a private note but ${DEBRIEF_PRIVATE} is missing or unreadable - applying now would drop it silently.`) + console.error('re-run the propose step (fde debrief --smart | fde ingest propose ).') + process.exit(1) + } } else { input = readDebriefInput(args) } diff --git a/test/fde-cli.test.js b/test/fde-cli.test.js index 992094b..70ea972 100644 --- a/test/fde-cli.test.js +++ b/test/fde-cli.test.js @@ -1824,6 +1824,66 @@ test('routable lines inside a block are sealed, never routed unsealed' assert.doesNotMatch(fs.readFileSync(html, 'utf8'), /12345678/) }) +test('a secret hidden in an HTML comment never reaches a preview or memory', () => { + const sandbox = makeSandbox('private-comment') + assert.equal(runFde(sandbox, ['resume', '--init', 'commentco']).status, 0) + const eng = engagementPath(sandbox, 'commentco') + const notes = path.join(sandbox.workspace, 'notes.md') + fs.writeFileSync(notes, [ + 'Decided: renew the support contract.', + '', + '/g, '') - .replace(//g, '') + if (opts.sealDangling) text = text.replace(/