From 17481148e22009f6afa2420e725babd89a4f0662 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 23:10:26 -0500 Subject: [PATCH] docs(review): row 36 S1 round 2, approve (filbert) Co-Authored-By: Claude Opus 5.5 --- .../work/slice1-s1-review/mutants-r2.sh | 48 ++++ .../work/slice1-s1-review/probe-r2.mjs | 267 ++++++++++++++++++ .../work/slice1-s1-review/r2-mut-summary.txt | 26 ++ .../work/slice1-s1-review/r2-node24.txt | 10 + .../work/slice1-s1-review/r2-probe.txt | 53 ++++ .../work/slice1-s1-review/r2-suites.txt | 6 + .../work/slice1-s1-review/r2-v1-compat.txt | 17 ++ .../work/slice1-s1-review/review-r2.md | 129 +++++++++ 8 files changed, 556 insertions(+) create mode 100644 agents/filbert/work/slice1-s1-review/mutants-r2.sh create mode 100644 agents/filbert/work/slice1-s1-review/probe-r2.mjs create mode 100644 agents/filbert/work/slice1-s1-review/r2-mut-summary.txt create mode 100644 agents/filbert/work/slice1-s1-review/r2-node24.txt create mode 100644 agents/filbert/work/slice1-s1-review/r2-probe.txt create mode 100644 agents/filbert/work/slice1-s1-review/r2-suites.txt create mode 100644 agents/filbert/work/slice1-s1-review/r2-v1-compat.txt create mode 100644 agents/filbert/work/slice1-s1-review/review-r2.md diff --git a/agents/filbert/work/slice1-s1-review/mutants-r2.sh b/agents/filbert/work/slice1-s1-review/mutants-r2.sh new file mode 100644 index 00000000..de965930 --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/mutants-r2.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Round 2: round 1 mutants M1-M20 plus N1-N6, against the round 2 candidate. +# name|file|perl substitution +cd ~/filbert-scratch/r36b/repo +S=packages/business/src +L=~/filbert-scratch/r36b/logs +: > $L/mut-summary.txt +while IFS='|' read -r name file expr; do + [ -z "$name" ] && continue + rm -rf $S && cp -a ~/filbert-scratch/r36b/src-orig $S + perl -0pi -e "$expr" $S/$file + if cmp -s $S/$file ~/filbert-scratch/r36b/src-orig/$file; then echo "$name NOAPPLY" >> $L/mut-summary.txt; continue; fi + node --test packages/business/tests/ > $L/mut-$name.txt 2>&1 + f=$(grep -E '^ℹ fail' $L/mut-$name.txt | awk '{print $3}') + extra="" + if [ "$f" = 0 ]; then bash scripts/test-task.sh > $L/mut-$name-task.txt 2>&1; extra=" task:$(grep -E 'passed' $L/mut-$name-task.txt | tail -1)"; fi + echo "$name business_fail=$f$extra" >> $L/mut-summary.txt +done <<'LIST' +M1-vars-narrower-wider|vars.mjs|s/NETWORKS\.indexOf\(a\) <= NETWORKS\.indexOf\(b\)/NETWORKS.indexOf(a) >= NETWORKS.indexOf(b)/ +M2-resolve-network-wider|resolve.mjs|s/NETWORKS\.indexOf\(a\) <= NETWORKS\.indexOf\(b\)/NETWORKS.indexOf(a) >= NETWORKS.indexOf(b)/ +M3-cross-not-narrowed|resolve.mjs|s/crossRole = crossRole\.filter\(\(a\) => vars\["limits\.authority"\]\.includes\(a\)\);// +M4-layer-check-off|vars.mjs|s/if \(!entry\.layers\.includes\(layer\)\)/if (false)/ +M5-no-realpath-file|credentials.mjs|s/real = realpathSync\(ref\.file\);/real = ref.file;/ +M6-gated-only-allowed|role.mjs|s/if \(GATED_ONLY\.includes\(action\)\) refuse/if (false) refuse/ +M7-launcher-in-instances|business.mjs|s/if \(name === launch\.by\) refuse/if (false) refuse/ +M8-sync-bot-shared|business.mjs|s/role\.tracker && \(role\.tracker\.bot === tracker\.sync\.bot/false && (role.tracker.bot === tracker.sync.bot/ +M9-within-cross-overlap|role.mjs|s/if \(both\.length > 0\) refuse/if (false) refuse/ +M10-project-id-mismatch|resolve.mjs|s/\|\| declared\[0\] !== project\.id// +M11-token-empty-ok|credentials.mjs|s/if \(stat\.size === 0\) problems/if (false) problems/ +M12-token-uid-off|credentials.mjs|s/if \(stat\.uid !== uid\) problems/if (false) problems/ +M13-contract-symlink-ok|role.mjs|s/!stat\.isFile\(\) \|\| stat\.isSymbolicLink\(\) \|\| stat\.size === 0/!stat.isFile() || stat.size === 0/ +M14-vikunja-expired-ok|credentials.mjs|s/problems\.push\(`\$\{label\}: expired/warnings.push(`\${label}: expired/ +M15-credmap-not-exact|business.mjs|s/refuse\(`\$\{where\} must reference exactly/if (false) refuse(`\${where} must reference exactly/ +M16-tracker-without-vikunja|business.mjs|s/refuse\(`\$\{at\} has "tracker" but/if (false) refuse(`\${at} has "tracker" but/ +M17-max-ceiling-off|business.mjs|s/ \|\| count > ceiling// +M18-project-undeclared-instance|resolve.mjs|s/if \(!Object\.hasOwn\(business\.roles, name\)\) refuse\(`project/if (false) refuse(`project/ +M19-unknown-var-ok|vars.mjs|s/if \(!entry\) refuse\(`\$\{where\}: unknown variable/if (!entry) continue; if (false) refuse(`\${where}: unknown variable/ +M20-launch-gate-cross-only|resolve.mjs|s/withinRole = withinRole\.filter\(\(a\) => a !== "role\.launch"\);// +N1-launch-by-cross-ok|business.mjs|s/if \(!launcher\.authority\.withinRole\.includes\("role\.launch"\)\)/if (![...launcher.authority.withinRole, ...launcher.authority.crossRole].includes("role.launch"))/ +N2-launch-checks-cross|resolve.mjs|s/&& withinRole\.includes\("role\.launch"\) \?/\&\& (withinRole.includes("role.launch") || crossRole.includes("role.launch")) ?/ +N3-enoent-not-distinct|util.mjs|s/if \(error\.code === "ENOENT"\) refuse/if (false) refuse/ +N4-uid-check-off|business.mjs|s/if \(stat\.uid !== process\.getuid\(\)\) refuse/if (false) refuse/ +N5-mode-mask-group-only|business.mjs|s/\(stat\.mode & 0o022\)/(stat.mode \& 0o020)/ +N6-launch-any-instance|resolve.mjs|s/business\.launch && business\.launch\.by === instance && withinRole/business.launch \&\& withinRole/ +LIST +rm -rf $S && cp -a ~/filbert-scratch/r36b/src-orig $S +diff -r $S ~/filbert-scratch/r36b/src-orig && echo RESTORED +cat $L/mut-summary.txt diff --git a/agents/filbert/work/slice1-s1-review/probe-r2.mjs b/agents/filbert/work/slice1-s1-review/probe-r2.mjs new file mode 100644 index 00000000..c6dfa369 --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/probe-r2.mjs @@ -0,0 +1,267 @@ +// Row 36 round 2 probes (Filbert). Round 1 probe.mjs plus C10-C16 and E1-E8. PROBE_REPO picks the candidate clone. +import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, chmodSync, symlinkSync, renameSync, rmSync } from "node:fs"; +import { join } from "node:path"; +const R = process.env.PROBE_REPO ?? `${process.env.HOME}/filbert-scratch/r36b/repo`; +const B = await import(`${R}/packages/business/src/index.mjs`); +const H = await import(`${R}/packages/business/tests/helpers.mjs`); +const base = process.env.PROBE_WORK ?? `${process.env.HOME}/filbert-scratch/r36b/probe/work`; +mkdirSync(base, { recursive: true }); +let n = 0; +const out = (name, ok, detail = "") => console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` :: ${detail}` : ""}`); +const tryRun = (f) => { try { return { ok: true, v: f() }; } catch (e) { return { ok: false, e: e.message, code: e.exitCode }; } }; + +function setup(mut = (d) => d, project = null, rolesDir = null) { + const root = mkdtempSync(join(base, "b-")); + H.systemConfig(root); + const env = { MOSAIC_CONFIG: join(root, "config", "config.json") }; + const doc = mut(H.businessDoc(root)); + const dir = join(root, "config"); + H.writeJson(join(dir, "businesses", "acme.json"), doc); + if (project) H.writeJson(join(root, "project", ".mosaic", "project.json"), project); + rolesDir ??= join(R, "roles"); + return { root, env, dir, rolesDir }; +} +function load(s) { return B.loadBusiness("acme", { rolesDir: s.rolesDir, dir: s.dir, env: s.env }); } + +// A. example refuses as shipped, and each placeholder refuses alone. +{ + const ex = JSON.parse(readFileSync(`${R}/packages/business/examples/mosaic-stack.example.json`, "utf8")); + const r = tryRun(() => B.validateBusinessDocument(ex, "mosaic-stack.json", { rolesDir: `${R}/roles` })); + out("A1 example refuses as shipped", !r.ok, r.e); + const fixBots = structuredClone(ex); let id = 10; + fixBots.tracker.sync.botId = id++; for (const v of Object.values(fixBots.roles)) v.tracker.botId = id++; + const r2 = tryRun(() => B.validateBusinessDocument(fixBots, "mosaic-stack.json", { rolesDir: `${R}/roles` })); + out("A2 bot ids fixed, dates still placeholders: refuses", !r2.ok, r2.e); + const fixDates = JSON.parse(JSON.stringify(ex).replaceAll("YYYY-MM-DD", "2099-01-01")); + const r3 = tryRun(() => B.validateBusinessDocument(fixDates, "mosaic-stack.json", { rolesDir: `${R}/roles` })); + out("A3 dates fixed, bot ids 0: refuses", !r3.ok, r3.e); + const both = JSON.parse(JSON.stringify(fixBots).replaceAll("YYYY-MM-DD", "2099-01-01")); + const r4 = tryRun(() => B.validateBusinessDocument(both, "mosaic-stack.json", { rolesDir: `${R}/roles` })); + out("A4 both fixed: document validates (shape only; token files are a CLI check)", r4.ok, r4.e); +} + +// B. resolver precedence, intersect, refusals. +const sys = (root) => H.systemFor(root); +{ + const s = setup((d) => { d.vars["limits.tools"] = ["read", "grep", "bash", "write"]; d.vars["limits.network"] = "open"; + d.roles.coder.vars = { harness: "pi", "limits.tools": ["read", "bash", "edit", "write"], "limits.network": "open" }; return d; }, + { projectVersion: 1, id: "stack", vars: { "tracker.pollSeconds": 45, "limits.tools": ["read", "bash", "grep", "write"] }, + roles: { coder: { vars: { "tracker.pollSeconds": 20 } } } }); + const b = load(s); + const p = B.loadProject(join(s.root, "project")); + const r = B.resolveInstance({ system: sys(s.root), business: b, project: p, instance: "coder" }); + out("B1 replace: project roles. beats project beats business", r.vars["tracker.pollSeconds"] === 20, `${r.vars["tracker.pollSeconds"]} from ${r.provenance["tracker.pollSeconds"]}`); + out("B2 limits.tools intersects business ∩ project ∩ agent ∩ ceiling", JSON.stringify(r.limits.tools) === JSON.stringify(["read", "write", "bash"]), JSON.stringify(r.limits.tools)); + out("B3 limits.network open can't widen role api-only", r.limits.network === "api-only", r.limits.network); + const r0 = B.resolveInstance({ system: sys(s.root), business: b, instance: "coder" }); + out("B4 no project: business value", r0.vars["tracker.pollSeconds"] === 60, String(r0.vars["tracker.pollSeconds"])); + out("B5 default kept when unset", r0.vars["tracker.reconcileMinutes"] === 60 && r0.provenance["tracker.reconcileMinutes"] === "default"); +} +for (const [name, mut] of [ + ["B6 unknown key at business refuses", (d) => { d.vars["tracker.pollseconds"] = 30; return d; }], + ["B7 agent-only key (harness) at business refuses", (d) => { d.vars.harness = "pi"; return d; }], + ["B8 business-only key at agent refuses", (d) => { d.roles.coder.vars = { "tracker.baseUrl": "http://x" }; return d; }], + ["B9 system key at business refuses", (d) => { d.vars.dataRoot = "/x"; return d; }], + ["B10 __proto__ key refuses", (d) => JSON.parse(JSON.stringify(d).replace('"vars":{', '"vars":{"__proto__":{"a":1},')) ], + ["B11 limits.network outside vocabulary refuses", (d) => { d.vars["limits.network"] = "lan"; return d; }], + ["B12 limits.authority unknown action refuses", (d) => { d.vars["limits.authority"] = ["task.delete"]; return d; }], +]) { + const s = setup(mut); + const r = tryRun(() => load(s)); + out(name, !r.ok && r.code === 2, r.e); +} +for (const [name, project] of [ + ["B13 project file: agent key (model) at project vars refuses", { projectVersion: 1, id: "stack", vars: { model: "x" }, roles: {} }], + ["B14 project roles.: agent key refuses (project layer)", { projectVersion: 1, id: "stack", vars: {}, roles: { coder: { vars: { harness: "pi" } } } }], + ["B15 project roles names undeclared instance refuses", { projectVersion: 1, id: "stack", vars: {}, roles: { ghost: { vars: {} } } }], + ["B16 project roles.constructor refuses", { projectVersion: 1, id: "stack", vars: {}, roles: { constructor: { vars: {} } } }], +]) { + const s = setup((d) => d, project); + const b = load(s); + const r = tryRun(() => B.resolveInstance({ system: sys(s.root), business: b, project: B.loadProject(join(s.root, "project")), instance: "coder" })); + out(name, !r.ok, r.e); +} +{ + const s = setup(); + const b = load(s); + for (const inst of ["constructor", "toString", "__proto__"]) { + const r = tryRun(() => B.resolveInstance({ system: sys(s.root), business: b, instance: inst })); + out(`B17 instance ${inst} refuses`, !r.ok, r.e); + } +} + +// C. authority: limits.authority allowlist, role.launch. +{ + const s = setup((d) => { d.vars["limits.authority"] = ["task.create", "task.assign", "role.launch", "message.send", "task.scope.change", "review.verdict"]; + d.roles.pm.vars = { ...d.roles.pm.vars, "limits.authority": ["task.create", "role.launch", "task.scope.change", "task.close"] }; return d; }); + const b = load(s); + const pm = B.resolveInstance({ system: sys(s.root), business: b, instance: "pm" }); + out("C1 pm within = definition ∩ business ∩ agent", JSON.stringify(pm.limits.authority.withinRole) === JSON.stringify(["task.create", "role.launch"]), JSON.stringify(pm.limits.authority)); + out("C2 pm cross keeps scope.change, drops priority.change", JSON.stringify(pm.limits.authority.crossRole) === JSON.stringify(["task.scope.change"])); + out("C3 classify: dropped action is gated", B.classify(pm, "task.assign") === "gated" && B.classify(pm, "task.priority.change") === "gated"); + out("C4 classify: gated-only action is gated", B.classify(pm, "deploy") === "gated"); + out("C5 limits can't grant an action outside the definition (review.verdict for pm)", B.classify(pm, "review.verdict") === "gated"); + const rv = B.resolveInstance({ system: sys(s.root), business: b, instance: "reviewer" }); + out("C6 reviewer keeps only listed actions", JSON.stringify(rv.limits.authority.withinRole) === JSON.stringify(["review.verdict", "message.send"]), JSON.stringify(rv.limits.authority.withinRole)); + const e = tryRun(() => B.classify(pm, "task.delete")); + out("C7 classify unknown action refuses", !e.ok, e.e); +} +{ + const s = setup(); + const b = load(s); + const pm = B.resolveInstance({ system: sys(s.root), business: b, instance: "pm" }); + out("C8 pm with launch.by=pm: role.launch within", B.classify(pm, "role.launch") === "within" && pm.launch !== null); + const s2 = setup((d) => { delete d.launch; return d; }); + const r2 = tryRun(() => load(s2)); + if (r2.ok) { const pm2 = B.resolveInstance({ system: sys(s2.root), business: r2.v, instance: "pm" }); + out("C9 no launch block: pm role.launch gated", B.classify(pm2, "role.launch") === "gated" && pm2.launch === null); } + else out("C9 no launch block: business refuses (launch required)", true, r2.e); + const s3 = setup((d) => { d.launch.by = "cto"; d.launch.instances = ["coder", "reviewer"]; return d; }); + const r3 = tryRun(() => load(s3)); + out("C10 launch.by=cto (no role.launch in cto definition) refuses, exit 2", !r3.ok && r3.code === 2, r3.e); + const s4 = setup((d) => { d.roles.pm.vars = { ...d.roles.pm.vars, "limits.authority": ["task.create"] }; return d; }); + const b4 = load(s4); + const pm4 = B.resolveInstance({ system: sys(s4.root), business: b4, instance: "pm" }); + out("C11 agent limits.authority drops role.launch for launch.by: launch null, gated", pm4.launch === null && B.classify(pm4, "role.launch") === "gated", `launch=${pm4.launch ? "set" : "null"} classify=${B.classify(pm4, "role.launch")}`); +} +const sweep = []; +{ + const s = setup((d) => { d.vars["limits.authority"] = ["task.create", "task.assign", "message.send", "review.verdict", "task.update.assigned"]; return d; }); + const b = load(s); + const pm = B.resolveInstance({ system: sys(s.root), business: b, instance: "pm" }); + out("C12 business limits.authority drops role.launch: pm launch null, gated", pm.launch === null && B.classify(pm, "role.launch") === "gated"); + sweep.push(["business-layer drop", s, b]); +} +for (const [name, where, expectLoad] of [ + ["C13 a definition with role.launch only cross-role can't be launch.by", "crossRole", false], + ["C14 a definition with role.launch within-role can be launch.by", "withinRole", true], +]) { + const s = setup((d) => { d.launch.by = "cto"; d.launch.instances = ["coder", "reviewer"]; return d; }); + const roles = H.rolesCopy(s.root); + const ctoFile = join(roles, "cto.json"); + const cto = JSON.parse(readFileSync(ctoFile, "utf8")); + cto.authority[where].push("role.launch"); + writeFileSync(ctoFile, JSON.stringify(cto, null, 2)); + s.rolesDir = roles; + const r = tryRun(() => load(s)); + if (!expectLoad) { out(name, !r.ok, r.e); continue; } + if (!r.ok) { out(name, false, r.e); continue; } + const c = B.resolveInstance({ system: sys(s.root), business: r.v, instance: "cto" }); + const p = B.resolveInstance({ system: sys(s.root), business: r.v, instance: "pm" }); + out(name, c.launch?.by === "cto" && B.classify(c, "role.launch") === "within" && p.launch === null && B.classify(p, "role.launch") === "gated", + `cto=${B.classify(c, "role.launch")} pm=${B.classify(p, "role.launch")}`); + sweep.push(["cto launcher", s, r.v]); +} +{ + const two = setup((d) => { d.roles.pm2 = { ...d.roles.pm, tracker: { bot: "bot-acme-pm2", botId: 77 } }; delete d.roles.pm2.holder; d.launch.by = "pm2"; return d; }); + const r = tryRun(() => load(two)); + if (r.ok) { + const pm = B.resolveInstance({ system: sys(two.root), business: r.v, instance: "pm" }); + const pm2 = B.resolveInstance({ system: sys(two.root), business: r.v, instance: "pm2" }); + out("C15 two pm instances: only launch.by keeps role.launch", pm.launch === null && B.classify(pm, "role.launch") === "gated" && pm2.launch?.by === "pm2" && B.classify(pm2, "role.launch") === "within"); + sweep.push(["pm2 launcher", two, r.v]); + } else out("C15 two pm instances", false, r.e); + const def = setup(); sweep.push(["default", def, load(def)]); + const nol = setup((d) => { delete d.launch; return d; }); sweep.push(["no launch", nol, load(nol)]); + const agentDrop = setup((d) => { d.roles.pm.vars = { ...d.roles.pm.vars, "limits.authority": ["task.create"] }; return d; }); sweep.push(["agent-layer drop", agentDrop, load(agentDrop)]); + const projDrop = setup((d) => d, { projectVersion: 1, id: "stack", vars: {}, roles: { pm: { vars: { "limits.authority": ["task.create", "message.send"] } } } }); + sweep.push(["project-layer drop", projDrop, load(projDrop), B.loadProject(join(projDrop.root, "project"))]); + let checked = 0; const bad = []; + for (const [label, s, b, p] of sweep) { + for (const instance of Object.keys(b.roles)) { + const rec = B.resolveInstance({ system: sys(s.root), business: b, project: p, instance }); + checked++; + if ((rec.launch !== null) !== (B.classify(rec, "role.launch") === "within")) bad.push(`${label}/${instance}`); + } + } + out("C16 sweep: launch !== null exactly when classify(role.launch) is within", bad.length === 0 && checked > 0, `checked=${checked} configs=${sweep.length} bad=${JSON.stringify(bad)}`); +} + +// D. credentials: never opened (fs hook counts opens of token paths). +{ + const s = setup(); + const b = load(s); + const refs = [...Object.values(b.tracker.sync.credentials), ...Object.values(b.roles).flatMap((r) => Object.values(r.credentials))]; + const opened = globalThis.__opened; + opened.length = 0; + const problems = refs.flatMap((ref) => B.checkCredentialRef(ref, { forbiddenRoots: [R] }).problems); + const tokenOpens = opened.filter((p) => refs.some((ref) => ref.file && String(p).startsWith(ref.file))); + out("D1 checkCredentialRef on 9 token files: 0 opens", tokenOpens.length === 0 && problems.length === 0, `refs=${refs.length} opens=${JSON.stringify(tokenOpens)} problems=${problems.length}`); + // symlinked parent pointing into a forbidden root + const inRepo = join(s.root, "fakerepo"); mkdirSync(join(inRepo, "sec"), { recursive: true }); + const tok = join(inRepo, "sec", "t.token"); writeFileSync(tok, "x"); chmodSync(tok, 0o600); + const link = join(s.root, "outside"); symlinkSync(join(inRepo, "sec"), link); + const ref = B.parseCredentialRef({ file: join(link, "t.token"), rotateBy: "2099-01-01" }, "gitea", "p"); + const r = B.checkCredentialRef(ref, { forbiddenRoots: [inRepo] }); + out("D2 token reached through a symlinked parent inside a forbidden root refuses", r.problems.some((p) => p.includes("inside")), JSON.stringify(r.problems)); + chmodSync(tok, 0o640); + const r2 = B.checkCredentialRef(B.parseCredentialRef({ file: tok, expires: "2099-01-01" }, "vikunja", "p"), {}); + out("D3 mode 640 refuses", r2.problems.some((p) => p.includes("mode")), JSON.stringify(r2.problems)); + const r3 = B.checkCredentialRef(B.parseCredentialRef({ file: tok, expires: "2000-01-01" }, "vikunja", "p"), {}); + out("D4 expired vikunja refuses", r3.problems.some((p) => p.includes("expired"))); + const r4 = B.checkCredentialRef(B.parseCredentialRef({ env: "GITEA_TOKEN", rotateBy: "2000-01-01" }, "gitea", "p"), { env: {} }); + out("D5 gitea rotateBy past only warns; env unset only warns", r4.problems.length === 0 && r4.warnings.length === 2, JSON.stringify(r4.warnings)); +} +{ // hook sanity: the business file read is seen + const s = setup(); globalThis.__opened.length = 0; load(s); + out("D0 hook sees the business file read", globalThis.__opened.some((p) => p.endsWith("acme.json")), String(globalThis.__opened.length)); +} + +// E. the one-descriptor reader (n1). +{ + const s = setup(); + const f = join(s.dir, "businesses", "acme.json"); + globalThis.__opened.length = 0; load(s); + const paths = globalThis.__opened.filter((p) => p === f); + const fdReads = globalThis.__opened.filter((p) => p.startsWith("fd:")); + out("E1 business load: one open of the path, reads by descriptor", paths.length === 1 && fdReads.length >= 1, JSON.stringify(globalThis.__opened.map((p) => p.replace(s.root, "").replace(R, "")))); +} +{ + const s = setup(); + const f = join(s.dir, "businesses", "acme.json"); + const real = join(s.root, "real.json"); writeFileSync(real, readFileSync(f)); chmodSync(real, 0o600); + rmSync(f); symlinkSync(real, f); + const r = tryRun(() => load(s)); + out("E2 business file is a symlink: exit 4", !r.ok && r.code === 4, r.e); +} +{ + const s = setup(); + const f = join(s.dir, "businesses", "acme.json"); + chmodSync(f, 0o000); + const r = tryRun(() => load(s)); + out("E3 business file mode 000 (EACCES at open): refuses", !r.ok, `exit=${r.code} ${r.e}`); + chmodSync(f, 0o600); +} +{ + const s = setup(); + const d = join(s.dir, "businesses"); + const moved = join(s.root, "elsewhere"); renameSync(d, moved); symlinkSync(moved, d); + const r = tryRun(() => load(s)); + out("E4 businesses/ itself a symlink (parent, not the file): loads, as in round 1", r.ok, r.ok ? "" : r.e); +} +{ + const s = setup(); + const roles = H.rolesCopy(s.root); + const pm = join(roles, "pm.json"); const real = join(s.root, "pm-real.json"); + renameSync(pm, real); symlinkSync(real, pm); + s.rolesDir = roles; + const r = tryRun(() => load(s)); + out("E5 role definition is a symlink: exit 4", !r.ok && r.code === 4, r.e); +} +{ + const s = setup((d) => d, { projectVersion: 1, id: "stack", vars: {}, roles: {} }); + const pf = join(s.root, "project", ".mosaic", "project.json"); const real = join(s.root, "project-real.json"); + renameSync(pf, real); symlinkSync(real, pf); + const r = tryRun(() => B.loadProject(join(s.root, "project"))); + out("E6 project file is a symlink: exit 4", !r.ok && r.code === 4, r.e); +} +{ + const s = setup(); + const f = join(s.dir, "businesses", "acme.json"); + chmodSync(f, 0o604); + const r = tryRun(() => load(s)); + out("E7 other-readable but not writable (604) loads", r.ok, r.ok ? "" : r.e); + chmodSync(f, 0o602); + const r2 = tryRun(() => load(s)); + out("E8 other-writable (602) refuses, exit 2", !r2.ok && r2.code === 2, r2.e); +} diff --git a/agents/filbert/work/slice1-s1-review/r2-mut-summary.txt b/agents/filbert/work/slice1-s1-review/r2-mut-summary.txt new file mode 100644 index 00000000..11b753c2 --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/r2-mut-summary.txt @@ -0,0 +1,26 @@ +M1-vars-narrower-wider business_fail=2 +M2-resolve-network-wider business_fail=2 +M3-cross-not-narrowed business_fail=1 +M4-layer-check-off business_fail=5 +M5-no-realpath-file business_fail=1 +M6-gated-only-allowed business_fail=2 +M7-launcher-in-instances business_fail=1 +M8-sync-bot-shared business_fail=1 +M9-within-cross-overlap business_fail=1 +M10-project-id-mismatch business_fail=1 +M11-token-empty-ok business_fail=1 +M12-token-uid-off business_fail=1 +M13-contract-symlink-ok business_fail=0 task:selftest: 98 passed, 0 failed +M14-vikunja-expired-ok business_fail=2 +M15-credmap-not-exact business_fail=2 +M16-tracker-without-vikunja business_fail=1 +M17-max-ceiling-off business_fail=1 +M18-project-undeclared-instance business_fail=2 +M19-unknown-var-ok business_fail=2 +M20-launch-gate-cross-only business_fail=1 +N1-launch-by-cross-ok business_fail=0 task:selftest: 98 passed, 0 failed +N2-launch-checks-cross business_fail=0 task:selftest: 98 passed, 0 failed +N3-enoent-not-distinct business_fail=6 +N4-uid-check-off business_fail=0 task:selftest: 98 passed, 0 failed +N5-mode-mask-group-only business_fail=1 +N6-launch-any-instance business_fail=1 diff --git a/agents/filbert/work/slice1-s1-review/r2-node24.txt b/agents/filbert/work/slice1-s1-review/r2-node24.txt new file mode 100644 index 00000000..4a187392 --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/r2-node24.txt @@ -0,0 +1,10 @@ +v24.21.0 +✔ merge doesn't change its inputs (0.251118ms) +ℹ tests 60 +ℹ suites 0 +ℹ pass 60 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2734.347022 diff --git a/agents/filbert/work/slice1-s1-review/r2-probe.txt b/agents/filbert/work/slice1-s1-review/r2-probe.txt new file mode 100644 index 00000000..4d0d6606 --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/r2-probe.txt @@ -0,0 +1,53 @@ +PASS A1 example refuses as shipped :: mosaic-stack.json roles.pm.tracker.botId must be a positive integer (got 0) +PASS A2 bot ids fixed, dates still placeholders: refuses :: mosaic-stack.json roles.pm.credentials.gitea.rotateBy must be a date YYYY-MM-DD (got "YYYY-MM-DD") +PASS A3 dates fixed, bot ids 0: refuses :: mosaic-stack.json roles.pm.tracker.botId must be a positive integer (got 0) +PASS A4 both fixed: document validates (shape only; token files are a CLI check) +PASS B1 replace: project roles. beats project beats business :: 20 from project:stack:roles.coder +PASS B2 limits.tools intersects business ∩ project ∩ agent ∩ ceiling :: ["read","write","bash"] +PASS B3 limits.network open can't widen role api-only :: api-only +PASS B4 no project: business value :: 60 +PASS B5 default kept when unset +PASS B6 unknown key at business refuses :: ~/filbert-scratch/r36b/probe/work/b-r1Zh8W/config/businesses/acme.json vars: unknown variable "tracker.pollseconds" +PASS B7 agent-only key (harness) at business refuses :: ~/filbert-scratch/r36b/probe/work/b-iKw1uQ/config/businesses/acme.json vars: variable harness can't be set at the business layer (allowed: agent) +PASS B8 business-only key at agent refuses :: ~/filbert-scratch/r36b/probe/work/b-HcYwsQ/config/businesses/acme.json roles.coder.vars: variable tracker.baseUrl can't be set at the agent layer (allowed: business) +PASS B9 system key at business refuses :: ~/filbert-scratch/r36b/probe/work/b-juFiiI/config/businesses/acme.json vars: variable dataRoot can't be set at the business layer (allowed: system) +PASS B10 __proto__ key refuses :: ~/filbert-scratch/r36b/probe/work/b-DKVQBq/config/businesses/acme.json vars: unknown variable "__proto__" +PASS B11 limits.network outside vocabulary refuses :: variable limits.network must be one of: none, api-only, open (got "lan") +PASS B12 limits.authority unknown action refuses :: variable limits.authority entry names an unknown action: "task.delete" +PASS B13 project file: agent key (model) at project vars refuses :: ~/filbert-scratch/r36b/probe/work/b-eL0D1t/project/.mosaic/project.json vars: variable model can't be set at the project layer (allowed: agent) +PASS B14 project roles.: agent key refuses (project layer) :: ~/filbert-scratch/r36b/probe/work/b-Fn7lVd/project/.mosaic/project.json roles.coder.vars: variable harness can't be set at the project layer (allowed: agent) +PASS B15 project roles names undeclared instance refuses :: project stack sets vars for role instance ghost, which business acme doesn't declare +PASS B16 project roles.constructor refuses :: project stack sets vars for role instance constructor, which business acme doesn't declare +PASS B17 instance constructor refuses :: business acme declares no role instance "constructor" +PASS B17 instance toString refuses :: business acme declares no role instance "toString" +PASS B17 instance __proto__ refuses :: business acme declares no role instance "__proto__" +PASS C1 pm within = definition ∩ business ∩ agent :: {"withinRole":["task.create","role.launch"],"crossRole":["task.scope.change"]} +PASS C2 pm cross keeps scope.change, drops priority.change +PASS C3 classify: dropped action is gated +PASS C4 classify: gated-only action is gated +PASS C5 limits can't grant an action outside the definition (review.verdict for pm) +PASS C6 reviewer keeps only listed actions :: ["review.verdict","message.send"] +PASS C7 classify unknown action refuses :: unknown action: "task.delete" +PASS C8 pm with launch.by=pm: role.launch within +PASS C9 no launch block: pm role.launch gated +PASS C10 launch.by=cto (no role.launch in cto definition) refuses, exit 2 :: ~/filbert-scratch/r36b/probe/work/b-I8HG87/config/businesses/acme.json launch.by names cto, whose role cto doesn't hold role.launch within-role +PASS C11 agent limits.authority drops role.launch for launch.by: launch null, gated :: launch=null classify=gated +PASS C12 business limits.authority drops role.launch: pm launch null, gated +PASS C13 a definition with role.launch only cross-role can't be launch.by :: ~/filbert-scratch/r36b/probe/work/b-dit59X/config/businesses/acme.json launch.by names cto, whose role cto doesn't hold role.launch within-role +PASS C14 a definition with role.launch within-role can be launch.by :: cto=within pm=gated +PASS C15 two pm instances: only launch.by keeps role.launch +PASS C16 sweep: launch !== null exactly when classify(role.launch) is within :: checked=29 configs=7 bad=[] +PASS D1 checkCredentialRef on 9 token files: 0 opens :: refs=9 opens=[] problems=0 +PASS D2 token reached through a symlinked parent inside a forbidden root refuses :: ["gitea file ~/filbert-scratch/r36b/probe/work/b-WKi0wx/outside/t.token: inside ~/filbert-scratch/r36b/probe/work/b-WKi0wx/fakerepo; token files live outside the repository and dataRoot"] +PASS D3 mode 640 refuses :: ["vikunja file ~/filbert-scratch/r36b/probe/work/b-WKi0wx/fakerepo/sec/t.token: mode 640 gives group or other access; use 600"] +PASS D4 expired vikunja refuses +PASS D5 gitea rotateBy past only warns; env unset only warns :: ["gitea env GITEA_TOKEN: not set in this environment; the launcher must provide it","gitea env GITEA_TOKEN: rotation was due on 2000-01-01"] +PASS D0 hook sees the business file read :: 10 +PASS E1 business load: one open of the path, reads by descriptor :: ["/config/businesses/acme.json","fd:21","/roles/pm.json","fd:21","/roles/cto.json","fd:21","/roles/coder.json","fd:21","/roles/reviewer.json","fd:21"] +PASS E2 business file is a symlink: exit 4 :: business file must be a regular, non-symbolic-link file: ~/filbert-scratch/r36b/probe/work/b-l791eV/config/businesses/acme.json +PASS E3 business file mode 000 (EACCES at open): refuses :: exit=4 business file must be a regular, non-symbolic-link file: ~/filbert-scratch/r36b/probe/work/b-NERwev/config/businesses/acme.json +PASS E4 businesses/ itself a symlink (parent, not the file): loads, as in round 1 +PASS E5 role definition is a symlink: exit 4 :: role file must be a regular, non-symbolic-link file: ~/filbert-scratch/r36b/probe/work/b-Yw9PhA/roles/pm.json +PASS E6 project file is a symlink: exit 4 :: project file must be a regular, non-symbolic-link file: ~/filbert-scratch/r36b/probe/work/b-oDByB5/project/.mosaic/project.json +PASS E7 other-readable but not writable (604) loads +PASS E8 other-writable (602) refuses, exit 2 :: business file must not be writable by group or other (mode 602): ~/filbert-scratch/r36b/probe/work/b-cZe15L/config/businesses/acme.json diff --git a/agents/filbert/work/slice1-s1-review/r2-suites.txt b/agents/filbert/work/slice1-s1-review/r2-suites.txt new file mode 100644 index 00000000..bfb76235 --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/r2-suites.txt @@ -0,0 +1,6 @@ +v26.8.1 +business exit 0 ℹ pass 60 ℹ fail 0 +test-config exit 0 selftest: 24 passed, 0 failed +test-task exit 0 selftest: 98 passed, 0 failed +test-conductor exit 0 selftest: 17 passed, 0 failed +test-queue exit 0 queue suite: 27 passed, 0 failed diff --git a/agents/filbert/work/slice1-s1-review/r2-v1-compat.txt b/agents/filbert/work/slice1-s1-review/r2-v1-compat.txt new file mode 100644 index 00000000..9c34e85a --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/r2-v1-compat.txt @@ -0,0 +1,17 @@ +SAME a.json r1=0 r2=0 MOSAIC_ROLE_TOOLS=read,bash MOSAIC_ROLE_NETWORK=none +SAME b.json r1=0 r2=0 MOSAIC_ROLE_TOOLS=read MOSAIC_ROLE_NETWORK=open +SAME c.json r1=2 r2=2 +SAME d.json r1=2 r2=2 +SAME e.json r1=2 r2=2 +SAME f.json r1=2 r2=2 +SAME g.json r1=2 r2=2 +SAME h.json r1=2 r2=2 +SAME i.json r1=2 r2=2 +SAME j.json r1=2 r2=2 +SAME k.json r1=2 r2=2 +SAME l.json r1=2 r2=2 +SAME M.json r1=2 r2=2 +SAME n.json r1=2 r2=2 +SAME o.json r1=2 r2=2 +SAME researcher.json r1=0 r2=0 MOSAIC_ROLE_TOOLS=read,grep,find,ls,bash MOSAIC_ROLE_NETWORK=none +SAME missing.json r1=4 r2=4 diff --git a/agents/filbert/work/slice1-s1-review/review-r2.md b/agents/filbert/work/slice1-s1-review/review-r2.md new file mode 100644 index 00000000..a753bdca --- /dev/null +++ b/agents/filbert/work/slice1-s1-review/review-r2.md @@ -0,0 +1,129 @@ +# Slice 1 S1, row 36, round 2 review (Filbert) + +Issue #1518, request comment 26728, queue revs 108 to 110 (`768243cc`). +Packet: `agents/darkwing/work/slice1-s1/build-r2.md` (`d9568cec`). +Candidate: manifest `build-r2-manifest.sha256`, sha256 +`869168c702c272b051bc957a4a4314df962553ac432a5c2c5210f5ba1291afc7`, 34 +files, over `fef4b362` with `build-r2.patch` (sha256 +`a27890d5c70c5dd38d6b98ce4badfff6334c1cc0c485dcee7c90b1e6438e8a15`). +Round 1 record: `review-r1.md`. + +Verdict: **approve.** B1 and B2 are fixed. The n1 fix holds, and three +notes below don't block. + +## Method + +- New scratch clone `~/filbert-scratch/r36b/repo` at `fef4b362`, push URL + `DISABLED`. `git apply build-r2.patch`, then `sha256sum -c`: 34 OK. The + patch touches the same 34 paths as round 1. Comparing the two applied + trees file by file, exactly the six files in `r1-to-r2.diff` differ. +- `probe-r2.mjs` is round 1's `probe.mjs` with C10 and C11 turned into + assertions, plus C12 to C16 and E1 to E8. It runs under `hook.mjs` as + before. +- `mutants-r2.sh` runs round 1's 20 mutants on the round 2 source, plus + six new ones (N1 to N6). +- I reran v1 `resolve-role` on the 17 role files, round 1 candidate + against round 2 candidate. +- Teed output is in `r2-*.txt` in this directory. + +## Suites + +| Suite | Result | +|---|---| +| `node --test packages/business/tests/` (Node 26.8.1) | 60 pass, 0 fail | +| `node --test "packages/business/tests/*.test.mjs"` (Node 24.21.0, `node:24`, no network, clone read-only, host uid) | 60 pass, 0 fail | +| test-config, test-task | 24 and 98 passed, 0 failed | +| test-conductor, test-queue | 17 and 27 passed, 0 failed | + +## B1, `launch` and `role.launch`: fixed + +- C10: `launch.by: "cto"` refuses with exit 2, "launch.by names cto, whose + role cto doesn't hold role.launch within-role". +- `launch` is null and `role.launch` is gated when `limits.authority` + drops `role.launch` for pm: + - C11, at the agent layer; + - C12, at the business layer. + + Darkwing's tests cover the project layer. +- C13 and C14 use a copied roles directory with `role.launch` added to + cto: + - Cross-role only: `launch.by: "cto"` still refuses. + - Within-role: cto may launch (`launch` set, `within`), and pm is gated + with `launch` null. +- C15: with two pm-definition instances, only the one named in + `launch.by` keeps `role.launch`. +- C16 sweeps 7 configurations, which resolve to 29 instance records. In + every one, `launch !== null` exactly when `classify(record, + "role.launch")` is `within`. That is the README's new sentence, checked. + +## B2, cross-role narrowing: fixed + +The new test leaves `task.reassign` out of coder's allowlist and checks +that it classifies `gated`. My round 1 mutant M3 now fails 1 business +test. + +## n1, the one-descriptor reader: fixed + +- E1: with the fs hook on, a business load opens the business file path + once, then reads by descriptor. Each role file is opened the same way. + No second path lookup follows the open, so a swap between the check + and the read has nothing to hit. +- A symlink refuses with exit 4 in each of these places: the business file + (E2), a role definition (E5) and the project file (E6). +- Mode 604 loads (E7). Mode 602 refuses with exit 2 (E8), as does 660 in + Darkwing's test. +- Darkwing's tests cover a directory and a FIFO. Both pass on Node 24 + too, and the FIFO case returns rather than hanging. + +## Round 1 probes and v1 + +- A1 to A4, B1 to B17, C1 to C9 and D0 to D5 give the same results as + round 1. D1 still records no open of any of the nine token paths. +- v1 `resolve-role`: all 17 files give the same stdout and exit code as + round 1's candidate, which matched the base. + +## Mutants + +22 of 26 killed. + +| Mutant | Result | +|---|---| +| M1 to M12, M14 to M20 (round 1 set) | killed | +| M3 crossRole not narrowed by `limits.authority` | killed (1), was B2 | +| M13 contract symlink check off | survived, equivalent (round 1 n2) | +| N1 `launch.by` check accepts `role.launch` cross-role | **survived** (note 2) | +| N2 `resolveInstance` keeps `launch` if `role.launch` is cross-role | survived, equivalent while `checkLaunch` holds | +| N3 open errors no longer tell ENOENT apart | killed (6) | +| N4 business file uid check off | survived, untestable without a second uid | +| N5 mode mask checks group only | killed (1) | +| N6 `launch` given to any instance holding the verb | killed (1) | + +## Notes (not blocking) + +1. **Open errors other than ENOENT all say "must be a regular, + non-symbolic-link file".** The intended case is ELOOP. EACCES gets the + same text and exit 4: a business file at mode 000 refuses as "must be + a regular, non-symbolic-link file" (E3). It still fails closed. Round 1 + reported the same file as "not valid JSON", which was no better, so + this isn't a regression. Adding `error.code` to the message for + anything other than ELOOP would make it accurate. +2. **No test for a launcher whose definition holds `role.launch` only + cross-role.** N1 survives because the shipped roles never put the verb + in `crossRole`. My C13 probe shows the code refuses that case. A test + with a copied roles directory, like C13, would pin it. +3. **N4: the uid check.** A test would need a file owned by a second user, + which node:test can't create without root. Leave it untested. +4. **Parent directory links.** `O_NOFOLLOW` covers only the last path + component, so a symlinked `businesses/` directory still loads (E4). + Round 1 behaved the same way, and the directory is the user's own. + This is not a finding, only a record of the boundary. + +## Files + +- `probe-r2.mjs`, `mutants-r2.sh` +- Output: + - `r2-probe.txt` + - `r2-mut-summary.txt` + - `r2-suites.txt` + - `r2-node24.txt` + - `r2-v1-compat.txt`