From 6da3c324128bf042bd85dd343543883073409f7c Mon Sep 17 00:00:00 2001 From: John Smith Date: Sun, 18 Oct 2026 04:30:25 -0400 Subject: [PATCH] Audit remediation 20261018 follow-up: find-exec placeholder P0, fork taint, lesson-once, guard root, Formatter built-in leg, 1.0.11 bump --- README.md | 2 +- docs/reports/audit-remediation-20261018.md | 183 +++++++++ package.json | 2 +- scripts/host-test/harness-host.upstream.ts | 271 +++++++++++++- src/context/episodic-memory.ts | 12 + src/guards/shell-parse.ts | 108 ++++++ src/guards/taint-guard.ts | 18 +- src/host/contract-adapter.ts | 7 + src/index.ts | 31 +- src/interceptors/session-interceptors.ts | 98 ++++- src/version.ts | 2 +- test/audit-20261018-remediation.test.ts | 416 ++++++++++++++++++++- 12 files changed, 1127 insertions(+), 23 deletions(-) create mode 100644 docs/reports/audit-remediation-20261018.md diff --git a/README.md b/README.md index 6b1898f..7fdfb6f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# `@heretek-ai/opencode-harness` (`v1.0.10`) +# `@heretek-ai/opencode-harness` (`v1.0.11`) Plugin-first OpenCode v2 in-process harness targeting **Muse 1.3** (`opencode-go/muse-spark-1.3-contributor`): zero-patch built-in tool interceptors (`edit`, `read`, `write`, `patch`, `shell`, `subagent`), post-formatter FNV-1a `` resync receipts, adaptive `session.hook("retry")` stream/overload recovery, cross-file relative import & named-export contract verification, lexer-safe atomic multi-file `rename_symbol`, Dual Direct + Code Mode (`tools.harness.*`) tool registration, 4-hex FNV-1a hash-anchored file edits, AST structural codemods, PageRank repository mapping, lexer-aware symbol reference & call-site search, blast-radius, circular-dependency & dead-code architecture auditing, all-or-nothing atomic transactions, warm-cached post-mutation diagnostics, stack-frame `` localization, head+tail anti-truncation output compaction, KV-cache-safe context budgeting, automatic circuit-breaker episodic lesson synthesis, role/path/contract/taint permission guards, a doom-loop circuit breaker, and DOM geometry auditing. diff --git a/docs/reports/audit-remediation-20261018.md b/docs/reports/audit-remediation-20261018.md new file mode 100644 index 0000000..fef2a21 --- /dev/null +++ b/docs/reports/audit-remediation-20261018.md @@ -0,0 +1,183 @@ +# Stage Report — Audit Remediation 20261018 (`audit-remediation-20261018`) + +- **Stage ID:** `audit-remediation-20261018` +- **Branch:** `audit-remediation-20261018` (worktree: `worktrees/audit-remediation-20261018`) +- **Baseline:** `a8aa58a` (`Add drift-guard verification: VERDICT PASS (audit 20261018)`) +- **Source findings:** `AUDIT_REPORT_FINDINGS.md` (audit date 2026-10-18, package `1.0.10`) +- **Scope:** single-session OpenCode v2 in-process plugin ONLY (no WebUI / HTTP / + fleet / worktree-manager / research-engine code — refused by charter). + +## Summary + +All five code-remediation findings are fixed, regression-pinned, and green +across every gate (`bun run check`, `bun test`, `make check`, `make accept`, +`bun run build`), plus a live host-test run against the pinned upstream +(`anomalyco/opencode@ed772f5`) proving the new built-in-`edit` leg with the +real upstream `Formatter.file`. Package bumped `1.0.10` → `1.0.11` in +`package.json` + `src/version.ts` (+ README title, coupled by test). +**No tags were pushed, nothing was published, no GitHub Release was created — +those are human-only steps listed verbatim in §7.** + +## Deliverables per finding + +### OH-6.5-find-exec-placeholder (P0) — `find -exec rm -rf {} \;` bypass + +- `src/guards/shell-parse.ts` + - New `findPathOperands()` (leading path operands before the first + expression primary) + `findHasWipeRoot()` (any operand is a root-wipe + target: `/`, `/*`, `*`, `~`, `~/*`, `~/…`, `$HOME`, `${HOME}`, …). + - New `findExecPayloadIsPlaceholderWipe()` (`-exec`/`-execdir`/`-ok`/`-okdir` + payload running `rm` + recursive/force flags over the `{}` placeholder, + including wrapper-stripped payloads and `$(…)`/backtick substitution). + - `segmentIsDestructive()` `find` branch: wipe-root + placeholder-wipe + payload → `destructive` (fail-closed). Scoped roots stay `write`; + `find /tmp -name x -delete` and `find /tmp -exec rm -rf / \;` behavior + preserved. +- `src/guards/taint-guard.ts` + - `evaluateTaintGuard()` denies destructive shell while tainted + (`TAINT-DESTRUCTIVE-1`) — the taint layer no longer reads `allow` for a + wipe idiom (permission layer denies first for every role regardless). +- Tests (`test/audit-20261018-remediation.test.ts`, 6 new): + `\;`/`+` × `-exec`/`-execdir`/`-ok`/`-okdir` destructive matrix; `/`/`~`/ + `$HOME`/`${HOME}`/`/*` roots; scoped-root write preservation; builder/ + architect/director/manager/qa verdicts all `rule-1-destructive`; tainted + `allow`→`deny` at both layers + clean-session layering pins. + +### OH-6.5-guard-root-unwired (P3) — `guardConfig.workspaceRoot` default + +- `src/index.ts` (`resolveHarnessOptions`): custom `guardConfig` without + `workspaceRoot` now defaults to `rootDir` + (`{ workspaceRoot: rootDir, ...customGuardConfig }`); an explicit non-blank + value wins; a blank value normalizes back to `rootDir` (fail-closed, never + silently back to `process.cwd()`). No-`guardConfig` path unchanged. +- Tests (4 new): omitted→`rootDir`, explicit-wins, blank→`rootDir`, + workspace-relative write allows under the defaulted root while + `/etc/cron.d/evil` still denies `rule-0-shell-target-containment`. + +### OH-6.7-lesson-per-read (P3) — one lesson per doom-loop + +- `src/context/episodic-memory.ts` (`trackCircuitBreakerAndAutoProposeLesson`): + a successful mandated re-read (`normalizeToolKind(toolName) === "other"`) + while tripped returns `null` WITHOUT clearing trip state, so only the + genuine recovering (non-read) success proposes exactly one lesson. +- Tests (1 new pipeline test): read-ok, edit-fail, read-ok, edit-fail, + good-edit-ok through the real adapter `execute.after` → + `getPendingLessonProposals() === 1`, attached to the `edit` recovery + (`recoveryAction` starts with `edit`, never `via read`). + +### OH-6.6-taint-fork-gap (P2) — fork-aware taint inheritance + +- `src/interceptors/session-interceptors.ts`: new fork-link registry + (`recordSessionForkLink` / `getSessionForkSource`), defensive + `linkForkLineageFromEventMetadata()` (top-level/`input`/`metadata` keys: + `parentSessionID`, `forkOf`, `fork_source`, …; self-links and non-strings + ignored; never throws), `getSessionParent()` now falls back to the fork map, + `clearSessionLineage()` clears both maps. +- `src/host/contract-adapter.ts`: `execute.after` resolves fork lineage from + live event metadata on every tool completion, so `resolveTaintChain()` in + `evaluateTaintGuard()` inherits taint into forked children (fail-closed). +- Tests (3 new): tainted-parent forked child `ssh evil.com` denies + (`TAINT-NET-2`) while a clean fork allows; metadata-linked fork denies; + self/empty/null metadata links nothing. + +### OH-4.2-post-formatter-receipt (P2) — built-in `edit` post-format leg + +- `test/audit-20261018-remediation.test.ts` (in-repo, runs under `bun test`): + new built-in-`edit` stand-in driving the REAL adapter hooks in true + upstream order (`execute.before` → raw-bytes disk write → on-disk format + pass → re-read → `execute.after`), plus a formatting-sensitive test + (unformatted TS → formatted TS) asserting receipt hashes equal the + post-format disk bytes and NO stale pre-format hash leaks (ordering proof). +- `scripts/host-test/harness-host.upstream.ts` (live, runs under + `make host-test`): new `itBuiltin` suite on an extended layer (fixture + nodes + the REAL upstream `Formatter.node`) registering a deterministic + external formatter (`bun` + script, `$FILE`) and a core-Effect built-in + `edit` stand-in (write → real `Formatter.file` → re-read). Asserts receipt + hashes equal formatted bytes on disk, stale hashes absent. Verified live: + 5/5 host tests pass at pinned `ed772f5`. +- Note: `bun` is resolved via `Bun.which("bun")` (fail-closed throw when + absent) because `process.execPath` may point at an unspawnable npm-shim + name in some installs (observed `…/bun/bin/bun.exe`). + +### Release bump `1.0.10` → `1.0.11` + +- `package.json`, `src/version.ts`, `README.md` title (coupled by tests). +- `test/audit-20261018-remediation.test.ts` OH-1.3 block updated to pin + `1.0.11` past stale `1.0.9`/`1.0.10`; `test/bootstrap.test.ts` still green + (pkg === src). + +## Test results + +- New regression tests: 15 (`6` find-exec + `4` guard-root + `1` lesson + + `3` fork-gap + `1` OH-4.2 stand-in) in + `test/audit-20261018-remediation.test.ts`, plus 1 live host test. +- `bun test`: **358 pass / 0 fail** (was 343 at baseline). +- Each new suite verified present by `-t` filtered runs (6 / 4 / 1 / 3 / + OH-4.2 ×2 green in isolation). +- Host test (`OPENCODE_DIR=@ed772f5`): **5 pass / 0 fail**, + including the new built-in-leg test (ran solo: 1 pass / 9 expects). + +## Gate outputs (worktree root, all exit 0) + +- `bun run check` → `tsc --noEmit`, exit 0. +- `bun test` → 358 pass / 0 fail, exit 0. +- `make check` → check + test, exit 0. +- `make accept` → waves 1–7 `ALL GATES GREEN` (incl. zero-stub, scope-refusal, + KV-cache scans), exit 0. +- `bun run build` → `tsc -p tsconfig.build.json`, exit 0; `dist/version.js` + carries `1.0.11`. +- `make host-test` (networked, pinned `ed772f5`): 5 pass / 0 fail. + (Scratch upstream copy at `/dev/shm/ochost`, outside the repo.) + +## Self-audit notes + +- **Zero stubs:** `grep TODO src/ test/` → zero hits; no `test.skip`/ + `describe.skip`/`.only` (one `exit(` false positive inside a command + string); no `|| true` in scripts (only the wave-8 guard that *fails* on + it); the single `catch { return undefined; }` (fork-metadata linker) is a + documented fail-closed total function (unparseable events link nothing), + not a swallowed error. +- **KV-cache invariants:** untouched — no changes under `src/context/` + except the episodic proposal gate (no tag-format, ordering, or + `event.system` changes); `make accept` wave-2/5 KV checks green. +- **Scope refusal:** no WebUI/HTTP/fleet/worktree/research code in `src/` + (`make accept` scope scan green). +- **Red-proof:** new suites assert exact rules/denials that fail if the + corresponding production leg is neutered (find-branch removal, + threshold sabotage, and Builder/Architect allow all go RED by + construction); formal QA sabotage + drift `VERDICT` remain downstream. +- Previous-report files (`docs/reports/wave-audit-20261018-verification.md`, + `audit-20261018-remediation-implementation.md`) intentionally left + untouched as historical record. + +## Human follow-ups (NOT executed — forbidden to agents) + +Tag, publish, and release `1.0.11` from a green `main` after merge: + +```bash +git tag v1.0.11 +git push --tags +npm publish --provenance --access public +gh release create v1.0.11 --generate-notes +npm view @heretek-ai/opencode-harness dist-tags.latest # expect 1.0.11 +``` + +This closes OH-1.3-stale-npm-latest (P0) and OH-1.1-unreleased (P1) / +OH-1.1-no-gh-release (P3), which cannot be closed from inside the sandbox. + +## Files changed + +- `src/guards/shell-parse.ts` (+108): find-path operands, placeholder-wipe + detector, `find`-branch destructive leg. +- `src/guards/taint-guard.ts` (+18/-): `TAINT-DESTRUCTIVE-1` taint-layer leg. +- `src/guards/permission-guard.ts`: untouched (Rule 1 ordering already denies + first — verified by tests). +- `src/index.ts` (+31/-): `guardConfig.workspaceRoot` → `rootDir` default. +- `src/context/episodic-memory.ts` (+12): re-read-is-not-recovery gate. +- `src/interceptors/session-interceptors.ts` (+98/-): fork registry + + metadata linker + fork-aware `getSessionParent`. +- `src/host/contract-adapter.ts` (+7): per-completion fork-link resolution. +- `test/audit-20261018-remediation.test.ts` (+416): 15 regression tests. +- `scripts/host-test/harness-host.upstream.ts` (+271): live Formatter leg. +- `package.json`, `src/version.ts`, `README.md`: `1.0.11` bump. +- `docs/reports/audit-remediation-20261018.md`: this report (new). diff --git a/package.json b/package.json index f145b11..3c9678e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@heretek-ai/opencode-harness", - "version": "1.0.10", + "version": "1.0.11", "description": "Plugin-first OpenCode v2 in-process harness for Muse 1.3: zero-patch Edit/Read/Patch/Shell/Subagent/Retry interceptors, post-formatter FNV-1a anchor resync receipts, cross-file import/export contract verification, atomic multi-file rename_symbol, Dual Direct + Code Mode (tools.harness.*) tools, Hashline edits, AST codemods, PageRank RepoMap, blast-radius & dead-code architecture auditing, atomic transactions, KV-cache-safe context budgeting, permission/taint guards, and DOM geometry auditing.", "license": "MIT", "repository": { diff --git a/scripts/host-test/harness-host.upstream.ts b/scripts/host-test/harness-host.upstream.ts index 8de903a..eb4e91c 100644 --- a/scripts/host-test/harness-host.upstream.ts +++ b/scripts/host-test/harness-host.upstream.ts @@ -19,13 +19,53 @@ import { PluginHost } from "@opencode/core/plugin/host" import { PluginPromise } from "@opencode/core/plugin/promise" import { PluginHooks } from "@opencode/core/plugin/hooks" import { Tool } from "@opencode/core/tool" +import { Formatter } from "@opencode/core/formatter" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { AISDK } from "@opencode/core/aisdk" +import { Command } from "@opencode/core/command" +import { Config } from "@opencode/core/config" +import { Credential } from "@opencode/core/credential" +import { Database } from "@opencode/core/database/database" +import { FileAccess } from "@opencode/core/file-access" +import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform" +import { AppProcess } from "@opencode/util/process" +import { Bus } from "@opencode/core/bus" +import { FileSystem } from "@opencode/core/filesystem" +import { FSUtil } from "@opencode/util/fs-util" +import { Form } from "@opencode/core/form" +import { Generate } from "@opencode/core/generate" +import { Integration } from "@opencode/core/integration" +import { KV } from "@opencode/core/kv" +import { Location } from "@opencode/core/location" +import { ManagedPolicy } from "@opencode/core/managed-policy" +import { Mcp } from "@opencode/core/mcp/index" +import { Model } from "@opencode/core/model" +import { Npm } from "@opencode/util/npm" +import { Provider } from "@opencode/core/provider" import { Session } from "@opencode/schema/session" +import { Session as CoreSession } from "@opencode/core/session" import { SessionMessage } from "@opencode/schema/session-message" -import { Effect, Exit, Scope } from "effect" +import { PersistentPty } from "@opencode/core/persistent-pty" +import { LocationServiceMap } from "@opencode/core/location-service-map" +import { Permission } from "@opencode/core/permission" +import { Reference } from "@opencode/core/reference" +import { Rpc } from "@opencode/core/rpc" +import { Skill } from "@opencode/core/skill" +import { SkillDiscovery } from "@opencode/core/skill/discovery" +import { Vcs } from "@opencode/core/vcs" +import { Watcher } from "@opencode/core/filesystem/watcher" +import { WebSearch } from "@opencode/core/websearch" +import { Worktree } from "@opencode/core/worktree" +import { WorktreeStrategies } from "@opencode/core/worktree/strategies" +import { Effect, Exit, Layer, Scope } from "effect" import { testEffect } from "../lib/effect" import { readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" import { join } from "node:path" import { PluginTestLayer } from "./fixture" +import { tempLocationLayer } from "../fixture/location" +import { emptyMcpLayer } from "../fixture/mcp" const HARNESS_ROOT = process.env.HARNESS_ROOT if (!HARNESS_ROOT) throw new Error("HARNESS_ROOT must point at the opencode-harness checkout") @@ -370,9 +410,9 @@ it.live("opencode-harness post-edit receipt matches post-write disk bytes (OH-4. // TS becomes formatted TS, and the `` receipt appended // by `execute.after` must equal the bytes read from disk AFTER the write // completes — the same ordering the built-in `edit` path relies on after - // upstream `Formatter.file` runs (the built-in leg is pinned at the - // adapter level in `test/audit-20261018-remediation.test.ts` because - // `PluginTestLayer` registers no built-in edit tool). + // upstream `Formatter.file` runs (the built-in leg runs live below in + // the OH-4.2 built-in-leg test with the real upstream Formatter.file; + // this test pins the harness-tool leg of the same ordering invariant). const probeName = `oh42-format-probe-${Date.now().toString(36)}.ts` const probeRel = probeName const probeAbs = join(HARNESS_ROOT, probeName) @@ -412,3 +452,226 @@ it.live("opencode-harness post-edit receipt matches post-write disk bytes (OH-4. } }), ) + +// Test layer for the OH-4.2 built-in leg: the shared PluginTestLayer plus +// the REAL upstream Formatter node (AppNodeBuilder wires location-scoped +// nodes; a bare LayerNode.compile cannot bind @opencode/Location). All other +// nodes and replacements mirror the upstream fixture exactly so harness +// wiring behaves identically to the main suite. +const oh42NpmLayer = Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: (name) => Effect.succeed({ directory: "", name }), + resolve: (name) => Effect.succeed({ directory: "", name }), + check: () => Effect.succeed(false), + update: (name) => Effect.succeed({ directory: "", name }), + which: () => Effect.undefined, + }), +) + +const oh42GenerateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text: () => Effect.succeed("") })) + +const oh42ConfigLayer = Config.testLayer() + +const oh42PermissionLayer = Layer.succeed( + Permission.Service, + Permission.Service.of({ + close: Effect.void, + ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }), + assert: () => Effect.void, + reply: () => Effect.void, + get: () => Effect.succeed(undefined), + forSession: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }), +) + +const Oh42FormatterLayer = AppNodeBuilder.build( + LayerNode.group([ + AppProcess.node, + FileSystem.node, + FSUtil.node, + Location.node, + Npm.node, + Credential.node, + Config.node, + Database.node, + FileAccess.node, + Bus.node, + Form.node, + Generate.node, + LayerNodePlatform.httpClient, + Plugin.node, + Agent.node, + AISDK.node, + Provider.node, + Model.node, + Command.node, + Integration.node, + KV.node, + ManagedPolicy.node, + Mcp.node, + CoreSession.node, + PersistentPty.node, + LocationServiceMap.node, + Permission.node, + PluginHooks.node, + Reference.node, + Rpc.node, + Skill.node, + SkillDiscovery.node, + Tool.node, + Vcs.node, + Watcher.node, + WebSearch.node, + Worktree.node, + WorktreeStrategies.node, + Formatter.node, + ]), + [ + Location.node.replace(tempLocationLayer), + Npm.node.replace(oh42NpmLayer), + Config.node.replace(oh42ConfigLayer), + Mcp.node.replace(emptyMcpLayer), + Generate.node.replace(oh42GenerateLayer), + Permission.node.replace(oh42PermissionLayer), + ], +).pipe(Layer.provideMerge(oh42ConfigLayer)) + +const itBuiltin = testEffect(Oh42FormatterLayer) + +itBuiltin.live("opencode-harness built-in edit receipt matches post-Formatter.file disk bytes (OH-4.2 built-in leg)", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const tools = yield* Tool.Service + yield* PluginPromise.fromPromise({ + id: harness.id, + setup: (ctx: unknown) => harness.setup(ctx, { rootDir: HARNESS_ROOT }), + }).effect(yield* PluginHost.make(plugins)) + + const probeName = `oh42-builtin-probe-${Date.now().toString(36)}.ts` + const probeAbs = join(HARNESS_ROOT, probeName) + const oldString = "return title;" + const newString = "return title.trim();" + const expectedFormatted = [ + "export function renderCard(title: string) {", + " return title.trim();", + "}", + ] + writeFileSync( + probeAbs, + `${["export function renderCard( title:string ) {", oldString, "}"].join("\n")}\n`, + "utf8", + ) + + // Formatter runtime: the same `bun` running this test (fail-closed when + // unresolvable — `process.execPath` may point at an npm shim name that + // cannot spawn, so it is never used here). + const bunBin = Bun.which("bun") + if (!bunBin) throw new Error("OH-4.2 probe formatter requires `bun` on PATH (fail-closed)") + + // Deterministic external formatter process: real bytes rewritten on disk + // through the REAL upstream Formatter.file code path (child-process spawn + // with $FILE replacement), never an in-test copy. + const fmtScript = join(tmpdir(), `harness-probe-fmt-${Date.now().toString(36)}.mjs`) + writeFileSync( + fmtScript, + [ + `import { readFileSync, writeFileSync } from "node:fs"`, + `const target = process.argv[process.argv.length - 1]`, + `const raw = readFileSync(target, "utf8")`, + `const out = []`, + `let depth = 0`, + `for (const line of raw.split("\\n")) {`, + ` const trimmed = line.trim().replace(/[ \\t]+/g, " ").replace(/\\(\\s+/g, "(").replace(/\\s+\\)/g, ")").replace(/:(?=\\S)/g, ": ").replace(/,(?=\\S)/g, ", ").replace(/\\)\\s*\\{/g, ") {")`, + ` if (trimmed === "") continue`, + ` if (/^\\}/.test(trimmed)) depth = Math.max(0, depth - 1)`, + ` out.push(" ".repeat(depth) + trimmed)`, + ` if (/\\{$/.test(trimmed)) depth += 1`, + `}`, + `writeFileSync(target, out.join("\\n") + "\\n")`, + ``, + ].join("\n"), + "utf8", + ) + try { + const formatter = yield* Formatter.Service + yield* formatter.transform((editor) => { + editor.set({ + name: "harness-probe-tsfmt", + extensions: [".ts"], + enabled: Effect.succeed([bunBin, fmtScript, "$FILE"]), + }) + }) + + // Built-in `edit` stand-in as a core Effect executor (direct + // `tools.transform` registration, like upstream's own tool tests): + // exact oldString→newString disk write, then the REAL upstream + // Formatter.file, then a disk re-read — mirroring EditTool's + // write → formatter.file → syncTextBom order. + yield* tools.transform((editor) => + editor.add({ + name: "edit", + description: "Built-in edit stand-in for the OH-4.2 post-format receipt probe", + input: { + type: "object", + properties: { + path: { type: "string" }, + oldString: { type: "string" }, + newString: { type: "string" }, + }, + required: ["path", "oldString", "newString"], + additionalProperties: false, + }, + options: { codemode: false, permission: "edit" }, + execute: (input: unknown) => + Effect.gen(function* () { + const args = input as { path: string; oldString: string; newString: string } + const target = join(HARNESS_ROOT, String(args.path)) + const prev = readFileSync(target, "utf8") + if (!prev.includes(String(args.oldString))) { + return yield* new Tool.Error({ + message: `oldString not found in ${String(args.path)}`, + }) + } + writeFileSync( + target, + prev.replace(String(args.oldString), String(args.newString)), + "utf8", + ) + const didFormat: boolean = yield* formatter.file(target) + if (!didFormat) { + return yield* new Tool.Error({ + message: `probe formatter did not run for ${String(args.path)}`, + }) + } + return { + content: [{ type: "text" as const, text: `Edited ${String(args.path)}` }], + } + }), + }), + ) + + const snapshot = yield* tools.snapshot() + const r = yield* call(snapshot, "edit", { path: probeName, oldString, newString }) + expect(Exit.isSuccess(r)).toBe(true) + const body = textOf(r) + expect(body).toContain(" isRootWipeTarget(p)); +} + +/** + * OH-6.5-find-exec-placeholder: `true` when an `-exec`/`-execdir`/`-ok`/ + * `-okdir` payload runs `rm` with recursive+force flags over the `{}` + * placeholder (`rm -rf {}`). Callers gate this on a root-wipe find root so + * scoped uses (`find /tmp -name x -exec rm -rf {} \;`) stay write-class. A + * payload substitution (`$(…)`/backticks) with recursive+force flags is + * destructive because a real shell expands it before `rm` sees its targets. + */ +function findExecPayloadIsPlaceholderWipe(payload: string): boolean { + const tokens = stripLeadingWrapperTokens( + tokenizeSegment(canonicalizeShellCommand(payload)), + ); + const subst = /`[^`]*`|\$\(/.test(payload); + for (let s = 0; s < tokens.length; s++) { + if (commandBasename(tokens[s] ?? "") !== "rm") { + continue; + } + let hasRecursive = false; + let hasForce = false; + let hasPlaceholder = false; + let endOfOptions = false; + for (let k = s + 1; k < tokens.length; k++) { + const a = tokens[k] ?? ""; + if (!endOfOptions && a === "--") { + endOfOptions = true; + continue; + } + if (!endOfOptions && a === "--no-preserve-root") { + return true; + } + if (!endOfOptions && (a === "--recursive" || a === "-r" || a === "-R")) { + hasRecursive = true; + continue; + } + if (!endOfOptions && (a === "--force" || a === "-f")) { + hasForce = true; + continue; + } + if (!endOfOptions && /^-[rRf]+$/.test(a)) { + hasRecursive = true; + hasForce = true; + continue; + } + if (!endOfOptions && /^-[A-Za-z]+$/.test(a)) { + const letters = a.slice(1); + if (/[rR]/.test(letters)) hasRecursive = true; + if (letters.includes("f")) hasForce = true; + continue; + } + if (!endOfOptions && a.startsWith("-") && a !== "-") { + continue; + } + if (a === "{}") { + hasPlaceholder = true; + } + } + if (hasRecursive && hasForce && (hasPlaceholder || subst)) { + return true; + } + } + return false; +} + export function parseCommandSegments(command: string, depth: number = 0): ParsedSegment[] { if (!command.trim()) return []; if (depth > MAX_SHELL_UNWRAP_DEPTH) { @@ -1049,6 +1145,18 @@ function segmentIsDestructive(segment: string): boolean { return true; } } + // OH-6.5-find-exec-placeholder: `{}` expands to every path under the + // find root, so with a root-wipe root (`/`, `~`, `$HOME`, …) any + // `-exec`/`-execdir`/`-ok`/`-okdir` payload running `rm` with + // recursive+force flags over `{}` is a root wipe: destructive. + // Scoped roots stay write-class (`segmentIsWrite`). + if (findHasWipeRoot(args)) { + for (const payload of findExecPayloads(args)) { + if (findExecPayloadIsPlaceholderWipe(payload)) { + return true; + } + } + } return false; } diff --git a/src/guards/taint-guard.ts b/src/guards/taint-guard.ts index 51a2e67..fefa6ec 100644 --- a/src/guards/taint-guard.ts +++ b/src/guards/taint-guard.ts @@ -2,6 +2,7 @@ import { getSessionParent } from "../interceptors/session-interceptors.js"; import { shellNamedFileTargets } from "./permission-guard.js"; import { commandBasename, + isDestructiveShellCommand, parseCommandSegments, shellArgvFileTargets, stripLeadingWrapperTokens, @@ -159,9 +160,9 @@ export function clearTaintState(sessionId?: string): void { /** * Resolve the nearest taint records for `sessionId`, walking up the - * `recordSessionParentLink` fork/subagent/compaction chain so child sessions - * inherit their parent's taint (fail-closed: a fork of a tainted session is - * tainted). + * `recordSessionParentLink`/`recordSessionForkLink` subagent/fork/compaction + * chain so child sessions inherit their parent's taint (fail-closed: a fork + * of a tainted session is tainted; see OH-6.6-taint-fork-gap). */ function resolveTaintChain(sessionId: string): TaintRecord[] { const visited = new Set(); @@ -245,6 +246,17 @@ export function evaluateTaintGuard( const latestSource = taints[taints.length - 1]?.sourceTool ?? "untrusted-source"; + // OH-6.5-find-exec-placeholder: while tainted, a destructive shell command + // (`find / -exec rm -rf {} \;`, `rm -rf /`, …) denies fail-closed at the + // taint layer too. The permission layer denies it for every role first, + // but taint must never read as `allow` for a wipe idiom. + if (actionType === "shell" && isDestructiveShellCommand(commandOrPath)) { + return denyTaint( + "TAINT-DESTRUCTIVE-1", + `Blocked destructive shell command "${commandOrPath}" while session is tainted by untrusted tool "${latestSource}".`, + ); + } + if (actionType === "file") { if (matchesTaintSensitivePath(commandOrPath)) { return denyTaint( diff --git a/src/host/contract-adapter.ts b/src/host/contract-adapter.ts index 01af1c8..b96ece8 100644 --- a/src/host/contract-adapter.ts +++ b/src/host/contract-adapter.ts @@ -56,6 +56,7 @@ import { calibrateSystemPromptInPlace, decontaminateCompactionEventInPlace, handleSessionRetryInPlace, + linkForkLineageFromEventMetadata, recordSessionParentLink, type SessionRetryEvent, } from "../interceptors/session-interceptors.js"; @@ -686,6 +687,12 @@ export function wireOpenCodeV2PromiseContext(params: { "execute.after", async (event: OpenCodeV2ToolAfterEvent): Promise => { const sessionId = event.sessionID || "default"; + // OH-6.6-taint-fork-gap: resolve fork lineage via session metadata on + // every tool completion. Host-level forks are invisible to the + // promise-plugin hook surface, so without this the forked child + // would shed its parent's taint on subsequent `evaluateTaintGuard` + // calls. Fail-closed: unparseable events link nothing. + linkForkLineageFromEventMetadata(sessionId, event); const toolName = (event.tool || "").toLowerCase(); const failed = didToolExecutionFail(event); const ok = !failed; diff --git a/src/index.ts b/src/index.ts index a3d38e0..8cab2b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -274,19 +274,36 @@ export function resolveHarnessOptions( "opencode-harness: rootDir must be a non-empty string (fail-closed)", ); } - const guardConfig: GuardPolicyConfig = - settings.guardConfig !== undefined - ? settings.guardConfig - : defaultGuardConfig(); + const customGuardConfig = + settings.guardConfig !== undefined ? settings.guardConfig : undefined; if ( - guardConfig === null || - typeof guardConfig !== "object" || - Array.isArray(guardConfig.lockedContractGlobs) === false + customGuardConfig !== undefined && + (customGuardConfig === null || + typeof customGuardConfig !== "object" || + Array.isArray(customGuardConfig.lockedContractGlobs) === false) ) { throw new Error( "opencode-harness: guardConfig must carry a lockedContractGlobs array (fail-closed)", ); } + // OH-6.5-guard-root-unwired: a custom guardConfig without workspaceRoot + // used to fall back to process.cwd() inside resolveGuardWorkspaceRoot, + // over-blocking legitimate writes when rootDir !== cwd. Default the root + // to rootDir; an explicit non-blank workspaceRoot wins. A blank value + // normalizes back to rootDir so it cannot silently reintroduce the cwd + // fallback (fail-closed toward the operator's stated root). + let guardConfig: GuardPolicyConfig; + if (customGuardConfig === undefined) { + guardConfig = defaultGuardConfig(); + } else { + guardConfig = { workspaceRoot: rootDir, ...customGuardConfig }; + if ( + typeof guardConfig.workspaceRoot !== "string" || + guardConfig.workspaceRoot.trim() === "" + ) { + guardConfig.workspaceRoot = rootDir; + } + } const budget = settings.contextBudgetTokens !== undefined ? settings.contextBudgetTokens diff --git a/src/interceptors/session-interceptors.ts b/src/interceptors/session-interceptors.ts index 33f3180..c8d19c2 100644 --- a/src/interceptors/session-interceptors.ts +++ b/src/interceptors/session-interceptors.ts @@ -144,6 +144,101 @@ export function decontaminateCompactionEventInPlace(event: { const SESSION_PARENT_MAP = new Map(); +/** + * OH-6.6-taint-fork-gap: host-level session forks (`SessionEvent.Forked` → + * `projectFork`) copy tainted history into the child without emitting a + * promise-plugin hook the harness can observe, so no parent link is ever + * recorded for forks through the `recordSessionParentLink` call sites + * (subagent completion, affinity headers). This registry carries fork + * lineage explicitly: hosts and tests record it via + * `recordSessionForkLink` (or implicitly via + * `linkForkLineageFromEventMetadata`), and both lineage resolution + * (`getSessionParent`) and taint inheritance consult it fail-closed — a + * fork of a tainted session is tainted. + */ +const SESSION_FORK_MAP = new Map(); + +export function recordSessionForkLink( + childSessionID: string, + parentSessionID: string, +): void { + if ( + !childSessionID || + !parentSessionID || + childSessionID === parentSessionID + ) { + return; + } + SESSION_FORK_MAP.set(childSessionID, parentSessionID); +} + +export function getSessionForkSource(sessionId: string): string | undefined { + return SESSION_FORK_MAP.get(sessionId); +} + +/** + * Fork lineage metadata keys observed on child-session tool events. Checked + * on live tool events because the fork projection itself is invisible to + * the promise-plugin hook surface (fail-closed: only non-empty string + * values distinct from the child session id are honored). + */ +const FORK_SOURCE_METADATA_KEYS = [ + "parentSessionID", + "parentSessionId", + "parentID", + "forkOf", + "fork_source", + "forkSource", + "sourceSessionID", + "sourceSessionId", + "forkedFrom", + "forkParentSessionID", +] as const; + +/** + * OH-6.6-taint-fork-gap: resolve fork lineage via session metadata on live + * tool events. When a forked child session carries its source session id + * under a known metadata key (top-level, `input`, or `metadata`), record + * the fork link so subsequent `evaluateTaintGuard` calls inherit taint. + * Returns the linked parent session id, or `undefined` when nothing + * linkable is present. Never throws (fail-closed: unparseable events link + * nothing). + */ +export function linkForkLineageFromEventMetadata( + sessionId: string, + event: unknown, +): string | undefined { + try { + if (!sessionId || event === null || typeof event !== "object") { + return undefined; + } + const record = event as Record; + const holders: Array> = [record]; + for (const holderKey of ["input", "metadata"] as const) { + const holder = record[holderKey]; + if (holder !== null && typeof holder === "object") { + holders.push(holder as Record); + } + } + for (const holder of holders) { + for (const key of FORK_SOURCE_METADATA_KEYS) { + const candidate = holder[key]; + if ( + typeof candidate === "string" && + candidate !== "" && + candidate !== sessionId + ) { + recordSessionForkLink(sessionId, candidate); + return candidate; + } + } + } + return undefined; + } catch { + return undefined; + } +} + export function recordSessionParentLink( childSessionID: string, parentSessionID: string, @@ -172,10 +267,11 @@ export function resolveRootSessionAffinity(sessionID: string): string { export function clearSessionLineage(): void { SESSION_PARENT_MAP.clear(); + SESSION_FORK_MAP.clear(); } export function getSessionParent(sessionId: string): string | undefined { - return SESSION_PARENT_MAP.get(sessionId); + return SESSION_PARENT_MAP.get(sessionId) ?? SESSION_FORK_MAP.get(sessionId); } /** diff --git a/src/version.ts b/src/version.ts index 46de0a9..777505e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,5 +2,5 @@ * Single source of truth for the harness version. Must equal * `package.json#version` (asserted by `test/bootstrap.test.ts`). */ -export const HARNESS_RELEASE_VERSION = "1.0.10"; +export const HARNESS_RELEASE_VERSION = "1.0.11"; export const HARNESS_VERSION = HARNESS_RELEASE_VERSION; diff --git a/test/audit-20261018-remediation.test.ts b/test/audit-20261018-remediation.test.ts index f08c523..3ac8f08 100644 --- a/test/audit-20261018-remediation.test.ts +++ b/test/audit-20261018-remediation.test.ts @@ -1,7 +1,9 @@ /** * `test/audit-20261018-remediation.test.ts` — Regression suite for every - * finding in `AUDIT_REPORT_FINDINGS.md` (audit date 2026-10-18, package - * `1.0.9` → `1.0.10` remediation branch). + * finding in `AUDIT_REPORT_FINDINGS.md` (audit date 2026-10-18; follow-up + * remediation round: `1.0.10` → `1.0.11` for the find-exec placeholder, + * guard-root, lesson-per-read, taint-fork, and post-formatter built-in-leg + * findings). * * Each `describe` block names the finding ID it pins so a reintroduced * defect goes RED under the exact finding key: @@ -12,7 +14,7 @@ * - OH-6.7-edit-breaker-unreachable (P1, unit + adapter pipeline) * - OH-8.2-install-story (P2), OH-scope-loc-budget (P2) * - OH-4.2-post-formatter-receipt (P2, built-in edit receipt vs disk) - * - OH-1.3-stale-npm-latest (P0, version bump to 1.0.10) + * - OH-1.3-stale-npm-latest (P0, version bump to 1.0.11) */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -21,13 +23,20 @@ import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { clearAllSessionAttempts, + clearPendingLessonProposals, + clearSessionLineage, defaultGuardConfig, evaluateBoardGuard, evaluateSession, + getPendingLessonProposals, + getSessionForkSource, + linkForkLineageFromEventMetadata, recordAndEvaluateCircuitBreaker, + recordSessionForkLink, recordToolAttempt, registerContextBudgetHook, resetSessionAttempts, + resolveHarnessOptions, shellAllFileTargets, shellTargetEscapesWorkspace, toTrippedDirective, @@ -36,6 +45,7 @@ import { } from "../src/index"; import { classifyShellCommand, + findExecPayloads, isDestructiveShellCommand, shellArgvFileTargets, } from "../src/guards/shell-parse"; @@ -59,6 +69,8 @@ function makeTempDir(prefix: string): string { beforeEach(() => { clearAllSessionAttempts(); clearTaintState(); + clearPendingLessonProposals(); + clearSessionLineage(); }); afterEach(() => { @@ -67,6 +79,8 @@ afterEach(() => { } clearAllSessionAttempts(); clearTaintState(); + clearPendingLessonProposals(); + clearSessionLineage(); }); describe("OH-6.5-wrapper-laundering: every prefix wrapper denies rm -rf /", () => { @@ -678,11 +692,12 @@ describe("OH-4.2-post-formatter-receipt: built-in edit receipt reflects post-for }); }); -describe("OH-1.3-stale-npm-latest: release carries the P0 fixes at 1.0.10", () => { +describe("OH-1.3-stale-npm-latest: release carries the P0 fixes at 1.0.11", () => { test("package.json, src/version.ts, and the entrypoint agree past the stale 1.0.9", () => { expect(HARNESS_RELEASE_VERSION).toBe(pkgJson.version); - expect(HARNESS_RELEASE_VERSION).toBe("1.0.10"); + expect(HARNESS_RELEASE_VERSION).toBe("1.0.11"); expect(HARNESS_RELEASE_VERSION).not.toBe("1.0.9"); + expect(HARNESS_RELEASE_VERSION).not.toBe("1.0.10"); }); test("README title tracks the release version", () => { @@ -690,3 +705,394 @@ describe("OH-1.3-stale-npm-latest: release carries the P0 fixes at 1.0.10", () = expect(readme).toContain(`(\`v${pkgJson.version}\`)`); }); }); + +describe("OH-6.5-find-exec-placeholder: {} placeholder wipe laundering", () => { + test("findExecPayloads unwraps -exec/-execdir/-ok/-okdir payloads verbatim", () => { + expect(findExecPayloads(["/", "-exec", "rm", "-rf", "{}", ";"])).toEqual([ + "rm -rf {}", + ]); + expect(findExecPayloads(["/", "-execdir", "rm", "-rf", "{}", "+"])).toEqual([ + "rm -rf {}", + ]); + expect(findExecPayloads(["/", "-ok", "rm", "-rf", "{}", ";"])).toEqual([ + "rm -rf {}", + ]); + expect(findExecPayloads(["/", "-okdir", "rm", "-rf", "{}", ";"])).toEqual([ + "rm -rf {}", + ]); + }); + + test("terminator x starter matrix over a wipe root is destructive", () => { + for (const starter of ["-exec", "-execdir", "-ok", "-okdir"]) { + for (const terminator of ["\\;", "+"]) { + const command = `find / ${starter} rm -rf {} ${terminator}`; + expect(classifyShellCommand(command).class).toBe("destructive"); + expect(isDestructiveShellCommand(command)).toBe(true); + } + } + }); + + test("wipe roots deny; scoped roots stay write-class", () => { + for (const root of ["/", "~", "$HOME", "${HOME}", "/*"]) { + const command = `find ${root} -exec rm -rf {} \\;`; + expect(isDestructiveShellCommand(command)).toBe(true); + } + for (const command of [ + "find /tmp -name x -exec rm -rf {} \\;", + "find /tmp -name x -exec rm -rf {} +", + "find . -exec rm -rf {} \\;", + "find /tmp/x -okdir rm -rf {} \\;", + ]) { + expect(classifyShellCommand(command).class).toBe("write"); + expect(isDestructiveShellCommand(command)).toBe(false); + } + }); + + test("builder/architect/director/qa verdicts deny with rule-1-destructive", () => { + const command = "find / -exec rm -rf {} \\;"; + for (const role of [ + "builder", + "architect", + "director", + "manager", + "qa-adversary", + "agency-qa", + ]) { + const verdict = evaluateBoardGuard({ role, tool: "shell", command }); + expect(verdict.allowed).toBe(false); + expect(verdict.rule).toBe("rule-1-destructive"); + } + }); + + test("preserved taxonomy: scoped delete allows; literal wipe payload still denies", () => { + expect(classifyShellCommand("find /tmp -name x -delete").class).toBe("write"); + expect( + evaluateBoardGuard({ + role: "builder", + tool: "shell", + command: "find /tmp -name x -delete", + }).allowed, + ).toBe(true); + expect(isDestructiveShellCommand("find /tmp -exec rm -rf / \\;")).toBe(true); + const scoped = "find /tmp -name x -exec rm -rf {} \\;"; + expect( + evaluateBoardGuard({ role: "builder", tool: "shell", command: scoped }).allowed, + ).toBe(true); + expect( + evaluateBoardGuard({ role: "architect", tool: "shell", command: scoped }) + .allowed, + ).toBe(true); + }); + + test("tainted session: placeholder wipe denies at both layers (allow-to-deny)", () => { + const session = `oh65-find-taint-${Date.now()}`; + const command = "find / -exec rm -rf {} \\;"; + recordUntrustedContentTaint(session, "webfetch"); + const tainted = evaluateTaintGuard(session, command, "shell"); + expect(tainted.action).toBe("deny"); + expect(tainted.ruleId).toBe("TAINT-DESTRUCTIVE-1"); + expect(evaluateTaintGuard(session, "rm -rf /", "shell").action).toBe("deny"); + expect( + evaluateBoardGuard({ role: "builder", tool: "shell", command }).allowed, + ).toBe(false); + clearTaintState(session); + expect(evaluateTaintGuard(session, command, "shell").action).toBe("allow"); + expect( + evaluateTaintGuard(session, "find /tmp -name x -exec rm -rf {} \\;", "shell") + .action, + ).toBe("allow"); + }); +}); + +describe("OH-6.5-guard-root-unwired: custom guardConfig defaults workspaceRoot to rootDir", () => { + test("omitted workspaceRoot defaults to rootDir, not process.cwd()", () => { + const resolved = resolveHarnessOptions({ + rootDir: "/repo/proj", + guardConfig: { lockedContractGlobs: ["**/contracts/**"] }, + }); + expect(resolved.rootDir).toBe("/repo/proj"); + expect(resolved.guardConfig.workspaceRoot).toBe("/repo/proj"); + }); + + test("explicit workspaceRoot wins over rootDir", () => { + const resolved = resolveHarnessOptions({ + rootDir: "/repo/proj", + guardConfig: { lockedContractGlobs: [], workspaceRoot: "/explicit/root" }, + }); + expect(resolved.guardConfig.workspaceRoot).toBe("/explicit/root"); + }); + + test("blank workspaceRoot normalizes back to rootDir (fail-closed)", () => { + const resolved = resolveHarnessOptions({ + rootDir: "/repo/proj", + guardConfig: { lockedContractGlobs: [], workspaceRoot: " " }, + }); + expect(resolved.guardConfig.workspaceRoot).toBe("/repo/proj"); + }); + + test("workspace-relative writes allow under the defaulted root; escapes still deny", () => { + const root = makeTempDir("oh65-root-"); + const resolved = resolveHarnessOptions({ + rootDir: root, + guardConfig: { lockedContractGlobs: [] }, + }); + expect( + evaluateBoardGuard( + { role: "builder", tool: "shell", command: "echo hi > out.txt" }, + resolved.guardConfig, + ).allowed, + ).toBe(true); + const escape = evaluateBoardGuard( + { role: "builder", tool: "shell", command: "echo hi > /etc/cron.d/evil" }, + resolved.guardConfig, + ); + expect(escape.allowed).toBe(false); + expect(escape.rule).toBe("rule-0-shell-target-containment"); + }); +}); + +describe("OH-6.7-lesson-per-read: exactly one pending proposal per doom-loop", () => { + test("pipeline: read-ok, edit-fail, read-ok, edit-fail, good-edit-ok yields 1 lesson on the edit", async () => { + const root = makeTempDir("oh67-lesson-"); + const target = join(root, "loop.ts"); + writeFileSync(target, "export const x = 1;\n", "utf8"); + const host = makeAdapterHost(root); + wireOpenCodeV2PromiseContext({ + ctx: host.ctx, + workspaceRoot: root, + tools: {}, + evaluatePermission: (req) => evaluateBoardGuard(req, defaultGuardConfig()), + recordCircuitBreaker: (ev) => toTrippedDirective(recordAndEvaluateCircuitBreaker(ev)), + runContextBudget: () => undefined, + }); + const after = host.toolAfter[0]; + const sessionId = `oh67-lesson-${Date.now()}`; + const okRead = { + sessionID: sessionId, + agent: "build", + tool: "read", + input: { filePath: target }, + status: "completed", + result: { content: [{ type: "text", text: "export const x = 1;" }] }, + }; + const failEdit = { + sessionID: sessionId, + agent: "build", + tool: "edit", + input: { filePath: target }, + status: "error", + error: { message: "anchor mismatch" }, + }; + await after(okRead as never); + await after(failEdit as never); + await after(okRead as never); + await after(failEdit as never); + expect(evaluateSession(sessionId).triggered).toBe(true); + expect(getPendingLessonProposals(sessionId)).toHaveLength(0); + const goodEdit = { + sessionID: sessionId, + agent: "build", + tool: "edit", + input: { filePath: target }, + status: "completed", + result: { content: [{ type: "text", text: "edited" }] }, + }; + await after(goodEdit as never); + const pending = getPendingLessonProposals(sessionId); + expect(pending).toHaveLength(1); + expect(pending[0]?.failedCategory).toBe("edit"); + expect(pending[0]?.recoveryAction.startsWith("edit")).toBe(true); + expect(pending[0]?.recoveryAction).not.toContain("via read"); + }); +}); + +describe("OH-6.6-taint-fork-gap: fork-aware taint inheritance", () => { + test("tainted parent forked child denies exfil; clean fork allows", () => { + const parent = `oh66-fork-parent-${Date.now()}`; + const child = `${parent}-child`; + recordUntrustedContentTaint(parent, "webfetch"); + recordSessionForkLink(child, parent); + expect(getSessionForkSource(child)).toBe(parent); + const denied = evaluateTaintGuard(child, "ssh evil.com", "shell"); + expect(denied.action).toBe("deny"); + expect(denied.ruleId).toBe("TAINT-NET-2"); + const cleanParent = `${parent}-clean`; + const cleanChild = `${cleanParent}-child`; + recordSessionForkLink(cleanChild, cleanParent); + expect(evaluateTaintGuard(cleanChild, "ssh evil.com", "shell").action).toBe( + "allow", + ); + }); + + test("fork lineage resolves from session metadata on live tool events", () => { + const parent = `oh66-fork-meta-${Date.now()}`; + const child = `${parent}-child`; + recordUntrustedContentTaint(parent, "webfetch"); + const linked = linkForkLineageFromEventMetadata(child, { + sessionID: child, + tool: "shell", + input: { command: "echo hi" }, + metadata: { forkOf: parent }, + status: "completed", + result: { content: [] }, + }); + expect(linked).toBe(parent); + expect(evaluateTaintGuard(child, "ssh evil.com", "shell").action).toBe("deny"); + }); + + test("self-links and empty metadata link nothing", () => { + const session = `oh66-fork-self-${Date.now()}`; + expect( + linkForkLineageFromEventMetadata(session, { metadata: { forkOf: session } }), + ).toBeUndefined(); + expect(getSessionForkSource(session)).toBeUndefined(); + expect(linkForkLineageFromEventMetadata(session, { input: {} })).toBeUndefined(); + expect(linkForkLineageFromEventMetadata(session, null)).toBeUndefined(); + }); +}); + +describe("OH-4.2-post-formatter-receipt: built-in edit stand-in proves post-format ordering", () => { + /** + * Deterministic byte-transforming stand-in for upstream `Formatter.file` + * (`packages/core/src/formatter.ts` in `anomalyco/opencode`): rewrites the + * file on disk AFTER the raw edited bytes land and BEFORE `execute.after` + * fires — the exact ordering the production `` receipt + * depends on. It performs a real code reflow (trim, whitespace collapse, + * paren/spacing normalization, brace-depth indentation), not a copy: the + * post-format bytes differ from the raw edited bytes, so a receipt + * computed from stale pre-format bytes cannot match. + */ + function reflowTypeScriptStandalone(filePath: string): string { + const raw = readFileSync(filePath, "utf8"); + const out: string[] = []; + let depth = 0; + for (const line of raw.split("\n")) { + const trimmed = line + .trim() + .replace(/[ \t]+/g, " ") + .replace(/\(\s+/g, "(") + .replace(/\s+\)/g, ")") + .replace(/:(?=\S)/g, ": ") + .replace(/,(?=\S)/g, ", ") + .replace(/\)\s*\{/g, ") {"); + if (trimmed === "") { + continue; + } + if (/^\}/.test(trimmed)) { + depth = Math.max(0, depth - 1); + } + out.push(`${" ".repeat(depth)}${trimmed}`); + if (/\{$/.test(trimmed)) { + depth += 1; + } + } + const formatted = `${out.join("\n")}\n`; + writeFileSync(filePath, formatted, "utf8"); + return formatted; + } + + /** + * Built-in `edit` stand-in: drives the REAL adapter hooks in true upstream + * order — `execute.before` (input repair), disk write of the raw edited + * bytes (like `FileMutation.write`), the on-disk format pass (like + * `Formatter.file`), a disk re-read (like `FileMutation.syncTextBom`), + * then `execute.after` (receipt computed from the re-read bytes). + */ + async function runBuiltInEditStandIn(params: { + before: Array<(ev: never) => void | Promise>; + after: Array<(ev: never) => void | Promise>; + sessionId: string; + filePath: string; + oldString: string; + newString: string; + }): Promise<{ formattedOnDisk: string; outputText: string }> { + const { before, after, sessionId, filePath, oldString, newString } = params; + const input = { filePath, oldString, newString }; + for (const hook of before) { + await hook({ + sessionID: sessionId, + agent: "build", + tool: "edit", + input, + } as never); + } + const current = readFileSync(filePath, "utf8"); + if (!current.includes(oldString)) { + throw new Error(`stand-in: oldString not found in ${filePath} (fail-closed)`); + } + if (current.indexOf(oldString) !== current.lastIndexOf(oldString)) { + throw new Error( + `stand-in: oldString is ambiguous in ${filePath} (fail-closed)`, + ); + } + writeFileSync(filePath, current.replace(oldString, newString), "utf8"); + const formattedOnDisk = reflowTypeScriptStandalone(filePath); + const completed = { + sessionID: sessionId, + agent: "build", + tool: "edit", + input, + status: "completed", + result: { content: [{ type: "text", text: "Edited" }] }, + }; + for (const hook of after) { + await hook(completed as never); + } + const outputText = (completed.result.content as Array<{ text: string }>) + .map((c) => c.text) + .join("\n"); + return { formattedOnDisk, outputText }; + } + + test("unformatted TS edit through the built-in sequence receipts post-format bytes only", async () => { + const root = makeTempDir("oh42-builtin-"); + const target = join(root, "widget.ts"); + const beforeContent = [ + "export function renderCard( title:string ) {", + "return title;", + "}", + ].join("\n"); + writeFileSync(target, `${beforeContent}\n`, "utf8"); + const host = makeAdapterHost(root); + wireOpenCodeV2PromiseContext({ + ctx: host.ctx, + workspaceRoot: root, + tools: {}, + evaluatePermission: (req) => evaluateBoardGuard(req, defaultGuardConfig()), + recordCircuitBreaker: (ev) => toTrippedDirective(recordAndEvaluateCircuitBreaker(ev)), + runContextBudget: () => undefined, + }); + const sessionId = `oh42-builtin-${Date.now()}`; + const { formattedOnDisk, outputText } = await runBuiltInEditStandIn({ + before: host.toolBefore, + after: host.toolAfter, + sessionId, + filePath: target, + oldString: "return title;", + newString: "return title.trim();", + }); + // The format pass genuinely rewrote the bytes on disk. + const expectedFormatted = [ + "export function renderCard(title: string) {", + " return title.trim();", + "}", + ].join("\n"); + expect(formattedOnDisk).toBe(`${expectedFormatted}\n`); + expect(readFileSync(target, "utf8")).toBe(`${expectedFormatted}\n`); + // The receipt reflects the post-format bytes: every formatted line hash + // is present, and no stale pre-format line hash leaks in (ordering proof: + // a receipt computed from bytes read before formatting would carry the + // unformatted line hashes instead). + expect(outputText).toContain("