ktg-plugin-marketplace/plugins/linkedin-studio/scripts/check-replace-safety.mjs
Kjell Tore Guttormsen 431a893f7c fix(linkedin-studio): S13 — close S12 WARN ($-scalar + false-green test) + $-safety lint guard
Closes the 2 grep/Read-verified findings from the S12 cold full-brief re-review
(docs/remediation/review.md, WARN 0/1/1/0, 0 dropped) and closes the $-injection
CLASS — not the line — across the whole state-updater.mjs mutation surface.

See docs/remediation/review.md (S13 ALLOW, 0/0/0/0) for the full closure record:
replaceField -> replacement function; the 3 additive-insert sites -> functions
(m === $1, behavior-preserving); a scalar assert.match pins last_post_topic; and a
behavioral, coverage-complete, self-testing Section 12 guard (check-replace-safety.mjs)
that is mutation-proven. Docs three-doc + residuals updated. test-runner.sh 71/0/0,
node --test 98/98.
2026-05-30 19:12:45 +02:00

281 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// `$`-safety structural guard for hooks/scripts/state-updater.mjs (remediation S13).
//
// WHY: the S9→S12 "close the class, not the line" lesson recurred. S12 converted the
// 5 section-append `String.replace` sites to replacement *functions* but left
// `replaceField` — the scalar writer — on a replacement *string*, so an untrusted
// `last_post_topic` carrying `$&`/`$1`/`` $` `` silently corrupted state, and the S12
// test asserted only the section entry (never the scalar), shipping the bug GREEN.
// The class is "untrusted user content reaching ANY `String.replace` replacement
// STRING": in a replacement string `$&` expands to the whole match, `` $` ``/`$'` to the
// pre/suffix, `$$`→`$`, `$n`→group n — so a `$`-bearing value rewrites the field. A
// replacement FUNCTION returns its string verbatim (no `$`-substitution), closing the
// class by construction.
//
// This guard proves the PROPERTY behaviorally rather than grepping a syntactic proxy
// (which cannot tell a replacement-position template literal from a RegExp-pattern one
// across multi-line calls): it drives every exported state mutator with an adversarial
// payload built from EVERY special replacement token, in every free-text AND date
// field that reaches a `.replace()`, and asserts the payload survives VERBATIM. Two
// structural backstops make it a CLASS guard, not a per-line test:
// (1) COVERAGE-COMPLETENESS — every exported mutator (minus the documented I/O
// wrapper) must appear in the battery. A NEW export added without `$`-coverage
// fails here, so future code cannot reintroduce the class unguarded.
// (2) NON-VACUITY SELF-TEST — a naive string-replace fed the same payload MUST
// corrupt, and a function replacement MUST preserve it. A guard that cannot
// observe the corruption it forbids certifies nothing, so it fails the suite
// instead (mirrors Section 8/10/11 self-tests; the S7→S10 "proof run once by
// hand, never committed" lesson applied to the `$`-axis).
//
// Zero dependencies (node:assert + the module under test). Invoked from
// scripts/test-runner.sh Section 12; exit code mapped to pass/fail.
import assert from "node:assert/strict";
import * as mod from "../hooks/scripts/state-updater.mjs";
// Adversarial payload: every JS replacement-string special token. If ANY of these is
// interpreted (string replacement) instead of inserted literally (function
// replacement), the payload will not survive verbatim.
const TOKENS = ["$&", "$`", "$'", "$$", "$1", "$0"];
const PAYLOAD = `lead ${TOKENS.join(" ")} tail`;
// Minimal fixtures (mirror hooks/scripts/__tests__/state-updater.test.mjs). SAMPLE
// omits last_firsthour_date/last_outreach_date (additive-insert path); WITH_FH carries
// last_firsthour_date (the replaceField scalar path); TEMPLATE ships the two append
// sections pre-created (the production section-append path).
const SAMPLE = `---
last_post_date: "2026-04-05"
first_post_date: "2026-01-15"
last_post_topic: "AI strategy"
posts_this_week: 2
weekly_goal: 3
current_streak: 5
longest_streak: 12
current_week: "2026-W14"
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
## Milestone Log
`;
const WITH_FH = SAMPLE.replace(
'last_post_date: "2026-04-05"',
() => 'last_post_date: "2026-04-05"\nlast_firsthour_date: null'
);
const TEMPLATE = `---
last_post_date: "2026-04-05"
last_post_topic: "AI strategy"
posts_this_week: 2
follower_count: 850
follower_target: 10000
target_date: "2026-12-31"
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
## First-Hour Plans
<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->
## Outreach Pipeline
<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->
`;
// Coverage battery: one or more cases per exported mutator. `fields` lists the
// `.replace()` paths exercised; `expect` are substrings that MUST appear verbatim in
// the output (each would differ if a `$`-token expanded).
const BATTERY = [
{
fn: "updatePostTracking",
cases: [
{
path: "scalar replaceField (:58) + Recent Posts append (:122)",
run: () => mod.updatePostTracking(SAMPLE, {
postDate: "2026-04-09", postTopic: PAYLOAD, hookText: PAYLOAD, charCount: 1200, format: "post",
}),
expect: [`last_post_topic: "${PAYLOAD}"`, `"${PAYLOAD}" (1200) - ${PAYLOAD}`],
once: [/^## Recent Posts$/gm],
},
],
},
{
fn: "pruneContentHistory",
cases: [
{
path: "section rewrite of KEPT entries (:171)",
run: () => {
const today = new Date();
const old = new Date(today); old.setDate(old.getDate() - 100);
const recent = new Date(today); recent.setDate(recent.getDate() - 10);
const oldDate = old.toISOString().slice(0, 10);
const recentDate = recent.toISOString().slice(0, 10);
// Plant the payload via a FUNCTION replace so the fixture itself is not
// pre-corrupted by the very expansion under test.
const state = SAMPLE.replace(
"## Recent Posts\n\n",
() => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n`
);
const r = mod.pruneContentHistory(state, 90);
return { ...r, _recentDate: recentDate, _oldDate: oldDate };
},
assert: (r) => {
assert.notEqual(r, null, "prune returned null");
assert.equal(r.pruned, 1, "exactly the old entry pruned");
assert.ok(!r.content.includes(r._oldDate), "old entry pruned");
assert.ok(r.content.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim");
},
once: [/^## Recent Posts$/gm],
},
],
},
{
fn: "updateFollowerCount",
cases: [
{
// No free-text field: count is an integer, month is a `YYYY-MM` date used in
// date math. There is NO untrusted-string replacement surface here. Covered
// for completeness with a structural-integrity assertion on benign input.
path: "no untrusted-string replacement surface (count/month are int/date)",
run: () => mod.updateFollowerCount(SAMPLE, { count: 920, month: "2026-04" }),
expect: ["follower_count: 920", "[2026-04] 920 (+70)"],
once: [/^## Milestone Log$/gm],
},
],
},
{
fn: "recordFirstHourPlan",
cases: [
{
path: "section append, free-text topic/targets/comments (:271)",
run: () => mod.recordFirstHourPlan(TEMPLATE, {
planDate: "2026-05-30 09:00", postTopic: PAYLOAD, targets: [PAYLOAD], draftComments: [PAYLOAD], plan: ["09:00 — live"],
}),
expect: [`### [2026-05-30 09:00] ${PAYLOAD}`, `- ${PAYLOAD}`],
once: [/^## First-Hour Plans$/gm],
},
{
path: "date additive-insert (:246) — planDate fuzzed",
run: () => mod.recordFirstHourPlan(SAMPLE, { planDate: PAYLOAD, postTopic: "t" }),
expect: [`last_firsthour_date: "${PAYLOAD}"`],
once: [/^last_firsthour_date:/gm],
},
{
path: "date scalar replaceField (:237) — planDate fuzzed",
run: () => mod.recordFirstHourPlan(WITH_FH, { planDate: PAYLOAD, postTopic: "t" }),
expect: [`last_firsthour_date: "${PAYLOAD}"`],
once: [/^last_firsthour_date:/gm],
},
],
},
{
fn: "recordOutreachContact",
cases: [
{
path: "section append, free-text partner/stage/nextAction (:331)",
run: () => mod.recordOutreachContact(TEMPLATE, {
contactDate: "2026-05-30 14:00", track: "collab", partner: PAYLOAD, stage: PAYLOAD, nextAction: PAYLOAD, dueDate: "2026-06-06",
}),
expect: [`${PAYLOAD}`, `**Stage:** ${PAYLOAD}`, `**Next action:** ${PAYLOAD}`],
once: [/^## Outreach Pipeline$/gm],
},
{
path: "date additive-insert via last_post_date anchor (:308) — contactDate fuzzed",
run: () => mod.recordOutreachContact(SAMPLE, { contactDate: PAYLOAD, partner: "p" }),
expect: [`last_outreach_date: "${PAYLOAD}"`],
once: [/^last_outreach_date:/gm],
},
],
},
];
// --- Backstop 1: coverage completeness -------------------------------------------
// Every exported mutator must be in the battery, minus the documented I/O wrapper.
// A new export added without `$`-coverage fails here.
const IO_WRAPPER_EXEMPT = new Set(["writeState"]);
const exportedFns = Object.keys(mod).filter((k) => typeof mod[k] === "function");
const covered = new Set(BATTERY.map((b) => b.fn));
const uncovered = exportedFns.filter((k) => !covered.has(k) && !IO_WRAPPER_EXEMPT.has(k));
let failed = 0;
const failures = [];
if (uncovered.length) {
failed++;
failures.push(`coverage gap: exported mutator(s) without $-safety coverage → ${uncovered.join(", ")} (add a battery case or document an exemption)`);
}
// Guard the exemption too: if writeState is renamed/removed, surface it rather than
// silently exempting a stale name.
for (const name of IO_WRAPPER_EXEMPT) {
if (!exportedFns.includes(name)) {
failed++;
failures.push(`exemption stale: '${name}' is exempted but no longer exported — re-check the exemption list`);
}
}
// --- Backstop 2: non-vacuity self-test -------------------------------------------
// Prove the payload is genuinely dangerous (string replace corrupts) and that the
// fix shape (function replace) preserves it. A guard that cannot see the corruption
// it forbids enforces nothing.
{
const subject = 'last_post_topic: "OLD"';
const naive = subject.replace(/^last_post_topic: .*/m, `last_post_topic: "${PAYLOAD}"`);
const safe = subject.replace(/^last_post_topic: .*/m, () => `last_post_topic: "${PAYLOAD}"`);
if (naive.includes(PAYLOAD)) {
failed++;
failures.push("self-test vacuous: a STRING replacement did NOT corrupt the payload — the payload no longer exercises `$`-expansion, so a PASS is meaningless");
}
if (!safe.includes(PAYLOAD)) {
failed++;
failures.push("self-test broken: a FUNCTION replacement did NOT preserve the payload — the verbatim-survival assertion is unsound");
}
}
// --- Run the battery -------------------------------------------------------------
let cases = 0;
for (const { fn, cases: list } of BATTERY) {
for (const c of list) {
cases++;
try {
const out = c.run();
if (c.assert) {
c.assert(out);
} else {
const content = out && out.content;
assert.ok(content, `${fn} [${c.path}]: no content returned`);
for (const sub of c.expect || []) {
assert.ok(content.includes(sub), `${fn} [${c.path}]: missing verbatim → ${JSON.stringify(sub)}`);
}
for (const re of c.once || []) {
const hits = (content.match(re) || []).length;
assert.equal(hits, 1, `${fn} [${c.path}]: structural anchor ${re} appeared ${hits}× (expected 1 — a $&/$1 expansion duplicated it)`);
}
}
} catch (e) {
failed++;
failures.push(`${fn} [${c.path}]: ${e.message}`);
}
}
}
if (failed === 0) {
console.log(`$-safety: ${cases} adversarial case(s) across ${BATTERY.length} mutator(s) preserved the payload verbatim; coverage complete; self-test non-vacuous`);
process.exit(0);
} else {
console.error(`$-safety guard FAILED (${failed}):`);
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}