fix(cli): confine browser mounts and keep halt on binding damage (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 22:34:36 -06:00
1 parent 905d313cdf
commit fce831c053
7 files changed
+291 -58

No files matched your search

+2
View File
@@ -6200,3 +6200,5 @@ After: personally authored three-file skills/ms-ux-improvement coordinator packa
2026-11-06 | #797 ui797 support R2 review corrections verified | Before: local bd8340b9b accepted unrelated named instances, allowed Chromium WebSocket escape, trusted journal token totals, bypassed halt on lock failure, split model allowances across routes, omitted the absolute cutoff/100M GLM exception, and leaked browser fixtures. After: literal ui797/config/marker validation and persisted campaign revalidation; forced origin-only HTTP proxy (loopback bypass disabled, upgrades refused, HTTPS CONNECT restricted) with real HTTP/HTTPS Chromium negatives and independent zero-TCP/zero-upgrade forbidden-listener assertions; authoritative entry-token comparison/recomputed totals; preserved stale locks with durable halt on interruption/restart/storage failure; chartered proxy GLM/Claude/Codex bindings and one allowance per provider/model; inclusive 2026-11-07T06:00:00.000Z cutoff; exact GLM 100M/other models 10M maxima; awaited reverse-order fixture shutdown with owned-process checks and an intentional-failure whole-file regression. Initial CDP/response-policy browser containment and daemon close-event attempts failed and were corrected; terminal individual whole-file runs are identity 8, browser 8, budget 33 = 49 pass / 0 fail / 0 skip / 0 cancelled. All retained review probes rerun: wrong instance/deadline/split-route refuse, numeric corruption latches/halts, locked checks halt without reclaim, forbidden WebSocket hits 0, SIGTERM/SIGKILL survivors 0. The original general probe now exits 1 at its uncaught, correctly refused split-route initialization; a logging-only companion records every outcome. Final owned-process and 17440/17442/17443 listener sweeps are empty; unrelated existing sessions untouched. Source scope packages/cli/live only plus append-only logs. Queue refused linked worktree; no queue/forge write, push, stash, model call, .53/live contact or product-runtime/role/contract/dependency change. Local author candidate only; independent exact-head approval remains with lead. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-evidence/ and ui797-support-fix-r2-report.md. 2026-11-06 | #797 ui797 support R2 review corrections verified | Before: local bd8340b9b accepted unrelated named instances, allowed Chromium WebSocket escape, trusted journal token totals, bypassed halt on lock failure, split model allowances across routes, omitted the absolute cutoff/100M GLM exception, and leaked browser fixtures. After: literal ui797/config/marker validation and persisted campaign revalidation; forced origin-only HTTP proxy (loopback bypass disabled, upgrades refused, HTTPS CONNECT restricted) with real HTTP/HTTPS Chromium negatives and independent zero-TCP/zero-upgrade forbidden-listener assertions; authoritative entry-token comparison/recomputed totals; preserved stale locks with durable halt on interruption/restart/storage failure; chartered proxy GLM/Claude/Codex bindings and one allowance per provider/model; inclusive 2026-11-07T06:00:00.000Z cutoff; exact GLM 100M/other models 10M maxima; awaited reverse-order fixture shutdown with owned-process checks and an intentional-failure whole-file regression. Initial CDP/response-policy browser containment and daemon close-event attempts failed and were corrected; terminal individual whole-file runs are identity 8, browser 8, budget 33 = 49 pass / 0 fail / 0 skip / 0 cancelled. All retained review probes rerun: wrong instance/deadline/split-route refuse, numeric corruption latches/halts, locked checks halt without reclaim, forbidden WebSocket hits 0, SIGTERM/SIGKILL survivors 0. The original general probe now exits 1 at its uncaught, correctly refused split-route initialization; a logging-only companion records every outcome. Final owned-process and 17440/17442/17443 listener sweeps are empty; unrelated existing sessions untouched. Source scope packages/cli/live only plus append-only logs. Queue refused linked worktree; no queue/forge write, push, stash, model call, .53/live contact or product-runtime/role/contract/dependency change. Local author candidate only; independent exact-head approval remains with lead. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-evidence/ and ui797-support-fix-r2-report.md.
2026-11-06 | #797 ui797 support R3 P1 corrections verified | Before: 19e6cb0f1 still emitted WebRTC STUN/TURN UDP despite its flag, and unavailable ledger directories bypassed halt and terminated serve. After: mandatory Bubblewrap network/PID namespaces with an isolated, up loopback; a single namespace TCP relay over a 0600 Unix socket to the existing origin-only proxy, preserving private NSS/TLS validation, control socket permissions and awaited browser-before-proxy cleanup. Real Chromium regressions from both origins create nine ICE agents against six independent IPv4/IPv6/127.0.0.2 STUN/TURN UDP listeners and three TCP listeners; zero datagrams/connections, existing WebSocket/HTTP/SSO/CA negatives retained. Campaign initialization now exclusively pins owner/config/binding/ledger evidence beside the campaign, independently of the ledger volume; missing/renamed/0500 storage still attempts supported CLI halt, explicitly reports nondurable latch/receipt, preserves evidence and never recreates an epoch. Serve survives failures and reasserts each tick/Resume, retaining its stop across storage recovery; invalid/foreign/changed targets refuse. Older intact ledger format preserved. Final sequential whole files: identity 8, browser 10, budget 43 = 61 pass / 0 fail / 0 skip / 0 cancelled. All R1/R2 probes rerun: zero forbidden TCP/UDP, all four storage variants count one halt, original latch preserved, stale-lock/corruption/cap/cutoff/identity negatives retained, SIGTERM/SIGKILL/startup-failure survivors zero. Original R1 general probe exits 1 at its correctly refused split-route setup. Independent final sweep: zero owned processes/listeners at fixed and 43 recorded endpoints; scratch sweep parser corrected before its terminal run. Exactly packages/cli/live plus this single entry and one SESSIONS line; linked-worktree queue refused, no queue/forge writes, push, stash, model call, .53 contact, host network/DNS/trust changes or unrelated-session cleanup. Local implementation ready for independent exact-head review; owner-service successful quiescence remains a later acceptance gate. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-evidence/ and ui797-support-fix-r3-report.md. 2026-11-06 | #797 ui797 support R3 P1 corrections verified | Before: 19e6cb0f1 still emitted WebRTC STUN/TURN UDP despite its flag, and unavailable ledger directories bypassed halt and terminated serve. After: mandatory Bubblewrap network/PID namespaces with an isolated, up loopback; a single namespace TCP relay over a 0600 Unix socket to the existing origin-only proxy, preserving private NSS/TLS validation, control socket permissions and awaited browser-before-proxy cleanup. Real Chromium regressions from both origins create nine ICE agents against six independent IPv4/IPv6/127.0.0.2 STUN/TURN UDP listeners and three TCP listeners; zero datagrams/connections, existing WebSocket/HTTP/SSO/CA negatives retained. Campaign initialization now exclusively pins owner/config/binding/ledger evidence beside the campaign, independently of the ledger volume; missing/renamed/0500 storage still attempts supported CLI halt, explicitly reports nondurable latch/receipt, preserves evidence and never recreates an epoch. Serve survives failures and reasserts each tick/Resume, retaining its stop across storage recovery; invalid/foreign/changed targets refuse. Older intact ledger format preserved. Final sequential whole files: identity 8, browser 10, budget 43 = 61 pass / 0 fail / 0 skip / 0 cancelled. All R1/R2 probes rerun: zero forbidden TCP/UDP, all four storage variants count one halt, original latch preserved, stale-lock/corruption/cap/cutoff/identity negatives retained, SIGTERM/SIGKILL/startup-failure survivors zero. Original R1 general probe exits 1 at its correctly refused split-route setup. Independent final sweep: zero owned processes/listeners at fixed and 43 recorded endpoints; scratch sweep parser corrected before its terminal run. Exactly packages/cli/live plus this single entry and one SESSIONS line; linked-worktree queue refused, no queue/forge writes, push, stash, model call, .53 contact, host network/DNS/trust changes or unrelated-session cleanup. Local implementation ready for independent exact-head review; owner-service successful quiescence remains a later acceptance gate. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r3-evidence/ and ui797-support-fix-r3-report.md.
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/.
+1
View File
@@ -2068,3 +2068,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-05 | Codex ui797 implementation child | #797 feat/ui797-support author handoff | Three isolated test tools and focused tests ready for lead review; final unique gates 149 pass / 0 fail / 0 skip, real production-CLI browser OIDC sign-in included, metered-stop/ancestry limits documented. Local commit only; no remote/live activity, spend, role/contract or product runtime edits. 2026-11-05 | Codex ui797 implementation child | #797 feat/ui797-support author handoff | Three isolated test tools and focused tests ready for lead review; final unique gates 149 pass / 0 fail / 0 skip, real production-CLI browser OIDC sign-in included, metered-stop/ancestry limits documented. Local commit only; no remote/live activity, spend, role/contract or product runtime edits.
2026-11-06 | ui797-support-fix-r2 implementation sub-agent | #797 corrections on feat/ui797-support above bd8340b9b, confined to packages/cli/live plus one appended build entry and this line | All eight review findings addressed with regression tests; sequential whole files identity 8/browser 8/budget 33 (49 pass, 0 fail/skip/cancel), all review probes rerun, owned-process/listener sweeps empty; forced proxy chosen after browser-only containment failed. Local commit authorized; independent review pending. Queue refused linked worktree; no canonical queue/forge writes, push, stash, live/.53 contact, model spend, product-runtime/role/contract/dependency changes or attribution trailers. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-report.md. 2026-11-06 | ui797-support-fix-r2 implementation sub-agent | #797 corrections on feat/ui797-support above bd8340b9b, confined to packages/cli/live plus one appended build entry and this line | All eight review findings addressed with regression tests; sequential whole files identity 8/browser 8/budget 33 (49 pass, 0 fail/skip/cancel), all review probes rerun, owned-process/listener sweeps empty; forced proxy chosen after browser-only containment failed. Local commit authorized; independent review pending. Queue refused linked worktree; no canonical queue/forge writes, push, stash, live/.53 contact, model spend, product-runtime/role/contract/dependency changes or attribution trailers. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-report.md.
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 | 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.
+26 -12
View File
@@ -72,10 +72,11 @@ the instance data root. Use owner-only directories (0700) and campaign files
The test instance uses its own proxy; there is no shared-proxy attachment here. The test instance uses its own proxy; there is no shared-proxy attachment here.
Requirements: the repository's pinned Node dependencies, Node with `node:sqlite`, Requirements: the repository's pinned Node dependencies, Node with `node:sqlite`,
OpenSSL, Chromium, NSS `certutil`, and Linux Bubblewrap (`/usr/bin/bwrap`) when the OpenSSL, Chromium, NSS `certutil`, and Linux Bubblewrap (`/usr/bin/bwrap`) with
OS user's legacy `~/.pki/nssdb` exists. All examples assume a provisioner already usable user, mount, network and PID namespaces on every browser host. All
created/validated the named instance through `scripts/bootstrap.sh --instance examples assume a provisioner already created/validated the named instance through `scripts/bootstrap.sh --instance
ui797`, with a matching `instance.json`. Never change HOME or clear agent markers. ui797`, with a matching `instance.json`. Preserve the owner shell HOME and agent
markers.
For temporary test directories set `TMPDIR` using For temporary test directories set `TMPDIR` using
`mktemp -d /mnt/storage/scratch/tmp/XXXXXX` (never `/tmp`). `mktemp -d /mnt/storage/scratch/tmp/XXXXXX` (never `/tmp`).
@@ -97,8 +98,9 @@ node packages/cli/live/isolated-identity.mjs prepare \
Add that exact `console.sso` block through the approved test-config setup step. Add that exact `console.sso` block through the approved test-config setup step.
The public origin is `https://127.0.0.1:17443`, issuer The public origin is `https://127.0.0.1:17443`, issuer
`https://127.0.0.1:17443/oidc/`. Only `ui797-agent-owner` can be issued, displayed `https://127.0.0.1:17443/oidc/`, behind the TLS forwarder to Console port 17440.
as `ui797-owner`. Authorization immediately redirects back with a random, There is no identity listener on port 17442. Only `ui797-agent-owner` can be
issued, displayed as `ui797-owner`. Authorization immediately redirects back with a random,
single-use 60-second code, state and S256 PKCE; signed ID tokens last 300 seconds. single-use 60-second code, state and S256 PKCE; signed ID tokens last 300 seconds.
There is no refresh grant, alternate subject or live identity-provider integration. There is no refresh grant, alternate subject or live identity-provider integration.
@@ -149,9 +151,15 @@ blocked network attempt makes the session fail until
`stop`. The driver never contacts 7332 or mosaic.woltje.com. `stop`. The driver never contacts 7332 or mosaic.woltje.com.
The browser trusts the test CA through a private NSS database under its evidence The browser trusts the test CA through a private NSS database under its evidence
directory. If Chromium would prefer the user's legacy NSS store, Bubblewrap directory. Bubblewrap starts with an empty mount view, adds read-only `/usr`,
mounts the private database over that path **only inside the browser process's libraries and selected NSS/font/certificate configuration, a minimal private
mount namespace**. HOME and host trust files remain unchanged. Certificate `/dev` and `/proc`, and private tmpfs `/tmp`, `/run` and home. Only this browser's
evidence/profile/download directories and proxy/control sockets are writable
host mounts. Host home, runtime directories, pathname sockets and credential
paths are hidden. An explicit environment allowlist supplies a private HOME and
XDG directories; host D-Bus/SSH-agent addresses and secrets are not inherited.
The private NSS database is mounted at the sandbox HOME's `.pki/nssdb`; the
owner shell HOME and host trust files remain unchanged. Certificate
verification stays enabled; a different CA is rejected. Startup refuses when the verification stays enabled; a different CA is rejected. Startup refuses when the
required trust setup or namespace is unavailable; it does not fall back to a required trust setup or namespace is unavailable; it does not fall back to a
certificate-error exception. certificate-error exception.
@@ -268,9 +276,15 @@ Initialization exclusively writes `campaign.json.binding.json` beside the owner
campaign, outside the ledger volume, before creating the ledger. Preserve this campaign, outside the ledger volume, before creating the ledger. Preserve this
0600 file and the campaign: they independently pin the target and ledger path 0600 file and the campaign: they independently pin the target and ledger path
when accounting storage is unavailable. Checks never recreate that file or an when accounting storage is unavailable. Checks never recreate that file or an
accounting epoch. A campaign initialized by an older implementation without this accounting epoch. The version-2 ledger manifest independently pins config bytes,
pin requires intact ledger evidence to identify its target; do not reinitialize so missing, unreadable, malformed or wrong-mode sidecars stop and still attempt halt against
or reset an existing campaign to work around missing evidence. the verified target. Each report and halt receipt includes `evidenceErrors`;
damaged files are preserved. An intact sidecar must agree with both the owner
campaign bytes and ledger campaign/epoch. A running service retains its verified
binding and epoch while revalidating the actual target on every tick. Legacy
version-1 ledgers require their intact independent sidecar to verify config bytes;
a sidecar-less legacy ledger refuses targeting. Never reinitialize or reset an
existing campaign to work around missing evidence.
Initialization is exclusive. There is no reset, raise or resume command. A stale Initialization is exclusive. There is no reset, raise or resume command. A stale
`check.lock` after interruption requires owner diagnosis; never auto-reclaim it. `check.lock` after interruption requires owner diagnosis; never auto-reclaim it.
An existing or uncreatable lock prevents accounting and still asserts the sticky An existing or uncreatable lock prevents accounting and still asserts the sticky
+53 -24
View File
@@ -61,11 +61,12 @@ export function initialize({ campaign, ledger }) {
outsideRuntime(ledger,{dataRoot:b.dataRoot},c.config); outsideRuntime(ledger,{dataRoot:b.dataRoot},c.config);
// Independent owner evidence survives loss of the accounting volume. Created // Independent owner evidence survives loss of the accounting volume. Created
// only at initialization; a check must never reconstruct an epoch or this pin. // only at initialization; a check must never reconstruct an epoch or this pin.
exclusive(campaign+'.binding.json',JSON.stringify({version:1,hash,configHash:digest(readFileSync(c.config,'utf8')),binding:b,ledger})+'\n'); const configHash=digest(readFileSync(c.config,'utf8'));
exclusive(campaign+'.binding.json',JSON.stringify({version:1,hash,configHash,binding:b,ledger,campaign:c})+'\n');
flushDir(dirname(campaign)); flushDir(dirname(campaign));
privateDir(ledger,true); privateDir(ledger,true);
for (const cap of c.caps) exclusive(join(ledger,filename(cap)),JSON.stringify({ version:1,cap })+'\n'); for (const cap of c.caps) exclusive(join(ledger,filename(cap)),JSON.stringify({ version:1,cap })+'\n');
exclusive(join(ledger,'campaign.json'),JSON.stringify({ version:1,hash,binding:b,campaign:c })+'\n'); exclusive(join(ledger,'campaign.json'),JSON.stringify({ version:2,hash,configHash,binding:b,campaign:c })+'\n');
flushDir(ledger); flushDir(ledger);
return { initialized:true,ledger }; return { initialized:true,ledger };
} }
@@ -132,33 +133,53 @@ function journal(ledger,cap) {
} }
function loadLedger(ledger) { function loadLedger(ledger) {
privateDir(ledger); const p=JSON.parse(readPrivate(join(ledger,'campaign.json'))); privateDir(ledger); const p=JSON.parse(readPrivate(join(ledger,'campaign.json')));
if (p.version !== 1 || !p.binding || !p.campaign) fail('ledger-manifest-invalid'); if (![1,2].includes(p.version) || !p.binding || !p.campaign ||
(p.version===2 && !/^[0-9a-f]{64}$/.test(p.configHash))) fail('ledger-manifest-invalid');
return p; return p;
} }
function checkTarget({campaign,ledger}) { function checkTarget({campaign,ledger}, retained=null) {
let p, pin, reason=null, writable=true; let p, pin, reason=null, writable=true;
const evidenceErrors=[];
try { privateDir(ledger); } catch { writable=false; reason='ledger-storage-unavailable'; } try { privateDir(ledger); } catch { writable=false; reason='ledger-storage-unavailable'; }
try {
pin=JSON.parse(readPrivate(campaign+'.binding.json'));
if(pin?.version!==1 || !pin.binding || !/^[0-9a-f]{64}$/.test(pin.configHash)) throw new Error('invalid');
} catch(e) {
pin=null;
evidenceErrors.push(e.code==='ENOENT'?'owner-binding-missing':'owner-binding-unreadable-or-invalid');
}
try { if (!writable) throw new Error(reason); p=loadLedger(ledger); } catch { try { if (!writable) throw new Error(reason); p=loadLedger(ledger); } catch {
const {c,hash}=campaignAt(campaign);pin=JSON.parse(readPrivate(campaign+'.binding.json')); reason??='ledger-manifest-invalid';
if(pin.version!==1 || pin.hash!==hash || pin.ledger!==ledger || !pin.binding) fail('owner-binding-invalid-or-changed'); if(retained) p=retained.p;
p={campaign:c,hash,binding:pin.binding}; reason??='ledger-manifest-invalid'; else {
if(!pin || pin.ledger!==ledger) fail('owner-binding-unverified');
const {c,hash}=campaignAt(campaign);
// A fallback must never infer a new target/epoch from altered owner bytes.
if(pin.hash!==hash) fail('owner-binding-invalid-or-changed');
p={campaign:pin.campaign??c,hash:pin.hash,configHash:pin.configHash,binding:pin.binding};
}
} }
const c=p.campaign; const c=p.campaign;
// Pin the target before any halt. A changed config cannot redirect the stop into live data. // Pin the target before any halt. A changed config cannot redirect the stop into live data.
checkBinding(p.binding,c); outsideRuntime(ledger,{dataRoot:p.binding.dataRoot},c.config); checkBinding(p.binding,c); outsideRuntime(ledger,{dataRoot:p.binding.dataRoot},c.config);
outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},c.config); outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},c.config);
// Older intact ledgers retain their original binding format. New campaigns const configHash=p.configHash??pin?.configHash??retained?.configHash;
// additionally pin config bytes in independent owner evidence, without rewriting them. if(!configHash) fail('owner-binding-unverified');
if(pin || existsSync(campaign+'.binding.json')) { if(configHash!==digest(readFileSync(c.config,'utf8'))) fail('campaign-instance-changed');
pin??=JSON.parse(readPrivate(campaign+'.binding.json')); for(const source of [pin,retained && {...retained.p,ledger,configHash:retained.configHash}].filter(Boolean)) {
if(pin.version!==1 || pin.ledger!==ledger || JSON.stringify(pin.binding)!==JSON.stringify(p.binding) || if(source.ledger!==ledger || JSON.stringify(source.binding)!==JSON.stringify(p.binding) || source.configHash!==configHash) {
pin.configHash!==digest(readFileSync(c.config,'utf8')))fail('campaign-instance-changed'); if(source!==pin || !p.configHash) fail('campaign-instance-changed');
evidenceErrors.push('owner-binding-target-changed');
}
if(source.hash!==p.hash || (source.campaign && JSON.stringify(source.campaign)!==JSON.stringify(c)))
evidenceErrors.push('owner-binding-epoch-changed');
} }
return {p,c,reason,writable}; reason??=evidenceErrors[0]??null;
return {p,c,reason,writable,configHash,evidenceErrors};
} }
export async function check(opts, { now=Date.now, read=readAccounting, halt=supportedHalt, acquireLock=mkdirSync, stopReason=null }={}) { export async function check(opts, { now=Date.now, read=readAccounting, halt=supportedHalt, acquireLock=mkdirSync, stopReason=null, retained=null }={}) {
const {ledger,campaign}=opts; const {ledger,campaign}=opts;
const {p,c,reason:targetReason,writable}=checkTarget(opts); const {p,c,reason:targetReason,writable,configHash,evidenceErrors}=checkTarget(opts,retained);
const lock=join(ledger,'check.lock'); let locked=false, lockFailure=false; const lock=join(ledger,'check.lock'); let locked=false, lockFailure=false;
if(writable) try { acquireLock(lock,{mode:0o700}); locked=true; } catch { lockFailure=true; } if(writable) try { acquireLock(lock,{mode:0o700}); locked=true; } catch { lockFailure=true; }
// An interrupted/active/unwritable lock prevents accounting, never the verified halt. // An interrupted/active/unwritable lock prevents accounting, never the verified halt.
@@ -210,27 +231,33 @@ export async function check(opts, { now=Date.now, read=readAccounting, halt=supp
const storageErrors=[]; const storageErrors=[];
try { try {
if(!writable) throw new Error('unavailable'); if(!writable) throw new Error('unavailable');
if (!existsSync(stopFile)) { exclusive(stopFile,JSON.stringify({version:1,at:new Date(now()).toISOString(),reason,binding:p.binding,totals})+'\n'); flushDir(ledger); } if (!existsSync(stopFile)) { exclusive(stopFile,JSON.stringify({version:1,at:new Date(now()).toISOString(),reason,binding:p.binding,totals,evidenceErrors})+'\n'); flushDir(ledger); }
} catch { storageErrors.push('stop-latch-not-durable'); } } catch { storageErrors.push('stop-latch-not-durable'); }
// Always attempt halt, even when the evidence volume cannot persist the latch. // Always attempt halt, even when the evidence volume cannot persist the latch.
let result,error; let result,error;
try { checkBinding(p.binding,c); result=await halt(p.binding); } catch { error='supported-halt-refused-or-unknown'; } try {
checkBinding(p.binding,c);
if(configHash!==digest(readFileSync(c.config,'utf8'))) fail('campaign-instance-changed');
result=await halt(p.binding);
} catch { error='supported-halt-refused-or-unknown'; }
try { try {
if(!writable) throw new Error('unavailable'); if(!writable) throw new Error('unavailable');
const attempt=readdirSync(ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length+1; const attempt=readdirSync(ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length+1;
exclusive(join(ledger,`halt-${attempt}.json`),JSON.stringify({at:new Date(now()).toISOString(),ok:!error,result:result??null,error:error??null,storageError:storageErrors[0]??null})+'\n'); flushDir(ledger); exclusive(join(ledger,`halt-${attempt}.json`),JSON.stringify({at:new Date(now()).toISOString(),ok:!error,result:result??null,error:error??null,storageError:storageErrors[0]??null,evidenceErrors})+'\n'); flushDir(ledger);
} catch { storageErrors.push('stop-receipt-not-durable'); } } catch { storageErrors.push('stop-receipt-not-durable'); }
if (storageErrors.length) throw Object.assign(new Error(storageErrors.join('; ')),{ if (storageErrors.length) throw Object.assign(new Error(storageErrors.join('; ')),{
result:{stopped:true,reason,totals,halt:result??null,haltError:error??null,storageErrors}, result:{stopped:true,reason,totals,halt:result??null,haltError:error??null,storageErrors,evidenceErrors},
}); });
if (error) fail(error); if (error) throw Object.assign(new Error(error),{result:{stopped:true,reason,totals,evidenceErrors}});
return {stopped:true,reason,totals,halt:result}; return {stopped:true,reason,totals,halt:result,evidenceErrors};
} }
return {stopped:false,totals}; return {stopped:false,totals};
} finally { if (locked) try { rmdirSync(lock); } catch { /* Preserve an unavailable lock; never recreate its parent. */ } } } finally { if (locked) try { rmdirSync(lock); } catch { /* Preserve an unavailable lock; never recreate its parent. */ } }
} }
export function status({ campaign,ledger }) { export function status({ campaign,ledger }) {
const p=loadLedger(ledger); validateCampaign(p.campaign); checkBinding(p.binding,p.campaign); const p=loadLedger(ledger); validateCampaign(p.campaign); checkBinding(p.binding,p.campaign);
const target=checkTarget({campaign,ledger});
if(target.reason) fail(target.reason);
outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},p.campaign.config); outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},p.campaign.config);
const owner=campaignAt(campaign); const owner=campaignAt(campaign);
if (owner.hash!==p.hash || JSON.stringify(owner.c)!==JSON.stringify(p.campaign)) fail('campaign-changed'); if (owner.hash!==p.hash || JSON.stringify(owner.c)!==JSON.stringify(p.campaign)) fail('campaign-changed');
@@ -238,11 +265,13 @@ export function status({ campaign,ledger }) {
return { stopped:existsSync(join(ledger,'stop.json')),end:p.campaign.end,totals }; return { stopped:existsSync(join(ledger,'stop.json')),end:p.campaign.end,totals };
} }
export async function serveBudget(opts, {interval=1000,signal,report=value=>console.log(JSON.stringify(value)),...deps}={}) { export async function serveBudget(opts, {interval=1000,signal,report=value=>console.log(JSON.stringify(value)),...deps}={}) {
checkTarget(opts); // Refuse an invalid initial target before entering the service loop. // 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});
let stopReason=null; let stopReason=null;
while(!signal?.aborted) { while(!signal?.aborted) {
try { try {
const result=await check(opts,{...deps,stopReason}); const result=await check(opts,{...deps,stopReason,retained});
if(result.stopped) stopReason=result.reason; if(result.stopped) stopReason=result.reason;
report(result); report(result);
} catch(e) { } catch(e) {
+116 -8
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync,renameSync,existsSync,lstatSync,chmodSync,rmSync } from 'node:fs'; import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync,renameSync,existsSync,lstatSync,chmodSync,rmSync } from 'node:fs';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { once } from 'node:events'; import { once } from 'node:events';
import { join } from 'node:path'; import { join } from 'node:path';
import { initialize,check,status,readAccounting,supportedHalt,main,serveBudget,CUTOFF } from './budget-supervisor.mjs'; import { initialize,check,status,readAccounting,supportedHalt,main,serveBudget,CUTOFF } from './budget-supervisor.mjs';
@@ -275,12 +276,22 @@ test('an unavailable ledger refuses invalid, foreign, changed or unpinned target
} }
}); });
test('an older intact ledger keeps its epoch without creating an owner binding during checks',async t=>{ test('a required modern sidecar cannot be deleted to regain an allowance; unpinned legacy evidence refuses',async t=>{
const f=setup(t,100),manifest=readFileSync(join(f.ledger,'campaign.json'),'utf8'); const f=setup(t,100),manifest=readFileSync(join(f.ledger,'campaign.json'),'utf8');
unlinkSync(f.campaign+'.binding.json'); unlinkSync(f.campaign+'.binding.json');
assert.equal((await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt})).stopped,false); const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('missing required pin'),halt:f.halt});
assert.equal(result.stopped,true);assert.deepEqual(result.evidenceErrors,['owner-binding-missing']);
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),manifest); assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),manifest);
assert.equal(existsSync(f.campaign+'.binding.json'),false);assert.equal(f.halts.length,0); assert.equal(existsSync(f.campaign+'.binding.json'),false);assert.equal(f.halts.length,1);
const g=setup(t,100),file=join(g.ledger,'campaign.json'),old=JSON.parse(readFileSync(file));
old.version=1;delete old.configHash;writeFileSync(file,JSON.stringify(old));unlinkSync(g.campaign+'.binding.json');
await assert.rejects(check(g.opts,{now:()=>NOW,halt:g.halt}),/owner-binding-unverified/);
assert.equal(g.halts.length,0);
const h=setup(t,100),hfile=join(h.ledger,'campaign.json'),previous=JSON.parse(readFileSync(hfile));
previous.version=1;delete previous.configHash;writeFileSync(hfile,JSON.stringify(previous));
const before=readFileSync(hfile,'utf8');
assert.equal((await check(h.opts,{now:()=>NOW,halt:h.halt,read:()=>[]})).stopped,false);
assert.equal(readFileSync(hfile,'utf8'),before);
}); });
test('serve survives unavailable storage, reasserts after Resume each tick and preserves its in-memory stop on recovery',async t=>{ test('serve survives unavailable storage, reasserts after Resume each tick and preserves its in-memory stop on recovery',async t=>{
@@ -317,8 +328,9 @@ test('serve keeps retrying a refused halt and refuses targeting if the config ch
assert.equal(existsSync(join(f.ledger,'stop.json')),true); assert.equal(existsSync(join(f.ledger,'stop.json')),true);
}); });
test('real serve CLI stays alive and attempts supported halt every tick with a missing ledger',async t=>{ for(const damage of ['missing-ledger','read','parse','mode'])test(`real serve CLI stays alive and attempts supported halt every tick with ${damage} damage`,async t=>{
const f=setup(t,1);renameSync(f.ledger,f.ledger+'-offline'); const f=setup(t,1);
if(damage==='missing-ledger')renameSync(f.ledger,f.ledger+'-offline');else damagePin(f,damage);
const child=spawn(process.execPath,['packages/cli/live/budget-supervisor.mjs','serve','--campaign',f.campaign,'--ledger',f.ledger,'--interval','0.1'],{ const child=spawn(process.execPath,['packages/cli/live/budget-supervisor.mjs','serve','--campaign',f.campaign,'--ledger',f.ledger,'--interval','0.1'],{
env:{...process.env,MOSAIC_AGENT_RUN:'test-unavailable-ledger'},stdio:['ignore','pipe','pipe'], env:{...process.env,MOSAIC_AGENT_RUN:'test-unavailable-ledger'},stdio:['ignore','pipe','pipe'],
}); });
@@ -335,8 +347,104 @@ test('real serve CLI stays alive and attempts supported halt every tick with a m
const [code,signal]=await once(child,'close');clearTimeout(timer); const [code,signal]=await once(child,'close');clearTimeout(timer);
assert.equal(signal,null,errors);assert.equal(code,0,errors);assert.ok(reports.length>=3,errors); assert.equal(signal,null,errors);assert.equal(code,0,errors);assert.ok(reports.length>=3,errors);
for(const r of reports) { for(const r of reports) {
assert.equal(r.stopped,true);assert.equal(r.haltError,'supported-halt-refused-or-unknown'); assert.equal(r.stopped,true);
assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']); if(damage==='missing-ledger'){
assert.equal(r.haltError,'supported-halt-refused-or-unknown');
assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
}else{assert.match(r.error,/supported-halt-refused/);assert.deepEqual(r.evidenceErrors,['owner-binding-unreadable-or-invalid']);}
}
assert.equal(existsSync(f.ledger),damage!=='missing-ledger');
});
// Every damaged-sidecar case uses the real usage store and preserves the evidence.
function damagePin(f,kind) {
const pin=f.campaign+'.binding.json';
if(kind==='read') chmodSync(pin,0o000);
if(kind==='parse') writeFileSync(pin,'{');
if(kind==='mode') chmodSync(pin,0o400);
return ()=>{
if(kind==='read'){assert.equal(lstatSync(pin).mode&0o777,0);chmodSync(pin,0o600);}
if(kind==='mode')assert.equal(lstatSync(pin).mode&0o777,0o400);
assert.equal(readFileSync(pin,'utf8'),kind==='parse'?'{':readFileSync(pin+'-original','utf8'));
};
}
for(const kind of ['read','parse','mode']) for(const state of ['restart','cutoff','over-cap','latched-resume'])
test(`sidecar ${kind} damage at ${state} still latches and halts on restart and between service ticks`,async t=>{
for(const running of [false,true]) await t.test(running?'running service':'fresh service',async t=>{
const f=setup(t,state==='over-cap'||state==='latched-resume'?3:100);
insert(f);
const pin=f.campaign+'.binding.json',epoch=readFileSync(join(f.ledger,'campaign.json'),'utf8');
writeFileSync(pin+'-original',readFileSync(pin),{mode:0o600});
const now=()=>state==='cutoff'?CUTOFF:NOW;
if(state==='latched-resume')await check(f.opts,{now,halt:f.halt});
let assertDamage;
if(!running)assertDamage=damagePin(f,kind);
const controller=new AbortController(),reports=[];
await serveBudget(f.opts,{interval:1,signal:controller.signal,now,halt:f.halt,report:r=>{
reports.push(r);
if(running && reports.length===1){assertDamage=damagePin(f,kind);f.halts.length=0;}
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.ok(r.evidenceErrors.includes('owner-binding-unreadable-or-invalid'));}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,running?2:state==='latched-resume'?4:3);
assertDamage();assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),epoch);
assert.ok(existsSync(join(f.ledger,'stop.json')));
assert.ok(JSON.parse(readFileSync(join(f.ledger,`halt-${readdirSync(f.ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length}.json`))).evidenceErrors.length);
});
});
test('running service retains validated target evidence when both sidecar and ledger become unavailable',async t=>{
const f=setup(t,100),controller=new AbortController(),reports=[];
await serveBudget(f.opts,{now:()=>NOW,interval:1,signal:controller.signal,read:()=>[],halt:f.halt,report:r=>{
reports.push(r);
if(reports.length===1){writeFileSync(f.campaign+'.binding.json','{');renameSync(f.ledger,f.ledger+'-offline');}
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.equal(r.storageErrors.length,2);assert.ok(r.evidenceErrors.length);}
if(reports.length===3)controller.abort();
}});
assert.equal(f.halts.length,2);assert.equal(existsSync(f.ledger),false);
});
for(const kind of ['read','parse','mode'])test(`damaged sidecar (${kind}) never permits changed config bytes or foreign marker/root/store`,async t=>{
for(const changed of ['config','invalid','marker','root','store']) {
const f=setup(t,1);damagePin(f,kind);
if(changed==='config')writeFileSync(f.config,readFileSync(f.config,'utf8')+' ');
if(changed==='invalid')writeFileSync(f.config,'{');
if(changed==='marker')writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'foreign'}));
if(changed==='root')renameSync(f.dataRoot,f.dataRoot+'-offline');
if(changed==='store')renameSync(join(f.dataRoot,'bus/bus.sqlite'),join(f.dataRoot,'bus/bus.sqlite-offline'));
await assert.rejects(check(f.opts,{now:()=>CUTOFF,halt:f.halt}));assert.equal(f.halts.length,0,changed);
}
});
for(const variant of ['coherent-epoch-edit','zero-pin-hash'])test(`real-store ${variant} stops without accepting a fresh accounting epoch`,async t=>{
const f=setup(t,5);insert(f);
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).totals[0].total,4);
const pin=f.campaign+'.binding.json',original=readFileSync(pin,'utf8');
if(variant==='coherent-epoch-edit') {
f.c.start='2026-11-06T02:29:30.000Z';const text=JSON.stringify(f.c);writeFileSync(f.campaign,text);
const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;p.hash=createHash('sha256').update(text).digest('hex');writeFileSync(file,JSON.stringify(p));
for(const n of readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')))writeFileSync(join(f.ledger,n),readFileSync(join(f.ledger,n),'utf8').split('\n')[0]+'\n');
f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
'2026-11-06T02:29:45.000Z',f.c.business,row().run,'assistant','proxy','glm-5.3-flash','t2','transcript','end','known',4,0,0,0);
assert.equal(f.store.get('SELECT sum(input+output+cache_write+cache_read) total FROM usage_records').total,8);
} else {const p=JSON.parse(original);p.hash='0'.repeat(64);writeFileSync(pin,JSON.stringify(p));}
const before=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]);
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
assert.equal(result.reason,'owner-binding-epoch-changed');assert.equal(f.halts.length,1);
assert.deepEqual(readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]),before);
if(variant==='coherent-epoch-edit')assert.equal(readFileSync(pin,'utf8'),original);
assert.throws(()=>status(f.opts),/owner-binding-epoch-changed/);
});
test('semantic sidecar target damage stops using independent config/marker/root/store evidence',async t=>{
for(const field of ['ledger','binding','configHash']) {
const f=setup(t,100),path=f.campaign+'.binding.json',pin=JSON.parse(readFileSync(path));
if(field==='ledger')pin.ledger+='-changed';
if(field==='binding')pin.binding.business='changed-business';
if(field==='configHash')pin.configHash='0'.repeat(64);
const damaged=JSON.stringify(pin);writeFileSync(path,damaged);
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
assert.equal(result.stopped,true);assert.ok(result.evidenceErrors.includes('owner-binding-target-changed'));
assert.equal(f.halts.length,1);assert.equal(f.halts[0].business,f.c.business);assert.equal(readFileSync(path,'utf8'),damaged);
} }
assert.equal(existsSync(f.ledger),false);
}); });
+23 -13
View File
@@ -5,10 +5,9 @@ import { createServer, createConnection } from 'node:net';
import {createServer as httpServer,request as httpRequest} from 'node:http'; import {createServer as httpServer,request as httpRequest} from 'node:http';
import { once } from 'node:events'; import { once } from 'node:events';
import { createHash, X509Certificate } from 'node:crypto'; import { createHash, X509Certificate } from 'node:crypto';
import { appendFileSync, chmodSync, existsSync, readdirSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs'; import { appendFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { exclusive, fail, isMain, options, plainPath, privateDir, readPrivate } from './support-common.mjs'; import { exclusive, fail, isMain, options, plainPath, privateDir, readPrivate } from './support-common.mjs';
const ownedDaemons = new Map(); const ownedDaemons = new Map();
const HERE = fileURLToPath(import.meta.url); const HERE = fileURLToPath(import.meta.url);
@@ -33,13 +32,8 @@ async function chromium(evidence, ca, proxy) {
} }
for (const name of readdirSync(nss)) chmodSync(join(nss,name),0o600); for (const name of readdirSync(nss)) chmodSync(join(nss,name),0o600);
const profile = join(evidence, 'profile'); privateDir(profile, true); const profile = join(evidence, 'profile'); privateDir(profile, true);
// NSS prefers ~/.pki/nssdb when it exists, even with XDG_DATA_HOME set. Overlay it
// in a private mount namespace, never mutate the owner's trust store or change HOME.
// On newer clean hosts, the process-local XDG database alone is sufficient.
const legacyNss = join(homedir(), '.pki/nssdb');
const legacy = existsSync(legacyNss);
if (legacy) plainPath(legacyNss);
const executable = process.env.CHROMIUM || '/usr/bin/chromium'; const executable = process.env.CHROMIUM || '/usr/bin/chromium';
plainPath(executable);
const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage', const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage',
'--disable-background-networking','--disable-component-update','--disable-sync','--disable-quic', '--disable-background-networking','--disable-component-update','--disable-sync','--disable-quic',
`--proxy-server=http://127.0.0.1:${proxy.port}`,'--proxy-bypass-list=<-loopback>', `--proxy-server=http://127.0.0.1:${proxy.port}`,'--proxy-bypass-list=<-loopback>',
@@ -47,12 +41,28 @@ async function chromium(evidence, ca, proxy) {
`--user-data-dir=${profile}`]; `--user-data-dir=${profile}`];
// Bubblewrap brings up the new namespace's own loopback. There is no veth, // Bubblewrap brings up the new namespace's own loopback. There is no veth,
// route, DNS forwarder or host-network FD. Only the fixed proxy has a relay. // route, DNS forwarder or host-network FD. Only the fixed proxy has a relay.
const child = spawn('/usr/bin/bwrap', ['--die-with-parent','--unshare-net','--unshare-pid', // Start with bwrap's empty root. A network namespace does not hide pathname
'--bind','/','/','--dev-bind','/dev','/dev','--proc','/proc', // Unix sockets: never expose host /run, /tmp, home, devices or the repository.
...(legacy ? ['--bind',nss,legacyNss] : []),'--',process.execPath, const system = ['/usr','/etc/ld.so.cache','/etc/nsswitch.conf','/etc/passwd','/etc/group',
fileURLToPath(new URL('./browser-network.mjs',import.meta.url)),readlinkSync('/proc/self/ns/net'), '/etc/fonts','/etc/ssl/certs','/etc/localtime'];
const mounts = system.filter(path=>existsSync(path)).flatMap(path=>['--ro-bind',path,path]);
for (const path of ['/bin','/sbin','/lib','/lib64']) if (existsSync(path)) {
mounts.push(...(lstatSync(path).isSymbolicLink() ? ['--symlink',readlinkSync(path),path] : ['--ro-bind',path,path]));
}
const home='/home/browser', runtime='/run/browser';
const environment={PATH:'/usr/bin:/bin',LANG:'C.UTF-8',HOME:home,PWD:home,TMPDIR:'/tmp',
XDG_RUNTIME_DIR:runtime,XDG_DATA_HOME:xdg,XDG_CONFIG_HOME:home+'/.config',XDG_CACHE_HOME:home+'/.cache'};
const child = spawn('/usr/bin/bwrap', ['--die-with-parent','--unshare-user','--unshare-net','--unshare-pid','--unshare-ipc','--unshare-uts',
...mounts,'--dev','/dev','--proc','/proc','--tmpfs','/tmp','--tmpfs','/run','--tmpfs','/home',
'--dir',home,'--dir',runtime,'--bind',evidence,evidence,
'--dir',home+'/.pki','--bind',nss,home+'/.pki/nssdb',
'--ro-bind',process.execPath,process.execPath,
'--ro-bind',fileURLToPath(new URL('./browser-network.mjs',import.meta.url)),'/opt/ui797/browser-network.mjs',
...(executable.startsWith('/usr/') ? [] : ['--ro-bind',executable,executable]),
'--chdir',home,'--clearenv',...Object.entries(environment).flatMap(([key,value])=>['--setenv',key,value]),
'--',process.execPath,'/opt/ui797/browser-network.mjs',readlinkSync('/proc/self/ns/net'),
proxy.socket,String(proxy.port),executable,...chromeArgs], proxy.socket,String(proxy.port),executable,...chromeArgs],
{ env:{...process.env,XDG_DATA_HOME:xdg}, stdio: ['ignore','ignore','ignore','pipe','pipe'] }); { env:{PATH:'/usr/bin:/bin',LANG:'C.UTF-8'}, stdio: ['ignore','ignore','ignore','pipe','pipe'] });
let id = 0, buffer = ''; let id = 0, buffer = '';
const pending = new Map(), events = new Map(); const pending = new Map(), events = new Map();
const rejectAll = () => { for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Chromium exited')); } pending.clear(); }; const rejectAll = () => { for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Chromium exited')); } pending.clear(); };
+70 -1
View File
@@ -1,7 +1,7 @@
import test,{after} from 'node:test'; import test,{after} from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { createServer as tcpServer } from 'node:net'; import { createServer as tcpServer,createConnection } from 'node:net';
import { createSocket } from 'node:dgram'; import { createSocket } from 'node:dgram';
import { readFileSync, readdirSync, existsSync,writeFileSync,lstatSync,readlinkSync } from 'node:fs'; import { readFileSync, readdirSync, existsSync,writeFileSync,lstatSync,readlinkSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -173,6 +173,75 @@ for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`C
for(const count of counters)assert.equal(count.datagrams??count.connections,0,JSON.stringify(count)); for(const count of counters)assert.equal(count.datagrams??count.connections,0,JSON.stringify(count));
}); });
for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`actual Chromium ${origin} cannot reach parent pathname D-Bus`,async t=>{
const f=await setup(t,'<!doctype html><h1>D-Bus boundary</h1>');
if(origin.startsWith('https:')){const id=await serve(f.identity);f.after(()=>closeServer(id));}
const path=join(f.root,'dbus.sock');let connections=0,auth=0;
const bus=tcpServer(peer=>{connections++;peer.on('error',()=>{});peer.on('data',data=>{if(data.includes(Buffer.from('AUTH EXTERNAL')))auth++;peer.destroy();});peer.setTimeout(100,()=>peer.destroy());});
await new Promise((r,j)=>{bus.once('error',j);bus.listen(path,r);});f.after(()=>new Promise(r=>bus.close(r)));
// Prove the fixture is reachable from the host before asserting zero traffic.
await new Promise((r,j)=>{const p=createConnection(path);p.once('error',j);p.once('connect',()=>p.end('AUTH EXTERNAL positive control'));p.on('data',()=>{});p.once('close',r);});
assert.equal(connections,1);assert.equal(auth,1);connections=0;auth=0;
const saved=process.env.DBUS_SESSION_BUS_ADDRESS;
process.env.DBUS_SESSION_BUS_ADDRESS='unix:path='+path;
try {await start({evidence:f.evidence,origin,ca:join(f.identity,'ca.pem')});await pause(500);await callBrowser(f.evidence,'stop');}
finally {if(saved===undefined)delete process.env.DBUS_SESSION_BUS_ADDRESS;else process.env.DBUS_SESSION_BUS_ADDRESS=saved;}
t.diagnostic(JSON.stringify({origin,connections,auth}));assert.equal(connections,0);assert.equal(auth,0);
});
test('CHROMIUM diagnostic sees only browser mounts and explicit environment; host sockets and a 0600 file stay hidden',async t=>{
const f=await setup(t,'<!doctype html><h1>Native mount boundary</h1>');
const path=join(f.root,'host-agent.sock'),abstract='\0ui797-native-'+process.pid;
let pathnameHits=0,abstractHits=0;
for(const [address,count] of [[path,()=>pathnameHits++],[abstract,()=>abstractHits++]]) {
const server=tcpServer(peer=>{count();peer.on('error',()=>{});peer.on('data',()=>peer.destroy());});
await new Promise((r,j)=>{server.once('error',j);server.listen(address,r);});f.after(()=>new Promise(r=>server.close(r)));
await new Promise((r,j)=>{const p=createConnection(address);p.once('error',j);p.once('connect',()=>p.end('positive'));p.on('data',()=>{});p.once('close',r);});
}
assert.equal(pathnameHits,1);assert.equal(abstractHits,1);pathnameHits=abstractHits=0;
let tcpHits=0,udpHits=0;
const tcp=tcpServer(peer=>{tcpHits++;peer.on('error',()=>{});peer.on('data',()=>peer.destroy());});
await new Promise((r,j)=>{tcp.once('error',j);tcp.listen(0,'127.0.0.1',r);});f.after(()=>new Promise(r=>tcp.close(r)));
await new Promise((r,j)=>{const p=createConnection({host:'127.0.0.1',port:tcp.address().port});p.once('error',j);p.once('connect',()=>p.end('positive'));p.on('data',()=>{});p.once('close',r);});
const udp=createSocket('udp4');udp.on('message',()=>udpHits++);
await new Promise(r=>udp.bind(0,'127.0.0.1',r));f.after(()=>new Promise(r=>udp.close(r)));
await new Promise(r=>udp.send('positive',udp.address().port,'127.0.0.1',r));await pause(50);
assert.equal(tcpHits,1);assert.equal(udpHits,1);tcpHits=udpHits=0;
const secret=join(f.root,'synthetic-credential');writeFileSync(secret,'synthetic fixture only',{mode:0o600});
assert.equal(readFileSync(secret,'utf8'),'synthetic fixture only');
const output=join(f.evidence,'native.json'),wrapper=join(f.root,'chromium-diagnostic.mjs');
// The supported override is a single read-only executable. It needs no host
// script directory or inherited variables, then starts the actual Chromium.
writeFileSync(wrapper,`#!${process.execPath}
import {createConnection} from 'node:net';
import {createSocket} from 'node:dgram';
import {readFileSync,writeFileSync,existsSync,readlinkSync,closeSync} from 'node:fs';
import {spawn} from 'node:child_process';
const hostNet=${JSON.stringify(readlinkSync('/proc/self/ns/net'))};
if(readlinkSync('/proc/self/ns/net')===hostNet)throw Error('network namespace missing');
const attempts=[];
for(const path of ${JSON.stringify([path,abstract,{host:'127.0.0.1',port:tcp.address().port}])})attempts.push(await new Promise(resolve=>{
const p=createConnection(path);p.setTimeout(300,()=>p.destroy());p.on('connect',()=>{p.end('unexpected');resolve('connected');});p.on('error',e=>resolve(e.code));p.on('close',()=>resolve('closed'));
}));
const udp=createSocket('udp4');await new Promise(r=>udp.send('negative',${udp.address().port},'127.0.0.1',()=>{udp.close();r();}));
let file;try{readFileSync(${JSON.stringify(secret)});file='readable';}catch(e){file=e.code;}
writeFileSync(${JSON.stringify(output)},JSON.stringify({attempts,file,environment:process.env,hostRoot:existsSync(${JSON.stringify(f.config)}),hostRuntime:existsSync('/run/dbus/system_bus_socket'),privateHome:process.env.HOME==='/home/browser',privateRuntime:process.env.XDG_RUNTIME_DIR==='/run/browser',nss:existsSync(process.env.HOME+'/.pki/nssdb/cert9.db')}));
const child=spawn('/usr/bin/chromium',process.argv.slice(2),{stdio:['ignore','ignore','ignore',3,4]});closeSync(3);closeSync(4);
child.once('exit',(code)=>{process.exitCode=code;});
`,{mode:0o700});
const names=['CHROMIUM','DBUS_SESSION_BUS_ADDRESS','SSH_AUTH_SOCK','XDG_RUNTIME_DIR','UI797_SYNTHETIC_SECRET'];
const saved=Object.fromEntries(names.map(n=>[n,process.env[n]]));
Object.assign(process.env,{CHROMIUM:wrapper,DBUS_SESSION_BUS_ADDRESS:'unix:path='+path,SSH_AUTH_SOCK:path,XDG_RUNTIME_DIR:f.root,UI797_SYNTHETIC_SECRET:'synthetic env canary'});
try {await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});await callBrowser(f.evidence,'stop');}
finally {for(const n of names)if(saved[n]===undefined)delete process.env[n];else process.env[n]=saved[n];}
const result=JSON.parse(readFileSync(output));t.diagnostic(JSON.stringify(result));
assert.equal(result.file,'ENOENT');assert.equal(result.hostRoot,false);assert.equal(result.hostRuntime,false);
assert.equal(result.privateHome,true);assert.equal(result.privateRuntime,true);assert.equal(result.nss,true);
assert.ok(result.attempts.every(r=>r!=='connected'));assert.equal(pathnameHits,0);assert.equal(abstractHits,0);assert.equal(tcpHits,0);assert.equal(udpHits,0);
assert.equal(result.environment.PWD,'/home/browser');
assert.deepEqual(Object.keys(result.environment).sort(),['HOME','LANG','PATH','PWD','TMPDIR','XDG_CACHE_HOME','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_RUNTIME_DIR'].sort());
});
after(async()=>{ after(async()=>{
const {createConnection}=await import('node:net'); const {createConnection}=await import('node:net');
for(const port of [17440,17442,17443]) await new Promise((resolve,reject)=>{ for(const port of [17440,17442,17443]) await new Promise((resolve,reject)=>{