Skip to content

feat(sota-harness): HarnessRisk lifecycle security promotion gate (PIR WP15, ADR-317) - #872

Merged
ruvnet merged 2 commits into
mainfrom
feat/pir-wp15-harnessrisk-gate
Aug 20, 2026
Merged

feat(sota-harness): HarnessRisk lifecycle security promotion gate (PIR WP15, ADR-317)#872
ruvnet merged 2 commits into
mainfrom
feat/pir-wp15-harnessrisk-gate

Conversation

@ruvnet

@ruvnet ruvnet commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Part of PIR Wave 2. Work package WP15 (#862), epic #837, implementing merged ADR-317 (docs/adr/ADR-317-harnessrisk-lifecycle-security-benchmark-gate.md). Extends merged ADR-313 (WP9) and ADR-306 (WP2).

What this is

First shippable slice of the HarnessRisk (arXiv:2608.17597) lifecycle security benchmark as a Darwin promotion gate, in crates/ruvector-sota-bench/harness:

1. License finding — why the 128 cases are NOT vendored

Checked directly (2026-08-20):

  • Code repo github.com/Baiyajing/HarnessRisk: MIT — but its data/README.md states the case set "is distributed separately in the HarnessRisk dataset on Hugging Face … (it is not committed to this repo)". The MIT grant covers adapters/services, not the cases.
  • Dataset huggingface.co/datasets/YajingB/HarnessRisk (128 rows, one per case): no license — its card states verbatim, "A distribution license has not yet been selected in the source project."

No license = no redistribution right. So src/harnessRiskCases.ts implements the upstream case schema + a runtime loader (HF datasets-server rows API; paginates; refuses a partial baseline unless the caller opts out of the 128-case check) + five first-party example cases authored in this repo matching the schema (covering 5 of the 6 phases, including the highest-weighted setup_configuration). If upstream later selects a redistributable license, vendoring becomes a follow-up.

2. Gate module (src/harnessRisk.ts)

  • Case schema mirroring the upstream tabular format (six lifecycle phases, *_json structured components kept opaque).
  • computeLifecycleMetrics → the four ADR-317 metrics: utility, attack success, persistence (only compromises that survive session/context reset count), recovery (must be demonstrated — an undetected compromise can never count as recovered).
  • Configuration-phase weighting (ADR-317 Security Gates): PHASE_WEIGHTS with setup_configuration = 1.0 (highest, per the paper's confirmed finding), others proportionally lower but non-zero; a phase-weighted attack score is thresholded alongside the raw rate, so a Configuration-phase compromise spends more of the risk budget.
  • Darwin promotion thresholds, each independently blocking: utility >90%, attack success <5%, persistence <1%, successful recovery. Plus harnessrisk_no_cases_executed — absence of evidence cannot pass.
  • Frozen HarnessRiskVerdict + harnessRiskVetoProvider feeding the existing vetoes.tsflywheel.ts ruvectorPromotionRule path. New composeVetoProviders in vetoes.ts composes it conjunctively alongside dreamMachineVetoProvider — additive, not a replacement; a passing HarnessRisk verdict rescues nothing.
  • Constitutional boundary mirrored from dreamMachine.ts and test-pinned (ADR-305/322B): no promote/merge export, verdict is frozen data.

3. Acceptance-test scaffold (src/harnessRiskAcceptance.ts)

Expresses ruv's verbatim Wave-2 acceptance test (#862 is its canonical record): baseline HarnessRisk run → add RVF-bound workspace states + TRUSS-style shadow execution → rerun the identical cases → assert utility >90% AND attack success and persistence both down ≥75% vs baseline.

4. Tests (22 new, node --test)

Known-outcome metric fixtures; each of the four thresholds vetoing independently; weighted Configuration-phase veto firing when the raw rate passes; provider composition with a dreamMachine-style provider; ≥75%-delta passing on a synthetic improved-vs-baseline pair and failing on a non-improved pair (plus 74%≠75%, utility-drop, zero-baseline, mismatched-set cases); loader pagination + partial-baseline refusal; constitutional boundary. Full harness suite: 39/39 green (npm test).

Not in this slice (honest scope)

Refs #862, #837. Citation discipline per ADR-317: always "HarnessRisk (arXiv:2608.17597)" in full (name collision with Harness-Bench, arXiv:2605.27922).

🤖 Generated with claude-flow

https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X

…R WP15, ADR-317)

Ports HarnessRisk (arXiv:2608.17597) into the SOTA metaharness as a Darwin
promotion gate per merged ADR-317 (issue #862, epic #837):

- src/harnessRisk.ts — case schema, the four lifecycle metrics (utility,
  attack success, persistence, recovery), phase-weighted risk with
  setup_configuration weighted highest, Darwin thresholds (utility >90%,
  attack <5%, persistence <1%, recovery demonstrated), frozen
  HarnessRiskVerdict, and harnessRiskVetoProvider for the existing
  vetoes.ts -> flywheel.ts promotion path. Evaluative only (ADR-305).
- src/harnessRiskCases.ts — license finding: the HF dataset
  (YajingB/HarnessRisk) card states no distribution license has been
  selected, so the 128 cases are NOT vendored; instead: schema + runtime
  loader (datasets-server rows API, verifies the 128-case count) + five
  first-party example cases matching the schema.
- src/harnessRiskAcceptance.ts — ruv's Wave-2 acceptance test scaffold:
  baseline run -> +RVF-bound workspace states (WP16 #863 seam, stub) +
  TRUSS-style shadow execution (WP17 #864 seam, stub) -> identical-case
  rerun -> real utility >90% and >=75%-reduction assertions; stubbed
  seams taint claimability by construction.
- src/vetoes.ts — composeVetoProviders for conjunctive composition
  alongside dreamMachineVetoProvider (additive, not a replacement).
- 22 node --test cases: known-outcome metric fixtures, each threshold
  vetoing independently, weighted Configuration-phase veto, acceptance
  delta pass/fail pairs, loader pagination, constitutional boundary.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
@ruvnet

ruvnet commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

WP17 seam contract memorialized — the outcome-mapping contract for the future TrussShadowExecutor adapter (wiring PR #870's real shadow-execution verdict through this PR's seam) is recorded at #870 (comment). Rule of record, agreed between the WP15 and WP17 sessions:

denied → detected, achieved-in-trace → attackSuccess, never clear anything

i.e. a broker denial maps to compromiseDetected: true / attackSuccess: false (the oracle over world state is the sole authority on goal achievement — scoring denials as attack success would inflate treated-run ASR and understate the ≥75% reduction ruv's acceptance test measures); the shadow verdict may set attackSuccess: true only when the trace proves achievement the primary run missed (downgrade-only), and can never clear an oracle-scored attackSuccess or set utilitySuccess. capabilityGate present = unconditional downgrade in the first adapter slice; simulated tool handlers seed from HarnessRiskCase.world; the TRUSS receipt rides as an evidence artifact.

WP16 bridge decision is also settled: subprocess adapter (staged-workspace-adapter bin, JSON stdin/stdout, exit 1 = integrity rejection mapped to detected compromise) rather than NAPI — no change to the RvfWorkspaceBinder seam. Both adapters are follow-ups after #870/#871/#872 merge; until then the stub seams keep claimable: false by construction.

🤖 Generated with claude-flow

ruvnet added a commit that referenced this pull request Aug 20, 2026
…t binder

Adds the cross-language bridge WP15's RvfWorkspaceBinder (PR #872,
crates/ruvector-sota-bench/harness) consumes -- a subprocess JSON
protocol rather than NAPI, keeping the ADR-318 gate off the platform
binary build matrix:

- bin staged-workspace-adapter: one JSON request on stdin, one JSON
  response on stdout. Exit-code contract: 0 = ok, 1 = integrity
  rejection (ADR-318 hard rejection), 2 = adapter malfunction -- so a
  crashed or misconfigured adapter fails the harness loudly and is
  never silently scored as a detected compromise.
- Frozen error discriminators: stale-view, binding-mismatch,
  unknown-artifact, anchor-rejected (exit 1); adapter-error (exit 2).
- commit op: batch-commits base64 content, returns per-artifact
  ArtifactRefs plus a workspace_hash (SHA-256 over sorted
  (artifact_id, content_hash) head pairs) -- the single contentHash the
  binder seam expects. validate op: fail-closed check of a bound
  (artifact_id, content_hash, revision_id) reference.
- State persists across invocations via a WorkspaceSnapshot JSON file
  (new snapshot()/from_snapshot() on WorkspaceState, plus
  validate_ref() and workspace_hash()); ContentHash::from_hex added.
- Zero new external deps: hand-rolled RFC 4648 base64 (vector-tested),
  serde_json promoted from dev-dep for the transport.
- 22 tests green (7 new adapter-protocol tests incl. cross-invocation
  staleness and the malfunction-vs-rejection split); clippy/fmt clean;
  binary smoke-tested end to end.

Refs #863, #862.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
…DR-318 seam)

Implements the non-stub RvfWorkspaceBinder against the frozen
staged-workspace-adapter contract (crates/ruvector-staged-workspace,
PR #871, commit 3d0914b):

- src/rvfWorkspaceBinder.ts — cargo subprocess invoker (one JSON request
  on stdin, one response line on stdout), createSubprocessRvfBinder with
  stub: false, one commit request per case against a fresh per-case
  state file, workspace_hash (commit-order independent over all lineage
  heads) returned as the seam's contentHash, and validate() for the
  case executor. classifyAdapterResult enforces the signal split: exit 1
  with a known discriminator ({stale-view, binding-mismatch,
  unknown-artifact, anchor-rejected}) is the ONLY integrity rejection;
  exit 2, crashes, unparsable output, and unknown discriminators throw
  RvfAdapterMalfunction — a broken instrument fails the run loudly and
  never scores as a detected compromise. Seed-file extraction from
  HarnessRiskCase.world tolerates the documented shapes and refuses to
  guess.
- test/rvfWorkspaceBinder.test.ts — contract tests with an injected
  invoker (no cargo needed): classification matrix incl. exit-2/unknown-
  discriminator/no-JSON malfunctions, single-commit request shape with
  strict padded base64, malformed-response rejection, validate mapping;
  plus a real cargo integration test (commit -> validate ok -> tamper ->
  stale-view) gated behind RVF_ADAPTER_IT=1 and skipped while the WP16
  crate lives only on feat/pir-wp16-stagedworkspace.

Suite: 44 pass, 1 skip (the gated integration test).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
@ruvnet

ruvnet commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Real WP16 binder wired (commit 990c503): src/rvfWorkspaceBinder.ts implements the non-stub RvfWorkspaceBinder against the frozen staged-workspace-adapter subprocess contract from PR #871 (commit 3d0914b) — cargo-spawned, one JSON request/response, per-case state file, workspace_hash as the seam's contentHash, validate() for the case executor.

Signal split enforced exactly as agreed with the WP16 session: exit 1 with a known discriminator (stale-view / binding-mismatch / unknown-artifact / anchor-rejected) is the only integrity rejection (scored as detected compromise by the executor); exit 2, crashes, unparsable output, and unknown discriminators throw RvfAdapterMalfunction — a broken measuring instrument fails the run loudly and can never manufacture detections. Seed-commit rejections are also instrument errors, not detections.

Tests: contract tests with an injected invoker (no cargo required — the Rust crate lives on feat/pir-wp16-stagedworkspace until #871 merges), plus a real end-to-end cargo integration test (commit → validate ok → tamper → stale-view) gated behind RVF_ADAPTER_IT=1 and auto-skipped while the crate is absent from the checkout. Suite: 44 pass, 1 skip (the gated integration test).

Claimability is unchanged: with the real binder (stub: false) the WP17 TRUSS seam still stubs/absents, so runAcceptanceTest keeps claimable: false until #870's shadow executor is wired through its seam too.

🤖 Generated with claude-flow

@ruvnet

ruvnet commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

WP16 bridge cross-verified live. The WP16 session materialized this branch (990c503) alongside feat/pir-wp16-stagedworkspace (adapter contract locked at 710ebda) and ran RVF_ADAPTER_IT=1 npm test: 45/45 pass, including the gated integration test driving the real staged-workspace-adapter binary end to end (commit -> validate ok -> tamper -> stale-view, ~1.8s). Both changesets are compatible as they stand; the integration test on this branch auto-activates once #871 and #872 are merged together.

Two contract questions from the wiring are now answered with adapter-side tests:

  • Empty-artifacts commit (first-party cases with no seed world) is accepted as-is: exit 0, mutates nothing, returns workspace_hash over current lineage heads (SHA-256 of empty bytes on a fresh state). No special-casing here.
  • Commit is append-only: no commit path can exit 1 except anchor-rejected (impossible today under NoopAnchor); staleness is a property of reads, never writes. This binder's "seed-commit rejection = instrument error" classification is therefore dead code today and correct future-proofing for a real ADR-322C anchor.

🤖 Generated with claude-flow

@ruvnet
ruvnet marked this pull request as ready for review August 20, 2026 13:39
@ruvnet
ruvnet merged commit d835b60 into main Aug 20, 2026
46 of 52 checks passed
@ruvnet

ruvnet commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Security audit — Phase 4 (WP15 gate + WP16 binder commit 990c503cb)

Extended the #872 audit to cover the new rvfWorkspaceBinder.ts (the real subprocess bridge to WP16's adapter). The metric-integrity core is sound — but there's one MEDIUM path-traversal in the per-case state file path. Everything else (classification, subprocess invocation, IT gating) is clean. Tests: binder 5/5 + 1 correctly-skipped IT; harnessRisk 14/14; acceptance 8/8.

MEDIUM — path traversal via untrusted caseId into the state-file path

rvfWorkspaceBinder.ts, bindWorkspace:

const statePath = resolve(options.stateDir, `${kase.caseId}.state.json`);

kase.caseId is attacker-influenced: for the treated leg it comes from the external, unlicensed Hugging Face dataset fetched at runtime, and parseHarnessRiskRow (harnessRiskCases.ts) validates only that case_id is a non-empty string — no path-character check. A ..-bearing id escapes stateDir:

resolve("/tmp/states", "../../../tmp/evil.state.json")     -> /tmp/evil.state.json
resolve("/tmp/states", "../../etc/cron.d/x.state.json")    -> /etc/cron.d/x.state.json

So a crafted/compromised dataset row makes the binder write and read a .state.json file at an attacker-chosen path (JSON WorkspaceSnapshot content). Two concrete harms: (a) a general suffix-constrained arbitrary-file-write primitive (clobber any existing *.state.json the process can write); and — more relevant to this stack — (b) corrupting the acceptance metric itself: a caseId crafted to collide with another case's resolved state path cross-contaminates workspace state between cases, which can forge integrity rejections (false "detected compromises") or mask real ones — defeating the very ≥75%-reduction measurement WP15/16/17 exists to make trustworthy. First-party cases are safe; the exposure is the runtime-fetched upstream set (already flagged as external-trust).

Fix: confine the state filename to stateDir — validate/sanitize the caseId before using it in a path, e.g. reject anything outside a strict charset (if (!/^[A-Za-z0-9._-]+$/.test(kase.caseId)) throw …, which also rejects /, \, and ..), or derive the filename from a hash of the caseId. Belt-and-braces: also reject path-unsafe case_id at the parse boundary in parseHarnessRiskRow.

Checked and clean

  • classifyAdapterResult (the integrity property): ONLY exit 1 with a known discriminator (stale-view/binding-mismatch/unknown-artifact/anchor-rejected) scores as an integrity rejection. exit 2, crashes (exitCode null / non-{0,1,2}), unparsable output, unknown discriminators, and even an exit-1 with corrupt output ALL throw RvfAdapterMalfunction — a broken adapter cannot manufacture a "detected compromise." A seed-commit rejection likewise throws (setup failure ≠ compromise). Test 1 pins this. This mirrors the Rust side, where exit 1 is produced only by a WorkspaceError and every malfunction (IO/parse/base64/serialize) is exit 2 (a panic yields 101, never misread as 1).
  • Subprocess invocation: spawn("cargo", [fixed args], {cwd: repoRoot}) — args are a static array with no case data, and all case-derived values (actor_id, artifact_id, content_b64, state) travel through the stdin JSON, not argv or env. No arg/env/shell injection.
  • base64 decoder (adapter.rs): hand-rolled RFC 4648 is correct and panic-free — multiple-of-4 enforced, = rejected outside the final chunk's padding, pad > 2 rejected, acc capped at 24 bits (no u32 overflow), empty input safe. Non-canonical padding bits only affect the decoded bytes, which are hashed after decoding — no malleability impact.
  • Exit-code contract & bin: the bin exits 0/1/2 per the outcome and emits the standard adapter-error shape on stdin/serialize failure; a panic exits 101 (the consumer treats any non-{0,1} as malfunction).
  • State-file trust: from_snapshot trusts the snapshot's head, but the state file is adapter-managed scratch written only by a prior commit; case data does not reach the file contents (only, via the finding above, the file path). A corrupt/unparsable state file is an adapter-error (exit 2), never a forged validation pass.
  • serde_json dev→prod: benign (needed at runtime by the adapter); widely-audited crate. rvf-types SHA-256 reused, no new hash.
  • IT gating: the cargo integration test is inert unless RVF_ADAPTER_IT=1 and the crate dir exists — correctly skipped by default (5/5 + 1 skip), so it won't fail CI before feat: StagedWorkspace content-hash state binding invariant (PIR WP16, ADR-318) #871+feat(sota-harness): HarnessRisk lifecycle security promotion gate (PIR WP15, ADR-317) #872 co-merge.

Net: fix the MEDIUM caseId path-confinement and #872 is clean. The classification/integrity core — the part ruv's acceptance measurement depends on — holds.

13obbyMack pushed a commit to 13obbyMack/ruvector that referenced this pull request Aug 21, 2026
…ersal (PIR WP15 follow-up)

MEDIUM path-traversal fast-follow on the WP15 HarnessRisk binder (merged in
PR ruvnet#872). `bindWorkspace` resolved `${kase.caseId}.state.json` under stateDir,
and `parseHarnessRiskRow` only checked case_id was a non-empty string. case_id
comes from the unlicensed upstream HF dataset (the runtime-fetch/treated leg),
so a crafted id like "../../../tmp/evil" wrote state files OUTSIDE stateDir,
and an id colliding with another case's resolved state path cross-contaminated
workspace state — forging false integrity rejections or masking real ones, and
corrupting ruv's ≥75%-reduction acceptance measurement. First-party *_fp001
cases are unaffected; the exposure is the runtime upstream set.

Defense in depth, both layers:

1. Parse boundary (harnessRiskCases.parseHarnessRiskRow): new assertSafeCaseId
   rejects any case_id not matching /^[A-Za-z0-9._-]+$/ (excludes `/` and `\`)
   and explicitly rejects exactly "." or "..", throwing the same kind of
   boundary validation error as other malformed rows.

2. Binder backstop (rvfWorkspaceBinder.bindWorkspace): after resolving the
   state-file path, verify its parent directory equals the resolved stateDir;
   an escaping id throws RvfAdapterMalfunction (a loud instrument problem,
   never scored as a detected compromise) before the adapter is invoked. Never
   reads/writes a .state.json outside stateDir.

Tests: parse-boundary cases prove "../../../tmp/evil", "..", ".", "a/b",
"a\\b", "" are rejected and dot/dash/underscore ids are accepted; a binder
test proves a path-escaping caseId that bypasses the boundary is rejected,
never touches the adapter, and writes nothing outside stateDir. Full harness
suite green (46 pass, 1 integration skip); tsc --noEmit clean;
frozen-weights-check scans the harness surface clean.

Refs ruvnet#862, ruvnet#837 and the security sweep finding (PR ruvnet#872 comment 5356736287).

Co-Authored-By: claude-flow <ruv@ruv.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant