Plugin security & quality cluster: SkillSpector package scanning, prompt PII masking, plugin v1.0 pass (#453, #431, #361)#477
Merged
Conversation
…ges (#453) Every executable plugin package that passes PackageUploadService.ingest (direct upload, hub install, Builder install) is now scanned by an NVIDIA SkillSpector sidecar — fire-and-forget after ingest success, so a scanner outage or an unset SKILLSPECTOR_URL can never fail or delay an install. Verdicts are cached by ZIP sha256 + scanner version (migration 0021), surface on the store detail page (badge + verdict field), and support an operator ack with a persisted audit trail. Advisory-only in v1: nothing blocks, per the approved plan on the issue (role-gated blocking is deferred until omadia has a role model).
…431) Checklist items from the 2026-07-08 triage on the issue: add the missing READMEs (web-search, privacy-guard, quality-guard) with purpose, config keys, and published capabilities; translate agent-seo-analyst's operator- catalog description to English; align privacy-guard package.json with its manifest version (0.2.0); declare the v4 path the single canonical privacy-guard implementation (no legacy branch exists on main — the split resolves as documentation, keeping src/v4/ paths stable for #361); record explicit adopt/skip decisions for ctx.jobs / ctx.status / ctx.llm / ctx.mcp per plugin and the per-kind package-layout convention. No wire- behavior changes; privacyV4* suites untouched.
Implements the approved design on the issue: detected PII spans in the user's own prompt (and the ingested attachment tail) are substituted with realistic, type-shaped pseudonyms before the prompt crosses the LLM wire, using the shipped v4 pseudonym-projection mechanism with a server-held per-turn map that is inverted over the final answer — not a reintroduced on-wire token map. Detection is the pluggable PromptPiiDetector seam: a deterministic C0 regex baseline ships active, the C1 transformer slot ships inert until the committed validation harness passes its documented recall gates for a locale. Failure-closed: C1 failure degrades to C0 with audit; baseline failure or a residual span blocks the turn. Gated on the privacy-guard plugin's new default-off mask_user_prompt setup field — flag-off is byte-identical to previous behavior. MCP tool-result coverage via internToolResultV4 pinned in privacyInternPolicy.test.ts.
Review fixup for the prompt-masking wiring: - Fact extraction (non-stream + direct-line) now receives the MASKED wire variants of user message and answer — its Haiku prompt is LLM-bound like the main call. Extracted facts are restored surrogate-to-real before ingest via a snapshot of the turn map (snapshotPromptRestorer on PrivacyGuardService/PrivacyTurnHandle) that stays valid after finalizeTurn dropped the live map. Direct-line skips extraction with an audit line when masking is blocked (fail closed). - Persistence stores POST-restore answers: session log / KG / promoted memories and the Palaia excerpt carry real values, never surrogates — restoration moved before every persist site in chatInContextInner / chatStreamInner (final answer, choice-card short-circuits, excerpt, auto-promotion fallback). - Recalled prior context is masked through the same per-turn pseudonym map before injection, so a raw span persisted on turn N never re-crosses the wire on turn N+1; answer-side restore covers recall spans too. - createPromptPseudonymMap collision-checks surrogate candidates against ALL real values seen so far in the turn (earlier calls included). - Docs aligned: manifest help/README state the every-LLM-bound-consumer coverage and the real-values-persisted contract; CHANGELOG + validation README state the de+en / C0-only harness coverage, the C1 inert stub, the concrete gates, and that per-locale enablement requires posting harness results to issue #361 first; stale 0.2.0 version bullet fixed. - New orchestrator-level test (promptMaskPipeline.test.ts) asserts masked main + fact-extraction LLM params, real persisted values, and masked turn-N+1 recall injection with the real guard service and FactExtractor.
Second-review fixes for the #453+#431+#361 cluster: - #361: input.priorTurns replay persisted REAL values, so every prior userMessage and assistant answer is now masked through the same per-turn map before message assembly on BOTH paths (non-streaming + streaming); the direct-line relay payload is masked before dispatch, the sub-agent answer restored, and a blocked mask fails the turn closed with the generic privacy answer. Pipeline test extended with the IBAN-in-turn-1 / priorTurns-in-turn-2 case. - #453: the sidecar shim positively verifies SkillSpector's REAL report schema (issues + risk_assessment, observed live against the pinned commit via Docker: benign -> findings=[], exfil fixture -> E1) and answers ok:false on exit!=0 / non-JSON / schema mismatch, so the middleware records scan_failed instead of a false no_signals; the TS parser drops all field-name guessing too. - supply chain: requirements.txt pinned to commit c2d09df019e358d3dc12d980b82c798b87cb9f56; pin-bump procedure in the new sidecar README. - ack hygiene: acks record ack_severity and are cleared when a re-scan WORSENS the verdict (kept when equal/better); covered by fake-store tests and a pg-gated AgentGraphStore test run against real Postgres. - no-scanner UX: SKILLSPECTOR_URL unset wires no scheduler at all — no verdict rows, no badge; NullPluginScanner removed. - cosmetic surrogate exposure: pendingUserChoice (question/rationale/ options) and follow-up buttons are restored surrogate->real before rendering/persisting. - docs: manifest help, plugin README, CHANGELOG, security-architecture, .env.example and compose overlay updated so the 'every LLM-bound copy' claim is true and the no-scanner behavior is stated honestly. Refs #361 #453 #431
…nt blind spot (#361, #453) Codex review finding 1 (#361): POST /chat/steer text was appended raw to the LLM-bound message array, bypassing prompt masking. The steering drain loop now runs every steered message through maskPromptForWire on the same per-turn pseudonym map (answer-side restore covers steered spans; fails closed via PromptMaskBlockedError, converted to the graceful privacy done event by the existing stream guard). The steer_applied echo stays raw — it is user-facing, not wire content. Codex review finding 2 (#453): a lifecycle.entry inside node_modules was accepted by upload validation but skipped by the scanner walk, so the runtime would execute code SkillSpector never saw under a no_signals badge. Belt and braces: upload validation now rejects an entry that resolves below node_modules/.git or any hidden segment (package.entry_unscannable), and the scanner guarantees entry-point coverage — force-including the manifest entry file when the walk skipped it and recording scan_failed when coverage cannot be guaranteed (missing, symlink, oversize, root escape, undeterminable manifest). Tests: steering IBAN case in promptMaskPipeline (zero raw spans in the streaming request body, surrogate on the wire, restored answer); upload rejection for node_modules/hidden entries; collectScanPayload coverage guard (force-include, absent entry, symlink, boilerplate layout). Docs: CHANGELOG + security-architecture claims extended accordingly.
Weegy
added a commit
that referenced
this pull request
Jul 9, 2026
… finding) Adversarial review of the admin routes proved a cross-operator authorization gap by executing it: as operator 'bob' it read alice's job and streamed her agent logs, and cancelled her running job. The launcher gate sat on POST /jobs and /retry only; the other seven job routes — GET /jobs, GET /jobs/:id, the artifacts routes, and the epic's single SSE stream — authorized by session presence alone. /retry gating while /cancel did not was the tell that this was an oversight, not a design. The gate that protects who may START a job now equally protects who may read, stream, cancel, or apply one. loadAuthorizedJob loads the job and checks the caller against its repo; a missing job and an unauthorized one return the same 404, so it is not an oracle for which ids exist. GET /jobs filters to repos the caller may launch. GET /jobs/:id/events authorizes before opening the stream — its events carry log, tool, egress, and token payloads. Four cross-operator tests now assert bob gets 404 on read, cancel, and SSE (with no log payload in the body), and that the list is scoped per operator. Two further review minors: - isPermittedLauncher matched allowed_launchers against the caller's role, sub, AND email. Spec §6 defines them as role keys; folding in sub/email is a cross-namespace match that could grant launch to a caller whose opaque sub or IdP-controlled email happened to equal an entry. Narrowed to role keys plus the creator's sub. - A failed POST /repos left the staged device-flow token parked in the vault pending slot with no repo to clean it up. The error path now clears it. The load/authorize helpers moved to devPlatformShared.ts to keep devPlatform.ts under the 500-line rule. 135 tests green. Migration is 0022 (0021 is plugin_verdict, merged to main via #477; the branch merged main and renumbered). Refs #470
Weegy
added a commit
that referenced
this pull request
Jul 10, 2026
… build-out (#361) (#481) * feat(privacy): GLiNER PII-detector sidecar (C1 tier, #361) The C0 regex baseline shipped in #477 structurally cannot detect person names and free-form addresses — the classes this multilingual GLiNER sidecar (urchade/gliner_multi_pii-v1, Apache-2.0; Piiranha was rejected for its CC-BY-NC-ND license) provides for the mask_user_prompt feature. Skillspector-pattern deployment: stdlib-only HTTP shim, stateless, fail-closed (never a 200 half-result), exact-version pins, model baked into the image at a pinned HF revision so the running container needs no egress. Quantized ONNX on CPU by default to dodge the AVX2 torch crash hazard; internal-network-only compose overlay because the sidecar receives raw prompt PII (text and span values are never logged). Span offsets are Unicode code points with the exact text slice echoed so the middleware client can verify its UTF-16 conversion — a mis-anchored span is a leak. Middleware-side wiring follows in the next unit. * feat(privacy): wire C1 GLiNER detector into the prompt-mask seam (#361) Implements the PromptPiiDetector contract over the pii-detector sidecar as a fail-closed HTTP client (createC1HttpDetector, id c1-gliner) and injects it through the existing createPrivacyGuardService({c1Detector}) slot — no service/mask/orchestrator logic changes; a throw rides the shipped audited degrade-to-C0 path. Why the strictness: the sidecar reports Unicode code-point offsets while the seam needs UTF-16, so the client converts exactly and asserts slice==span text per span — a mis-anchored person span would mask the wrong characters, i.e. leak. URL resolves live per call (setup field c1_detector_url first, then PRIVACY_C1_DETECTOR_URL); unset means C0-only with no C1 call, which is deliberately not a degrade. Manifest 0.4.0 adds the non-secret URL field and one reviewable network.outbound entry (plugin was previously pure compute). Own ref'ed timeout timer instead of AbortSignal.timeout because that signal's timer is unref'ed and a transport ignoring the signal must still not stall maskUserPrompt. * feat(privacy): 6-locale prompt-PII validation build-out (#361) Fixtures for fr/es/it/nl plus scaled-up de/en (121 items per locale: 89 positives incl. a 25-item hand-built OOD slice, 32 negatives), all original content (no ai4privacy rows or derivatives — restricted commercial terms). Harness gains env-built detector sets (c0 always; c0+c1 with an enforced person-recall gate and a never-gated c1-solo ablation when PII_DETECTOR_URL is set), per-set un-timed warm-up, hand-slice person-recall split, informational idnum reporting, load-time fixture lint that fails the run loudly, and a --markdown mode emitting GFM tables for posting run results to issue #361. Scoring/aggregation/rendering helpers extracted to validation/report.ts (pure functions, both files under the 500-line rule). c0-only run: de/en still PASS structured gates; fr/es/it/nl C0 locale gaps recorded honestly in the validation README. Flag policy unchanged. * feat(web-ui): render maskedPromptSpans in the privacy receipt card (#361) Mirror PromptMaskedSpanInfo into the frontend PrivacyReceipt and surface the shipped backend field in PrivacyReceiptCard: collapsed summary chunk ("prompt: N masked"), expanded fact row with a per-type breakdown (open-set types rendered verbatim, detector ids kept out of the UI), and a dedicated explainer line. Absent or empty field renders the card byte-identically to before. New privacyReceipt i18n keys in en + de. * fix(privacy): overlap remainders kept + recorded 6-locale run (#361) Review fixes on the C1 prompt-PII branch: 1. dedupSpans resolved detector overlaps by dropping the losing span wholesale. Because C0 spans always carry confidence 1, a long C1 free-form-address span (GLiNER score 0.8) that merely brushed a short C0 hit inside it (e.g. the postal code) lost its entire coverage and the rest of the address reached the LLM wire unmasked - and the post-mask residual check only asserts kept map keys, so it could not catch the drop. Winners now own only the contested characters: every uncovered remainder of a losing span is kept as a masking span of its own (word-boundary re-extended, output still non-overlapping). Regression tests cover the reviewer's exact de-fixture scenario at the dedupSpans and maskPrompt level. 2. The plan's closing deliverable is now recorded: a full c0 / c0+c1 / c1-solo x 6-locale harness run against the real pinned GLiNER ONNX sidecar, committed as src/validation/RESULTS.md (tables ready to post to issue #361). de/en/it pass ALL gates on c0+c1 (person recall 100% incl. the hand-built OOD slice); es/fr/nl fail on the recorded C0 structured locale gaps, not on C1 quality. Validation README now links the recorded run instead of only estimating c0 gaps. Also: gitignore Python bytecode (sidecar unit-test runs left a __pycache__/ in the tree). * docs(privacy): reviewer nits — stale promptMask note, RESULTS.md EOF (#361)
Weegy
added a commit
that referenced
this pull request
Jul 11, 2026
…) (#476) * feat(scripts): spec-driven wave workflows + W0 unit manifest for epic #470 Adds the three-script execution flow for building a large epic from wave specs, in the idiom of the existing issue-*.workflow.mjs scripts: - wave-decompose: spec -> conflict-checked unit manifest. Extraction fans out by slice; the dependsOn reduce is a barrier because it needs every unit at once. Hub files (index.ts, config.ts, messages/*.json) are subtracted from the collision signal and assigned to a single wiring unit, so a merge conflict becomes a dependency edge. - wave-implement: worktree-isolated Engineer/Anvil per unit, then a cross-family Forge refutation and a Cato security audit on sensitive units. Colliding or dependent units run sequentially on one branch; disjoint units pipeline. Undeclared spec drift is a review failure; declared drift is expected and reported back as specDeltas. - wave-verify: acceptance checklist, then an independent refuter and a security auditor. A criterion passes only when the checker and the refuter agree. End-to-end and UI criteria report as 'manual' rather than silently passing. None of the three writes to GitHub or pushes; the caller owns the remote-write choke point, and the human gates force the three-script split because a Workflow script cannot call AskUserQuestion. docs/dev-platform/w0-manifest.json is the W0 decomposition, derived from the W0 spec: 11 units, verified acyclic, 8 security-sensitive. * feat(dev-platform): W0 unit w0-schema — migration 0021 + shared types Migration 0021_dev_platform.sql: dev_repos, dev_jobs, dev_job_events, dev_job_artifacts. Load-bearing details, each of which a spec review caught: - dev_job_events.id is a BIGINT IDENTITY and the only ordering key (SSE id: and Last-Event-ID read it). A provision column plus UNIQUE(job_id, provision, seq) prevents the cross-provision collision that would otherwise discard a gated job's entire second-provision event stream under ON CONFLICT DO NOTHING. - No CHECK on dev_job_events.type or dev_job_artifacts.kind: both enums grow in W1-W3 and are validated in TypeScript instead. A CHECK on a growing enum is a liability. The remaining six enums stay DB-constrained. - status is 'applying', not 'pushing': the middleware moves the ref, not the runner. kind drops the phantom 'file_issues'. claimed_by is UUID. types.ts: RUNNER_PROTOCOL_VERSION, the unions, and their runtime validators — now the only enforcement for type/kind. Each union and its validator derive from one 'as const' array so they cannot drift. DevJobSpec carries no credential field; the clone token is fetched at git time and is read-only. Reviewed by Forge (cross-family): applied the migration twice against Postgres 16, introspected pg_constraint, compiled under strict + noUncheckedIndexedAccess. No findings. Refs #470 * feat(dev-platform): W0 unit w0-store — durable job spine DevJobStore over Postgres: FOR UPDATE SKIP LOCKED claim, lease-fenced worker writes raising a typed DevJobLeaseLostError on a stale lease, append-only events keyed (job_id, provision, seq) and ordered by the IDENTITY id. Queue state lives in Postgres from day one; the builder's in-memory BuildQueue is deliberately not copied, because a restart must never orphan a job. finalizeDevJob is the single terminal-transition choke point. The store's terminal write is guarded by an unexported brand symbol that only finalizeDevJob.ts imports, so no sibling module can mark a job terminal — a runtime guard, where a TypeScript 'private' would merely have made the sole legitimate caller unable to reach it. W2 fills the revocation hook; the seam exists now so W2 does not retrofit it. jobToken: sha256 at rest, timing-safe verification that does not throw on a length mismatch. Split out of the store to stay under the 500-line rule: devRepoStore (repo CRUD) and pgMappers (shared pg coercion). Verified against a throwaway database on the local Postgres with the real migration runner: 26 tests, including two concurrent claims returning disjoint jobs, a stale lease raising, and appendEvents accepting the same seq under a different provision. Refs #470 * feat(dev-platform): W0 unit w0-onboarding — repo credentials, tracker read, brief devRepoCredentials: per-repo tokens in the vault namespace core:dev-platform, never in Postgres and never in a browser-facing shape. clear() purges all three keys. Device-flow tokens stage under pending/<sub>/ until the repo row exists. branchProtectionCheck: tri-state. 200 protected, 404 unprotected (the UI warns loudly), 403 could-not-verify — classic device-flow tokens lack admin read, and reporting that honestly beats reporting 'unprotected'. githubIssuesTracker: the issues endpoint also returns pull requests; they are filtered on the pull_request key. briefComposer: the ticket body is wrapped in explicit untrusted markers, and a body line reproducing a marker is neutralised — otherwise the reporter closes the block early and speaks as omadia. This is framing, not enforcement, and the code says so: W0's actual enforcement is that the runner holds no write credential at all. The test drives an adversarial ticket that tries exactly that. Reviewed by Forge (cross-family), which ran every marker-collision variant against the real composer — exact, whitespace-padded, extra-dash, truncation boundary, and marker-in-title — and found no early close. No findings. Refs #470 * feat(dev-platform): W0 unit w0-forge-apply — server-side diff apply The runner uploads a diff and never pushes. The middleware reconstructs the commit from the diff it validated, so what was reviewed and what was committed are the same object. This unit carries that guarantee for the whole epic. apply(): parse -> numstat cross-check -> path-escape refusal -> policy verdict (a seam returning allow in W0; W3 adds rules) -> blobs -> tree with base_tree at the job's pinned base_sha -> commit -> create a fresh ref -> open the PR. Validation is a read-only first phase; the first mutating call happens only after every check passes. applyHunks verifies each deletion and context line against the base before consuming it, and fails closed with DiffContextMismatchError. Without that check the applier was positional: a diff claiming to delete 'harmless' would delete whatever sat at that line number, while the numstat totals still matched. Hunk bodies must also consume exactly the counts their header declares, or DiffStructureError. Both attacks were reproduced against the original code and now abort before any write. Content that reaches a tree is never runner-supplied: modified files are reconstructed from the base blob fetched at base_sha. Symlink (120000) and gitlink (160000) modes are refused; a declared 100755 is preserved. Binary content is refused rather than fabricated, because a textual diff carries no bytes. '.git/**' is refused locally rather than relying on the forge to reject it. Two findings from my own audit of the reviewed code: - The branch name was taken from the caller. assertJobBranch now enforces the omadia/job-* prefix here, so 'only a fresh job ref is ever created' is a property of this code rather than of whoever calls it. - DiffContextMismatchError put the base line in its message. That message reaches dev_jobs.error and event payloads, which the admin UI renders and SSE streams; a base line belongs to the customer's repository and may be a secret. The lines stay on the error object; the message no longer carries them. Reviewed by Forge (cross-family), which found the positional-apply hole and reproduced it. A same-family reviewer shares the implementer's assumption that reconstructing from base plus diff is sufficient. Refs #470 * docs(dev-platform): state the terminal-write guarantee accurately The store's brand symbol is exported, so any module could import it and call finishTerminal directly. It prevents an accidental terminal write, not a deliberate one. The earlier comment (and my commit message on a4a28d6) claimed no sibling module could mark a job terminal; that overstates it. The invariant is upheld by review, and the brand makes a violation loud rather than silent. A comment that asserts a security property the code does not enforce is worse than no comment: the next reader trusts it and removes the real control. Refs #470 * feat(dev-platform): W0 unit w0-runner-api — phone-home router createDevRunnerRouter, mounted at /api/v1/dev-runner. Deliberately not behind requireAuth: the sole authentication is the per-job bearer token, verified against its stored sha256 with a timing-safe compare. index.ts applies requireAuth per router rather than as a blanket /api guard, so this mount is correct as designed. Six routes per spec §4. GET /spec carries the protocol version and no credential field (asserted by a recursive key scan and a raw-text sentinel, a regression test for a real spec bug). GET /scm-token is read-scoped and one-shot per provision. POST /events validates event types in TypeScript, because 0021 deliberately dropped the DB CHECK, and is idempotent per (job, provision, seq). POST /result routes failed and no_changes through finalizeDevJob, never the store, and a spy on the choke point proves it. Every body is size-capped and every field treated as hostile: the runner sits inside the blast chamber. No error echoes the bearer, an upstream body, or a stack. Two fixes on top of the implementation: - DevJobStore gains touchHeartbeat. appendEvents bumps last_heartbeat_at only alongside an event batch and returns early on an empty one, so an agent that thinks for two minutes without emitting a tool call would have been reaped by findStalled while perfectly healthy. The router first carried this as an optional injected hook that the wiring unit was expected to supply; an optional liveness bump is a trap, so it is now a required method on the injected store interface and cannot be omitted. Status-guarded, so a half-dead runner cannot revive a terminal job by heartbeating. - req.params[k] is 'string | string[]' under this express typing. A repeated :id arrives as an array; the auth middleware now treats anything but a single string as unauthenticated rather than coercing it. The implementation reported a clean typecheck; the filter it used hid these two errors. Refs #470 * fix(dev-platform): harden the phone-home router against a hostile runner Adversarial review of 9bc5f6c rejected the unit with two majors, both reproduced against the real router. This router is the only surface a compromised runner can reach; two of its stated guarantees held only when the runner cooperated. POST /events never bound the client-supplied provision to the job. Idempotency is (job_id, provision, seq), so a runner sending provision 999, then 1000, replays the same seq forever and defeats dedupe entirely. The provision must now equal the job's own; a real runner reads it from GET /spec, and a re-provision bumps it server-side. The existing test codified the defect — it sent provision 2 for a job whose provision was 1 and asserted acceptance — so it now simulates a real re-provision, and a mismatch is a 409. POST /result never checked that diffArtifactId belonged to the job. A runner naming another job's artifact would have had that diff committed and opened as this job's pull request, which is precisely the guarantee the epic rests on. Artifact ids are opaque, so this is defence in depth — but the check belongs somewhere rather than in each layer's assumption about the other. Also: the scm-token one-shot guard was a check-then-await-then-add TOCTOU, and resolve() is a Vault round-trip; two concurrent requests both got a credential. W0 resolves the same static credential so nothing new leaked, but W2 swaps in a scoped token minted on demand and the race would mint two. It now reserves before the await and rolls back on failure. diff_ready without a diffArtifactId is refused. seq and provision are bounded to int4, and a single event payload is capped, so a hostile runner cannot force 500s or store a 4 MiB payload verbatim. Three guarantees were asserted in comments but never tested: auth before body parsing, the one-shot under concurrency, and artifact ownership. Each now has a regression test that fails against the previous code. Splits, to honour the 500-line rule that 9bc5f6c had already broken at 522 lines: artifact access moves to devJobArtifactStore.ts, and the router's test harness to devRunnerApi.harness.ts with the adversarial cases in devRunnerApi.hostile.test.ts. 114 tests green. Refs #470 * feat(dev-platform): W0 unit w0-admin-routes — admin REST + the epic's only SSE route createDevPlatformRouter, mounted behind requireAuth at /api/v1/admin/dev-platform. GET /jobs/:id/events is the ONLY job-event SSE route in the epic: W3's chat card consumes this one rather than standing up a second. The SSE id: field is dev_job_events.id, the server-assigned IDENTITY column, never the runner's seq. A reconnect with Last-Event-ID therefore resumes correctly across a provision boundary, which a seq-keyed stream could not — the two provisions of a gated job both start their seq at zero. A test drives that exact reconnect. Launch authorization: POST /jobs requires the caller to be the repo's created_by or hold one of allowed_launchers. An empty list means created_by alone, so the gate is fail-closed. Without it any operator session launches jobs against any registered repo, which does not survive an enterprise deployment. Note the implementation matches allowed_launchers against the caller's role, sub, and email, where the spec named only role keys — an extension, not a hole. Auth-mode admission (the question-4 decision): subscription is refused when the mode flag is off, when the repo's tests execute, when the source is not admin, and on the fly backend. The route gives a good error; the worker will enforce the same check as the boundary. The local backend is likewise refused for a runs_tests repo and for any non-admin source. Cancel routes through finalizeDevJob, never the store. Repo and job views carry a credential status, never a token; a recursive key scan asserts it. Split for the 500-line rule: repo onboarding into devPlatformRepos.ts, shared guards and views into devPlatformShared.ts, the test fakes into devPlatformRoutes.harness.ts. 131 tests green. Refs #470 * fix(dev-platform): runner-api review minors — byte-accurate payload cap, scm-token rollback Re-review of the hardened router passed both majors closed. Three minors: - The per-event payload cap measured JSON.stringify(payload).length — UTF-16 code units, not bytes — while the field, default, and message all say bytes. A CJK payload slipped ~3x past the nominal cap. Now Buffer.byteLength, matching POST /diff. A multibyte regression test asserts a 60-char CJK payload (180 bytes) is rejected under a 128-byte cap where .length would have passed it. - The scm-token reservation rolled back on a resolve() throw and a missing credential but not on a failed response send, so a client that dropped the socket mid-body left the provision un-issuable until restart. Now the send is inside the try; a legitimate same-provision retry works. - The real store's uuid pre-filter on artifactBelongsToJob was exercised by no test (the fake keys off art-N ids). A pg test now asserts a real addArtifact id is a uuid, is accepted for its own job, and rejected for a foreign job and a non-uuid. 131 tests green. Refs #470 * chore(dev-platform): renumber migration 0021 -> 0022 (0021 taken by plugin_verdict via PR #477) * chore(dev-platform): drop old 0021_dev_platform.sql (renamed to 0022) * fix(dev-platform): authorize every job route, not just create (review finding) Adversarial review of the admin routes proved a cross-operator authorization gap by executing it: as operator 'bob' it read alice's job and streamed her agent logs, and cancelled her running job. The launcher gate sat on POST /jobs and /retry only; the other seven job routes — GET /jobs, GET /jobs/:id, the artifacts routes, and the epic's single SSE stream — authorized by session presence alone. /retry gating while /cancel did not was the tell that this was an oversight, not a design. The gate that protects who may START a job now equally protects who may read, stream, cancel, or apply one. loadAuthorizedJob loads the job and checks the caller against its repo; a missing job and an unauthorized one return the same 404, so it is not an oracle for which ids exist. GET /jobs filters to repos the caller may launch. GET /jobs/:id/events authorizes before opening the stream — its events carry log, tool, egress, and token payloads. Four cross-operator tests now assert bob gets 404 on read, cancel, and SSE (with no log payload in the body), and that the list is scoped per operator. Two further review minors: - isPermittedLauncher matched allowed_launchers against the caller's role, sub, AND email. Spec §6 defines them as role keys; folding in sub/email is a cross-namespace match that could grant launch to a caller whose opaque sub or IdP-controlled email happened to equal an entry. Narrowed to role keys plus the creator's sub. - A failed POST /repos left the staged device-flow token parked in the vault pending slot with no repo to clean it up. The error path now clears it. The load/authorize helpers moved to devPlatformShared.ts to keep devPlatform.ts under the 500-line rule. 135 tests green. Migration is 0022 (0021 is plugin_verdict, merged to main via #477; the branch merged main and renumbered). Refs #470 * fix(scripts): wave workflows need the meta block to run in the Workflow tool * fix(scripts): wave-implement — explicit base checkout in agent worktrees, Engineer fallback for Anvil, crash-safe Forge/Cato stages * feat(dev-platform): W0 unit w0-shim — runner shim package New workspace package `@omadia/dev-runner-shim` (Node builtins only, never imports middleware). Implements the spec §5 runner: fetch spec with a loud protocol-version gate, read-only clone at the pinned base_sha, drive the headless Claude CLI, translate its stream-json to runner events, and upload a diff — the shim holds no write credential and moves no ref. - protocol.ts: baked RUNNER_PROTOCOL_VERSION, DevJobSpec/event/result contract (deliberately duplicated from middleware; the version check catches skew), env reader. - homeClient.ts: phone-home HTTP client + HomeApi seam; never echoes the bearer. - gitOps.ts: clone via a 0600 git-credential-store file created outside the work tree and deleted in finally — token never in env, argv, or .git/config; https-only; stage + diff/numstat; no push anywhere. - eventTranslate.ts: CLI NDJSON → the documented event table (system/init, coalesced text, tool_use/tool_result, result); 2 KB preview caps. - agentRunner.ts: spawn with an ALLOWLIST env (keeps ANTHROPIC_BASE_URL — does not reuse CLI_ENV_SCRUB_KEYS), prompt on stdin, stderr → log events, 1 s/50 batching. - diffUpload.ts: self-describing numstat-marker bundle so the host splits the one text artifact back into diff + numstat. - index.ts: lifecycle with heartbeat/cancel, serialized per-provision event seq, best-effort terminal result. Tests (node:test): gitOps.test.ts is the verifiedBy — a recording fake-git proves the credential/no-push guarantees and greps the bundle; plus event-table, agent-runner (fake CLI: stderr→log, stdin prompt, allowlist env), and lifecycle (protocol mismatch names both versions, clean/dirty paths). * fix(scripts): wave-implement — default workflow agent for implementers (persona agents drop StructuredOutput on long runs) * feat(dev-platform): W0 unit w0-ui — minimal /admin/dev-platform surface Epic #470 W0, UI spec (issue comment 4922825984). Adds the operator-facing admin surface for the dev platform: - /admin/dev-platform hub: repos | jobs tabs (deep-linkable via ?tab=), repo table with credential-mode text colors, protection verdicts and first-run empty panel; job table with the spec's exact status->token mapping, waiting/failed row edges, client-side filters and a 5s poll as the W0 live-update fallback - add-repo wizard (own page): device-flow quick start with text-only polling state (user code focal, .lume-busy-dots, no spinner), the --warning-edge plain-language trade-off block, PAT fallback, and the branch-protection verdict (warns loudly, never blocks) - job detail: DevJobPhaseRail (eight stops, role=tablist, roving tabindex, Left/Right/Home/End keyboard support, aria-current=step vs aria-selected kept distinct, ?phase= deep link), live log pane on useStickToBottom + ScrollToBottomButton, SSE via new useDevJobEvents (modeled on useSpecEvents), metadata sidebar via useFormatter() - shared promoted components under app/_components/devjobs/ (DevJobPhaseRail, DevJobStatusText — single source of the status map) - every string in messages/en.json + de.json (i18n:check green) Lume invariants hold: state is text color + edges only, no fills, no spinners, no toasts, no skeleton shimmer; only Button variants. * fix(dev-platform): w0-shim review findings — gated LLM auth, job-scoped HOME, userinfo-URL guard, wall-clock kill - LLM auth passthrough is now gated: OMADIA_ANTHROPIC_* crosses into the child CLI only when the backend plumbs OMADIA_LLM_ENV_ALLOWED=true (set exclusively under the W0 jail acknowledgment DEV_PLATFORM_UNSAFE_LOCAL); buildAgentEnv refuses to wire ANTHROPIC_* without llmEnvAllowed even when a caller supplies a token. W1 per-job proxy tokens replace this. - Child HOME is ALWAYS job-scoped (workspace/home, mkdir'd by the shim, cwd fallback) — the parent HOME with the runner user's ~/.claude credentials is never inherited. - cloneAtBaseSha rejects clone URLs with embedded userinfo before fetching a token or invoking git (argv/.git-config credential bypass). - spec.limits.wallClockMs is enforced: timer kills a hung CLI (SIGTERM, then SIGKILL after killGraceMs), streams a budget_exceeded status event, fails the job with the budget named, exits 1. - Tests updated/added for all four: gated env (no 'bearer' leak codified), HOME-in-workspace + parent-HOME canary, userinfo rejection with no git invocation, wall-clock kill with a sleeping fake CLI. * feat(dev-platform): W0 unit w0-backend-local — RunnerBackend seam + jailed LocalProcessBackend runnerBackend.ts re-exports the canonical seam from types.ts and adds the admission-bearing DevJobProvisionContext (source, repo.runsTests) plus a typed RunnerBackendError; provision() fails closed when the admission facts are missing. LocalProcessBackend (spec #470 W0 §1/§5): - constructs only under the explicit DEV_PLATFORM_UNSAFE_LOCAL=true acknowledgment (boot warning naming the restriction) and a dedicated unprivileged uid (never 0) - refuses runs_tests=true repos and any non-admin source at its own boundary - shim env is ALLOWLIST-built from nothing: PATH/LANG/TERM, a job-scoped HOME (never the parent HOME), the five OMADIA_* shim inputs, and the OMADIA_LLM_ENV_ALLOWED gate set if and only if the jail acknowledgment is present — no Vault path, DATABASE_URL, or CLAUDE_CONFIG_DIR can leak - spawns the shim detached in its own process group as the jail uid; a shim.pid file makes orphans findable across a middleware restart - terminate() SIGTERMs the process group, escalates to SIGKILL after the grace window, and removes the workspace; hostile runner_handle ids that escape the workspace root are refused (no arbitrary rm -rf from DB JSONB) - reap() SIGKILLs orphan pids from previous runs, removes leftover workspaces, and returns dead tracked handles so the worker can finalizeDevJob(..., 'stalled') Verified: middleware build green; devplatform suite 150 pass (15 new); dev-runner-shim suite 31 pass. * fix(dev-platform): reap SIGKILLs the dead tracked runner's process group Review finding on w0-backend-local: the tracked-dead branch of reap() removed the workspace and returned the handle but never signalled the process group. If the shim dies abnormally (OOM/SIGKILL/segfault) its finally-cleanup never runs, and the CLI child it spawned — carrying ANTHROPIC_BASE_URL/AUTH_TOKEN via the gated passthrough — survived as an orphan making authenticated LLM calls after the job was finalized 'stalled'. Now the branch kills the whole group with SIGKILL before removing the workspace, exactly like the sibling untracked-orphan branch. Test: FakeProcs models process groups (kill(-pgid) hits every live member; the group outlives a dead leader), plus a regression test: dead shim leader + live CLI child in the group → reap kills the child and removes the workspace. * fix(dev-platform): terminate() keys liveness on the process group, not the shim leader terminate() gated its entire kill sequence on the LEADER pid being alive. When the shim died abnormally (OOM/SIGKILL) its finally-cleanup never ran and its process group could still hold a live claude CLI child carrying ANTHROPIC_* credentials — terminate() then sent no signal, removed the workspace + pid file, and dropped the handle, leaving the child permanently unreapable. Same class of hole the previous fixup closed in reap()'s tracked-dead branch, now mirrored into terminate(): - isGroupAlive(): probe kill(-pid, 0) with a leader-pid fallback - terminate() gates SIGTERM -> grace -> SIGKILL on GROUP liveness and waitForGroupExit() polls the group, so a dead leader with a live child still gets the full escalation before the workspace is removed - new test: dead leader + live group child => group is SIGTERM/SIGKILLed and the child is dead before the workspace disappears; the existing idempotency test still passes (a fully dead group draws zero signals) * fix(dev-platform): reap's orphan branch kills the group unconditionally The leader pid in a stale pid file may be long dead while its process group still holds a CLI child holding ANTHROPIC_* credentials. Probing the leader first reintroduced the blind spot terminate() and the tracked-dead branch had already closed. * fix(dev-platform): confirm process-group exit before deleting a runner workspace Signal delivery is not proof of exit. terminate() and both reap() branches SIGKILLed the runner group and then immediately rm-ed the workspace + pid file — the only handles a later reap() has. When the group survived (EPERM masked by killTree, or exit slower than the grace window) that stranded a live, credential-bearing CLI child as a permanent unreapable orphan. - killTree() now REPORTS its outcome instead of swallowing every error: 'signalled' | 'gone' | 'failed'. EPERM (group alive, unsignalable) is 'failed', not silently retried as a single-pid kill. The single-pid fallback fires only on ESRCH-on-group (the non-detached edge where the leader might still be individually signalable). - New killGroupAndConfirmExit() kills then confirms via waitForGroupExit; it returns true only when the group is provably gone. terminate() and both reap() branches now delete the workspace only on confirmed exit and otherwise LEAVE the workspace + pid file for the next reap sweep (terminate() throws local_terminate_incomplete, caught by finalizeDevJob's onError). - provision()'s post-spawn catch now tears down the already-live shim group before rm, and keeps the workspace + writes a pid file if it cannot confirm exit, instead of orphaning a running child. Tests: model EPERM (unkillable) and signal-surviving (immortal) group members in FakeProcs, plus a configurable post-spawn failure; add 6 cases covering the retain-on-unconfirmed-exit paths and the live-child spawn-failure cleanup. * test(dev-platform): widen makeBackend spawnOpts to match makeSpawn (review nit) * feat(devplatform): W0 devJobWorker — claim loop, enforcement, apply, auth admission Add the in-process control loop for the epic #470 job spine: - claim loop bounded by DEV_PLATFORM_MAX_CONCURRENT_JOBS (active-slot aware) - stall -> stalled and wall-clock -> budget_exceeded, both via finalizeDevJob - host-side apply of the uploaded diff bundle: applying -> done (+ pr_url) once, failure -> failed with the diff artifact retained for the apply retry - auth-mode admission at the worker boundary (subscription refused for runs_tests repos, non-admin sources, and when the mode flag is unset) - every terminal transition routes through finalizeDevJob; the worker owns terminate dispatch + onError, so a terminate() throwing local_terminate_incomplete still finalizes and is logged - reaped runners finalized as stalled exactly once (finalize idempotency) All terminal transitions go through finalizeDevJob; the numstat split marker mirrors the shim's NUMSTAT_MARKER wire constant. * fix(devplatform): exempt the host-side apply phase from runner-liveness enforcement `applying` is a host-side phase with no live runner: the shim posts its diff and exits 0 by design, then the host commits it. It was still subject to every runner-liveness path, so a normally-completing apply was pre-empted and lost: - reapBackends() finalized an `applying` job `stalled` when the backend reaped the (deliberately) dead pid before applyReady() ran. `stalled` is terminal, so POST /jobs/:id/apply refused to retry — the PR was unrecoverable (blocker). - enforceTimeouts() applied stale-heartbeat and wall-clock enforcement to `applying` jobs, finalizing them `stalled`/`budget_exceeded` across a restart or a long agent run — same lost-PR outcome by a second route (major). Fix: add `isHostSideApplyPhase` and skip `applying` in both reapBackends() and enforceTimeouts(); reorder tick() so applyReady() runs before the liveness sweeps (defense in depth). Split the pure policy/wire helpers into devJobWorkerPolicy.ts to land devJobWorker.ts back under the 500-line guideline (minor). Tests cover: a full tick() applying a job whose runner pid is already dead (asserts apply runs, pr_url stored, done, never stalled); reapBackends dropping an applying job's reaped handle; and an applying job that is BOTH stale and over wall-clock still being applied, not finalized. * chore(dev-platform): reconcile w0 manifest — worker policy module, wire-unit store seams * feat(devplatform): wire W0 dev-platform behind DEV_PLATFORM_ENABLED + worker store seams Epic #470 W0 hub unit. Bind the merged dev-platform modules into the middleware boot path and implement the three DevJobStore seams the worker injects. Wiring (src/devplatform/wireDevPlatform.ts, src/index.ts, src/config.ts): - assembleDevPlatform builds the whole subsystem from the shared pool + vault; mountDevPlatform attaches the auth-gated admin router at /api/v1/admin/dev-platform and the job-token-gated runner router at /api/v1/dev-runner. The runner mount carries NO session guard by design — the per-job bearer token is its only auth (a guard there is a full auth bypass). - Dark by default: DEV_PLATFORM_ENABLED unset mounts nothing and starts no worker; it also requires the Postgres graphPool. The worker starts with DEV_PLATFORM_MAX_CONCURRENT_JOBS and stops cleanly on SIGTERM/SIGINT. - config.ts adds the DEV_PLATFORM_* env vars and two boot-time REFUSALS (subscription mode without its ack; unsafe-local without a uid). Store seams (src/devplatform/devJobStore.ts + devJobWorkerSeams.ts, split for the file-size guideline): countActiveJobs (concurrency gate), findActiveByHandleId (reap mapping that EXCLUDES applying, so a completing job is never finalized stalled), and prepareProvision (mint the one-time runner token — sha256 stored, plaintext returned once — and pin branch/base_sha, lease-fenced). UI: /admin/dev-platform nav leaf + admin-page card, en/de strings. Docs: middleware/.env.example documents every new var. Test: test/devplatform/devPlatform.e2e.test.ts — boot refusals (always) plus a DB-gated full loop that mounts both routers the way index.ts does, drives a job with a fake runner phoning home over HTTP and a stub forge, and asserts the runner router needs only a job token while the admin router needs a session. * fix(dev-platform): the runner router was behind the blanket /api auth guard index.ts mounts `app.use('/api', requireAuth, chatRouter)`, and express runs that middleware for every /api/* request whichever router answers. The runner phone-home router therefore 401'd for every runner call in production, so no job could ever finish — while the e2e test built a bare express() app and proved the opposite. Extract the public-paths allowlist into src/auth/publicPaths.ts so index.ts and the e2e test consume the same array, add /api/v1/dev-runner to it, and mount the guard in the test the way production does. Removing the entry now fails two tests instead of only failing in production. * docs(dev-platform): W1 unit manifest + option-D spike result + W0 handoffs * feat(dev-platform): omadia/dev-runner image + publish both dev images (#470 W1) Add the hardened per-job runner image at middleware/sidecars/dev-runner/ and publish it — plus the not-yet-built dev-runner-daemon image — from the release workflow, version-locked to the middleware release. The image runs as USER 1000:1000 with tini as PID 1 (reaps the CLI and git on SIGTERM), CLAUDE_CONFIG_DIR=/tmp/claude-cli, and no openssh-client (HTTPS-only clone in W1). It bundles the existing @omadia/dev-runner-shim workspace package (compiled in a build stage, not duplicated) and pins the Claude CLI to CLAUDE_CODE_VERSION=2.1.187 — the same pin as the root middleware Dockerfile. No credentials or omadia secrets are baked in. publish-images.yml gains dev-runner and dev-runner-daemon matrix entries under ghcr.io/byte5ai, each tagged :<OMADIA_VERSION> and :sha-<gitsha> so the daemon can pin the launch image by digest. The daemon build step is guarded on its Dockerfile's presence so the workflow stays valid until the w1-daemon-service-api unit adds it. * feat(dev-platform): daemon<->middleware wire protocol schema (W1 #470) Add the control-plane wire protocol for the runner daemon, dual-defined as duplicated zod schemas: the middleware copy at src/devplatform/daemonProtocol.ts and the standalone daemon copy at sidecars/dev-runner-daemon/src/protocol.ts. The daemon package must not inherit the middleware dependency surface, so the definitions are duplicated and kept honest by a contract test that snapshots both copies to JSON Schema and diffs them (drift fails CI). The POST /v1/jobs body is exactly { protocol, jobId, leaseTtlSec } via strictObject: a body smuggling env/image/egressAllowlist/limits is rejected by the schema itself (review finding S3 - a clamp that trusts caller-supplied policy is not a clamp). Every request carries protocol: 1; a mismatch throws WireProtocolMismatchError naming both versions. Distinct from the W0 phone-home RUNNER_PROTOCOL_VERSION. Adds a minimal package.json/tsconfig so the schema and its test typecheck; the full daemon scaffold lands in w1-daemon-service-api. * feat(dev-platform): server-side job-policy derivation (epic #470 W1) Add deriveJobPolicy() as the single source of a dev job's effective policy — image, env, and egress allowlist — derived from the dev_repos row and the job's auth_mode, never from the caller (review finding S3). Both the new internal endpoint and (later) DockerBackend.provision call this one function so they cannot disagree. Mount GET /api/v1/dev-runner/internal/job-policy/:jobId (in its own module to respect the 500-line rule), authenticated by the DAEMON token — a distinct credential from the per-job djr_ runner bearer, which is rejected there. The effective allowlist is {middleware host} u {forge host(s) from clone_url} u DEV_EGRESS_BASE_ALLOWLIST u dev_repos.egress_allowlist, read live per job so an added host takes effect on the next job with no restart. Subscription jobs get the direct Anthropic hosts plus the CLI off-switches and no LLM-proxy base url. The derived env never carries a provider key, a Vault path, DATABASE_URL, or the daemon token. * feat(dev-platform): add W1 middleware LLM proxy (epic #470) Anthropic-compatible reverse proxy that keeps provider API keys out of job containers. The Claude CLI in a job is handed ANTHROPIC_BASE_URL -> the proxy and a per-job djr_ bearer as its ANTHROPIC_AUTH_TOKEN; the proxy validates the bearer as a job token and swaps in the real provider key from Vault before forwarding. - createLlmProxyRouter (src/devplatform/llmProxy.ts): GET / probe (2xx for the CLI's reachability check), POST /v1/messages (and ?beta=true). Validates the job token, rejects terminal jobs (410), enforces the ctx.llm model allowlist (403 dev.model_not_allowed), attaches the Vault provider key as x-api-key and strips any client x-api-key / Authorization, streams the upstream response back verbatim (SSE passthrough, no buffering), and meters usage from message_start/message_delta into dev_jobs.tokens_in/out plus the token_usage ledger (source='dev-job', sessionId='devjob:<jobId>'). - Failure semantics: 429 passes Retry-After through; a 5xx is retried once, but only before any byte reaches the client, so a stream that already emitted tokens is never retried (no double-billing); a mid-stream disconnect still meters the partial usage and logs the truncation. - devRunnerApi.ts: mount the proxy sub-router at /llm on the phone-home router (it does its own per-job bearer auth, so no session/authMw). - llmProxy.test.ts: covers every acceptance criterion incl. the no-retry-after-tokens rule and header stripping. The store/Vault/policy/usage seams are injected; the real wiring lands in the DockerBackend/wire unit. * fix(dev-platform): wire W1 keystones + close audit findings (epic #470) Addresses the w1-layer1-audit-fixes review rejection. Keystone wiring (were built but never mounted in prod): - assembleDevPlatform now threads DEV_RUNNER_DAEMON_TOKEN, the job-policy config (image + egress base + middleware host + llm-proxy base url) and an always-mounted LLM proxy into createDevRunnerRouter; index.ts + config.ts supply them. New store seams DevJobStore.resolveJobByToken (hash lookup) and addJobUsage (atomic increment). The e2e now asserts the internal job-policy endpoint answers a daemon token, rejects a djr_ runner bearer, and that GET /llm/ probes 2xx while a job token reaches the proxy + model gate. Hardening: - llmProxy: refuse duplicate JSON keys (model-allowlist bypass); build upstream headers from an allowlist (drops cookie/x-forwarded-*/proxy-authorization/ hop-by-hop); strip auth-like response headers + redact the provider key from non-stream error bodies; write dev_jobs (authoritative) before the ledger and surface — never swallow — an accounting-write failure. - deriveJobPolicy: SSRF-guard clone_url (https only, no userinfo, reject internal/metadata hosts via the shared ssrfGuard predicate) and validate each egress entry as a bare hostname, dropping IP literals / schemes / ports / wildcards / CIDRs with a loud log. - daemonProtocol (both byte-parallel copies): jobId is a UUID; leaseTtlSec is bounded to [30,3600]. - ssrfGuard: export isInternalHost/isInternalIp for reuse. * fix(dev-platform): close W1 audit round-2 security findings (epic #470) Address the five review findings on the W1 keystones: - llmProxy: canonicalise the request body. Parse it, enforce the model allowlist on the parsed object, and forward JSON.stringify(parsed) with content-length recomputed — never the raw bytes. Kills the whole "validate one representation, forward another" class (duplicate keys, \u-escaped keys, whitespace/nesting). Reject a non-object top-level body. - llmProxy: redact the provider key on the STREAMED path too. A rolling window (holds back N-1 bytes, N = key length) catches a secret split across SSE chunks, so an upstream error event can no longer leak the key into the job container. - llmProxy: strip headers named by the Connection header (hop-by-hop, RFC 7230 §6.1) before applying the forward allowlist. - devJobStore.resolveJobByToken: verify the bearer with a constant-time timingSafeEqual after the indexed lookup, and return null for terminal jobs so the proxy answers 401 (not 410) for both — no valid/terminal token oracle. - ssrfGuard/deriveJobPolicy: strip a single trailing FQDN dot before the internal-host checks so `metadata.google.internal.` / `localhost.` can no longer slip into the egress allowlist; reject malformed (`..`, NUL) hosts. Tests cover each finding; full devplatform suite 269/269 green with live pg. * chore(dev-platform): escape control bytes in deriveJobPolicy's regex A literal NUL and DEL inside the character class made git treat the file as binary, so every diff of it was unreviewable. Semantically identical. * Revert "chore(dev-platform): escape control bytes in deriveJobPolicy's regex" The commit was made from a stale working tree and silently reverted every security fix from e1f2517 (canonicalised body, StreamRedactor, timing-safe token resolve, hostname normalisation, Connection-header stripping). * chore(dev-platform): escape control bytes in deriveJobPolicy's regex A literal NUL and DEL inside the character class made git treat the file as binary, so every diff of it was unreviewable. Semantically identical (\x00-\x20 plus \x7f). * fix(security): IPv4-mapped IPv6 literals bypassed the SSRF internal-host guard new URL('https://[::ffff:127.0.0.1]/').hostname canonicalises to the hex form '[::ffff:7f00:1]', which the '::ffff:' dotted-quad strip did not recognise, so loopback and RFC1918 targets slipped past isInternalHost/isInternalIp. Both predicates now normalise either spelling before the range checks. Found by the cross-family audit of epic #470 W1; ssrfGuard is shared with the MCP and OAuth-metadata fetch paths, so the bypass was not dev-platform-only. * feat(dev-platform): W1 runner daemon scaffold + bearer-authed control-plane API Epic #470 W1 (unit w1-daemon-service-api). The daemon is the only process that talks to the docker engine, keeping a docker socket out of the middleware next to its Vault. This lands the standalone service scaffold; the clamp, image warmer, lease reaper and egress proxy mount on the ContainerEngine seam it defines. - src/daemon.mjs: node:http control-plane server. Routes POST/DELETE /v1/jobs, POST /v1/jobs/:id/lease, GET /v1/jobs, GET /v1/jobs/:id/logs, GET /v1/health, POST /v1/warm — every route bearer-gated, /v1/health included. Binds only the control-plane interface (DEV_DAEMON_BIND) and refuses a wildcard bind so nothing listens toward dev-engine. Binds to protocol.ts (no schema redefined). - src/auth.mjs: DEV_RUNNER_DAEMON_TOKEN, >=32 chars enforced at boot, constant- time compare, comma-separated rotation list for zero-downtime rollover. - src/policyClient.mjs: fetches GET /api/v1/dev-runner/internal/job-policy/:jobId with the daemon token and validates the response. The daemon derives policy itself; the caller names a job, never a policy (review finding S3). - src/jobs.mjs: JobManager (idempotent create, in-flight de-dup, lease, reap source) + the ContainerEngine seam. createDockerEngine wires dockerode to dind over TLS (DOCKER_HOST) and implements ping; the mutating lifecycle methods are the seam the clamp unit fills, throwing EngineNotImplementedError until then. - Dockerfile: node:22.23.1-alpine, non-root, tini; dockerode + zod only. Standalone package (dockerode + zod + node builtins), never imports middleware. 26 daemon tests green (real server over a real socket); tsc --noEmit clean; docker build succeeds; middleware devplatform suite (incl. protocol parity) green. * fix(dev-platform): daemon-side policy clamp + pinned/bounded policy fetch (epic #470 W1) Close the round-3 W1 daemon audit findings — the daemon must be the last line of defence, not a courier for whatever the middleware returns. - policyClient: clamp the UNTRUSTED job-policy response daemon-side. The image repository must be in DEV_RUNNER_ALLOWED_IMAGES (refused at boot when empty); the image must be digest-pinned unless DEV_RUNNER_REQUIRE_DIGEST is off (default on); a floating/malformed digest is refused; the policy env may not carry a reserved key (DOCKER_*, DEV_RUNNER_DAEMON_TOKEN, PATH, LD_PRELOAD, LD_LIBRARY_PATH, NODE_OPTIONS). Any violation throws before the policy reaches createJobContainer, so no container is created from a rejected policy. - policyClient: refuse to follow a 30x redirect (redirect: 'error') so the fetch stays pinned to the configured endpoint. - policyClient: keep the abort signal armed across the body read and cap the body at 256 KiB, reading the stream with a byte counter — a slow-body or oversized peer now fails fast instead of hanging/exhausting the daemon. - jobs: dockerOptionsFromEnv enforces the tcp:// scheme with an explicit host+port and refuses unix/http/https/ssh/npipe; requires DOCKER_TLS_VERIFY=1 and a readable DOCKER_CERT_PATH, with a clear fatal message per failure. - Dockerfile: default DEV_RUNNER_REQUIRE_DIGEST=true and document the required DEV_RUNNER_ALLOWED_IMAGES. - Tests: new policyClient + dockerOptions suites (image/env/digest clamp, redirect/oversized/slow-body transport safety, DOCKER_HOST rejection table). * fix(dev-platform): invert daemon policy-env clamp to an allowlist (epic #470 W1) A denylist (RESERVED_ENV_KEYS + DOCKER_ prefix) could not enumerate the command-execution env keys a runner image carries: it blocked NODE_OPTIONS and LD_PRELOAD but forwarded GIT_SSH_COMMAND, GIT_EXTERNAL_DIFF, GIT_PROXY_COMMAND, BASH_ENV, ENV and LD_AUDIT. A compromised middleware — this unit's stated adversary — could ship policy env {GIT_SSH_COMMAND: ...} and get arbitrary execution on the next git invocation, the exact class NODE_OPTIONS/LD_PRELOAD were blocked for. Invert assertPolicyEnv to an ALLOWLIST (ALLOWED_ENV_KEYS): accept only the exact keys the runner needs (shim inputs, LLM/CLI behaviour keys, egress proxy vars in both spellings, benign locale/tooling), refuse everything else by key name (never its value). Error code daemon.env_reserved_key -> daemon.env_key_not_allowed. Tests cover the GIT_*/BASH_ENV/LD_AUDIT/ENV refusal table, every allowlisted key passing, mixed rejection, and the rejection naming the key but never the value. * fix(dev-platform): HOME and CLAUDE_CONFIG_DIR are not allowlistable policy env Both were on the daemon's env allowlist (my own suggestion — wrong). They are command-execution levers, not settings: an attacker-controlled HOME supplies ~/.gitconfig, whose core.pager / core.sshCommand / alias.* all execute, and an attacker-controlled CLAUDE_CONFIG_DIR supplies Claude Code hooks. Admitting them from an untrusted policy reopens the arbitrary-execution class the allowlist exists to shut. The image fixes both to job-scoped paths, exactly as localProcessBackend already does for the shim. * fix(dev-platform): daemon owns runner identity/CLI env, never the policy (epic #470 W1) The daemon-side policy clamp allowlisted four keys whose VALUES are execution or redirection sinks, so a compromised/spoofed middleware could still steer the runner even through the clamp: - OMADIA_CLI_BIN: the shim reads it as cliBin and agentRunner spawns it; a policy value like ./pwn runs an attacker binary from the cloned repo. - OMADIA_JOB_BASE_URL: the shim's homeClient builds every phone-home URL and the bearer header from it; a hostile value redirects the runner's spec fetch, SCM-token fetch, events, diff and result to an attacker host. - OMADIA_JOB_ID / OMADIA_WORKSPACE: let the middleware skew a job's identity or the clone directory. These are DAEMON-OWNED values the daemon already knows (its own middleware URL, the UUID-validated request jobId, the container's fixed /workspace, its configured CLI). Remove all four from ALLOWED_ENV_KEYS; reject a policy that carries any of them LOUDLY (daemon.env_key_reserved — a policy that supplies one is a compromised middleware, not a stray key); and INJECT the daemon-owned values on every fetched policy. OMADIA_JOB_TOKEN stays policy-supplied — the middleware legitimately mints it and, with the base URL now daemon-pinned, a hostile token can no longer be aimed anywhere. Wired from DEV_RUNNER_JOB_BASE_URL (default: the daemon's own middleware URL), DEV_RUNNER_WORKSPACE (default /workspace), DEV_RUNNER_CLI_BIN (default claude). * fix(dev-platform): daemon owns egress proxy, clamps egress allowlist, closes DELETE/create race (epic #470 W1) Review round-4 fixes for w1-daemon-egress-and-races: - HTTP(S)_PROXY / NO_PROXY (both spellings) are now DAEMON-OWNED, not policy-supplied. They are a generic egress-routing lever (redirect every http(s) client incl. git), unlike ANTHROPIC_BASE_URL. Removed from ALLOWED_ENV_KEYS, added to DAEMON_OWNED_ENV_KEYS (a policy carrying one is rejected), and injected by the daemon from its own config (DEV_RUNNER_EGRESS_PROXY_URL / DEV_RUNNER_NO_PROXY). - egressAllowlist is clamped daemon-side with the same rigour as the image: each entry must be a bare hostname (no scheme/port/path/userinfo/wildcard/ CIDR/control chars), not an IP literal. Ported classifyEgressEntry + isInternalIp into a standalone netClassify.mjs (copied, not imported) with a parity test. List length capped; a bad entry rejects the whole policy. - DELETE racing an in-flight create no longer leaks an unreaped container: JobManager.destroy marks the id cancelled when it is in #inflight, and #provision tears down the just-created container instead of registering it, throwing JobCancelledError (mapped to 409). Deterministic race test added. - The policy response's jobId is constrained to a UUID and must equal the requested id; a mismatch is rejected (daemon.policy_job_mismatch). * fix(dev-platform): close egress numeric-IP bypass + daemon resource-exhaustion holes (epic #470 W1) Review hardening for w1-daemon-final-hardening (4 findings): - Egress allowlist 'no IP literal' clamp was bypassable with non-dotted IPv4 spellings: WHATWG URL parsing canonicalises 2130706433 / 0x7f.0.0.1 / 017700000001 / 3232235777 / 127.1 to loopback/RFC1918, but the label-shaped match accepted them as hostnames — allowlisting internal targets. Both copies of classifyEgressEntry (daemon netClassify.mjs + middleware deriveJobPolicy.ts) now canonicalise each entry through `new URL('http://'+entry+'/')` and reject any entry the parser rewrites (whole class: numeric/hex/octal/short-form) or that resolves to a v4/bracketed-v6 IP literal. Parity fixtures extended in both test files (numeric forms + bracketed IPv6 / IPv4-mapped-IPv6). - JobManager.destroy() dropped the job from the registry BEFORE awaiting the engine teardown, so a failed docker cleanup leaked a container nothing tracked. Now tears down FIRST and only forgets the job on success; on failure it keeps the record (retryable) and raises JobCleanupError → HTTP 502. - #jobs / #inflight were unbounded. Added admission caps (DEV_RUNNER_MAX_LIVE_JOBS=8, DEV_RUNNER_MAX_INFLIGHT_JOBS=4): a new job past either cap raises JobCapacityError → HTTP 429 and creates nothing; idempotent re-attach to an existing job is still allowed. - GET /v1/jobs/:id/logs?follow=1 now destroys the upstream docker stream on client disconnect / res error, caps concurrent follows (DEV_RUNNER_MAX_LOG_FOLLOWS=4) → HTTP 429, and applies idle + absolute timeouts so abandoned follows cannot pin streams/sockets. * fix(dev-platform): reserve the log-follow slot before the await, not after The cap was check-then-act: it counted before `await engine.streamLogs()` and incremented after. Real docker I/O spans event-loop turns, so a concurrent burst all observed activeFollows === 0 and every request was admitted. The slot is now reserved in the same turn as the check and released on every exit path. The existing test opened follows sequentially against a stub that resolved in one microtask, so it could not exercise the race. The new test bursts six requests at a stub that resolves on a macrotask, and fails against the old logic. Also completes both egress parity tables with the [::1] and [::ffff:127.0.0.1] literals the acceptance criteria name. * fix(dev-platform): canonicalise the daemon bind; accept the whole token rotation list Two findings from the cross-family audit: - The wildcard-bind guard matched spellings. Node binds 0, 000.000.000.000, ::0 and 0:0:0:0:0:0:0:0 to the wildcard just as it binds 0.0.0.0, so the control API could be exposed toward dev-engine through a spelling the Set did not list. The bind is now canonicalised before comparison — the same rule the egress classifier applies, for the same reason. - DEV_RUNNER_DAEMON_TOKEN is a comma list so a token can rotate with zero downtime, and the daemon accepts every entry inbound — but the middleware compared against the raw string, so mid-rotation every policy lookup 401'd. Both ends now accept the list; every candidate is compared without early exit. * fix(dev-platform): four residual daemon findings from the sign-off audit - The bind guard still accepted ::ffff:0.0.0.0 (URL compresses it to ::ffff:0:0), which node listens on as the wildcard. Bind canonicalisation now reduces an IPv4-mapped IPv6 to its v4 form, reusing netClassify's toDottedQuad. - assertPolicyEgress classified canonical hosts and then forwarded the RAW policy list, so 'GitHub.com.' and '[registry.npmjs.org]' reached the engine unnormalised. It now returns the classified hosts and the caller uses them. Same class as the LLM proxy's body canonicalisation. - A create racing an in-flight DELETE was handed the doomed record, because the record survives until teardown is proven. Ids being destroyed are tracked and an overlapping create is refused. - Malformed percent-encoding in a job-id path threw URIError and surfaced as a 500; it is a bad request and now answers 400. * fix(dev-platform): the cancel path must not drop the handle either When a DELETE cancels an in-flight create, #provision tears the just-created container down. If that teardown throws, the frame unwound without registering the record — and the container, possibly still running, had no handle for a later DELETE or the reaper. destroy() already refuses to cause that leak; the cancel path now refuses it too, registering the record before rethrowing. * fix(dev-platform): honour a disconnect during streamLogs; DELETE awaits the cancelled create - A client that vanished while dockerode was still opening the log stream was never noticed: the stream resolved into nobody's hands, was never destroyed, and its reserved follow slot was never released. Enough of those and every later follow 429s until restart. The disconnect is now recorded from the moment the slot is taken and honoured when the stream arrives. - destroy() answered success as soon as it flagged an in-flight create as cancelled. If the cancel teardown then failed, the container survived and stayed tracked while the DELETE caller had been told the job was gone. It now awaits the create's outcome and reports failure when the container survives. * test(dev-platform): two race tests deadlocked against the new destroy() contract destroy() now awaits the in-flight create's outcome, so a test that awaited it before releasing the create gate could never settle. Node reported this as `cancelled 9` with `fail 0` — the whole file ran zero assertions while the summary looked green. Both tests now start the delete, release the gate, then await the result. * fix(dev-platform): one teardown per job id, and forget only the record you tore down Two concurrent DELETEs both entered destroy(). The first tore the container down, dropped the record and cleared the guard; a fresh create could then register a new job — and when the second DELETE finally returned, its unconditional `#jobs.delete(jobId)` removed that new record, leaving its container untracked. The same lost-handle invariant the previous fixes protect. Teardowns are now deduplicated per id (both callers await the same promise) and the registry entry is only removed if it is still the record that was destroyed. * feat(dev-runner-daemon): real DockerEngine with the §4 hardening clamp (epic #470 W1) Fill the ContainerEngine seam's mutating methods behind the same signature the JobManager depends on. The clamp IS the isolation: - buildContainerCreateOptions is a PURE, exported function that builds the dockerode create-options from nothing (allowlist over HostConfig, never a scrub-list): non-root 1000:1000, read-only rootfs, CapDrop ALL / no CapAdd, no-new-privileges, memory+swap cap, NanoCpus, PidsLimit, nofile ulimit, tmpfs /tmp (no noexec), the per-job volume as the only bind, the per-job bridge as NetworkMode, RestartPolicy no. Forbidden fields are absent by construction. - The engine hands that exact object to docker.createContainer (the validated object is the launched object), after creating the per-job network+volume; a floating-tag image fails with a spec_rejected-shaped error before any resource is created; any create failure rolls the partial resources back. - destroyJobContainer removes container (graceful stop then force), network and volume, VERIFIES each is gone, is idempotent on 404s, and surfaces a failed removal so the caller keeps the handle to retry. - streamLogs returns combined stdout/stderr as a Readable honouring follow; warmImages pulls each ref and returns the resolved digests. Tests: clamp table asserts the exact create-options object (required present, forbidden absent, exact key sets); engine driven against a fake dockerode for provision/rollback/idempotent-teardown; a real-dockerode integration check (env-guarded) confirms a genuine hardened container via docker inspect and reaps it. All 197 prior daemon tests still green; tsc clean; docker build succeeds. * fix(dev-runner-daemon): clamp refusal is a 400; demux job logs; pick the right RepoDigest Four follow-ups on the clamp unit, three of them from its own review: - SpecRejectedError mapped to 400 daemon.spec_rejected. The unit could not add it (daemon.mjs was outside its file set) and it surfaced as a 500: a caller naming an image the daemon refuses to run deserves to be told, not to see an internal error. - streamLogs demuxes docker's 8-byte frame headers. The clamp creates non-TTY containers, so a following operator was reading docker's wire format. The test fake now emits real frames — a stub that hands back clean text could never have caught this. - warmImages matched RepoDigests[0], which for a multi-registry image can be a digest from a different registry. It now matches on the repository actually pulled, with a parser that does not mistake a registry port for a tag. - Dropped a dead if-branch whose body was a comment. * fix(dev-runner-daemon): a failed rollback must name what survived createJobContainer collected its rollback errors and then threw the original failure, discarding them. The job is never registered on that path, so nothing holds a handle on a container/network/volume the rollback could not remove — the error was its only possible trace. It now raises CreateRollbackError naming the deterministic omadia-job-<id> resources, so an operator or the reaper can find them. Same lost-handle invariant destroy() already protects. * fix(dev-runner-daemon): demux the one-shot log path too Only the follow path was demuxed. A plain GET /v1/jobs/:id/logs handed the operator docker's 8-byte frame headers, because the container is non-TTY on both paths. The test fake now frames both, so the gap cannot reappear. * fix(dev-runner): the job workspace must be writable by uid 1000 The clamp runs the runner as uid 1000 with a read-only rootfs and mounts a fresh named volume at /workspace. Docker copies an image directory's contents AND its ownership into an empty named volume on first mount — but only when that directory is non-empty. /workspace was an empty, root-owned dir, so every volume came up root:root and the shim's first clone would have died with EACCES. The platform would not have run a single job. Found by the cross-family audit, which reproduced it against the built image. A marker makes docker carry the 1000:1000 ownership across, and a docker-gated integration test now asserts the property on the real image — it fails against a Dockerfile without the marker. * fix(dev-runner): the job workspace must be writable by uid 1000 The clamp runs the runner as uid 1000 with a read-only rootfs and mounts a fresh named volume at /workspace. Docker copies an image directory's contents AND its ownership into an empty named volume on first mount — but only when that directory is non-empty. /workspace was an empty, root-owned dir, so every volume came up root:root and the shim's first clone would have died with EACCES. The platform would not have run a single job. Found by the cross-family audit, which reproduced it against the built image. A ".keep" marker makes docker carry the 1000:1000 ownership across, and a docker-gated integration test now asserts the property on the real image — it fails against a Dockerfile without the marker. * feat(dev-runner-daemon): image-cache warming loop with digest pinning and health state (#470 W1) Add src/warmer.mjs: a periodic image-warm loop wrapping the engine's warmImages(refs), plus the warm-state object GET /v1/health reports. - warms on boot and on a configurable interval (default 6h); the timer is unref'd, never stacks (a tick joins the in-flight pull), and stops on stop() - getState() exposes { digests, warm, lastWarmedAt, lastError }; warm is never true until a pull has succeeded and resolved >=1 digest - concurrent warms join one engine pull (no stampede); a pull failure is logged, recorded in lastError, retried next tick, never rejects the loop, and never marks the image warm or clobbers the last-good digest set - digests are stored exactly as the engine resolved them (repositoryOf-matched in jobs.mjs), so a multi-registry ref keeps the right repository's digest Standalone module: daemon.mjs is untouched (reaper unit owns it in parallel); the orchestrator wires the warmer into /v1/health, /v1/warm, and main() via the seam documented at the top of warmer.mjs. * feat(dev-runner-daemon): enforce lease expiry, sweep orphans, rebuild state on boot The daemon recorded and renewed leaseExpiresAt but never ENFORCED it, so a wedged or compromised middleware could create allowed-image jobs and pin dind resources forever. Add a periodic reaper that makes the daemon self-authoritative for containers (spec #470 W1 §7): - lease enforcement: a live job past its lease is torn down through the SAME deduplicated path a DELETE uses (JobManager.destroy) — cleanup proven before the record is forgotten, a failed teardown surfaced and the handle retained for the next sweep. No second teardown path. - boot-time state rebuild: labelled containers are re-adopted (a daemon restart does not orphan a live job) and any already past its lease is reaped (boot_stale). - orphan sweep: labelled containers/networks/volumes with no tracked job are removed, including the partial network/volume a failed create could not roll back (deterministic omadia-job-<id> names); a job that is live OR mid-create is never swept. - configurable cadence (DEV_RUNNER_SWEEP_INTERVAL_MS), single-flighted, unref'd timer stopped on daemon close, survives an engine error and retries next tick. - every reap logs the job id and reason (lease_expired | orphan | boot_stale). Adds ContainerEngine.listManagedResources (label-set reconciliation source), JobManager.adopt/tracks, and wires the reaper into the daemon boot in main(). * feat(dev-runner-daemon): mount the image warmer; /v1/health reports its state The warmer unit could not touch daemon.mjs (the reaper was editing it in parallel), so it shipped unmounted — the third time in this epic that a correct component was built and never wired. main() now starts it before traffic and stops it with the server; /v1/health reads the warmer's state and /v1/warm joins its de-duplicated pull. The new test failed for the wrong reason at first: startDaemon dropped the warmer dep, so the assertions passed through the fallback path. The harness now threads it, and the test fails when /v1/health stops reading the warmer. * fix(dev-runner-daemon): a daemon restart must not kill the jobs it adopts The boot rebuild read each container's lease from its create-time label, but a live job's lease is renewed in memory — and that memory dies with the daemon. A job renewed for an hour looked, from its label alone, an hour overdue, so the rebuild would reap exactly the jobs it exists to preserve. Latent until W2 wires renewal; a trap laid for a later wave. boot_stale now keys on the honest signal: a container that is no longer running is a corpse whatever its lease says. A running container is adopted with a grace window (one sweep …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch implements three related issues around plugin security and quality, hardened through four review rounds (three Claude adversarial reviews + cross-vendor codex review).
#453 — Advisory SkillSpector code scanning for ingested packages
Every executable plugin package that passes
PackageUploadService.ingest(direct upload, hub install, Builder install) is statically scanned by an NVIDIA SkillSpector sidecar (middleware/sidecars/skillspector/, enabled viaSKILLSPECTOR_URL, deterministic--no-llmmode, dependency pinned to an exact upstream commit SHA). The scan is fire-and-forget after ingest success: a scanner outage or an unsetSKILLSPECTOR_URLcan never fail or delay an install. Verdicts are cached by ZIP sha256 + scanner version (migration0021_plugin_verdict.sql), surface as a badge +verdictfield on the store detail page, and support an operator ack with a persisted audit trail (ack_severityrecorded; ack cleared automatically when a re-scan worsens the verdict). Advisory-only in v1 — nothing blocks.The result pipeline is fail-closed end to end:
issues, not the initially guessed keys) and answersok: falseon exit != 0 / non-JSON / schema mismatch; the middleware parser accepts only the promised contract. Anything else is recorded asscan_failed, never as a falseno_signalsall-clear.lifecycle.entrythat resolves belownode_modules,.git, or any hidden segment (package.entry_unscannable) — the scanner's directory walk skips those, so the runtime would otherwise execute code the scan never saw. As defense in depth, the scanner force-includes the manifest's entry file in the scan payload when the walk skipped it, and recordsscan_failedwhen coverage cannot be guaranteed (missing file, symlink, oversize, root escape, undeterminable manifest).#361 — Free-text user-prompt PII masking (default off)
harness-plugin-privacy-guard0.3.0 adds a default-offmask_user_promptsetup field. When on, PII spans detected in the user's own message (C0 regex baseline: email, IBAN, phone, German street+postal address, amounts, DOB dates) are replaced with realistic pseudonyms before the prompt crosses the LLM wire, via the shipped v4 pseudonym-projection mechanism with a server-held per-turn map — no on-wire token map. Real values are restored server-side in the final answer and persisted content; masked spans surface (PII-free) asmaskedPromptSpanson thePrivacyReceipt. Failure-closed: a baseline failure or residual span blocks the turn; there is no pass-through-unmasked path. Flag-off is byte-identical to previous behavior.Every LLM-bound copy consumes the masked wire variant: message assembly, live chat history (
priorTurns, which replays persisted real values), ingested attachment tail, mid-turn steering messages injected viaPOST /chat/steer(codex review fix — steered text now runs through the same per-turn map before the iteration loop folds it into the conversation, so answer-side restore covers steered spans and a blocked mask fails the turn closed), model/persona routing, KG-recall query, recalled-context injection, direct-line relay payloads, fact-extraction prompts, nudge pipeline, card router, and the excerpt pass. Server-side persistence stores real values only; user-facing card content is restored before rendering. A committed validation harness (de+enfixtures, C0 tier) documents the recall/precision/latency gates required before enabling the flag for a locale.#431 — v1.0 readiness pass across the earliest core plugins
Missing READMEs added (web-search, privacy-guard, quality-guard) with purpose, config keys, and published capabilities;
agent-seo-analystoperator-catalog description translated to English; privacy-guardpackage.jsonaligned with its manifest; the v4 path declared the single canonical privacy-guard implementation; explicit adopt/skip decisions recorded forctx.jobs/ctx.status/ctx.llm/ctx.mcpper plugin. No wire-behavior changes.Maintainer decisions & things to sign off in this review
Refs, notCloses) — the C1 transformer (Piiranha/GLiNER) evaluation across 6 locales remains its deliverable; the shipped C0 baseline does not detect names ("Anna Schmidt" is not masked; disclosed in help text and CHANGELOG).docker-compose.skillspector.yamloverlay + a comment line indocker-compose.yaml+ a new Python sidecar image (stdlib-only shim). Default stack is behavior-unchanged; follows the existing overlay pattern (storage/diagrams/embeddings). Please sign off explicitly.0021. The dev-platform epic Epic: Integrated dev platform — isolated per-job runners that clone, develop, test, and ship PRs #470 specs also reserve 0021–0024 — whichever lands second must renumber.Tests and verification
New/extended coverage: orchestrator-level prompt-mask pipeline tests (main call, fact extraction, recall injection, priorTurns, steering — zero raw spans in the request body, surrogate on the wire, restored answer), upload rejection for
node_modules/hidden entry points, scan-payload coverage guard, verdict store/scheduler tests including a pg-gated run, and sidecar parser fail-closed tests.main(incl. fix(web-ui): humanize provider errors across chat surfaces (#403) #472): typecheck clean, 213/213 tests.no_signals; exfiltration fixture →flagged(E1); six malformed payload shapes →scan_failed.Closes #453
Closes #431
Refs #361