← back to AbramsEgo
Spend → Reviews: bind approval to content (anti-drift seal)
ddc28d6a76322b7f4988cdf5f9e7e408ef34ff04 · 2026-09-10 13:53:16 -0700 · Steve
Codex red-team caught the key correctness hole: approval wasn't bound to content, so
a regenerated draft or changed seller address could be executed differing from what a
human approved (stale-approval replay). Fix: approve now seals a sha1 of the exact
executable payload; the executor refuses + re-gates to 'draft' if content drifted; and
resolve/draft/seller-email-to changes invalidate a stale seal. Proven on :9773
(matching content passes, tampered draft blocked). TK-11433.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p
Files touched
M lib/spend-reviews/executors.jsM lib/spend-reviews/router.jsM lib/spend-reviews/store.js
Diff
commit ddc28d6a76322b7f4988cdf5f9e7e408ef34ff04
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 10 13:53:16 2026 -0700
Spend → Reviews: bind approval to content (anti-drift seal)
Codex red-team caught the key correctness hole: approval wasn't bound to content, so
a regenerated draft or changed seller address could be executed differing from what a
human approved (stale-approval replay). Fix: approve now seals a sha1 of the exact
executable payload; the executor refuses + re-gates to 'draft' if content drifted; and
resolve/draft/seller-email-to changes invalidate a stale seal. Proven on :9773
(matching content passes, tampered draft blocked). TK-11433.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p
---
lib/spend-reviews/executors.js | 11 +++++++++++
lib/spend-reviews/router.js | 32 ++++++++++++++++++++++++++++----
lib/spend-reviews/store.js | 21 ++++++++++++++++++++-
3 files changed, 59 insertions(+), 5 deletions(-)
diff --git a/lib/spend-reviews/executors.js b/lib/spend-reviews/executors.js
index f915e02b..31ef12f9 100644
--- a/lib/spend-reviews/executors.js
+++ b/lib/spend-reviews/executors.js
@@ -124,6 +124,17 @@ async function executeTarget(item, target, opts = {}) {
if (!approved) {
return { ok: false, gated: true, reason: 'target not per-item approved', plan };
}
+ // GATE 1b: the approved content must be UNCHANGED since approval (anti-drift /
+ // stale-approval-replay). A regenerated draft or a changed seller address
+ // invalidates the seal → re-gate to 'draft', fire nothing.
+ const seal = item.approvals && item.approvals[target];
+ const currentHash = store.targetContentHash(item, target);
+ if (!seal || seal.contentHash !== currentHash) {
+ store.updateItem(item.id, { targets: Object.assign({}, item.targets, { [target]: 'draft' }),
+ approvals: Object.assign({}, item.approvals, { [target]: null }) });
+ store.logAction({ action: 'execute-blocked-drift', id: item.id, target });
+ return { ok: false, gated: true, reason: 'content changed since approval — re-approve before sending', plan };
+ }
// GATE 2 + 3: live env switch AND a matching confirm token.
if (!liveEnabled || !liveRequested) {
store.logAction({ action: 'execute-gated', id: item.id, target, liveEnabled, liveRequested });
diff --git a/lib/spend-reviews/router.js b/lib/spend-reviews/router.js
index 9a4b9bb5..6c370145 100644
--- a/lib/spend-reviews/router.js
+++ b/lib/spend-reviews/router.js
@@ -22,15 +22,28 @@ function loadAdapter(source) {
function newBudget() { return { remaining: cfg.PLACES_MAX_LOOKUPS_PER_RUN, spent: 0 }; }
+// Downgrade any 'approved' target whose sealed content no longer matches (drift).
+function invalidateDriftedApprovals(item, targets, approvals) {
+ for (const t of cfg.TARGETS) {
+ if (targets[t] === 'approved') {
+ const seal = approvals[t];
+ if (!seal || seal.contentHash !== store.targetContentHash(item, t)) { targets[t] = 'draft'; approvals[t] = null; }
+ }
+ }
+}
+
async function resolveAndDraft(item, budget) {
const resolved = await resolver.resolveItem(item, budget);
const withResolved = Object.assign({}, item, { resolved });
const d = drafts.generateAll(withResolved);
const targets = Object.assign({}, item.targets);
+ const approvals = Object.assign({}, item.approvals);
for (const t of cfg.TARGETS) {
if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
}
- return store.updateItem(item.id, { resolved, drafts: d, targets });
+ const next = Object.assign({}, withResolved, { drafts: d, targets, approvals });
+ invalidateDriftedApprovals(next, targets, approvals);
+ return store.updateItem(item.id, { resolved, drafts: d, targets, approvals });
}
function summary() {
@@ -111,8 +124,11 @@ router.post('/draft/:id', (req, res) => {
if (!it) return res.status(404).json({ ok: false, error: 'not found' });
const d = drafts.generateAll(it);
const targets = Object.assign({}, it.targets);
+ const approvals = Object.assign({}, it.approvals);
for (const t of cfg.TARGETS) if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
- res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets }) });
+ const next = Object.assign({}, it, { drafts: d, targets, approvals });
+ invalidateDriftedApprovals(next, targets, approvals);
+ res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets, approvals }) });
});
// Per-item human approval flip (the whole point — nothing posts without this).
@@ -123,8 +139,11 @@ function setTarget(req, res, status) {
if (!it) return res.status(404).json({ ok: false, error: 'not found' });
if (status === 'approved' && (!it.drafts || !it.drafts[target])) return res.status(400).json({ ok: false, error: 'no draft to approve for this target' });
const targets = Object.assign({}, it.targets, { [target]: status });
+ // Seal the approved content (anti-drift). Un/skip clears the seal.
+ const approvals = Object.assign({}, it.approvals);
+ approvals[target] = status === 'approved' ? { contentHash: store.targetContentHash(it, target), approvedAt: new Date().toISOString() } : null;
store.logAction({ action: 'set-status', id, target, status });
- res.json({ ok: true, item: store.updateItem(id, { targets }) });
+ res.json({ ok: true, item: store.updateItem(id, { targets, approvals }) });
}
router.post('/approve', (req, res) => setTarget(req, res, 'approved'));
router.post('/unapprove', (req, res) => setTarget(req, res, 'draft'));
@@ -135,7 +154,12 @@ router.post('/seller-email-to', (req, res) => {
const { id, to } = req.body || {};
const it = store.getItem(id);
if (!it) return res.status(404).json({ ok: false, error: 'not found' });
- res.json({ ok: true, item: store.updateItem(id, { seller_email_to: (to || '').toString().slice(0, 200) }) });
+ // changing the destination invalidates a prior seller_email approval seal
+ const targets = Object.assign({}, it.targets);
+ const approvals = Object.assign({}, it.approvals);
+ const next = Object.assign({}, it, { seller_email_to: (to || '').toString().slice(0, 200) });
+ invalidateDriftedApprovals(next, targets, approvals);
+ res.json({ ok: true, item: store.updateItem(id, { seller_email_to: next.seller_email_to, targets, approvals }) });
});
// THE GATED EXECUTE. Per item + per target. Returns the plan (gated) unless every
diff --git a/lib/spend-reviews/store.js b/lib/spend-reviews/store.js
index e2dbe3d9..72b2804d 100644
--- a/lib/spend-reviews/store.js
+++ b/lib/spend-reviews/store.js
@@ -101,9 +101,28 @@ function makeItem(input) {
resolved: { google: null, amazon: null },
drafts: { google_review: null, amazon_review: null, seller_email: null },
targets: { google_review: 'none', amazon_review: 'none', seller_email: 'none' },
+ // per-target approval binding: {contentHash, approvedAt} snapshot of EXACTLY
+ // what a human approved, so a later content drift can't be silently executed.
+ approvals: { google_review: null, amazon_review: null, seller_email: null },
+ seller_email_to: null,
};
}
+/** Hash the EXACT executable payload for a target — the anti-drift approval seal. */
+function targetContentHash(item, target) {
+ const d = (item.drafts && item.drafts[target]) || {};
+ let payload;
+ if (target === 'seller_email') {
+ payload = [item.seller_email_to || '', d.subject || '', d.body || ''].join('');
+ } else {
+ const g = (item.resolved && item.resolved.google) || {};
+ const a = (item.resolved && item.resolved.amazon) || {};
+ const tgtUrl = target === 'google_review' ? (g.writeUrl || g.mapsUrl || '') : (a.reviewUrl || a.productUrl || '');
+ payload = [d.title || '', d.text || '', String(d.rating || ''), tgtUrl].join('');
+ }
+ return crypto.createHash('sha1').update(target + '' + payload).digest('hex');
+}
+
function readItems() { return readJsonl(cfg.ITEMS_FILE); }
function getItem(id) { return readItems().find((i) => i.id === id) || null; }
@@ -158,6 +177,6 @@ function logAction(row) {
}
module.exports = {
- normalizeMerchant, displayMerchant, makeItem, readItems, getItem, upsertItems, updateItem,
+ normalizeMerchant, displayMerchant, makeItem, targetContentHash, readItems, getItem, upsertItems, updateItem,
getPlace, setPlace, logAction, readJsonl,
};
← b5451cd2 AbramsEgo: add Spend → Reviews panel (ingest → resolve → dra
·
back to AbramsEgo
·
auto-data-snapshot: 2026-09-10T14:13:31 (1 data files) — dat 65195041 →