diff --git a/agents/darkwing/work/slice1-s2-review/agent-ancestry-r2.txt b/agents/darkwing/work/slice1-s2-review/agent-ancestry-r2.txt new file mode 100644 index 00000000..4be10659 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/agent-ancestry-r2.txt @@ -0,0 +1,5 @@ +2275787 bash 1000 +406108 claude 1000 +609458 node-MainThread 1000 +609414 node-MainThread 1000 +1274 systemd 1000 diff --git a/agents/darkwing/work/slice1-s2-review/comment-r2.md b/agents/darkwing/work/slice1-s2-review/comment-r2.md new file mode 100644 index 00000000..f52b3421 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/comment-r2.md @@ -0,0 +1,30 @@ +Row 37, S2, round 2: **approve** (Darkwing) + +Candidate `candidate-manifest.sha256` `61519059…`, 24 files under +`packages/bus/`. Full record: +`agents/darkwing/work/slice1-s2-review/review-r2.md`. + +The patch applies at `fef4b362` and adds only `packages/bus/`. The +manifest checks 24/24. Tests pass 43/43 on Node 26 and on Node 24, and +Rocko's contract script passes against S1 round 2. + +All five required items are fixed, and I reran my round 1 probes: +- R1: terminal 8357's real ancestry passes. Unreadable ancestors still + get the command and launch checks. +- R2: a revoked run gets `run-revoked`, including after a broker restart. +- R3: cto and pm both route their own cross-role decisions to `human`. +- R4: a refused bind replies and the broker keeps serving. +- R5: 17 of Rocko's mutants, plus 5 of my 6, are killed against a clean + baseline, and the no-op isn't. + +For Sage: since R1, an agent run here passes the human proof with +`setsid -f env -i node human-cli.mjs`, because `systemd --user`'s environ +is unreadable. I reproduced it from my own session. A CLI reparented to +pid 1 passed in round 1 too. That's inside the README's cooperative +limit, but REQ-DEC-3 doesn't say so. I recommend a lead decision that +slice 1's human proof is cooperative, with S6 isolating managed agents. +My round 1 claim that the fix wouldn't weaken anything was wrong. + +Non-blocking: no test checks a within-role `route_to`, which is my one +surviving mutant. Two R2 contract lines come from a script that isn't in +the packet. A refused `bindLaunch` uses up its run name. diff --git a/agents/darkwing/work/slice1-s2-review/escape-r2.mjs b/agents/darkwing/work/slice1-s2-review/escape-r2.mjs new file mode 100644 index 00000000..4e785114 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/escape-r2.mjs @@ -0,0 +1,30 @@ +// Round 2: does a reparented human CLI started from this agent run pass now that EACCES ancestors skip markers? +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { startBroker } from '../src/runtime.mjs'; +const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'cto' }, roles: { cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } }; +const root = mkdtempSync(join(tmpdir(), 'dw-human-')); mkdirSync(join(root, 'data')); +const rt = await startBroker({ dataRoot: join(root, 'data'), businesses }); +const cli = fileURLToPath(new URL('../src/human-cli.mjs', import.meta.url)); +writeFileSync(join(root, 'req'), JSON.stringify({ business: 'demo', verb: 'inbox' })); +const cases = { + 'setsid -f, env -i': `setsid -f sh -c 'sleep 0.5; echo "ppid $(cut -d" " -f4 /proc/$$/stat) $(cat /proc/$(cut -d" " -f4 /proc/$$/stat)/comm)" > "$0/A.ppid"; exec env -i PATH="$PATH" "$1" "$2" "$3" < "$0/req" > "$0/A.out" 2>&1' "${root}" "${process.execPath}" "${cli}" "${rt.path}"`, + 'setsid -f, inherited env': `setsid -f sh -c 'sleep 0.5; exec "$1" "$2" "$3" < "$0/req" > "$0/B.out" 2>&1' "${root}" "${process.execPath}" "${cli}" "${rt.path}"`, + 'env -i, no setsid': `env -i PATH="$PATH" "${process.execPath}" "${cli}" "${rt.path}" < "${root}/req" > "${root}/C.out" 2>&1`, +}; +try { + for (const [name, cmd] of Object.entries(cases)) { + const c = spawn('sh', ['-c', cmd], { stdio: 'ignore' }); + await new Promise((r) => c.on('close', r)); + } + for (let i = 0; i < 50 && !['A', 'B', 'C'].every((k) => existsSync(join(root, k + '.out')) && readFileSync(join(root, k + '.out'), 'utf8')); i++) await sleep(200); + for (const [k, name] of [['A', 'setsid -f, env -i'], ['B', 'setsid -f, inherited env'], ['C', 'env -i, no setsid']]) { + const f = join(root, k + '.out'); + console.log(name + ':', existsSync(f) ? readFileSync(f, 'utf8').trim() : 'no output'); + } + if (existsSync(join(root, 'A.ppid'))) console.log('setsid child reparented to', readFileSync(join(root, 'A.ppid'), 'utf8').trim()); +} finally { await rt.close(); rmSync(root, { recursive: true, force: true }); } diff --git a/agents/darkwing/work/slice1-s2-review/escape-r2.txt b/agents/darkwing/work/slice1-s2-review/escape-r2.txt new file mode 100644 index 00000000..afc600f1 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/escape-r2.txt @@ -0,0 +1,4 @@ +setsid -f, env -i: [] +setsid -f, inherited env: human-required +env -i, no setsid: human-required +setsid child reparented to ppid 1274 systemd diff --git a/agents/darkwing/work/slice1-s2-review/mutations-dw2.py b/agents/darkwing/work/slice1-s2-review/mutations-dw2.py new file mode 100644 index 00000000..d6d841ea --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/mutations-dw2.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# Round 2: Rocko's cases plus Darkwing's boundary mutants, repository layout, each compared to a clean baseline. +import pathlib,tempfile,shutil,subprocess,json,re,sys +src=open(sys.argv[1]).read() +cases=eval(src[src.index('cases=[')+6:src.index('\n]\n')+2]) +extra=[ + ('dw-eacces-all','human.mjs',"if(e.code!=='EACCES')throw e;",''), + ('dw-command-skip-null-env','human.mjs',"const command=basename(current.argv[0]??'');","const command=current.env===null?'':basename(current.argv[0]??'');"), + ('dw-launch-skip-null-env','human.mjs',"launches.some((r)=>r.pid===current.pid","current.env!==null&&launches.some((r)=>r.pid===current.pid"), + ('dw-route-any-class','broker.mjs',"cls==='cross-role'&&proposedRoute===s.role","proposedRoute===s.role"), + ('dw-reply-identifier','broker.mjs',"if(a[k]!==undefined){identifier(a[k]);","if(a[k]!==undefined){"), + ('dw-token-15','credentials.mjs',"if(token.length<16)","if(token.length<15)"), +] +source=pathlib.Path(sys.argv[2]) +def run(dest): + r=subprocess.run(['node','--test',*[str(p) for p in sorted((dest/'tests').glob('*.test.mjs'))]],capture_output=True,text=True,timeout=180) + tests=set(l[2:].rsplit(' (',1)[0] for l in r.stdout.splitlines() if l.startswith('✖ ') and not l.startswith('✖ failing tests')) + return r.returncode,tests +with tempfile.TemporaryDirectory(prefix='dw-bus-mut2-') as root: + dest=pathlib.Path(root)/'packages'/'bus';shutil.copytree(source,dest,ignore=shutil.ignore_patterns('dw')) + code,base=run(dest);print(json.dumps({'baseline_exit':code,'baseline_failures':sorted(base)}),flush=True) + code,f=run(dest);print(json.dumps({'mutation':'no-op','exit':code,'killed':bool(f-base)}),flush=True) + out=[] + for name,file,old,new in cases+extra: + p=dest/'src'/file;b=p.read_text();pat=r'\s*'.join(re.escape(c) for c in old if not c.isspace());n=len(re.findall(pat,b)) + if n!=1: print(json.dumps({'mutation':name,'error':f'{n} matches'}),flush=True);continue + p.write_text(re.sub(pat,lambda m:new,b,count=1)) + try: code,f=run(dest) + finally: p.write_text(b) + x=sorted(f-base);out.append({'mutation':name,'exit':code,'killed':bool(x),'new_failures':x}) + print(json.dumps(out[-1]),flush=True) + print('killed',sum(x['killed'] for x in out),'of',len(out)) diff --git a/agents/darkwing/work/slice1-s2-review/mutations-dw2.txt b/agents/darkwing/work/slice1-s2-review/mutations-dw2.txt new file mode 100644 index 00000000..5a1d708e --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/mutations-dw2.txt @@ -0,0 +1,26 @@ +{"baseline_exit": 0, "baseline_failures": []} +{"mutation": "no-op", "exit": 0, "killed": false} +{"mutation": "ancestor-eacces", "exit": 1, "killed": true, "new_failures": ["EACCES ancestor environments skip only markers; commands and registered launches still refuse", "real pid 1 remains inspectable when its environment is protected"]} +{"mutation": "revoked-reclaim", "exit": 1, "killed": true, "new_failures": ["revocation permanently bars the old run from reclaiming first, including after broker restart"]} +{"mutation": "self-approval", "exit": 1, "killed": true, "new_failures": ["both arbiters require human resolution when their cross-role route is themselves"]} +{"mutation": "bind-refusal", "exit": 1, "killed": true, "new_failures": ["a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it"]} +{"mutation": "short-token", "exit": 1, "killed": true, "new_failures": ["opaque tokens shorter than 16 characters refuse before use"]} +{"mutation": "refusal-error", "exit": 1, "killed": true, "new_failures": ["empty message references refuse before storage; refusal-evidence failure stays a typed error"]} +{"mutation": "holder-check", "exit": 1, "killed": true, "new_failures": ["claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic", "launch identity is stamped, payload identity is refused and stale holder cannot send"]} +{"mutation": "human-resolution", "exit": 1, "killed": true, "new_failures": ["both arbiters require human resolution when their cross-role route is themselves", "decision classes route from policy; gated resolution is human-only, choice and target must match", "launch events require a human CLI capability; generic emit cannot forge authority events"]} +{"mutation": "decision-action", "exit": 1, "killed": true, "new_failures": ["authority never transfers between action, run, target, unresolved or replaced role holder"]} +{"mutation": "decision-target", "exit": 1, "killed": true, "new_failures": ["decision classes route from policy; gated resolution is human-only, choice and target must match"]} +{"mutation": "decision-choice", "exit": 1, "killed": true, "new_failures": ["authority never transfers between action, run, target, unresolved or replaced role holder", "both arbiters require human resolution when their cross-role route is themselves", "decision classes route from policy; gated resolution is human-only, choice and target must match"]} +{"mutation": "reader-write", "exit": 1, "killed": true, "new_failures": ["observer capabilities read human inbox but cannot mutate or forge launch identity"]} +{"mutation": "payload-secret", "exit": 1, "killed": true, "new_failures": ["loaded fixture token is absent from socket replies and SQLite, including refusal evidence"]} +{"mutation": "schema-open", "exit": 1, "killed": true, "new_failures": ["rollback is atomic and schema metadata is checked against trusted DDL, not just itself"]} +{"mutation": "task-current", "exit": 1, "killed": true, "new_failures": ["task projection uses schema current view, skipping earlier and equal-start polls"]} +{"mutation": "timestamp-commit", "exit": 1, "killed": true, "new_failures": ["writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers"]} +{"mutation": "human-ancestry", "exit": 1, "killed": true, "new_failures": ["EACCES ancestor environments skip only markers; commands and registered launches still refuse", "human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse"]} +{"mutation": "dw-eacces-all", "exit": 1, "killed": true, "new_failures": ["EACCES ancestor environments skip only markers; commands and registered launches still refuse"]} +{"mutation": "dw-command-skip-null-env", "exit": 1, "killed": true, "new_failures": ["EACCES ancestor environments skip only markers; commands and registered launches still refuse"]} +{"mutation": "dw-launch-skip-null-env", "exit": 1, "killed": true, "new_failures": ["EACCES ancestor environments skip only markers; commands and registered launches still refuse"]} +{"mutation": "dw-route-any-class", "exit": 0, "killed": false, "new_failures": []} +{"mutation": "dw-reply-identifier", "exit": 1, "killed": true, "new_failures": ["empty message references refuse before storage; refusal-evidence failure stays a typed error"]} +{"mutation": "dw-token-15", "exit": 1, "killed": true, "new_failures": ["opaque tokens shorter than 16 characters refuse before use"]} +killed 22 of 23 diff --git a/agents/darkwing/work/slice1-s2-review/node24-r2.txt b/agents/darkwing/work/slice1-s2-review/node24-r2.txt new file mode 100644 index 00000000..5c6eb30b --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/node24-r2.txt @@ -0,0 +1,52 @@ +v24.21.0 +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (154.473782ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (224.999695ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (218.2267ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (137.241048ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (132.177847ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (81.576178ms) +✔ task action subjects and linked decision trail are complete and ordered (82.729828ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (43.684156ms) +✔ business isolation includes inherited object names and cross-business message references (80.693553ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (119.344537ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (63.000167ms) +✔ revocation permanently bars the old run from reclaiming first, including after broker restart (105.963726ms) +✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (59.240762ms) +✔ both arbiters require human resolution when their cross-role route is themselves (95.888631ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.641801ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (3.700265ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (5.697539ms) +✔ expiry refuses use and env references never become client data (1.073265ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.120409ms) +✔ opaque tokens shorter than 16 characters refuse before use (0.43432ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (3.445442ms) +✔ process reader gets own kernel identity without exposing environment values (0.777039ms) +✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (2.360178ms) +✔ real pid 1 remains inspectable when its environment is protected (0.479225ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (183.010225ms) +✔ startup token refusal returns safe code without value or partial listening broker (46.446663ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (157.60812ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (150.794865ms) +✔ trusted host registers later launches; socket clients never have a registration verb (147.923936ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (48.612501ms) +✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (146.769057ms) +✔ v3b prototype refusals, views and append-only mutations (881.545888ms) +✔ creates private WAL store and excludes a second writer until explicit close (103.52694ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (170.510621ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (141.33725ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (142.218029ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (81.918208ms) +✔ async transactions refuse before invoking their function (64.829609ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (139.594133ms) +✔ two wire claims serialize; a lost reply never automatically retries (153.392792ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (90.253752ms) +✔ client preserves UTF-8 when a response divides a multibyte character (12.675617ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (110.705929ms) +ℹ tests 43 +ℹ suites 0 +ℹ pass 43 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1679.066298 diff --git a/agents/darkwing/work/slice1-s2-review/node26-r2.txt b/agents/darkwing/work/slice1-s2-review/node26-r2.txt new file mode 100644 index 00000000..36ca93da --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/node26-r2.txt @@ -0,0 +1,52 @@ +v26.8.1 +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (138.901031ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (225.790118ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (266.266044ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (190.815244ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (173.510957ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (116.366573ms) +✔ task action subjects and linked decision trail are complete and ordered (85.875561ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (48.582074ms) +✔ business isolation includes inherited object names and cross-business message references (86.215424ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (120.666191ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (58.840197ms) +✔ revocation permanently bars the old run from reclaiming first, including after broker restart (96.298649ms) +✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (60.71768ms) +✔ both arbiters require human resolution when their cross-role route is themselves (112.352052ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.905483ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.577536ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.303784ms) +✔ expiry refuses use and env references never become client data (1.353573ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.848422ms) +✔ opaque tokens shorter than 16 characters refuse before use (0.370908ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.303165ms) +✔ process reader gets own kernel identity without exposing environment values (1.698768ms) +✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.235204ms) +✔ real pid 1 remains inspectable when its environment is protected (0.470908ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (185.421326ms) +✔ startup token refusal returns safe code without value or partial listening broker (47.484669ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (165.943844ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (179.207621ms) +✔ trusted host registers later launches; socket clients never have a registration verb (184.516304ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (39.558799ms) +✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (161.839535ms) +✔ v3b prototype refusals, views and append-only mutations (1051.689327ms) +✔ creates private WAL store and excludes a second writer until explicit close (128.588098ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (146.442339ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (160.892644ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (171.292568ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (104.224826ms) +✔ async transactions refuse before invoking their function (102.362634ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (128.936609ms) +✔ two wire claims serialize; a lost reply never automatically retries (156.189584ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (98.289776ms) +✔ client preserves UTF-8 when a response divides a multibyte character (13.417854ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (121.332995ms) +ℹ tests 43 +ℹ suites 0 +ℹ pass 43 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1872.076149 diff --git a/agents/darkwing/work/slice1-s2-review/pid1-reparent.mjs b/agents/darkwing/work/slice1-s2-review/pid1-reparent.mjs new file mode 100644 index 00000000..50b639c5 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/pid1-reparent.mjs @@ -0,0 +1,8 @@ +// A CLI reparented to pid 1 with a clean environment: verifyHuman never reads pid 1, so the walk ends at the CLI. +const nonce = 'a'.repeat(64), cliPath = '/fixture/human-cli.mjs'; +for (const tree of ['s2-review', 's2-r2']) { + const { verifyHuman } = await import(`/home/jwoltje/darkwing-scratch/${tree}/packages/bus/src/human.mjs`); + const read = (pid) => { if (pid === 4242) return { pid, ppid: 1, startTime: '1', uid: process.getuid(), argv: ['node', cliPath], env: { MOSAIC_BUS_CLI_NONCE: nonce } }; throw Object.assign(new Error('unexpected read ' + pid), { code: 'x' }); }; + let out; try { out = JSON.stringify(verifyHuman({ pid: 4242, startTime: '1', nonce, business: 'demo' }, { cliPath, readProcess: read })); } catch (e) { out = e.code; } + console.log(tree === 's2-review' ? 'round 1:' : 'round 2:', 'CLI with ppid 1, clean env ->', out); +} diff --git a/agents/darkwing/work/slice1-s2-review/pid1-reparent.txt b/agents/darkwing/work/slice1-s2-review/pid1-reparent.txt new file mode 100644 index 00000000..e0b580f1 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/pid1-reparent.txt @@ -0,0 +1,2 @@ +round 1: CLI with ppid 1, clean env -> {"business":"demo"} +round 2: CLI with ppid 1, clean env -> {"business":"demo"} diff --git a/agents/darkwing/work/slice1-s2-review/probes-r1-rerun.txt b/agents/darkwing/work/slice1-s2-review/probes-r1-rerun.txt new file mode 100644 index 00000000..a455bb42 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/probes-r1-rerun.txt @@ -0,0 +1,44 @@ +P1 revoked pm reclaims -> "run-revoked" +P1 replacement pm claim -> {"role":"pm","run":"pm-next"} +P2 cto raise route_to human +P3 authorize 0 {"class":"gated","decision":"ebd52fe9-1676-46ed-bdf9-d3d93e71939c"} +P3 authorize 1 {"class":"gated","decision":"ebd52fe9-1676-46ed-bdf9-d3d93e71939c"} +P3 authorize 2 {"class":"gated","decision":"ebd52fe9-1676-46ed-bdf9-d3d93e71939c"} +P4 events added by 10 refusals: 10 +P5 -> "invalid-request" +✔ P1 revoked run reclaims its role with its old capability (101.410494ms) +✖ P2 arbiter resolves its own cross-role decision (69.554373ms) +✔ P3 one approval authorizes repeatedly (79.923114ms) +✔ P4 refusals from a reader and a non-holder append events (106.267135ms) +✔ P5 empty in_reply_to (54.604374ms) +P6 boot true +P6 first bind true +P6 duplicate bind {"ok":false,"error":"duplicate-run"} +P6 exit code after refused bind null +✔ P6 a refused bindLaunch over IPC stops the broker process (3104.287659ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 5 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3577.172243 + +✖ failing tests: + +test at dw/probes.test.mjs:41:1 +✖ P2 arbiter resolves its own cross-role decision (69.554373ms) + Error: human-required + at fail (file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/broker.mjs:48:9) + at #human (file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/broker.mjs:138:65) + at #dispatch (file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/broker.mjs:513:55) + at file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/broker.mjs:321:38 + at Store.transaction (file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/store.mjs:156:22) + at Broker.request (file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/broker.mjs:319:26) + at call (file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/dw/probes.test.mjs:26:45) + at TestContext. (file:///home/jwoltje/darkwing-scratch/s2-r2/packages/bus/dw/probes.test.mjs:46:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) { + code: 'human-required' + } diff --git a/agents/darkwing/work/slice1-s2-review/probes-r2.test.mjs b/agents/darkwing/work/slice1-s2-review/probes-r2.test.mjs new file mode 100644 index 00000000..70507651 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/probes-r2.test.mjs @@ -0,0 +1,91 @@ +// Darkwing round 2 probes. Not part of the candidate. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import { Store } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; +const options = [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }]; +const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { + pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: ['task.priority.change'] } }, + cto: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } }, + coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } } } } }; +const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); +const code = (f) => { try { return JSON.stringify(f()); } catch (e) { return e.code ?? String(e); } }; +const raise = (b, cap, action, extra = {}) => call(b, cap, 'decision.raise', { action, question: 'Allow?', options, recommendation: 'no', blocking: false, ...extra }); +function open(root) { + const store = new Store(root); + const b = new Broker({ store, businesses }); + const agent = (role, run = role + '-run') => b.bindLaunch({ business: 'demo', role, run, harness: 'pi', address: run }); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + return { b, store, agent, human }; +} +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'dw-bus-')); + const o = open(root); + t.after(() => { try { o.store.close(); } catch {} rmSync(root, { recursive: true, force: true }); }); + return { root, ...o }; +} +for (const [raiser, other, action, extra] of [['cto', 'pm', 'task.scope.change', { domain: 'technical' }], ['pm', 'cto', 'task.priority.change', {}]]) { + test(`R3 ${raiser} self-arbiter routes to human`, (t) => { + const { b, agent, human } = setup(t), me = agent(raiser), them = agent(other); + call(b, me, 'role.claim'); call(b, them, 'role.claim'); + const d = raise(b, me, action, { ...extra, target: 'x1' }); + console.log(`R3 ${raiser} raise class=${d.class} route_to=${d.route_to}`); + console.log(`R3 ${raiser} self resolve ->`, code(() => call(b, me, 'decision.resolve', { id: d.id, choice: 'yes' }))); + console.log(`R3 ${raiser} other role resolve ->`, code(() => call(b, them, 'decision.resolve', { id: d.id, choice: 'yes' }))); + console.log(`R3 ${raiser} authorize before ->`, code(() => b.authorize(me, action, { decision: d.id, target: 'x1' }))); + console.log(`R3 ${raiser} human resolve ->`, code(() => call(b, human, 'decision.resolve', { id: d.id, choice: 'yes' }).status)); + console.log(`R3 ${raiser} authorize after ->`, code(() => b.authorize(me, action, { decision: d.id, target: 'x1' }))); + }); +} +test('R3 non-arbiter coder still routes to cto', (t) => { + const { b, agent } = setup(t), c = agent('coder'), cto = agent('cto'); + call(b, c, 'role.claim'); call(b, cto, 'role.claim'); + const d = raise(b, c, 'task.scope.change', { domain: 'technical', target: 'x1' }); + console.log(`R3 coder raise class=${d.class} route_to=${d.route_to}`); + console.log('R3 cto resolves coder ->', code(() => call(b, cto, 'decision.resolve', { id: d.id, choice: 'yes' }).status)); + console.log('R3 coder authorize ->', code(() => b.authorize(c, 'task.scope.change', { decision: d.id, target: 'x1' }))); +}); +test('R2 revoked run across a broker restart', (t) => { + const { root, b, store, agent, human } = setup(t), c = agent('coder'), p = agent('pm'); + call(b, c, 'role.claim'); call(b, p, 'role.claim'); + const d = raise(b, c, 'role.revoke', { target: 'pm' }); + call(b, human, 'decision.resolve', { id: d.id, choice: 'yes' }); + call(b, c, 'role.revoke', { role: 'pm', decision: d.id }); + store.close(); + const o = open(root); + t.after(() => o.store.close()); + const p1 = o.agent('pm'); + console.log('R2 revoked pm-run after restart ->', code(() => call(o.b, p1, 'role.claim'))); + const p2 = o.agent('pm', 'pm-next'); + console.log('R2 replacement after restart ->', code(() => call(o.b, p2, 'role.claim'))); + console.log('R2 revoked pm-run still other verbs ->', code(() => call(o.b, p1, 'message.send', { to: 'cto', body: 'x' }))); +}); +test('bindLaunch run key after a refused record', (t) => { + const { b, agent } = setup(t); + agent('pm', 'r9'); + const { store } = { store: null }; + console.log('dup ->', code(() => b.bindLaunch({ business: 'demo', role: 'pm', run: 'r9', harness: 'pi' }))); + console.log('bad harness then good ->', code(() => b.bindLaunch({ business: 'demo', role: 'pm', run: 'r10', harness: 'x' })), code(() => typeof b.bindLaunch({ business: 'demo', role: 'pm', run: 'r10', harness: 'pi' }))); +}); +test('R4 broker keeps serving after a refused bind, protocol error exits 2', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'dw-bus-p6-')); + const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); rmSync(root, { recursive: true, force: true }); }); + const ask = async (msg) => { const m = once(child, 'message'); child.send(msg); return (await m)[0]; }; + console.log('R4 boot', (await ask({ op: 'boot', config: { dataRoot: root, businesses, launches: [] } })).ok); + const rec = { business: 'demo', role: 'cto', run: 'r1', harness: 'pi', pid: process.pid, startTime: '1' }; + console.log('R4 first', (await ask({ op: 'bindLaunch', record: rec })).ok); + console.log('R4 duplicate', JSON.stringify(await ask({ op: 'bindLaunch', record: rec }))); + console.log('R4 malformed record', JSON.stringify(await ask({ op: 'bindLaunch', record: { nope: 1 } }))); + const r2 = await ask({ op: 'bindLaunch', record: { ...rec, run: 'r2' } }); + console.log('R4 later bind', r2.ok, r2.launch?.run); + const exit = once(child, 'exit'); + console.log('R4 second boot', JSON.stringify(await ask({ op: 'boot', config: {} }))); + const timer = setTimeout(() => child.kill('SIGKILL'), 5000); + console.log('R4 exit after protocol error', (await exit)[0]); clearTimeout(timer); +}); diff --git a/agents/darkwing/work/slice1-s2-review/probes-r2.txt b/agents/darkwing/work/slice1-s2-review/probes-r2.txt new file mode 100644 index 00000000..bda6ed11 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/probes-r2.txt @@ -0,0 +1,41 @@ +R3 cto raise class=cross-role route_to=human +R3 cto self resolve -> human-required +R3 cto other role resolve -> human-required +R3 cto authorize before -> decision-not-approved +R3 cto human resolve -> undefined +R3 cto authorize after -> {"class":"cross-role","decision":"4381b2ea-f8bb-4614-89a2-c3b58dcc9478"} +R3 pm raise class=cross-role route_to=human +R3 pm self resolve -> human-required +R3 pm other role resolve -> human-required +R3 pm authorize before -> decision-not-approved +R3 pm human resolve -> undefined +R3 pm authorize after -> {"class":"cross-role","decision":"228defd1-0385-47dc-a562-14cc7a1fe158"} +R3 coder raise class=cross-role route_to=cto +R3 cto resolves coder -> undefined +R3 coder authorize -> {"class":"cross-role","decision":"134e5879-d92a-4694-9f57-99d3511ae7f5"} +R2 revoked pm-run after restart -> run-revoked +R2 replacement after restart -> {"role":"pm","run":"pm-next"} +R2 revoked pm-run still other verbs -> not-holder +dup -> duplicate-run +bad harness then good -> invalid-harness "string" +✔ R3 cto self-arbiter routes to human (94.464351ms) +✔ R3 pm self-arbiter routes to human (94.142635ms) +✔ R3 non-arbiter coder still routes to cto (77.105785ms) +✔ R2 revoked run across a broker restart (116.284091ms) +✔ bindLaunch run key after a refused record (50.576946ms) +R4 boot true +R4 first true +R4 duplicate {"ok":false,"error":"duplicate-run"} +R4 malformed record {"ok":false,"error":"invalid-launch-process"} +R4 later bind true r2 +R4 second boot {"ok":false,"error":"invalid-host-request"} +R4 exit after protocol error 2 +✔ R4 broker keeps serving after a refused bind, protocol error exits 2 (97.430903ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 6 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 592.244641 diff --git a/agents/darkwing/work/slice1-s2-review/review-r2.md b/agents/darkwing/work/slice1-s2-review/review-r2.md new file mode 100644 index 00000000..e727eef2 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/review-r2.md @@ -0,0 +1,166 @@ +# Row 37, S2 bus and broker core, round 2 review (Darkwing) + +Issue #1519. Candidate: Rocko's `candidate-manifest.sha256` (sha256 +`6151905968ea5db80ed19ab44aebdb37e433f1803a6d8b022fa35b31c91f6ffa`), the +24 files under `packages/bus/`. Packet `agents/rocko/work/slice1-s2-r2/`, +`BUILD.md` sha256 `cfcef300…`, `build.patch` sha256 `40d7e838…`. Round 1 +is `review-r1.md` in this directory. + +Verdict: **approve.** R1 to R5 are fixed, and so are notes 4, 5 and 6. I +reran every round 1 probe and added new ones, and each fix holds. One +finding needs Sage rather than Rocko. R1's fix, which I asked for, lets an +agent run on this machine pass the human proof with two commands. That +is inside the limit the README states, but round 1 blocked the same +command, and my round 1 review said the fix wouldn't weaken anything. That +was wrong. Details under "For Sage". + +## What I checked + +- The three packet hashes match Rocko's message. `build.patch` applies at + `fef4b362` and adds only `packages/bus/`; the manifest checks 24/24. +- Nine files changed from round 1: `README.md`, four in `src/` + (`broker`, `credentials`, `human`, `process`) and their four test files. + I read every source and README hunk. +- Tests: 43/43 on Node 26.8.1 (`node26-r2.txt`). In `node:24` (24.21.0), + with no network, a non-root UID and the package mounted read-only in the + repository layout, also 43/43 (`node24-r2.txt`). +- Rocko's round 1 contract script passes against S1 round 2 plus this + candidate (`s1r2-contract-r2.txt`). + +## Required items from round 1 + +**R1, real terminals.** An environ read that fails with EACCES now sets +`env` to null. The walk skips only the marker check for that ancestor; the +command check and the launch-registry check still run. Any other read +error still refuses, and the CLI process itself still needs a readable +nonce. `terminal-walk-r2.txt` walks the real ancestry of terminal 8357 up +to `plasmalogin` 2173, with only the CLI hop synthetic, and passes. +`readProcess(1)` returns `env: null`. Rocko's test reads the real pid 1. + +**R2, revoked run.** `role.claim` refuses a run with a `revoke` row. In my +round 1 probe P1, the revoked `pm-run` now gets `run-revoked` and the +replacement claims (`probes-r1-rerun.txt`). After a store close and a new +Broker on the same data, the revoked run still gets `run-revoked` and the +replacement still claims (`probes-r2.txt`). + +**R3, self-arbiter.** When a cross-role route names the raiser, it goes +to `human` and the class stays `cross-role`. For both cto +(`task.scope.change`, technical) and pm (`task.priority.change`, +delivery): `route_to` is `human`, the raiser and the other role both get +`human-required` on resolve, and `authorize` refuses with +`decision-not-approved` until the human resolves. After that it +authorizes. A coder's `task.scope.change` still routes to cto, and cto's +resolution authorizes it. Round 1's P2 now fails at the self-resolve, +which is the intended result. + +**R4, `bindLaunch` over IPC.** A duplicate run gets +`{ok:false,error:'duplicate-run'}`, a malformed record gets +`invalid-launch-process`, and a later valid bind still succeeds. A second +`boot` still exits 2 (`probes-r2.txt`). + +**R5, mutation evidence.** Rocko's new runner keeps the repository +layout, requires a clean baseline and a no-op control, and counts only +new failures. The packet retracts round 1's 11/11 claim in writing. +`mutations-dw2.py` runs Rocko's 17 cases plus six of mine against a clean +baseline. The no-op is not killed, all 17 of Rocko's are killed, and five +of my six are killed (`mutations-dw2.txt`): + +| My mutant | Result | +|---|---| +| every environ read error skips markers, not only EACCES | killed | +| command check skipped for a null-env ancestor | killed | +| launch-registry check skipped for a null-env ancestor | killed | +| `in_reply_to` and `corrects` skip `identifier()` | killed | +| token minimum 15 instead of 16 | killed | +| the self-route rule applied to every class, not only cross-role | survives | + +The survivor is note 2 below. + +## Notes 4 to 6 from round 1 + +- 4: `in_reply_to: ""` returns `invalid-request` (P5). +- 5: tokens shorter than 16 characters refuse, after the LF trim. +- 6: a failed refusal-evidence write throws `storage-unavailable`. + Rocko's `refusal-error` mutant shows a test catches it. + +## For Sage + +**The human proof and a reparented CLI.** With R1 in place, an agent run +on this machine passes the proof by running + + setsid -f env -i PATH="$PATH" node human-cli.mjs + +`escape-r2.txt` ran it from my own session, which has `CLAUDECODE` set, +against a scratch broker. The CLI got `[]` back from `inbox`, so the +broker bound it as Jason. `setsid -f` moves the CLI under +`systemd --user` (1274), the nearest subreaper above my session +(`agent-ancestry-r2.txt`). Its environ is unreadable, so the marker check +skips it, its command is `systemd`, and its parent is pid 1. `env -i` +strips the markers from the CLI. Without `env -i`, or without `setsid`, +the CLI is still refused. + +Round 1 refused that same command only because of `systemd --user`'s +unreadable environ, so that refusal was incidental. A CLI reparented +straight to pid 1 with a clean environment passed in round 1 too: the +walk stops at a parent of 1 and never reads it (`pid1-reparent.txt`, both +rounds). An agent with no subreaper above it had this path all along. + +The README already places reparenting inside the "cooperative trust +limit". The slice 1 brief and REQ-DEC-3 say human resolution comes "only +from a CLI process outside any agent run", and no lead decision records +that the requirement holds only against agents that don't try to escape. +I don't think a same-UID `/proc` check can do better. Every process an +agent starts can drop its markers, and each ancestor test I can think of +has a way around it. Narrowing the EACCES skip to other-UID +ancestors would close the `systemd --user` path here, but terminals that +run under `systemd --user` would then fail as they did in round 1, and +the pid 1 path stays open. + +My recommendation: record a lead decision that REQ-DEC-3 in slice 1 is +cooperative, meaning it stops an agent that runs `mosaic decide` +directly but not one that deliberately escapes. S6 should launch managed +agents where this can't work, for example a separate PID namespace or +UID with the broker socket not mounted. The T3 seats run unsandboxed and +can do this today. This doesn't block S2: Rocko built what the review +asked for, and the README states the limit. + +**Still open from round 1.** One gated approval still authorizes the same +action without limit (note 1, P3 unchanged). It needs a ruling before S3 +and S6 call `authorize`. R3 implements my default, `human`; Rocko is +waiting on your ruling if you want the other arbiter instead. + +## Notes, not blocking + +1. Rocko's packet includes two R2 contract lines ("loadBusiness refuses + launch.by…" and "narrowed launch is null…") whose script isn't in the + packet. The round 1 script reproduces; those two lines don't. +2. No test checks `route_to` on a within-role decision. With the + `cls === 'cross-role'` guard removed, a cto within-role decision records + `route_to: human` instead of `cto` and all 43 tests pass + (`within-role-route.txt`). One assertion fixes it. +3. `Broker.bindLaunch` adds the run key before the secret check and the + launch-record comparison (unchanged from round 1). A record refused on + either check uses up the run name, and a corrected retry gets + `duplicate-run`. That fails closed. S6 should know it before it + writes retries. I found this by reading the code; I didn't run it. +4. The broad suite receipts are round 1's, as `BUILD.md` says. Sage's + integration gate still covers every `scripts/test-*.sh`. +5. Round 1 notes 2, 3, 7 and 8 stand as written. Note 7's S3 work stays + with me. + +## Files added for round 2 + +- `review-r2.md`, `comment-r2.md` +- `node26-r2.txt`, `node24-r2.txt`, `s1r2-contract-r2.txt` +- `probes-r1-rerun.txt`: round 1's P1 to P6 against this candidate +- `probes-r2.test.mjs`, `probes-r2.txt`: R2 across restart, R3 for both + arbiters and a coder, R4 later binds and protocol exit +- `terminal-walk-r2.txt`: R1 on the real ancestry, and `readProcess(1)` +- `escape-r2.mjs`, `escape-r2.txt`, `agent-ancestry-r2.txt`, + `pid1-reparent.mjs`, `pid1-reparent.txt`: the reparenting finding +- `mutations-dw2.py`, `mutations-dw2.txt` +- `within-role-route.mjs`, `within-role-route.txt`: note 2 + +The probe scripts run from a `dw/` directory beside `packages/bus/src`; +`pid1-reparent.mjs` and `within-role-route.mjs` use absolute scratch +paths. diff --git a/agents/darkwing/work/slice1-s2-review/s1r2-contract-r2.txt b/agents/darkwing/work/slice1-s2-review/s1r2-contract-r2.txt new file mode 100644 index 00000000..61581769 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/s1r2-contract-r2.txt @@ -0,0 +1,2 @@ +s2-manifest-ok +PASS actual S1 validate/resolve -> S2 normalize/start -> socket claim/message; scoped fixture token callback; launch classification diff --git a/agents/darkwing/work/slice1-s2-review/terminal-walk-r2.txt b/agents/darkwing/work/slice1-s2-review/terminal-walk-r2.txt new file mode 100644 index 00000000..66a4f1fe --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/terminal-walk-r2.txt @@ -0,0 +1,7 @@ +readProcess 8357 ok alacritty +readProcess 4295 ok Hyprland +readProcess 4152 ok /usr/bin/start-hyprland +readProcess 4136 ok /usr/lib/plasmalogin-helper +readProcess 2173 ok /usr/bin/plasmalogin +verifyHuman from a real alacritty ancestry: {"business":"demo"} +readProcess(1) /usr/lib/systemd/systemd env null uid 0 diff --git a/agents/darkwing/work/slice1-s2-review/within-role-route.mjs b/agents/darkwing/work/slice1-s2-review/within-role-route.mjs new file mode 100644 index 00000000..a7d9c376 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/within-role-route.mjs @@ -0,0 +1,9 @@ +import { mkdtempSync } from 'node:fs'; import { join } from 'node:path'; +import { Store } from '/home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/store.mjs'; +import { Broker } from '/home/jwoltje/darkwing-scratch/s2-r2/packages/bus/src/broker.mjs'; +const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'cto' }, roles: { cto: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } } } } }; +const store = new Store(mkdtempSync('/home/jwoltje/darkwing-scratch/tmp/wr-')); const b = new Broker({ store, businesses }); +const c = b.bindLaunch({ business: 'demo', role: 'cto', run: 'r', harness: 'pi' }); b.request(c, { verb: 'role.claim', args: {} }); +const d = b.request(c, { verb: 'decision.raise', args: { action: 'task.create', question: 'q', options: [{ key: 'yes', text: 'y' }, { key: 'no', text: 'n' }], recommendation: 'yes', blocking: false, choice: 'yes' } }); +console.log('within-role class', d.class, 'route_to', d.route_to, 'status', d.status ?? '(n/a)'); +store.close(); diff --git a/agents/darkwing/work/slice1-s2-review/within-role-route.txt b/agents/darkwing/work/slice1-s2-review/within-role-route.txt new file mode 100644 index 00000000..c5dcd63e --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/within-role-route.txt @@ -0,0 +1,2 @@ +within-role class within-role route_to cto status (n/a) +mutant: within-role class within-role route_to human status (n/a)