From 79699c143be4c6d708331241ac71abae9b9ce991 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 23:13:35 -0500 Subject: [PATCH] docs(review): row 37 S2 round 1, changes (darkwing) Co-Authored-By: Claude Opus 5.5 --- .../work/slice1-s2-review/ancestry.txt | 8 + .../darkwing/work/slice1-s2-review/comment.md | 33 ++++ .../slice1-s2-review/flattened-baseline.txt | 85 ++++++++++ .../work/slice1-s2-review/human-escape.mjs | 19 +++ .../work/slice1-s2-review/human-escape.txt | 1 + .../work/slice1-s2-review/mutations-dw.py | 22 +++ .../work/slice1-s2-review/mutations-dw.txt | 13 ++ .../darkwing/work/slice1-s2-review/node24.txt | 45 ++++++ .../darkwing/work/slice1-s2-review/node26.txt | 45 ++++++ .../work/slice1-s2-review/probes.test.mjs | 85 ++++++++++ .../darkwing/work/slice1-s2-review/probes.txt | 29 ++++ .../work/slice1-s2-review/review-r1.md | 151 ++++++++++++++++++ .../work/slice1-s2-review/s1r2-contract.txt | 1 + .../work/slice1-s2-review/scan-bench.mjs | 15 ++ .../work/slice1-s2-review/scan-bench.txt | 3 + .../work/slice1-s2-review/terminal-walk.mjs | 10 ++ .../work/slice1-s2-review/terminal-walk.txt | 6 + 17 files changed, 571 insertions(+) create mode 100644 agents/darkwing/work/slice1-s2-review/ancestry.txt create mode 100644 agents/darkwing/work/slice1-s2-review/comment.md create mode 100644 agents/darkwing/work/slice1-s2-review/flattened-baseline.txt create mode 100644 agents/darkwing/work/slice1-s2-review/human-escape.mjs create mode 100644 agents/darkwing/work/slice1-s2-review/human-escape.txt create mode 100644 agents/darkwing/work/slice1-s2-review/mutations-dw.py create mode 100644 agents/darkwing/work/slice1-s2-review/mutations-dw.txt create mode 100644 agents/darkwing/work/slice1-s2-review/node24.txt create mode 100644 agents/darkwing/work/slice1-s2-review/node26.txt create mode 100644 agents/darkwing/work/slice1-s2-review/probes.test.mjs create mode 100644 agents/darkwing/work/slice1-s2-review/probes.txt create mode 100644 agents/darkwing/work/slice1-s2-review/review-r1.md create mode 100644 agents/darkwing/work/slice1-s2-review/s1r2-contract.txt create mode 100644 agents/darkwing/work/slice1-s2-review/scan-bench.mjs create mode 100644 agents/darkwing/work/slice1-s2-review/scan-bench.txt create mode 100644 agents/darkwing/work/slice1-s2-review/terminal-walk.mjs create mode 100644 agents/darkwing/work/slice1-s2-review/terminal-walk.txt diff --git a/agents/darkwing/work/slice1-s2-review/ancestry.txt b/agents/darkwing/work/slice1-s2-review/ancestry.txt new file mode 100644 index 00000000..b217c4e5 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/ancestry.txt @@ -0,0 +1,8 @@ +# alacritty terminal ancestry, 2026-10-04 +8357 uid=1000 environ=readable alacritty +4295 uid=1000 environ=readable Hyprland --watchdog-fd 4 +4152 uid=1000 environ=readable /usr/bin/start-hyprland +4136 uid=0 environ=DENIED /usr/lib/plasmalogin-helper --socket /tmp/plasmalogin-auth-2 +2173 uid=0 environ=DENIED /usr/bin/plasmalogin +# systemd user manager +1274 uid=1000 environ=DENIED /usr/lib/systemd/systemd --user --deserialize=8 diff --git a/agents/darkwing/work/slice1-s2-review/comment.md b/agents/darkwing/work/slice1-s2-review/comment.md new file mode 100644 index 00000000..2d4a5578 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/comment.md @@ -0,0 +1,33 @@ +Row 37, S2, round 1: **changes** (Darkwing) + +Candidate `candidate-manifest.sha256` `dbfd4a07…`, 24 files under +`packages/bus/`. Full record: +`agents/darkwing/work/slice1-s2-review/review-r1.md`. + +What holds on my machine: the patch applies at `fef4b362` and adds only +`packages/bus/`; the manifest checks 24/24; the schema is byte-identical to +v3b; the tests pass 36/36 on Node 26 and on Node 24; the S1 contract check +passes against S1 round 2; and all 11 mutants are killed against a clean +baseline. + +Required: +- **R1.** The human proof refuses every real terminal here. It refuses + when it can't read an ancestor's environ, and Jason's terminals descend + from root-owned `plasmalogin-helper`. `systemd --user` and `sshd` have + the same problem. Skip only the marker check for such an ancestor, and + test with real `/proc` (pid 1). +- **R2.** A revoked holder can claim its role again with its old + capability, before the replacement. `role.claim` should refuse a run + with a `revoke` row. +- **R3.** An arbiter resolves its own cross-role decision: cto with + `task.scope.change` via `domain: technical`, and pm with + `task.priority.change`. Route to `human` when the arbiter is the raiser. +- **R4.** A refused `bindLaunch` over IPC exits the broker with 2. Reply + and keep running. +- **R5.** `mutations.py` runs on a flattened copy where two tests fail + before any mutation, so `killed` is always true. Keep the repository + layout and compare against the baseline. + +Eight notes, none blocking. Two of them: one approval authorizes the same +action without limit (a lead question for Sage), and the per-commit +timestamp scan costs 116 ms at 100k events. diff --git a/agents/darkwing/work/slice1-s2-review/flattened-baseline.txt b/agents/darkwing/work/slice1-s2-review/flattened-baseline.txt new file mode 100644 index 00000000..3be8cab2 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/flattened-baseline.txt @@ -0,0 +1,85 @@ +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (327.283488ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (352.419355ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (334.415458ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (225.908515ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (168.374155ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (149.553744ms) +✔ task action subjects and linked decision trail are complete and ordered (120.891844ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (68.677116ms) +✔ business isolation includes inherited object names and cross-business message references (98.2943ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (187.772264ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (93.163536ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.499889ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (4.237347ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (32.669675ms) +✔ expiry refuses use and env references never become client data (1.225229ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.979725ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.848627ms) +✔ process reader gets own kernel identity without exposing environment values (0.898907ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (343.988351ms) +✖ startup token refusal returns safe code without value or partial listening broker (62.495699ms) +✖ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (60.149224ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (251.798699ms) +✔ trusted host registers later launches; socket clients never have a registration verb (250.627344ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (60.119362ms) +✔ v3b prototype refusals, views and append-only mutations (1437.059795ms) +✔ creates private WAL store and excludes a second writer until explicit close (274.812312ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (199.395272ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (258.703697ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (239.366608ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (125.181636ms) +✔ async transactions refuse before invoking their function (113.038006ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (328.571433ms) +✔ two wire claims serialize; a lost reply never automatically retries (232.268638ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (163.54825ms) +✔ client preserves UTF-8 when a response divides a multibyte character (13.212496ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (167.701574ms) +ℹ tests 36 +ℹ suites 0 +ℹ pass 34 +ℹ fail 2 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2231.263241 + +✖ failing tests: + +test at tests/process.test.mjs:67:1 +✖ startup token refusal returns safe code without value or partial listening broker (62.495699ms) + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + actual - expected + + + 'credential-location' + - 'credential-file' + ^ + + at TestContext. (file:///home/jwoltje/darkwing-scratch/tmp/tmp.K2k73RU0Vy/bus/tests/process.test.mjs:81:10) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'credential-location', + expected: 'credential-file', + operator: 'strictEqual', + diff: 'simple' + } + +test at tests/process.test.mjs:86:1 +✖ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (60.149224ms) + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + false !== true + + at TestContext. (file:///home/jwoltje/darkwing-scratch/tmp/tmp.K2k73RU0Vy/bus/tests/process.test.mjs:102:10) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: 'strictEqual', + diff: 'simple' + } diff --git a/agents/darkwing/work/slice1-s2-review/human-escape.mjs b/agents/darkwing/work/slice1-s2-review/human-escape.mjs new file mode 100644 index 00000000..002e603a --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/human-escape.mjs @@ -0,0 +1,19 @@ +// The human CLI started as a direct child of an agent run is refused. +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +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)); +const req = JSON.stringify({ business: 'demo', verb: 'launch.revoke' }); +try { + const child = spawn(process.execPath, [cli, rt.path]); let text = ''; + child.stdout.on('data', (b) => (text += b)); child.stderr.on('data', (b) => (text += b)); child.stdin.end(req); + const status = await new Promise((r) => child.on('close', r)); + console.log('direct child of this agent run:', status, text.trim()); +} catch (e) { console.log('error', e.code ?? e.message); } +finally { await rt.close(); rmSync(root, { recursive: true, force: true }); } diff --git a/agents/darkwing/work/slice1-s2-review/human-escape.txt b/agents/darkwing/work/slice1-s2-review/human-escape.txt new file mode 100644 index 00000000..40a264f2 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/human-escape.txt @@ -0,0 +1 @@ +direct child of this agent run: 2 human-required diff --git a/agents/darkwing/work/slice1-s2-review/mutations-dw.py b/agents/darkwing/work/slice1-s2-review/mutations-dw.py new file mode 100644 index 00000000..7a89d2e2 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/mutations-dw.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# Rocko's 11 cases, run in the repository layout, each compared against an unmutated baseline. +import pathlib,tempfile,shutil,subprocess,json,re,sys,importlib.util +spec=importlib.util.spec_from_file_location('m',sys.argv[1]);src=open(sys.argv[1]).read() +cases=eval(src[src.index('cases=')+6:src.index(']\nresults')+1]) +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=120) + 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-mut-') 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)})) + out=[] + for name,file,old,new in cases: + p=dest/'src'/file;b=p.read_text();pat=r'\s*'.join(re.escape(c) for c in old if not c.isspace());assert len(re.findall(pat,b))==1,name + p.write_text(re.sub(pat,lambda m:new,b,count=1)) + try: code,f=run(dest) + finally: p.write_text(b) + extra=sorted(f-base);out.append({'mutation':name,'exit':code,'killed':bool(extra),'new_failures':extra}) + print(json.dumps(out[-1])) + print('killed',sum(x['killed'] for x in out),'of',len(out)) diff --git a/agents/darkwing/work/slice1-s2-review/mutations-dw.txt b/agents/darkwing/work/slice1-s2-review/mutations-dw.txt new file mode 100644 index 00000000..8dbae00e --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/mutations-dw.txt @@ -0,0 +1,13 @@ +{"baseline_exit": 0, "baseline_failures": []} +{"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": ["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", "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": ["human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse"]} +killed 11 of 11 diff --git a/agents/darkwing/work/slice1-s2-review/node24.txt b/agents/darkwing/work/slice1-s2-review/node24.txt new file mode 100644 index 00000000..6b78f060 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/node24.txt @@ -0,0 +1,45 @@ +v24.21.0 +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (245.06358ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (1142.141856ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (2394.791078ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (1075.893721ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (871.842456ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (513.357109ms) +✔ task action subjects and linked decision trail are complete and ordered (1120.610371ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (707.229775ms) +✔ business isolation includes inherited object names and cross-business message references (713.088638ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (966.612104ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (449.349586ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (5.421455ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (12.385838ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (4.761414ms) +✔ expiry refuses use and env references never become client data (1.151008ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.879108ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (3.611938ms) +✔ process reader gets own kernel identity without exposing environment values (0.778583ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (259.158665ms) +✔ startup token refusal returns safe code without value or partial listening broker (51.170183ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (857.29398ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (940.56401ms) +✔ trusted host registers later launches; socket clients never have a registration verb (1251.441474ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (64.802777ms) +✔ v3b prototype refusals, views and append-only mutations (5692.249049ms) +✔ creates private WAL store and excludes a second writer until explicit close (173.827041ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (449.7513ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (1532.33713ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (1265.555561ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (664.51824ms) +✔ async transactions refuse before invoking their function (502.005626ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (216.996179ms) +✔ two wire claims serialize; a lost reply never automatically retries (455.787446ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (966.353921ms) +✔ client preserves UTF-8 when a response divides a multibyte character (33.714587ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (1509.265511ms) +ℹ tests 36 +ℹ suites 0 +ℹ pass 36 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 10356.911701 diff --git a/agents/darkwing/work/slice1-s2-review/node26.txt b/agents/darkwing/work/slice1-s2-review/node26.txt new file mode 100644 index 00000000..87770460 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/node26.txt @@ -0,0 +1,45 @@ +v26.8.1 +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (1299.252328ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (1784.874653ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (1761.468106ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (300.172157ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (1225.044933ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (122.399485ms) +✔ task action subjects and linked decision trail are complete and ordered (110.240069ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (60.441721ms) +✔ business isolation includes inherited object names and cross-business message references (157.572812ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (209.508135ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (331.218276ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (4.086579ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (24.227391ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (61.833018ms) +✔ expiry refuses use and env references never become client data (4.965603ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (19.912455ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.929166ms) +✔ process reader gets own kernel identity without exposing environment values (0.852077ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (1234.027941ms) +✔ startup token refusal returns safe code without value or partial listening broker (46.596127ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (1100.730149ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (725.201808ms) +✔ trusted host registers later launches; socket clients never have a registration verb (1003.357659ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (60.32702ms) +✔ v3b prototype refusals, views and append-only mutations (6351.025319ms) +✔ creates private WAL store and excludes a second writer until explicit close (1095.939265ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (1097.529651ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (1092.896429ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (985.459125ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (536.247869ms) +✔ async transactions refuse before invoking their function (154.406673ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (1262.225842ms) +✔ two wire claims serialize; a lost reply never automatically retries (1374.708433ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (575.311861ms) +✔ client preserves UTF-8 when a response divides a multibyte character (12.372236ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (934.564269ms) +ℹ tests 36 +ℹ suites 0 +ℹ pass 36 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 7446.300331 diff --git a/agents/darkwing/work/slice1-s2-review/probes.test.mjs b/agents/darkwing/work/slice1-s2-review/probes.test.mjs new file mode 100644 index 00000000..9791eb2a --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/probes.test.mjs @@ -0,0 +1,85 @@ +// Darkwing review 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' }]; +// Data-model table: cto crossRole task.scope.change, pm crossRole task.priority.change. +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'] } } } } }; +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'dw-bus-')); + const store = new Store(root); + const b = new Broker({ store, businesses }); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + 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 }; +} +const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); +const raise = (b, cap, action, extra = {}) => call(b, cap, 'decision.raise', { action, question: 'Allow?', options, recommendation: 'no', blocking: false, ...extra }); + +test('P1 revoked run reclaims its role with its old capability', (t) => { + const { b, 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 }); + let out; try { out = call(b, p, 'role.claim'); } catch (e) { out = e.code; } + console.log('P1 revoked pm reclaims ->', JSON.stringify(out)); + const p2 = agent('pm', 'pm-next'); + let out2; try { out2 = call(b, p2, 'role.claim'); } catch (e) { out2 = e.code; } + console.log('P1 replacement pm claim ->', JSON.stringify(out2)); +}); +test('P2 arbiter resolves its own cross-role decision', (t) => { + const { b, agent } = setup(t), cto = agent('cto'), pm = agent('pm'); + call(b, cto, 'role.claim'); call(b, pm, 'role.claim'); + const d = raise(b, cto, 'task.scope.change', { domain: 'technical', target: 'x1' }); + console.log('P2 cto raise route_to', d.route_to); + call(b, cto, 'decision.resolve', { id: d.id, choice: 'yes' }); + console.log('P2 cto authorize ->', JSON.stringify(b.authorize(cto, 'task.scope.change', { decision: d.id, target: 'x1' }))); + const d2 = raise(b, pm, 'task.priority.change', { target: 'x2' }); + console.log('P2 pm raise route_to', d2.route_to); + call(b, pm, 'decision.resolve', { id: d2.id, choice: 'yes' }); + console.log('P2 pm authorize ->', JSON.stringify(b.authorize(pm, 'task.priority.change', { decision: d2.id, target: 'x2' }))); +}); +test('P3 one approval authorizes repeatedly', (t) => { + const { b, agent, human } = setup(t), c = agent('coder'); + call(b, c, 'role.claim'); + const d = raise(b, c, 'deploy', { target: 'v1' }); + call(b, human, 'decision.resolve', { id: d.id, choice: 'yes' }); + for (let i = 0; i < 3; i++) console.log('P3 authorize', i, JSON.stringify(b.authorize(c, 'deploy', { decision: d.id, target: 'v1' }))); +}); +test('P4 refusals from a reader and a non-holder append events', (t) => { + const { b, store, agent } = setup(t), r = b.bindReader({ business: 'demo' }), c = agent('coder'); + const before = store.get('SELECT count(*) n FROM events').n; + for (let i = 0; i < 5; i++) { try { call(b, r, 'role.claim'); } catch {} try { call(b, c, 'message.send', { to: 'pm', body: 'x' }); } catch {} } + console.log('P4 events added by 10 refusals:', store.get('SELECT count(*) n FROM events').n - before); +}); +test('P5 empty in_reply_to', (t) => { + const { b, store, agent } = setup(t), c = agent('coder'); + call(b, c, 'role.claim'); + let out; try { out = call(b, c, 'message.send', { to: 'pm', body: 'x', in_reply_to: '' }); out = store.get('SELECT in_reply_to FROM messages WHERE id=?', out.id); } catch (e) { out = e.code; } + console.log('P5 ->', JSON.stringify(out)); +}); +test('P6 a refused bindLaunch over IPC stops the broker process', 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 }); }); + let m = once(child, 'message'); + child.send({ op: 'boot', config: { dataRoot: root, businesses, launches: [] } }); + console.log('P6 boot', (await m)[0].ok); + const rec = { business: 'demo', role: 'cto', run: 'r1', harness: 'pi', pid: process.pid, startTime: '1' }; + m = once(child, 'message'); child.send({ op: 'bindLaunch', record: rec }); console.log('P6 first bind', (await m)[0].ok); + const exit = once(child, 'exit'); + m = once(child, 'message'); child.send({ op: 'bindLaunch', record: rec }); console.log('P6 duplicate bind', JSON.stringify((await m)[0])); + const timer = setTimeout(() => child.kill('SIGKILL'), 3000); + console.log('P6 exit code after refused bind', (await exit)[0]); clearTimeout(timer); +}); diff --git a/agents/darkwing/work/slice1-s2-review/probes.txt b/agents/darkwing/work/slice1-s2-review/probes.txt new file mode 100644 index 00000000..2e2f4eed --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/probes.txt @@ -0,0 +1,29 @@ +P1 revoked pm reclaims -> {"role":"pm","run":"pm-run"} +P1 replacement pm claim -> "role-already-held" +P2 cto raise route_to cto +P2 cto authorize -> {"class":"cross-role","decision":"3d7d663d-9e2a-4a89-8c30-8bcc8a9e823c"} +P2 pm raise route_to pm +P2 pm authorize -> {"class":"cross-role","decision":"b4b8950a-2960-45d3-9b7c-ecd6a3e5889f"} +P3 authorize 0 {"class":"gated","decision":"a5cc81c6-9708-4916-980f-53925572d8f5"} +P3 authorize 1 {"class":"gated","decision":"a5cc81c6-9708-4916-980f-53925572d8f5"} +P3 authorize 2 {"class":"gated","decision":"a5cc81c6-9708-4916-980f-53925572d8f5"} +P4 events added by 10 refusals: 10 +P5 -> "storage-refused" +✔ P1 revoked run reclaims its role with its old capability (99.988778ms) +✔ P2 arbiter resolves its own cross-role decision (91.589345ms) +✔ P3 one approval authorizes repeatedly (76.263444ms) +✔ P4 refusals from a reader and a non-holder append events (99.06011ms) +✔ P5 empty in_reply_to (49.308542ms) +P6 boot true +P6 first bind true +P6 duplicate bind {"ok":false,"error":"duplicate-run"} +P6 exit code after refused bind 2 +✔ P6 a refused bindLaunch over IPC stops the broker process (121.706676ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 6 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 609.863753 diff --git a/agents/darkwing/work/slice1-s2-review/review-r1.md b/agents/darkwing/work/slice1-s2-review/review-r1.md new file mode 100644 index 00000000..6fd0525d --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/review-r1.md @@ -0,0 +1,151 @@ +# Row 37, S2 bus and broker core, round 1 review (Darkwing) + +Issue #1519. Candidate: Rocko's `candidate-manifest.sha256` (sha256 +`dbfd4a074601cf49b01abcae88eef15a2b30c28e6992bd7eb559d0c6c2dc6b6e`), the +24 files under `packages/bus/`. Packet `agents/rocko/work/slice1-s2/`, +`BUILD.md` sha256 `35ec7d92…`, `build.patch` sha256 `c2c75d71…`. + +Verdict: **changes.** The store, the transport and the authority checks +are careful work, and most of what Rocko claims holds up on my machine. +Three defects in the broker's own rules need fixing first, and so does the +human proof. On this machine the proof refuses Jason from every real +terminal. One piece of packet evidence also needs redoing. + +## What I checked and what holds + +- `build.patch` applies to a fresh clone at `fef4b362` and adds only + `packages/bus/`. The manifest checks 24/24. +- `schema.sql` is byte-identical to `slice1-proto/schema-v3b.sql` + (`179ffe35…`). +- `node --test packages/bus/tests/*.test.mjs`: 36/36 on Node 26.8.1 + (`node26.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 + 36/36 (`node24.txt`). +- Rocko's `s1-contract-check.mjs` passes against S1 round 2 + (`build-r2.patch`, manifest 34/34) plus this candidate + (`s1r2-contract.txt`). Round 2's narrowing doesn't break S2. +- Rocko's 11 mutants, rerun in the repository layout against a clean + baseline: each one fails at least one test the baseline passes, so 11 + of 11 are killed (`mutations-dw.txt`). The packet's own run doesn't show + that; see R5. +- From inside my agent run, `human-cli.mjs` as a direct child is refused + with `human-required` (`human-escape.txt`). The reader capability + can't mutate, the client never retries, and a dropped reply reports + `outcome-unknown`. The tests show all three, and I read the code paths. + +## Required + +**R1. The human proof refuses every real terminal on this machine.** +`readProcess` reads `/proc//environ` for every ancestor and refuses +when it can't. On Jason's desktop each alacritty window descends from +`plasmalogin-helper` and `plasmalogin`, both owned by root, and a +non-root user can't read their environ. `systemd --user` is the same: it +runs as UID 1000, but its environ is unreadable too, and it adopts any +detached or `systemd-run` process. `terminal-walk.mjs` reads the real +ancestry of terminal 8357 and gives the CLI hop only as a fixture. It +ends `human-required` (`terminal-walk.txt`, `ancestry.txt`). SSH logins +hit the same wall at `sshd`. The tests missed this because every +positive case uses a synthetic ancestry. + +Fix: an ancestor whose environ can't be read (EACCES) shouldn't refuse. +Its `stat` and `cmdline` can still be read, so keep the command check and +the launch-registry check on it, and skip only the marker check. The CLI +process itself still needs a readable environ for the nonce. Test it on +real `/proc` with pid 1 (environ unreadable to a non-root test), plus a +synthetic chain whose top two ancestors throw EACCES. The agent-side +refusals hold as they are, since a managed run is a same-UID dumpable +process. This doesn't weaken the cooperative boundary README already +states. + +**R2. A revoked holder can take its role back.** After a gated revoke the +revoked run's capability still works for `role.claim`. In `probes.txt` +P1, `pm-run` is revoked, claims `pm` again and gets it, and the +replacement launch then gets `role-already-held`. A revoke that Jason +approved is undone by the stuck session waking up. The existing revoke +test claims the replacement first, so it never sees this. + +Fix: `role.claim` refuses a run that has a `revoke` row for that business +and role. That survives a restart, which dropping capabilities wouldn't. +Test the revoked run claiming before the replacement does. + +**R3. An arbiter approves its own cross-role decision.** The raiser +chooses `domain`, and `route_to` is that domain's arbiter, even when the +arbiter is the raiser. With the data model's own table (cto crossRole +`task.scope.change`, technical arbiter cto; pm crossRole +`task.priority.change`, delivery arbiter pm), each role raises, resolves +and authorizes its own cross-role action (P2). Cross-role turns into +within-role for both arbiters. The data model I wrote didn't cover this, +so the gap started with me, not Rocko. + +Fix: when the route equals the raising role, route to `human`. That +fails closed, and Sage can pick the other arbiter instead if they'd +rather; either way, add a test for each arbiter. + +**R4. One refused `bindLaunch` stops the broker.** In `process.mjs` every +caught error replies and then calls `close(2)`. A duplicate run over IPC +gets `{ok:false,error:'duplicate-run'}` and the broker exits 2 (P6). +Every agent's capability goes with it. S6 binds launches over this +channel, so a retried launch would take the bus down. Fix: a refused +`bindLaunch` replies and keeps running; only boot and protocol failures +exit. Test both. + +**R5. The mutation evidence.** `mutations.py` copies the package +flattened to `/bus`. There the runtime infers the repository root as +the temp parent, which also holds the fixture tokens, so two process +tests fail before any mutation: "startup token refusal…" and "loaded +fixture token is absent…" (`flattened-baseline.txt`). Every entry in +`mutations.json` lists both, and `killed` is `exit != 0`, so the file +would say 11/11 for mutations that change nothing. My rerun shows the +mutants really are killed. Fix the script so it keeps the `packages/bus` +layout and counts a mutant killed only on a failure the baseline doesn't +have. `mutations-dw.py` does both and can be reused. + +## Notes, not blocking + +1. **Approval is reusable.** One resolved gated decision authorizes the + same action, target and run without limit: three `deploy` calls on + one "yes" in P3. README says so and leaves exactly-once to S3 and S6. + Whether a gated approval is single-use is a lead question, and it + should be answered before S3 and S6 call `authorize`. I'll raise it + with Sage. +2. **The timestamp scan grows with the database.** `#verifyTimes` scans + every timed table on every commit, reads included, synchronously. An + empty transaction takes 14.7 ms at 10k events, 116 ms at 100k and + 462 ms at 500k (`scan-bench.txt`). Fine for slice 1. Near 5M events it + would pass the 5 s client timeout. Checking only rows above each + table's starting `seq` keeps the same guarantee. A follow-up row would + do. +3. Refusals from a reader or a non-holder each append an `action.refused` + event: 10 refusals gave 10 events (P4). That's acceptable under the + cooperative boundary; nothing limits the rate. +4. `in_reply_to: ""` reaches the foreign key and returns `storage-refused` + (P5). `identifier()` on `in_reply_to` and `corrects` would return + `invalid-request`. +5. `assertClean` refuses any value containing a token as a substring. + Nothing stops a one-character token, which would refuse almost + everything. A minimum token length (16 or so) costs nothing. +6. If the refusal-evidence transaction in `request()` throws, the raw + error reaches `serve`, which reports `invalid-envelope`. +7. For S3, my row: there is no registration point for task verbs. + `#dispatch` is a closed switch, `request()` runs synchronously inside a + transaction, and Vikunja calls are async. Nothing lets the `@sync` + identity write `task_snapshots` or `task.changed.external`, because + `recordEvent` needs an agent holder. The S3 brief lets me touch + `packages/bus` to register verbs, so I'll add these there and + coordinate with Rocko. `decision.resolve.technical` is in the + vocabulary but nothing checks it yet. +8. A message delivered to a holder that dies before `message.read` is + never delivered again, and there is no `failed` write path. The trail + shows it. S4 should show it too. + +## Files here + +- `review-r1.md`, `comment.md` +- `probes.test.mjs`, `probes.txt`: P1 to P6 +- `terminal-walk.mjs`, `terminal-walk.txt`, `ancestry.txt`: R1 +- `human-escape.mjs`, `human-escape.txt`: the direct-child refusal +- `scan-bench.mjs`, `scan-bench.txt`: note 2 +- `mutations-dw.py`, `mutations-dw.txt`, `flattened-baseline.txt`: R5 +- `node24.txt`, `node26.txt`, `s1r2-contract.txt` + +The probe scripts run from a `dw/` directory beside `packages/bus/src`. diff --git a/agents/darkwing/work/slice1-s2-review/s1r2-contract.txt b/agents/darkwing/work/slice1-s2-review/s1r2-contract.txt new file mode 100644 index 00000000..8e31cd8a --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/s1r2-contract.txt @@ -0,0 +1 @@ +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/scan-bench.mjs b/agents/darkwing/work/slice1-s2-review/scan-bench.mjs new file mode 100644 index 00000000..a3cd3d2d --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/scan-bench.mjs @@ -0,0 +1,15 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { Store } from '../src/store.mjs'; +const root = mkdtempSync(join(tmpdir(), 'dw-bench-')); +const store = new Store(root); +let ms = Date.parse('2026-10-01T00:00:00.000Z'), have = 0; +for (const target of [10000, 100000, 500000]) { + store.transaction(() => { for (; have < target; have++) store.run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)', randomUUID(), new Date(ms++).toISOString(), 'demo', 'action.allowed', 'pm', 'pm-run', null, '{"action":"routine"}'); }); + const t0 = performance.now(); + for (let i = 0; i < 5; i++) store.transaction(() => {}); + console.log(`events=${target} empty transaction ms=${((performance.now() - t0) / 5).toFixed(1)}`); +} +store.close(); rmSync(root, { recursive: true, force: true }); diff --git a/agents/darkwing/work/slice1-s2-review/scan-bench.txt b/agents/darkwing/work/slice1-s2-review/scan-bench.txt new file mode 100644 index 00000000..1d270288 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/scan-bench.txt @@ -0,0 +1,3 @@ +events=10000 empty transaction ms=14.7 +events=100000 empty transaction ms=116.3 +events=500000 empty transaction ms=461.8 diff --git a/agents/darkwing/work/slice1-s2-review/terminal-walk.mjs b/agents/darkwing/work/slice1-s2-review/terminal-walk.mjs new file mode 100644 index 00000000..9da18829 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/terminal-walk.mjs @@ -0,0 +1,10 @@ +// Real /proc reads for every ancestor; only the CLI hop itself is synthetic. +import { readProcess, verifyHuman } from '../src/human.mjs'; +const terminal = Number(process.argv[2]); +const nonce = 'a'.repeat(64), cliPath = '/fixture/human-cli.mjs'; +for (const pid of [terminal, 4295, 4152, 4136, 2173]) { + try { const p = readProcess(pid); console.log('readProcess', pid, 'ok', p.argv[0]); } catch (e) { console.log('readProcess', pid, e.code); } +} +const read = (pid) => pid === 999999 ? { pid, ppid: terminal, startTime: '1', uid: process.getuid(), argv: ['node', cliPath], env: { MOSAIC_BUS_CLI_NONCE: nonce } } : readProcess(pid); +try { console.log('verifyHuman from a real alacritty ancestry:', JSON.stringify(verifyHuman({ pid: 999999, startTime: '1', nonce, business: 'demo' }, { cliPath, readProcess: read }))); } +catch (e) { console.log('verifyHuman from a real alacritty ancestry:', e.code); } diff --git a/agents/darkwing/work/slice1-s2-review/terminal-walk.txt b/agents/darkwing/work/slice1-s2-review/terminal-walk.txt new file mode 100644 index 00000000..ce0e9506 --- /dev/null +++ b/agents/darkwing/work/slice1-s2-review/terminal-walk.txt @@ -0,0 +1,6 @@ +readProcess 8357 ok alacritty +readProcess 4295 ok Hyprland +readProcess 4152 ok /usr/bin/start-hyprland +readProcess 4136 human-required +readProcess 2173 human-required +verifyHuman from a real alacritty ancestry: human-required