docs(review): row 37 S2 round 2, approve (darkwing)

Round 2 of #1519: R1 to R5 fixed and reproduced, 43/43 on Node 26 and
Node 24, 22 of 23 mutants killed against a clean baseline. Records a
reparented-CLI path past the human proof for Sage's ruling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 23:31:49 -05:00
co-authored by Claude Opus 5.5
parent 79699c143b
commit 1e3c06c78b
18 changed files with 603 additions and 0 deletions
@@ -0,0 +1,5 @@
2275787 bash 1000
406108 claude 1000
609458 node-MainThread 1000
609414 node-MainThread 1000
1274 systemd 1000
@@ -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.
@@ -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 }); }
@@ -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
@@ -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))
@@ -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
@@ -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
@@ -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
@@ -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);
}
@@ -0,0 +1,2 @@
round 1: CLI with ppid 1, clean env -> {"business":"demo"}
round 2: CLI with ppid 1, clean env -> {"business":"demo"}
@@ -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.<anonymous> (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'
}
@@ -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);
});
@@ -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
@@ -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 <socket>
`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.
@@ -0,0 +1,2 @@
s2-manifest-ok
PASS actual S1 validate/resolve -> S2 normalize/start -> socket claim/message; scoped fixture token callback; launch classification
@@ -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
@@ -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();
@@ -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)