fix(cli): require owner-named pin targets and fixed serve paths (#797)

A pin corroborates a ledger target only when its binding names its own
campaign and the owner campaign. serveBudget freezes the startup campaign
and ledger paths and retains the ledger with its startup target; a running
service accepts its exactly unchanged startup ledger. Without retained
evidence an owner business/config retarget now refuses as ambiguous.
This commit is contained in:
jason.woltje committed 2026-11-05 23:22:32 -06:00
1 parent 85e1ec5d15
commit 22e3d21889
4 files changed
+97 -6

No files matched your search

+2
View File
@@ -6204,3 +6204,5 @@ After: personally authored three-file skills/ms-ux-improvement coordinator packa
2026-11-06 UTC | #797 ui797 support R4 review corrections verified | Before: unchanged 905d313cd reproduces 13 host pathname D-Bus connections/11 AUTH EXTERNAL requests, four damaged-sidecar cases with zero halt attempts, and a coherent epoch edit accepting 4 of 8 real stored tokens against cap 5. After: empty Bubblewrap mount view with read-only system/browser dependencies, minimal dev/proc, private tmpfs tmp/run/home, browser-owned writable evidence/NSS mounts and explicit environment; actual Chromium D-Bus traffic zero on HTTP/HTTPS, diagnostic pathname/abstract/TCP attempts fail, UDP listener sees zero, synthetic 0600 file/config/runtime paths hidden. Private NSS trust and unchanged production Console OIDC sign-in pass. Version-2 ledger independently pins config bytes; sidecar read/parse/mode/deletion and semantic corruption stop with explicit evidenceErrors and counted supported halts, preserving damage. Serve retains verified target/config/epoch evidence and revalidates marker/root/store/config before targeting and immediately before halt. Normal-path independent campaign hashes/epochs must agree; real coherent-epoch-edit and zero pin hash halt without journal reset. Modern sidecar deletion stops; legacy version-1 requires intact independent config-byte proof and an unpinned legacy ledger refuses, without migration/reinitialization. README requires Bubblewrap/user/network/PID namespaces on every browser host and states identity is HTTPS 17443/oidc behind the Console TLS forwarder, never 17442. Sequential whole files budget 89, identity 8, browser 13, request-lifecycle-live 2 = 112 pass / 0 fail / 0 skip / 0 cancelled. Both unchanged reviewer probes first fail semantically on baseline, then all six controls pass; three further retained probes pass, 31 network records/12 denied CONNECTs and both WebRTC origins retain zero forbidden traffic. External-reader positive control, changed-path 64-hex pin search, ten syntax checks and whitespace pass. Development missing import and explicit PWD assertion corrected; evidence retained. Cleanup finds zero processes from this run, no fixed test listeners and empty/removed TMPDIR; 29 older other-session daemons untouched after overbroad sweep was narrowed to this run. Five packages/cli/live paths plus this entry and one SESSIONS line only; original log bytes preserved. Queue read refused linked worktree; no queue/forge writes, push, stash, model spend, real secrets, .53/live-service/host-trust changes or attribution trailers. Local implementation ready for independent exact-commit review; owner-service quiescence remains outside this author charter. Report/evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md and ui797-support-fix-r4-evidence/.
2026-11-06 UTC | #797 ui797 support R5 conflicting-ledger correction verified | Before: fce831c05 let a valid pin and a v2 ledger disagree on target and still halted the ledger's binding; the reviewer's unchanged target-conflict probe sent 8 halts to the wrong business/config (business-only corruption and a foreign ledger, check and serve startup). After: checkTarget runs corroborate() on every tick. A ledger target directs a halt only when the owner campaign, the owner pin, or the live binding of the owner campaign names the same target; otherwise the owner pin replaces it only when it matches the owner campaign hash, the live config/marker/root/store and the live config digest (evidence 'ledger-target-changed'); anything else refuses with campaign-instance-changed. Retained-target comparison stays as the second guard. Four new regressions (check, serve startup, running service, no corroboration) fail 4/4 on r4 source and pass after. Probe after fix: 0 wrong halts in all six cases. Sequential whole files budget 93, identity 8, browser 13, request-lifecycle-live 2 = 116 pass / 0 fail / 0 skip. No push, stash, .53, live service or model spend. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r5-fix-evidence/.
2026-11-06 UTC | #797 ui797 support R6 matching-damage and ledger-path correction verified | Before: 85e1ec5d1 rule (b) accepted a pin whose binding.business had been damaged to match a damaged ledger, and a foreign pin restored with the invoked ledger path; the reviewer's probe sent 8 halts to business B in check and serve startup. serveBudget re-read opts.ledger every tick, so a programmatic caller that mutated it moved stop/halt receipts to the replacement ledger. After: a pin corroborates a ledger target only when its binding names its own campaign's instance/config/business/owner and, while the owner campaign is readable, the owner's too. serveBudget freezes the startup campaign/ledger paths and retains the ledger path with the startup target; a running service accepts an exactly unchanged startup ledger as its own corroboration. Tradeoff (recorded): with no retained evidence, an owner business/config edit while the pin and ledger are intact now refuses (ambiguous target, indistinguishable from a forged pin+ledger pair) instead of halting the original; a running service still halts its retained startup target with reason campaign-changed; owner cap/epoch edits and sidecar-only damage still stop. Proof: 4 new regressions, 3 fail on 85e1ec5d1 and all pass after (the running matching-damage case already refused on r5); whole files budget-supervisor 97, isolated-identity 8, ui-browser 13, request-lifecycle-live 2, all pass/0 fail; reviewer probes with r6 expectations: matching-damage 6 cases 0 wrong halts, ledger-path 2 halts to the startup binding with stop/halt receipts only in the startup ledger, adversarial 104 cases verified (3 owner-retarget cases refuse in check/startup), r4 target-conflict exit 0. Evidence /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r6-evidence/. Local commit only; independent r6 review next.
+1
View File
@@ -2070,3 +2070,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-06 | ui797-support-fix-r3 implementation sub-agent | #797 two P1 corrections above 19e6cb0f1 on feat/ui797-support | Mandatory browser network namespace/private proxy relay and independently bound unavailable-ledger halt with persistent serve reassertion; final sequential whole-file suites 61 pass, 0 fail/skip/cancel; all R1/R2 probes rerun, zero forbidden TCP/UDP, storage halts counted and latch preserved, zero owned processes/listeners. One BUILD-LOG entry and packages/cli/live changes only; local commit authorized, independent review pending. Queue refused linked worktree; no push/stash/forge/model/.53/host network-DNS-trust or unrelated-session action. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-report.md.
2026-11-06 UTC | ui797-support-fix-r4 implementation sub-agent | #797 review corrections on feat/ui797-support above 905d313cd | Minimal browser mount/environment boundary; independent config-byte proof and retained binding keep halt on sidecar damage; normal-path campaign hash/epoch agreement; unconditional namespaces and HTTPS 17443/oidc docs. Baseline failures retained; six controls corrected; whole files 112 pass, 0 fail/skip/cancel; five retained probes exit 0, zero forbidden traffic, no owned survivors/listeners, TMPDIR removed. Five support paths, one appended BUILD entry and this line; prefix bytes verified. Queue refused linked worktree; no queue/forge/push/stash/model/secrets/.53/live or unrelated-session action. Local author candidate for independent review. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r4-report.md.
2026-11-06 UTC | MVP3 lead #797 (Opus) | #797 support R4 P1 fix on feat/ui797-support above fce831c05 | Conflicting ledger targets now need owner corroboration or are replaced by the owner-verified pin, else refused; 4 regressions fail on r4 and pass after; whole files 116 pass/0 fail; reviewer probe 0 wrong halts. Local commit; independent r5 review next. No push/stash/.53/live action.
2026-11-06 UTC | MVP3 lead #797 (Opus) | #797 support R5 P1 fix on feat/ui797-support above 85e1ec5d1 | Pin must name its own campaign's and the owner's target; serve freezes startup paths and retains the ledger path; running service corroborates by its unchanged startup ledger. Owner business/config retarget refuses without retained evidence (tradeoff recorded). 4 regressions (3 fail on r5); whole files 120 pass/0 fail; reviewer probes 0 wrong halts. Local commit; independent r6 review next. No push/stash/.53/live action.
+23 -6
View File
@@ -142,12 +142,25 @@ function loadLedger(ledger) {
// current owner campaign resolves to. A conflicting ledger never authorizes its
// own target. The owner-verified pin replaces it only when the pin agrees with
// the owner campaign and the live config/marker/root/store; anything else refuses.
function corroborate(p,pin,{campaign,ledger}) {
// A pin corroborates only a target its own campaign names and, while the owner
// campaign is readable, the target the owner names too: a pin whose binding was
// damaged to match a damaged ledger justifies nothing (support r5 P1). The pin and
// ledger are both writable by whoever damaged them; only the owner campaign is not.
// So with no retained startup evidence, an owner business/config edit refuses as an
// ambiguous target. A running service still halts its retained startup target while
// the ledger is exactly the one it verified at startup.
const TARGET_FIELDS=['instance','config','business','owner'];
const names=(b,c)=>!!c && TARGET_FIELDS.every(k=>b?.[k]===c[k]);
function corroborate(p,pin,{campaign,ledger},retained=null) {
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
let owner=null, live=null, liveHash=null;
try { owner=campaignAt(campaign); live=binding(owner.c); liveHash=digest(readFileSync(owner.c.config,'utf8')); } catch { /* No owner evidence. */ }
if(owner && owner.hash===p.hash && same(owner.c,p.campaign)) return p;
if(pin && pin.ledger===ledger && same(pin.binding,p.binding) && pin.configHash===p.configHash) return p;
if(retained && retained.ledger===ledger && retained.p.hash===p.hash && same(retained.p.campaign,p.campaign) &&
same(retained.p.binding,p.binding) && retained.configHash===p.configHash) return p;
const pinCampaign=pin && (pin.campaign ?? (owner && pin.hash===owner.hash ? owner.c : null));
if(pin && pin.ledger===ledger && same(pin.binding,p.binding) && pin.configHash===p.configHash &&
names(pin.binding,pinCampaign) && (!owner || names(pin.binding,owner.c))) return p;
if(live && same(live,p.binding)) return p;
if(owner && pin && pin.ledger===ledger && pin.hash===owner.hash && (!pin.campaign || same(pin.campaign,owner.c)) &&
same(pin.binding,live) && pin.configHash===liveHash)
@@ -177,7 +190,7 @@ function checkTarget({campaign,ledger}, retained=null) {
}
}
if(p.configHash) {
const resolved=corroborate(p,pin,{campaign,ledger});
const resolved=corroborate(p,pin,{campaign,ledger},retained);
if(resolved!==p) { p=resolved; evidenceErrors.push('ledger-target-changed'); }
}
const c=p.campaign;
@@ -187,7 +200,7 @@ function checkTarget({campaign,ledger}, retained=null) {
const configHash=p.configHash??pin?.configHash??retained?.configHash;
if(!configHash) fail('owner-binding-unverified');
if(configHash!==digest(readFileSync(c.config,'utf8'))) fail('campaign-instance-changed');
for(const source of [pin,retained && {...retained.p,ledger,configHash:retained.configHash}].filter(Boolean)) {
for(const source of [pin,retained && {...retained.p,ledger:retained.ledger,configHash:retained.configHash}].filter(Boolean)) {
if(source.ledger!==ledger || JSON.stringify(source.binding)!==JSON.stringify(p.binding) || source.configHash!==configHash) {
if(source!==pin || !p.configHash) fail('campaign-instance-changed');
evidenceErrors.push('owner-binding-target-changed');
@@ -288,11 +301,15 @@ export function status({ campaign,ledger }) {
export async function serveBudget(opts, {interval=1000,signal,report=value=>console.log(JSON.stringify(value)),...deps}={}) {
// Retain independent target/config/epoch evidence, but revalidate the actual
// config, marker, root and store on EVERY tick, including just before halt.
const target=checkTarget(opts),retained=structuredClone({p:target.p,configHash:target.configHash});
// Every tick uses the startup campaign and ledger paths, and the retained clone
// records that ledger, so a caller that later mutates its options object cannot
// move stop/halt receipts to another ledger (support r5).
const fixed=Object.freeze({campaign:opts.campaign,ledger:opts.ledger});
const target=checkTarget(fixed),retained=structuredClone({p:target.p,configHash:target.configHash,ledger:fixed.ledger});
let stopReason=null;
while(!signal?.aborted) {
try {
const result=await check(opts,{...deps,stopReason,retained});
const result=await check(fixed,{...deps,stopReason,retained});
if(result.stopped) stopReason=result.reason;
report(result);
} catch(e) {
@@ -495,3 +495,74 @@ test('a conflicting ledger without corroborating owner evidence refuses every ha
assert.equal(f.halts.length,0);
}
});
// A pin damaged to match a damaged ledger, or a foreign pin restored with the
// invoked ledger path, never corroborates that ledger's target (support r5 P1).
function matchingDamage(t,kind) {
const {f,g,alter:alterLedger,wrong}=conflictingLedger(t,kind==='business'?'business-only':'foreign');
const pinPath=f.campaign+'.binding.json',owner=readFileSync(f.campaign,'utf8');
const alter=()=>{
if(kind==='business') { const pin=JSON.parse(readFileSync(pinPath));pin.binding.business='unrelated-business';writeFileSync(pinPath,JSON.stringify(pin)); }
else { const pin=JSON.parse(readFileSync(g.campaign+'.binding.json'));pin.ledger=f.ledger;writeFileSync(pinPath,JSON.stringify(pin)); }
alterLedger();
};
const receipts=dir=>readdirSync(dir).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n));
return {f,g,alter,wrong,owner,receipts};
}
test('matching pin and ledger damage refuses check and serve startup with no halt',async t=>{
for(const kind of ['business','foreign']) {
let m=matchingDamage(t,kind);m.alter();
await assert.rejects(check(m.f.opts,{now:()=>NOW,read:()=>[row()],halt:m.f.halt}),/campaign-instance-changed/);
assert.equal(m.f.halts.length,0);assert.deepEqual(m.receipts(m.f.ledger),[]);assert.equal(readFileSync(m.f.campaign,'utf8'),m.owner);
m=matchingDamage(t,kind);m.alter();
await assert.rejects(serveBudget(m.f.opts,{interval:1,now:()=>NOW,read:()=>[row()],halt:m.f.halt,report:()=>assert.fail('no tick may run')}),/campaign-instance-changed/);
assert.equal(m.f.halts.length,0);assert.deepEqual(m.receipts(m.f.ledger),[]);
if(m.g) assert.deepEqual(m.receipts(m.g.ledger),[]);
}
});
test('matching pin and ledger damage under a running service refuses every later tick',async t=>{
for(const kind of ['business','foreign']) {
const m=matchingDamage(t,kind),controller=new AbortController(),reports=[];
await serveBudget(m.f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:m.f.halt,report:r=>{
reports.push(r);if(reports.length===1)m.alter();if(reports.length===3)controller.abort();
}});
assert.equal(reports[0].stopped,false);
for(const r of reports.slice(1)) assert.equal(r.error,'campaign-instance-changed');
assert.equal(m.f.halts.length,0);assert.deepEqual(m.wrong(),[]);assert.deepEqual(m.receipts(m.f.ledger),[]);
}
});
test('a running service keeps its startup ledger when the caller mutates the options object',async t=>{
const f=setup(t,100),replacement=join(f.root,'replacement-ledger'),controller=new AbortController(),reports=[];
const startup=JSON.parse(readFileSync(join(f.ledger,'campaign.json'))),opts={...f.opts};let now=NOW;
await serveBudget(opts,{interval:1,signal:controller.signal,now:()=>now,read:()=>[],halt:f.halt,report:r=>{
reports.push(r);
if(reports.length===1) {
mkdirSync(replacement,{mode:0o700});
for(const n of readdirSync(f.ledger)) writeFileSync(join(replacement,n),readFileSync(join(f.ledger,n)),{mode:0o600});
const pin=JSON.parse(readFileSync(f.campaign+'.binding.json'));pin.ledger=replacement;writeFileSync(f.campaign+'.binding.json',JSON.stringify(pin));
opts.ledger=replacement;now=Date.parse(f.c.end);
}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,2);for(const b of f.halts) assert.deepEqual(b,startup.binding);
const names=readdirSync(f.ledger).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n)).sort();
assert.deepEqual(names,['halt-1.json','halt-2.json','stop.json']);
const stop=JSON.parse(readFileSync(join(f.ledger,'stop.json')));
assert.equal(stop.reason,'campaign-ended');assert.deepEqual(stop.binding,startup.binding);
assert.deepEqual(readdirSync(replacement).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n)),[]);
});
test('an owner business or config retarget refuses without retained evidence and halts the retained target when running',async t=>{
for(const field of ['business','config']) {
const retarget=f=>{const g=setup(t,100,{business:'unrelated-business'}),c=JSON.parse(readFileSync(f.campaign));c[field]=field==='business'?g.c.business:g.config;writeFileSync(f.campaign,JSON.stringify(c));return g;};
let f=setup(t,100),g=retarget(f);
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/campaign-instance-changed/);
assert.equal(f.halts.length,0);assert.equal(g.halts.length,0);
f=setup(t,100);const startup=JSON.parse(readFileSync(join(f.ledger,'campaign.json'))),controller=new AbortController(),reports=[];
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:f.halt,report:r=>{
reports.push(r);if(reports.length===1)retarget(f);if(reports.length===3)controller.abort();
}});
assert.equal(reports[0].stopped,false);
for(const r of reports.slice(1)) { assert.equal(r.stopped,true);assert.equal(r.reason,'campaign-changed'); }
assert.equal(f.halts.length,2);for(const b of f.halts) assert.deepEqual(b,startup.binding);
}
});