* fix(#2444): re-resolve body-parser to 2.3.0 in lockfile (GHSA-v422-hmwv-36x6) GHSA-v422-hmwv-36x6 (body-parser DoS via invalid limit value, low severity, published 2026-07-20T23:23:26Z) made tests/npm-integrity-gate.test.cjs (#3588: root workspace production tree has no advisories) fail any subsequent npm audit --omit=dev. The advisory affects body-parser >=2.0.0 <2.3.0 pulled transitively via @anthropic-ai/claude-agent-sdk -> @modelcontextprotocol/sdk -> express -> body-parser@2.2.2. express@5.2.1 already declares body-parser as ^2.2.1, so 2.3.0 is a valid re-resolution within express's own compatibility range — no override needed. Regenerated the lockfile via 'npm audit fix --omit=dev' which re-resolves transitive deps within their declared ranges; package.json is unchanged. Verified: npm audit --omit=dev reports 0/0/0/0/0 advisories; body-parser now reads as 2.3.0 in 'npm ls body-parser --omit=dev'. * test(#2450): failing-first CRLF regression for record-session insert path cmdStateRecordSession's section-rewrite regexes (src/state.cts:1166, :1195) used literal \\n which cannot match CRLF STATE.md delimiters. The detector regex (CRLF-tolerant via $ under /m) entered the rewrite branch, the writer regex silently no-op'd, but updated.push(...)/ sessionCreated=true ran unconditionally. Result: caller reported recorded:true with 'Resume File' in updated, but the field was never written to disk. With core.autocrlf=input, the CRLF working-tree file produces no git diff, so the bug was invisible. Adds three regression tests covering all three rewrite paths: - CRLF STATE.md with ## Session and Resume file absent - CRLF STATE.md with ## Session and Stopped at absent - CRLF STATE.md with ## Session Continuity (bootstrap shape) Each asserts the field IS on disk (the bug discriminator: the pre-fix command's JSON output looked identical to a successful write). * fix(#2450): CRLF-tolerant session-section rewrite + no-op-detection guard Two regexes in cmdStateRecordSession used literal \\n which cannot match CRLF STATE.md, silently no-op'ing the section rewrite while the CRLF- tolerant detector above entered the branch. The reporter's exact repro: on a CRLF STATE.md with one canonical session field absent, the command returned recorded:true + updated:['Resume File'] but the field was never written to disk. Three changes: 1. src/state.cts:1166 (canonical ## Session rewrite regex): \\n -> \\r?\\n 2. src/state.cts:1195 (## Session Continuity insert regex): \\n -> \\r?\\n 3. Defensive invariant (#2450 class fix per reporter's suggestion): track whether the chosen branch's replace actually matched via callback flag. Only set sessionCreated=true and push to updated when rewriteMatched. Unreachable post-fix, but fail-loud is the right posture for a silent- success gate. If a future drift between the detector and writer regexes reintroduces the asymmetry, the caller will not see false updated entries. Same canonical CRLF-tolerant form already in use at check-command-router.cts :205 (extractPlanDesignatedSections). Same bug class previously fixed in #1658, #1668, #2206, #2449. * fix(#2450): address review followups + add changeset Code-review + security-review both flagged the unreachable else at the Session Continuity branch (defaulted rewriteMatched=true in dead code, re-arming the bug class for future drift). Removed the else; the remaining code path leaves rewriteMatched=false if linesToInsert is empty, preserving the fail-loud posture. Added scope-limitation doc to the rewriteMatched gate: it covers the INSERT path only, not the earlier in-place stateReplaceField successes (which DID land on disk and correctly push to updated unconditionally). Tests: - Normalized STATE_CRLF_SESSION_MISSING_RESUME fixture to match the canonical 6-key frontmatter of STATE_WITH_SESSION (code-review I2). - Added mixed-ending test (LF frontmatter + CRLF body) to close CONTRIBUTING.md:490 'Mixed CRLF/LF newlines' requirement (I1). Added Fixed changeset (code-review H1). * docs(changeset): backfill PR number to 2482
This commit is contained in:
@@ -1155,6 +1155,16 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
||||
const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
|
||||
const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
|
||||
|
||||
// Track whether the chosen branch's rewrite actually matched. The detector
|
||||
// regexes (existingCanonicalSession/existingSessionContinuity) are CRLF-
|
||||
// tolerant ($ under /m treats \r as a line terminator); the writer regexes
|
||||
// below must be too. If a writer regex silently fails to match (line-ending
|
||||
// mismatch, unexpected heading shape, ...), do NOT report success — the
|
||||
// caller would believe fields were persisted that were silently dropped
|
||||
// (#2450). The append branch always sets rewriteMatched=true (it always
|
||||
// mutates content).
|
||||
let rewriteMatched = false;
|
||||
|
||||
if (existingCanonicalSession) {
|
||||
// Normalize in place: replace the ENTIRE BODY of the existing ## Session
|
||||
// section (heading + all content up to the next ## heading or EOF) with
|
||||
@@ -1162,17 +1172,27 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
||||
// `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
|
||||
// which correctly stops at the next section boundary without consuming it.
|
||||
// A trailing blank line is added so the next ## heading keeps its spacing.
|
||||
//
|
||||
// CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not
|
||||
// match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while
|
||||
// updated.push(...) reported success — #2450. The detector regex on the
|
||||
// line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the
|
||||
// asymmetry armed the bug.
|
||||
const canonicalReplacement = [
|
||||
'## Session',
|
||||
'',
|
||||
`**Last session:** ${now}`,
|
||||
`**Stopped at:** ${stoppedAtValue}`,
|
||||
`**Resume file:** ${resumeValue}`,
|
||||
'',
|
||||
'',
|
||||
].join('\n');
|
||||
content = content.replace(
|
||||
/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m,
|
||||
[
|
||||
'## Session',
|
||||
'',
|
||||
`**Last session:** ${now}`,
|
||||
`**Stopped at:** ${stoppedAtValue}`,
|
||||
`**Resume file:** ${resumeValue}`,
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
/^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m,
|
||||
() => {
|
||||
rewriteMatched = true;
|
||||
return canonicalReplacement;
|
||||
},
|
||||
);
|
||||
} else if (existingSessionContinuity) {
|
||||
// #1101: a `## Session Continuity` section already exists (bootstrap
|
||||
@@ -1183,6 +1203,8 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
||||
// (e.g. prose like "Next recommended action"). Fields already updated in
|
||||
// place above (needs* false) are not re-inserted. A function replacement
|
||||
// is used so `$`-bearing caller values are inserted literally (#3454).
|
||||
//
|
||||
// CRLF-tolerant (`\r?\n`): same #2450 fix as the canonical branch above.
|
||||
const linesToInsert: string[] = [];
|
||||
if (needsLastSession) linesToInsert.push(`**Last session:** ${now}`);
|
||||
if (needsStoppedAt) linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
|
||||
@@ -1192,10 +1214,20 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
||||
// above (#1101 review F3) — otherwise a lowercase heading would detect
|
||||
// but no-op the insert while still reporting the fields as updated.
|
||||
content = content.replace(
|
||||
/^(## Session Continuity[ \t]*\n)/im,
|
||||
(_m, heading: string) => heading + linesToInsert.join('\n') + '\n',
|
||||
/^(## Session Continuity[ \t]*\r?\n)/im,
|
||||
(_m, heading: string) => {
|
||||
rewriteMatched = true;
|
||||
return heading + linesToInsert.join('\n') + '\n';
|
||||
},
|
||||
);
|
||||
}
|
||||
// No `else` branch: if linesToInsert.length === 0 the outer guard at
|
||||
// :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile
|
||||
// || needsLastSession)) could not have fired, so this whole block is
|
||||
// unreachable. Leaving `rewriteMatched = false` here is the fail-loud
|
||||
// posture — a future change to the outer guard or needs* computation
|
||||
// that makes this branch reachable will surface as a missing
|
||||
// updated[] entry (silent recorded:false) rather than re-arming #2450.
|
||||
} else {
|
||||
// No session heading exists at all — append a new canonical section.
|
||||
const scaffold = [
|
||||
@@ -1208,13 +1240,28 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
||||
'',
|
||||
].join('\n');
|
||||
content = content.trimEnd() + '\n' + scaffold;
|
||||
rewriteMatched = true;
|
||||
}
|
||||
|
||||
sessionCreated = true;
|
||||
|
||||
if (needsLastSession) updated.push('Last session');
|
||||
if (needsStoppedAt) updated.push('Stopped At');
|
||||
if (needsResumeFile) updated.push('Resume File');
|
||||
// #2450 defensive invariant: only report sessionCreated/updated when the
|
||||
// chosen branch's rewrite actually mutated content. Unreachable when the
|
||||
// writer regexes above stay in sync with the CRLF-tolerant detector —
|
||||
// but unreachable-defensive is the right posture for a silent-success
|
||||
// gate. A no-op replace here means a future line-ending or shape drift
|
||||
// between detector and writer; fail to record rather than claim success.
|
||||
//
|
||||
// Scope limitation (not a regression of this fix): the gate covers only
|
||||
// the section-rewrite block. The earlier in-place stateReplaceField
|
||||
// successes at :1081/:1083/:1089/:1101/:1108/:1114 push to `updated`
|
||||
// unconditionally — those represent fields that DID land on disk via
|
||||
// same-line replace (CRLF-agnostic seam), so unconditional push is
|
||||
// correct. The class-defect防御 here is for the INSERT path only.
|
||||
if (rewriteMatched) {
|
||||
sessionCreated = true;
|
||||
if (needsLastSession) updated.push('Last session');
|
||||
if (needsStoppedAt) updated.push('Stopped At');
|
||||
if (needsResumeFile) updated.push('Resume File');
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
|
||||
Reference in New Issue
Block a user