From 0673e947d257bd9946c3937b17ae63b4cb8a2a9a Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 13:51:35 +0200 Subject: [PATCH 01/51] docs(spec006): distinguish lifecycle retries from agent input Avoid duplicate input after an uncertain agent-send response while retaining durable workspace operation recovery. Refs: #72 --- specs/006-whr-cli/spec.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/specs/006-whr-cli/spec.md b/specs/006-whr-cli/spec.md index 56406aed..007e61ae 100644 --- a/specs/006-whr-cli/spec.md +++ b/specs/006-whr-cli/spec.md @@ -313,12 +313,13 @@ verify host-key pinning, credential lifetime, exit status and refusal for foreig `version` and shell completion. Bare `whr` and `--help` print help; `--version` equals `version`. - **FR-002**: Every workspace command MUST act through the same owner-authenticated application operations as the browser, with identical authorization, idempotency, trust, audit and confirmation rules. -- **FR-003**: Every mutating command MUST send an idempotency key: the caller's, or else one the CLI generates and +- **FR-003**: Every workspace lifecycle mutation (`ws create`, `start`, `stop`, `rebuild`, `rm`) MUST send an idempotency key: the caller's, or else one the CLI generates and reports before sending. The CLI MUST reuse that key for its own bounded retries, report the durable operation identifier, and fail as a conflict when a key is reused with a changed payload. -- **FR-003a**: Mutating commands MUST wait for a terminal operation state by default, support `--no-wait` (return +- **FR-003a**: Workspace lifecycle mutations covered by FR-003 MUST wait for a terminal operation state by default, support `--no-wait` (return the operation identifier immediately) and `--timeout`. Interruption or timeout MUST NOT cancel the operation or report it complete; the CLI exits with the interrupted or timeout code and the operation identifier. +- **FR-003c**: Interactive streams and agent controls follow their session contracts, not lifecycle-operation waiting. `agent send` MUST NOT be retried automatically after uncertain delivery; report delivery as unknown and require explicit owner resend. Host-local setup, service and offboarding use resumable local checkpoints rather than workspace operation records. - **FR-003b**: `whr op show` and `whr op wait` MUST retrieve an operation by identifier or idempotency key, so that an operation whose response was lost can be found without creating a duplicate. - **FR-004**: `ws rm` MUST delete runtime only by default; data deletion MUST require a separate flag and an @@ -514,8 +515,8 @@ verify host-key pinning, credential lifetime, exit status and refusal for foreig - **SC-012**: Automated tests map every exit path (missing and incompatible session interface, unattended refusals, remote statuses 0, 1 and 255 for `shell`, `console`, `ssh` and `agent attach`) to the code the exit-code ADR assigns. -- **SC-011**: A lost-response test (drop the reply to a mutating request) yields exactly one operation, retrievable - with `whr op show --key`. +- **SC-011**: A lost-response test (drop the reply to a workspace lifecycle mutation) yields exactly one operation, retrievable + with `whr op show --key`. A lost `agent send` response reports unknown delivery without an automatic resend. ## Assumptions From a183ad2114bbb11017135dbeff4b48f24b1cb0e9 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 13:51:35 +0200 Subject: [PATCH 02/51] docs(pilot): plan installable workspace and CLI work items Define the sequential implementation and independent review route, selective Python-to-Go parity work, and real-host install/recovery acceptance without claiming a runnable product. Refs: #67, #72 --- docs/exec-plans/active/installable-pilot.md | 30 +++++ specs/006-whr-cli/contracts/owner-api.md | 30 +++++ specs/006-whr-cli/data-model.md | 20 +++ specs/006-whr-cli/migration.md | 29 +++++ specs/006-whr-cli/plan.md | 134 ++++++++++++++++++++ specs/006-whr-cli/quickstart.md | 45 +++++++ specs/006-whr-cli/research.md | 39 ++++++ specs/006-whr-cli/tasks.md | 108 ++++++++++++++++ 8 files changed, 435 insertions(+) create mode 100644 docs/exec-plans/active/installable-pilot.md create mode 100644 specs/006-whr-cli/contracts/owner-api.md create mode 100644 specs/006-whr-cli/data-model.md create mode 100644 specs/006-whr-cli/migration.md create mode 100644 specs/006-whr-cli/plan.md create mode 100644 specs/006-whr-cli/quickstart.md create mode 100644 specs/006-whr-cli/research.md create mode 100644 specs/006-whr-cli/tasks.md diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md new file mode 100644 index 00000000..e2469c90 --- /dev/null +++ b/docs/exec-plans/active/installable-pilot.md @@ -0,0 +1,30 @@ +# Installable pilot execution record + +Owner request: 2026-10-14, continue on `feat/installable-pilot` in the same worktree, +include Python-to-Go migration, independently review/fix each WI, PR after all WIs. +Baseline: `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`. +Authority: [Feature 001](../../../specs/001-workspace-lifecycle/spec.md), +[Feature 006 plan](../../../specs/006-whr-cli/plan.md), +[tasks](../../../specs/006-whr-cli/tasks.md), +[migration](../../../specs/006-whr-cli/migration.md). + +## Progress + +- Created and retained the pilot worktree; native permission approved branch switch. +- Read-only exact-baseline spec review identified a lifecycle/session retry ambiguity. + Draft correction scopes durable retries to workspace lifecycle; uncertain agent + input delivery is not automatically resent. Re-review pending. +- Official dependency research completed; proposal records concrete version/layout + choices and identifies runtime-dependent transport/SSH decisions before those WIs. +- Pilot plan, migration inventory, data model, owner API design, tasks and validation + guide drafted. No product code, task completion or live result claimed. +- Documentation checker and whitespace check passed before task-file generation; + final candidate checks/review remain pending. + +## Execution limitations + +Native write approval was initially denied because the chat was scoped to SheetMill; +after explicit return/authorization, WorkHarbor writes were approved. No indirect +workaround was used. The configured Docker read-only probe did not complete and +was interrupted; this establishes no available host. No runtime was activated. +Signed release and live test/installation inputs are separate owner-controlled gates. diff --git a/specs/006-whr-cli/contracts/owner-api.md b/specs/006-whr-cli/contracts/owner-api.md new file mode 100644 index 00000000..5787e0af --- /dev/null +++ b/specs/006-whr-cli/contracts/owner-api.md @@ -0,0 +1,30 @@ +# Owner API design + +Status: proposed, not live. Prefix `/api/v1`, verified TLS, versioned payloads. +CLI connects only to the control plane; provider handles stay opaque. + +| Group | Capability | +| --- | --- | +| auth | secret-channel login, own credential status/revoke; no secret URL/argument | +| workspaces | owner-scoped read and lifecycle, exact revision trust, separate destructive confirmation | +| operations | own ID or workspace/key lookup and waiting; preallocated identity supports lost create reply | +| hosts | owner enrollment/rotation/revoke/confirmation; agent outbound mTLS and assigned scope | +| access | fresh scoped grants, bounded shell/console/SSH, console status/close | +| agent-sessions | versioned contract v1 forwarding; authorization before lookup; no native-tool launch | +| spec-kit | immutable read projection only; read-scoped dashboard cannot mutate any endpoint | + +Every workspace lifecycle request carries owner-scoped stable workspace identity, +key and canonical normalized body hash. Durability precedes provider mutation. +Transient retries retain identity/key. Host-local checkpoint commands and session +controls do not use lifecycle waiting. Lost agent send response is unknown and +never automatically resent. + +Publish root `contracts/cli/output.schema.json` in the output WI. Nonstream stdout: +one versioned envelope; stream stdout: typed JSON events with base64 bytes and a +final CLI/remote exit event when transport permits. Human progress goes to stderr. +Decoded stream frames <=16 KiB; encoded limits include base64/envelope overhead. +Agent send <=65,536 decoded bytes, checked before partial delivery. No compression. +Server enforces Host/Origin/anti-forgery, scope, runtime generation and attachment +bounds. Stateful terminal filtering spans chunks and bounds unterminated control +strings; C1 introducers and arbitrary bytes are covered by negative fixtures. +Audit identities/action/outcome, never raw input, grants or credential payloads. diff --git a/specs/006-whr-cli/data-model.md b/specs/006-whr-cli/data-model.md new file mode 100644 index 00000000..720624ab --- /dev/null +++ b/specs/006-whr-cli/data-model.md @@ -0,0 +1,20 @@ +# Feature 006 data model + +Status: proposed extension of Feature 001 domain contracts. + +| Entity | Fields and invariant | +| --- | --- | +| CLIProfile | canonical HTTPS endpoint, server identity, owner, credential reference/scope/expiry; private atomic files, no workspace/Git secret. | +| OwnerCredential | owner, opaque token hash, scopes, expiry/revocation; dashboard cannot mutate or mint mutation grants. | +| Submission | preallocated owner/workspace/key, canonical request hash and operation; changed payload conflicts before provider calls. | +| ConsoleSession | workspace, runtime generation, start time, process/PTY, state, attachments, 64 KiB byte ring and dropped count; one active/workspace. | +| Attachment | owner/workspace/session/audience, consumed grant, monotonic lifetimes, quotas/buffers; independent of session lifetime. | +| Grant | issuer/key ID, owner, workspace/generation, audience, kind/operations, optional session, jti/expiry; attach atomically consumed across endpoint restart. | +| HostCheckpoint | host/operation, completed/remaining steps, confirmed revocation; private, atomic, no embedded secrets, incomplete never success. | +| ArtifactProjection | repository/feature/task, commit/paths/content hashes, parser capability, evidence source/time and availability; checkbox never approval. | +| Envelope/Event | schema version, typed data or redacted error, operation/key where known, CLI/remote exit kind; base64 stream bytes. | +| PlatformEvidence | exact source, command, client/host versions, observed results/date; a build target is not verified platform support. | + +Retain Feature 001 ownership, separate storage lifecycle, desired/observed state, +generation checks and operation outcomes. Restore resets observations to unknown +and does not start runtime. Lost agent-input delivery is unknown, never auto-resend. diff --git a/specs/006-whr-cli/migration.md b/specs/006-whr-cli/migration.md new file mode 100644 index 00000000..3cb5ffcc --- /dev/null +++ b/specs/006-whr-cli/migration.md @@ -0,0 +1,29 @@ +# Selective Python-to-Go migration + +Status: proposed inventory and acceptance; ports not implemented. +Source pin: `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`. +Authority: [ADR 0010](../../docs/adr/0010-go-whr-cli-layout.md). + +| Existing Python behavior | Go target / disposition | +| --- | --- | +| `scripts/provider_profiles.py`: strict JSON parser, canonical bytes/digest | Candidate pure wire primitives in `apps/api_client/wirejson`; first prove product relevance and freeze duplicate keys, UTF-8, integer/float rules, non-ASCII ordering and exact digests. | +| `scripts/single_task_context.py`: canonical lexical relative paths | Candidate config validator in `apps/host_agent/internal/config`; freeze traversal/absolute/noncanonical negative cases. Runtime symlink/provider validation remains a separate requirement. | +| Other Python canonical serializers | Inventory differing domains/errors; never silently replace one profile or digest with another. | +| Task state/approval, recovery, tracker publication, Symphony and profile eligibility | Retain Python development tooling. No product orchestration port in wave one. | +| CI runners, verifiers and fixtures | Retain the independent Python harness and add explicit Go acceptance. | +| Python development CLI output/exit codes | Not inherited. Product contract is Feature 006 / ADR 0013. | + +## Migration WI acceptance + +Freeze selected source pins, input bytes, success/rejection classifications, +canonical output bytes and SHA-256 hashes before the Go port. Generate in a +confined offline environment. Independently review cases including nested duplicate +keys, malformed UTF-8, boolean/integer distinctions, large integers, floats, +non-finite values, supplementary-plane key ordering and path attacks. +Write failing Go tests first. Compare byte-for-byte rather than parsed equality. +Go tests use checked-in vectors, never runtime Python; an independent Python job +reproduces the frozen vectors. Retain the existing Python tests. A changed vector +requires reviewed decision/evidence, not regeneration to fit Go output. +Review parity and product dependency boundaries before switching callers. +Record each retained, ported or deliberately replaced behavior. Do not claim +wholesale Python migration; that would require a superseding ADR and new scope. diff --git a/specs/006-whr-cli/plan.md b/specs/006-whr-cli/plan.md new file mode 100644 index 00000000..6f952999 --- /dev/null +++ b/specs/006-whr-cli/plan.md @@ -0,0 +1,134 @@ +# Implementation Plan: WorkHarbor installable pilot + +Branch: `feat/installable-pilot` | Date: 2026-10-14 +Spec: [spec.md](spec.md) +Baseline: `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd` +Status: draft planning work in progress; no implementation or verification claim. + +## Authorized objective + +The owner explicitly requested on 2026-10-14 that work continue in this WorkHarbor +worktree, on this branch, through the installable pilot, including selective +Python-to-Go migration, independent review and fixes after each work item, and +one PR after all pilot work items are accepted. The same worktree is retained. +This is a WorkHarbor task; no SheetMill files or sibling product scope are changed. + +## Work-item sequence + +1. Complete and review the combined Feature 001 / Feature 006 pilot design and + acceptance mapping. Resolve retry semantics before product code. +2. Pin and scaffold Phoenix control plane, Go host agent, CLI and shared API client; + establish explicit application format, build and test gates. +3. Inventory selected Python behavior, freeze reference vectors, port pure product + primitives to Go and prove parity. Retain the Python development harness. +4. Implement ownership, authentication, durable lifecycle operations and audit. +5. Implement mTLS enrollment, host identity rotation/revocation and reconnect. +6. Implement trusted Git/devcontainer resolution, Docker provider and persistent data. +7. Implement browser and CLI lifecycle paths, bounded terminals and persistent console. +8. Implement agent-session interoperation and read-only Spec Kit dashboard. +9. Implement isolated SSH, resumable host setup/offboarding and diagnostics. +10. Implement backup/restore, local install packaging and real-host/browser acceptance. +11. Resolve all review findings, run exact-head required checks and open the combined PR. + +## Migration boundary + +ADR 0010 permits selective ports with frozen Python behavior vectors. Python remains +development tooling, not a product dependency. Do not migrate Symphony, tracker, +provider eligibility or task-approval orchestration into product code. Product exit +codes come from ADR 0013 rather than Python development-script exit codes. + +## Review and evidence + +Every work item records its scope, prerequisites, actual check results and exact +candidate reviewed. Fix actionable findings before successor work. Missing tools, +unknown runtime outcomes and mocks are not passing real-host acceptance. Signed +release, deployment, activation and merge gates remain separate owner decisions. + +## Technical design + +Phoenix 1.8.15 / Elixir 1.20.4 / OTP 29 control plane with Ecto/SQLite; +Go 1.27.2 host agent, CLI and exported API client in separate modules under +`apps/host_agent`, `apps/whr`, `apps/api_client`, with a root `go.work`. +Pin direct and transitive dependencies in setup and prove clean-checkout builds. +Use Cobra 1.10.2/pflag for interspersed flags and completion, without Viper. +Standard net/http verifies TLS, refuses credential redirects and applies deadlines. +Use coder/websocket 1.8.15 for WSS relay and golang-jwt/jwt 5.3.1 for Ed25519 +operation-scoped grant verification. No dependency version is claimed tested yet. + +Linux/systemd/Docker is the first host target. Client build targets are Linux and +macOS amd64/arm64; mark only actual exercised combinations verified. Windows is +unsupported until credential ACL and terminal evidence exist. Reuse the official +Dev Container CLI, pin its exact tested version in the provider WI, and reject +untested capabilities before hooks or provider mutation. + +The control plane owns owner login, scoped opaque credentials, durable operations, +audit, trust records and the owner API. Host agents connect outbound over mTLS. +The CLI never contacts a provider or host agent directly. Local host commands use +explicit privileged adapters. Dashboard credentials authorize reads only; browser +cookies are Secure, HttpOnly and SameSite=Strict, with Host/Origin and anti-forgery +validation on requests and upgrades. No direct credential in URLs or arguments. + +A lifecycle submission allocates its stable workspace identity and idempotency key +before transmission. Retrying preserves both; changed payload conflicts. Retry at +most three times at 1/2/4 seconds on documented transient failures. Operator wait +interruption never cancels provider work. Agent input is not automatically resent. +The console is one process/session per workspace with a 64 KiB byte ring, dropped +byte reporting and attachment-independent lifetime. Use Feature 001 quotas, grant +TTLs, idle/maximum duration and cleanup bounds. Cancel pumps/release accounting +independently of graceful WebSocket close; force close within the five-second bound. + +Grant claims bind issuer, owner, workspace, runtime generation, audience, kind, +operations, session when applicable, jti and expiry. Attach issuance and reusable +control-grant lifetimes are 60 seconds; no expiry grace. Use configured public keys, +never token-selected URLs. Atomic attach consumption survives interface restart; +emergency revocation closes active attachments. Validate before session lookup. +Agent-owned sessions remain separate from the platform console. The provider +session transport and SSH server/certificate provisioning contract must be finalized +and reviewed before those WIs; adding a hidden helper or configuring an agent tool +is not an acceptable substitute for an authorized in-container interface. + +`whr ssh` uses Go SSH over an owner-authorized byte tunnel with a pinned server +key and fresh certificate, without personal SSH state. External config uses private +isolated files, states expiry and warns that external-client traffic is unfiltered. +Unsupported SSH capability fails before connection. Host setup/offboarding use +private atomic local checkpoints; success requires health or confirmed revocation, +respectively. Dry-run has no writes and incomplete/offline is never success. + +Artifact views use repository/feature/task identity, immutable Git commits/content +hashes, parser capability, evidence source/time and availability. Safely render +untrusted Markdown; never execute it or fetch embedded references. Missing objects +or unsupported layouts remain unavailable, not fabricated task completion. + +## Existing design reconciliation + +Committed constitution v1.1.0 permits ordinary in-container agents; all tracker, +AI orchestration and provider account management remain development tooling. +Feature 001's older plan wording excluding product AI and conflating PTY/session +lifetime is superseded only by the accepted constitution and ADR 0012; its actual +lifecycle, authorization, recovery and release requirements are retained. +The explicit user execution request supplies implementation scope, but does not +supply a live host grant, signed release, merge or evidence that checks passed. + +## Installable-pilot acceptance + +All Feature 001 tasks and Feature 006 stories remain required. Additional command +surface (rebuild/removal, CLI auth/operations, host administration, agent controls, +dashboard, SSH and release verification) is not completed by a scaffold. Each task +requires its own meaningful failure-path tests and independent review. +Application checks: Go format/vet/build/tests per module, Phoenix format, +warnings-as-errors compile and tests, plus inherited `make check` using Python +3.11+ in a pristine committed checkout. Extend the reviewed acceptance runner and +manifest explicitly; do not weaken discovery or call missing tools passing. + +Real acceptance: fresh install, TLS login, enrollment/rotation/revocation, trusted +image/Dockerfile provisioning, console reattachment, stop/start/rebuild with exact +uncommitted file hashes, interrupted-operation recovery without duplicate runtime, +agent interface, dashboard authorization/browser-origin negatives, SSH, resumable +offboarding and quiesced clean restore. Measure Feature 001 five-workspace/eight- +attachment load, timings and idle/load footprint. Stub tests do not prove real use. + +Local unsigned archives are labelled development artifacts. Signed root release +tag, public distribution, package channels and deployment need separate owner +authorization. Do not claim SC-006 from an unsigned archive. Actual disposable +Linux host, TLS hostname/certificate, live-test scope and signer allowlist are +owner-controlled inputs; a configured Docker context establishes none of them. diff --git a/specs/006-whr-cli/quickstart.md b/specs/006-whr-cli/quickstart.md new file mode 100644 index 00000000..01b01fb9 --- /dev/null +++ b/specs/006-whr-cli/quickstart.md @@ -0,0 +1,45 @@ +# Installable pilot validation + +Status: future acceptance guide, not a runnable product claim. + +## Existing checks + +From the committed pilot checkout with Python 3.11+: + +```sh +python3 scripts/select_feature.py 006-whr-cli +make check PYTHON=python3.14 +git diff --check +``` + +Native permission is required for the runner's isolated temporary Git worktree. +A permission error is blocked, not passing. After scaffolding, run format/vet/build/ +tests in each Go module and Phoenix format/compile-with-warnings-as-errors/tests. +Run independent Python/Go golden-vector parity before switching migrated callers. + +## Real acceptance after implementation and separate live authorization + +1. Record exact head, versions, Linux Docker host, client, TLS identity and approved + disposable scope. Install from a clean local archive and perform hidden-input + owner login, enrollment and health. Dry-run must write nothing. +2. Provision trusted image/Dockerfile workspaces; deny changed revisions before hooks. + Compare operation/audit/state through browser and CLI, including lost responses. +3. Type/resize, detach a running console, measure <=5s relay cleanup, reattach with + a new grant and verify retained/dropped output. Exercise grants/quotas/buffers. +4. Stop/start/rebuild and inject failures/restarts; compare uncommitted file hashes + and prove inspection/reconciliation creates no duplicate runtime. +5. Exercise conforming real agent interface and independent stub, without requiring + an AI subscription. Lost send reply must not resend. Mock evidence is labelled. +6. Reject dashboard mutation, foreign owner and hostile browser origin requests; + prove immutable Git source display and explicit unavailable states. +7. Exercise isolated SSH, generated config, key mismatch/expiry, statuses 0/1/255 + and transport loss on an advertised SSH-capable fixture. +8. Quiesce backup, clean restore, retained hashes/outcomes, unknown observations, + revoked identity rejection and resumable offboarding with data retained by default. +9. Exercise five workspaces/eight attachments; capture exact commands, timings and + idle/load footprint. Missing real host/browser results block pilot acceptance. + +Package archives/checksums/SBOM/provenance and clean install/uninstall instructions. +Unsigned local pilot artifacts are not signed releases; SC-006 remains open until +an owner-authorized signed-tag verification exercise. Publish the combined PR only +after all pilot WIs clear independent review and exact-head required checks pass. diff --git a/specs/006-whr-cli/research.md b/specs/006-whr-cli/research.md new file mode 100644 index 00000000..74ff5db2 --- /dev/null +++ b/specs/006-whr-cli/research.md @@ -0,0 +1,39 @@ +# Pilot research + +Status: researched choices for implementation review; nothing claimed implemented. + +- Separate Go modules plus root workspace: [Go workspaces](https://go.dev/doc/tutorial/workspaces). + Build distribution archives from the full checkout; independent module publication + needs directory-prefixed tags and is not implied by a root release tag. +- Cobra/pflag handles flags after positional arguments and completion; + [stdlib flag](https://pkg.go.dev/flag) stops at the first positional. + [Cobra guide](https://github.com/spf13/cobra/blob/main/site/content/user_guide.md). + Silence framework printing and route failures through the frozen JSON/exit layer. +- [Coder WebSocket](https://pkg.go.dev/github.com/coder/websocket): encoded limits + include base64 expansion; disable compression. Graceful Close can exceed five + seconds, so cleanup must cancel/release independently and force close by deadline. +- [JWT best practices](https://www.rfc-editor.org/info/rfc8725/) and + [Go validation options](https://pkg.go.dev/github.com/golang-jwt/jwt/v5): require + explicit EdDSA, issuer/audience/expiry, configured keys, runtime generation and + atomic replay consumption. A valid signature alone does not enforce single use. +- [Go SSH](https://pkg.go.dev/golang.org/x/crypto/ssh): FixedHostKey, fresh certs and + an owner-authorized tunnel avoid personal config/agent access. External + [OpenSSH config](https://man.openbsd.org/ssh_config) needs isolated known hosts, + identities, disabled forwarding/connection sharing and no untrusted shell expansion. +- Linux host management uses fixed argument vectors for systemctl, atomic private + state, and explicit Docker privilege. Docker-group membership is host-privileged; + service sandbox flags do not turn it into a hostile-workload isolation boundary. + [systemd source](https://github.com/systemd/systemd/blob/main/man/systemd.exec.xml). +- [Go build flags](https://pkg.go.dev/cmd/go): trim paths, stamp exact VCS revision; + derive build date from commit/tag for reproducibility. Embedded version is not + provenance. [GitHub attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations) + and [Syft](https://github.com/anchore/syft) are candidates for reviewed release jobs. + Pin action/tool commits, publish drafts only, and retain downloadable verification. + +## Decisions still needed before dependent WIs + +Provider-reachable in-container endpoint transport without undeclared helper or +native-tool configuration; SSH-enabled workspace/server-key provisioning; actual +Linux disposable acceptance host and live authorization; signed release signer. +These do not block pure scaffolding or the selected migration-vector WI, but do +block the corresponding interoperation/live/release acceptance claims. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md new file mode 100644 index 00000000..7cf58a52 --- /dev/null +++ b/specs/006-whr-cli/tasks.md @@ -0,0 +1,108 @@ +# Feature 006 pilot work items + +Status: proposed execution baseline; no product task is complete. +Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. +Input: [spec](spec.md), [plan](plan.md), [research](research.md), +[data model](data-model.md), [migration](migration.md), [owner API](contracts/owner-api.md). + +Each Tnnn below is one WI. Execute in dependency order, write failure-path tests +before behavior, run actual acceptance, then obtain independent review and fix all +findings before its successor. Each WI records its exact candidate and reviewed +revision. Existing Feature 001 task IDs remain authoritative and are completed only +with their own required evidence. This list maps the combined delivery; it does not +silently weaken Feature 001 or mark its tasks complete. + +## Phase 1: Setup + +Goal: locked, reproducible buildable applications, not a usable product. +Independent check: clean-checkout format/build/tests with missing-tool failures. + +- [ ] T001 Scaffold and pin Phoenix/Ecto/SQLite in `apps/control_plane/`, Go modules `apps/host_agent/go.mod`, `apps/whr/go.mod`, `apps/api_client/go.mod`, root `go.work` and `.tool-versions`; lock dependencies and pass each application format/build/test gate (Feature 001 T001/T011). +- [ ] T002 Add explicit application checks and meaningful required test inventory to `scripts/check_ci.py`, `docs/development/ci-acceptance.json`, `Makefile` and `.github/workflows/check.yml`; prove local/CI equivalence, fail on missing tools and preserve all inherited tests (001 T012; ADR 0007/0010). + +## Phase 2: Foundations and migration + +Goal: product boundary, durable owner state and reviewed Python-to-Go parity. +Independent check: negative vectors, cross-language bytes and no runtime Python. + +- [ ] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). +- [ ] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). +- [ ] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). +- [ ] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). +- [ ] T007 Define/validate versioned host protocol in `contracts/host-protocol/`, `apps/control_plane/lib/workharbor/host_protocol.ex`, `apps/host_agent/internal/protocol/`; reject invalid version/scope/replay before provider calls and redact diagnostics in both suites (001 T003). + +## Phase 3: US1 — Authenticate and manage workspaces (P1) + +Goal: lifecycle and operation recovery shared by browser and CLI. +Independent check: trusted image/Dockerfile create, start/stop/rebuild/removal, +retained files and exactly one operation after lost responses. + +- [ ] T008 [US1] Implement one-time enrollment, mTLS identity/rotation/revocation and outbound heartbeat/reconnect in `apps/control_plane/lib/workharbor/hosts.ex`, `apps/control_plane/lib/workharbor/host_connections.ex`, `apps/host_agent/internal/identity/`, `apps/host_agent/internal/connection/`; exercise concurrency/expiry/scope and real handshake/reconnect; stale after 30 seconds (001 T004/T014). +- [ ] T009 [US1] Implement trusted config/submodule resolution, owner revision approval, official pinned Dev Container CLI and Docker storage/provider operations in `apps/host_agent/internal/config/`, `apps/host_agent/internal/providers/docker/`, `apps/control_plane/lib/workharbor/config_trust.ex`, `examples/workspace/` and `docs/development/devcontainer-matrix.md`; prove refusal/unsupported config has no hooks/provider mutation and real Docker retains files (001 T005/T016/T006). +- [ ] T010 [US1] Implement lifecycle reconciliation, bounded nonoverlapping retries and durable start/stop/rebuild/runtime-versus-data deletion in `apps/control_plane/lib/workharbor/operations.ex`, `apps/control_plane/lib/workharbor/workspaces.ex`, `apps/host_agent/internal/service/`, `apps/host_agent/internal/providers/docker/`; test interrupted provisioning, generations, failed rebuild, exhausted retries and exact uncommitted hashes on Docker (001 T007/T020; FR-003–005a). +- [ ] T011 [US1] Implement typed TLS owner client and `auth`, `ws`, `op` commands in `apps/api_client/` and `apps/whr/internal/commands/`; test first login, secret refusal, profile permissions, server identity, stable preallocated workspace/key, lost-response lookup and wait timeout/detach (FR-002–005a/035). +- [ ] T012 [US1] Implement enrollment/trust/workspace lifecycle browser views in `apps/control_plane/lib/workharbor_web/live/`; test and record real browser success/error/stale/retry/progress journeys with corresponding audit (001 T015/T017/T009). + +## Phase 4: US2 — Shell and persistent console (P1) + +Goal: scoped bounded access with session lifetime distinct from attachment lifetime. +Independent check: type/resize, detach/reattach, retained tail and no host access. + +- [ ] T013 [US2] Implement scoped atomic grants, bounded relay, fresh shell and one persistent console per workspace in `apps/control_plane/lib/workharbor/sessions.ex`, `apps/control_plane/lib/workharbor_web/channels/`, `apps/host_agent/internal/terminal/`; test all Feature 001 bounds, 64 KiB tail/drop reporting, concurrent single use, endpoint restart replay and <=5s cleanup; stop/rebuild/delete/close end session (001 T008; FR-006–007a). +- [ ] T014 [US2] Implement `open`, `shell`, `console` and browser terminal in `apps/whr/internal/commands/`, `apps/control_plane/lib/workharbor_web/live/terminal_live.ex`, `apps/control_plane/assets/js/terminal.js`; verify input/resize, fresh-grant reconnect, status/close, foreign scope, remote 0–255 and real browser/PTY evidence (001 T018). + +## Phase 5: US3 — Host operations (P2) + +Goal: resumable local administration with truthful state and revocation. +Independent check: dry-run no writes, interrupted setup/offboard resumes safely. + +- [ ] T015 [US3] Implement typed Linux/systemd change plans and atomic checkpoints for `setup`, `service`, `offboard host` in `apps/whr/internal/host/`; test unsupported OS/privilege, enrollment failure, each interrupted step, confirmed revocation before deletion, local-only nonzero and default data retention; exercise a disposable real Linux host (FR-009/011–013). +- [ ] T016 [US3] Implement `doctor` and shareable redacted report in `apps/whr/internal/doctor/`; test pass/fail/skipped/unknown, absent tools, private-file permissions and secret redaction; verify real host findings are measured (FR-010/021). + +## Phase 6: US4 — Stable output and scripting (P2) + +Goal: every command has frozen output/exit semantics and safe terminal rendering. +Independent check: success/failure schema validation and hostile bytes/corpus. + +- [ ] T017 [US4] Implement output envelopes/typed events, fresh exit table, color and framework error routing in `apps/whr/internal/output/`, `apps/whr/internal/exitcode/`, `contracts/cli/output.schema.json`; cover every command/exit path and stream event, stderr/stdout purity, 0/1/255 remote passthrough and unsupported JSON refusal (FR-014–019/027; SC-003/012). +- [ ] T018 [US4] Implement bounded stateful control-string filtering and human-text escaping in `apps/whr/internal/terminal/`; fuzz split/unterminated OSC/DCS/APC/PM/SOS, C1, bidi, arbitrary binary and raw per-session opt-in; prove JSON never emits raw bytes (FR-020–022a; SC-004). + +## Phase 7: US5 — Agent interoperation and read-only dashboard (P2) + +Goal: conforming in-container sessions without native-tool launch or dispatch. +Independent check: all five actions, lost send response and direct API scope negatives. + +- [ ] T019 [US5] Finalize/review provider-reachable session transport and grant verification; implement contract validators, independent conformance fixture and scoped forwarding in `apps/host_agent/internal/agentsessions/`, `apps/control_plane/lib/workharbor/agent_sessions.ex`, `apps/whr/internal/commands/agent.go`, `tests/test_agent_session_contract.py`; test every contract shape/bound/grant/version and unknown delivery without resend, plus real in-container conformance without native-agent configuration (FR-029–031). +- [ ] T020 [US5] Implement immutable Git artifact projection and read-only pages in `apps/control_plane/lib/workharbor/spec_kit.ex`, `apps/control_plane/lib/workharbor_web/live/spec_kit_live.ex`; cover unavailable/unknown layouts, revision/evidence source, safe Markdown, every mutation endpoint called with read credential and hostile browser origins; record browser walkthrough (FR-032/033; SC-008/009). + +## Phase 8: US6 — Isolated SSH (P3) + +Goal: pinned workspace SSH without personal state or host shell access. +Independent check: key/cert failures, remote statuses and external config disclosure. + +- [ ] T021 [US6] Finalize/review SSH-capable fixture, key/certificate provisioning and owner-authorized tunnel; implement `apps/host_agent/internal/ssh/`, `apps/control_plane/lib/workharbor/ssh_access.ex`, `apps/whr/internal/commands/ssh.go`; test pinned keys/expiry/scope, no personal config/agent, generated isolated config, filtering boundary and 0/1/255/lost-transport on real supported fixture (FR-008/008a/019). + +## Phase 9: Pilot install and final acceptance + +Goal: installed recoverable product and exact-head reviewable handoff. +Independent check: clean client/host install, real loaded journey, restore and release verification. + +- [ ] T022 Implement quiesced backup/clean restore in `scripts/backup-workharbor.sh`, `scripts/restore-workharbor.sh`, `docs/development/backup-restore.md`; prove exact uncommitted hashes, owner/storage/operation associations, revoked identity rejection, unknown restored observations, no auto-start and failure to quiesce aborts (001 T019). +- [ ] T023 Implement local archive/install/uninstall, pinned toolchain instructions, checksums, SBOM/provenance and draft-only release machinery in `scripts/pilot/`, `.github/workflows/`, `docs/development/installable-pilot.md`; test clean installation and contents; separately authorized allowlisted human-signed tag exercise is required for SC-006, never claimed from unsigned local artifacts (FR-023–027). +- [ ] T024 Run every story/criterion at five-workspace/eight-attachment load on the authorized real host, record exact commands/versions/timings/failures in `specs/006-whr-cli/quickstart.md`, `specs/001-workspace-lifecycle/quickstart.md`, `docs/quality.md`; fix findings, run exact-head canonical/application gates, obtain independent final review and open the combined PR to main (001 T010; SC-001–012). + +## Dependencies and execution + +T001 -> T002 -> T003 -> T004 -> T005 -> T006 -> T007 -> T008 -> T009 -> +T010 -> T011 -> T012 -> T013 -> T014 -> T015 -> T016 -> T017 -> T018 -> +T019 -> T020 -> T021 -> T022 -> T023 -> T024. + +This deliberately follows the owner's sequential WI/review route in one worktree; +file-disjoint parallelism is not used. Output/error plumbing needed by an earlier +WI is implemented minimally there and receives full matrix acceptance in T017, +not a passing claim before T017. Feature 001 mappings above retain all original +acceptance and task completion rules; review evidence is appended, never fabricated. + +## Evidence log + +Planning only. No product task is checked and no runtime acceptance is claimed. From 016c4dc279b1a4e82c3756d62e1d8266513f0f5b Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 13:56:37 +0200 Subject: [PATCH 03/51] docs(pilot): require terminal safety before attachment acceptance Move baseline text escaping and stream filtering into the first WIs that expose untrusted output so later exhaustive acceptance cannot leave earlier work items unsafe. Record the owner's delegated intermediate review route, pending independent Sol re-review, and corrected design baseline while retaining the frozen migration oracle. Refs: #72 --- docs/exec-plans/active/installable-pilot.md | 21 +++++++++++++++++++-- specs/006-whr-cli/plan.md | 5 +++-- specs/006-whr-cli/tasks.md | 14 +++++++++----- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index e2469c90..31a534fe 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -2,7 +2,17 @@ Owner request: 2026-10-14, continue on `feat/installable-pilot` in the same worktree, include Python-to-Go migration, independently review/fix each WI, PR after all WIs. -Baseline: `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`. +Latest owner instruction: while the owner is out of office, Sol subagents perform +intermediate planning/WI reviews and fixes; the owner reviews the final combined PR. +This supersedes the earlier intermediate human spec/plan-review workflow for this +authorized pilot execution. No historical human artifact approval is asserted, and +no pending intermediate human approval is treated as an execution blocker. +Status: proposed intermediate planning baseline; the prior Sol planning review +identified a terminal-safety ordering finding. Its correction awaits separate +independent Sol re-review before successor work. No product WI is accepted. +Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). +The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; +changing the design baseline does not change frozen Python behavior. Authority: [Feature 001](../../../specs/001-workspace-lifecycle/spec.md), [Feature 006 plan](../../../specs/006-whr-cli/plan.md), [tasks](../../../specs/006-whr-cli/tasks.md), @@ -19,7 +29,14 @@ Authority: [Feature 001](../../../specs/001-workspace-lifecycle/spec.md), - Pilot plan, migration inventory, data model, owner API design, tasks and validation guide drafted. No product code, task completion or live result claimed. - Documentation checker and whitespace check passed before task-file generation; - final candidate checks/review remain pending. + those historical checks do not establish acceptance of later revisions. +- Corrected the planning review finding: T011 requires baseline FR-022 human-text + escaping, T014 requires the full baseline FR-022a stream filter and targeted + hostile/split/unterminated tests, and T018 retains exhaustive corpus/fuzz and + integration acceptance. `python3.14 scripts/check_repo.py` and + `git diff --check` pass for this documentation correction; product runtime is + not evaluated. Independent Sol re-review remains pending; final human review + is reserved for the combined PR. ## Execution limitations diff --git a/specs/006-whr-cli/plan.md b/specs/006-whr-cli/plan.md index 6f952999..884b4840 100644 --- a/specs/006-whr-cli/plan.md +++ b/specs/006-whr-cli/plan.md @@ -2,8 +2,9 @@ Branch: `feat/installable-pilot` | Date: 2026-10-14 Spec: [spec.md](spec.md) -Baseline: `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd` -Status: draft planning work in progress; no implementation or verification claim. +Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` +Status: proposed intermediate execution baseline; separate independent Sol +re-review is pending. No implementation or runtime verification is claimed. ## Authorized objective diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index 7cf58a52..1fa376c4 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -1,6 +1,7 @@ # Feature 006 pilot work items -Status: proposed execution baseline; no product task is complete. +Status: proposed intermediate execution baseline; independent Sol re-review of +the terminal-safety ordering correction is pending. No product task is complete. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), [data model](data-model.md), [migration](migration.md), [owner API](contracts/owner-api.md). @@ -40,7 +41,7 @@ retained files and exactly one operation after lost responses. - [ ] T008 [US1] Implement one-time enrollment, mTLS identity/rotation/revocation and outbound heartbeat/reconnect in `apps/control_plane/lib/workharbor/hosts.ex`, `apps/control_plane/lib/workharbor/host_connections.ex`, `apps/host_agent/internal/identity/`, `apps/host_agent/internal/connection/`; exercise concurrency/expiry/scope and real handshake/reconnect; stale after 30 seconds (001 T004/T014). - [ ] T009 [US1] Implement trusted config/submodule resolution, owner revision approval, official pinned Dev Container CLI and Docker storage/provider operations in `apps/host_agent/internal/config/`, `apps/host_agent/internal/providers/docker/`, `apps/control_plane/lib/workharbor/config_trust.ex`, `examples/workspace/` and `docs/development/devcontainer-matrix.md`; prove refusal/unsupported config has no hooks/provider mutation and real Docker retains files (001 T005/T016/T006). - [ ] T010 [US1] Implement lifecycle reconciliation, bounded nonoverlapping retries and durable start/stop/rebuild/runtime-versus-data deletion in `apps/control_plane/lib/workharbor/operations.ex`, `apps/control_plane/lib/workharbor/workspaces.ex`, `apps/host_agent/internal/service/`, `apps/host_agent/internal/providers/docker/`; test interrupted provisioning, generations, failed rebuild, exhausted retries and exact uncommitted hashes on Docker (001 T007/T020; FR-003–005a). -- [ ] T011 [US1] Implement typed TLS owner client and `auth`, `ws`, `op` commands in `apps/api_client/` and `apps/whr/internal/commands/`; test first login, secret refusal, profile permissions, server identity, stable preallocated workspace/key, lost-response lookup and wait timeout/detach (FR-002–005a/035). +- [ ] T011 [US1] Implement typed TLS owner client and `auth`, `ws`, `op` commands in `apps/api_client/` and `apps/whr/internal/commands/`, with shared human-text escaping in `apps/whr/internal/terminal/` before displaying any untrusted names, errors or remote messages; replace every FR-022 control class with visible placeholders in human output and escape them in JSON. Write targeted hostile-text tests before behavior, alongside first login, secret refusal, profile permissions, server identity, stable preallocated workspace/key, lost-response lookup and wait timeout/detach tests (FR-002–005a/022/035). - [ ] T012 [US1] Implement enrollment/trust/workspace lifecycle browser views in `apps/control_plane/lib/workharbor_web/live/`; test and record real browser success/error/stale/retry/progress journeys with corresponding audit (001 T015/T017/T009). ## Phase 4: US2 — Shell and persistent console (P1) @@ -49,7 +50,7 @@ Goal: scoped bounded access with session lifetime distinct from attachment lifet Independent check: type/resize, detach/reattach, retained tail and no host access. - [ ] T013 [US2] Implement scoped atomic grants, bounded relay, fresh shell and one persistent console per workspace in `apps/control_plane/lib/workharbor/sessions.ex`, `apps/control_plane/lib/workharbor_web/channels/`, `apps/host_agent/internal/terminal/`; test all Feature 001 bounds, 64 KiB tail/drop reporting, concurrent single use, endpoint restart replay and <=5s cleanup; stop/rebuild/delete/close end session (001 T008; FR-006–007a). -- [ ] T014 [US2] Implement `open`, `shell`, `console` and browser terminal in `apps/whr/internal/commands/`, `apps/control_plane/lib/workharbor_web/live/terminal_live.ex`, `apps/control_plane/assets/js/terminal.js`; verify input/resize, fresh-grant reconnect, status/close, foreign scope, remote 0–255 and real browser/PTY evidence (001 T018). +- [ ] T014 [US2] Implement `open`, `shell`, `console` and browser terminal in `apps/whr/internal/commands/`, `apps/control_plane/lib/workharbor_web/live/terminal_live.ex`, `apps/control_plane/assets/js/terminal.js`; implement the complete baseline FR-022a bounded stateful stream filter in `apps/whr/internal/terminal/` before accepting any CLI attachment. Filter OSC/DCS/APC/PM/SOS by default, including split-frame and unterminated strings; require explicit per-session raw opt-in displayed at session start and never persisted, and never emit raw terminal bytes in JSON. Write targeted hostile-stream, split-frame, unterminated-string, bounded-state and raw-opt-in/reset tests before behavior; verify input/resize, fresh-grant reconnect, status/close, foreign scope, remote 0–255 and real browser/PTY evidence (001 T018; FR-022/022a). ## Phase 5: US3 — Host operations (P2) @@ -65,7 +66,7 @@ Goal: every command has frozen output/exit semantics and safe terminal rendering Independent check: success/failure schema validation and hostile bytes/corpus. - [ ] T017 [US4] Implement output envelopes/typed events, fresh exit table, color and framework error routing in `apps/whr/internal/output/`, `apps/whr/internal/exitcode/`, `contracts/cli/output.schema.json`; cover every command/exit path and stream event, stderr/stdout purity, 0/1/255 remote passthrough and unsupported JSON refusal (FR-014–019/027; SC-003/012). -- [ ] T018 [US4] Implement bounded stateful control-string filtering and human-text escaping in `apps/whr/internal/terminal/`; fuzz split/unterminated OSC/DCS/APC/PM/SOS, C1, bidi, arbitrary binary and raw per-session opt-in; prove JSON never emits raw bytes (FR-020–022a; SC-004). +- [ ] T018 [US4] Complete exhaustive hostile-corpus, fuzz and command-integration acceptance of the human-text escaping and bounded stateful stream filter already implemented in T011/T014 in `apps/whr/internal/terminal/`; fuzz split/unterminated OSC/DCS/APC/PM/SOS, C1, bidi, arbitrary binary and raw per-session opt-in, cover every existing output/attachment path and prove JSON never emits raw bytes. Later agent/SSH WIs must reuse the accepted shared filter and verify their own integration before acceptance (FR-020–022a; SC-004). ## Phase 7: US5 — Agent interoperation and read-only dashboard (P2) @@ -100,7 +101,10 @@ T019 -> T020 -> T021 -> T022 -> T023 -> T024. This deliberately follows the owner's sequential WI/review route in one worktree; file-disjoint parallelism is not used. Output/error plumbing needed by an earlier WI is implemented minimally there and receives full matrix acceptance in T017, -not a passing claim before T017. Feature 001 mappings above retain all original +not a passing claim before T017. FR-022 text escaping is mandatory from T011, +and the full baseline FR-022a stream filter is mandatory from T014; T018 expands +corpus/fuzz/integration evidence rather than introducing either safety behavior. +Feature 001 mappings above retain all original acceptance and task completion rules; review evidence is appended, never fabricated. ## Evidence log From c64c654b55c2ae49bfc987d239e893019773282c Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 14:09:29 +0200 Subject: [PATCH 04/51] feat(pilot): scaffold pinned Phoenix and Go applications Establish reproducible application boundaries before lifecycle behavior, retaining the official Phoenix HTML/LiveView asset scaffold and a shared exported Go API-client package. Keep CLI and host-agent entrypoints truthful while refusing unimplemented product commands. Record actual scaffold verification and upstream limitations without claiming pilot acceptance or activating a runtime. Refs: #67, #72 --- .gitignore | 4 + .tool-versions | 5 + apps/api_client/doc.go | 5 + apps/api_client/go.mod | 3 + apps/control_plane/.formatter.exs | 6 + apps/control_plane/.gitignore | 40 ++ apps/control_plane/README.md | 32 ++ apps/control_plane/assets/css/app.css | 105 ++++ apps/control_plane/assets/js/app.js | 82 +++ apps/control_plane/assets/tsconfig.json | 11 + apps/control_plane/assets/vendor/heroicons.js | 43 ++ apps/control_plane/assets/vendor/topbar.js | 138 +++++ apps/control_plane/config/config.exs | 63 +++ apps/control_plane/config/dev.exs | 68 +++ apps/control_plane/config/prod.exs | 27 + apps/control_plane/config/runtime.exs | 109 ++++ apps/control_plane/config/test.exs | 32 ++ apps/control_plane/lib/workharbor.ex | 9 + .../lib/workharbor/application.ex | 40 ++ apps/control_plane/lib/workharbor/repo.ex | 5 + apps/control_plane/lib/workharbor_web.ex | 109 ++++ .../components/core_components.ex | 501 ++++++++++++++++++ .../lib/workharbor_web/components/layouts.ex | 160 ++++++ .../components/layouts/root.html.heex | 43 ++ .../workharbor_web/controllers/error_html.ex | 24 + .../workharbor_web/controllers/error_json.ex | 21 + .../controllers/page_controller.ex | 7 + .../workharbor_web/controllers/page_html.ex | 10 + .../controllers/page_html/home.html.heex | 199 +++++++ .../lib/workharbor_web/endpoint.ex | 51 ++ .../lib/workharbor_web/router.ex | 27 + .../lib/workharbor_web/telemetry.ex | 93 ++++ apps/control_plane/mix.exs | 96 ++++ apps/control_plane/mix.lock | 36 ++ .../priv/repo/migrations/.formatter.exs | 4 + apps/control_plane/priv/repo/seeds.exs | 11 + apps/control_plane/priv/static/favicon.ico | Bin 0 -> 152 bytes .../control_plane/priv/static/images/logo.svg | 6 + apps/control_plane/priv/static/robots.txt | 5 + apps/control_plane/test/support/conn_case.ex | 38 ++ apps/control_plane/test/support/data_case.ex | 58 ++ apps/control_plane/test/test_helper.exs | 2 + .../controllers/error_html_test.exs | 14 + .../controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + apps/host_agent/cmd/workharbor-agent/main.go | 24 + .../cmd/workharbor-agent/main_test.go | 21 + apps/host_agent/go.mod | 3 + apps/whr/cmd/whr/main.go | 24 + apps/whr/cmd/whr/main_test.go | 21 + apps/whr/go.mod | 3 + docs/exec-plans/active/installable-pilot.md | 59 ++- go.work | 7 + third_party/README.md | 13 + third_party/phoenix/LICENSE | 22 + third_party/topbar/LICENSE | 22 + 56 files changed, 2578 insertions(+), 3 deletions(-) create mode 100644 .tool-versions create mode 100644 apps/api_client/doc.go create mode 100644 apps/api_client/go.mod create mode 100644 apps/control_plane/.formatter.exs create mode 100644 apps/control_plane/.gitignore create mode 100644 apps/control_plane/README.md create mode 100644 apps/control_plane/assets/css/app.css create mode 100644 apps/control_plane/assets/js/app.js create mode 100644 apps/control_plane/assets/tsconfig.json create mode 100644 apps/control_plane/assets/vendor/heroicons.js create mode 100644 apps/control_plane/assets/vendor/topbar.js create mode 100644 apps/control_plane/config/config.exs create mode 100644 apps/control_plane/config/dev.exs create mode 100644 apps/control_plane/config/prod.exs create mode 100644 apps/control_plane/config/runtime.exs create mode 100644 apps/control_plane/config/test.exs create mode 100644 apps/control_plane/lib/workharbor.ex create mode 100644 apps/control_plane/lib/workharbor/application.ex create mode 100644 apps/control_plane/lib/workharbor/repo.ex create mode 100644 apps/control_plane/lib/workharbor_web.ex create mode 100644 apps/control_plane/lib/workharbor_web/components/core_components.ex create mode 100644 apps/control_plane/lib/workharbor_web/components/layouts.ex create mode 100644 apps/control_plane/lib/workharbor_web/components/layouts/root.html.heex create mode 100644 apps/control_plane/lib/workharbor_web/controllers/error_html.ex create mode 100644 apps/control_plane/lib/workharbor_web/controllers/error_json.ex create mode 100644 apps/control_plane/lib/workharbor_web/controllers/page_controller.ex create mode 100644 apps/control_plane/lib/workharbor_web/controllers/page_html.ex create mode 100644 apps/control_plane/lib/workharbor_web/controllers/page_html/home.html.heex create mode 100644 apps/control_plane/lib/workharbor_web/endpoint.ex create mode 100644 apps/control_plane/lib/workharbor_web/router.ex create mode 100644 apps/control_plane/lib/workharbor_web/telemetry.ex create mode 100644 apps/control_plane/mix.exs create mode 100644 apps/control_plane/mix.lock create mode 100644 apps/control_plane/priv/repo/migrations/.formatter.exs create mode 100644 apps/control_plane/priv/repo/seeds.exs create mode 100644 apps/control_plane/priv/static/favicon.ico create mode 100644 apps/control_plane/priv/static/images/logo.svg create mode 100644 apps/control_plane/priv/static/robots.txt create mode 100644 apps/control_plane/test/support/conn_case.ex create mode 100644 apps/control_plane/test/support/data_case.ex create mode 100644 apps/control_plane/test/test_helper.exs create mode 100644 apps/control_plane/test/workharbor_web/controllers/error_html_test.exs create mode 100644 apps/control_plane/test/workharbor_web/controllers/error_json_test.exs create mode 100644 apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs create mode 100644 apps/host_agent/cmd/workharbor-agent/main.go create mode 100644 apps/host_agent/cmd/workharbor-agent/main_test.go create mode 100644 apps/host_agent/go.mod create mode 100644 apps/whr/cmd/whr/main.go create mode 100644 apps/whr/cmd/whr/main_test.go create mode 100644 apps/whr/go.mod create mode 100644 go.work create mode 100644 third_party/phoenix/LICENSE create mode 100644 third_party/topbar/LICENSE diff --git a/.gitignore b/.gitignore index b13101f3..c67a12fc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ __pycache__/ .codex/auth.json .codex/sessions/ WORKFLOW.local.md + +# Local Go scaffold executables (build acceptance normally targets a temp dir). +/apps/host_agent/workharbor-agent +/apps/whr/whr diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..6c8d4ae5 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,5 @@ +erlang 29.1.1 +elixir 1.20.4-otp-29 +golang 1.27.2 +nodejs 26.11.1 +python 3.14.8 diff --git a/apps/api_client/doc.go b/apps/api_client/doc.go new file mode 100644 index 00000000..864bc19d --- /dev/null +++ b/apps/api_client/doc.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package apiclient reserves the shared owner API client boundary for whr and the +// host agent. Transport and authentication are not implemented yet. +package apiclient diff --git a/apps/api_client/go.mod b/apps/api_client/go.mod new file mode 100644 index 00000000..8dd05c6a --- /dev/null +++ b/apps/api_client/go.mod @@ -0,0 +1,3 @@ +module github.com/wstein/workharbor-nxt/apps/api_client + +go 1.27.2 diff --git a/apps/control_plane/.formatter.exs b/apps/control_plane/.formatter.exs new file mode 100644 index 00000000..ef8840ce --- /dev/null +++ b/apps/control_plane/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/apps/control_plane/.gitignore b/apps/control_plane/.gitignore new file mode 100644 index 00000000..db499fd8 --- /dev/null +++ b/apps/control_plane/.gitignore @@ -0,0 +1,40 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +workharbor-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + +# Database files +*.db +*.db-* diff --git a/apps/control_plane/README.md b/apps/control_plane/README.md new file mode 100644 index 00000000..d370a04d --- /dev/null +++ b/apps/control_plane/README.md @@ -0,0 +1,32 @@ +# WorkHarbor control-plane scaffold + +Generated with the official Phoenix 1.8.15 installer, retaining Ecto/SQLite, +HTML, LiveView and assets. No mailer, dashboard, Gettext or generated agent +instructions are included. Unused DNS clustering is omitted for the initial +single-instance SQLite control plane. This is a development scaffold, not a usable platform; +no owner API or lifecycle/provider behavior exists yet. + +The root `.tool-versions` pins the tested toolchains. `mix.lock` pins every +resolved Hex dependency and the heroicons/daisyUI Git revisions. The esbuild and +Tailwind executable versions are pinned in `config/config.exs`; assets use Mix +wrappers rather than an npm dependency graph. + +From this directory, verify without starting a server: + +```sh +mix deps.get --check-locked +mix format --check-formatted +mix compile --warnings-as-errors +mix test +mix assets.setup +mix assets.build +``` + +Tests use a local ignored SQLite database and `server: false`. Generated +development secrets are public local defaults, not deployment credentials. +Production authentication, TLS policy and installation remain later work items. +The generated Phoenix welcome page is retained unchanged; no browser acceptance +of product UI is claimed. + +`assets/tsconfig.json` supplies editor completion with Mix dependency paths; its +generated comments were removed to comply with the repository's strict JSON gate. diff --git a/apps/control_plane/assets/css/app.css b/apps/control_plane/assets/css/app.css new file mode 100644 index 00000000..91ba02d4 --- /dev/null +++ b/apps/control_plane/assets/css/app.css @@ -0,0 +1,105 @@ +/* See the Tailwind configuration guide for advanced usage + https://tailwindcss.com/docs/configuration */ + +@import "tailwindcss" source(none); +@import "phoenix-colocated/workharbor/colocated.css"; +@source "../css"; +@source "../js"; +@source "../../lib/workharbor_web"; +/* Required for Tailwind to automatically pick up changes in colocated CSS files in dev */ +@source "../../_build/dev/phoenix-colocated/workharbor/*/"; + +/* A Tailwind plugin that makes "hero-#{ICON}" classes available. + The heroicons installation itself is managed by your mix.exs */ +@plugin "../vendor/heroicons"; + +/* daisyUI Tailwind Plugin. */ +@plugin "daisyui/packages/bundle/daisyui" { + themes: false; +} + +/* daisyUI theme plugin. + We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired + on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */ +@plugin "daisyui/packages/bundle/daisyui-theme" { + name: "dark"; + default: false; + prefersdark: true; + color-scheme: "dark"; + --color-base-100: oklch(30.33% 0.016 252.42); + --color-base-200: oklch(25.26% 0.014 253.1); + --color-base-300: oklch(20.15% 0.012 254.09); + --color-base-content: oklch(97.807% 0.029 256.847); + --color-primary: oklch(58% 0.233 277.117); + --color-primary-content: oklch(96% 0.018 272.314); + --color-secondary: oklch(58% 0.233 277.117); + --color-secondary-content: oklch(96% 0.018 272.314); + --color-accent: oklch(60% 0.25 292.717); + --color-accent-content: oklch(96% 0.016 293.756); + --color-neutral: oklch(37% 0.044 257.287); + --color-neutral-content: oklch(98% 0.003 247.858); + --color-info: oklch(58% 0.158 241.966); + --color-info-content: oklch(97% 0.013 236.62); + --color-success: oklch(60% 0.118 184.704); + --color-success-content: oklch(98% 0.014 180.72); + --color-warning: oklch(66% 0.179 58.318); + --color-warning-content: oklch(98% 0.022 95.277); + --color-error: oklch(58% 0.253 17.585); + --color-error-content: oklch(96% 0.015 12.422); + --radius-selector: 0.25rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.21875rem; + --size-field: 0.21875rem; + --border: 1.5px; + --depth: 1; + --noise: 0; +} + +@plugin "daisyui/packages/bundle/daisyui-theme" { + name: "light"; + default: true; + prefersdark: false; + color-scheme: "light"; + --color-base-100: oklch(98% 0 0); + --color-base-200: oklch(96% 0.001 286.375); + --color-base-300: oklch(92% 0.004 286.32); + --color-base-content: oklch(21% 0.006 285.885); + --color-primary: oklch(70% 0.213 47.604); + --color-primary-content: oklch(98% 0.016 73.684); + --color-secondary: oklch(55% 0.027 264.364); + --color-secondary-content: oklch(98% 0.002 247.839); + --color-accent: oklch(0% 0 0); + --color-accent-content: oklch(100% 0 0); + --color-neutral: oklch(44% 0.017 285.786); + --color-neutral-content: oklch(98% 0 0); + --color-info: oklch(62% 0.214 259.815); + --color-info-content: oklch(97% 0.014 254.604); + --color-success: oklch(70% 0.14 182.503); + --color-success-content: oklch(98% 0.014 180.72); + --color-warning: oklch(66% 0.179 58.318); + --color-warning-content: oklch(98% 0.022 95.277); + --color-error: oklch(58% 0.253 17.585); + --color-error-content: oklch(96% 0.015 12.422); + --radius-selector: 0.25rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.21875rem; + --size-field: 0.21875rem; + --border: 1.5px; + --depth: 1; + --noise: 0; +} + +/* Add variants based on LiveView classes */ +@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); +@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); +@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); + +/* Use the data attribute for dark mode */ +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + +/* Make LiveView wrapper divs transparent for layout */ +[data-phx-session], [data-phx-teleported-src] { display: contents } + +/* This file is for your main application CSS */ diff --git a/apps/control_plane/assets/js/app.js b/apps/control_plane/assets/js/app.js new file mode 100644 index 00000000..f1a05c67 --- /dev/null +++ b/apps/control_plane/assets/js/app.js @@ -0,0 +1,82 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// +// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. +// To load it, simply add a second `` to your `root.html.heex` file. + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import {hooks as colocatedHooks} from "phoenix-colocated/workharbor" +import topbar from "../vendor/topbar" + +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken}, + hooks: {...colocatedHooks}, +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + +// The lines below enable quality of life phoenix_live_reload +// development features: +// +// 1. stream server logs to the browser console +// 2. click on elements to jump to their definitions in your code editor +// +if (process.env.NODE_ENV === "development") { + window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { + // Enable server log streaming to client. + // Disable with reloader.disableServerLogs() + reloader.enableServerLogs() + + // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component + // + // * click with "c" key pressed to open at caller location + // * click with "d" key pressed to open at function component definition location + let keyDown + window.addEventListener("keydown", e => keyDown = e.key) + window.addEventListener("keyup", _e => keyDown = null) + window.addEventListener("click", e => { + if(keyDown === "c"){ + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtCaller(e.target) + } else if(keyDown === "d"){ + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtDef(e.target) + } + }, true) + + window.liveReloader = reloader + }) +} diff --git a/apps/control_plane/assets/tsconfig.json b/apps/control_plane/assets/tsconfig.json new file mode 100644 index 00000000..4910bec8 --- /dev/null +++ b/apps/control_plane/assets/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["../deps/*"] + }, + "allowJs": true, + "noEmit": true + }, + "include": ["js/**/*"] +} diff --git a/apps/control_plane/assets/vendor/heroicons.js b/apps/control_plane/assets/vendor/heroicons.js new file mode 100644 index 00000000..296f80e4 --- /dev/null +++ b/apps/control_plane/assets/vendor/heroicons.js @@ -0,0 +1,43 @@ +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + content = encodeURIComponent(content) + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) +}) diff --git a/apps/control_plane/assets/vendor/topbar.js b/apps/control_plane/assets/vendor/topbar.js new file mode 100644 index 00000000..0552337c --- /dev/null +++ b/apps/control_plane/assets/vendor/topbar.js @@ -0,0 +1,138 @@ +/** + * @license MIT + * topbar 3.0.0 + * http://buunguyen.github.io/topbar + * Copyright (c) 2024 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + if (!canvas.parentElement) document.body.appendChild(canvas); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/apps/control_plane/config/config.exs b/apps/control_plane/config/config.exs new file mode 100644 index 00000000..17d19ac0 --- /dev/null +++ b/apps/control_plane/config/config.exs @@ -0,0 +1,63 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :workharbor, + namespace: WorkHarbor, + ecto_repos: [WorkHarbor.Repo], + generators: [timestamp_type: :utc_datetime, binary_id: true] + +# Configure the endpoint +config :workharbor, WorkHarborWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: WorkHarborWeb.ErrorHTML, json: WorkHarborWeb.ErrorJSON], + layout: false + ], + pubsub_server: WorkHarbor.PubSub, + live_view: [signing_salt: "lSjF4/WT"] + +# Configure LiveView +config :phoenix_live_view, + # the attribute set on all root tags. Used for Phoenix.LiveView.ColocatedCSS. + root_tag_attribute: "phx-r" + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.25.4", + workharbor: [ + args: + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "4.3.3", + workharbor: [ + args: ~w( + --input=assets/css/app.css + --output=priv/static/assets/css/app.css + ), + cd: Path.expand("..", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + +# Configure Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/apps/control_plane/config/dev.exs b/apps/control_plane/config/dev.exs new file mode 100644 index 00000000..cedd22df --- /dev/null +++ b/apps/control_plane/config/dev.exs @@ -0,0 +1,68 @@ +import Config + +# Configure your database +config :workharbor, WorkHarbor.Repo, + database: Path.expand("../workharbor_dev.db", __DIR__), + pool_size: 5, + stacktrace: true, + show_sensitive_data_on_connection_error: true + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :workharbor, WorkHarborWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "9kwSZZ3zqgPLCQHHHC7Q9KYXXSVttc/mmuUT4ZmFiV1BGre/cOXzP7jNrsMeH4N4", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:workharbor, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:workharbor, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Do not include metadata nor timestamps in development logs +config :logger, :default_formatter, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include debug annotations and locations in rendered markup. + # Changing this configuration will require mix clean and a full recompile. + debug_heex_annotations: true, + debug_attributes: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true diff --git a/apps/control_plane/config/prod.exs b/apps/control_plane/config/prod.exs new file mode 100644 index 00000000..1c82a81c --- /dev/null +++ b/apps/control_plane/config/prod.exs @@ -0,0 +1,27 @@ +import Config + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :workharbor, WorkHarborWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +# Force using SSL in production. This also sets the "strict-security-transport" header, +# known as HSTS. If you have a health check endpoint, you may want to exclude it below. +# Note `:force_ssl` is required to be set at compile-time. +config :workharbor, WorkHarborWeb.Endpoint, + force_ssl: [ + rewrite_on: [:x_forwarded_proto], + exclude: [ + # paths: ["/health"], + hosts: ["localhost", "127.0.0.1"] + ] + ] + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/apps/control_plane/config/runtime.exs b/apps/control_plane/config/runtime.exs new file mode 100644 index 00000000..971f875e --- /dev/null +++ b/apps/control_plane/config/runtime.exs @@ -0,0 +1,109 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/workharbor start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :workharbor, WorkHarborWeb.Endpoint, server: true +end + +config :workharbor, WorkHarborWeb.Endpoint, + http: [port: String.to_integer(System.get_env("PORT", "4000"))] + +if config_env() == :dev do + # Reload browser tabs when matching files change. + config :workharbor, WorkHarborWeb.Endpoint, + live_reload: [ + web_console_logger: true, + patterns: [ + # Static assets, except user uploads + ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E, + # Router, Controllers, LiveViews and LiveComponents + ~r"lib/workharbor_web/router\.ex$"E, + ~r"lib/workharbor_web/(controllers|live|components)/.*\.(ex|heex)$"E + ] + ] +end + +if config_env() == :prod do + database_path = + System.get_env("DATABASE_PATH") || + raise """ + environment variable DATABASE_PATH is missing. + For example: /etc/workharbor/workharbor.db + """ + + config :workharbor, WorkHarbor.Repo, + database: database_path, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") + + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + + config :workharbor, WorkHarborWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0} + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :workharbor, WorkHarborWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://plug.hexdocs.pm/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :workharbor, WorkHarborWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. +end diff --git a/apps/control_plane/config/test.exs b/apps/control_plane/config/test.exs new file mode 100644 index 00000000..7c20c9c4 --- /dev/null +++ b/apps/control_plane/config/test.exs @@ -0,0 +1,32 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :workharbor, WorkHarbor.Repo, + database: Path.expand("../workharbor_test.db", __DIR__), + pool_size: 5, + pool: Ecto.Adapters.SQL.Sandbox + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :workharbor, WorkHarborWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl", + server: false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true + +# Sort query params output of verified routes for robust url comparisons +config :phoenix, + sort_verified_routes_query_params: true diff --git a/apps/control_plane/lib/workharbor.ex b/apps/control_plane/lib/workharbor.ex new file mode 100644 index 00000000..b403ce32 --- /dev/null +++ b/apps/control_plane/lib/workharbor.ex @@ -0,0 +1,9 @@ +defmodule WorkHarbor do + @moduledoc """ + WorkHarbor keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/apps/control_plane/lib/workharbor/application.ex b/apps/control_plane/lib/workharbor/application.ex new file mode 100644 index 00000000..0205befb --- /dev/null +++ b/apps/control_plane/lib/workharbor/application.ex @@ -0,0 +1,40 @@ +defmodule WorkHarbor.Application do + # See https://elixir.hexdocs.pm/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + WorkHarborWeb.Telemetry, + WorkHarbor.Repo, + {Ecto.Migrator, + repos: Application.fetch_env!(:workharbor, :ecto_repos), skip: skip_migrations?()}, + {Phoenix.PubSub, name: WorkHarbor.PubSub}, + # Start a worker by calling: WorkHarbor.Worker.start_link(arg) + # {WorkHarbor.Worker, arg}, + # Start to serve requests, typically the last entry + WorkHarborWeb.Endpoint + ] + + # See https://elixir.hexdocs.pm/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: WorkHarbor.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + WorkHarborWeb.Endpoint.config_change(changed, removed) + :ok + end + + defp skip_migrations?() do + # By default, sqlite migrations are run when using a release + System.get_env("RELEASE_NAME") == nil + end +end diff --git a/apps/control_plane/lib/workharbor/repo.ex b/apps/control_plane/lib/workharbor/repo.ex new file mode 100644 index 00000000..9195c961 --- /dev/null +++ b/apps/control_plane/lib/workharbor/repo.ex @@ -0,0 +1,5 @@ +defmodule WorkHarbor.Repo do + use Ecto.Repo, + otp_app: :workharbor, + adapter: Ecto.Adapters.SQLite3 +end diff --git a/apps/control_plane/lib/workharbor_web.ex b/apps/control_plane/lib/workharbor_web.ex new file mode 100644 index 00000000..fcdc6b88 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web.ex @@ -0,0 +1,109 @@ +defmodule WorkHarborWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use WorkHarborWeb, :controller + use WorkHarborWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components + import WorkHarborWeb.CoreComponents + + # Common modules used in templates + alias Phoenix.LiveView.JS + alias WorkHarborWeb.Layouts + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: WorkHarborWeb.Endpoint, + router: WorkHarborWeb.Router, + statics: WorkHarborWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/apps/control_plane/lib/workharbor_web/components/core_components.ex b/apps/control_plane/lib/workharbor_web/components/core_components.ex new file mode 100644 index 00000000..e88ae658 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/components/core_components.ex @@ -0,0 +1,501 @@ +defmodule WorkHarborWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as tables, forms, and + inputs. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The foundation for styling is Tailwind CSS, a utility-first CSS framework, + augmented with daisyUI, a Tailwind CSS plugin that provides UI components + and themes. Here are useful references: + + * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get + started and see the available components. + + * [Tailwind CSS](https://tailwindcss.com) - the foundational framework + we build on. You will use it for layout, sizing, flexbox, grid, and + spacing. + + * [Heroicons](https://heroicons.com) - see `icon/1` for usage. + + * [Phoenix.Component](https://phoenix-live-view.hexdocs.pm/Phoenix.Component.html) - + the component system used by Phoenix. Some components, such as `<.link>` + and `<.form>`, are defined there. + + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders flash notices. + + ## Examples + + <.flash kind={:info} flash={@flash} /> + <.flash + id="welcome-back" + kind={:info} + phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")} + hidden + > + Welcome Back! + + """ + attr :id, :string, doc: "the optional id of flash container" + attr :flash, :map, default: %{}, doc: "the map of flash messages to display" + attr :title, :string, default: nil + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" + + slot :inner_block, doc: "the optional inner block that renders the flash message" + + def flash(assigns) do + assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) + + ~H""" +
hide("##{@id}")} + role="alert" + class="toast toast-top toast-end z-50" + {@rest} + > +
+ <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> +
+

{@title}

+

{msg}

+
+
+ +
+
+ """ + end + + @doc """ + Renders a button with navigation support. + + ## Examples + + <.button>Send! + <.button phx-click="go" variant="primary">Send! + <.button navigate={~p"/"}>Home + """ + attr :rest, :global, include: ~w(href navigate patch method download name value disabled) + attr :class, :any + attr :variant, :string, values: ~w(primary) + slot :inner_block, required: true + + def button(%{rest: rest} = assigns) do + variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} + + assigns = + assign_new(assigns, :class, fn -> + ["btn", Map.fetch!(variants, assigns[:variant])] + end) + + if rest[:href] || rest[:navigate] || rest[:patch] do + ~H""" + <.link class={@class} {@rest}> + {render_slot(@inner_block)} + + """ + else + ~H""" + + """ + end + end + + @doc """ + Renders an input with label and error messages. + + A `Phoenix.HTML.FormField` may be passed as argument, + which is used to retrieve the input name, id, and values. + Otherwise all attributes may be passed explicitly. + + ## Types + + This function accepts all HTML input types, considering that: + + * You may also set `type="select"` to render a ` + """ + end + + def input(%{type: "checkbox"} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> + Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + end) + + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # Helper used by inputs to generate form errors + defp error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc """ + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" + + + + + + + + + + + + + +
{col[:label]} + Actions +
+ {render_slot(col, @row_item.(row))} + +
+ <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
    +
  • +
    +
    {item.title}
    +
    {render_slot(item)}
    +
    +
  • +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in `assets/vendor/heroicons.js`. + + ## Examples + + <.icon name="hero-x-mark" /> + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :any, default: "size-4" + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(WorkHarborWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(WorkHarborWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/apps/control_plane/lib/workharbor_web/components/layouts.ex b/apps/control_plane/lib/workharbor_web/components/layouts.ex new file mode 100644 index 00000000..2c8eefc4 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/components/layouts.ex @@ -0,0 +1,160 @@ +defmodule WorkHarborWeb.Layouts do + @moduledoc """ + This module holds layouts and related functionality + used by your application. + """ + use WorkHarborWeb, :html + + # Embed all files in layouts/* within this module. + # The default root.html.heex file contains the HTML + # skeleton of your application, namely HTML headers + # and other static content. + embed_templates "layouts/*" + + @doc """ + Renders your app layout. + + This function is typically invoked from every template, + and it often contains your application menu, sidebar, + or similar. + + ## Examples + + +

Content

+
+ + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + + attr :current_scope, :map, + default: nil, + doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)" + + slot :inner_block, required: true + + def app(assigns) do + ~H""" + + +
+
+ {render_slot(@inner_block)} +
+
+ + <.flash_group flash={@flash} /> + """ + end + + @doc """ + Shows the flash group with standard titles and content. + + ## Examples + + <.flash_group flash={@flash} /> + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + attr :id, :string, default: "flash-group", doc: "the optional id of flash container" + + def flash_group(assigns) do + ~H""" +
+ <.flash kind={:info} flash={@flash} /> + <.flash kind={:error} flash={@flash} /> + + <.flash + id="client-error" + kind={:error} + title="We can't find the internet" + phx-disconnected={ + show(".phx-client-error #client-error") + |> JS.remove_attribute("hidden", to: ".phx-client-error #client-error") + } + phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + Attempting to reconnect + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + + + <.flash + id="server-error" + kind={:error} + title="Something went wrong!" + phx-disconnected={ + show(".phx-server-error #server-error") + |> JS.remove_attribute("hidden", to: ".phx-server-error #server-error") + } + phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + Attempting to reconnect + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + +
+ """ + end + + @doc """ + Provides dark vs light theme toggle based on themes defined in app.css. + + See in root.html.heex which applies the theme before page load. + """ + def theme_toggle(assigns) do + ~H""" +
+
+ + + + + + +
+ """ + end +end diff --git a/apps/control_plane/lib/workharbor_web/components/layouts/root.html.heex b/apps/control_plane/lib/workharbor_web/components/layouts/root.html.heex new file mode 100644 index 00000000..a8430ea8 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/components/layouts/root.html.heex @@ -0,0 +1,43 @@ + + + + + + + <.live_title default="WorkHarbor" suffix=" · Phoenix Framework" phx-no-format>{assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/apps/control_plane/lib/workharbor_web/controllers/error_html.ex b/apps/control_plane/lib/workharbor_web/controllers/error_html.ex new file mode 100644 index 00000000..bc86b62f --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule WorkHarborWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use WorkHarborWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/workharbor_web/controllers/error_html/404.html.heex + # * lib/workharbor_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/error_json.ex b/apps/control_plane/lib/workharbor_web/controllers/error_json.ex new file mode 100644 index 00000000..2021e44e --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule WorkHarborWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/page_controller.ex b/apps/control_plane/lib/workharbor_web/controllers/page_controller.ex new file mode 100644 index 00000000..e5ae4085 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule WorkHarborWeb.PageController do + use WorkHarborWeb, :controller + + def home(conn, _params) do + render(conn, :home) + end +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/page_html.ex b/apps/control_plane/lib/workharbor_web/controllers/page_html.ex new file mode 100644 index 00000000..3c432a77 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule WorkHarborWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use WorkHarborWeb, :html + + embed_templates "page_html/*" +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/page_html/home.html.heex b/apps/control_plane/lib/workharbor_web/controllers/page_html/home.html.heex new file mode 100644 index 00000000..5a550fd0 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/page_html/home.html.heex @@ -0,0 +1,199 @@ + + +
+
+ +
+

+ Phoenix Framework + + v{Application.spec(:phoenix, :vsn)} + +

+ +
+ +

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/apps/control_plane/lib/workharbor_web/endpoint.ex b/apps/control_plane/lib/workharbor_web/endpoint.ex new file mode 100644 index 00000000..b5921a95 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/endpoint.ex @@ -0,0 +1,51 @@ +defmodule WorkHarborWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :workharbor + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_workharbor_key", + signing_salt: "W6GBXzES", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :workharbor, + gzip: not code_reloading?, + only: WorkHarborWeb.static_paths(), + raise_on_missing_only: code_reloading? + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :workharbor + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug WorkHarborWeb.Router +end diff --git a/apps/control_plane/lib/workharbor_web/router.ex b/apps/control_plane/lib/workharbor_web/router.ex new file mode 100644 index 00000000..2b953552 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/router.ex @@ -0,0 +1,27 @@ +defmodule WorkHarborWeb.Router do + use WorkHarborWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {WorkHarborWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", WorkHarborWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Other scopes may use custom stacks. + # scope "/api", WorkHarborWeb do + # pipe_through :api + # end +end diff --git a/apps/control_plane/lib/workharbor_web/telemetry.ex b/apps/control_plane/lib/workharbor_web/telemetry.ex new file mode 100644 index 00000000..06b4256c --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule WorkHarborWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://telemetry-metrics.hexdocs.pm + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("workharbor.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("workharbor.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("workharbor.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("workharbor.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("workharbor.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {WorkHarborWeb, :count_users, []} + ] + end +end diff --git a/apps/control_plane/mix.exs b/apps/control_plane/mix.exs new file mode 100644 index 00000000..6c6a1c94 --- /dev/null +++ b/apps/control_plane/mix.exs @@ -0,0 +1,96 @@ +defmodule WorkHarbor.MixProject do + use Mix.Project + + def project do + [ + app: :workharbor, + version: "0.1.0", + elixir: "== 1.20.4", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {WorkHarbor.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "== 1.8.15"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:ecto_sqlite3, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.2.0"}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.5", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.2.0", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:daisyui, + github: "saadeghi/daisyui", + tag: "v5.5.20", + sparse: "packages/bundle", + app: false, + compile: false, + depth: 1}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:bandit, "~> 1.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["compile", "tailwind workharbor", "esbuild workharbor"], + "assets.deploy": [ + "tailwind workharbor --minify", + "esbuild workharbor --minify", + "phx.digest" + ], + precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/apps/control_plane/mix.lock b/apps/control_plane/mix.lock new file mode 100644 index 00000000..97bc8e96 --- /dev/null +++ b/apps/control_plane/mix.lock @@ -0,0 +1,36 @@ +%{ + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "daisyui": {:git, "https://github.com/saadeghi/daisyui.git", "22ecff57f2c391b80a75617325748cf4d13fdf47", [tag: "v5.5.20", sparse: "packages/bundle", depth: 1]}, + "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, + "decimal": {:hex, :decimal, "3.2.0", "f1235f05d841a211fda78a08fdb9777f2ff51a71f18455233fae186ff76187c6", [:mix], [], "hexpm", "190bca80580ce61aa69bb0eacadd7677710205c770225c2c2c07e32a5c31b234"}, + "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, + "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.25.0", "309898d694b17a8ca8cd88d648d3a7fe8479b04ea6831a18d97d56ed3541031b", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "7da65c7af38dccf228320db32f93ae49650b0afdd850a09fd2fb191554b3faf5"}, + "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "exqlite": {:hex, :exqlite, "0.42.0", "3effa63f9fa365893fc318c597a9dc1253aa5acf1abd138660efacf830317f6d", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "01e1cce89dd6be4966acc87d426d7ba93455820138fe1564f422a6478419f1f7"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "lazy_html": {:hex, :lazy_html, "0.1.13", "860ea816f5bc7936d1ab5ccebc388f9d1031ba9c35ed81a4db9a1c3a50737704", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "9a8405d6785fe6f8423b86e0ec5f21806ef79941fe853eac3d14fbbb173c34e9"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.4.1", "2ff3e7ba8b432120989870997b9adaefb76e1f63805554ab0b9e948987981f4b", [:mix], [], "hexpm", "352fba7a87bf61395b6e97da66944ad9b1bfe5459e5981039f60d68270bd9700"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.2.0", "7632c19c01d88d8aaca5da1a0e8912f5af39b79e7c08a2c253aeb3c14c2c957e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "71dde12fc29b58b9c77ec17ec319109e5ca848d010fc1965ed4463bba1837c07"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, +} diff --git a/apps/control_plane/priv/repo/migrations/.formatter.exs b/apps/control_plane/priv/repo/migrations/.formatter.exs new file mode 100644 index 00000000..49f9151e --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/apps/control_plane/priv/repo/seeds.exs b/apps/control_plane/priv/repo/seeds.exs new file mode 100644 index 00000000..6f8753a9 --- /dev/null +++ b/apps/control_plane/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# WorkHarbor.Repo.insert!(%WorkHarbor.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/apps/control_plane/priv/static/favicon.ico b/apps/control_plane/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/apps/control_plane/priv/static/images/logo.svg b/apps/control_plane/priv/static/images/logo.svg new file mode 100644 index 00000000..9f26baba --- /dev/null +++ b/apps/control_plane/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/apps/control_plane/priv/static/robots.txt b/apps/control_plane/priv/static/robots.txt new file mode 100644 index 00000000..26e06b5f --- /dev/null +++ b/apps/control_plane/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/apps/control_plane/test/support/conn_case.ex b/apps/control_plane/test/support/conn_case.ex new file mode 100644 index 00000000..35ca5dd9 --- /dev/null +++ b/apps/control_plane/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule WorkHarborWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use WorkHarborWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint WorkHarborWeb.Endpoint + + use WorkHarborWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import WorkHarborWeb.ConnCase + end + end + + setup tags do + WorkHarbor.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/apps/control_plane/test/support/data_case.ex b/apps/control_plane/test/support/data_case.ex new file mode 100644 index 00000000..d0abce6b --- /dev/null +++ b/apps/control_plane/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule WorkHarbor.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use WorkHarbor.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias WorkHarbor.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import WorkHarbor.DataCase + end + end + + setup tags do + WorkHarbor.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(WorkHarbor.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/apps/control_plane/test/test_helper.exs b/apps/control_plane/test/test_helper.exs new file mode 100644 index 00000000..7189e644 --- /dev/null +++ b/apps/control_plane/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(WorkHarbor.Repo, :manual) diff --git a/apps/control_plane/test/workharbor_web/controllers/error_html_test.exs b/apps/control_plane/test/workharbor_web/controllers/error_html_test.exs new file mode 100644 index 00000000..c14d1f0d --- /dev/null +++ b/apps/control_plane/test/workharbor_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule WorkHarborWeb.ErrorHTMLTest do + use WorkHarborWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template, only: [render_to_string: 4] + + test "renders 404.html" do + assert render_to_string(WorkHarborWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(WorkHarborWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/apps/control_plane/test/workharbor_web/controllers/error_json_test.exs b/apps/control_plane/test/workharbor_web/controllers/error_json_test.exs new file mode 100644 index 00000000..a3840ab6 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule WorkHarborWeb.ErrorJSONTest do + use WorkHarborWeb.ConnCase, async: true + + test "renders 404" do + assert WorkHarborWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert WorkHarborWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs b/apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs new file mode 100644 index 00000000..62cb502d --- /dev/null +++ b/apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule WorkHarborWeb.PageControllerTest do + use WorkHarborWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/apps/host_agent/cmd/workharbor-agent/main.go b/apps/host_agent/cmd/workharbor-agent/main.go new file mode 100644 index 00000000..09c78f25 --- /dev/null +++ b/apps/host_agent/cmd/workharbor-agent/main.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package main + +import ( + "fmt" + "io" + "os" +) + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } + +func run(args []string, out, errOut io.Writer) int { + if len(args) == 0 || (len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help")) { + fmt.Fprintln(out, "workharbor-agent scaffold: product commands are not implemented.\nUsage: workharbor-agent [--help | --version]") + return 0 + } + if len(args) == 1 && args[0] == "--version" { + fmt.Fprintln(out, "workharbor-agent 0.1.0-dev (scaffold)") + return 0 + } + fmt.Fprintln(errOut, "workharbor-agent: product commands are not implemented; use --help") + return 1 +} diff --git a/apps/host_agent/cmd/workharbor-agent/main_test.go b/apps/host_agent/cmd/workharbor-agent/main_test.go new file mode 100644 index 00000000..38edad44 --- /dev/null +++ b/apps/host_agent/cmd/workharbor-agent/main_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestScaffoldRefusesProductCommands(t *testing.T) { + for _, args := range [][]string{{"setup"}, {"ws", "create"}, {"--version", "setup"}} { + var out, errOut bytes.Buffer + if code := run(args, &out, &errOut); code == 0 { + t.Fatalf("run(%q) succeeded before product commands exist", args) + } + if out.Len() != 0 || !strings.Contains(errOut.String(), "not implemented") { + t.Fatalf("run(%q): stdout=%q stderr=%q", args, out.String(), errOut.String()) + } + } +} diff --git a/apps/host_agent/go.mod b/apps/host_agent/go.mod new file mode 100644 index 00000000..40a14e5a --- /dev/null +++ b/apps/host_agent/go.mod @@ -0,0 +1,3 @@ +module github.com/wstein/workharbor-nxt/apps/host_agent + +go 1.27.2 diff --git a/apps/whr/cmd/whr/main.go b/apps/whr/cmd/whr/main.go new file mode 100644 index 00000000..11236829 --- /dev/null +++ b/apps/whr/cmd/whr/main.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package main + +import ( + "fmt" + "io" + "os" +) + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } + +func run(args []string, out, errOut io.Writer) int { + if len(args) == 0 || (len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help")) { + fmt.Fprintln(out, "whr scaffold: product commands are not implemented.\nUsage: whr [--help | --version]") + return 0 + } + if len(args) == 1 && args[0] == "--version" { + fmt.Fprintln(out, "whr 0.1.0-dev (scaffold)") + return 0 + } + fmt.Fprintln(errOut, "whr: product commands are not implemented; use --help") + return 1 +} diff --git a/apps/whr/cmd/whr/main_test.go b/apps/whr/cmd/whr/main_test.go new file mode 100644 index 00000000..38edad44 --- /dev/null +++ b/apps/whr/cmd/whr/main_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestScaffoldRefusesProductCommands(t *testing.T) { + for _, args := range [][]string{{"setup"}, {"ws", "create"}, {"--version", "setup"}} { + var out, errOut bytes.Buffer + if code := run(args, &out, &errOut); code == 0 { + t.Fatalf("run(%q) succeeded before product commands exist", args) + } + if out.Len() != 0 || !strings.Contains(errOut.String(), "not implemented") { + t.Fatalf("run(%q): stdout=%q stderr=%q", args, out.String(), errOut.String()) + } + } +} diff --git a/apps/whr/go.mod b/apps/whr/go.mod new file mode 100644 index 00000000..d7e4d3d0 --- /dev/null +++ b/apps/whr/go.mod @@ -0,0 +1,3 @@ +module github.com/wstein/workharbor-nxt/apps/whr + +go 1.27.2 diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 31a534fe..c1fde6a4 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -7,9 +7,9 @@ intermediate planning/WI reviews and fixes; the owner reviews the final combined This supersedes the earlier intermediate human spec/plan-review workflow for this authorized pilot execution. No historical human artifact approval is asserted, and no pending intermediate human approval is treated as an execution blocker. -Status: proposed intermediate planning baseline; the prior Sol planning review -identified a terminal-safety ordering finding. Its correction awaits separate -independent Sol re-review before successor work. No product WI is accepted. +Status: independent Sol planning re-review cleared `602ae81c63be52ccb2ee18aa9802a50d3e35260d` +without findings; repository and whitespace checks passed. T001 scaffold is an +authored candidate awaiting independent Sol review. No product WI is accepted. Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; changing the design baseline does not change frozen Python behavior. @@ -45,3 +45,56 @@ after explicit return/authorization, WorkHarbor writes were approved. No indirec workaround was used. The configured Docker read-only probe did not complete and was interrupted; this establishes no available host. No runtime was activated. Signed release and live test/installation inputs are separate owner-controlled gates. + +## T001 — pinned application scaffolds (candidate; review pending) + +Input: planning clearance at `602ae81c63be52ccb2ee18aa9802a50d3e35260d`. +Only T001 is implemented by this candidate; T002 and all Feature 001/006 task +completion markers remain pending. Independent review must evaluate the exact +commit containing this record before successor work. + +- `.tool-versions`: Erlang/OTP 29.1.1 (read from the installed `OTP_VERSION`), + Elixir 1.20.4/OTP 29, Go 1.27.2, Node 26.11.1 and Python 3.14.8. +- Official `phx_new` 1.8.15 generated `apps/control_plane` with Ecto/SQLite, + binary IDs, HTML/LiveView/assets and the requested exclusions. Phoenix and + Elixir constraints are exact; `mix.lock` freezes all actual direct/transitive + dependencies, including Git asset revisions. Unused DNS clustering was removed + for the initial single-instance SQLite design. Generated editor JSON was made + strict JSON, and whitespace was normalized to satisfy inherited checks. +- Three Go 1.27.2 modules and root `go.work` establish the exported `apiclient` + boundary and separate CLI/host-agent commands. The shared package deliberately + has no transport yet. No external Go dependencies exist, so no `go.sum` or + `go.work.sum` is needed. Help/version are explicitly scaffold output; unknown + or future product commands fail with exit 1 and no success output. Refusal + tests were authored first and failed on the absent `run` implementation. +- Phoenix format, warnings-as-errors application compile and `mix test` pass + (5 tests). `mix deps.get --check-locked`, `mix assets.setup` and + `mix assets.build` pass with Tailwind 4.3.3 and esbuild 0.25.4. Every Go module + passes empty `gofmt -l .`, `go vet ./...`, `go build` and `go test ./...`; + `apiclient` reports no test files because it has no implemented behavior. + Command builds use `go build -o /private/tmp/workharbor-pilot-build// ./...`; + the library uses `go build ./...`. Both built entrypoints were exercised for + `--help`, `--version` (exit 0) and `setup` (exit 1). +- The same application gates passed from a clean source snapshot under + `/private/tmp/workharbor-t001-clean-eynnx4d7`, without copied dependencies, + builds or databases (before final strict-JSON/whitespace-only normalization). + Locked dependency fetching and asset-tool installation succeeded there. + `python3.14 scripts/check_repo.py` and staged/working-tree whitespace checks + pass for this candidate; inherited full `make check` is not claimed rerun here. + T002 adds explicit CI/application gates and preserves inherited acceptance. +- Verification used darwin/arm64 only. `MIX_ARCHIVES` used the isolated approved + generator directory; subsequent Mix state used `/private/tmp/workharbor-pilot-mix-home`. + Go caches/build outputs used `/private/tmp`. No global tool configuration changed. + Initial default-cache/build-path permission errors were corrected with explicit + temporary cache/output locations; no denied native-write path was bypassed. +- Fresh dependency compilation emits a `phoenix_ecto` upstream xref deprecation; + application warnings-as-errors compile still exits 0. No warning was suppressed. + Initial SQLite creation logged a recovered database-lock message while all + tests passed; subsequent working-tree and clean-source tests passed without it. + Revisit dependency compatibility and SQLite startup if T002 fresh CI reproduces + these messages; passing scaffold checks do not establish production durability. + +No API, lifecycle/provider behavior, Python migration or harness change is included. +No web server, Docker host or live operation was activated. No build outputs or +SQLite artifacts are tracked. Real-host/browser/install/release acceptance remains +open, as does the final owner review of the combined PR. diff --git a/go.work b/go.work new file mode 100644 index 00000000..b0ce4733 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.27.2 + +use ( + ./apps/api_client + ./apps/host_agent + ./apps/whr +) diff --git a/third_party/README.md b/third_party/README.md index fc1a9145..b936be71 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -14,3 +14,16 @@ snapshot. Its MIT notice is retained in `spec-kit/LICENSE`. The project constitu is WorkHarbor-specific. Review release migration before upgrading; do not overwrite project documents. No Symphony source is vendored. WorkHarbor is licensed under the [EUPL-1.2](../LICENSE); the third-party MIT license does not license WorkHarbor. + +## Phoenix scaffold + +`apps/control_plane/` was generated by the official `phx_new` 1.8.15 installer +with Ecto/SQLite, binary IDs, HTML, LiveView and assets, excluding mailer, +dashboard, Gettext and generated agent instructions. Phoenix template provenance: +https://github.com/phoenixframework/phoenix/tree/v1.8.15/installer +Its MIT notice is retained in [phoenix/LICENSE](phoenix/LICENSE). The generated +vendored `assets/vendor/topbar.js` identifies topbar 3.0.0, MIT, Copyright 2024 +Buu Nguyen; its full MIT notice is retained in [topbar/LICENSE](topbar/LICENSE). +Generated Phoenix welcome images/markup remain scaffold assets, not WorkHarbor +branding. Other fetched packages are not vendored; exact Hex checksums and +heroicons/daisyUI Git revisions are recorded in the control-plane `mix.lock`. diff --git a/third_party/phoenix/LICENSE b/third_party/phoenix/LICENSE new file mode 100644 index 00000000..a2197e0d --- /dev/null +++ b/third_party/phoenix/LICENSE @@ -0,0 +1,22 @@ +# MIT License + +Copyright (c) 2014 Chris McCord + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third_party/topbar/LICENSE b/third_party/topbar/LICENSE new file mode 100644 index 00000000..4054f2b2 --- /dev/null +++ b/third_party/topbar/LICENSE @@ -0,0 +1,22 @@ +# MIT License + +Copyright (c) 2024 Buu Nguyen + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 65ec1b92cf2ce97118d6dc89140f3a821adec567 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 14:12:55 +0200 Subject: [PATCH 05/51] docs(pilot): separate internal acceptance from signed release Avoid requiring a signed release on main before the PR that introduces the implementation. Keep local packaging and release-policy tests in the pilot, retain real-host/browser/load gates, and leave SC-006 plus full Feature 006 completion open for a separately authorized signed-release follow-up. Refs: #72 --- docs/exec-plans/active/installable-pilot.md | 24 ++++++++++++++ specs/006-whr-cli/plan.md | 36 +++++++++++++++------ specs/006-whr-cli/quickstart.md | 16 ++++++--- specs/006-whr-cli/tasks.md | 6 ++-- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index c1fde6a4..b339a437 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -98,3 +98,27 @@ No API, lifecycle/provider behavior, Python migration or harness change is inclu No web server, Docker host or live operation was activated. No build outputs or SQLite artifacts are tracked. Real-host/browser/install/release acceptance remains open, as does the final owner review of the combined PR. + +## Internal-pilot acceptance ordering correction (review pending) + +Independent Sol planning review found that T024's former all-SC-001–012 gate +required a human-signed release on `main` before the PR introducing the product +implementation. The owner requested an internal installable pilot and final human +PR review, with no release publication; that release prerequisite cannot precede +this handoff. + +- T023 retains local archive/install/uninstall, checksums, SBOM, recorded build + inputs/provenance and draft-only release machinery/policy tests. +- T024 requires SC-001–005 and SC-007–012 and all real-host, browser and + five-workspace/eight-attachment load acceptance before the combined PR. +- The owner-authorized allowlisted human-signed release exercise and SC-006 remain + a separate follow-up outside internal-pilot acceptance. Full Feature 006 remains + open until SC-006 passes; release publication remains separately authorized. +- Plan, tasks and quickstart now agree on this boundary. No task is marked done. + The T001 author record above and product source at + `3dd0b7825ae78c13d9e4dba3f9da683ff486f0f9` are unchanged by this correction; its + independent scaffold review remains separate from planning-delta review. +- `python3.14 scripts/check_repo.py` and `git diff --check` pass for this + documentation correction. Independent Sol planning re-review remains pending. + No runtime or signed-release result is claimed; full `make check` was not rerun + by this documentation author. diff --git a/specs/006-whr-cli/plan.md b/specs/006-whr-cli/plan.md index 884b4840..ab135c83 100644 --- a/specs/006-whr-cli/plan.md +++ b/specs/006-whr-cli/plan.md @@ -11,7 +11,9 @@ re-review is pending. No implementation or runtime verification is claimed. The owner explicitly requested on 2026-10-14 that work continue in this WorkHarbor worktree, on this branch, through the installable pilot, including selective Python-to-Go migration, independent review and fixes after each work item, and -one PR after all pilot work items are accepted. The same worktree is retained. +one PR after all internal-pilot work items are accepted, for final human review. +The pilot is internal; release publication is outside this execution scope. The +same worktree is retained. This is a WorkHarbor task; no SheetMill files or sibling product scope are changed. ## Work-item sequence @@ -112,10 +114,13 @@ supply a live host grant, signed release, merge or evidence that checks passed. ## Installable-pilot acceptance -All Feature 001 tasks and Feature 006 stories remain required. Additional command -surface (rebuild/removal, CLI auth/operations, host administration, agent controls, -dashboard, SSH and release verification) is not completed by a scaffold. Each task -requires its own meaningful failure-path tests and independent review. +All Feature 001 tasks and applicable Feature 006 internal-pilot stories remain +required, including SC-001–005 and SC-007–012. Additional command surface +(rebuild/removal, CLI auth/operations, host administration, agent controls, +dashboard and SSH) and local packaging/release-policy checks are not completed +by a scaffold. Each task requires its own meaningful failure-path tests and +independent review. SC-006 is excluded only from internal-pilot acceptance; full +Feature 006 completion remains open until its signed-release verification passes. Application checks: Go format/vet/build/tests per module, Phoenix format, warnings-as-errors compile and tests, plus inherited `make check` using Python 3.11+ in a pristine committed checkout. Extend the reviewed acceptance runner and @@ -128,8 +133,19 @@ agent interface, dashboard authorization/browser-origin negatives, SSH, resumabl offboarding and quiesced clean restore. Measure Feature 001 five-workspace/eight- attachment load, timings and idle/load footprint. Stub tests do not prove real use. -Local unsigned archives are labelled development artifacts. Signed root release -tag, public distribution, package channels and deployment need separate owner -authorization. Do not claim SC-006 from an unsigned archive. Actual disposable -Linux host, TLS hostname/certificate, live-test scope and signer allowlist are -owner-controlled inputs; a configured Docker context establishes none of them. +T023 produces local unsigned archives labelled development artifacts, checksums, +SBOM and recorded build inputs/provenance, plus clean install/uninstall evidence +and draft-only release machinery/policy tests. T024 requires the real-host, +browser and load acceptance above and exact-head checks/independent Sol review +before the combined PR is handed to the owner for final human review. + +The owner-authorized allowlisted human-signed release exercise on `main` and +end-to-end SC-006 verification are a separate follow-up after this internal-pilot +handoff; they cannot be prerequisites for the PR introducing the implementation. +Full Feature 006 remains open until SC-006 passes. No signed-release evidence is +claimed from an unsigned archive. Signed release tags, public distribution, +package channels and deployment need separate owner authorization. Actual +disposable Linux host, TLS hostname/certificate and live-test scope remain +owner-controlled inputs required for pilot acceptance; signer allowlist and +release authorization are inputs to the later release exercise. A configured +Docker context establishes none of them. diff --git a/specs/006-whr-cli/quickstart.md b/specs/006-whr-cli/quickstart.md index 01b01fb9..0537d10d 100644 --- a/specs/006-whr-cli/quickstart.md +++ b/specs/006-whr-cli/quickstart.md @@ -39,7 +39,15 @@ Run independent Python/Go golden-vector parity before switching migrated callers 9. Exercise five workspaces/eight attachments; capture exact commands, timings and idle/load footprint. Missing real host/browser results block pilot acceptance. -Package archives/checksums/SBOM/provenance and clean install/uninstall instructions. -Unsigned local pilot artifacts are not signed releases; SC-006 remains open until -an owner-authorized signed-tag verification exercise. Publish the combined PR only -after all pilot WIs clear independent review and exact-head required checks pass. +Package local unsigned archives, checksums, SBOM, recorded build inputs/provenance +and clean install/uninstall instructions; verify archive contents and draft-only +release machinery/policy tests without publishing a release. Internal-pilot +acceptance requires SC-001–005 and SC-007–012, including every real-host/browser/ +load result above. Open the combined PR for final human review only after all +internal-pilot WIs clear independent Sol review and exact-head required checks pass. + +The owner-authorized allowlisted human-signed release exercise on `main`, including +end-to-end signature/checksum/SBOM/provenance verification for SC-006, is a separate +follow-up outside internal-pilot acceptance. Unsigned local artifacts do not satisfy +SC-006. Full Feature 006 completion remains open until SC-006 passes; no release +publication, tag or package-channel update is authorized by this pilot handoff. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index 1fa376c4..ae9dd1e1 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -86,11 +86,11 @@ Independent check: key/cert failures, remote statuses and external config disclo ## Phase 9: Pilot install and final acceptance Goal: installed recoverable product and exact-head reviewable handoff. -Independent check: clean client/host install, real loaded journey, restore and release verification. +Independent check: clean client/host install, real loaded journey, restore and local package/release-policy checks. - [ ] T022 Implement quiesced backup/clean restore in `scripts/backup-workharbor.sh`, `scripts/restore-workharbor.sh`, `docs/development/backup-restore.md`; prove exact uncommitted hashes, owner/storage/operation associations, revoked identity rejection, unknown restored observations, no auto-start and failure to quiesce aborts (001 T019). -- [ ] T023 Implement local archive/install/uninstall, pinned toolchain instructions, checksums, SBOM/provenance and draft-only release machinery in `scripts/pilot/`, `.github/workflows/`, `docs/development/installable-pilot.md`; test clean installation and contents; separately authorized allowlisted human-signed tag exercise is required for SC-006, never claimed from unsigned local artifacts (FR-023–027). -- [ ] T024 Run every story/criterion at five-workspace/eight-attachment load on the authorized real host, record exact commands/versions/timings/failures in `specs/006-whr-cli/quickstart.md`, `specs/001-workspace-lifecycle/quickstart.md`, `docs/quality.md`; fix findings, run exact-head canonical/application gates, obtain independent final review and open the combined PR to main (001 T010; SC-001–012). +- [ ] T023 Implement local archive/install/uninstall, pinned toolchain instructions, checksums, SBOM, recorded build inputs/provenance and draft-only release machinery in `scripts/pilot/`, `.github/workflows/`, `docs/development/installable-pilot.md`; test clean installation/uninstallation, archive contents and release policy, including signed-tag/allowlist/main/check requirements and refusal to publish automatically. The owner-authorized allowlisted human-signed release exercise and SC-006 are a separate follow-up outside internal-pilot acceptance; unsigned local artifacts never satisfy SC-006 (FR-023–027). +- [ ] T024 Run every applicable internal-pilot story/criterion, including SC-001–005 and SC-007–012, at five-workspace/eight-attachment load on the authorized real host, record exact commands/versions/timings/failures in `specs/006-whr-cli/quickstart.md`, `specs/001-workspace-lifecycle/quickstart.md`, `docs/quality.md`; fix findings, run exact-head canonical/application gates, obtain independent Sol final review and open the combined PR to main for owner review (001 T010). SC-006 signed-release verification is excluded only from this internal-pilot handoff and remains a separately authorized follow-up; full Feature 006 completion remains open until SC-006 passes. ## Dependencies and execution From 24d2ee364492783661f42efd33dc5d894e1c92eb Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 14:15:52 +0200 Subject: [PATCH 06/51] fix(whr): return documented usage exit for unsupported commands Honor ADR 0013 from the scaffold onward so unsupported commands cannot establish an incompatible CLI exit contract. Require exact usage exit 64 and the whr stderr prefix in the refusal regression test, retaining the host-agent contract and historical scaffold measurements. Refs: #72 --- apps/whr/cmd/whr/main.go | 2 +- apps/whr/cmd/whr/main_test.go | 6 +++--- docs/exec-plans/active/installable-pilot.md | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/whr/cmd/whr/main.go b/apps/whr/cmd/whr/main.go index 11236829..a0add95f 100644 --- a/apps/whr/cmd/whr/main.go +++ b/apps/whr/cmd/whr/main.go @@ -20,5 +20,5 @@ func run(args []string, out, errOut io.Writer) int { return 0 } fmt.Fprintln(errOut, "whr: product commands are not implemented; use --help") - return 1 + return 64 } diff --git a/apps/whr/cmd/whr/main_test.go b/apps/whr/cmd/whr/main_test.go index 38edad44..f86ee48f 100644 --- a/apps/whr/cmd/whr/main_test.go +++ b/apps/whr/cmd/whr/main_test.go @@ -11,10 +11,10 @@ import ( func TestScaffoldRefusesProductCommands(t *testing.T) { for _, args := range [][]string{{"setup"}, {"ws", "create"}, {"--version", "setup"}} { var out, errOut bytes.Buffer - if code := run(args, &out, &errOut); code == 0 { - t.Fatalf("run(%q) succeeded before product commands exist", args) + if code := run(args, &out, &errOut); code != 64 { + t.Fatalf("run(%q) exit = %d, want usage exit 64", args, code) } - if out.Len() != 0 || !strings.Contains(errOut.String(), "not implemented") { + if out.Len() != 0 || !strings.HasPrefix(errOut.String(), "whr:") || !strings.Contains(errOut.String(), "not implemented") { t.Fatalf("run(%q): stdout=%q stderr=%q", args, out.String(), errOut.String()) } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index b339a437..64117ca5 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -49,6 +49,9 @@ Signed release and live test/installation inputs are separate owner-controlled g ## T001 — pinned application scaffolds (candidate; review pending) Input: planning clearance at `602ae81c63be52ccb2ee18aa9802a50d3e35260d`. +The measurements below describe original candidate +`3dd0b7825ae78c13d9e4dba3f9da683ff486f0f9`; the reviewed usage-exit correction +is recorded separately below. Only T001 is implemented by this candidate; T002 and all Feature 001/006 task completion markers remain pending. Independent review must evaluate the exact commit containing this record before successor work. @@ -122,3 +125,21 @@ this handoff. documentation correction. Independent Sol planning re-review remains pending. No runtime or signed-release result is claimed; full `make check` was not rerun by this documentation author. + +## T001 review correction — CLI usage exit (re-review pending) + +Independent Sol review of `3dd0b7825ae78c13d9e4dba3f9da683ff486f0f9` +returned NOT CLEAR with one P2 finding: the `whr` scaffold returned exit 1 for +unsupported arguments instead of ADR 0013 usage exit 64. The refusal test now +requires exactly 64, empty stdout and the `whr:` stderr prefix, including the +unsupported `--version setup` combination. The strengthened test first failed +with actual exit 1; the implementation then changed only `whr`'s refusal code. +Host-agent exit behavior remains unchanged because ADR 0013 governs `whr`. + +Affected `whr` checks pass: empty `gofmt -l .`, `go vet ./...`, +`go build -o /private/tmp/workharbor-pilot-build/whr/ ./...` and `go test ./...`. +Built CLI refusal probes for `setup`, `ws create` and `--version setup` return +64 with empty stdout and a `whr:` stderr line; help/version still return 0. +`python3.14 scripts/check_repo.py` and staged/working-tree whitespace checks pass. +No full-suite rerun, runtime activation, task-completion marker or publication is +claimed. The exact corrective commit awaits the same independent Sol reviewer. From cf9878539ba65948c21ebf2fa93b47407d3d4ac3 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 14:36:21 +0200 Subject: [PATCH 07/51] ci(pilot): enforce explicit Go and Phoenix acceptance Add fixed application gates and required outcome inventories so clean local and CI checks reject missing tools, missing/skipped tests and failed builds. Bootstrap locked dependencies in isolated state without changing inherited Python policy or check deadlines. Refs: #67, #72 Co-authored-by: Codex --- .github/workflows/check.yml | 21 ++ Makefile | 5 +- README.md | 4 +- .../test/support/acceptance_formatter.ex | 33 +++ docs/adr/0007-explicit-ci-acceptance.md | 17 ++ docs/development/ci-acceptance.json | 35 ++- docs/development/harness.md | 75 +++++-- docs/exec-plans/active/installable-pilot.md | 174 ++++++++++++++- scripts/application_checks.py | 205 ++++++++++++++++++ scripts/check_ci.py | 48 +++- scripts/test_application_checks.py | 163 ++++++++++++++ scripts/test_check_ci.py | 69 +++++- scripts/test_task_readiness.py | 2 +- scripts/test_task_source.py | 2 +- specs/006-whr-cli/plan.md | 5 +- specs/006-whr-cli/tasks.md | 9 +- 16 files changed, 817 insertions(+), 50 deletions(-) create mode 100644 apps/control_plane/test/support/acceptance_formatter.ex create mode 100644 scripts/application_checks.py create mode 100644 scripts/test_application_checks.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fbb0ceb1..f8c012db 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -5,11 +5,32 @@ permissions: jobs: bootstrap: runs-on: ubuntu-24.04 + timeout-minutes: 35 steps: # pull_request checks GitHub's ordinary synthetic merge into main. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + # Native tool installers only; no product runtime, containers or activation. + # SHAs resolved from the official repositories' tag API on 2026-10-14. + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.14.8' + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.27.2' + cache: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '26.11.1' + package-manager-cache: false + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1 + with: + version-type: strict + otp-version: '29.1.1' + elixir-version: '1.20.4' + install-hex: false + install-rebar: false - name: Validate explicit repository acceptance run: python3 -I -B scripts/check_ci.py diff --git a/Makefile b/Makefile index b2259ff1..bb2f061b 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ PYTHON ?= python3 -.PHONY: check check-raw test +.PHONY: check check-raw check-apps test check: $(PYTHON) -I -B scripts/check_ci.py +check-apps: + $(PYTHON) -I -B scripts/check_ci.py --applications-only + check-raw: $(PYTHON) scripts/check_repo.py $(PYTHON) -m unittest discover -s tests -v diff --git a/README.md b/README.md index 76d8cd9c..d0858aeb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ make check The bundle must be supplied separately; it is not required for the GitHub clone. -Requires Git, Make, Python 3.11+ and Bash. On macOS the built-in Python may need a +Requires Git, Make, Bash, native C build tools and the exact versions in +`.tool-versions` (Python, Go, Node, Elixir and Erlang). The application check fetches +locked dependencies and pinned build tools; see the [harness](docs/development/harness.md). On macOS the built-in Python may need a newer installation. Open Codex in this repository; generated `$speckit-*` skills live under `.agents/skills/`. Read [AGENTS.md](AGENTS.md), the [constitution](.specify/memory/constitution.md) and [documentation index](docs/index.md). diff --git a/apps/control_plane/test/support/acceptance_formatter.ex b/apps/control_plane/test/support/acceptance_formatter.ex new file mode 100644 index 00000000..2248b689 --- /dev/null +++ b/apps/control_plane/test/support/acceptance_formatter.ex @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.AcceptanceFormatter do + @moduledoc false + use GenServer + + def init(_opts) do + path = System.fetch_env!("WORKHARBOR_EXUNIT_EVENTS") + {:ok, File.open!(path, [:write, :exclusive, :utf8])} + end + + def handle_cast({:suite_started, _opts}, io) do + emit(io, %{event: "suite_started"}) + {:noreply, io} + end + + def handle_cast({:test_finished, test}, io) do + status = if is_nil(test.state), do: "passed", else: "not_passed" + + emit(io, %{event: "test_finished", id: "#{inspect(test.module)}/#{test.name}", status: status}) + + {:noreply, io} + end + + def handle_cast({:suite_finished, _times}, io) do + emit(io, %{event: "suite_finished"}) + {:noreply, io} + end + + def handle_cast(_event, io), do: {:noreply, io} + + defp emit(io, event), do: IO.write(io, Jason.encode!(event) <> "\n") +end diff --git a/docs/adr/0007-explicit-ci-acceptance.md b/docs/adr/0007-explicit-ci-acceptance.md index 56827830..3c6d0f36 100644 --- a/docs/adr/0007-explicit-ci-acceptance.md +++ b/docs/adr/0007-explicit-ci-acceptance.md @@ -90,3 +90,20 @@ publication or deployment is authorized by this decision. The bounded Feature 004 CI/prepublication contracts are updated to this decision; other planned interfaces remain proposed. Owner review and merge adopt this policy through the ordinary repository process, without a separate external pin ceremony. + +## Installable-pilot application extension (proposed) + +Feature 006 T002 extends the same runner and manifest to Go/Phoenix scaffold +acceptance. Schema 2 retains all existing Python required IDs and retained-outcome +semantics and adds typed application required test identities, with fixed argv in +`scripts/application_checks.py`. Go JSON events and a test-only ExUnit formatter +prove each required case actually passed; all other discovered test failures/skips +remain blockers. The doc-only API client has no fabricated required test. + +The application gate performs pinned bootstrap/locked dependency acquisition from a +fresh checkout, then Go format/vet/build/tests and Phoenix format/compile/assets/tests. +Local and CI use the same function; focused `make check-apps` is development evidence +only. Tool versions, cache boundaries, event semantics and unchanged budgets are in +[the harness guide](../development/harness.md). This candidate inventory requires +the combined pilot's final owner PR review and merge; neither a local pass nor +independent code review adopts its own policy. No runtime or release is activated. diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 048f482a..90a80412 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "required_test_ids": [ "test_approval_binding.ApprovalBindingTests.test_changed_scope_requirements_checks_dependencies_or_mode_requires_renewal", "test_approval_binding.ApprovalBindingTests.test_clean_and_process_filters_are_refused_without_executing_shell", @@ -492,7 +492,36 @@ "verify_t004_cli.CliBoundaryTests.test_mixed_legacy_and_sqlite_is_never_adopted", "verify_t004_cli.CliBoundaryTests.test_persistence_exit_six_is_safe", "verify_t004_cli.CliBoundaryTests.test_scope_blocker_exit_three", - "verify_t004_cli.CliBoundaryTests.test_unsafe_lock_paths_rejected_without_external_writes" + "verify_t004_cli.CliBoundaryTests.test_unsafe_lock_paths_rejected_without_external_writes", + "test_application_checks.ApplicationChecksTests.test_inventory_rejects_missing_duplicate_empty_critical_and_command_fields", + "test_application_checks.ApplicationChecksTests.test_go_missing_skip_fail_duplicate_and_incomplete_events_block", + "test_application_checks.ApplicationChecksTests.test_go_doc_only_library_still_rejects_unexpected_bad_tests", + "test_application_checks.ApplicationChecksTests.test_exunit_missing_skipped_excluded_failed_duplicate_and_incomplete_block", + "test_application_checks.ApplicationChecksTests.test_malformed_event_bytes_never_qualify", + "test_application_checks.ApplicationChecksTests.test_missing_tool_blocks_before_any_application_command", + "test_application_checks.ApplicationChecksTests.test_wrong_tool_version_unformatted_source_and_failed_gates_block", + "test_application_checks.ApplicationChecksTests.test_fixed_gates_locked_setup_external_caches_and_all_modules", + "test_application_checks.ApplicationChecksTests.test_ci_and_local_share_the_application_gate", + "test_check_ci.SourceTests.test_application_failure_blocks_even_when_all_python_checks_pass", + "test_check_ci.SourceTests.test_captured_output_keeps_raw_bytes_and_refuses_existing_file_before_spawn", + "test_application_checks.ApplicationChecksTests.test_concurrent_nested_packages_keep_exact_test_ownership", + "test_check_ci.SourceTests.test_python3_children_use_selected_interpreter_and_path_restores_on_exception" ], - "retained_assertion_failures": [] + "retained_assertion_failures": [], + "application_tests": { + "whr": [ + "github.com/wstein/workharbor-nxt/apps/whr/cmd/whr/TestScaffoldRefusesProductCommands" + ], + "host_agent": [ + "github.com/wstein/workharbor-nxt/apps/host_agent/cmd/workharbor-agent/TestScaffoldRefusesProductCommands" + ], + "api_client": [], + "control_plane": [ + "WorkHarborWeb.ErrorHTMLTest/test renders 404.html", + "WorkHarborWeb.ErrorHTMLTest/test renders 500.html", + "WorkHarborWeb.ErrorJSONTest/test renders 404", + "WorkHarborWeb.ErrorJSONTest/test renders 500", + "WorkHarborWeb.PageControllerTest/test GET /" + ] + } } diff --git a/docs/development/harness.md b/docs/development/harness.md index 4e19e145..798c833c 100644 --- a/docs/development/harness.md +++ b/docs/development/harness.md @@ -1,8 +1,12 @@ # Development harness ## Bootstrap checks -Python 3.11+ and Bash are enough for `make check`; no product runtime or container -engine is needed. Checks validate project document links, tracked JSON, required +`make check` requires the exact Python, Go, Node, Elixir and Erlang versions in +`.tool-versions`, plus Git, Make, Bash and the native C build tools required by +locked Phoenix dependencies. No container engine or product server is started. +The application gate acquires locked dependencies and pinned build tools over +HTTPS; an offline checkout needs a separately reviewed dependency supply path. +Checks validate project document links, tracked JSON, required artifacts, pending feature status, contract shape of the workspace example, and coarse dependency boundaries. Harness regression tests inject broken references, unsafe paths and forbidden dependencies to prove rejection. These checks do not @@ -18,7 +22,8 @@ scripts; create `001-workspace-lifecycle` for implementation. Run `make check`, targeted behavior tests, then appropriate real-host/browser acceptance. Record commands, versions, output summary, limitations and commit. -Implement product format/build/test targets in T001 before claiming product checks. +The application gate validates scaffold format/build/test behavior; it does not +establish lifecycle, browser or real-host acceptance. The architecture lint catches obvious prohibited references only; add compiler-backed Elixir/Go dependency enforcement when application packages exist. @@ -32,8 +37,9 @@ or background services implicitly. ## CI and explicit acceptance -Run `make check PYTHON=python3.11` (or a newer Python) in a pristine isolated -checkout. Local and CI acceptance both use `scripts/check_ci.py` and +Run `make check PYTHON=python3.14` with Python 3.14.8 in a pristine isolated +checkout and install the remaining `.tool-versions` pins first. +Local and CI acceptance both use `scripts/check_ci.py` and [ci-acceptance.json](ci-acceptance.json), as proposed by [ADR 0007](../adr/0007-explicit-ci-acceptance.md). Owner review and merge are required for this policy, manifest inventory and retained-outcome changes. Neither a passing @@ -42,19 +48,49 @@ check nor a candidate manifest authenticates its own approval. The runner discovers every baseline and operator regression module, and directly loads the twelve `verify_t004_cli.CliBoundaryTests` and twelve `verify_t003_state.StatePrimitiveTests`. Required IDs are a minimum: new green -regressions need no task-specific exception or hash update. The initial manifest -allows exactly five test-body AssertionErrors by ID and exact message, each with a -reason and removal condition. Missing required IDs, new failures, unexpected passes, -errors, skips, xfails, failed subtests, duplicates and empty discovery block. -Setup, teardown and cleanup failures cannot impersonate a retained test assertion. - -`make check-raw` preserves the baseline suite's actual outcome: currently 41 tests, -36 passes, five exact failures and Make exit 2 (direct unittest exit 1). The qualified -runner prints that raw result and separately verifies the full discovered matrix. -It also runs repository validation, diff whitespace, shell syntax and Feature 001 -prerequisites; raw test reds cannot skip these static checks. Qualified acceptance -is not full-green product or task implementation evidence. The retained scenarios -remain incomplete until their reviewed migration conditions are satisfied. +regressions need no task-specific exception or hash update. The current manifest retains every inherited required Python ID +and has no retained +failures. Missing required IDs, new failures, errors, skips, xfails, failed subtests, +duplicates and empty discovery block. Setup, teardown and cleanup failures cannot +impersonate a retained test assertion. Historical checkpoint rules and measurements +remain in ADR 0007; they do not describe current raw outcomes. + +`make check-raw` preserves the baseline suite's actual outcome. The qualified runner +prints that raw result and separately verifies the full discovered matrix. It also +runs repository validation, diff whitespace, shell syntax and Feature 001 +prerequisites; raw reds cannot skip these static checks. Raw and qualified aggregate +budgets remain 600 and 1200 seconds, respectively; utility commands retain 300 seconds. + +Schema 2 adds an explicit `application_tests` inventory. Fixed repository code runs +`gofmt -l`, `go mod download`, `go vet`, `go build` and uncached `go test -json` for +all three Go modules. The two command refusal tests are required. The API client +currently has documentation only and no required tests; any new test outcome must +still pass. Package skips qualify only for an explicit `[no test files]` event, +never for a skipped test. Phoenix runs locked dependency acquisition, format, +warnings-as-errors compile, asset tool installation/build and warnings-as-errors +tests. All five existing ExUnit tests are required. The test-only formatter records +actual test outcomes alongside the ordinary CLI formatter; missing IDs, skipped or +excluded tests, duplicate outcomes and incomplete streams fail. New green tests +are collected without requiring new manifest entries. Thirteen required Python controls +cover the added validators, tool/setup failures and integration into full acceptance. +Manifest changes remain proposals until owner review and merge. + +Both CI and local full acceptance execute this gate inside the runner's fresh +disposable checkout. `make check-apps` runs the same application function as a focused +development check; it does not replace full `make check` or source qualification. +Hex 2.5.2 is installed through Mix's signed installer and Rebar 3.27.3 through its +exact official URL and SHA-512. `mix deps.get --check-locked` uses committed +`mix.lock`; Tailwind/esbuild versions come from committed Phoenix configuration. +No owner global tool state is written. A private temporary HOME, Mix/Hex state, +Go module/build caches, Go command build output and acceptance event files live +outside the source tree. Phoenix dependency checkouts, build intermediates, test +SQLite files and assets are generated fresh inside the disposable checkout because +the generated asset configuration uses their relative paths; none are copied in. +The isolated Python cache also holds a temporary `python3` alias to the selected +`PYTHON` executable for inherited tests that use that spelling; PATH is restored +on success or exception. Ordinary script children retain script-directory imports. +Inherited Go/BEAM flags, Hex mirrors and server activation variables are not passed +to the gate. Tools are checked against `.tool-versions` before application work. GitHub Actions uses ordinary PR synthetic-merge checkout, contents read permission and `persist-credentials: false`. The runner checks exact source context and actual @@ -108,6 +144,7 @@ python3 -I -B -c 'import sys,unittest;sys.path.insert(0,"scripts");raise SystemE Disposable Git fixtures exercise package provenance, host approval/argv drift, missing guard/capabilities, strict new outcomes and exact assertion/raw-exit handling. -The prepublication source pin also covers the package and state traversal dependencies; +The prepublication source pin also covers the package, state traversal and +application-gate helper dependencies; it continues to identify code rather than establish owner approval. No prerequisites, network services or real feature execution are used by these fixtures. diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 64117ca5..4001b37b 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -7,9 +7,12 @@ intermediate planning/WI reviews and fixes; the owner reviews the final combined This supersedes the earlier intermediate human spec/plan-review workflow for this authorized pilot execution. No historical human artifact approval is asserted, and no pending intermediate human approval is treated as an execution blocker. -Status: independent Sol planning re-review cleared `602ae81c63be52ccb2ee18aa9802a50d3e35260d` -without findings; repository and whitespace checks passed. T001 scaffold is an -authored candidate awaiting independent Sol review. No product WI is accepted. +Status: independent Sol planning review cleared +`602ae81c63be52ccb2ee18aa9802a50d3e35260d`, and the internal-pilot/release ordering +correction cleared `0c2c135d8d82d8ed708095f7f1596760df38696b`. T001 scaffold is +accepted after independent Sol CLEAR of `d3d84c8361a1389292fad96d02a828ac9fabc2e3`. +T002 is implemented and under validation; its independent review remains pending. +No product runtime acceptance is claimed. Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; changing the design baseline does not change frozen Python behavior. @@ -143,3 +146,168 @@ Built CLI refusal probes for `setup`, `ws create` and `--version setup` return `python3.14 scripts/check_repo.py` and staged/working-tree whitespace checks pass. No full-suite rerun, runtime activation, task-completion marker or publication is claimed. The exact corrective commit awaits the same independent Sol reviewer. + +## T001 independent review clearance + +Independent Sol re-review cleared exact corrective head +`d3d84c8361a1389292fad96d02a828ac9fabc2e3`. Feature 006 T001 is now checked; +T002 remains pending until its separate review. Original Feature 001 checkboxes +remain unchanged because their protected baseline fixture requires later legitimate +reconciliation; T001 evidence maps to 001 T001/T011 without a false completion claim. + +## T002 — explicit application acceptance (candidate; review pending) + +Prerequisite: independent Sol CLEAR of T001 at +`d3d84c8361a1389292fad96d02a828ac9fabc2e3`. Scope is application acceptance only, +including fixed typed gates, meaningful required test identity inventory, clean +dependency bootstrap, Makefile/CI equivalence and focused negative fixtures. +The helper and test-only ExUnit formatter are listed in the T002 path scope; they +keep executable argv out of manifest data and report actual ExUnit events without +adding product behavior. No Python product behavior is ported in this WI. + +Schema 2 preserves all 492 inherited required Python IDs and the empty retained +failure list, and adds thirteen required Python controls, both existing Go command +refusal tests and all five existing Phoenix tests. The API client remains doc-only +with no invented required test; every actual Go test event must pass. Required ID +loss, failures, errors, skipped/excluded tests, duplicates, incomplete streams, +missing tools, version drift and failed setup/format/build/assets gates block. +Raw/qualified deadlines remain 600/1200 seconds; utility commands remain 300. + +The application gate uses the exact `.tool-versions` pins and temporary isolated +HOME/Mix/Hex/Go state, with Go binaries/events outside the checkout. Phoenix's +dependency checkouts/build intermediates/assets/test SQLite files are generated +fresh in the disposable checkout to preserve its generated relative asset paths. +Hex 2.5.2 uses Mix's signed installer. Rebar 3.27.3 uses the official GitHub release +URL and SHA-512, with downloaded bytes also checked against GitHub's published +SHA-256 during authoring. CI pins checkout/setup-go/setup-node/setup-python and +setup-beam commits resolved through official GitHub tag APIs on 2026-10-14; +setup-beam's annotated v1 tag was resolved to its commit. CI contents permission +remains read-only, credentials are not persisted, and no runtime/release starts. + +Preimplementation evidence: nine new application-control tests errored because +`scripts/application_checks.py` did not yet exist. After implementation, all nine +passed; the additional full-runner integration control also passed. Existing +`test_check_ci` ran 48 tests in 29.401s and passed with native process observation, +including the descendant-cleanup regression. Later controls and source checks are +recorded below after final candidate validation. + +Intermediate application failures are retained honestly: + +- Initial real gate failed because `go build -o ./...` cannot build + the doc-only API client. The fixed library gate uses `go build ./...`; command + modules keep external output directories. +- A source-only fresh snapshot downloaded pinned dependencies, then failed + compiling telemetry because Mix CDN's Rebar 3.22.0 binary cannot load on OTP 29. + Rebar 3.27.3 corrected that compatibility failure without weakening any gate. +- The next application run reached Phoenix format and rejected two missing blank + lines in the new test-only formatter. The formatter was corrected. + +Historical full baseline at `c597fa6` (before this WI): raw 411 tests passed in +426.531s; qualified 818 tests ran in 819.587s and was RED with two failure subcases +in `test_check_ci.AcceptanceTests.test_nested_check_processes_stop_before_disposable_checkout_cleanup` +because sandbox process observation denied `ps`. A direct isolated-file attempt +subsequently failed import before executing a test. The corrected trusted loader +with `scripts` on its import path passed that unchanged test's two subcases with +native permission in 2.301s. No baseline failure was reclassified, test/process +check weakened or unrelated production bug fixed. The final T002 full check uses +a pristine committed private clone and native process-observation permission. + +T002 remains unchecked and awaits independent Sol review. No publication, runtime, +Docker/browser/install or release acceptance is claimed. + +Before candidate commit, independent Sol review identified a P2 in Go event +classification: prefix matching could treat a nested package's test as owned by +its parent, rejecting a valid concurrent parent `[no test files]` skip. A new +regression first failed in both event orderings (child running and child already +passed). The classifier now tracks exact `(package, test)` ownership for running +and passed cases. This changes no skip policy and accepts no failed test. + +Actual application gates now pass: both Go refusal tests, the API-client library +build with no test files, Phoenix format/warnings-as-errors compile, asset +installation/build and all five ExUnit cases. The source snapshot originally had +no copied dependencies or builds; intermediate setup failures above were corrected +in that snapshot. The canonical committed fresh-clone check remains the authoritative +clean-build/full-suite evidence and is pending at this candidate commit. + +Final focused verification before the nested-package correction: 59 application +and CI-runner controls passed in 32.152s. `python3.14 scripts/check_repo.py` and +`git diff --check` passed. Linux CI, Docker/browser/install/release acceptance +remain unexecuted; CI uses the same native gate but is not claimed observed green. + +After the nested-package correction, all ten application-control tests pass in +0.076s, including both previously failing ownership orderings. Repository and +whitespace checks pass. The existing CI job identity `bootstrap` is retained for +required-check continuity; the application gate is added to that same job. + +## T002 canonical-run environment correction + +The first canonical full run on `3900757138f84cf04838d7d3433f94d862ed2a83` +(tree `7538d7e7e99d2846903eeffb380bb5db2b33ffa3`) finished raw RED: 411 tests +in 448.736s, one failure and two errors, raw Make exit 2. All three were unchanged +Symphony tests whose literal `python3` subprocess selected macOS Python 3.9.6 +instead of the primary selected Python 3.14.8; `prepare_symphony.py` failed import +at its `Path | None` annotation. No failing test was waived or reclassified. +The remaining qualified phase was interrupted after diagnosis, through SIGINT to +only the owned Make process group after native ps/lsof verified its exact private +clone cwd. The runner drained its active check group through normal cleanup; +the driver recorded signal exit -2 after 631.030s. No full green was claimed. + +A red-first regression with an incompatible inherited `python3` sentinel failed +both normal/exception subcases. The existing isolated Python-cache lifecycle now +creates a private temporary `python3` symlink to the selected `sys.executable` and +prepends its directory to PATH, restoring PATH on success or exception. It adds no +flags, so ordinary CLI script-directory imports remain supported. This corrects +local/CI interpreter selection without global alias writes or legacy test edits. +Independent review also measured all four unchanged Symphony cases passing with +an equivalent temporary alias. The revised candidate requires fresh exact-head +review and a coherent canonical full run; the first raw RED remains evidence. + +Before this behavior correction, the local T002 commit message was amended to add +truthful Codex co-author attribution under CrewBook commit hygiene. The initial +`3900757` and message-only successor `4ad64b97f6965357941844204aab6ecaff7b884b` +shared tree `7538d7e7e99d2846903eeffb380bb5db2b33ffa3`. A pristine clone of +`4ad64b9` passed source_context/source_identity/manifest/whitespace validation. +The interpreter correction changes the tree and receives separate fresh checks. + +The correction's focused application/CI-runner/Symphony suite passed 65 tests +using the new isolated alias lifecycle, including all four unchanged Symphony +cases. The dedicated alias/cache/import controls passed three tests in 0.531s. +The first run's private clone lists only its original checkout after interruption, +confirming the disposable execution worktree was removed through normal cleanup. +Repository and whitespace checks pass. Canonical validation is pending on the +revised committed candidate; no prior red or interrupted run is called green. + +## T002 real-checker fixture dependency correction + +The second coherent full run at +`345972d46d79700604ebbefe63062c6914411fa1` (tree +`989a79890477121f5169741bab9b68ff37bd37b1`) completed with overall Make exit 2 +after 1256.069s. Raw acceptance was GREEN: 411 tests in 431.761s, exit 0. +Qualified acceptance was RED: 831 tests in 787.641s, 19 failure records (including +subcases) and one error. Every failure/error belonged to existing readiness/source +fixtures that copied the real checker and its previous four source dependencies +but omitted the newly required `application_checks.py`. Strict source_identity +correctly rejected the incomplete fixtures before their expected positive paths. +No failure was qualified or waived. Actual fresh application gates subsequently +passed: both Go refusal tests, API-client doc-only build/no test files, Phoenix +format/warnings-as-errors compile, assets and all five ExUnit cases. + +A focused pre-fix reproduction ran two affected cases and produced three failure +records in 11.059s (`source-readiness-blocked`). The author then audited all +`check_ci.py` references across scripts/tests, and independent Sol review confirmed +exactly two stale real-checker copy lists: `test_task_readiness.py` and +`test_task_source.py`. Their lists now include the required helper, without changing +any assertions, guard policy or source_identity behavior. `test_check_ci.py` already +carried the helper; publication tests use an independent fake checker port and need +no helper. The immutable historical BLOBS inventory remains unchanged. T002's path +scope now explicitly includes both fixture builders. + +Complete direct CI/application controls after the fixture repair pass 61 tests in +34.649s. Complete affected readiness/source suites and the final revised canonical +run are required before clearance; the prior complete RED remains evidence. + +Both complete affected suites now pass: 52 readiness/source cases in 345.657s, +including every previously failing positive path and all unchanged negative guards. +The 61 direct CI/application controls also passed after repair. Repository and +whitespace checks pass. The fixture-only correction is folded into the T002 +candidate for fresh exact-head independent review and coherent full acceptance. diff --git a/scripts/application_checks.py b/scripts/application_checks.py new file mode 100644 index 00000000..fb8ae4f7 --- /dev/null +++ b/scripts/application_checks.py @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: EUPL-1.2 +"""Fixed application gates; manifest data supplies test identities, never argv.""" +import json +import os +import re +import shutil +import sys +import tempfile +from pathlib import Path + +GO_MODULES = ('whr', 'host_agent', 'api_client') +MODULE_PREFIX = 'github.com/wstein/workharbor-nxt/apps/' +HEX_VERSION = '2.5.2' +REBAR_URL = 'https://github.com/erlang/rebar3/releases/download/3.27.3/rebar3' +REBAR_SHA512 = ('30410ea3222f52f0af90e6ba7df483d9a1e3452dc6ed6f60f372008fc017dbd8a' + 'e22c45e1359302796f2dbb805bbcfce53bf5d08fdd43885203b0ae015ff0516') + + +def validate_inventory(value): + if not isinstance(value, dict) or set(value) != {*GO_MODULES, 'control_plane'}: + raise ValueError('Application inventory requires exactly the three Go modules and control_plane') + for module, ids in value.items(): + if (not isinstance(ids, list) or (not ids and module != 'api_client') + or any(not isinstance(identity, str) or not identity.strip() for identity in ids) + or len(ids) != len(set(ids))): + raise ValueError('Application required IDs must be unique and critical suites nonempty') + for identity in ids: + pattern = (re.escape(MODULE_PREFIX + module) + r'/[^\s]+/Test[^\s]+' + if module in GO_MODULES else r'WorkHarbor[A-Za-z0-9_.]*/test [^\r\n]+') + # A module-root Go package can also own a required behavior test. + if module in GO_MODULES and identity.startswith(MODULE_PREFIX + module + '/Test'): + pattern = re.escape(MODULE_PREFIX + module) + r'/Test[^\s]+' + if not re.fullmatch(pattern, identity): + raise ValueError('Invalid application required test identity') + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError('Duplicate application event key') + result[key] = value + return result + + +def read_events(path): + try: + events = [json.loads(line, object_pairs_hook=unique_object) for line in path.read_text().splitlines()] + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError('Missing or malformed application events') from error + if not events or any(not isinstance(event, dict) for event in events): + raise ValueError('Empty or malformed application events') + return events + + +def validate_go_events(events, required): + packages, running, passed, no_tests = {}, set(), set(), set() + for event in events: + action, package, test = event.get('Action'), event.get('Package'), event.get('Test') + if action == 'build-output': + continue + if action == 'build-fail' or not isinstance(package, str) or not package: + raise ValueError('Invalid or failed Go build/test event') + if test is not None and (not isinstance(test, str) or not test): + raise ValueError('Invalid Go test identity') + identity = (package, test) + if action == 'start' and not test: + if package in packages: + raise ValueError('Duplicate Go package start') + packages[package] = 'running' + elif package not in packages or packages[package] != 'running': + raise ValueError('Go event outside a running package') + elif action == 'run' and test: + if identity in running or identity in passed: + raise ValueError('Duplicate Go test dispatch') + running.add(identity) + elif action == 'pass' and test: + if identity not in running: + raise ValueError('Go pass without test dispatch') + running.remove(identity) + passed.add(identity) + elif action in ('pass', 'skip') and not test: + if any(test_package == package for test_package, _ in running): + raise ValueError('Incomplete Go test events') + if action == 'skip' and (package not in no_tests or any(test_package == package for test_package, _ in passed)): + raise ValueError('Go package skipped tests') + packages[package] = action + elif action == 'output': + if not test and '[no test files]' in event.get('Output', ''): + no_tests.add(package) + elif action in ('pause', 'cont') and test and identity in running: + continue + else: + raise ValueError('Failed, skipped or unknown Go test event') + passed_ids = {package + '/' + test for package, test in passed} + if (not packages or running or 'running' in packages.values() or not set(required) <= passed_ids): + raise ValueError('Missing required or incomplete Go test outcomes') + return len(passed) + + +def validate_exunit_events(events, required): + if (not events or events[0] != {'event': 'suite_started'} + or events[-1] != {'event': 'suite_finished'}): + raise ValueError('Incomplete ExUnit suite events') + passed = set() + for event in events[1:-1]: + if (set(event) != {'event', 'id', 'status'} or event['event'] != 'test_finished' + or not isinstance(event['id'], str) or not event['id'] or event['status'] != 'passed' + or event['id'] in passed): + raise ValueError('Failed, skipped, excluded, duplicate or malformed ExUnit outcome') + passed.add(event['id']) + if not passed or not set(required) <= passed: + raise ValueError('Missing required ExUnit test outcomes') + return len(passed) + + +def tool_pins(root): + try: + rows = [line.split() for line in (root/'.tool-versions').read_text().splitlines() if line.strip()] + pins = dict(rows) + except (OSError, ValueError) as error: + raise ValueError('Invalid .tool-versions') from error + if (len(rows) != len(pins) or set(pins) != {'erlang', 'elixir', 'golang', 'nodejs', 'python'} + or any(not re.fullmatch(r'\d+\.\d+\.\d+(?:-otp-\d+)?', value) for value in pins.values())): + raise ValueError('Incomplete or invalid pinned tool versions') + return pins + + +def check_applications(root, inventory, run): + """Execute fixed gates with process ownership supplied by check_ci.run.""" + root = Path(root).resolve() + validate_inventory(inventory) + pins = tool_pins(root) + if '.'.join(map(str, sys.version_info[:3])) != pins['python']: + raise ValueError('Python does not match .tool-versions') + tools = {} + for name in ('go', 'gofmt', 'mix', 'elixir', 'erl', 'node'): + tools[name] = shutil.which(name) + if not tools[name]: + raise ValueError('Missing required tool: ' + name + '; install .tool-versions before checking') + with tempfile.TemporaryDirectory(prefix='workharbor-app-checks-') as directory: + cache = Path(directory) + # No inherited GOFLAGS, Mix/Hex mirrors, BEAM flags, server activation or + # developer credential/config paths. Build intermediates/deps remain in + # the disposable source tree for Phoenix's generated asset-relative paths. + env = {key: os.environ[key] for key in ('PATH', 'LANG', 'LC_ALL', 'TMPDIR') if key in os.environ} + env.update(HOME=str(cache/'home'), MIX_HOME=str(cache/'mix'), HEX_HOME=str(cache/'hex'), + MIX_ARCHIVES=str(cache/'mix/archives'), GOCACHE=str(cache/'go/build'), + GOMODCACHE=str(cache/'go/mod'), GOENV='off', GOTOOLCHAIN='local', + GOWORK=str(root/'go.work'), MIX_ENV='test', + GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull, + GIT_TERMINAL_PROMPT='0', GIT_CONFIG_COUNT='1', GIT_CONFIG_KEY_0='core.hooksPath', + GIT_CONFIG_VALUE_0=os.devnull) + for name in ('HOME', 'MIX_HOME', 'HEX_HOME', 'MIX_ARCHIVES', 'GOCACHE', 'GOMODCACHE'): + Path(env[name]).mkdir(parents=True, exist_ok=True) + serial = 0 + def execute(argv, cwd=root): + nonlocal serial + serial += 1 + output = cache/('output-' + str(serial)) + if run(argv, cwd, env=env, output_path=output) != 0: + raise ValueError('Application command failed: ' + ' '.join(argv)) + return output + versions = [ + ([tools['go'], 'version'], lambda value: value.startswith('go version go' + pins['golang'] + ' ')), + ([tools['elixir'], '--version'], lambda value: bool(re.search( + r'^Elixir ' + re.escape(pins['elixir'].split('-otp-')[0]) + r' \(compiled with Erlang/OTP ' + + re.escape(pins['erlang'].split('.')[0]) + r'\)$', value, re.M))), + ([tools['erl'], '-noshell', '-eval', + '{ok,V}=file:read_file(filename:join([code:root_dir(),"releases",erlang:system_info(otp_release),"OTP_VERSION"])),io:put_chars(V),halt().'], + lambda value: value.strip() == pins['erlang']), + ([tools['node'], '--version'], lambda value: value.strip() == 'v' + pins['nodejs']), + ] + for argv, matches in versions: + if not matches(execute(argv).read_text()): + raise ValueError('Tool version does not match .tool-versions: ' + argv[0]) + for module in GO_MODULES: + cwd = root/'apps'/module + if execute([tools['gofmt'], '-l', '.'], cwd).read_bytes(): + raise ValueError('Unformatted Go source: ' + module) + execute([tools['go'], 'mod', 'download'], cwd) + execute([tools['go'], 'vet', './...'], cwd) + output = cache/('build-' + module) + output.mkdir() + argv = ([tools['go'], 'build', './...'] if module == 'api_client' else + [tools['go'], 'build', '-o', str(output) + '/', './...']) + execute(argv, cwd) + events = execute([tools['go'], 'test', '-json', '-count=1', './...'], cwd) + count = validate_go_events(read_events(events), inventory[module]) + print(f'Application {module}: {count} Go tests passed; all required IDs present.', flush=True) + cwd = root/'apps/control_plane' + execute([tools['mix'], 'local.hex', HEX_VERSION, '--force'], cwd) + execute([tools['mix'], 'local.rebar', 'rebar3', REBAR_URL, '--sha512', REBAR_SHA512, '--force'], cwd) + execute([tools['mix'], 'deps.get', '--check-locked'], cwd) + execute([tools['mix'], 'format', '--check-formatted'], cwd) + execute([tools['mix'], 'compile', '--warnings-as-errors'], cwd) + execute([tools['mix'], 'assets.setup'], cwd) + execute([tools['mix'], 'assets.build'], cwd) + events = cache/'exunit.jsonl' + env['WORKHARBOR_EXUNIT_EVENTS'] = str(events) + execute([tools['mix'], 'test', '--warnings-as-errors', '--formatter', 'ExUnit.CLIFormatter', + '--formatter', 'WorkHarbor.AcceptanceFormatter'], cwd) + count = validate_exunit_events(read_events(events), inventory['control_plane']) + print(f'Application control_plane: {count} ExUnit tests passed; all required IDs present.', flush=True) + return True diff --git a/scripts/check_ci.py b/scripts/check_ci.py index 9e34a898..d8408bf8 100644 --- a/scripts/check_ci.py +++ b/scripts/check_ci.py @@ -123,9 +123,15 @@ def isolated_python_cache(): with tempfile.TemporaryDirectory(prefix='workharbor-ci-cache-') as cache: # Runner/check interpreters use -I. Normal CLI subprocesses under test # still need Python's script-directory imports for adjacent modules. + # Legacy tests invoke python3 by name. Bind that spelling to the selected + # interpreter without adding isolated flags to ordinary script children. + aliases = Path(cache)/'bin' + aliases.mkdir() + (aliases/'python3').symlink_to(sys.executable) names = {'PYTHONPYCACHEPREFIX': cache, 'PYTHONDONTWRITEBYTECODE': '1', - 'PYTHONNOUSERSITE': '1', 'GIT_NO_REPLACE_OBJECTS': '1'} - previous = {name: value for name, value in os.environ.items() if name.startswith('PYTHON') or name == 'GIT_NO_REPLACE_OBJECTS'} + 'PYTHONNOUSERSITE': '1', 'GIT_NO_REPLACE_OBJECTS': '1', + 'PATH': str(aliases) + os.pathsep + os.environ.get('PATH', os.defpath)} + previous = {name: value for name, value in os.environ.items() if name.startswith('PYTHON') or name in ('PATH', 'GIT_NO_REPLACE_OBJECTS')} prefix, no_write = sys.pycache_prefix, sys.dont_write_bytecode for name in previous: os.environ.pop(name) @@ -136,7 +142,7 @@ def isolated_python_cache(): finally: sys.pycache_prefix, sys.dont_write_bytecode = prefix, no_write for name in tuple(os.environ): - if name.startswith('PYTHON') or name == 'GIT_NO_REPLACE_OBJECTS': + if name.startswith('PYTHON') or name in ('PATH', 'GIT_NO_REPLACE_OBJECTS'): os.environ.pop(name) os.environ.update(previous) @@ -206,6 +212,10 @@ def python_argv(*args): return [sys.executable, '-I', '-B', *prefix, *args] +_apps = runpy.run_path(str(Path(__file__).with_name('application_checks.py'))) +check_applications = _apps['check_applications'] + + def load_manifest(root): def unique_object(pairs): result = {} @@ -216,8 +226,8 @@ def unique_object(pairs): return result value = json.loads((root/MANIFEST).read_text(), object_pairs_hook=unique_object) if (not isinstance(value, dict) - or set(value) != {'schema_version', 'required_test_ids', 'retained_assertion_failures'} - or type(value['schema_version']) is not int or value['schema_version'] != 1): + or set(value) != {'schema_version', 'required_test_ids', 'retained_assertion_failures', 'application_tests'} + or type(value['schema_version']) is not int or value['schema_version'] != 2): raise ValueError('Unsupported acceptance manifest') required = value['required_test_ids'] if (not isinstance(required, list) or not required or @@ -235,6 +245,7 @@ def unique_object(pairs): ids.append(case['id']) if len(ids) != len(set(ids)): raise ValueError('Duplicate retained failure ID') + _apps['validate_inventory'](value['application_tests']) return value @@ -320,7 +331,7 @@ def acceptance_suite(root): def source_identity(root, source): if not re.fullmatch('[0-9a-f]{40}', source) or git(root, 'cat-file', '-t', source) != b'commit\n': raise ValueError('Source must identify an exact available commit') - for name in ('check_ci.py', 'task_outcomes.py', 'task_package.py', 'single_task_state.py'): + for name in ('check_ci.py', 'task_outcomes.py', 'task_package.py', 'single_task_state.py', 'application_checks.py'): checker = Path(__file__).with_name(name) relative = 'scripts/' + name entry = git(root, 'ls-tree', source, '--', relative).split() @@ -371,17 +382,25 @@ def drain_group(process): raise ProcessCleanupError('Unable to establish check process group cleanup') from error -def run(argv, root, *, timeout=300): +def run(argv, root, *, timeout=300, env=None, output_path=None): print('+ ' + shlex.join(argv), flush=True) # Make and test runners spawn children. Own their ordinary process group so # no check can keep using the disposable checkout after this call finishes. - process = subprocess.Popen(argv, cwd=root, start_new_session=True, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + capture = output_path.open('xb') if output_path is not None else None + try: + process = subprocess.Popen(argv, cwd=root, start_new_session=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) + except BaseException: + if capture is not None: + capture.close() + raise output_errors = [] def forward_output(): try: for line in process.stdout: + if capture is not None: + capture.write(line) sys.stdout.buffer.write(line) sys.stdout.buffer.flush() except OSError as error: @@ -401,6 +420,8 @@ def forward_output(): if reader.is_alive(): raise ProcessCleanupError('Check process output did not close after group termination') process.stdout.close() + if capture is not None: + capture.close() if output_errors: raise output_errors[0] @@ -436,6 +457,7 @@ def check(root, context=None, source=None): # This does not change any approved package per-check deadline. valid = run(python_argv('-c', code, str(execution/'scripts/check_ci.py'), str(execution)), execution, timeout=QUALIFIED_AGGREGATE_TIMEOUT_SECONDS) == 0 and valid + valid = check_applications(execution, manifest['application_tests'], run) and valid clean_checkout(execution, context.checkout, allow_generated=True) if source_context(root, *context) != context: raise ValueError('Source changed during checks') @@ -450,6 +472,7 @@ def main(argv=None): parser.add_argument('--root', type=Path, default=ROOT) for name in ('event', 'base', 'head', 'checkout'): parser.add_argument('--' + name) + parser.add_argument('--applications-only', action='store_true', help='Focused application checks; full make check still required') parser.add_argument('--source', help='Exact checker code pin; does not establish owner review') parser.add_argument('--policy-source', help='Deprecated alias for --source (no adoption semantics)') args = parser.parse_args(argv) @@ -460,6 +483,13 @@ def main(argv=None): pins = (args.event, args.base, args.head, args.checkout) if any(pins) and not all(pins): parser.error('Source context requires all four pins') + if args.applications_only: + try: + check_applications(args.root, load_manifest(args.root)['application_tests'], run) + return 0 + except (OSError, ValueError, subprocess.SubprocessError) as error: + print('Application checks blocked: ' + str(error), file=sys.stderr) + return 1 return check(args.root, SourceContext(*pins) if all(pins) else None, args.source or args.policy_source) diff --git a/scripts/test_application_checks.py b/scripts/test_application_checks.py new file mode 100644 index 00000000..a0932a24 --- /dev/null +++ b/scripts/test_application_checks.py @@ -0,0 +1,163 @@ +"""Application acceptance negatives use recorded events, never installed host tools.""" +import copy +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +class ApplicationChecksTests(unittest.TestCase): + def setUp(self): + spec = importlib.util.spec_from_file_location('application_checks', Path(__file__).with_name('application_checks.py')) + self.apps = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.apps) + self.inventory = { + 'whr': ['github.com/wstein/workharbor-nxt/apps/whr/cmd/whr/TestScaffoldRefusesProductCommands'], + 'host_agent': ['github.com/wstein/workharbor-nxt/apps/host_agent/cmd/workharbor-agent/TestScaffoldRefusesProductCommands'], + 'api_client': [], + 'control_plane': ['WorkHarborWeb.PageControllerTest/test GET /'], + } + + def go_events(self, module='whr'): + identity = self.inventory[module][0] + package, name = identity.rsplit('/', 1) + return [dict(Action='start', Package=package), dict(Action='run', Package=package, Test=name), + dict(Action='pass', Package=package, Test=name), dict(Action='pass', Package=package)] + + def exunit_events(self): + return [dict(event='suite_started'), dict(event='test_finished', id=self.inventory['control_plane'][0], status='passed'), + dict(event='suite_finished')] + + def test_inventory_rejects_missing_duplicate_empty_critical_and_command_fields(self): + self.apps.validate_inventory(self.inventory) + variants = [dict(self.inventory, shell=['touch marker']), dict(self.inventory, whr=[]), + dict(self.inventory, control_plane=[]), dict(self.inventory, api_client=['wrong/TestX'])] + missing = dict(self.inventory); del missing['host_agent']; variants.append(missing) + duplicate = copy.deepcopy(self.inventory); duplicate['whr'] *= 2; variants.append(duplicate) + for value in variants: + with self.subTest(value=value), self.assertRaises(ValueError): self.apps.validate_inventory(value) + + def test_go_missing_skip_fail_duplicate_and_incomplete_events_block(self): + good = self.go_events() + self.apps.validate_go_events(good, self.inventory['whr']) + variants = [[], good[:1] + good[3:], good[:-1], good + [good[2]], + good + [dict(Action='fail', Package='additional/package', Test='TestRegression')], + good + [dict(Action='skip', Package='additional/package', Test='TestOptional')]] + for value in variants: + with self.subTest(value=value), self.assertRaises(ValueError): + self.apps.validate_go_events(value, self.inventory['whr']) + + def test_concurrent_nested_packages_keep_exact_test_ownership(self): + parent, child = 'module/pkg', 'module/pkg/child' + prefix = [dict(Action='start', Package=parent), dict(Action='start', Package=child), + dict(Action='run', Package=child, Test='TestChild')] + parent_end = [dict(Action='output', Package=parent, Output='[no test files]'), dict(Action='skip', Package=parent)] + child_end = [dict(Action='pass', Package=child, Test='TestChild'), dict(Action='pass', Package=child)] + for ending in (parent_end + child_end, child_end + parent_end): + with self.subTest(ending=ending): + self.assertEqual(1, self.apps.validate_go_events(prefix + ending, [child + '/TestChild'])) + + def test_go_doc_only_library_still_rejects_unexpected_bad_tests(self): + package = 'github.com/wstein/workharbor-nxt/apps/api_client' + self.apps.validate_go_events([dict(Action='start', Package=package), dict(Action='output', Package=package, Output='[no test files]'), dict(Action='skip', Package=package)], []) + for action in ('skip', 'fail'): + events = [dict(Action='start', Package=package), dict(Action=action, Package=package, Test='TestNew'), + dict(Action='pass', Package=package)] + with self.subTest(action=action), self.assertRaises(ValueError): self.apps.validate_go_events(events, []) + + def test_exunit_missing_skipped_excluded_failed_duplicate_and_incomplete_block(self): + good = self.exunit_events() + self.apps.validate_exunit_events(good, self.inventory['control_plane']) + variants = [[], [good[0], good[2]], good[:-1], good + [good[1]]] + for status in ('skipped', 'excluded', 'failed', 'invalid'): + variants.append([good[0], dict(good[1], status=status), good[2]]) + variants.append([good[0], good[1], dict(event='test_finished', id='Extra/test regression', status=status), good[2]]) + for value in variants: + with self.subTest(value=value), self.assertRaises(ValueError): + self.apps.validate_exunit_events(value, self.inventory['control_plane']) + + def test_malformed_event_bytes_never_qualify(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/'events' + for content in (b'', b'not JSON\n', b'{"Action":"pass","Action":"skip"}\n', b'[]\n', b'\xff\n'): + path.write_bytes(content) + with self.subTest(content=content), self.assertRaises(ValueError): self.apps.read_events(path) + + def exercise(self, *, missing=None, fail=None, formatted=False, wrong_version=False): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory)/'source'; root.mkdir() + (root/'.tool-versions').write_text('erlang 29.1.1\nelixir 1.20.4-otp-29\ngolang 1.27.2\nnodejs 26.11.1\npython 3.14.8\n') + observed = [] + def execute(argv, cwd, *, env, output_path): + observed.append((argv, cwd, env)) + command = tuple(argv) + if fail and fail(command): return 1 + name = Path(argv[0]).name + data = b'' + if command[1:] == ('version',): data = b'go version go1.27.2 fixture/fixture\n' + elif command[1:] == ('--version',): + data = (b'Elixir 1.20.4 (compiled with Erlang/OTP 29)\n' if name == 'elixir' else b'v26.11.1\n') + if wrong_version: data = b'wrong version\n' + elif name == 'erl': data = b'29.1.1\n' + elif command[1:] == ('-l', '.') and formatted: data = b'cmd/main.go\n' + elif command[1:3] == ('test', '-json'): + module = Path(cwd).name + events = self.go_events(module) if module != 'api_client' else [ + dict(Action='start', Package='library'), dict(Action='output', Package='library', Output='[no test files]'), dict(Action='skip', Package='library')] + data = ''.join(json.dumps(event) + '\n' for event in events).encode() + elif command[1:2] == ('test',): + Path(env['WORKHARBOR_EXUNIT_EVENTS']).write_text(''.join(json.dumps(e) + '\n' for e in self.exunit_events())) + Path(output_path).write_bytes(data) + return 0 + def which(name): return None if name == missing else '/fixture/bin/' + name + with mock.patch.object(self.apps.shutil, 'which', side_effect=which), \ + mock.patch.object(self.apps.sys, 'version_info', (3, 14, 8)): + self.apps.check_applications(root, self.inventory, execute) + return observed, root + + def test_missing_tool_blocks_before_any_application_command(self): + for name in ('go', 'gofmt', 'mix', 'elixir', 'erl', 'node'): + with self.subTest(name=name), self.assertRaisesRegex(ValueError, 'Missing required tool'): self.exercise(missing=name) + + def test_wrong_tool_version_unformatted_source_and_failed_gates_block(self): + for options in (dict(wrong_version=True), dict(formatted=True), + dict(fail=lambda argv: 'vet' in argv), dict(fail=lambda argv: 'build' in argv), + dict(fail=lambda argv: 'assets.build' in argv), dict(fail=lambda argv: 'deps.get' in argv), + dict(fail=lambda argv: 'test' in argv), dict(fail=lambda argv: 'format' in argv)): + with self.subTest(options=options), self.assertRaises(ValueError): self.exercise(**options) + + def test_fixed_gates_locked_setup_external_caches_and_all_modules(self): + observed, root = self.exercise() + commands = [argv[1:] for argv, _, _ in observed] + self.assertIn(['local.hex', '2.5.2', '--force'], commands) + self.assertIn(['local.rebar', 'rebar3', self.apps.REBAR_URL, '--sha512', self.apps.REBAR_SHA512, '--force'], commands) + self.assertIn(['deps.get', '--check-locked'], commands) + self.assertIn(['format', '--check-formatted'], commands) + self.assertIn(['compile', '--warnings-as-errors'], commands) + self.assertIn(['assets.setup'], commands) + self.assertIn(['assets.build'], commands) + self.assertEqual(3, commands.count(['test', '-json', '-count=1', './...'])) + self.assertEqual(3, commands.count(['vet', './...'])) + self.assertIn(['build', './...'], commands) + self.assertEqual(2, sum(argv[:2] == ['build', '-o'] for argv in commands)) + for _, _, env in observed: + for name in ('GOCACHE', 'GOMODCACHE', 'MIX_HOME', 'HEX_HOME', 'MIX_ARCHIVES', 'HOME'): + self.assertFalse(Path(env[name]).is_relative_to(root)) + self.assertEqual('off', env['GOENV']) + self.assertEqual('local', env['GOTOOLCHAIN']) + self.assertNotIn('ERL_AFLAGS', env) + self.assertNotIn('PHX_SERVER', env) + + def test_ci_and_local_share_the_application_gate(self): + root = Path(__file__).resolve().parents[1] + make = (root/'Makefile').read_text() + workflow = (root/'.github/workflows/check.yml').read_text() + self.assertIn('--applications-only', make) + self.assertIn('python3 -I -B scripts/check_ci.py', workflow) + self.assertIn(' bootstrap:', workflow) # Preserve the inherited required-check identity. + for version in ('29.1.1', '1.20.4', '1.27.2', '26.11.1', '3.14.8'): + self.assertIn(version, workflow) + self.assertNotIn('continue-on-error', workflow) diff --git a/scripts/test_check_ci.py b/scripts/test_check_ci.py index 26d7e7fa..d834d2a9 100644 --- a/scripts/test_check_ci.py +++ b/scripts/test_check_ci.py @@ -224,7 +224,7 @@ def test_setup_alias_and_delegation_cannot_impersonate_test_dispatch(self): self.assertFalse(ci.evaluate(ids, manifest, result)) def test_manifest_rejects_duplicates_missing_rationale_and_unknown_fields(self): - good = {'schema_version': 1, 'required_test_ids': ['probe.test'], 'retained_assertion_failures': [ + good = {'schema_version': 2, 'application_tests': ci.load_manifest(ci.ROOT)['application_tests'], 'required_test_ids': ['probe.test'], 'retained_assertion_failures': [ {'id': 'probe.test', 'message': 'known', 'reason': 'pending behavior', 'removal_condition': 'reviewed implementation'}]} with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -234,7 +234,7 @@ def test_manifest_rejects_duplicates_missing_rationale_and_unknown_fields(self): self.assertEqual(good, ci.load_manifest(root)) variants = [] for field, value in (('required_test_ids', []), ('required_test_ids', ['probe.test'] * 2), - ('schema_version', 2), ('schema_version', True), ('schema_version', 1.0), + ('schema_version', 1), ('schema_version', True), ('schema_version', 2.0), ('retained_assertion_failures', good['retained_assertion_failures'] * 2)): variants.append(dict(good, **{field: value})) for field in ('id', 'message', 'reason', 'removal_condition'): @@ -477,7 +477,7 @@ def setUp(self): (self.root/'scripts/check_ci.py').write_bytes(Path(ci.__file__).read_bytes()) (self.root/'scripts/check_ci.py').chmod(Path(ci.__file__).stat().st_mode & 0o777) (self.root/'scripts/task_outcomes.py').write_bytes(Path(ci.__file__).with_name('task_outcomes.py').read_bytes()) - for name in ('task_package.py', 'single_task_state.py'): + for name in ('task_package.py', 'single_task_state.py', 'application_checks.py'): (self.root/'scripts'/name).write_bytes(Path(ci.__file__).with_name(name).read_bytes()) self.git('add', '.') self.git('commit', '--quiet', '-m', 'test: baseline') @@ -641,7 +641,7 @@ def test_source_pin_binds_extracted_classifier_dependency(self): with self.assertRaises(ValueError): ci.source_identity(self.root, self.base) def test_source_pin_binds_package_and_traversal_dependencies(self): - for name in ('task_package.py', 'single_task_state.py'): + for name in ('task_package.py', 'single_task_state.py', 'application_checks.py'): with self.subTest(name=name): ci.source_identity(self.root, self.base) self.commit('scripts/' + name, 'pass\n') @@ -657,6 +657,33 @@ def test_python_cache_is_external_for_current_and_child_imports_and_restored(sel self.assertEqual([1, str(prefix), True], json.loads(result.stdout)) self.assertFalse(prefix.exists()); self.assertEqual(before, (sys.pycache_prefix, sys.dont_write_bytecode)) + def test_python3_children_use_selected_interpreter_and_path_restores_on_exception(self): + fake_bin = Path(self.temporary.name)/'incompatible-bin' + fake_bin.mkdir() + sentinel = fake_bin/'called' + incompatible = fake_bin/'python3' + incompatible.write_text('#!/bin/sh\nprintf incompatible-python >&2\nprintf called > "' + str(sentinel) + '"\nexit 97\n') + incompatible.chmod(0o755) + inherited_path = str(fake_bin) + os.pathsep + os.environ['PATH'] + for raises in (False, True): + with self.subTest(raises=raises), mock.patch.dict(os.environ, PATH=inherited_path): + alias = None + try: + with ci.isolated_python_cache(): + result = subprocess.run(['python3', '-c', 'import sys;print(sys.version_info[:3])'], + capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(str(sys.version_info[:3]), result.stdout.strip()) + alias = Path(shutil.which('python3')) + self.assertEqual(Path(sys.executable).resolve(), alias.resolve()) + if raises: raise RuntimeError('fixture exception') + except RuntimeError: + self.assertTrue(raises) + self.assertEqual(inherited_path, os.environ['PATH']) + self.assertFalse(sentinel.exists()) + self.assertIsNotNone(alias) + self.assertFalse(alias.exists()) + def test_normal_cli_subprocess_retains_adjacent_module_imports(self): (self.root/'scripts/adjacent_module.py').write_text('VALUE = 7\n') (self.root/'scripts/probe.py').write_text('import adjacent_module\nprint(adjacent_module.VALUE)\n') @@ -699,6 +726,18 @@ def test_command_timeouts_remain_finite_and_explicit(self): self.assertEqual(0, ci.run(['suite'], self.root, timeout=600)) self.assertEqual(600, process.wait.call_args.kwargs['timeout']) + def test_captured_output_keeps_raw_bytes_and_refuses_existing_file_before_spawn(self): + capture = Path(self.temporary.name)/'captured-output' + env = {'PATH': os.environ['PATH'], 'APPLICATION_PROBE': 'explicit fixture'} + argv = [sys.executable, '-I', '-B', '-c', + 'import os,sys;sys.stdout.buffer.write(os.environ["APPLICATION_PROBE"].encode()+b"\\n");' + 'sys.stderr.buffer.write(b"stderr fixture\\n")'] + self.assertEqual(0, ci.run(argv, self.root, env=env, output_path=capture)) + self.assertEqual([b'explicit fixture', b'stderr fixture'], sorted(capture.read_bytes().splitlines())) + with mock.patch.object(ci.subprocess, 'Popen') as spawn: + with self.assertRaises(FileExistsError): ci.run(argv, self.root, env=env, output_path=capture) + spawn.assert_not_called() + def test_aggregate_suite_timeout_is_bounded_and_fails_closed(self): original_run = ci.subprocess.run observed = [] @@ -711,7 +750,8 @@ def checks(argv, root, **kwargs): if kwargs.get('timeout') == ci.QUALIFIED_AGGREGATE_TIMEOUT_SECONDS: raise subprocess.TimeoutExpired(argv, ci.QUALIFIED_AGGREGATE_TIMEOUT_SECONDS) return 0 - with mock.patch.object(ci, 'load_manifest', return_value={'retained_assertion_failures': []}), \ + with mock.patch.object(ci, 'load_manifest', return_value={'retained_assertion_failures': [], 'application_tests': {}}), \ + mock.patch.object(ci, 'check_applications', return_value=True), \ mock.patch.object(ci, 'run', side_effect=checks), \ mock.patch.object(ci.subprocess, 'run', side_effect=commands): self.assertEqual(1, ci.check(self.root)) @@ -720,7 +760,7 @@ def checks(argv, root, **kwargs): self.assertTrue(all(not arguments for arguments in observed[1:-1])) def test_post_check_source_drift_blocks(self): - manifest = {'retained_assertion_failures': []} + manifest = {'retained_assertion_failures': [], 'application_tests': {}} original_run = ci.subprocess.run def commands(argv, *args, **kwargs): if argv[:2] == ['bash', '.specify/scripts/bash/check-prerequisites.sh']: @@ -732,10 +772,27 @@ def successful_checks(argv, root, **kwargs): (self.root/'guide.md').write_text('changed\n') return 0 with self.subTest(drift=drift), mock.patch.object(ci, 'load_manifest', return_value=manifest), \ + mock.patch.object(ci, 'check_applications', return_value=True), \ mock.patch.object(ci, 'run', side_effect=successful_checks), \ mock.patch.object(ci.subprocess, 'run', side_effect=commands): self.assertEqual(1 if drift else 0, ci.check(self.root)) + def test_application_failure_blocks_even_when_all_python_checks_pass(self): + original_run = ci.subprocess.run + def commands(argv, *args, **kwargs): + if argv[:2] == ['bash', '.specify/scripts/bash/check-prerequisites.sh']: + return subprocess.CompletedProcess(argv, 0) + return original_run(argv, *args, **kwargs) + inventory = {'fixture': 'not shell argv'} + with mock.patch.object(ci, 'load_manifest', return_value={ + 'retained_assertion_failures': [], 'application_tests': inventory}), \ + mock.patch.object(ci, 'run', return_value=0), \ + mock.patch.object(ci.subprocess, 'run', side_effect=commands), \ + mock.patch.object(ci, 'check_applications', side_effect=ValueError('missing required Go test')) as apps: + self.assertEqual(1, ci.check(self.root)) + self.assertEqual(inventory, apps.call_args.args[1]) + self.assertIs(ci.run, apps.call_args.args[2]) + def test_workflow_checks_ordinary_merge_without_external_policy_source(self): workflow = (ci.ROOT/'.github/workflows/check.yml').read_text() self.assertIn('contents: read', workflow) diff --git a/scripts/test_task_readiness.py b/scripts/test_task_readiness.py index 389b2530..992eacbc 100644 --- a/scripts/test_task_readiness.py +++ b/scripts/test_task_readiness.py @@ -316,7 +316,7 @@ def setUp(self): # Exercise the real source helper with its exact checker pin, rather # than replacing source/history validation with a permissive fake. (self.root / 'scripts').mkdir() - for name in ('check_ci.py', 'task_outcomes.py', 'task_package.py', 'single_task_state.py'): + for name in ('check_ci.py', 'task_outcomes.py', 'task_package.py', 'single_task_state.py', 'application_checks.py'): shutil.copy2(Path(package.__file__).with_name(name), self.root / 'scripts' / name) self.git('add', '.') self.git('commit', '-qm', 'fixture checker') diff --git a/scripts/test_task_source.py b/scripts/test_task_source.py index 2b3e6808..4fc47b5c 100644 --- a/scripts/test_task_source.py +++ b/scripts/test_task_source.py @@ -24,7 +24,7 @@ def setUp(self): self.git('config', 'user.name', 'Fixture') self.git('config', 'user.email', 'fixture@invalid') (self.root/'scripts').mkdir() - for name in ('check_ci.py', 'task_outcomes.py', 'task_package.py', 'single_task_state.py'): + for name in ('check_ci.py', 'task_outcomes.py', 'task_package.py', 'single_task_state.py', 'application_checks.py'): origin = Path(__file__).with_name(name) target = self.root/'scripts'/name target.write_bytes(origin.read_bytes()) diff --git a/specs/006-whr-cli/plan.md b/specs/006-whr-cli/plan.md index ab135c83..6bf1e226 100644 --- a/specs/006-whr-cli/plan.md +++ b/specs/006-whr-cli/plan.md @@ -3,8 +3,9 @@ Branch: `feat/installable-pilot` | Date: 2026-10-14 Spec: [spec.md](spec.md) Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` -Status: proposed intermediate execution baseline; separate independent Sol -re-review is pending. No implementation or runtime verification is claimed. +Status: execution in progress. T001 scaffold independently Sol CLEAR at +`d3d84c8361a1389292fad96d02a828ac9fabc2e3`; later work items remain open. +Scaffold checks do not establish product runtime verification. ## Authorized objective diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index ae9dd1e1..a85d912a 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -1,7 +1,8 @@ # Feature 006 pilot work items -Status: proposed intermediate execution baseline; independent Sol re-review of -the terminal-safety ordering correction is pending. No product task is complete. +Status: execution in progress. T001 scaffold independently Sol CLEAR at +`d3d84c8361a1389292fad96d02a828ac9fabc2e3`; T002 and later work items remain open. +No product runtime acceptance is claimed. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), [data model](data-model.md), [migration](migration.md), [owner API](contracts/owner-api.md). @@ -18,8 +19,8 @@ silently weaken Feature 001 or mark its tasks complete. Goal: locked, reproducible buildable applications, not a usable product. Independent check: clean-checkout format/build/tests with missing-tool failures. -- [ ] T001 Scaffold and pin Phoenix/Ecto/SQLite in `apps/control_plane/`, Go modules `apps/host_agent/go.mod`, `apps/whr/go.mod`, `apps/api_client/go.mod`, root `go.work` and `.tool-versions`; lock dependencies and pass each application format/build/test gate (Feature 001 T001/T011). -- [ ] T002 Add explicit application checks and meaningful required test inventory to `scripts/check_ci.py`, `docs/development/ci-acceptance.json`, `Makefile` and `.github/workflows/check.yml`; prove local/CI equivalence, fail on missing tools and preserve all inherited tests (001 T012; ADR 0007/0010). +- [x] T001 Scaffold and pin Phoenix/Ecto/SQLite in `apps/control_plane/`, Go modules `apps/host_agent/go.mod`, `apps/whr/go.mod`, `apps/api_client/go.mod`, root `go.work` and `.tool-versions`; lock dependencies and pass each application format/build/test gate (Feature 001 T001/T011). +- [ ] T002 Add explicit application checks and meaningful required test inventory to `scripts/check_ci.py`, `docs/development/ci-acceptance.json`, `Makefile` and `.github/workflows/check.yml`, with fixed gate/event-validation helper `scripts/application_checks.py`, negative fixtures `scripts/test_application_checks.py`, complete real-checker fixture copy lists in `scripts/test_task_readiness.py` and `scripts/test_task_source.py`, and test-only ExUnit reporter `apps/control_plane/test/support/acceptance_formatter.ex`; the helpers keep application argv out of manifest data and report actual ExUnit outcomes without adding product behavior. Prove local/CI equivalence, fail on missing tools and preserve all inherited tests (001 T012; ADR 0007/0010). ## Phase 2: Foundations and migration From d366a2351ccb74ab051da34f804e9c7c0fa451bb Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 16:28:40 +0200 Subject: [PATCH 08/51] test(migration): freeze selected Python wire and path contracts Pin independent source behavior before the Go port so malformed inputs, canonical bytes and deliberately restricted product domains cannot drift silently. Keep the offline oracle and its negative controls in development tooling, with portable runtime provenance and explicit candidate review. Refs: #72 Co-authored-by: Codex --- contracts/README.md | 6 + contracts/migration/README.md | 138 + contracts/migration/manifest.json | 58 + .../migration/oracle/provider_profiles.py | 336 ++ .../migration/oracle/single_task_context.py | 542 ++ contracts/migration/vectors.json | 4491 +++++++++++++++++ contracts/migration/vectors.schema.json | 507 ++ docs/development/ci-acceptance.json | 17 +- docs/development/harness.md | 11 + docs/exec-plans/active/installable-pilot.md | 93 +- scripts/generate_go_vectors.py | 438 ++ specs/006-whr-cli/migration.md | 53 + specs/006-whr-cli/tasks.md | 13 +- tests/test_go_vectors.py | 216 + 14 files changed, 6913 insertions(+), 6 deletions(-) create mode 100644 contracts/migration/README.md create mode 100644 contracts/migration/manifest.json create mode 100644 contracts/migration/oracle/provider_profiles.py create mode 100644 contracts/migration/oracle/single_task_context.py create mode 100644 contracts/migration/vectors.json create mode 100644 contracts/migration/vectors.schema.json create mode 100644 scripts/generate_go_vectors.py create mode 100644 tests/test_go_vectors.py diff --git a/contracts/README.md b/contracts/README.md index 78887f4c..2616f30a 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -51,3 +51,9 @@ these accepted ports or approve runtime integration. [schema](agent-session-v1.schema.json): the versioned in-container session interface (list, attach, status, send input, stop) of owner decision H16:b, wstein/workharbor-nxt#71. Design baseline only; not implemented or verified. + +## Selective Python migration + +[migration/README.md](migration/README.md) records the pinned pure JSON/path +profiles, deliberate product restrictions and offline source/product vectors. +T003 implementation review is pending; no Go port or caller switch is claimed. diff --git a/contracts/migration/README.md b/contracts/migration/README.md new file mode 100644 index 00000000..a0b62148 --- /dev/null +++ b/contracts/migration/README.md @@ -0,0 +1,138 @@ +# Selected Python migration contracts + +T003 candidate; independent implementation review is pending. T004 Go ports and +caller changes are absent. Authority: [ADR 0010](../../docs/adr/0010-go-whr-cli-layout.md) +and the [migration inventory](../../specs/006-whr-cli/migration.md). + +The selected JSON profile can identify normalized integer-only lifecycle request +bodies for stable idempotency hashes. T005 must define the actual body schema and +excluded fields; arbitrary incoming JSON is not automatically a request identity. +The lexical path profile can validate spelling of repository-relative config +references. Neither primitive grants trust or authorization. These are not a +general Dev Container JSON parser or the RFC 8785/JCS canonicalization profile. + +## Profiles and deliberate restrictions + +JSON preserves the selected provider serializer's strict UTF-8, no BOM, decoded +unique keys at every object depth, integer-only numbers, exact boolean/integer +distinction, sorted Unicode scalar keys, compact UTF-8 bytes, no Unicode +normalization and no trailing LF. SHA-256 covers those exact canonical bytes. +HTML characters and U+2028/U+2029 are emitted literally. U+E000 sorts before +U+10000; UTF-16 key ordering would give a different result. + +- Parse input is at most 1,048,576 bytes. Larger bytes input is + `input-limit-exceeded` before UTF-8/syntax validation; non-bytes input is + `invalid-json` before size inspection. +- **4300 decimal digits** applies to parsed integers and direct + `canonical_bytes`/`canonical_digest` integers. The minus sign does not count; + zero has one digit and `-0` canonicalizes to `0`. No machine-width bound applies. + The runner fixes CPython's otherwise configurable integer-string limit to 4300. + Larger integers are `invalid-json`. +- **128 containers** is a deliberate product restriction for parsing, + canonicalization and digest. Scalars have depth zero; each object/array adds + one level, including the root. Keys and bracket/brace characters inside strings + add none. Greater depth is `invalid-json`. The source accepts our depth-129 + cases; its eventual recursion failure depends on interpreter/call-stack state. +- Canonicalization inherits no parse-input byte-size cap. The large native-string + vector proves this. Tooling expansion limits below are fixture-processing bounds, + not additional product limits. + +Paths preserve the pinned lexical predicate: reject empty/nonstring, NUL, +leading slash, backslash, CR/LF/tab, `*?[]`, empty components and components equal +to `.`, `..` or `.git`. Accepted paths are returned unchanged. Whole-string `.` +is permitted only with `dot=true`, whose default is false. + +The product path domain deliberately admits only Unicode scalar strings (valid +UTF-8 in Go), excluding source-accepted lone surrogates. The `dot` option must +be an exact boolean; the source accepts truthy nonbooleans and also ignores their +type for ordinary paths. Both restrictions reject with `context_schema`, including +`dot=1` on `a`. Source successes are preserved beside product rejections. + +Spaces, colons/drive-shaped spellings, `.Git`, `~`, percent-encoded text and other +permitted control characters retain their classifications. There is no inherited +path-length bound. Do not normalize, expand, case-fold or percent-decode names. +This POSIX-shaped lexical contract proves no symlink safety, containment, Git-entry +type, provider capability or owner trust. Runtime validation remains separate. + +## Offline reproduction and review + +From the repository root with the pinned CPython 3.14.8: + +```sh +python3.14 -I -B scripts/generate_go_vectors.py +python3.14 -I -B -m unittest discover -s tests -p test_go_vectors.py -v +python3.14 -I -B scripts/generate_go_vectors.py --candidate /private/tmp/proposed-workharbor-vectors.json +``` + +Default verification reads only and compares the complete reproduced fixture file +byte-for-byte, including its frozen SHA-256. Candidate mode explicitly writes a +new external file, refuses every path within the accepted checkout (including +symlink aliases), and refuses an existing destination. It never updates accepted +fixtures, their manifest hashes or source snapshots. Candidate generation checks +source/tool/schema pins but intentionally does not require the current fixture +file hash: a reviewed proposal may change cases or expectations. Strict schema, +source/product distinction and oracle reproduction still apply. Adoption is a +separate reviewed edit, followed by new exact-revision verification. + +The generator neither fetches Git history nor accesses source-linked resources. +It checks complete original snapshot SHA-256 and Git blob identities, then compiles +only the allowlisted AST definitions/imports recorded in [manifest](manifest.json). +JSON's closure imports only `json` and `hashlib`; paths import no modules. +Everything else in the mirrored original sources is inert provenance and never +executed. Full snapshots support offline shallow-checkout reproduction. The +original WorkHarbor sources and snapshots retain [EUPL-1.2](../../LICENSE); +mirrors are byte-identical, so license/provenance is recorded here and in the +manifest rather than inserting new bytes into their headers. + +The manifest pins CPython release identity, oracle/tool/schema/vector digests and +settings. It records the observed exact `.tool-versions` release pin, not an +unverified upstream source-archive digest. Each CLI run emits its actual executable +SHA-256, build, platform and path as execution evidence. Those platform-specific +values never enter vectors or act as portable preconditions: macOS and Linux +executables may differ. Both use CPython 3.14.8, digit limit 4300 and recursion +limit 1000. Settings are explicitly established, verified and restored; no exact +source recursion-failure threshold is claimed portable. Cross-platform reproduction +is required evidence when exercised, not claimed from this macOS run. + +Fixture processing uses bounded regular-file reads (8 MiB documents, 512 KiB +source snapshots), at most 256 cases, schema depth 64, native constructor depth +32, a 2 MiB aggregate native-construction budget, expanded byte values at most +2 MiB, and a 30-second standalone command deadline. The schema permits compact +nested containers up to 256, decimal spellings up to 5001 characters and powers +up to exponent 5000. +These bounds permit the selected negative cases without arbitrary execution. + +## Vector format and independent anchors + +[vectors.json](vectors.json) validates against [vectors.schema.json](vectors.schema.json). +The stdlib verifier implements precisely the schema's documented keyword subset; +unknown schema keywords/references and extra fixture fields are rejected. +Each case records operation, tagged input, note/decision, `source_expected` and +`product_expected`. Parity means the two results are equal. Deliberate restrictions +record an actual source success and product rejection with their decision label. + +Raw bytes use lowercase hex or exact prefix/unit/repetition-count/suffix expansion; +both representations include expanded length and SHA-256, checked before use. +Strings/path results use integer code points, preserving surrogate observations +without introducing malformed fixture JSON. Native integers use decimal chunks or +bounded arithmetic powers, so a 4301-digit value reaches the selected canonical +operation rather than failing during fixture construction. Unsupported native +float, tuple, bytes and nonstring-key cases reach the source validator as such. + +An independent Sol reviewer checked these anchors before initial freezing; tests +hard-code them separately from generator results: + +| Input | Canonical hex | SHA-256 | +| --- | --- | --- | +| `-0` | `30` | `5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9` | +| U+E000=1, U+10000=2 object | `7b22ee8080223a312c22f0908080223a327d` | `8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8` | +| U+2028/U+2029 string | `22e280a8e280a922` | `e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c` | +| `<>&/` string | `223c3e262f22` | `396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492` | +| boolean/integer object | `7b2262223a747275652c2269223a317d` | `1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63` | + +4300 decimal ones produce 4300 canonical bytes and SHA-256 +`5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950`. +Other independently checked negatives include nested/escaped/supplementary duplicate +keys, malformed UTF-8/surrogates, float/nonfinite values and oversize-input precedence. +Fixtures are reviewed evidence, not a substitute for the later independent Go port +review. Go tests must read them directly without runtime Python. diff --git a/contracts/migration/manifest.json b/contracts/migration/manifest.json new file mode 100644 index 00000000..d394fa25 --- /dev/null +++ b/contracts/migration/manifest.json @@ -0,0 +1,58 @@ +{ + "files": { + "scripts/generate_go_vectors.py": "2fbcf8e7a244841431492784ec027acaed6e13930dd6392692f2073941d09693", + "vectors.json": "0070cf8057253dca19abe7eaaa55e9c9e17313ba53c6cd820014443e3708a04c", + "vectors.schema.json": "1ac759b5cbdff1ae239632c33379c7879156e72d2ad6dc3f543479226d639f20" + }, + "runtime": { + "implementation": "cpython", + "int_max_str_digits": 4300, + "provenance": "Exact .tool-versions pin and observed CPython sys.version; upstream archive digest not claimed.", + "recursion_limit": 1000, + "release": "CPython v3.14.8", + "release_reference": "https://github.com/python/cpython/tree/v3.14.8", + "version": "3.14.8" + }, + "schema_version": 1, + "sources": [ + { + "blob": "9f5f1e0379c76ed7a704470c787abb0aae9e221a", + "definitions": [ + "MAX_JSON_BYTES", + "ProfileError", + "_json_value", + "_unique_object", + "_reject_number", + "parse_json", + "canonical_bytes", + "canonical_digest" + ], + "imports": [ + "hashlib", + "json" + ], + "license": "EUPL-1.2", + "original": "scripts/provider_profiles.py", + "profile": "json", + "revision": "fd0019b7711438a2dbf7255ce3a43572d5a3a6fd", + "sha256": "c199ba25ff55c87474d18a6ccd5e0c03ff529f01678246eea76d566ae27a1026", + "snapshot": "oracle/provider_profiles.py" + }, + { + "blob": "d510d9d0b7fd72b0dd18699a590917e2d45d8b47", + "definitions": [ + "ContextError", + "_require", + "_text", + "canonical_path" + ], + "imports": [], + "license": "EUPL-1.2", + "original": "scripts/single_task_context.py", + "profile": "path", + "revision": "fd0019b7711438a2dbf7255ce3a43572d5a3a6fd", + "sha256": "87acb4351f4b51e2585228315a36832b4a08ba6ff26bed80698a878dd60c9164", + "snapshot": "oracle/single_task_context.py" + } + ] +} diff --git a/contracts/migration/oracle/provider_profiles.py b/contracts/migration/oracle/provider_profiles.py new file mode 100644 index 00000000..9f5f1e03 --- /dev/null +++ b/contracts/migration/oracle/provider_profiles.py @@ -0,0 +1,336 @@ +"""Inert Feature 005 parsing, canonical identities and action requirements. + +These primitives neither assess permission nor supply capabilities/authority. +Typed-record validators must call require_keys and validate every field; generic +JSON parsing deliberately does not invent a profile or evidence schema. +""" +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +import re +import stat +from types import MappingProxyType + +MAX_JSON_BYTES = 1024 * 1024 +MAX_PROFILES = 100 +MAX_EVIDENCE_REFS = 100 +MAX_RETAINED_EVENTS = 1000 +MAX_DATABASE_BYTES = 16 * 1024 * 1024 +TASK_MODES = ('implement', 'handoff-only', 'owner-authorized rework') + + +class ProfileError(ValueError): + """Fixed, non-input-bearing diagnostic code; never expose raw exceptions.""" + + def __init__(self, code): + self.code = code + super().__init__(code) + + +def _json_value(value): + if value is None or type(value) in (bool, int): + return + if type(value) is str: + value.encode('utf-8', errors='strict') + elif type(value) is list: + for child in value: + _json_value(child) + elif type(value) is dict: + for key, child in value.items(): + if type(key) is not str: + raise ValueError + key.encode('utf-8', errors='strict') + _json_value(child) + else: + raise ValueError + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError + result[key] = value + return result + + +def _reject_number(_text): + raise ValueError + + +def parse_json(data): + """Strict UTF-8 bytes, unique keys at every depth; no floats or BOM.""" + if type(data) is not bytes: + raise ProfileError('invalid-json') + if len(data) > MAX_JSON_BYTES: + raise ProfileError('input-limit-exceeded') + try: + value = json.loads(data.decode('utf-8', errors='strict'), + object_pairs_hook=_unique_object, + parse_float=_reject_number, parse_constant=_reject_number) + _json_value(value) + return value + except (ValueError, UnicodeError, RecursionError, OverflowError): + raise ProfileError('invalid-json') from None + + +def canonical_bytes(value): + """Compact sorted UTF-8 JSON, without normalization or a trailing LF.""" + try: + _json_value(value) + return json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False, allow_nan=False).encode('utf-8') + except (ValueError, TypeError, UnicodeError, RecursionError, OverflowError): + raise ProfileError('invalid-json') from None + + +def canonical_digest(value): + return hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def require_keys(value, required, optional=()): + """Require exactly a caller-owned typed-record schema (including nested use).""" + if (type(value) is not dict or any(type(key) is not str for key in value) + or not set(required) <= value.keys() + or not value.keys() <= set(required) | set(optional)): + raise ProfileError('invalid-object-keys') + return value + + +def require_integer(value): + if type(value) is not int: + raise ProfileError('invalid-integer') + return value + + +def require_timestamp(value): + """A signed integer Unix second denotes UTC, never a local/float timestamp.""" + if type(value) is not int: + raise ProfileError('invalid-timestamp') + return value + + +def require_interval(start, end): + require_timestamp(start) + require_timestamp(end) + if start >= end: + raise ProfileError('invalid-interval') + return start, end + + +def require_identifier(value): + if type(value) is not str or re.fullmatch(r'[A-Za-z0-9._:-]{1,128}', value) is None: + raise ProfileError('invalid-identifier') + return value + + +def require_digest(value): + if type(value) is not str or re.fullmatch(r'[0-9a-f]{64}', value) is None: + raise ProfileError('invalid-digest') + return value + + +def _bounded_list(value, limit): + if type(value) is not list or len(value) > limit: + raise ProfileError('input-limit-exceeded') + return value + + +def require_profiles(value): + """Bound the list only; a later typed profile validator owns its contents.""" + return _bounded_list(value, MAX_PROFILES) + + +def require_evidence_refs(value): + """Bound the list only; evidence validation/authority is separate.""" + return _bounded_list(value, MAX_EVIDENCE_REFS) + + +def _bounded_count(value, limit): + if type(value) is not int or not 0 <= value <= limit: + raise ProfileError('input-limit-exceeded') + return value + + +def require_event_count(value): + """Size predicate only: does not append, archive or grant storage authority.""" + return _bounded_count(value, MAX_RETAINED_EVENTS) + + +def require_database_size(value): + return _bounded_count(value, MAX_DATABASE_BYTES) + + +def require_artifact_path(value): + """Canonical POSIX-relative names; never normalize aliases into acceptance.""" + if (type(value) is not str or not value or value.startswith('/') + or '\\' in value or '\x00' in value or ':' in value + or any(part in ('', '.', '..') for part in value.split('/'))): + raise ProfileError('invalid-artifact-path') + try: + value.encode('utf-8', errors='strict') + except UnicodeError: + raise ProfileError('invalid-artifact-path') from None + return value + + +def _directory_at(parent, component): + return os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent) + + +def read_artifact(root, relative): + """Read a bounded regular single-link snapshot under a trusted absolute root. + + Walk from / with no-follow directory descriptors, including root ancestors. + No symlink/nonregular input is opened for reading; no writes or path repair + occur. This is a checked local snapshot, not a hostile-owner sandbox or lease: + callers must freshly revalidate it at their own protected action boundary. + """ + require_artifact_path(relative) + try: + root = Path(root) + if not root.is_absolute() or any(part in ('.', '..') for part in root.parts): + raise ProfileError('invalid-artifact-path') + directory = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for component in (*root.parts[1:], *relative.split('/')[:-1]): + child = _directory_at(directory, component) + os.close(directory) + directory = child + name = relative.split('/')[-1] + before = os.stat(name, dir_fd=directory, follow_symlinks=False) + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + raise ProfileError('unsafe-artifact') + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory) + try: + opened = os.fstat(fd) + if (not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1 + or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino)): + raise ProfileError('unsafe-artifact') + if opened.st_size > MAX_JSON_BYTES: + raise ProfileError('input-limit-exceeded') + chunks = [] + total = 0 + while True: + chunk = os.read(fd, min(65536, MAX_JSON_BYTES + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > MAX_JSON_BYTES: + raise ProfileError('input-limit-exceeded') + after = os.fstat(fd) + current = os.stat(name, dir_fd=directory, follow_symlinks=False) + fields = ('st_dev', 'st_ino', 'st_mode', 'st_nlink', 'st_size', + 'st_mtime_ns', 'st_ctime_ns') + if (any(getattr(opened, field) != getattr(after, field) for field in fields) + or any(getattr(after, field) != getattr(current, field) for field in fields) + or total != after.st_size): + raise ProfileError('artifact-changed') + return b''.join(chunks) + finally: + os.close(fd) + finally: + os.close(directory) + except (OSError, TypeError, ValueError, UnicodeError) as error: + if isinstance(error, ProfileError): + raise + raise ProfileError('unsafe-artifact') from None + + +@dataclass(frozen=True) +class ActionSpec: + action_class: str + boundary: str | None + guard_operation: str | None + capabilities: tuple[str, ...] + package_action: str | None + + +# G(a,m) is intentionally deferred to unchanged task_package.required_capabilities; +# duplicating that implementation here would risk divergent requirements. +ACTIONS = MappingProxyType({ + 'begin': ActionSpec('execution', 'entry', 'begin', ('bridge.begin',), 'entry'), + 'inspect': ActionSpec('observation', None, 'inspect', ('bridge.inspect',), None), + 'verify': ActionSpec('execution', 'completion', 'verify', ('bridge.verify',), 'completion'), + 'complete': ActionSpec('execution', 'completion', 'complete', ('bridge.complete',), 'completion'), + 'record-commit': ActionSpec('execution', 'completion', 'record-commit', ('bridge.record-commit',), 'completion'), + 'resume': ActionSpec('execution', 'resume', 'resume', ('bridge.resume',), 'resume'), + 'rework': ActionSpec('execution', 'entry', 'rework', ('bridge.rework',), 'entry'), + 'status': ActionSpec('observation', None, None, ('ledger.read',), None), + 'lookup': ActionSpec('observation', None, None, ('bridge.lookup',), None), + 'transfer': ActionSpec('execution', 'eligibility', None, ('claim.fenced-transfer',), None), + 'ready': ActionSpec('execution', 'eligibility', None, (), 'ready'), + 'publish': ActionSpec('execution', 'handoff', None, (), 'handoff'), + 'final-labels': ActionSpec('execution', 'handoff', None, (), 'handoff'), + 'provider-execute': ActionSpec('execution', 'provider', None, ('provider.execution',), None), + 'safe-stop': ActionSpec('safety', None, None, ('runtime.safe-stop',), None), +}) + + +@dataclass(frozen=True) +class ActionRequirements: + capabilities: tuple[str, ...] + package_action: str | None + package_mode: str + downstream_transfer_required: bool + reference_executor_available: bool + + +def action_spec(action): + if type(action) is not str or action not in ACTIONS: + raise ProfileError('invalid-action') + return ACTIONS[action] + + +def require_actions(value, *, execution_only=False): + if type(value) is not list or not value or type(execution_only) is not bool: + raise ProfileError('invalid-actions') + for action in value: + spec = action_spec(action) + if execution_only and spec.action_class != 'execution': + raise ProfileError('invalid-actions') + if len(set(value)) != len(value): + raise ProfileError('invalid-actions') + return tuple(value) + + +def action_for_operation(operation): + spec = action_spec(operation) + if spec.guard_operation is None: + raise ProfileError('invalid-operation') + return spec.guard_operation + + +def action_for_command(command, *, operation=None): + if type(command) is not str: + raise ProfileError('invalid-command') + if command == 'guard': + return action_for_operation(operation) + if type(command) is str and command in ('status', 'transfer', 'lookup_creation') and operation is None: + return 'lookup' if command == 'lookup_creation' else command + raise ProfileError('invalid-command') + + +def action_requirements(action, mode, *, guard_attempt_retained=False): + """Return a requirement recipe, never an available-capability verdict. + + Trusted composition unions capabilities with the unchanged G(package_action, + package_mode) whenever package_action is non-null. Transfer additionally + requires fresh new-profile revalidation and *every* downstream transfer/owner + constraint. Other profile/task/inspection/safety/publication approvals remain + mandatory independently. Missing composition/observations cannot mean success. + """ + spec = action_spec(action) + if type(mode) is not str or mode not in TASK_MODES or type(guard_attempt_retained) is not bool: + raise ProfileError('invalid-capability-request') + capabilities = spec.capabilities + if action == 'status' and guard_attempt_retained: + capabilities = ('bridge.status', 'ledger.read') + return ActionRequirements(capabilities, spec.package_action, + 'owner-authorized rework' if action == 'rework' else mode, + action == 'transfer', action not in ('provider-execute', 'safe-stop')) diff --git a/contracts/migration/oracle/single_task_context.py b/contracts/migration/oracle/single_task_context.py new file mode 100644 index 00000000..d510d9d0 --- /dev/null +++ b/contracts/migration/oracle/single_task_context.py @@ -0,0 +1,542 @@ +"""Strict local TaskContext schema and canonical identities for Feature 003. + +References are operator-supplied local assertions, never authenticated ownership. +Schema validation executes no commands and changes no files or caller-owned data. +""" +import copy +import hashlib +import json +import math +import re +from pathlib import Path + +TASK_ID = re.compile(r'T[0-9]{3}\Z') +SHA256 = re.compile(r'[0-9a-f]{64}\Z') +ARTIFACTS = ('spec.md', 'plan.md', 'tasks.md') +CONTEXT_KEYS = {'schema_version', 'feature', 'task_id', 'mode', 'reviewed_artifacts', + 'approval', 'dependencies', 'source_assertion', 'prerequisite_evidence', + 'allowed_paths', 'semantic_constraints', 'checks'} +REWORK_KEYS = {'prior_attempt_id', 'prior_implementation_commit', 'rework_approval'} + + +class ContextError(ValueError): + """Safe machine-readable rejection without reproducing supplied values.""" + def __init__(self, code='context_schema'): + self.code = code + super().__init__('Task context validation failed; review the approved local inputs.') + + +def _require(condition): + if not condition: + raise ContextError() + + +def _record(value, keys): + _require(isinstance(value, dict) and set(value) == set(keys)) + + +def _text(value): + return isinstance(value, str) and bool(value) and '\0' not in value + + +def canonical_bytes(value): + """UTF-8 JSON; no Unicode normalization, nonfinite values or trailing LF.""" + try: + return json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False, allow_nan=False).encode('utf-8') + except (TypeError, ValueError, UnicodeError): + raise ContextError() from None + + +def context_digest(context): + value = copy.deepcopy(context) + _require(isinstance(value, dict) and isinstance(value.get('approval'), dict)) + value['approval'].pop('context_digest', None) + return hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def canonical_path(value, *, dot=False): + """Validate spelling without resolving, normalizing or following a path.""" + if dot and value == '.': + return value + _require(_text(value) and not value.startswith('/') and '\\' not in value + and not any(character in value for character in '\r\n\t*?[]')) + parts = value.split('/') + _require(all(part and part not in ('.', '..', '.git') for part in parts)) + return value + + +def _feature(value): + canonical_path(value) + _require('/' not in value) + + +def _identifier(value): + return isinstance(value, str) and TASK_ID.fullmatch(value) is not None + + +def _sha(value): + return isinstance(value, str) and SHA256.fullmatch(value) is not None + + +def _argv(value): + _require(isinstance(value, list) and bool(value) and all(_text(item) for item in value)) + + +def _manifest_shape(value): + _record(value, {'schema_version', 'entries'}) + _require(type(value['schema_version']) is int and value['schema_version'] == 1 + and isinstance(value['entries'], list)) + paths = [] + for entry in value['entries']: + _record(entry, {'path', 'type', 'mode', 'sha256'}) + paths.append(canonical_path(entry['path'])) + if entry['type'] == 'regular': + _require(entry['mode'] in ('100644', '100755') and _sha(entry['sha256'])) + else: + _require(entry['type'] == 'absent' and entry['mode'] is None and entry['sha256'] is None) + _require(paths == sorted(set(paths))) + + +def _prerequisite_shape(value): + _record(value, {'schema_version', 'feature', 'task_id', 'completed', 'content_manifest', + 'implementation_commit', 'check_results', 'evidence_ref'}) + _require(type(value['schema_version']) is int and value['schema_version'] == 1 + and _identifier(value['task_id']) and type(value['completed']) is bool + and _text(value['implementation_commit']) and _text(value['evidence_ref']) + and isinstance(value['check_results'], list) and bool(value['check_results'])) + _feature(value['feature']) + _manifest_shape(value['content_manifest']) + ids = [] + for check in value['check_results']: + _record(check, {'id', 'argv', 'cwd', 'exit', 'content_manifest_digest'}) + _require(_text(check['id']) and type(check['exit']) is int and _sha(check['content_manifest_digest'])) + ids.append(check['id']) + _argv(check['argv']) + canonical_path(check['cwd'], dot=True) + _require(len(ids) == len(set(ids))) + + +def validate_context(value): + """Validate schema only; filesystem/approval/prerequisite gates are separate.""" + _require(isinstance(value, dict)) + mode = value.get('mode') + _require(mode in ('implement', 'owner-authorized rework')) + _record(value, CONTEXT_KEYS | (REWORK_KEYS if mode == 'owner-authorized rework' else set())) + _require(type(value['schema_version']) is int and value['schema_version'] == 1) + feature = value['feature'] + _feature(feature) + _require(_identifier(value['task_id'])) + _record(value['reviewed_artifacts'], ARTIFACTS) + for name in ARTIFACTS: + record = value['reviewed_artifacts'][name] + _record(record, {'path', 'commit', 'sha256'}) + _require(record['path'] == f'specs/{feature}/{name}' and _text(record['commit']) and _sha(record['sha256'])) + canonical_path(record['path']) + approval = value['approval'] + _record(approval, {'owner_ref', 'evidence_ref', 'context_digest'}) + _require(_text(approval['owner_ref']) and _text(approval['evidence_ref']) and _sha(approval['context_digest'])) + dependencies = value['dependencies'] + _require(isinstance(dependencies, list) and all(_identifier(item) for item in dependencies)) + _require(dependencies == sorted(set(dependencies))) + source = value['source_assertion'] + _record(source, {'path', 'sha256', 'excerpt', 'owner_ref'}) + _require(source['path'] == f'specs/{feature}/tasks.md' and _sha(source['sha256']) + and _text(source['excerpt']) and _text(source['owner_ref'])) + canonical_path(source['path']) + evidence = value['prerequisite_evidence'] + _require(isinstance(evidence, list)) + for record in evidence: + _prerequisite_shape(record) + paths = value['allowed_paths'] + _require(isinstance(paths, list) and bool(paths)) + protected = {f'specs/{feature}/{name}' for name in ARTIFACTS} + for path in paths: + canonical_path(path) + _require(path not in protected and path.split('/')[0] != '.symphony' + and not any(path == prefix or path.startswith(prefix + '/') for prefix in + ('specs/001-workspace-lifecycle', 'specs/002-symphony-doc-smoke'))) + _require(len(paths) == len(set(paths))) + descriptions = value['semantic_constraints'] + _require(isinstance(descriptions, list) and bool(descriptions) and all(_text(item) for item in descriptions)) + checks = value['checks'] + _require(isinstance(checks, list) and bool(checks)) + ids = [] + for check in checks: + _record(check, {'id', 'argv', 'cwd', 'timeout_seconds', 'expected_exit'}) + _require(_text(check['id'])) + ids.append(check['id']) + _argv(check['argv']) + canonical_path(check['cwd'], dot=True) + timeout = check['timeout_seconds'] + try: + finite = type(timeout) in (int, float) and math.isfinite(timeout) and timeout > 0 + except OverflowError: + finite = False + _require(finite and type(check['expected_exit']) is int and check['expected_exit'] == 0) + _require(len(ids) == len(set(ids))) + if mode == 'owner-authorized rework': + _require(_text(value['prior_attempt_id']) and _text(value['prior_implementation_commit'])) + _record(value['rework_approval'], {'owner_ref', 'evidence_ref'}) + _require(all(_text(item) for item in value['rework_approval'].values())) + canonical_bytes(value) + return copy.deepcopy(value) + + +def load_context(path): + """Read strict UTF-8 JSON; duplicate keys and constants are invalid input.""" + def unique(pairs): + result = {} + for key, item in pairs: + if key in result: + raise ContextError('invalid_json') + result[key] = item + return result + + def constant(value): + raise ContextError('invalid_json') + + try: + value = json.loads(Path(path).read_text(encoding='utf-8'), + object_pairs_hook=unique, parse_constant=constant) + except ContextError: + raise + except (OSError, ValueError, UnicodeError): + raise ContextError('invalid_json') from None + return validate_context(value) + + +def reviewed_bytes(root, record): + """Resolve a reviewed ancestor and read its exact blob without fetching.""" + from single_task_scope import git + try: + commit = git(root, 'rev-parse', '--verify', '--end-of-options', + record['commit'] + '^{commit}').stdout.strip().decode('ascii') + if git(root, 'merge-base', '--is-ancestor', commit, 'HEAD', allowed=(0, 1)).returncode: + raise ContextError('artifact_commit') + entry = git(root, 'ls-tree', '-z', commit, '--', record['path']).stdout + metadata, path = entry.rstrip(b'\0').split(b'\t') + mode, kind, identity = metadata.split() + if mode not in (b'100644', b'100755') or kind != b'blob' or path.decode('utf-8') != record['path']: + raise ContextError('artifact_commit') + content = git(root, 'cat-file', 'blob', identity.decode('ascii')).stdout + except (ContextError, UnicodeError, ValueError): + raise ContextError('artifact_commit') from None + if hashlib.sha256(content).hexdigest() != record['sha256']: + raise ContextError('artifact_digest') + return content + + +def _footer_parts(content): + """Recognize a terminal structured footer only outside Markdown fences.""" + offset = 0 + fence = None + start = None + for line in content.splitlines(keepends=True): + marker = re.match(rb' {0,3}(`{3,}|~{3,})([^\r\n]*)', line) + if fence: + if marker and marker[1][:1] == fence[0] and len(marker[1]) >= fence[1] and not marker[2].strip(): + fence = None + elif marker and not (marker[1][:1] == b'`' and b'`' in marker[2]): + fence = (marker[1][:1], len(marker[1])) + elif line in (b'## Execution evidence\n', b'## Execution evidence\r\n'): + start = offset + offset += len(line) + if start is None: + return content, None, [] + footer = content[start:] + lines = footer.splitlines(keepends=True) + records = [] + for line in lines[1:]: + record = re.fullmatch(rb'- Evidence: (T[0-9]{3}) ([^ \t\r\n]+) ([0-9a-f]{64})(?:\r?\n)?', line) + if record is None: + return content, None, [] + records.append(record.groups()) + if not records: + return content, None, [] + return content[:start], footer, records + + +def _approval_body(content, tasks): + if not tasks: + return content + from single_task_scope import task_lines + value = bytearray(content) + for task in task_lines(content).values(): + value[task['marker_offset']] = ord(' ') + return bytes(value) + + +def validate_reviewed_artifacts(root, value): + """Bind approval and compare prose, permitting only documented progress.""" + from single_task_scope import read_regular, task_lines + context = validate_context(value) + if context_digest(context) != context['approval']['context_digest']: + raise ContextError('approval_digest') + reviewed = {name: reviewed_bytes(root, record) + for name, record in context['reviewed_artifacts'].items()} + try: + current = {name: read_regular(root, record['path']) + for name, record in context['reviewed_artifacts'].items()} + tasks = task_lines(current['tasks.md']) + for name in ARTIFACTS: + before, old_footer, old_records = _footer_parts(reviewed[name]) + after, new_footer, new_records = _footer_parts(current[name]) + before = _approval_body(before, name == 'tasks.md') + after = _approval_body(after, name == 'tasks.md') + if old_footer is not None: + matches = after == before and new_footer is not None and new_footer.startswith(old_footer) + elif new_footer is not None: + newline = b'\r\n' if new_footer.startswith(b'## Execution evidence\r\n') else b'\n' + matches = after in (before, before + newline) + else: + matches = after == before + if not matches: + raise ContextError('artifact_drift') + for task, path, digest in old_records + new_records: + path = path.decode('utf-8') + canonical_path(path) + if task.decode('ascii') not in tasks or hashlib.sha256(read_regular(root, path)).hexdigest().encode() != digest: + raise ContextError('artifact_drift') + except (ContextError, UnicodeError, OSError, ValueError): + raise ContextError('artifact_drift') from None + return reviewed + + +def _dependency_table(content): + from single_task_scope import task_lines + try: + tasks = task_lines(content) + lines = [] + fence = None + raw_lines = content.split(b'\n') + for index, line in enumerate(raw_lines): + if index < len(raw_lines) - 1 and line.endswith(b'\r'): + line = line[:-1] # Only CRLF is the documented comparison exception. + marker = re.match(rb' {0,3}(`{3,}|~{3,})(.*)', line) + if fence: + lines.append('') # Fences cannot join otherwise noncontiguous table rows. + if marker and marker[1][:1] == fence[0] and len(marker[1]) >= fence[1] and not marker[2].strip(): + fence = None + elif marker and not (marker[1][:1] == b'`' and b'`' in marker[2]): + lines.append('') + fence = (marker[1][:1], len(marker[1])) + else: + lines.append(line.decode('utf-8')) + header = '| Task | Prerequisites |' + if lines.count(header) != 1: + raise ValueError() + start = lines.index(header) + if lines[start + 1] != '| --- | --- |': + raise ValueError() + end = start + 2 + rows = [] + graph = {} + while end < len(lines) and lines[end].startswith('|'): + row = re.fullmatch(r'\| (T[0-9]{3}) \| (none|T[0-9]{3}(?:, T[0-9]{3})*) \|', lines[end]) + if row is None: + raise ValueError() + identity, prerequisites = row.groups() + dependencies = [] if prerequisites == 'none' else prerequisites.split(', ') + if identity in graph or dependencies != sorted(set(dependencies)) or identity in dependencies: + raise ValueError() + rows.append(identity) + graph[identity] = dependencies + end += 1 + if rows != sorted(tasks) or any(set(deps) - set(tasks) for deps in graph.values()): + raise ValueError() + pending = {identity: set(dependencies) for identity, dependencies in graph.items()} + while pending: + ready = {identity for identity, dependencies in pending.items() if not dependencies} + if not ready: + raise ValueError() + pending = {identity: dependencies - ready for identity, dependencies in pending.items() if identity not in ready} + return graph, '\n'.join(lines[start:end]) + except (ContextError, ValueError, IndexError, UnicodeError): + raise ContextError('dependency_table') from None + + +def _tree_entry(root, commit, path): + """Inspect every tree component, distinguishing absence from unsafe types.""" + from single_task_scope import git + components = canonical_path(path).split('/') + for index in range(len(components)): + prefix = '/'.join(components[:index + 1]) + raw = git(root, 'ls-tree', '-z', commit, '--', ':(literal)' + prefix).stdout + if not raw: + return None + metadata, actual = raw.rstrip(b'\0').split(b'\t') + mode, kind, identity = metadata.split() + if actual.decode('utf-8') != prefix: + raise ValueError() + if index < len(components) - 1: + if mode != b'040000' or kind != b'tree': + raise ValueError() + else: + return mode.decode('ascii'), kind, identity.decode('ascii') + + +def _validate_prerequisite(root, context, record, current_tasks): + from single_task_scope import git, task_lines + if (record['feature'] != context['feature'] or not record['completed'] or + record['task_id'] not in current_tasks or not current_tasks[record['task_id']]['checked']): + raise ValueError() + commit = git(root, 'rev-parse', '--verify', '--end-of-options', + record['implementation_commit'] + '^{commit}').stdout.strip().decode('ascii') + tasks_path = f'specs/{context["feature"]}/tasks.md' + if tasks_path not in {entry['path'] for entry in record['content_manifest']['entries']}: + raise ValueError() + for entry in record['content_manifest']['entries']: + actual = _tree_entry(root, commit, entry['path']) + if entry['type'] == 'absent': + if actual is not None or entry['path'] == tasks_path: + raise ValueError() + continue + if actual is None or actual[0] != entry['mode'] or actual[1] != b'blob': + raise ValueError() + content = git(root, 'cat-file', 'blob', actual[2]).stdout + if entry['path'] == tasks_path: + selected = task_lines(content).get(record['task_id']) + if selected is None or not selected['checked']: + raise ValueError() + normalized = bytearray(content) + normalized[selected['marker_offset']] = ord(' ') + content = bytes(normalized) + if hashlib.sha256(content).hexdigest() != entry['sha256']: + raise ValueError() + manifest_digest = hashlib.sha256(canonical_bytes(record['content_manifest'])).hexdigest() + if any(check['exit'] != 0 or check['content_manifest_digest'] != manifest_digest for check in record['check_results']): + raise ValueError() + + +def validate_prerequisites(root, value): + """Validate reviewed dependency grammar and exact local prerequisite records.""" + from single_task_scope import read_regular, task_lines + context = validate_context(value) + source = context['source_assertion'] + reviewed = reviewed_bytes(root, context['reviewed_artifacts']['tasks.md']) + graph, excerpt = _dependency_table(reviewed) + if (source['sha256'] != hashlib.sha256(reviewed).hexdigest() or source['excerpt'] != excerpt + or graph.get(context['task_id']) != context['dependencies']): + raise ContextError('dependency_mismatch') + try: + current = task_lines(read_regular(root, source['path'])) + records = context['prerequisite_evidence'] + ids = [record['task_id'] for record in records] + if sorted(ids) != context['dependencies']: + raise ValueError() + for record in records: + _validate_prerequisite(root, context, record, current) + except (ContextError, OSError, ValueError, UnicodeError): + raise ContextError('prerequisite_evidence') from None + return {record['task_id']: record for record in records} + + +def validate_rework_context(root, value, store): + """Validate supplied corrective authorization against retained local predecessor.""" + import single_task_scope as scope + import single_task_state as state + context = validate_context(value) + if context['mode'] != 'owner-authorized rework': + raise ContextError('context_schema') + entry = scope.validate_entry_scope(root, context) + validate_reviewed_artifacts(root, context) + validate_prerequisites(root, context) + try: + prior = store.read_attempt(context['prior_attempt_id']) + binding = state.implementation_binding(store, prior['attempt_id']) + if (prior['progress'] != 'handoff-ready' or prior['context']['feature'] != context['feature'] or + prior['context']['task_id'] != context['task_id'] or binding is None): + raise state.InvalidState('Rework requires the same completed bound predecessor') + events = store.read_events(prior['attempt_id']) + intents = [row for row in events if row['kind'] == 'completion-intent'] + if len(intents) != 1: + raise state.InvalidState('Predecessor completion intent is missing or contradictory') + event = intents[0] + intent = event['payload'] + state.validate_completion_intent_schema(intent) + manifest = scope.validate_manifest(intent['content_manifest']) + manifest_digest = hashlib.sha256(scope.canonical_manifest_bytes(manifest)).hexdigest() + expected_binding = {'schema_version': 1, 'implementation_commit': context['prior_implementation_commit'], + 'context_digest': prior['approval_context_digest'], + 'content_manifest_digest': manifest_digest, + 'completion_intent_digest': event['payload_digest']} + if canonical_bytes(binding) != canonical_bytes(expected_binding): + raise state.InvalidState('Corrective authorization does not name the retained implementation') + before = bytes.fromhex(intent['tasks_before_hex']) + after = bytes.fromhex(intent['tasks_after_hex']) + task_path = f"specs/{context['feature']}/tasks.md" + selected = scope.task_lines(before).get(context['task_id']) + if selected is None or selected['checked'] != (prior['entry_mode'] == 'owner-authorized rework'): + raise state.InvalidState('Predecessor marker mode is invalid') + offset = selected['marker_offset'] + expected_post = before if selected['checked'] else before[:offset] + b'x' + before[offset+1:] + if (intent['attempt_id'] != prior['attempt_id'] or intent['tasks_path'] != task_path or + before != prior['tasks_bytes'] or after != expected_post or + hashlib.sha256(before).hexdigest() != intent['tasks_before_sha256'] or + hashlib.sha256(after).hexdigest() != intent['tasks_after_sha256'] or + manifest_digest != intent['content_manifest_digest'] or + intent['context_digest'] != prior['approval_context_digest']): + raise state.InvalidState('Predecessor completion seal is invalid') + state.validate_retained_completion_phase(prior, events, event, after, after) + acceptance = next((row for row in events if row['sequence'] == intent['acceptance_sequence']), None) + if (acceptance is None or acceptance['kind'] != 'acceptance' or + acceptance['sequence'] >= event['sequence'] or + acceptance['payload_digest'] != intent['acceptance_digest'] or + acceptance['payload']['content_manifest'] != manifest or + acceptance['payload']['context_digest'] != prior['approval_context_digest']): + raise state.InvalidState('Predecessor acceptance seal is invalid') + state.validate_acceptance_schema(acceptance['payload']) + if (acceptance['payload']['attempt_id'] != prior['attempt_id'] or + acceptance['payload']['task_id'] != context['task_id']): + raise state.InvalidState('Predecessor acceptance task identity disagrees') + review = scope.validate_semantic_review(acceptance['payload']['semantic_review'], prior, manifest) + review_digest = hashlib.sha256(canonical_bytes(review)).hexdigest() + expected_checks = [{'schema_version': 1, 'id': check['id'], 'argv': check['argv'], + 'cwd': check['cwd'], 'expected_exit': 0, 'exit': 0, 'category': 'passed', + 'timed_out': False, 'context_digest': prior['approval_context_digest'], + 'content_manifest_digest': manifest_digest, + 'semantic_review_digest': review_digest} for check in prior['context']['checks']] + if (review_digest != intent['semantic_review_digest'] or + review_digest != acceptance['payload']['semantic_review_digest'] or + manifest_digest != acceptance['payload']['content_manifest_digest'] or + canonical_bytes(expected_checks) != canonical_bytes(acceptance['payload']['checks'])): + raise state.InvalidState('Predecessor acceptance identities disagree') + commit = context['prior_implementation_commit'] + if scope.git(root, 'rev-parse', '--verify', '--end-of-options', commit + '^{commit}').stdout.strip().decode('ascii') != commit: + raise state.InvalidState('Prior implementation commit unavailable') + scope.git(root, 'merge-base', '--is-ancestor', prior['baseline'], commit) + scope.git(root, 'merge-base', '--is-ancestor', commit, entry['baseline_commit']) + original = scope._git_inventory(root, prior['baseline']) + for revision in [commit] + scope.git(root, 'rev-list', commit, '^' + prior['baseline']).stdout.decode('ascii').splitlines(): + inventory = scope._git_inventory(root, revision) + for path in original.keys() | inventory.keys(): + current = inventory.get(path) + if path in prior['context']['allowed_paths']: + if current is not None and current[0] not in ('100644', '100755'): + raise state.InvalidState('Unsafe predecessor implementation output') + elif path == task_path: + if (current is None or current[0] != original[path][0] or + scope.normalize_tasks_bytes(scope._blob(root, current[1]), context['task_id']) != + scope.normalize_tasks_bytes(before, context['task_id'])): + raise state.InvalidState('Predecessor implementation changed protected tasks') + elif current != original.get(path): + raise state.InvalidState('Predecessor implementation changed protected content') + for item in manifest['entries']: + actual = _tree_entry(root, commit, item['path']) + if item['type'] == 'absent': + if actual is not None: + raise state.InvalidState('Predecessor sealed absence does not match') + else: + if actual is None or actual[:2] != (item['mode'], b'blob'): + raise state.InvalidState('Predecessor sealed type or mode does not match') + content = scope._blob(root, actual[2]) + if item['path'] == task_path: + if content != after: + raise state.InvalidState('Predecessor completed task bytes do not match') + content = scope.normalize_tasks_bytes(content, context['task_id']) + if hashlib.sha256(content).hexdigest() != item['sha256']: + raise state.InvalidState('Predecessor sealed bytes do not match') + return entry + except (ContextError, KeyError, TypeError, ValueError, UnicodeError): + raise state.InvalidState('Retained rework predecessor evidence is invalid') from None diff --git a/contracts/migration/vectors.json b/contracts/migration/vectors.json new file mode 100644 index 00000000..89a8db41 --- /dev/null +++ b/contracts/migration/vectors.json @@ -0,0 +1,4491 @@ +{ + "cases": [ + { + "decision": "parity", + "id": "json.negative-zero", + "input": { + "data": { + "encoding": "hex", + "hex": "2d30", + "length": 2, + "sha256": "ed79f26d03f412bde3db206601a698e0bb451bea9e3cc25289636ac17ea74b0a" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "30", + "length": 1, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + "digest": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "30", + "length": 1, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + "digest": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.null", + "input": { + "data": { + "encoding": "hex", + "hex": "6e756c6c", + "length": 4, + "sha256": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "6e756c6c", + "length": 4, + "sha256": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b" + }, + "digest": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "6e756c6c", + "length": 4, + "sha256": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b" + }, + "digest": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.true", + "input": { + "data": { + "encoding": "hex", + "hex": "74727565", + "length": 4, + "sha256": "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "74727565", + "length": 4, + "sha256": "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b" + }, + "digest": "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "74727565", + "length": 4, + "sha256": "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b" + }, + "digest": "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.false", + "input": { + "data": { + "encoding": "hex", + "hex": "66616c7365", + "length": 5, + "sha256": "fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "66616c7365", + "length": 5, + "sha256": "fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa" + }, + "digest": "fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "66616c7365", + "length": 5, + "sha256": "fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa" + }, + "digest": "fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.zero", + "input": { + "data": { + "encoding": "hex", + "hex": "30", + "length": 1, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "30", + "length": 1, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + "digest": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "30", + "length": 1, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + "digest": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.negative-integer", + "input": { + "data": { + "encoding": "hex", + "hex": "2d3432", + "length": 3, + "sha256": "fec80006df0542549b4cbaafb8987eee00bb49bca396eefe9ac8be5b5928e8f6" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "2d3432", + "length": 3, + "sha256": "fec80006df0542549b4cbaafb8987eee00bb49bca396eefe9ac8be5b5928e8f6" + }, + "digest": "fec80006df0542549b4cbaafb8987eee00bb49bca396eefe9ac8be5b5928e8f6", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "2d3432", + "length": 3, + "sha256": "fec80006df0542549b4cbaafb8987eee00bb49bca396eefe9ac8be5b5928e8f6" + }, + "digest": "fec80006df0542549b4cbaafb8987eee00bb49bca396eefe9ac8be5b5928e8f6", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.empty-object", + "input": { + "data": { + "encoding": "hex", + "hex": "7b7d", + "length": 2, + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b7d", + "length": 2, + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "digest": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b7d", + "length": 2, + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "digest": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.empty-array", + "input": { + "data": { + "encoding": "hex", + "hex": "5b5d", + "length": 2, + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b5d", + "length": 2, + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + "digest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b5d", + "length": 2, + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + "digest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.nested", + "input": { + "data": { + "encoding": "hex", + "hex": "7b227a223a5b6e756c6c2c747275652c2d322c7b2261223a2262227d5d7d", + "length": 30, + "sha256": "4b718052b1dac653c1e26da30804a669adf55f576da6a67245ecbc3d62f50114" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b227a223a5b6e756c6c2c747275652c2d322c7b2261223a2262227d5d7d", + "length": 30, + "sha256": "4b718052b1dac653c1e26da30804a669adf55f576da6a67245ecbc3d62f50114" + }, + "digest": "4b718052b1dac653c1e26da30804a669adf55f576da6a67245ecbc3d62f50114", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b227a223a5b6e756c6c2c747275652c2d322c7b2261223a2262227d5d7d", + "length": 30, + "sha256": "4b718052b1dac653c1e26da30804a669adf55f576da6a67245ecbc3d62f50114" + }, + "digest": "4b718052b1dac653c1e26da30804a669adf55f576da6a67245ecbc3d62f50114", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.boolean-integer", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2269223a312c2262223a747275657d", + "length": 16, + "sha256": "556764e342492127be823ed1520ffd242e4ee7719e2b139a1d302f458c78a2e9" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2262223a747275652c2269223a317d", + "length": 16, + "sha256": "1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63" + }, + "digest": "1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2262223a747275652c2269223a317d", + "length": 16, + "sha256": "1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63" + }, + "digest": "1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.html", + "input": { + "data": { + "encoding": "hex", + "hex": "223c3e262f22", + "length": 6, + "sha256": "396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "223c3e262f22", + "length": 6, + "sha256": "396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492" + }, + "digest": "396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "223c3e262f22", + "length": 6, + "sha256": "396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492" + }, + "digest": "396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.line-separators", + "input": { + "data": { + "encoding": "hex", + "hex": "225c75323032385c753230323922", + "length": 14, + "sha256": "b36c0076c7e28a52a8a9cb89306b164f887df89d715680d7b1ca8a858a8f3d33" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "22e280a8e280a922", + "length": 8, + "sha256": "e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c" + }, + "digest": "e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "22e280a8e280a922", + "length": 8, + "sha256": "e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c" + }, + "digest": "e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.supplementary-order", + "input": { + "data": { + "encoding": "hex", + "hex": "7b225c75643830305c7564633030223a322c225c7565303030223a317d", + "length": 29, + "sha256": "107815178221b988e51a6fedd200e03563d4a6e0729c196abd1e8417170bb181" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b22ee8080223a312c22f0908080223a327d", + "length": 18, + "sha256": "8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8" + }, + "digest": "8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b22ee8080223a312c22f0908080223a327d", + "length": 18, + "sha256": "8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8" + }, + "digest": "8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.surrogate-pair", + "input": { + "data": { + "encoding": "hex", + "hex": "225c75643833645c756465303022", + "length": 14, + "sha256": "e30439cb87e140c0b998ad3095285abfec0294e2a8916f9a8f30d85734e6f82b" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "22f09f988022", + "length": 6, + "sha256": "7a0c50b92434b015545fe93ab723db2d4b2cdd14a441405624a9ce8be29f1d5a" + }, + "digest": "7a0c50b92434b015545fe93ab723db2d4b2cdd14a441405624a9ce8be29f1d5a", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "22f09f988022", + "length": 6, + "sha256": "7a0c50b92434b015545fe93ab723db2d4b2cdd14a441405624a9ce8be29f1d5a" + }, + "digest": "7a0c50b92434b015545fe93ab723db2d4b2cdd14a441405624a9ce8be29f1d5a", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.literal-replacement", + "input": { + "data": { + "encoding": "hex", + "hex": "22efbfbd22", + "length": 5, + "sha256": "568601070314e0f4489c9944b3c151d5251d379330008293bb7e1a826a22a845" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "22efbfbd22", + "length": 5, + "sha256": "568601070314e0f4489c9944b3c151d5251d379330008293bb7e1a826a22a845" + }, + "digest": "568601070314e0f4489c9944b3c151d5251d379330008293bb7e1a826a22a845", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "22efbfbd22", + "length": 5, + "sha256": "568601070314e0f4489c9944b3c151d5251d379330008293bb7e1a826a22a845" + }, + "digest": "568601070314e0f4489c9944b3c151d5251d379330008293bb7e1a826a22a845", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.del", + "input": { + "data": { + "encoding": "hex", + "hex": "225c753030376622", + "length": 8, + "sha256": "226453a6508483054f1ddc449c26b45077f5a45d993034f0f5f04f3f9801c32c" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "227f22", + "length": 3, + "sha256": "6fe76740e12a93d5598fe685cb6ad3d3c94e44609065a56866b69647772026ec" + }, + "digest": "6fe76740e12a93d5598fe685cb6ad3d3c94e44609065a56866b69647772026ec", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "227f22", + "length": 3, + "sha256": "6fe76740e12a93d5598fe685cb6ad3d3c94e44609065a56866b69647772026ec" + }, + "digest": "6fe76740e12a93d5598fe685cb6ad3d3c94e44609065a56866b69647772026ec", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.unicode-noncharacter", + "input": { + "data": { + "encoding": "hex", + "hex": "225c756666666622", + "length": 8, + "sha256": "1ef1b6da15ca38e4564f03db3fa65252bcdd156176731c3b671fb2a6734fd8f2" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "22efbfbf22", + "length": 5, + "sha256": "7c6c5c98a320272b95b2a73e2a2ff15825794b0460f8af7835c9dfd9e76b38bb" + }, + "digest": "7c6c5c98a320272b95b2a73e2a2ff15825794b0460f8af7835c9dfd9e76b38bb", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "22efbfbf22", + "length": 5, + "sha256": "7c6c5c98a320272b95b2a73e2a2ff15825794b0460f8af7835c9dfd9e76b38bb" + }, + "digest": "7c6c5c98a320272b95b2a73e2a2ff15825794b0460f8af7835c9dfd9e76b38bb", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.normalization-distinct", + "input": { + "data": { + "encoding": "hex", + "hex": "7b225c7530306539223a312c22655c7530333031223a327d", + "length": 24, + "sha256": "30e2fc370c72a0a9f660cd87d6ee2487e62318b63897f4acac5609eb10843056" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2265cc81223a322c22c3a9223a317d", + "length": 16, + "sha256": "a7962fb10dc1255be368ece9c22b2256605921dc6d0a8c9409d3ee406bcb86e5" + }, + "digest": "a7962fb10dc1255be368ece9c22b2256605921dc6d0a8c9409d3ee406bcb86e5", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2265cc81223a322c22c3a9223a317d", + "length": 16, + "sha256": "a7962fb10dc1255be368ece9c22b2256605921dc6d0a8c9409d3ee406bcb86e5" + }, + "digest": "a7962fb10dc1255be368ece9c22b2256605921dc6d0a8c9409d3ee406bcb86e5", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.utf8", + "input": { + "data": { + "encoding": "hex", + "hex": "22c3a922", + "length": 4, + "sha256": "f2886017e9c7abacf804b54d64787dce2b611c9544ba21f3affdd126a6e50086" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "22c3a922", + "length": 4, + "sha256": "f2886017e9c7abacf804b54d64787dce2b611c9544ba21f3affdd126a6e50086" + }, + "digest": "f2886017e9c7abacf804b54d64787dce2b611c9544ba21f3affdd126a6e50086", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "22c3a922", + "length": 4, + "sha256": "f2886017e9c7abacf804b54d64787dce2b611c9544ba21f3affdd126a6e50086" + }, + "digest": "f2886017e9c7abacf804b54d64787dce2b611c9544ba21f3affdd126a6e50086", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.controls", + "input": { + "data": { + "encoding": "hex", + "hex": "225c75303030305c625c745c6e5c665c725c753030316622", + "length": 24, + "sha256": "1835422ca790a3c1cebce2873a512b81d9caf590b7fa2d0af6189f8ab2ce7b06" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "225c75303030305c625c745c6e5c665c725c753030316622", + "length": 24, + "sha256": "1835422ca790a3c1cebce2873a512b81d9caf590b7fa2d0af6189f8ab2ce7b06" + }, + "digest": "1835422ca790a3c1cebce2873a512b81d9caf590b7fa2d0af6189f8ab2ce7b06", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "225c75303030305c625c745c6e5c665c725c753030316622", + "length": 24, + "sha256": "1835422ca790a3c1cebce2873a512b81d9caf590b7fa2d0af6189f8ab2ce7b06" + }, + "digest": "1835422ca790a3c1cebce2873a512b81d9caf590b7fa2d0af6189f8ab2ce7b06", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.slashes-quotes", + "input": { + "data": { + "encoding": "hex", + "hex": "22615c2f625c5c635c226422", + "length": 12, + "sha256": "805887ebcac6b69a153215b7b0c8970db97061892eb7e7e1095de2658b0bc038" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "22612f625c5c635c226422", + "length": 11, + "sha256": "c14bab927eef918118da48179abdb8d84d4a9e4a13f17a67d48252813731a61a" + }, + "digest": "c14bab927eef918118da48179abdb8d84d4a9e4a13f17a67d48252813731a61a", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "22612f625c5c635c226422", + "length": 11, + "sha256": "c14bab927eef918118da48179abdb8d84d4a9e4a13f17a67d48252813731a61a" + }, + "digest": "c14bab927eef918118da48179abdb8d84d4a9e4a13f17a67d48252813731a61a", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.i64-over", + "input": { + "data": { + "encoding": "hex", + "hex": "39323233333732303336383534373735383038", + "length": 19, + "sha256": "c5c29af0c2b1ba23907ca40686689919361601e7740335050476b290f2f87ee8" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "39323233333732303336383534373735383038", + "length": 19, + "sha256": "c5c29af0c2b1ba23907ca40686689919361601e7740335050476b290f2f87ee8" + }, + "digest": "c5c29af0c2b1ba23907ca40686689919361601e7740335050476b290f2f87ee8", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "39323233333732303336383534373735383038", + "length": 19, + "sha256": "c5c29af0c2b1ba23907ca40686689919361601e7740335050476b290f2f87ee8" + }, + "digest": "c5c29af0c2b1ba23907ca40686689919361601e7740335050476b290f2f87ee8", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.u64-over", + "input": { + "data": { + "encoding": "hex", + "hex": "3138343436373434303733373039353531363136", + "length": 20, + "sha256": "8b292fc2d32f1fd491784610c61b90dc4632a6e4add19261eca4c3979e4ea6d2" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "3138343436373434303733373039353531363136", + "length": 20, + "sha256": "8b292fc2d32f1fd491784610c61b90dc4632a6e4add19261eca4c3979e4ea6d2" + }, + "digest": "8b292fc2d32f1fd491784610c61b90dc4632a6e4add19261eca4c3979e4ea6d2", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "3138343436373434303733373039353531363136", + "length": 20, + "sha256": "8b292fc2d32f1fd491784610c61b90dc4632a6e4add19261eca4c3979e4ea6d2" + }, + "digest": "8b292fc2d32f1fd491784610c61b90dc4632a6e4add19261eca4c3979e4ea6d2", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.negative-i64-over", + "input": { + "data": { + "encoding": "hex", + "hex": "2d39323233333732303336383534373735383039", + "length": 20, + "sha256": "a11b7b6918ba4e84f8e3ab75638e7325e9f891fcf575dd801756523d4bc8a966" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "2d39323233333732303336383534373735383039", + "length": 20, + "sha256": "a11b7b6918ba4e84f8e3ab75638e7325e9f891fcf575dd801756523d4bc8a966" + }, + "digest": "a11b7b6918ba4e84f8e3ab75638e7325e9f891fcf575dd801756523d4bc8a966", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "2d39323233333732303336383534373735383039", + "length": 20, + "sha256": "a11b7b6918ba4e84f8e3ab75638e7325e9f891fcf575dd801756523d4bc8a966" + }, + "digest": "a11b7b6918ba4e84f8e3ab75638e7325e9f891fcf575dd801756523d4bc8a966", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.whitespace", + "input": { + "data": { + "encoding": "hex", + "hex": "20090a0d207b20226122203a2031207d20090a", + "length": 19, + "sha256": "8453cc908e20c95c3ecbb3090499bbb33b4c77b238553d7b04ba23af23803286" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2261223a317d", + "length": 7, + "sha256": "015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862" + }, + "digest": "015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2261223a317d", + "length": 7, + "sha256": "015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862" + }, + "digest": "015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.duplicate", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2261223a312c2261223a327d", + "length": 13, + "sha256": "1c53ee0df7b12fd4d65b976120c7fa6b847dc41dffd7f0331c3237a1ceab1756" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.duplicate-nested", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2278223a5b7b2261223a312c2261223a327d5d7d", + "length": 21, + "sha256": "24301f19d56b15953041afa480032bd1940f460294a193ac098dcc468ca1ceff" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.duplicate-escaped", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2261223a312c225c7530303631223a327d", + "length": 18, + "sha256": "c95ba945b310a34906bbfc28ce1346b87741d5a2b3066d1d3ecfa0acbd86392c" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.duplicate-supplementary", + "input": { + "data": { + "encoding": "hex", + "hex": "7b225c75643830305c7564633030223a312c22f0908080223a327d", + "length": 27, + "sha256": "019f8e22d82f1180f336bb1b284534f5c6a8c2224155b5e40b164ff603d41cf4" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.empty", + "input": { + "data": { + "encoding": "hex", + "hex": "", + "length": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.truncated", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2261223a", + "length": 5, + "sha256": "ffb38b22ee3e0ca90325ebce953a9846990f292faf44c50498771602e31cb61f" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.trailing-value", + "input": { + "data": { + "encoding": "hex", + "hex": "7b7d205b5d", + "length": 5, + "sha256": "6c1bd26e549ea058bfb8f4be8dcc58632031fde4c153afa7474579c66013a238" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.trailing-comma", + "input": { + "data": { + "encoding": "hex", + "hex": "5b312c5d", + "length": 4, + "sha256": "886ad6246ed150b2930495926ba07d579307bc7fecaf4b391650dca8bae7bc66" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.comment", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2f2a782a2f7d", + "length": 7, + "sha256": "593aa521cee0e776bef3dbab2634f9cfa6c568c7cfdcb77bac82c03036074cc5" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.unquoted-key", + "input": { + "data": { + "encoding": "hex", + "hex": "7b613a317d", + "length": 5, + "sha256": "f09a9231c12685ac5f0b6f0b06c9154f13dc3a5b3548db5142c19bde1383f261" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.bad-escape", + "input": { + "data": { + "encoding": "hex", + "hex": "225c78343122", + "length": 6, + "sha256": "9619e42d6b4c7f7b79f8798042e5aebdc50ed48cd24d93f5e22cff7a66036405" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.raw-control", + "input": { + "data": { + "encoding": "hex", + "hex": "221f22", + "length": 3, + "sha256": "1521d03826a6641b193920a8c097c44f6930e7cc48751ce16ba551e3f5afc5ea" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.float", + "input": { + "data": { + "encoding": "hex", + "hex": "312e30", + "length": 3, + "sha256": "d0ff5974b6aa52cf562bea5921840c032a860a91a3512f7fe8f768f6bbe005f6" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.exponent", + "input": { + "data": { + "encoding": "hex", + "hex": "316530", + "length": 3, + "sha256": "bcac40fbed4ed4e1042a0c9dafbe9cdd58447415f19a11e59add45fdc083e2f5" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.float-nested", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2261223a5b312e355d7d", + "length": 11, + "sha256": "f181a1f8be1008c718a4b13c9161d340ff9ad840b5bd7476a57a748f09fc1bb7" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.nan", + "input": { + "data": { + "encoding": "hex", + "hex": "4e614e", + "length": 3, + "sha256": "d5b592c05dc25b5032553f1b27f4139be95e881f73db33b02b05ab20c3f9981e" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.infinity", + "input": { + "data": { + "encoding": "hex", + "hex": "496e66696e697479", + "length": 8, + "sha256": "d0067cad9a63e0813759a2bb841051ca73570c0da2e08e840a8eb45db6a7a010" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.negative-infinity", + "input": { + "data": { + "encoding": "hex", + "hex": "2d496e66696e697479", + "length": 9, + "sha256": "c64ddf11bcd45660f0cf66dd0c22d2b4570ef3d3fc6527a9a6f6c722aefa3c39" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.plus", + "input": { + "data": { + "encoding": "hex", + "hex": "2b31", + "length": 2, + "sha256": "c59dc4e44ff99288156d4dff2168f6ac7ddee6b1fc7ccc0754656ffaa6d351ea" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.leading-zero", + "input": { + "data": { + "encoding": "hex", + "hex": "3031", + "length": 2, + "sha256": "938db8c9f82c8cb58d3f3ef4fd250036a48d26a712753d2fde5abd03a85cabf4" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.negative-leading-zero", + "input": { + "data": { + "encoding": "hex", + "hex": "2d3031", + "length": 3, + "sha256": "feba731c642f4032a06ddddcc422802c811a9d32ec28f63e367f3010fbb29ea6" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.bad-minus", + "input": { + "data": { + "encoding": "hex", + "hex": "2d", + "length": 1, + "sha256": "3973e022e93220f9212c18d0d0c543ae7c309e46640da93a4a0314de999f5112" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.bom", + "input": { + "data": { + "encoding": "hex", + "hex": "efbbbf7b7d", + "length": 5, + "sha256": "aa25e978046d680ef8740d837e6de5bc1e2a2dc6089dbda1012544b538d53f65" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.utf8-invalid", + "input": { + "data": { + "encoding": "hex", + "hex": "22ff22", + "length": 3, + "sha256": "2c1ba6ac713bfc21e74f3429be952fca3e7a796734394fd18a48eb6713880d89" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.utf8-truncated", + "input": { + "data": { + "encoding": "hex", + "hex": "22e28222", + "length": 4, + "sha256": "83cb8dd8849f887edb0a1419ab55d5ee5058b4bcc6c8e97dcc4afcda83ad3c95" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.utf8-overlong", + "input": { + "data": { + "encoding": "hex", + "hex": "22c08022", + "length": 4, + "sha256": "3152957e025ba54af72846da0e2759f93a45dcc9c2e2392bd5f9be63e95359bf" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.utf8-continuation", + "input": { + "data": { + "encoding": "hex", + "hex": "228022", + "length": 3, + "sha256": "0fd123b00f85e9b35ca0addf705a2f45e2b0b4a9944fe66457daeee1d4d16ca8" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.utf8-surrogate", + "input": { + "data": { + "encoding": "hex", + "hex": "22eda08022", + "length": 5, + "sha256": "ee4a76500d4d714ca691afe56c1fd9e0a5fbcb42d6c8f8d7178aae4ddb683cf7" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.utf8-out-of-range", + "input": { + "data": { + "encoding": "hex", + "hex": "22f490808022", + "length": 6, + "sha256": "a0b68d72ba326dc7655b0da4c65889da62eda33c43e5b8aa7abccc3e80658ea6" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.surrogate-high", + "input": { + "data": { + "encoding": "hex", + "hex": "225c756438303022", + "length": 8, + "sha256": "8c0c59dd0d275aadcd462a5fe12eb352cbdfeaf961eae4f85a4660521df7d2f5" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.surrogate-low", + "input": { + "data": { + "encoding": "hex", + "hex": "225c756463303022", + "length": 8, + "sha256": "353c7370beca95e64c258c908edac60c2ab30d355ca1b5b7fc31c5bce4a4c65a" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.surrogate-reversed", + "input": { + "data": { + "encoding": "hex", + "hex": "225c75646330305c756438303022", + "length": 14, + "sha256": "21b59caaa12b2965ce063778289864b88a44d120c1a47f639d6fb9f6c3ad7a78" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.surrogate-bad-pair", + "input": { + "data": { + "encoding": "hex", + "hex": "225c75643830307822", + "length": 9, + "sha256": "ed16755f1ed92058a4a7b869df2e56cc6d596e5ca5d1ff3e73a182e19779cad9" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.digits-4300", + "input": { + "data": { + "count": 4300, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "", + "sha256": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "suffix_hex": "", + "unit_hex": "31" + }, + "kind": "bytes" + }, + "note": "Independent anchor: 4300 ones, canonical length 4300 and SHA-256 5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "", + "sha256": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "", + "sha256": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.digits-4301", + "input": { + "data": { + "count": 4301, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "", + "sha256": "8afececf38e8946df9cff0bb388b1f2909aaa9f595a91450a1c0d69407c3ec36", + "suffix_hex": "", + "unit_hex": "31" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.digits-negative-4300", + "input": { + "data": { + "count": 4300, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "2d", + "sha256": "95d9863c61475d18ae195f74e0dc421fddaf3057de3f6b4e6db550448ce6740c", + "suffix_hex": "", + "unit_hex": "31" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "2d", + "sha256": "95d9863c61475d18ae195f74e0dc421fddaf3057de3f6b4e6db550448ce6740c", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "95d9863c61475d18ae195f74e0dc421fddaf3057de3f6b4e6db550448ce6740c", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "2d", + "sha256": "95d9863c61475d18ae195f74e0dc421fddaf3057de3f6b4e6db550448ce6740c", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "95d9863c61475d18ae195f74e0dc421fddaf3057de3f6b4e6db550448ce6740c", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.digits-negative-4301", + "input": { + "data": { + "count": 4301, + "encoding": "repeat", + "length": 4302, + "prefix_hex": "2d", + "sha256": "2fd1cbe6f99e6a187b6c3cb4124528a409448280725bf474e6d43b642f0d9335", + "suffix_hex": "", + "unit_hex": "31" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.bytes-max", + "input": { + "data": { + "count": 1048575, + "encoding": "repeat", + "length": 1048576, + "prefix_hex": "30", + "sha256": "72f82c5d541d5dbc6af026ab81f09c21606a20cd512252585601309a64d00809", + "suffix_hex": "", + "unit_hex": "20" + }, + "kind": "bytes" + }, + "note": "Exactly 1 MiB is accepted; input-byte identity includes trailing spaces.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "30", + "length": 1, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + "digest": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "30", + "length": 1, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + "digest": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.bytes-over", + "input": { + "data": { + "count": 1048576, + "encoding": "repeat", + "length": 1048577, + "prefix_hex": "30", + "sha256": "fcc6e4d3d6da8ed46735c269b8f4887ddbc19a41a651e0a55a663678507ca1ee", + "suffix_hex": "", + "unit_hex": "20" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "input-limit-exceeded", + "status": "reject" + }, + "source_expected": { + "code": "input-limit-exceeded", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.bytes-over-malformed", + "input": { + "data": { + "count": 1048577, + "encoding": "repeat", + "length": 1048577, + "prefix_hex": "", + "sha256": "97f1b26601b4e889c9c1fa0c9fbffccd15eb2900e3a326f53715dd578c72b0a7", + "suffix_hex": "", + "unit_hex": "ff" + }, + "kind": "bytes" + }, + "note": "Input byte-size rejection precedes UTF-8 and syntax validation.", + "operation": "parse-json", + "product_expected": { + "code": "input-limit-exceeded", + "status": "reject" + }, + "source_expected": { + "code": "input-limit-exceeded", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.depth-127", + "input": { + "data": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 255, + "sha256": "4a4b332ecdf55e888291f75aa4995102b57a3139a60317913bccac1ef2c2a6df" + }, + "kind": "bytes" + }, + "note": "Root container counts as depth one; source accepts 129, product deliberately rejects it.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 255, + "sha256": "4a4b332ecdf55e888291f75aa4995102b57a3139a60317913bccac1ef2c2a6df" + }, + "digest": "4a4b332ecdf55e888291f75aa4995102b57a3139a60317913bccac1ef2c2a6df", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 255, + "sha256": "4a4b332ecdf55e888291f75aa4995102b57a3139a60317913bccac1ef2c2a6df" + }, + "digest": "4a4b332ecdf55e888291f75aa4995102b57a3139a60317913bccac1ef2c2a6df", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.depth-128", + "input": { + "data": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 257, + "sha256": "a87f12d34d179c4dd058f06d205b96585bd84797eab5360fe32bc414d56028ba" + }, + "kind": "bytes" + }, + "note": "Root container counts as depth one; source accepts 129, product deliberately rejects it.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 257, + "sha256": "a87f12d34d179c4dd058f06d205b96585bd84797eab5360fe32bc414d56028ba" + }, + "digest": "a87f12d34d179c4dd058f06d205b96585bd84797eab5360fe32bc414d56028ba", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 257, + "sha256": "a87f12d34d179c4dd058f06d205b96585bd84797eab5360fe32bc414d56028ba" + }, + "digest": "a87f12d34d179c4dd058f06d205b96585bd84797eab5360fe32bc414d56028ba", + "status": "ok" + } + }, + { + "decision": "depth-128", + "id": "json.depth-129", + "input": { + "data": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 259, + "sha256": "e1b6a767efda695ed26993a0a7ddd323c19c0cc20f97685ee2a8e203e531f766" + }, + "kind": "bytes" + }, + "note": "Root container counts as depth one; source accepts 129, product deliberately rejects it.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b305d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d", + "length": 259, + "sha256": "e1b6a767efda695ed26993a0a7ddd323c19c0cc20f97685ee2a8e203e531f766" + }, + "digest": "e1b6a767efda695ed26993a0a7ddd323c19c0cc20f97685ee2a8e203e531f766", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.depth-string-markers", + "input": { + "data": { + "encoding": "hex", + "hex": "225b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d22", + "length": 402, + "sha256": "17fb12b44989dc9abf7912dc8d29a4453c6fa9adcfe2bc093105423fc3dad91f" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "225b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d22", + "length": 402, + "sha256": "17fb12b44989dc9abf7912dc8d29a4453c6fa9adcfe2bc093105423fc3dad91f" + }, + "digest": "17fb12b44989dc9abf7912dc8d29a4453c6fa9adcfe2bc093105423fc3dad91f", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "225b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d22", + "length": 402, + "sha256": "17fb12b44989dc9abf7912dc8d29a4453c6fa9adcfe2bc093105423fc3dad91f" + }, + "digest": "17fb12b44989dc9abf7912dc8d29a4453c6fa9adcfe2bc093105423fc3dad91f", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.type-text", + "input": { + "codepoints": [ + 123, + 125 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.type-null", + "input": { + "kind": "null" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.type-bytearray", + "input": { + "data": { + "encoding": "hex", + "hex": "7b7d", + "length": 2, + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "kind": "bytearray" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.4300-positive", + "input": { + "exponent": 4299, + "kind": "power10", + "negative": false + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-bytes", + "product_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "31", + "sha256": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "31", + "sha256": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "canonical.4300-negative", + "input": { + "exponent": 4299, + "kind": "power10", + "negative": true + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-bytes", + "product_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "2d31", + "sha256": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "2d31", + "sha256": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "canonical.4301-positive", + "input": { + "exponent": 4300, + "kind": "power10", + "negative": false + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.4301-negative", + "input": { + "exponent": 4300, + "kind": "power10", + "negative": true + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.depth-128", + "input": { + "depth": 128, + "kind": "nested", + "leaf": { + "decimal": "0", + "kind": "integer" + }, + "shape": "alternating" + }, + "note": "Mixed object/array containers count equally; source accepts 129.", + "operation": "canonical-bytes", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d", + "length": 513, + "sha256": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007" + }, + "digest": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d", + "length": 513, + "sha256": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007" + }, + "digest": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007", + "status": "ok" + } + }, + { + "decision": "depth-128", + "id": "canonical.depth-129", + "input": { + "depth": 129, + "kind": "nested", + "leaf": { + "decimal": "0", + "kind": "integer" + }, + "shape": "alternating" + }, + "note": "Mixed object/array containers count equally; source accepts 129.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d", + "length": 515, + "sha256": "a5a81b8764994c8043079826ccc06e53f1657d21b510b824f55736bf61d0fca5" + }, + "digest": "a5a81b8764994c8043079826ccc06e53f1657d21b510b824f55736bf61d0fca5", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "canonical.float", + "input": { + "kind": "float", + "value": "1.5" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.nan", + "input": { + "kind": "float", + "value": "nan" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.infinity", + "input": { + "kind": "float", + "value": "inf" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.negative-infinity", + "input": { + "kind": "float", + "value": "-inf" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.tuple", + "input": { + "items": [ + { + "decimal": "1", + "kind": "integer" + } + ], + "kind": "tuple" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.bytes", + "input": { + "data": { + "encoding": "hex", + "hex": "7b7d", + "length": 2, + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.surrogate-value", + "input": { + "codepoints": [ + 55296 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.nonstring-key", + "input": { + "entries": [ + { + "key": { + "decimal": "1", + "kind": "integer" + }, + "value": { + "codepoints": [ + 120 + ], + "kind": "string" + } + } + ], + "kind": "object" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.surrogate-key", + "input": { + "entries": [ + { + "key": { + "codepoints": [ + 55296 + ], + "kind": "string" + }, + "value": { + "decimal": "1", + "kind": "integer" + } + } + ], + "kind": "object" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.4300-positive", + "input": { + "exponent": 4299, + "kind": "power10", + "negative": false + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-digest", + "product_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "31", + "sha256": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "31", + "sha256": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "916d8027b50598a584c7e66ba229c3b0363c6ad352b31dbbe5e228676aa09f22", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "digest.4300-negative", + "input": { + "exponent": 4299, + "kind": "power10", + "negative": true + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-digest", + "product_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "2d31", + "sha256": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4299, + "encoding": "repeat", + "length": 4301, + "prefix_hex": "2d31", + "sha256": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "suffix_hex": "", + "unit_hex": "30" + }, + "digest": "5963a977c205e4573202db4eb2ce60553c9cd4fe1bc24b594b685db48e5144d7", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "digest.4301-positive", + "input": { + "exponent": 4300, + "kind": "power10", + "negative": false + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.4301-negative", + "input": { + "exponent": 4300, + "kind": "power10", + "negative": true + }, + "note": "Native bounded arithmetic constructs bigint before the selected source operation; sign is not a digit.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.depth-128", + "input": { + "depth": 128, + "kind": "nested", + "leaf": { + "decimal": "0", + "kind": "integer" + }, + "shape": "alternating" + }, + "note": "Mixed object/array containers count equally; source accepts 129.", + "operation": "canonical-digest", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d", + "length": 513, + "sha256": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007" + }, + "digest": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d", + "length": 513, + "sha256": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007" + }, + "digest": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007", + "status": "ok" + } + }, + { + "decision": "depth-128", + "id": "digest.depth-129", + "input": { + "depth": 129, + "kind": "nested", + "leaf": { + "decimal": "0", + "kind": "integer" + }, + "shape": "alternating" + }, + "note": "Mixed object/array containers count equally; source accepts 129.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d", + "length": 515, + "sha256": "a5a81b8764994c8043079826ccc06e53f1657d21b510b824f55736bf61d0fca5" + }, + "digest": "a5a81b8764994c8043079826ccc06e53f1657d21b510b824f55736bf61d0fca5", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "digest.float", + "input": { + "kind": "float", + "value": "1.5" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.nan", + "input": { + "kind": "float", + "value": "nan" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.infinity", + "input": { + "kind": "float", + "value": "inf" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.negative-infinity", + "input": { + "kind": "float", + "value": "-inf" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.tuple", + "input": { + "items": [ + { + "decimal": "1", + "kind": "integer" + } + ], + "kind": "tuple" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.bytes", + "input": { + "data": { + "encoding": "hex", + "hex": "7b7d", + "length": 2, + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.surrogate-value", + "input": { + "codepoints": [ + 55296 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.nonstring-key", + "input": { + "entries": [ + { + "key": { + "decimal": "1", + "kind": "integer" + }, + "value": { + "codepoints": [ + 120 + ], + "kind": "string" + } + } + ], + "kind": "object" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.surrogate-key", + "input": { + "entries": [ + { + "key": { + "codepoints": [ + 55296 + ], + "kind": "string" + }, + "value": { + "decimal": "1", + "kind": "integer" + } + } + ], + "kind": "object" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "canonical.no-byte-cap", + "input": { + "codepoint": 120, + "count": 1048577, + "kind": "repeat-string" + }, + "note": "Source canonical_bytes has no 1 MiB parse-input cap; product adds none.", + "operation": "canonical-bytes", + "product_expected": { + "canonical": { + "count": 1048577, + "encoding": "repeat", + "length": 1048579, + "prefix_hex": "22", + "sha256": "64f74e9ed2280e81efbcb5b8c487a60d3fa61164e446f0f1e541fbd0332c742a", + "suffix_hex": "22", + "unit_hex": "78" + }, + "digest": "64f74e9ed2280e81efbcb5b8c487a60d3fa61164e446f0f1e541fbd0332c742a", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 1048577, + "encoding": "repeat", + "length": 1048579, + "prefix_hex": "22", + "sha256": "64f74e9ed2280e81efbcb5b8c487a60d3fa61164e446f0f1e541fbd0332c742a", + "suffix_hex": "22", + "unit_hex": "78" + }, + "digest": "64f74e9ed2280e81efbcb5b8c487a60d3fa61164e446f0f1e541fbd0332c742a", + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.normal", + "input": { + "codepoints": [ + 97, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 47, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 47, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.dot-default", + "input": { + "codepoints": [ + 46 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.empty", + "input": { + "codepoints": [], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.absolute", + "input": { + "codepoints": [ + 47, + 97 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.double-leading", + "input": { + "codepoints": [ + 47, + 47, + 97 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.trailing-slash", + "input": { + "codepoints": [ + 97, + 47 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.empty-component", + "input": { + "codepoints": [ + 97, + 47, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.dot-component", + "input": { + "codepoints": [ + 97, + 47, + 46, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.parent", + "input": { + "codepoints": [ + 97, + 47, + 46, + 46, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.parent-leading", + "input": { + "codepoints": [ + 46, + 46, + 47, + 97 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.git-root", + "input": { + "codepoints": [ + 46, + 103, + 105, + 116 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.git-component", + "input": { + "codepoints": [ + 97, + 47, + 46, + 103, + 105, + 116, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.backslash", + "input": { + "codepoints": [ + 97, + 92, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.nul", + "input": { + "codepoints": [ + 97, + 0, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.cr", + "input": { + "codepoints": [ + 97, + 13, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.lf", + "input": { + "codepoints": [ + 97, + 10, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.tab", + "input": { + "codepoints": [ + 97, + 9, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.star", + "input": { + "codepoints": [ + 97, + 42, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.question", + "input": { + "codepoints": [ + 97, + 63, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.open-bracket", + "input": { + "codepoints": [ + 97, + 91, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.close-bracket", + "input": { + "codepoints": [ + 97, + 93, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.colon", + "input": { + "codepoints": [ + 67, + 58, + 120 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 67, + 58, + 120 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 67, + 58, + 120 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.drive-shaped", + "input": { + "codepoints": [ + 67, + 58, + 47, + 97 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 67, + 58, + 47, + 97 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 67, + 58, + 47, + 97 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.git-case", + "input": { + "codepoints": [ + 97, + 47, + 46, + 71, + 105, + 116, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 47, + 46, + 71, + 105, + 116, + 47, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 47, + 46, + 71, + 105, + 116, + 47, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.space", + "input": { + "codepoints": [ + 97, + 32, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 32, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 32, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.padded-space", + "input": { + "codepoints": [ + 32, + 97, + 32 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 32, + 97, + 32 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 32, + 97, + 32 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.percent", + "input": { + "codepoints": [ + 97, + 47, + 37, + 50, + 101, + 37, + 50, + 101, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 47, + 37, + 50, + 101, + 37, + 50, + 101, + 47, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 47, + 37, + 50, + 101, + 37, + 50, + 101, + 47, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.tilde", + "input": { + "codepoints": [ + 97, + 47, + 126, + 47, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 47, + 126, + 47, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 47, + 126, + 47, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.escape-control", + "input": { + "codepoints": [ + 97, + 27, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 27, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 27, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.other-control", + "input": { + "codepoints": [ + 97, + 1, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 1, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 1, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.line-separator", + "input": { + "codepoints": [ + 97, + 8232, + 98 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97, + 8232, + 98 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97, + 8232, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.unicode", + "input": { + "codepoints": [ + 233, + 47, + 128512 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 233, + 47, + 128512 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 233, + 47, + 128512 + ], + "status": "ok" + } + }, + { + "decision": "scalar-path", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.surrogate", + "input": { + "codepoints": [ + 97, + 55296, + 98 + ], + "kind": "string" + }, + "note": "Source accepts lone surrogates; product path inputs are Unicode scalar strings.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 97, + 55296, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": true + }, + "id": "path.dot-true", + "input": { + "codepoints": [ + 46 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 46 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 46 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": true + }, + "id": "path.normal-dot-true", + "input": { + "codepoints": [ + 97 + ], + "kind": "string" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "codepoints": [ + 97 + ], + "status": "ok" + }, + "source_expected": { + "codepoints": [ + 97 + ], + "status": "ok" + } + }, + { + "decision": "boolean-dot", + "dot": { + "decimal": "1", + "kind": "integer" + }, + "id": "path.dot-integer", + "input": { + "codepoints": [ + 46 + ], + "kind": "string" + }, + "note": "Exact boolean dot is a deliberate product restriction, including non-dot paths.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 46 + ], + "status": "ok" + } + }, + { + "decision": "boolean-dot", + "dot": { + "decimal": "1", + "kind": "integer" + }, + "id": "path.normal-dot-integer", + "input": { + "codepoints": [ + 97 + ], + "kind": "string" + }, + "note": "Exact boolean dot is a deliberate product restriction, including non-dot paths.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 97 + ], + "status": "ok" + } + }, + { + "decision": "boolean-dot", + "dot": { + "kind": "null" + }, + "id": "path.normal-dot-null", + "input": { + "codepoints": [ + 97 + ], + "kind": "string" + }, + "note": "Exact boolean dot is a deliberate product restriction, including non-dot paths.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 97 + ], + "status": "ok" + } + }, + { + "decision": "boolean-dot", + "dot": { + "codepoints": [ + 121, + 101, + 115 + ], + "kind": "string" + }, + "id": "path.normal-dot-text", + "input": { + "codepoints": [ + 97 + ], + "kind": "string" + }, + "note": "Exact boolean dot is a deliberate product restriction, including non-dot paths.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 97 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.type-null", + "input": { + "kind": "null" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.type-integer", + "input": { + "decimal": "1", + "kind": "integer" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.type-bytes", + "input": { + "data": { + "encoding": "hex", + "hex": "61", + "length": 1, + "sha256": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + }, + "kind": "bytes" + }, + "note": "Pinned source classification and exact canonical bytes.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "code": "context_schema", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.depth-object-128", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a307d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d", + "length": 769, + "sha256": "a388f22d84ccc3c1efb76115a3a0af2e01ae9815c44c1adbcdeb0382fdd0bc92" + }, + "kind": "bytes" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a307d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d", + "length": 769, + "sha256": "a388f22d84ccc3c1efb76115a3a0af2e01ae9815c44c1adbcdeb0382fdd0bc92" + }, + "digest": "a388f22d84ccc3c1efb76115a3a0af2e01ae9815c44c1adbcdeb0382fdd0bc92", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a307d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d", + "length": 769, + "sha256": "a388f22d84ccc3c1efb76115a3a0af2e01ae9815c44c1adbcdeb0382fdd0bc92" + }, + "digest": "a388f22d84ccc3c1efb76115a3a0af2e01ae9815c44c1adbcdeb0382fdd0bc92", + "status": "ok" + } + }, + { + "decision": "depth-128", + "id": "json.depth-object-129", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a307d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d", + "length": 775, + "sha256": "027bb257fe07337629dd5091d64a067eb2d9262df3567580b216bf4be388c04a" + }, + "kind": "bytes" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a7b2278223a307d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d", + "length": 775, + "sha256": "027bb257fe07337629dd5091d64a067eb2d9262df3567580b216bf4be388c04a" + }, + "digest": "027bb257fe07337629dd5091d64a067eb2d9262df3567580b216bf4be388c04a", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.depth-alternating-128", + "input": { + "data": { + "encoding": "hex", + "hex": "7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d", + "length": 513, + "sha256": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007" + }, + "kind": "bytes" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "parse-json", + "product_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d", + "length": 513, + "sha256": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007" + }, + "digest": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007", + "status": "ok" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d", + "length": 513, + "sha256": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007" + }, + "digest": "4e5e7de7972a8b9318160a5dfed8ff16b808339ab9168801a98dd804f8ae7007", + "status": "ok" + } + }, + { + "decision": "depth-128", + "id": "json.depth-alternating-129", + "input": { + "data": { + "encoding": "hex", + "hex": "5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d", + "length": 515, + "sha256": "a5a81b8764994c8043079826ccc06e53f1657d21b510b824f55736bf61d0fca5" + }, + "kind": "bytes" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "canonical": { + "encoding": "hex", + "hex": "5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b7b2278223a5b305d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d7d5d", + "length": 515, + "sha256": "a5a81b8764994c8043079826ccc06e53f1657d21b510b824f55736bf61d0fca5" + }, + "digest": "a5a81b8764994c8043079826ccc06e53f1657d21b510b824f55736bf61d0fca5", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "canonical.decimal-chunks-4300", + "input": { + "decimal": "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "kind": "integer" + }, + "note": "Chunk constructor reaches actual oracle for an oversized native decimal integer.", + "operation": "canonical-bytes", + "product_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "", + "sha256": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "", + "sha256": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "canonical.decimal-chunks-4301", + "input": { + "decimal": "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "kind": "integer" + }, + "note": "Chunk constructor reaches actual oracle for an oversized native decimal integer.", + "operation": "canonical-bytes", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "digest.decimal-chunks-4300", + "input": { + "decimal": "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "kind": "integer" + }, + "note": "Chunk constructor reaches actual oracle for an oversized native decimal integer.", + "operation": "canonical-digest", + "product_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "", + "sha256": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "status": "ok" + }, + "source_expected": { + "canonical": { + "count": 4300, + "encoding": "repeat", + "length": 4300, + "prefix_hex": "", + "sha256": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "suffix_hex": "", + "unit_hex": "31" + }, + "digest": "5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950", + "status": "ok" + } + }, + { + "decision": "parity", + "id": "digest.decimal-chunks-4301", + "input": { + "decimal": "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "kind": "integer" + }, + "note": "Chunk constructor reaches actual oracle for an oversized native decimal integer.", + "operation": "canonical-digest", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + }, + { + "decision": "boolean-dot", + "dot": { + "decimal": "0", + "kind": "integer" + }, + "id": "path.normal-dot-zero", + "input": { + "codepoints": [ + 97 + ], + "kind": "string" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 97 + ], + "status": "ok" + } + }, + { + "decision": "boolean-dot", + "dot": { + "codepoints": [], + "kind": "string" + }, + "id": "path.normal-dot-empty-text", + "input": { + "codepoints": [ + 97 + ], + "kind": "string" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 97 + ], + "status": "ok" + } + }, + { + "decision": "boolean-dot", + "dot": { + "kind": "float", + "value": "1.5" + }, + "id": "path.dot-float", + "input": { + "codepoints": [ + 46 + ], + "kind": "string" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 46 + ], + "status": "ok" + } + }, + { + "decision": "scalar-path", + "dot": { + "kind": "boolean", + "value": false + }, + "id": "path.surrogate-low", + "input": { + "codepoints": [ + 97, + 56320, + 98 + ], + "kind": "string" + }, + "note": "Additional independently inspectable boundary case.", + "operation": "canonical-path", + "product_expected": { + "code": "context_schema", + "status": "reject" + }, + "source_expected": { + "codepoints": [ + 97, + 56320, + 98 + ], + "status": "ok" + } + }, + { + "decision": "parity", + "id": "json.utf8-byte-size", + "input": { + "data": { + "count": 524288, + "encoding": "repeat", + "length": 1048578, + "prefix_hex": "22", + "sha256": "1e83320962a680a1d9d46f59d05210d5a3e3b1a7a5ef4640fd0ba5dbe2a7cfc5", + "suffix_hex": "22", + "unit_hex": "c3a9" + }, + "kind": "bytes" + }, + "note": "Limit counts UTF-8 bytes, not decoded characters.", + "operation": "parse-json", + "product_expected": { + "code": "input-limit-exceeded", + "status": "reject" + }, + "source_expected": { + "code": "input-limit-exceeded", + "status": "reject" + } + }, + { + "decision": "parity", + "id": "json.type-bytearray-over", + "input": { + "data": { + "count": 1048576, + "encoding": "repeat", + "length": 1048577, + "prefix_hex": "30", + "sha256": "fcc6e4d3d6da8ed46735c269b8f4887ddbc19a41a651e0a55a663678507ca1ee", + "suffix_hex": "", + "unit_hex": "20" + }, + "kind": "bytearray" + }, + "note": "Wrong native type is invalid-json before input byte-size inspection.", + "operation": "parse-json", + "product_expected": { + "code": "invalid-json", + "status": "reject" + }, + "source_expected": { + "code": "invalid-json", + "status": "reject" + } + } + ], + "schema_version": 1, + "source_revision": "fd0019b7711438a2dbf7255ce3a43572d5a3a6fd" +} diff --git a/contracts/migration/vectors.schema.json b/contracts/migration/vectors.schema.json new file mode 100644 index 00000000..d176a629 --- /dev/null +++ b/contracts/migration/vectors.schema.json @@ -0,0 +1,507 @@ +{ + "$defs": { + "bytes": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "encoding": { + "const": "hex" + }, + "hex": { + "maxLength": 4194304, + "pattern": "(?:[0-9a-f]{2})*", + "type": "string" + }, + "length": { + "maximum": 2097152, + "minimum": 0, + "type": "integer" + }, + "sha256": { + "maxLength": 64, + "pattern": "[0-9a-f]{64}", + "type": "string" + } + }, + "required": [ + "encoding", + "hex", + "length", + "sha256" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "count": { + "maximum": 2097152, + "minimum": 0, + "type": "integer" + }, + "encoding": { + "const": "repeat" + }, + "length": { + "maximum": 2097152, + "minimum": 0, + "type": "integer" + }, + "prefix_hex": { + "maxLength": 4194304, + "pattern": "(?:[0-9a-f]{2})*", + "type": "string" + }, + "sha256": { + "maxLength": 64, + "pattern": "[0-9a-f]{64}", + "type": "string" + }, + "suffix_hex": { + "maxLength": 4194304, + "pattern": "(?:[0-9a-f]{2})*", + "type": "string" + }, + "unit_hex": { + "maxLength": 4194304, + "pattern": "(?:[0-9a-f]{2})+", + "type": "string" + } + }, + "required": [ + "encoding", + "prefix_hex", + "unit_hex", + "count", + "suffix_hex", + "length", + "sha256" + ], + "type": "object" + } + ] + }, + "case": { + "additionalProperties": false, + "properties": { + "decision": { + "enum": [ + "parity", + "depth-128", + "scalar-path", + "boolean-dot" + ] + }, + "dot": { + "$ref": "#/$defs/value" + }, + "id": { + "maxLength": 80, + "pattern": "[a-z0-9][a-z0-9.-]{0,79}", + "type": "string" + }, + "input": { + "$ref": "#/$defs/value" + }, + "note": { + "maxLength": 512, + "type": "string" + }, + "operation": { + "enum": [ + "parse-json", + "canonical-bytes", + "canonical-digest", + "canonical-path" + ] + }, + "product_expected": { + "$ref": "#/$defs/expected" + }, + "source_expected": { + "$ref": "#/$defs/expected" + } + }, + "required": [ + "id", + "operation", + "input", + "source_expected", + "product_expected", + "decision", + "note" + ], + "type": "object" + }, + "expected": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "invalid-json", + "input-limit-exceeded", + "context_schema" + ] + }, + "status": { + "const": "reject" + } + }, + "required": [ + "status", + "code" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "canonical": { + "$ref": "#/$defs/bytes" + }, + "digest": { + "maxLength": 64, + "pattern": "[0-9a-f]{64}", + "type": "string" + }, + "status": { + "const": "ok" + } + }, + "required": [ + "status", + "canonical", + "digest" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "codepoints": { + "items": { + "maximum": 1114111, + "minimum": 0, + "type": "integer" + }, + "maxItems": 8192, + "type": "array" + }, + "status": { + "const": "ok" + } + }, + "required": [ + "status", + "codepoints" + ], + "type": "object" + } + ] + }, + "value": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "null" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "boolean" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "decimal": { + "maxLength": 5001, + "pattern": "-?(0|[1-9][0-9]*)", + "type": "string" + }, + "kind": { + "const": "integer" + } + }, + "required": [ + "kind", + "decimal" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "exponent": { + "maximum": 5000, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "power10" + }, + "negative": { + "type": "boolean" + } + }, + "required": [ + "kind", + "exponent", + "negative" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "codepoints": { + "items": { + "maximum": 1114111, + "minimum": 0, + "type": "integer" + }, + "maxItems": 8192, + "type": "array" + }, + "kind": { + "const": "string" + } + }, + "required": [ + "kind", + "codepoints" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "codepoint": { + "maximum": 127, + "minimum": 0, + "type": "integer" + }, + "count": { + "maximum": 1048577, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "repeat-string" + } + }, + "required": [ + "kind", + "codepoint", + "count" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "float" + }, + "value": { + "enum": [ + "1.5", + "nan", + "inf", + "-inf" + ] + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/$defs/bytes" + }, + "kind": { + "const": "bytes" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/$defs/bytes" + }, + "kind": { + "const": "bytearray" + } + }, + "required": [ + "kind", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/$defs/value" + }, + "maxItems": 256, + "type": "array" + }, + "kind": { + "const": "list" + } + }, + "required": [ + "kind", + "items" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/$defs/value" + }, + "maxItems": 256, + "type": "array" + }, + "kind": { + "const": "tuple" + } + }, + "required": [ + "kind", + "items" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entries": { + "items": { + "additionalProperties": false, + "properties": { + "key": { + "$ref": "#/$defs/value" + }, + "value": { + "$ref": "#/$defs/value" + } + }, + "required": [ + "key", + "value" + ], + "type": "object" + }, + "maxItems": 256, + "type": "array" + }, + "kind": { + "const": "object" + } + }, + "required": [ + "kind", + "entries" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "depth": { + "maximum": 256, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "nested" + }, + "leaf": { + "$ref": "#/$defs/value" + }, + "shape": { + "enum": [ + "array", + "object", + "alternating" + ] + } + }, + "required": [ + "kind", + "shape", + "depth", + "leaf" + ], + "type": "object" + } + ] + } + }, + "$id": "https://workharbor.invalid/contracts/migration/vectors.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "cases": { + "items": { + "$ref": "#/$defs/case" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "schema_version": { + "const": 1, + "type": "integer" + }, + "source_revision": { + "const": "fd0019b7711438a2dbf7255ce3a43572d5a3a6fd" + } + }, + "required": [ + "schema_version", + "source_revision", + "cases" + ], + "title": "WorkHarbor selected migration vectors v1", + "type": "object" +} diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 90a80412..cadfffa8 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -505,7 +505,22 @@ "test_check_ci.SourceTests.test_application_failure_blocks_even_when_all_python_checks_pass", "test_check_ci.SourceTests.test_captured_output_keeps_raw_bytes_and_refuses_existing_file_before_spawn", "test_application_checks.ApplicationChecksTests.test_concurrent_nested_packages_keep_exact_test_ownership", - "test_check_ci.SourceTests.test_python3_children_use_selected_interpreter_and_path_restores_on_exception" + "test_check_ci.SourceTests.test_python3_children_use_selected_interpreter_and_path_restores_on_exception", + "test_go_vectors.GoVectorTests.test_all_frozen_cases_reproduce_byte_for_byte_without_writes", + "test_go_vectors.GoVectorTests.test_bigints_reach_canonical_and_digest_oracles_before_rejection", + "test_go_vectors.GoVectorTests.test_byte_size_precedence_and_canonicalization_without_byte_cap", + "test_go_vectors.GoVectorTests.test_candidate_generation_never_overwrites_accepted_or_existing_files", + "test_go_vectors.GoVectorTests.test_changed_expected_bytes_fail_verification_and_only_external_candidate_repairs", + "test_go_vectors.GoVectorTests.test_compact_inputs_check_length_hash_and_expansion_bounds", + "test_go_vectors.GoVectorTests.test_default_cli_is_offline_read_only_and_reports_runtime_evidence", + "test_go_vectors.GoVectorTests.test_hand_inspected_unicode_html_boolean_and_zero_anchors", + "test_go_vectors.GoVectorTests.test_native_construction_failure_is_not_attributed_to_source", + "test_go_vectors.GoVectorTests.test_oracle_namespace_contains_only_selected_behavior", + "test_go_vectors.GoVectorTests.test_runtime_limits_are_explicit_and_restored", + "test_go_vectors.GoVectorTests.test_schema_rejects_unknown_fields_types_options_ids_and_operations", + "test_go_vectors.GoVectorTests.test_snapshot_manifest_tool_and_schema_tampering_are_rejected", + "test_go_vectors.GoVectorTests.test_source_and_product_domains_remain_distinct", + "test_go_vectors.GoVectorTests.test_strict_fixture_loader_rejects_duplicate_keys_constants_utf8_and_size" ], "retained_assertion_failures": [], "application_tests": { diff --git a/docs/development/harness.md b/docs/development/harness.md index 798c833c..0c3e67be 100644 --- a/docs/development/harness.md +++ b/docs/development/harness.md @@ -148,3 +148,14 @@ The prepublication source pin also covers the package, state traversal and application-gate helper dependencies; it continues to identify code rather than establish owner approval. No prerequisites, network services or real feature execution are used by these fixtures. + +## Frozen migration vectors + +Feature 006 T003 adds `test_go_vectors.GoVectorTests` as required Python controls +without changing runner behavior or retained-failure policy. The tests reproduce +all frozen cases offline and reject source/tool/schema drift, malformed/oversized +fixtures, incorrect byte hashes and unsafe candidate destinations. +See [the migration contract](../../contracts/migration/README.md) for the standalone +`python3.14 -I -B scripts/generate_go_vectors.py` verification and explicit external +candidate procedure. No accepted fixtures are automatically regenerated, and no +Python dependency enters product code. Independent Go parity is T004. diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 4001b37b..7d61899b 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -11,7 +11,8 @@ Status: independent Sol planning review cleared `602ae81c63be52ccb2ee18aa9802a50d3e35260d`, and the internal-pilot/release ordering correction cleared `0c2c135d8d82d8ed708095f7f1596760df38696b`. T001 scaffold is accepted after independent Sol CLEAR of `d3d84c8361a1389292fad96d02a828ac9fabc2e3`. -T002 is implemented and under validation; its independent review remains pending. +T002 is independently accepted at `bc18034164597aa841bea561cf6a882e9c0715e2`. +T003 preparation is being implemented and remains unchecked pending independent review. No product runtime acceptance is claimed. Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; @@ -311,3 +312,93 @@ including every previously failing positive path and all unchanged negative guar The 61 direct CI/application controls also passed after repair. Repository and whitespace checks pass. The fixture-only correction is folded into the T002 candidate for fresh exact-head independent review and coherent full acceptance. + + +## T002 final independent CLEAR and coherent acceptance + +Independent Sol review cleared exact candidate +`bc18034164597aa841bea561cf6a882e9c0715e2`. Its final canonical +`make check PYTHON=/opt/homebrew/bin/python3.14` exited 0 in 1304.597s: +raw 411 tests GREEN in 413.799s; qualified 831 tests GREEN in 856.364s; +all three fresh Go format/vet/build/test gates passed (whr 1, host agent 1, +API client doc-only 0), followed by Phoenix format, warnings-as-errors compile, +assets and all five ExUnit cases. The same independent reviewer verified cleanup: +private execution worktree removed and owned check process group absent. +Logs: `/private/tmp/workharbor-t002-canonical-bc18034.log` and adjacent `.json`. +These local logs are execution evidence, not published artifacts. + +The complete earlier RED/interrupted history above is retained: `3900757` raw +one failure/two errors from an old child interpreter, qualified interrupted; +`345972d` raw GREEN but qualified 19 failure records/one error from two stale +real-checker fixture copy lists, with actual application gates GREEN. Both causes +were fixed without waivers and the final coherent run passed. Feature 006 T002 is +now checked. Original Feature 001 task markers remain untouched. Linux CI and +real-host/browser/install/release acceptance remain unexecuted. + +## T003 selected oracle contracts — author candidate, review pending + +Prerequisite: exact T002 CLEAR above. Scope is only selected behavior inventory, +full pinned source snapshots/provenance, strict bounded self-contained vectors, +`scripts/generate_go_vectors.py`, meaningful negatives in `tests/test_go_vectors.py`, +their required IDs in `docs/development/ci-acceptance.json`, contract/harness links +and this evidence. The owner-authorized intermediate Sol review route permits the +reviewed profile choices; final owner review is reserved for the combined PR. +No Go product code, caller, dependency or runtime is changed. + +Fifteen new controls were written before behavior: all 15 initially errored on the +absent generator (0.003s). The first implemented run passed 14 but exposed one test +setup inconsistency: changing only source_expected while labeling parity makes the +fixture invalid before candidate generation. The tamper control now changes both +expected digests equally, retaining a valid parity classification while proving +verification fails and only the explicit external candidate receives repaired +bytes. No validator was relaxed. All 15 then passed in 0.924s; initial 145 cases +reproduced byte-for-byte. Additional object/mixed-depth, decimal-chunk, Unicode +byte-size and option-domain cases are included in the final focused checks below. + +Source snapshots exactly match commit +`fd0019b7711438a2dbf7255ce3a43572d5a3a6fd` and their original Git blob/SHA-256 +identities. Only the JSON helper closure plus json/hashlib and the path helper +closure with no imports execute. Release provenance records CPython 3.14.8 and +explicit 4300-digit/1000-recursion settings. Actual macOS executable SHA-256 +`1b5cb11d163e5ba34e2a05fc533c0ed89574d4425f9749a9d89405f651980e0d` +is per-run evidence, not a portable fixture precondition. No upstream archive +checksum or Linux reproduction is claimed. No source-linked resource is fetched. + +Independent Sol anchors supplied before freezing cover -0, Unicode scalar key +ordering, literal HTML/U+2028/U+2029, boolean/integer separation and 4300 decimal +ones. Product depth 128, Unicode scalar paths and exact-boolean dot are explicit +restrictions with source-success/product-rejection pairs. Native oversized integers +reach the real canonical/digest oracle through bounded arithmetic/chunk construction. +The decimal limit covers parsing, direct canonicalization and digest equally. + +T003 remains unchecked until independent review of the committed exact candidate. +T004 Go implementation/caller parity and all later runtime acceptance remain open. +The complete canonical T002 result is prior-revision evidence, not a full-suite +claim for this new candidate. No new runner behavior is introduced; focused +contract/manifest/source/repository checks are recorded separately below. + + +Final author focused checks for the T003 candidate: + +- `python3.14 -I -B -m unittest discover -s tests -p test_go_vectors.py -v`: + 15 controls GREEN in 0.967s after the complete 159-case inventory. +- `python3.14 -I -B scripts/generate_go_vectors.py`: all 159 cases reproduced + byte-for-byte. The self-contained fixture is 163,819 bytes: 78 parse, 18 direct + canonical, 17 direct digest and 46 path cases. Fourteen explicit restriction + pairs retain source success/product rejection; 145 cases retain parity. +- Trusted checker `load_manifest`/`collect`/`validate_collection`: 520 required + IDs present among 846 discovered tests. All 505 prior required IDs are retained + in their original order; retained-failure and application inventories are + unchanged. This is collection validation, not an execution of those 846 tests. +- Both source snapshots compared byte-for-byte with `git show` at the pinned + original commit. Both added Python files compile through builtin `compile` + without writing bytecode. Original Feature 001 tasks diff is empty. +- `python3.14 -I -B scripts/check_repo.py` and `git diff --check` pass. + Staged checks and post-commit source identity are checked during handoff. + +Native write permission was requested for the authorized WorkHarbor paths; no +sandbox workaround or global configuration change was used. Standalone candidate +mode and default read-only mode are exercised by the focused controls. No runtime +Python/AI orchestration dependency was added to product code. Full new-head +canonical acceptance is not claimed; the final combined pilot candidate still +requires its own complete canonical and runtime acceptance. diff --git a/scripts/generate_go_vectors.py b/scripts/generate_go_vectors.py new file mode 100644 index 00000000..21c1dadd --- /dev/null +++ b/scripts/generate_go_vectors.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Offline selected-source oracle; default verification never updates vectors.""" +# SPDX-License-Identifier: EUPL-1.2 +import argparse +import ast +from contextlib import contextmanager +import copy +import hashlib +import itertools +import json +from pathlib import Path +import platform +import re +import signal +import sys + +PIN = 'fd0019b7711438a2dbf7255ce3a43572d5a3a6fd' +ROOT = Path(__file__).resolve().parents[1] +DIRECTORY = Path('contracts/migration') +MAX_DOCUMENT = 8 * 1024 * 1024 +MAX_BYTES = 2 * 1024 * 1024 +MAX_CASES = 256 +SOURCES = [ + dict(profile='json', original='scripts/provider_profiles.py', snapshot='oracle/provider_profiles.py', + revision=PIN, blob='9f5f1e0379c76ed7a704470c787abb0aae9e221a', + sha256='c199ba25ff55c87474d18a6ccd5e0c03ff529f01678246eea76d566ae27a1026', + license='EUPL-1.2', imports=['hashlib', 'json'], + definitions=['MAX_JSON_BYTES', 'ProfileError', '_json_value', '_unique_object', + '_reject_number', 'parse_json', 'canonical_bytes', 'canonical_digest']), + dict(profile='path', original='scripts/single_task_context.py', snapshot='oracle/single_task_context.py', + revision=PIN, blob='d510d9d0b7fd72b0dd18699a590917e2d45d8b47', + sha256='87acb4351f4b51e2585228315a36832b4a08ba6ff26bed80698a878dd60c9164', + license='EUPL-1.2', imports=[], + definitions=['ContextError', '_require', '_text', 'canonical_path']), +] +RUNTIME = dict(implementation='cpython', version='3.14.8', int_max_str_digits=4300, + recursion_limit=1000, release='CPython v3.14.8', + release_reference='https://github.com/python/cpython/tree/v3.14.8', + provenance='Exact .tool-versions pin and observed CPython sys.version; upstream archive digest not claimed.') + + +class ContractError(ValueError): + """Fixed diagnostics never echo fixture content or native exceptions.""" + + +def require(condition, code='invalid migration contract'): + if not condition: + raise ContractError(code) + + +def read_bounded(path, limit=MAX_DOCUMENT): + path = Path(path) + require(not path.is_symlink() and path.is_file(), 'expected regular contract file') + with path.open('rb') as stream: + data = stream.read(limit + 1) + require(len(data) <= limit, 'contract file limit exceeded') + return data + + +def strict_json(raw): + require(type(raw) is bytes and len(raw) <= MAX_DOCUMENT, 'fixture document limit exceeded') + def unique(pairs): + value = {} + for key, item in pairs: + require(key not in value, 'duplicate fixture key') + value[key] = item + return value + def constant(unused): + raise ContractError('nonfinite fixture number') + try: + return json.loads(raw.decode('utf-8'), object_pairs_hook=unique, parse_constant=constant) + except (ValueError, UnicodeError, RecursionError): + raise ContractError('invalid fixture JSON') from None + + +def render(value): + return (json.dumps(value, sort_keys=True, ensure_ascii=True, indent=2) + '\n').encode('ascii') + + +def validate(value, schema, root=None, level=0): + """Small stdlib validator for the exact checked-in JSON Schema keyword subset.""" + require(level <= 64, 'fixture schema depth exceeded') + root = schema if root is None else root + supported = {'$schema', '$id', '$defs', '$ref', 'title', 'description', 'type', 'const', 'enum', + 'oneOf', 'properties', 'required', 'additionalProperties', 'items', + 'minItems', 'maxItems', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum'} + require(type(schema) is dict and not set(schema) - supported, 'unsupported fixture schema') + if '$ref' in schema: + ref = schema['$ref'] + require(type(ref) is str and re.fullmatch(r'#/\$defs/[A-Za-z0-9_]+', ref), 'nonlocal schema reference') + require(ref[8:] in root['$defs'], 'missing schema reference') + validate(value, root['$defs'][ref[8:]], root, level + 1) + return + if 'oneOf' in schema: + matches = 0 + for option in schema['oneOf']: + try: + validate(value, option, root, level + 1) + matches += 1 + except ContractError: + pass + require(matches == 1, 'fixture schema alternative mismatch') + types = {'object': dict, 'array': list, 'string': str, 'integer': int, 'boolean': bool, 'null': type(None)} + if 'type' in schema: + require(schema['type'] in types and type(value) is types[schema['type']], 'fixture type mismatch') + if 'const' in schema: + require(type(value) is type(schema['const']) and value == schema['const'], 'fixture constant mismatch') + if 'enum' in schema: + require(any(type(value) is type(item) and value == item for item in schema['enum']), 'fixture enum mismatch') + if type(value) is dict: + properties = schema.get('properties', {}) + require(set(schema.get('required', [])) <= set(value), 'missing fixture field') + if schema.get('additionalProperties') is False: + require(set(value) <= set(properties), 'unknown fixture field') + for key, item in value.items(): + if key in properties: + validate(item, properties[key], root, level + 1) + if type(value) is list: + require(schema.get('minItems', 0) <= len(value) <= schema.get('maxItems', 100000), 'fixture item limit') + if 'items' in schema: + for item in value: + validate(item, schema['items'], root, level + 1) + if type(value) is str: + require(schema.get('minLength', 0) <= len(value) <= schema.get('maxLength', MAX_DOCUMENT), 'fixture string limit') + if 'pattern' in schema: + require(re.fullmatch(schema['pattern'], value) is not None, 'fixture string pattern') + if type(value) is int: + require(schema.get('minimum', -sys.maxsize) <= value <= schema.get('maximum', sys.maxsize), 'fixture numeric limit') + + +def validate_vectors(value, schema): + validate(value, schema) + ids = [case['id'] for case in value['cases']] + require(len(ids) == len(set(ids)), 'duplicate vector identity') + for case in value['cases']: + require(('dot' in case) == (case['operation'] == 'canonical-path'), 'operation option mismatch') + for result in (case['source_expected'], case['product_expected']): + if result['status'] == 'ok': + require(('codepoints' in result) == (case['operation'] == 'canonical-path'), 'operation result mismatch') + else: + allowed = ('context_schema',) if case['operation'] == 'canonical-path' else ('invalid-json', 'input-limit-exceeded') + require(result['code'] in allowed, 'operation rejection mismatch') + allowed_decisions = ('parity', 'scalar-path', 'boolean-dot') if case['operation'] == 'canonical-path' else ('parity', 'depth-128') + require(case['decision'] in allowed_decisions, 'operation decision mismatch') + equal = case['source_expected'] == case['product_expected'] + require(equal == (case['decision'] == 'parity'), 'source/product decision mismatch') + if not equal: + require(case['source_expected']['status'] == 'ok' and case['product_expected']['status'] == 'reject', + 'invalid restriction observation') + + +def load_contract(root=ROOT): + directory = Path(root)/DIRECTORY + manifest = strict_json(read_bounded(directory/'manifest.json', 65536)) + require(type(manifest) is dict and set(manifest) == {'schema_version', 'sources', 'runtime', 'files'}, + 'invalid provenance fields') + require(type(manifest['schema_version']) is int and manifest['schema_version'] == 1, 'invalid provenance version') + require(manifest['sources'] == SOURCES and manifest['runtime'] == RUNTIME, 'provenance identity mismatch') + files = manifest['files'] + require(type(files) is dict and set(files) == {'vectors.schema.json', 'vectors.json', 'scripts/generate_go_vectors.py'}, + 'invalid provenance file inventory') + require(all(type(value) is str and re.fullmatch('[0-9a-f]{64}', value) for value in files.values()), + 'invalid provenance digest') + verify_tools(root, manifest) + schema = strict_json(read_bounded(directory/'vectors.schema.json')) + vectors = strict_json(read_bounded(directory/'vectors.json')) + validate_vectors(vectors, schema) + return manifest, schema, vectors + + +def verify_tools(root, manifest): + for name in ('vectors.schema.json', 'scripts/generate_go_vectors.py'): + path = Path(root)/name if name.startswith('scripts/') else Path(root)/DIRECTORY/name + require(hashlib.sha256(read_bounded(path)).hexdigest() == manifest['files'][name], 'tool/schema digest mismatch') + + +def load_oracles(root=ROOT, manifest=None): + if manifest is None: + manifest, unused, unused2 = load_contract(root) + require(manifest['sources'] == SOURCES, 'source provenance mismatch') + result = {} + for source in SOURCES: + raw = read_bounded(Path(root)/DIRECTORY/source['snapshot'], 512 * 1024) + require(hashlib.sha256(raw).hexdigest() == source['sha256'], 'oracle source digest mismatch') + blob = b'blob ' + str(len(raw)).encode('ascii') + b'\0' + raw + require(hashlib.sha1(blob).hexdigest() == source['blob'], 'oracle Git blob mismatch') + tree = ast.parse(raw, filename=source['original']) + selected, names, imports = [], [], [] + for node in tree.body: + if isinstance(node, ast.Import): + if len(node.names) == 1 and node.names[0].name in source['imports'] and node.names[0].asname is None: + selected.append(node); imports.append(node.names[0].name) + elif isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in source['definitions']: + selected.append(node); names.append(node.name) + elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + if node.targets[0].id in source['definitions']: + selected.append(node); names.append(node.targets[0].id) + require(set(names) == set(source['definitions']) and len(names) == len(source['definitions']) + and sorted(imports) == sorted(source['imports']), 'oracle closure mismatch') + namespace = {'__name__': 'pinned_migration_' + source['profile']} + exec(compile(ast.Module(body=selected, type_ignores=[]), source['original'], 'exec'), namespace) + result[source['profile']] = namespace + return result + + +@contextmanager +def oracle_environment(): + require(sys.implementation.name == 'cpython' and tuple(sys.version_info[:3]) == (3, 14, 8), + 'reference requires CPython 3.14.8') + require(sys.flags.isolated and sys.dont_write_bytecode, 'reference requires -I -B') + previous = sys.get_int_max_str_digits(), sys.getrecursionlimit() + try: + sys.set_int_max_str_digits(4300); sys.setrecursionlimit(1000) + require(sys.get_int_max_str_digits() == 4300 and sys.getrecursionlimit() == 1000, 'reference settings mismatch') + yield + finally: + sys.set_int_max_str_digits(previous[0]); sys.setrecursionlimit(previous[1]) + + +def encode_bytes(raw): + require(type(raw) is bytes and len(raw) <= MAX_BYTES, 'expanded bytes limit exceeded') + result = dict(encoding='hex', hex=raw.hex(), length=len(raw), sha256=hashlib.sha256(raw).hexdigest()) + if len(raw) > 512: + offset, best = 0, (0, 0) + for unused, group in itertools.groupby(raw): + length = sum(1 for unused2 in group) + if length > best[1]: best = offset, length + offset += length + start, length = best + if length > 512: + result = dict(encoding='repeat', prefix_hex=raw[:start].hex(), unit_hex=raw[start:start+1].hex(), + count=length, suffix_hex=raw[start+length:].hex(), length=len(raw), + sha256=hashlib.sha256(raw).hexdigest()) + return result + + +def expand_bytes(value): + require(type(value) is dict and value.get('encoding') in ('hex', 'repeat'), 'invalid bytes constructor') + try: + if value['encoding'] == 'hex': + require(set(value) == {'encoding', 'hex', 'length', 'sha256'}, 'invalid hex fields') + require(type(value['hex']) is str and len(value['hex']) <= MAX_BYTES * 2 + and re.fullmatch('(?:[0-9a-f]{2})*', value['hex']) is not None, 'invalid hex bytes') + raw = bytes.fromhex(value['hex']) + else: + require(set(value) == {'encoding', 'prefix_hex', 'unit_hex', 'count', 'suffix_hex', 'length', 'sha256'}, + 'invalid repeat fields') + require(type(value['count']) is int and 0 <= value['count'] <= MAX_BYTES, 'repeat count limit') + for field in ('prefix_hex', 'unit_hex', 'suffix_hex'): + require(type(value[field]) is str and len(value[field]) <= MAX_BYTES * 2 + and re.fullmatch('(?:[0-9a-f]{2})*', value[field]) is not None, 'invalid repeat hex') + prefix, unit, suffix = (bytes.fromhex(value[field]) for field in ('prefix_hex', 'unit_hex', 'suffix_hex')) + require(unit and len(prefix) + len(unit)*value['count'] + len(suffix) <= MAX_BYTES, 'expanded bytes limit exceeded') + raw = prefix + unit*value['count'] + suffix + require(type(value['length']) is int and len(raw) == value['length'], 'expanded byte length mismatch') + require(hashlib.sha256(raw).hexdigest() == value['sha256'], 'expanded byte digest mismatch') + return raw + except (KeyError, TypeError, ValueError): + raise ContractError('invalid bytes constructor') from None + + +def materialize(value, level=0, budget=None): + budget = [MAX_BYTES] if budget is None else budget + def charge(amount): + require(type(amount) is int and 0 <= amount <= budget[0], 'aggregate native construction limit') + budget[0] -= amount + charge(64) + require(level <= 32 and type(value) is dict and type(value.get('kind')) is str, 'native constructor depth/type') + kind = value['kind'] + if kind == 'null': return None + if kind == 'boolean': + require(type(value.get('value')) is bool, 'invalid boolean constructor'); return value['value'] + if kind in ('bytes', 'bytearray'): + charge(value['data']['length']) + raw = expand_bytes(value['data']); return raw if kind == 'bytes' else bytearray(raw) + if kind == 'integer': + text = value.get('decimal') + require(type(text) is str and len(text) <= 5001 and re.fullmatch(r'-?(0|[1-9][0-9]*)', text), 'invalid integer constructor') + charge(len(text)) + negative = text.startswith('-'); digits = text[1:] if negative else text + # Each int() conversion stays below the oracle's decimal digit limit. + # Oversized native integers must reach the actual source operation. + number = 0 + for offset in range(0, len(digits), 9): + chunk = digits[offset:offset+9]; number = number * 10**len(chunk) + int(chunk) + return -number if negative else number + if kind == 'power10': + require(type(value.get('exponent')) is int and 0 <= value['exponent'] <= 5000 + and type(value.get('negative')) is bool, 'power constructor limit') + charge(value['exponent'] + 1) + return (-1 if value['negative'] else 1) * 10**value['exponent'] + if kind == 'string': + points = value.get('codepoints') + require(type(points) is list and len(points) <= 8192 + and all(type(point) is int and 0 <= point <= 0x10ffff for point in points), 'invalid codepoint constructor') + charge(len(points)*4) + return ''.join(chr(point) for point in points) + if kind == 'repeat-string': + point, count = value.get('codepoint'), value.get('count') + require(type(point) is int and 0 <= point <= 127 and type(count) is int + and 0 <= count <= 1048577, 'repeated string limit') + charge(count) + return chr(point)*count + if kind == 'float': + require(value.get('value') in ('1.5', 'nan', 'inf', '-inf'), 'invalid float constructor') + return float(value['value']) + if kind in ('list', 'tuple'): + require(type(value.get('items')) is list and len(value['items']) <= 256, 'native collection limit') + items = [materialize(item, level+1, budget) for item in value['items']] + return items if kind == 'list' else tuple(items) + if kind == 'object': + require(type(value.get('entries')) is list and len(value['entries']) <= 256, 'native object limit') + result = {} + for entry in value['entries']: + key = materialize(entry['key'], level+1, budget) + require(type(key) in (str, int, bool, type(None)), 'unhashable constructor key') + require(key not in result, 'duplicate constructor key') + result[key] = materialize(entry['value'], level+1, budget) + return result + if kind == 'nested': + depth = value.get('depth'); shape = value.get('shape') + require(type(depth) is int and 0 <= depth <= 256 and shape in ('array', 'object', 'alternating'), 'nested constructor limit') + charge(depth*64) + result = materialize(value['leaf'], level+1, budget) + for index in range(depth): + result = {'x': result} if shape == 'object' or (shape == 'alternating' and index % 2) else [result] + return result + raise ContractError('unknown native constructor') + + +def within_depth(value): + pending = [(value, 0)] + while pending: + item, parent_depth = pending.pop() + if type(item) in (list, dict): + depth = parent_depth + 1 + if depth > 128: return False + pending.extend((child, depth) for child in (item.values() if type(item) is dict else item)) + return True + + +def evaluate(case, oracles, product=False): + # Construction errors deliberately escape: they are not oracle rejections. + value = materialize(case['input']) + operation = case['operation'] + if operation == 'canonical-path': + dot = materialize(case['dot']) + if product: + if type(dot) is not bool: return dict(status='reject', code='context_schema') + if type(value) is str: + try: value.encode('utf-8', errors='strict') + except UnicodeError: return dict(status='reject', code='context_schema') + try: + accepted = oracles['path']['canonical_path'](value, dot=dot) + return dict(status='ok', codepoints=[ord(character) for character in accepted]) + except oracles['path']['ContextError'] as error: + return dict(status='reject', code=error.code) + require(operation in ('parse-json', 'canonical-bytes', 'canonical-digest'), 'unknown oracle operation') + oracle = oracles['json'] + try: + if operation == 'parse-json': value = oracle['parse_json'](value) + if product and not within_depth(value): return dict(status='reject', code='invalid-json') + if operation == 'canonical-digest': + digest = oracle['canonical_digest'](value) + raw = oracle['canonical_bytes'](value) + else: + raw = oracle['canonical_bytes'](value) + digest = hashlib.sha256(raw).hexdigest() + return dict(status='ok', canonical=encode_bytes(raw), digest=digest) + except oracle['ProfileError'] as error: + return dict(status='reject', code=error.code) + + +def reproduce(root=ROOT): + manifest, schema, vectors = load_contract(root) + verify_tools(root, manifest) + result = copy.deepcopy(vectors) + with oracle_environment(): + oracles = load_oracles(root, manifest) + for case in result['cases']: + case['source_expected'] = evaluate(case, oracles) + case['product_expected'] = evaluate(case, oracles, product=True) + validate_vectors(result, schema) + return result + + +def verify(root=ROOT): + manifest, unused, unused2 = load_contract(root) + path = Path(root)/DIRECTORY/'vectors.json' + original = read_bounded(path) + require(hashlib.sha256(original).hexdigest() == manifest['files']['vectors.json'], 'frozen vectors digest mismatch') + result = reproduce(root) + require(original == render(result), 'frozen vectors differ from oracle') + return len(result['cases']) + + +def write_candidate(root, destination): + root, destination = Path(root).resolve(), Path(destination).resolve() + require(destination != root and root not in destination.parents, 'candidate must be outside accepted checkout') + require(not destination.exists(), 'candidate destination already exists') + result = reproduce(root) + # Exclusive creation also prevents an accidental overwrite after the check. + with destination.open('xb') as stream: + stream.write(render(result)) + return len(result['cases']) + + +def runtime_evidence(): + executable = Path(sys.executable).resolve() + return dict(implementation=sys.implementation.name, version=platform.python_version(), + build=sys.version, platform=platform.platform(), executable=str(executable), + executable_sha256=hashlib.sha256(read_bounded(executable, 64*1024*1024)).hexdigest(), + int_max_str_digits=4300, recursion_limit=1000) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--candidate', type=Path, help='write a NEW external candidate; never updates accepted fixtures') + args = parser.parse_args(argv) + try: + require(sys.flags.isolated and sys.dont_write_bytecode, 'reference requires -I -B') + # Input/file/construction bounds are platform independent. An additional + # Unix wall deadline bounds this development-only offline command. + signal.signal(signal.SIGALRM, lambda unused, unused2: (_ for _ in ()).throw(ContractError('oracle deadline exceeded'))) + signal.alarm(30) + count = write_candidate(ROOT, args.candidate) if args.candidate else verify(ROOT) + print(json.dumps(dict(mode='candidate' if args.candidate else 'verify', verified_cases=count, + runtime=runtime_evidence()), sort_keys=True)) + return 0 + except (ContractError, OSError, ValueError, RecursionError): + print('Migration verification blocked; require -I -B, pinned runtime and reviewed contract files.', file=sys.stderr) + return 1 + finally: + signal.alarm(0) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/specs/006-whr-cli/migration.md b/specs/006-whr-cli/migration.md index 3cb5ffcc..27793ab9 100644 --- a/specs/006-whr-cli/migration.md +++ b/specs/006-whr-cli/migration.md @@ -27,3 +27,56 @@ requires reviewed decision/evidence, not regeneration to fit Go output. Review parity and product dependency boundaries before switching callers. Record each retained, ported or deliberately replaced behavior. Do not claim wholesale Python migration; that would require a superseding ADR and new scope. + +## T003 selected profiles and provenance + +The [migration contract](../../contracts/migration/README.md), +[strict schema](../../contracts/migration/vectors.schema.json), +[vectors](../../contracts/migration/vectors.json) and +[provenance manifest](../../contracts/migration/manifest.json) capture the selected +candidate. Independent implementation review remains pending; no Go port or caller +switch is included. Intermediate profile decisions were independently reviewed +before authoring under the owner's delegated Sol review route. + +The product JSON domain fixes CPython's configurable integer-string limit to +4300 decimal magnitude digits for parsing, direct canonicalization and digest; +the sign does not count. Arbitrary machine-width-independent integers within that +bound remain accepted. Over-limit integers are `invalid-json`. Product depth is +at most 128 object/array containers (root container is one, scalar is zero); +keys and bracket characters inside strings add none. Greater depth is +`invalid-json`. Depth 129 is a deliberate product restriction: the pinned source +accepts the selected cases, and its eventual recursion error is call-stack and +runtime dependent. Canonicalization inherits no parse-input byte-size bound. + +The lexical path product domain admits Unicode scalar strings/valid UTF-8 and an +exact boolean `dot` option, default false. The pinned source accepts lone +surrogates and truthy nonbooleans, including `dot=1` for both `.` and `a`. +Product exclusions are `context_schema`. Existing accepted spaces, colons, +`.Git`, percent text and permitted controls remain accepted unchanged; no path +normalization or filesystem security claim is introduced. + +Vectors distinguish actual `source_expected` from normative `product_expected`. +Deliberate domain restrictions retain source successes rather than fabricating +oracle failures. Original source snapshots are full-hash/Git-blob checked before +only allowlisted AST closures execute. Portable reproduction requires the pinned +CPython release and explicit settings, not an author's platform-specific binary +hash. Each CLI run emits its actual executable digest/build/platform as evidence. +No source-linked resources or Git objects are fetched. + +## Retained serializer and path differences + +The following inventory was inspected at the T002 baseline and is not a migration +of those behaviors or their digests: + +| Existing primitive | Retained domain/error distinction | +| --- | --- | +| `single_task_context.canonical_bytes` / `context_digest` | Finite floats and JSON-native serializer coercions are not excluded by provider `_json_value`; context digest removes its own approval digest. Errors use `ContextError`, not the selected provider diagnostic. | +| `single_task_state._canonical` | Generic finite-float-capable JSON serialization, with `InvalidState` on listed serialization failures; no provider byte-size predicate. | +| `single_task_scope.canonical_manifest_bytes` | Validates the task manifest first, then uses the context serializer. Its schema and scope diagnostics remain development-only. | +| `task_package.canonical_json` | Integer-only native-value validation plus state serialization and an output-byte limit; this is not the provider serializer's unbounded canonical output domain. | +| `approval_binding._canonical` / `binding_digest` | Generic serializer and approval-binding field selection, without the selected provider error wrapper or domain predicate. | +| `provider_profiles.require_artifact_path` | Different lexical rule: rejects colons and validates UTF-8, but does not inherit the context path's `.git`/glob exclusions. Not substituted for `canonical_path`. | + +Task state/approval, recovery, publication, Symphony, provider eligibility and all +filesystem readers remain Python development tooling. Existing Python tests remain +intact. CLI exit/output contracts remain Feature 006 / ADR 0013. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index a85d912a..55b71559 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -1,7 +1,8 @@ # Feature 006 pilot work items Status: execution in progress. T001 scaffold independently Sol CLEAR at -`d3d84c8361a1389292fad96d02a828ac9fabc2e3`; T002 and later work items remain open. +`d3d84c8361a1389292fad96d02a828ac9fabc2e3`; T002 is independently CLEAR at +`bc18034164597aa841bea561cf6a882e9c0715e2`; T003 and later work items remain open. No product runtime acceptance is claimed. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), @@ -20,14 +21,14 @@ Goal: locked, reproducible buildable applications, not a usable product. Independent check: clean-checkout format/build/tests with missing-tool failures. - [x] T001 Scaffold and pin Phoenix/Ecto/SQLite in `apps/control_plane/`, Go modules `apps/host_agent/go.mod`, `apps/whr/go.mod`, `apps/api_client/go.mod`, root `go.work` and `.tool-versions`; lock dependencies and pass each application format/build/test gate (Feature 001 T001/T011). -- [ ] T002 Add explicit application checks and meaningful required test inventory to `scripts/check_ci.py`, `docs/development/ci-acceptance.json`, `Makefile` and `.github/workflows/check.yml`, with fixed gate/event-validation helper `scripts/application_checks.py`, negative fixtures `scripts/test_application_checks.py`, complete real-checker fixture copy lists in `scripts/test_task_readiness.py` and `scripts/test_task_source.py`, and test-only ExUnit reporter `apps/control_plane/test/support/acceptance_formatter.ex`; the helpers keep application argv out of manifest data and report actual ExUnit outcomes without adding product behavior. Prove local/CI equivalence, fail on missing tools and preserve all inherited tests (001 T012; ADR 0007/0010). +- [x] T002 Add explicit application checks and meaningful required test inventory to `scripts/check_ci.py`, `docs/development/ci-acceptance.json`, `Makefile` and `.github/workflows/check.yml`, with fixed gate/event-validation helper `scripts/application_checks.py`, negative fixtures `scripts/test_application_checks.py`, complete real-checker fixture copy lists in `scripts/test_task_readiness.py` and `scripts/test_task_source.py`, and test-only ExUnit reporter `apps/control_plane/test/support/acceptance_formatter.ex`; the helpers keep application argv out of manifest data and report actual ExUnit outcomes without adding product behavior. Prove local/CI equivalence, fail on missing tools and preserve all inherited tests (001 T012; ADR 0007/0010). ## Phase 2: Foundations and migration Goal: product boundary, durable owner state and reviewed Python-to-Go parity. Independent check: negative vectors, cross-language bytes and no runtime Python. -- [ ] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). +- [ ] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`, strict fixture schema, meaningful negative tests in `tests/test_go_vectors.py` and their required IDs in `docs/development/ci-acceptance.json`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). - [ ] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). - [ ] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). - [ ] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). @@ -110,4 +111,8 @@ acceptance and task completion rules; review evidence is appended, never fabrica ## Evidence log -Planning only. No product task is checked and no runtime acceptance is claimed. +T001 and T002 are independently accepted at the revisions above. T003 candidate +preparation is captured in contracts/migration and awaits independent review; +no Go port or product runtime acceptance is claimed. Durable check and review +evidence, including earlier failed runs, is retained in +[the pilot execution record](../../docs/exec-plans/active/installable-pilot.md). diff --git a/tests/test_go_vectors.py b/tests/test_go_vectors.py new file mode 100644 index 00000000..4358a32c --- /dev/null +++ b/tests/test_go_vectors.py @@ -0,0 +1,216 @@ +"""Independent failure-path controls for frozen selective migration vectors.""" +# SPDX-License-Identifier: EUPL-1.2 +import copy +import hashlib +import importlib.util +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] + + +class GoVectorTests(unittest.TestCase): + def setUp(self): + spec = importlib.util.spec_from_file_location('go_vectors', ROOT/'scripts/generate_go_vectors.py') + self.tool = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.tool) + self.manifest, self.schema, self.vectors = self.tool.load_contract(ROOT) + + def copied(self, parent): + root = Path(parent)/'source' + shutil.copytree(ROOT/'contracts/migration', root/'contracts/migration') + (root/'scripts').mkdir() + shutil.copyfile(ROOT/'scripts/generate_go_vectors.py', root/'scripts/generate_go_vectors.py') + return root + + def case(self, identity): + return next(case for case in self.vectors['cases'] if case['id'] == identity) + + def test_all_frozen_cases_reproduce_byte_for_byte_without_writes(self): + paths = [ROOT/'scripts/generate_go_vectors.py', *(ROOT/'contracts/migration').rglob('*')] + before = {str(p): p.read_bytes() for p in paths if p.is_file()} + self.assertGreaterEqual(self.tool.verify(ROOT), 80) + self.assertEqual(before, {name: Path(name).read_bytes() for name in before}) + + def test_hand_inspected_unicode_html_boolean_and_zero_anchors(self): + anchors = { + 'json.negative-zero': ('30', '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'), + 'json.supplementary-order': ('7b22ee8080223a312c22f0908080223a327d', '8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8'), + 'json.line-separators': ('22e280a8e280a922', 'e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c'), + 'json.html': ('223c3e262f22', '396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492'), + 'json.boolean-integer': ('7b2262223a747275652c2269223a317d', '1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63'), + } + for identity, (raw_hex, digest) in anchors.items(): + with self.subTest(identity=identity): + expected = self.case(identity)['source_expected'] + self.assertEqual(bytes.fromhex(raw_hex), self.tool.expand_bytes(expected['canonical'])) + self.assertEqual(digest, expected['digest']) + self.assertEqual(expected, self.case(identity)['product_expected']) + + def test_source_and_product_domains_remain_distinct(self): + identities = ['json.depth-129', 'canonical.depth-129', 'digest.depth-129', + 'path.surrogate', 'path.dot-integer', 'path.normal-dot-integer'] + for identity in identities: + with self.subTest(identity=identity): + case = self.case(identity) + self.assertEqual('ok', case['source_expected']['status']) + self.assertEqual('reject', case['product_expected']['status']) + self.assertNotEqual('parity', case['decision']) + self.assertEqual('5155e7af4f6c5b0853aa65153bf170836c773666b83ae909760596f07154c950', + self.case('json.digits-4300')['source_expected']['digest']) + for identity in ('json.depth-128', 'canonical.depth-128', 'digest.depth-128'): + self.assertEqual('ok', self.case(identity)['product_expected']['status']) + + def test_bigints_reach_canonical_and_digest_oracles_before_rejection(self): + with self.tool.oracle_environment(): + oracles = self.tool.load_oracles(ROOT, self.manifest) + for operation in ('canonical-bytes', 'canonical-digest'): + for sign in ('positive', 'negative'): + case = self.case(('canonical' if operation == 'canonical-bytes' else 'digest') + '.4301-' + sign) + name = operation.replace('-', '_') + original = oracles['json'][name] + seen = [] + def observe(value): + seen.append(value) + return original(value) + with mock.patch.dict(oracles['json'], {name: observe}): + self.assertEqual({'status': 'reject', 'code': 'invalid-json'}, self.tool.evaluate(case, oracles)) + self.assertEqual(1, len(seen)) + self.assertIs(type(seen[0]), int) + self.assertEqual(10**4300, abs(seen[0])) + for identity in ('canonical.4300-positive', 'canonical.4300-negative', + 'digest.4300-positive', 'digest.4300-negative'): + self.assertEqual('ok', self.tool.evaluate(self.case(identity), oracles)['status']) + + def test_byte_size_precedence_and_canonicalization_without_byte_cap(self): + self.assertEqual('ok', self.case('json.bytes-max')['product_expected']['status']) + self.assertEqual({'status': 'reject', 'code': 'input-limit-exceeded'}, + self.case('json.bytes-over-malformed')['source_expected']) + expected = self.case('canonical.no-byte-cap')['product_expected'] + self.assertEqual('ok', expected['status']) + self.assertGreater(expected['canonical']['length'], 1024*1024) + + def test_schema_rejects_unknown_fields_types_options_ids_and_operations(self): + variants = [] + value = copy.deepcopy(self.vectors); value['unknown'] = True; variants.append(value) + value = copy.deepcopy(self.vectors); value['schema_version'] = True; variants.append(value) + value = copy.deepcopy(self.vectors); value['cases'][0]['operation'] = 'exec'; variants.append(value) + value = copy.deepcopy(self.vectors); value['cases'][0]['input']['unknown'] = 'secret'; variants.append(value) + value = copy.deepcopy(self.vectors); value['cases'][0]['source_expected']['status'] = 'maybe'; variants.append(value) + for value in variants: + with self.subTest(value=str(value)[:80]), self.assertRaises(self.tool.ContractError): + self.tool.validate(value, self.schema) + value = copy.deepcopy(self.vectors); value['cases'].append(copy.deepcopy(value['cases'][0])) + with self.assertRaises(self.tool.ContractError): self.tool.validate_vectors(value, self.schema) + value = copy.deepcopy(self.vectors); value['cases'][0]['dot'] = {'kind': 'boolean', 'value': True} + with self.assertRaises(self.tool.ContractError): self.tool.validate_vectors(value, self.schema) + + def test_strict_fixture_loader_rejects_duplicate_keys_constants_utf8_and_size(self): + for raw in (b'{"x":1,"x":2}', b'{"x":NaN}', b'"\xff"', b'[]'*5000000): + with self.subTest(length=len(raw)), self.assertRaises(self.tool.ContractError): + self.tool.strict_json(raw) + + def test_compact_inputs_check_length_hash_and_expansion_bounds(self): + spec = self.tool.encode_bytes(b'0' + b' '*1048575) + self.assertLess(len(json.dumps(spec)), 500) + self.assertEqual(1048576, len(self.tool.expand_bytes(spec))) + for field, bad in [('length', 3), ('sha256', '0'*64), ('count', 10**12)]: + value = dict(spec); value[field] = bad + with self.subTest(field=field), self.assertRaises(self.tool.ContractError): self.tool.expand_bytes(value) + with self.assertRaises(self.tool.ContractError): self.tool.materialize({'kind': 'power10', 'exponent': 10**9, 'negative': False}) + with self.assertRaises(self.tool.ContractError): self.tool.materialize({'kind': 'string', 'codepoints': [0x110000]}) + with self.assertRaises(self.tool.ContractError): self.tool.materialize({'kind': 'integer', 'decimal': 'secret-canary'}) + with self.assertRaises(self.tool.ContractError): + self.tool.materialize({'kind': 'list', 'items': [{'kind': 'repeat-string', 'codepoint': 120, 'count': 1048577}]*3}) + + def test_native_construction_failure_is_not_attributed_to_source(self): + case = copy.deepcopy(self.case('canonical.4301-positive')) + case['input'] = {'kind': 'power10', 'exponent': 10**9, 'negative': False} + with self.tool.oracle_environment(): + oracles = self.tool.load_oracles(ROOT, self.manifest) + with mock.patch.dict(oracles['json'], {'canonical_bytes': mock.Mock()}) as unused: + with self.assertRaises(self.tool.ContractError): self.tool.evaluate(case, oracles) + oracles['json']['canonical_bytes'].assert_not_called() + + def test_snapshot_manifest_tool_and_schema_tampering_are_rejected(self): + for relative in ('contracts/migration/oracle/provider_profiles.py', + 'contracts/migration/oracle/single_task_context.py', + 'scripts/generate_go_vectors.py', 'contracts/migration/vectors.schema.json'): + with self.subTest(path=relative), tempfile.TemporaryDirectory() as parent: + root = self.copied(parent); path = root/relative + path.write_bytes(path.read_bytes() + b' ') + with self.assertRaises(self.tool.ContractError): self.tool.verify(root) + with tempfile.TemporaryDirectory() as parent: + root = self.copied(parent); path = root/'contracts/migration/manifest.json' + manifest = json.loads(path.read_text()); manifest['sources'][0]['sha256'] = '0'*64 + path.write_text(json.dumps(manifest)) + with self.assertRaises(self.tool.ContractError): self.tool.verify(root) + + def test_oracle_namespace_contains_only_selected_behavior(self): + oracles = self.tool.load_oracles(ROOT, self.manifest) + self.assertNotIn('require_profiles', oracles['json']) + self.assertNotIn('read_artifact', oracles['json']) + self.assertNotIn('validate_context', oracles['path']) + self.assertNotIn('reviewed_bytes', oracles['path']) + self.assertNotIn('os', oracles['json']) + self.assertNotIn('Path', oracles['path']) + + def test_default_cli_is_offline_read_only_and_reports_runtime_evidence(self): + script = ROOT/'scripts/generate_go_vectors.py' + result = subprocess.run([sys.executable, '-I', '-B', str(script)], capture_output=True, check=False) + self.assertEqual(0, result.returncode, result.stderr.decode()) + evidence = json.loads(result.stdout) + self.assertGreaterEqual(evidence['verified_cases'], 80) + self.assertRegex(evidence['runtime']['executable_sha256'], '^[0-9a-f]{64}$') + self.assertNotIn('executable_sha256', self.manifest['runtime']) + result = subprocess.run([sys.executable, '-B', str(script)], capture_output=True, check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn(b'-I -B', result.stderr) + + def test_candidate_generation_never_overwrites_accepted_or_existing_files(self): + with tempfile.TemporaryDirectory() as parent: + root = self.copied(parent) + accepted = root/'contracts/migration/vectors.json'; before = accepted.read_bytes() + with self.assertRaises(self.tool.ContractError): self.tool.write_candidate(root, accepted) + alias = Path(parent)/'alias'; alias.symlink_to(root, target_is_directory=True) + with self.assertRaises(self.tool.ContractError): self.tool.write_candidate(root, alias/'contracts/migration/vectors.json') + candidate = Path(parent)/'candidate.json' + self.tool.write_candidate(root, candidate) + self.assertEqual(before, candidate.read_bytes()) + with self.assertRaises(self.tool.ContractError): self.tool.write_candidate(root, candidate) + self.assertEqual(before, accepted.read_bytes()) + + def test_changed_expected_bytes_fail_verification_and_only_external_candidate_repairs(self): + with tempfile.TemporaryDirectory() as parent: + root = self.copied(parent); accepted = root/'contracts/migration/vectors.json' + value = json.loads(accepted.read_text()); value['cases'][0]['source_expected']['digest'] = '0'*64; value['cases'][0]['product_expected']['digest'] = '0'*64 + accepted.write_bytes(self.tool.render(value)); changed = accepted.read_bytes() + with self.assertRaises(self.tool.ContractError): self.tool.verify(root) + candidate = Path(parent)/'candidate.json'; self.tool.write_candidate(root, candidate) + self.assertNotEqual(changed, candidate.read_bytes()) + self.assertEqual(changed, accepted.read_bytes()) + + def test_runtime_limits_are_explicit_and_restored(self): + digits, recursion = sys.get_int_max_str_digits(), sys.getrecursionlimit() + try: + sys.set_int_max_str_digits(0); sys.setrecursionlimit(1500) + with self.tool.oracle_environment(): + self.assertEqual(4300, sys.get_int_max_str_digits()) + self.assertEqual(1000, sys.getrecursionlimit()) + self.assertEqual(0, sys.get_int_max_str_digits()) + self.assertEqual(1500, sys.getrecursionlimit()) + finally: + sys.set_int_max_str_digits(digits); sys.setrecursionlimit(recursion) + with mock.patch.object(self.tool.sys, 'version_info', (3, 13, 0)): + with self.assertRaises(self.tool.ContractError): + with self.tool.oracle_environment(): pass + + +if __name__ == '__main__': + unittest.main() From e24b08a754a7544d8f518bd958a3407e08ab91fe Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 16:44:46 +0200 Subject: [PATCH 09/51] feat(migration): port strict JSON and lexical path primitives Preserve the independently frozen integer-only JSON identities and lexical path predicates in dependency-free Go packages before any callers switch. Validate native types and malformed Unicode in production, and require operation-specific negative/golden vectors plus adversarial and bounded fuzz evidence. Refs: #72 Co-authored-by: Codex --- apps/api_client/wirejson/wirejson.go | 341 ++++++++++++++ apps/api_client/wirejson/wirejson_test.go | 443 +++++++++++++++++++ apps/host_agent/internal/config/path.go | 48 ++ apps/host_agent/internal/config/path_test.go | 182 ++++++++ docs/development/ci-acceptance.json | 11 +- docs/exec-plans/active/installable-pilot.md | 100 ++++- specs/006-whr-cli/migration.md | 48 +- specs/006-whr-cli/tasks.md | 12 +- 8 files changed, 1174 insertions(+), 11 deletions(-) create mode 100644 apps/api_client/wirejson/wirejson.go create mode 100644 apps/api_client/wirejson/wirejson_test.go create mode 100644 apps/host_agent/internal/config/path.go create mode 100644 apps/host_agent/internal/config/path_test.go diff --git a/apps/api_client/wirejson/wirejson.go b/apps/api_client/wirejson/wirejson.go new file mode 100644 index 00000000..bab9abcc --- /dev/null +++ b/apps/api_client/wirejson/wirejson.go @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package wirejson implements WorkHarbor's selected integer-only JSON profile. +// It is not RFC 8785/JCS. See contracts/migration for the frozen source profile +// and deliberate product restrictions. These primitives grant no authority. +package wirejson + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "math/big" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +const ( + MaxInputBytes = 1 << 20 + MaxIntegerDigits = 4300 + MaxContainerDepth = 128 + CodeInvalidJSON = "invalid-json" + CodeInputLimitExceeded = "input-limit-exceeded" +) + +// Error reports a fixed diagnostic without exposing input or decoder details. +type Error struct{ Code string } + +func (e *Error) Error() string { return e.Code } +func invalid() error { return &Error{Code: CodeInvalidJSON} } + +// Parse accepts exactly []byte, including a typed nil slice (which is invalid +// JSON). Other native types are invalid-json before any size inspection. Bytes +// above MaxInputBytes are input-limit-exceeded before UTF-8/syntax validation. +// The result consists of nil, bool, string, *big.Int, []any and map[string]any. +// Integers retain arbitrary precision within MaxIntegerDigits magnitude digits. +// Objects reject duplicate decoded keys; containers are bounded to depth 128. +func Parse(data any) (any, error) { + raw, ok := data.([]byte) + if !ok { + return nil, invalid() + } + if len(raw) > MaxInputBytes { + return nil, &Error{Code: CodeInputLimitExceeded} + } + // encoding/json substitutes malformed UTF-8 and unpaired surrogates. Validate + // before tokenization so neither can become a valid replacement character. + if !utf8.Valid(raw) || !json.Valid(raw) || !scalarEscapes(raw) { + return nil, invalid() + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + value, err := readValue(decoder, 0) + if err != nil { + return nil, invalid() + } + if _, err = decoder.Token(); err != io.EOF { + return nil, invalid() + } + return value, nil +} + +// scalarEscapes runs only on syntactically valid JSON. Escaped backslashes must +// not turn a literal "\uD800" into a Unicode escape; paired escapes are consumed +// together. Literal UTF-8 has already been checked separately. +func scalarEscapes(raw []byte) bool { + for i := 0; i < len(raw); i++ { + if raw[i] != '"' { + continue + } + i++ + for raw[i] != '"' { + if raw[i] != '\\' { + i++ + continue + } + i++ + if raw[i] != 'u' { + i++ + continue + } + first := hexQuad(raw[i+1 : i+5]) + i += 5 + if first >= 0xd800 && first <= 0xdbff { + if i+6 > len(raw) || raw[i] != '\\' || raw[i+1] != 'u' { + return false + } + second := hexQuad(raw[i+2 : i+6]) + if second < 0xdc00 || second > 0xdfff { + return false + } + i += 6 + } else if first >= 0xdc00 && first <= 0xdfff { + return false + } + } + } + return true +} +func hexQuad(raw []byte) uint16 { + var n uint16 + for _, b := range raw { + n <<= 4 + switch { + case b >= '0' && b <= '9': + n += uint16(b - '0') + case b >= 'a' && b <= 'f': + n += uint16(b - 'a' + 10) + default: + n += uint16(b - 'A' + 10) + } + } + return n +} +func readValue(decoder *json.Decoder, depth int) (any, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + switch v := token.(type) { + case nil, bool, string: + return v, nil + case json.Number: + text := string(v) + if strings.ContainsAny(text, ".eE") || magnitudeDigits(text) > MaxIntegerDigits { + return nil, invalid() + } + n, ok := new(big.Int).SetString(text, 10) + if !ok { + return nil, invalid() + } + return n, nil + case json.Delim: + if depth >= MaxContainerDepth { + return nil, invalid() + } + switch v { + case '[': + result := []any{} + for decoder.More() { + child, e := readValue(decoder, depth+1) + if e != nil { + return nil, e + } + result = append(result, child) + } + close, e := decoder.Token() + if e != nil || close != json.Delim(']') { + return nil, invalid() + } + return result, nil + case '{': + result := map[string]any{} + for decoder.More() { + key, e := decoder.Token() + if e != nil { + return nil, e + } + name, ok := key.(string) + if !ok { + return nil, invalid() + } + if _, exists := result[name]; exists { + return nil, invalid() + } + child, e := readValue(decoder, depth+1) + if e != nil { + return nil, e + } + result[name] = child + } + close, e := decoder.Token() + if e != nil || close != json.Delim('}') { + return nil, invalid() + } + return result, nil + } + } + return nil, invalid() +} +func magnitudeDigits(text string) int { + if strings.HasPrefix(text, "-") { + return len(text) - 1 + } + return len(text) +} + +// CanonicalBytes accepts exact native nil, bool, string, []any, map[string]any, +// *big.Int and built-in int/int8/int16/int32/int64/uint/uint8/uint16/uint32/uint64. +// Defined types, uintptr, arrays, byte slices, json.Number, floats, structs and +// pointers other than nonnil *big.Int are invalid-json; custom marshalers never +// execute. Nil exact slices/maps encode as []/{}. Strings/keys require valid +// UTF-8. No normalization, escaping of HTML/U+2028/U+2029 or trailing LF occurs. +// Depth/digit limits also apply here, and cycles fail within the depth bound. +// There is no canonical output byte-size cap. Callers must not mutate values +// concurrently while they are being read. +func CanonicalBytes(value any) ([]byte, error) { + result, err := appendValue(nil, value, 0) + if err != nil { + return nil, err + } + return result, nil +} + +// CanonicalDigest is lowercase SHA-256 of CanonicalBytes, with identical input +// validation. It inherits no parse-input byte-size cap. +func CanonicalDigest(value any) (string, error) { + canonical, err := CanonicalBytes(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(canonical) + return hex.EncodeToString(digest[:]), nil +} +func appendValue(out []byte, value any, depth int) ([]byte, error) { + switch v := value.(type) { + case nil: + return append(out, "null"...), nil + case bool: + if v { + return append(out, "true"...), nil + } + return append(out, "false"...), nil + case string: + return appendString(out, v) + case []any: + if depth >= MaxContainerDepth { + return nil, invalid() + } + out = append(out, '[') + for i, child := range v { + if i > 0 { + out = append(out, ',') + } + var err error + out, err = appendValue(out, child, depth+1) + if err != nil { + return nil, err + } + } + return append(out, ']'), nil + case map[string]any: + if depth >= MaxContainerDepth { + return nil, invalid() + } + keys := make([]string, 0, len(v)) + for key := range v { + if !utf8.ValidString(key) { + return nil, invalid() + } + keys = append(keys, key) + } + // Valid UTF-8 byte ordering is Unicode scalar ordering, unlike UTF-16. + sort.Strings(keys) + out = append(out, '{') + for i, key := range keys { + if i > 0 { + out = append(out, ',') + } + var err error + out, err = appendString(out, key) + if err != nil { + return nil, err + } + out = append(out, ':') + out, err = appendValue(out, v[key], depth+1) + if err != nil { + return nil, err + } + } + return append(out, '}'), nil + default: + text, ok := integerText(value) + if !ok || magnitudeDigits(text) > MaxIntegerDigits { + return nil, invalid() + } + return append(out, text...), nil + } +} +func integerText(value any) (string, bool) { + switch v := value.(type) { + case *big.Int: + if v != nil { + return v.String(), true + } + case int: + return strconv.FormatInt(int64(v), 10), true + case int8: + return strconv.FormatInt(int64(v), 10), true + case int16: + return strconv.FormatInt(int64(v), 10), true + case int32: + return strconv.FormatInt(int64(v), 10), true + case int64: + return strconv.FormatInt(v, 10), true + case uint: + return strconv.FormatUint(uint64(v), 10), true + case uint8: + return strconv.FormatUint(uint64(v), 10), true + case uint16: + return strconv.FormatUint(uint64(v), 10), true + case uint32: + return strconv.FormatUint(uint64(v), 10), true + case uint64: + return strconv.FormatUint(v, 10), true + } + return "", false +} +func appendString(out []byte, text string) ([]byte, error) { + if !utf8.ValidString(text) { + return nil, invalid() + } + const digits = "0123456789abcdef" + out = append(out, '"') + for i := 0; i < len(text); i++ { + b := text[i] + switch b { + case '"', '\\': + out = append(out, '\\', b) + case '\b': + out = append(out, `\b`...) + case '\f': + out = append(out, `\f`...) + case '\n': + out = append(out, `\n`...) + case '\r': + out = append(out, `\r`...) + case '\t': + out = append(out, `\t`...) + default: + if b < 0x20 { + out = append(out, '\\', 'u', '0', '0', digits[b>>4], digits[b&15]) + } else { + out = append(out, b) + } + } + } + return append(out, '"'), nil +} diff --git a/apps/api_client/wirejson/wirejson_test.go b/apps/api_client/wirejson/wirejson_test.go new file mode 100644 index 00000000..1f447ffb --- /dev/null +++ b/apps/api_client/wirejson/wirejson_test.go @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: EUPL-1.2 +package wirejson_test + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "os" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" +) + +type byteVector struct { + Encoding string `json:"encoding"` + Hex string `json:"hex"` + Prefix string `json:"prefix_hex"` + Unit string `json:"unit_hex"` + Suffix string `json:"suffix_hex"` + Count int `json:"count"` + Length int `json:"length"` + SHA string `json:"sha256"` +} + +func (v byteVector) expand(t *testing.T) []byte { + t.Helper() + decode := func(s string) []byte { + b, e := hex.DecodeString(s) + if e != nil { + t.Fatal(e) + } + return b + } + var b []byte + switch v.Encoding { + case "hex": + b = decode(v.Hex) + case "repeat": + b = append(decode(v.Prefix), bytes.Repeat(decode(v.Unit), v.Count)...) + b = append(b, decode(v.Suffix)...) + default: + t.Fatalf("unknown bytes encoding %q", v.Encoding) + } + if len(b) != v.Length || fmt.Sprintf("%x", sha256.Sum256(b)) != v.SHA { + t.Fatal("frozen byte materialization mismatch") + } + return b +} + +type nativeVector struct { + Kind string `json:"kind"` + Value json.RawMessage `json:"value"` + Decimal string `json:"decimal"` + Exponent int `json:"exponent"` + Negative bool `json:"negative"` + Codepoints []int `json:"codepoints"` + Codepoint int `json:"codepoint"` + Count int `json:"count"` + Data byteVector `json:"data"` + Items []nativeVector `json:"items"` + Entries []struct { + Key nativeVector `json:"key"` + Value nativeVector `json:"value"` + } `json:"entries"` + Depth int `json:"depth"` + Shape string `json:"shape"` + Leaf *nativeVector `json:"leaf"` +} + +// bytearray is a distinct native type, never silently converted to accepted []byte. +type bytearray []byte + +type hostileMarshaler struct{} + +func (hostileMarshaler) MarshalJSON() ([]byte, error) { panic("custom marshaler must never execute") } + +func scalarString(points []int) string { + var out []byte + for _, p := range points { + if p >= 0xd800 && p <= 0xdfff { + // Surrogates have no scalar-string Go representation. Preserve their bytes + // as invalid UTF-8 so real production validation rejects them, not U+FFFD. + out = append(out, byte(0xe0|(p>>12)), byte(0x80|((p>>6)&63)), byte(0x80|(p&63))) + } else { + out = utf8.AppendRune(out, rune(p)) + } + } + return string(out) +} +func (v nativeVector) materialize(t *testing.T) any { + t.Helper() + switch v.Kind { + case "null": + return nil + case "boolean": + var b bool + if e := json.Unmarshal(v.Value, &b); e != nil { + t.Fatal(e) + } + return b + case "integer": + b, ok := new(big.Int).SetString(v.Decimal, 10) + if !ok { + t.Fatal("bad integer fixture") + } + return b + case "power10": + b := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(v.Exponent)), nil) + if v.Negative { + b.Neg(b) + } + return b + case "string": + return scalarString(v.Codepoints) + case "repeat-string": + return strings.Repeat(string(rune(v.Codepoint)), v.Count) + case "bytes": + return v.Data.expand(t) + case "bytearray": + return bytearray(v.Data.expand(t)) + case "float": + var s string + if e := json.Unmarshal(v.Value, &s); e != nil { + t.Fatal(e) + } + switch s { + case "1.5": + return 1.5 + case "nan": + return math.NaN() + case "inf": + return math.Inf(1) + case "-inf": + return math.Inf(-1) + } + case "list", "tuple": + items := make([]any, len(v.Items)) + for i, x := range v.Items { + items[i] = x.materialize(t) + } + if v.Kind == "list" { + return items + } + array := reflect.New(reflect.ArrayOf(len(items), reflect.TypeFor[any]())).Elem() + for i, x := range items { + if x != nil { + array.Index(i).Set(reflect.ValueOf(x)) + } + } + return array.Interface() + case "object": + object := map[string]any{} + other := map[any]any{} + stringsOnly := true + for _, entry := range v.Entries { + k, x := entry.Key.materialize(t), entry.Value.materialize(t) + other[k] = x + if s, ok := k.(string); ok { + object[s] = x + } else { + stringsOnly = false + } + } + if stringsOnly { + return object + } + return other + case "nested": + x := v.Leaf.materialize(t) + for i := 0; i < v.Depth; i++ { + if v.Shape == "object" || (v.Shape == "alternating" && i%2 == 1) { + x = map[string]any{"x": x} + } else { + x = []any{x} + } + } + return x + } + t.Fatalf("unhandled native kind %q", v.Kind) + return nil +} +func requireCode(t *testing.T, err error, code string) { + t.Helper() + var e *wirejson.Error + if !errors.As(err, &e) || e.Code != code || err.Error() != code { + t.Fatalf("error=%v, want safe code %s", err, code) + } +} +func TestFrozenJSONVectors(t *testing.T) { + raw, e := os.ReadFile("../../../contracts/migration/vectors.json") + if e != nil { + t.Fatal(e) + } + if fmt.Sprintf("%x", sha256.Sum256(raw)) != "0070cf8057253dca19abe7eaaa55e9c9e17313ba53c6cd820014443e3708a04c" { + t.Fatal("frozen vector hash changed") + } + var corpus struct { + Cases []struct { + ID string `json:"id"` + Operation string `json:"operation"` + Input nativeVector `json:"input"` + Expected struct { + Status string `json:"status"` + Code string `json:"code"` + Canonical byteVector `json:"canonical"` + Digest string `json:"digest"` + } `json:"product_expected"` + } `json:"cases"` + } + if e = json.Unmarshal(raw, &corpus); e != nil { + t.Fatal(e) + } + count := 0 + for _, c := range corpus.Cases { + if c.Operation == "canonical-path" { + continue + } + count++ + t.Run(c.ID, func(t *testing.T) { + v := c.Input.materialize(t) + var output []byte + var digest string + var err error + switch c.Operation { + case "parse-json": + v, err = wirejson.Parse(v) + case "canonical-bytes": + output, err = wirejson.CanonicalBytes(v) + case "canonical-digest": + digest, err = wirejson.CanonicalDigest(v) + default: + t.Fatal("unknown operation") + } + // Assert the selected operation before any follow-on operation can + // mask a wrongly accepted input with its own rejection. + if c.Expected.Status == "reject" { + requireCode(t, err, c.Expected.Code) + return + } + if err != nil { + t.Fatal(err) + } + if c.Operation != "canonical-bytes" { + output, err = wirejson.CanonicalBytes(v) + if err != nil { + t.Fatal(err) + } + } + if c.Operation != "canonical-digest" { + digest, err = wirejson.CanonicalDigest(v) + if err != nil { + t.Fatal(err) + } + } + if !bytes.Equal(output, c.Expected.Canonical.expand(t)) || digest != c.Expected.Digest { + t.Fatalf("canonical bytes/digest mismatch for %s", c.ID) + } + }) + } + if count != 113 { + t.Fatalf("executed %d JSON vectors, want 113", count) + } +} +func TestStrictJSONAdversarial(t *testing.T) { + bad := []string{`{"x":[{"a":0,"\u0061":1}]}`, `{"\ud800":0}`, `["\udfff"]`, `"\ud800\u0041"`, `"\udc00\ud800"`, `"\ud800\\udc00"`, `true false`, `0 0`, `[01]`, `1e0`, `1.0`, "\xef\xbb\xbfnull", "\"\xed\xa0\x80\"", "\"\xf0\x80\x80\x80\"", `{"a":1,}`, `[]garbage`} + for _, s := range bad { + t.Run(fmt.Sprintf("%x", s), func(t *testing.T) { _, e := wirejson.Parse([]byte(s)); requireCode(t, e, "invalid-json") }) + } + good := map[string]string{`"\ud800\udc00"`: "\"𐀀\"", `"\ufffd"`: "\"�\"", `{"�":1}`: `{"�":1}`, `"\\ud800"`: `"\\ud800"`, " \r\n\t [0,true] \n": "[0,true]", `{"\ud800\udc00":0}`: `{"𐀀":0}`} + for s, want := range good { + v, e := wirejson.Parse([]byte(s)) + if e != nil { + t.Fatalf("%q: %v", s, e) + } + got, e := wirejson.CanonicalBytes(v) + if e != nil || string(got) != want { + t.Fatalf("%q: %q %v", s, got, e) + } + } + _, e := wirejson.Parse(bytes.Repeat([]byte{0xff}, 1048577)) + requireCode(t, e, "input-limit-exceeded") + _, e = wirejson.Parse(bytearray(bytes.Repeat([]byte{0xff}, 1048577))) + requireCode(t, e, "invalid-json") +} +func TestCanonicalNativeDomain(t *testing.T) { + type namedString string + type namedInteger int + type namedList []any + type namedMap map[string]any + var n *big.Int + bad := []any{hostileMarshaler{}, uintptr(1), float32(1), float64(1), math.NaN(), math.Inf(-1), json.Number("1"), []byte("a"), [1]any{1}, map[int]any{1: 0}, map[any]any{"a": 0}, struct{ A int }{1}, namedString("a"), namedInteger(1), namedList{}, namedMap{}, n, (*string)(nil), string([]byte{0xff}), map[string]any{string([]byte{0xed, 0xa0, 0x80}): nil}} + cycleList := make([]any, 1) + cycleList[0] = cycleList + cycleMap := map[string]any{} + cycleMap["x"] = cycleMap + bad = append(bad, cycleList, cycleMap) + for i, v := range bad { + t.Run(fmt.Sprint(i), func(t *testing.T) { + _, e := wirejson.CanonicalBytes(v) + requireCode(t, e, "invalid-json") + _, e = wirejson.CanonicalDigest(v) + requireCode(t, e, "invalid-json") + }) + } + for _, v := range []any{int(42), int8(42), int16(42), int32(42), int64(42), uint(42), uint8(42), uint16(42), uint32(42), uint64(42), big.NewInt(42)} { + b, e := wirejson.CanonicalBytes(v) + if e != nil || string(b) != "42" { + t.Fatalf("%T: %q %v", v, b, e) + } + } + for _, c := range []struct { + v any + want string + }{{nil, "null"}, {[]any(nil), "[]"}, {map[string]any(nil), "{}"}, {false, "false"}, {"\b\f\n\r\t\x00\x1f\"\\/<>\u2028\u2029", `"\b\f\n\r\t\u0000\u001f\"\\/<>` + "\u2028\u2029" + `"`}} { + b, e := wirejson.CanonicalBytes(c.v) + if e != nil || string(b) != c.want { + t.Fatalf("%T: %q want %q: %v", c.v, b, c.want, e) + } + } + large := strings.Repeat("x", 1048577) + b, e := wirejson.CanonicalBytes(large) + if e != nil || len(b) != 1048579 { + t.Fatal("canonical output incorrectly bounded", e) + } +} +func TestIntegerAndContainerLimits(t *testing.T) { + for _, digits := range []int{4300, 4301} { + for _, negative := range []bool{false, true} { + s := strings.Repeat("9", digits) + if negative { + s = "-" + s + } + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatal("test integer") + } + _, pe := wirejson.Parse([]byte(s)) + b, ce := wirejson.CanonicalBytes(n) + d, de := wirejson.CanonicalDigest(n) + if digits == 4301 { + for _, e := range []error{pe, ce, de} { + requireCode(t, e, "invalid-json") + } + } else if pe != nil || ce != nil || de != nil || string(b) != s || d != fmt.Sprintf("%x", sha256.Sum256([]byte(s))) { + t.Fatal("4300 digit boundary failure") + } + } + } + for _, depth := range []int{128, 129} { + var v any = nil + for i := 0; i < depth; i++ { + if i%2 == 0 { + v = []any{v} + } else { + v = map[string]any{"x": v} + } + } + b, e := wirejson.CanonicalBytes(v) + _, de := wirejson.CanonicalDigest(v) + if depth == 129 { + requireCode(t, e, "invalid-json") + requireCode(t, de, "invalid-json") + } else { + if e != nil || de != nil { + t.Fatal(e, de) + } + if _, e = wirejson.Parse(b); e != nil { + t.Fatal(e) + } + } + } + // Assert Parse's own result for every container shape, independent of + // canonicalization, so a later encoder rejection cannot mask acceptance. + for _, shape := range []string{"array", "object", "alternating"} { + for _, depth := range []int{128, 129} { + raw := "0" + for i := 0; i < depth; i++ { + if shape == "object" || (shape == "alternating" && i%2 == 1) { + raw = `{"x":` + raw + `}` + } else { + raw = "[" + raw + "]" + } + } + _, err := wirejson.Parse([]byte(raw)) + if depth == 129 { + requireCode(t, err, "invalid-json") + } else if err != nil { + t.Fatalf("%s depth128: %v", shape, err) + } + } + } + + v, e := wirejson.Parse([]byte("[true,1,-0]")) + if e != nil { + t.Fatal(e) + } + a := v.([]any) + if _, ok := a[0].(bool); !ok { + t.Fatal("boolean coerced") + } + if a[1].(*big.Int).Cmp(big.NewInt(1)) != 0 || a[2].(*big.Int).Sign() != 0 { + t.Fatal("integer domain lost") + } +} +func FuzzParseCanonicalRoundTrip(f *testing.F) { + for _, s := range []string{`null`, `{"a":[true,-0,1]}`, `"\ud800"`, `{"a":0,"\u0061":1}`, `"\ud800\udc00"`, "\"�\u2028\""} { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, input []byte) { + v, e := wirejson.Parse(input) + if e != nil { + var safe *wirejson.Error + if !errors.As(e, &safe) { + t.Fatalf("unsafe error %v", e) + } + return + } + canonical, e := wirejson.CanonicalBytes(v) + if e != nil { + t.Fatal(e) + } + again, e := wirejson.Parse(canonical) + if e != nil { + t.Fatal(e) + } + stable, e := wirejson.CanonicalBytes(again) + if e != nil || !bytes.Equal(canonical, stable) { + t.Fatal("unstable canonicalization", e) + } + d, e := wirejson.CanonicalDigest(v) + if e != nil || d != fmt.Sprintf("%x", sha256.Sum256(canonical)) { + t.Fatal("digest disagreement", e) + } + }) +} diff --git a/apps/host_agent/internal/config/path.go b/apps/host_agent/internal/config/path.go new file mode 100644 index 00000000..c2d72310 --- /dev/null +++ b/apps/host_agent/internal/config/path.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package config contains pure configuration predicates. Lexical path validation +// alone establishes no containment, symlink safety, capability or owner trust. +package config + +import ( + "strings" + "unicode/utf8" +) + +// Error contains only a fixed diagnostic, never the supplied path or options. +type Error struct{ Code string } + +func (e *Error) Error() string { return e.Code } +func invalidPath() error { return &Error{Code: "context_schema"} } + +// CanonicalPath validates an exact native string with dot defaulting to false. +// It returns its unchanged spelling; it does not normalize or access a filesystem. +func CanonicalPath(value any) (string, error) { return CanonicalPathWithDot(value, false) } + +// CanonicalPathWithDot additionally accepts an exact native bool option. Defined +// string/bool types, byte slices, pointers and other values are context_schema. +// Invalid UTF-8, empty paths, NUL, leading slash, backslash, CR/LF/tab, *?[], empty +// components and components equal to ., .. or .git are context_schema. Only the +// whole-string "." exception is accepted when dot is true. There is no length +// bound, case folding, percent decoding, expansion or filesystem resolution. +func CanonicalPathWithDot(value any, dot any) (string, error) { + allowDot, ok := dot.(bool) + if !ok { + return "", invalidPath() + } + path, ok := value.(string) + if !ok || !utf8.ValidString(path) { + return "", invalidPath() + } + if allowDot && path == "." { + return path, nil + } + if path == "" || strings.HasPrefix(path, "/") || strings.ContainsAny(path, "\\\x00\r\n\t*?[]") { + return "", invalidPath() + } + for _, part := range strings.Split(path, "/") { + if part == "" || part == "." || part == ".." || part == ".git" { + return "", invalidPath() + } + } + return path, nil +} diff --git a/apps/host_agent/internal/config/path_test.go b/apps/host_agent/internal/config/path_test.go new file mode 100644 index 00000000..127aaedc --- /dev/null +++ b/apps/host_agent/internal/config/path_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "strings" + "testing" + "unicode/utf8" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" +) + +type pathValue struct { + Kind string `json:"kind"` + Value json.RawMessage `json:"value"` + Decimal string `json:"decimal"` + Codepoints []int `json:"codepoints"` + Data struct { + Hex string `json:"hex"` + Length int `json:"length"` + SHA string `json:"sha256"` + } `json:"data"` +} + +func pointString(points []int) string { + var out []byte + for _, p := range points { + if p >= 0xd800 && p <= 0xdfff { + // Preserve a source surrogate observation as invalid Go UTF-8, not U+FFFD. + out = append(out, byte(0xe0|(p>>12)), byte(0x80|((p>>6)&63)), byte(0x80|(p&63))) + } else { + out = utf8.AppendRune(out, rune(p)) + } + } + return string(out) +} +func (v pathValue) materialize(t *testing.T) any { + t.Helper() + switch v.Kind { + case "string": + return pointString(v.Codepoints) + case "null": + return nil + case "boolean": + var b bool + if e := json.Unmarshal(v.Value, &b); e != nil { + t.Fatal(e) + } + return b + case "integer": + n, ok := new(big.Int).SetString(v.Decimal, 10) + if !ok { + t.Fatal("fixture integer") + } + return n + case "float": + var s string + if e := json.Unmarshal(v.Value, &s); e != nil || s != "1.5" { + t.Fatal("fixture float") + } + return 1.5 + case "bytes": + b, e := hex.DecodeString(v.Data.Hex) + if e != nil || len(b) != v.Data.Length || fmt.Sprintf("%x", sha256.Sum256(b)) != v.Data.SHA { + t.Fatal("fixture bytes") + } + return b + } + t.Fatalf("unhandled path constructor %q", v.Kind) + return nil +} +func pathCode(t *testing.T, e error) { + t.Helper() + var safe *config.Error + if !errors.As(e, &safe) || safe.Code != "context_schema" || e.Error() != "context_schema" { + t.Fatalf("want safe context_schema, got %v", e) + } +} +func TestFrozenPathVectors(t *testing.T) { + raw, e := os.ReadFile("../../../../contracts/migration/vectors.json") + if e != nil { + t.Fatal(e) + } + if fmt.Sprintf("%x", sha256.Sum256(raw)) != "0070cf8057253dca19abe7eaaa55e9c9e17313ba53c6cd820014443e3708a04c" { + t.Fatal("frozen vector hash changed") + } + var corpus struct { + Cases []struct { + ID string `json:"id"` + Operation string `json:"operation"` + Input pathValue `json:"input"` + Dot pathValue `json:"dot"` + Expected struct { + Status string `json:"status"` + Code string `json:"code"` + Codepoints []int `json:"codepoints"` + } `json:"product_expected"` + } `json:"cases"` + } + if e = json.Unmarshal(raw, &corpus); e != nil { + t.Fatal(e) + } + count := 0 + for _, c := range corpus.Cases { + if c.Operation != "canonical-path" { + continue + } + count++ + t.Run(c.ID, func(t *testing.T) { + got, e := config.CanonicalPathWithDot(c.Input.materialize(t), c.Dot.materialize(t)) + if c.Expected.Status == "reject" { + if c.Expected.Code != "context_schema" { + t.Fatal("unexpected fixture code") + } + pathCode(t, e) + return + } + if e != nil || got != pointString(c.Expected.Codepoints) { + t.Fatalf("path mismatch: %q %v", got, e) + } + }) + } + if count != 46 { + t.Fatalf("ran %d path vectors, want 46", count) + } +} +func TestLexicalPathNativeDomain(t *testing.T) { + type namedString string + type namedBool bool + for _, v := range []any{nil, []byte("a"), 1, true, namedString("a"), (*string)(nil), string([]byte{0xff}), string([]byte{0xed, 0xa0, 0x80})} { + _, e := config.CanonicalPath(v) + pathCode(t, e) + } + for _, dot := range []any{nil, 0, 1, "", "true", namedBool(true), (*bool)(nil)} { + for _, path := range []string{"a", "."} { + _, e := config.CanonicalPathWithDot(path, dot) + pathCode(t, e) + } + } + for _, s := range []string{"x/.git/y", "a/./b", "a/../b", "a//b", "a/", "/a", "a\\b", "a\x00b", "a\nb", "a\rb", "a\tb", "a*b", "a?b", "a[b", "a]b", ""} { + _, e := config.CanonicalPath(s) + pathCode(t, e) + } + for _, s := range []string{"a", " C:x ", ".GIT/x", "%2e%2e/x", "~/a", "a\x1bb", "a\x01b", "�/𐀀", "e\u0301", "a\u2028b", strings.Repeat("a", 1048577)} { + got, e := config.CanonicalPath(s) + if e != nil || got != s { + t.Fatalf("accepted spelling changed: %v", e) + } + } + _, e := config.CanonicalPath(".") + pathCode(t, e) + got, e := config.CanonicalPathWithDot(".", true) + if e != nil || got != "." { + t.Fatal("dot=true failed") + } +} +func FuzzLexicalPathIdentity(f *testing.F) { + for _, s := range []string{"a", ".", "a/../b", "a\x1bb", "%2e%2e/x", string([]byte{0xff}), "�/𐀀"} { + f.Add(s, false) + f.Add(s, true) + } + f.Fuzz(func(t *testing.T, s string, dot bool) { + got, e := config.CanonicalPathWithDot(s, dot) + if e != nil { + pathCode(t, e) + return + } + if !utf8.ValidString(s) || got != s { + t.Fatal("invalid UTF-8 accepted or path changed") + } + again, e := config.CanonicalPathWithDot(got, dot) + if e != nil || again != got { + t.Fatal("path not idempotent") + } + }) +} diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index cadfffa8..d68d33a7 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -528,9 +528,16 @@ "github.com/wstein/workharbor-nxt/apps/whr/cmd/whr/TestScaffoldRefusesProductCommands" ], "host_agent": [ - "github.com/wstein/workharbor-nxt/apps/host_agent/cmd/workharbor-agent/TestScaffoldRefusesProductCommands" + "github.com/wstein/workharbor-nxt/apps/host_agent/cmd/workharbor-agent/TestScaffoldRefusesProductCommands", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestLexicalPathNativeDomain" + ], + "api_client": [ + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestStrictJSONAdversarial", + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestCanonicalNativeDomain", + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestIntegerAndContainerLimits" ], - "api_client": [], "control_plane": [ "WorkHarborWeb.ErrorHTMLTest/test renders 404.html", "WorkHarborWeb.ErrorHTMLTest/test renders 500.html", diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 7d61899b..6ba7428c 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -12,7 +12,8 @@ Status: independent Sol planning review cleared correction cleared `0c2c135d8d82d8ed708095f7f1596760df38696b`. T001 scaffold is accepted after independent Sol CLEAR of `d3d84c8361a1389292fad96d02a828ac9fabc2e3`. T002 is independently accepted at `bc18034164597aa841bea561cf6a882e9c0715e2`. -T003 preparation is being implemented and remains unchecked pending independent review. +T003 is independently accepted at `191c62baf227f64122770244d62d289900bb4d8d`. +T004 Go primitives are being implemented and remain unchecked pending independent review. No product runtime acceptance is claimed. Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; @@ -402,3 +403,100 @@ mode and default read-only mode are exercised by the focused controls. No runtim Python/AI orchestration dependency was added to product code. Full new-head canonical acceptance is not claimed; the final combined pilot candidate still requires its own complete canonical and runtime acceptance. + + +## T003 independent acceptance and T004 start + +Independent Sol review cleared exact T003 candidate +`191c62baf227f64122770244d62d289900bb4d8d` (parent +`bc18034164597aa841bea561cf6a882e9c0715e2`) before T004 writes were authorized. +The reviewer independently reimplemented construction/evaluation and checked all +159 results against the pinned original AST closures: 145 parity cases, five depth +restrictions, two scalar-path restrictions and seven exact-dot restrictions. The +offline verifier reproduced the frozen bytes and all 15 controls passed. The 505 +inherited required IDs/order/policy remained intact, with 15 added IDs; pristine +clone source/manifest/collection/repository/whitespace checks passed. These are +focused checks, not a full new-head canonical or Linux reproduction claim. +Feature 006 T003 is now checked; T004 and original Feature 001 markers stay open. + +## T004 Go pure primitives — author candidate, review pending + +Scope: only `apps/api_client/wirejson/`, +`apps/host_agent/internal/config/`, six meaningful Go required IDs in the existing +acceptance manifest, Feature 006 migration/task status and this execution record. +No callers, dependencies, provider operations, control-plane behavior or frozen +migration artifacts change. Public native types and rejection semantics are +specified in the migration inventory and package/function documentation. + +RED before implementation: `go test ./wirejson` in API client and +`go test ./internal/config` in host agent both exited 1 because the test-only +packages had no production Go files. This was compile RED for absent implementations, +not a claim that production behavior had been executed. After implementation, +`go test -count=1 ./wirejson` and `go test -count=1 ./internal/config` first passed +in 0.261s and 0.269s. Test files were written before any product implementation. +Caches were in private `/private/tmp/workharbor-t004-*` directories. + +Hash-pinned static Go tests execute all 113 JSON and 46 path product expectations; +Go never invokes Python. Independent Go negatives/goldens cover malformed Unicode +in keys/values, actual U+FFFD, valid surrogate pairs, duplicate decoded keys, EOF, +positive/negative 4300/4301-digit integers through all three APIs, depth 128/129, +cycles, exact native types and typed nils, literal controls/HTML/U+2028/U+2029, +unbounded canonical output and unchanged lexical spellings. Every rejection is +produced by a production entry point; adapters only materialize native inputs. +Source successes outside the admitted product profile remain separate evidence. + + +Focused author verification after the first GREEN: + +- Go 1.27.2 / darwin-arm64: all three modules passed `gofmt -l .` (empty), + `go vet ./...`, their library/executable build form and + `go test -json -count=1 ./...`. The first author script incorrectly applied + `go build -o / ./...` to the library-only API module, which + returned `no main packages to build`; the repository runner already uses + `go build ./...` for that module. This was an author command error, corrected + without a product or runner change. Final command outcomes are validated again + from the pristine committed clone before handoff. +- Bounded fuzz (`-run='^$' -fuzz= -fuzztime=5s -parallel=2`, + `GOMAXPROCS=2`, private caches): JSON round-trip/digest target passed 197,471 + executions in 6.196s; lexical identity target passed 42,174 in 6.192s. + No failure corpus or product files were written by fuzzing. +- Separate `python3.14 -I -B scripts/generate_go_vectors.py` reproduced all + 159 cases byte-for-byte; `python3.14 -I -B -m unittest discover -s tests + -p test_go_vectors.py -v` passed all 15 controls in 1.081s. Runtime was + CPython 3.14.8/macOS arm64, executable SHA-256 + `1b5cb11d163e5ba34e2a05fc533c0ed89574d4425f9749a9d89405f651980e0d`, + integer limit 4300, recursion limit 1000. No Linux reproduction is claimed. +- `python3.14 -I -B scripts/check_repo.py` and `git diff --check` pass; + the Feature 001 task and all accepted migration artifacts/generator/tests diff + is empty. Only six required Go IDs are appended; Python required IDs/order, + retained-failure policy and existing application IDs are preserved. + +The full canonical suite is not duplicated for unchanged runner behavior in this +WI. Exact-head full canonical plus applicable real-host/browser/install acceptance +remains mandatory on the final combined pilot candidate. T004 stays unchecked +until its separately assigned Sol reviewer clears the committed implementation. + + +Precommit independent Sol review identified an adapter evidence gap: the initial +JSON fixture runner checked a shared error only after follow-on canonical/digest +calls, so a later rejection could mask incorrect acceptance by the selected +operation. Production depth enforcement was correct, but the test did not prove +each operation's own rejection. The adapter now asserts the selected operation's +status/code first and performs cross-API comparisons only for expected successes. +Independent direct Parse regressions cover array/object/alternating shapes at both +128 and 129 containers. No frozen fixture or production profile was changed. +The original absent-package RED chronology above remains unchanged; no artificial +stub or later mutation is represented as that original RED. + + +After the precommit test correction, API-client `gofmt -l .`, `go vet ./...`, +`go build ./...` and `go test -json -count=1 ./...` passed (vet 2.518s, +build 0.065s, tests 1.016s). The unchanged repository event validator accepted +actual outcomes for all three modules, including every required ID: whr 1, +host agent 64 and API client 162 passing top-level/subtest/seed outcomes. +Trusted `load_manifest`/`collect`/`validate_collection` also passed: 520 Python +required IDs among 846 discovered; all prior Python IDs/order and failure policy +are identical, and existing application IDs retain their order with six additions. +The author worktree's ignored `.specify/feature.json` is preserved (SHA-256 +`05caf1787466a6410ce0cc3d1cdb371eae6187e38babba78a0c08d21edf1af83`); +source-context checks must run in a pristine validation clone instead. diff --git a/specs/006-whr-cli/migration.md b/specs/006-whr-cli/migration.md index 27793ab9..1d980055 100644 --- a/specs/006-whr-cli/migration.md +++ b/specs/006-whr-cli/migration.md @@ -1,6 +1,8 @@ # Selective Python-to-Go migration -Status: proposed inventory and acceptance; ports not implemented. +Status: T003 contracts independently accepted at +`191c62baf227f64122770244d62d289900bb4d8d`; T004 Go candidate awaiting +independent implementation review. No callers are switched. Source pin: `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`. Authority: [ADR 0010](../../docs/adr/0010-go-whr-cli-layout.md). @@ -34,8 +36,9 @@ The [migration contract](../../contracts/migration/README.md), [strict schema](../../contracts/migration/vectors.schema.json), [vectors](../../contracts/migration/vectors.json) and [provenance manifest](../../contracts/migration/manifest.json) capture the selected -candidate. Independent implementation review remains pending; no Go port or caller -switch is included. Intermediate profile decisions were independently reviewed +accepted T003 candidate. Independent Sol review cleared exact revision +`191c62baf227f64122770244d62d289900bb4d8d`; no caller switch is included. +Intermediate profile decisions were independently reviewed before authoring under the owner's delegated Sol review route. The product JSON domain fixes CPython's configurable integer-string limit to @@ -80,3 +83,42 @@ of those behaviors or their digests: Task state/approval, recovery, publication, Symphony, provider eligibility and all filesystem readers remain Python development tooling. Existing Python tests remain intact. CLI exit/output contracts remain Feature 006 / ADR 0013. + +## T004 native Go boundary (candidate) + +`apps/api_client/wirejson` exposes `Parse(any)`, `CanonicalBytes(any)` and +`CanonicalDigest(any)`. Parse accepts exact `[]byte`; source-native nonbytes and +bytearray fixtures therefore reach production rejection before size inspection. +It returns `nil`, `bool`, `string`, `*big.Int`, `[]any` and `map[string]any`. +Canonical operations accept those exact types and built-in signed/unsigned integer +types (`int` through `int64`, `uint` through `uint64`; not `uintptr`). Typed nil +slices/maps mean empty containers; a nil `*big.Int` is invalid. Defined types, +arrays, structs, other pointers, bytes, `json.Number`, floats and custom marshalers +are not admitted. No reflection/coercion or custom marshaling runs in product code. +Do not mutate native containers/integers concurrently with these read operations. + +`apps/host_agent/internal/config` exposes `CanonicalPath(any)` (dot false) and +`CanonicalPathWithDot(any, any)`. Exact native string/bool validation belongs to +production code; fixture adapters never generate rejection codes. Defined string +or bool types, byte slices, pointers and malformed UTF-8 are `context_schema`. +These are untrusted-value predicates before a future caller's typed schema. + +Go has no scalar-string representation for a source surrogate. The vector adapters +preserve surrogate observations as invalid UTF-8 bytes in Go strings, so the real +validator rejects them without replacing them with U+FFFD. Source results remain +in the frozen fixture; only `product_expected` is normative for Go. A distinct +native bytearray type, actual Go arrays for tuples and a non-string native map +preserve unsupported-type cases through production validation. Native oversize +integers are constructed as `big.Int` before canonicalization/digest calls. + +Both implementations apply the accepted 4300-digit and 128-container limits; +JSON failures use fixed `invalid-json`, except oversized bytes parse input uses +`input-limit-exceeded` first. Path failures use fixed `context_schema`. The Go +errors expose only these safe codes. No Python, third-party library, filesystem +resolution, provider behavior or caller switch is introduced. + +T004's narrow paths are the two Go primitive directories, required Go test IDs in +`docs/development/ci-acceptance.json`, this migration inventory, Feature 006 status +in `tasks.md` and the pilot execution record. Frozen vectors, schema, generator, +source snapshots and manifest pins remain unchanged; Feature 001 task markers +remain unchanged. Independent Python reproduction and Go tests remain separate. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index 55b71559..eb63aefb 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -2,7 +2,8 @@ Status: execution in progress. T001 scaffold independently Sol CLEAR at `d3d84c8361a1389292fad96d02a828ac9fabc2e3`; T002 is independently CLEAR at -`bc18034164597aa841bea561cf6a882e9c0715e2`; T003 and later work items remain open. +`bc18034164597aa841bea561cf6a882e9c0715e2`; T003 independently CLEAR at +`191c62baf227f64122770244d62d289900bb4d8d`; T004 and later work items remain open. No product runtime acceptance is claimed. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), @@ -28,7 +29,7 @@ Independent check: clean-checkout format/build/tests with missing-tool failures. Goal: product boundary, durable owner state and reviewed Python-to-Go parity. Independent check: negative vectors, cross-language bytes and no runtime Python. -- [ ] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`, strict fixture schema, meaningful negative tests in `tests/test_go_vectors.py` and their required IDs in `docs/development/ci-acceptance.json`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). +- [x] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`, strict fixture schema, meaningful negative tests in `tests/test_go_vectors.py` and their required IDs in `docs/development/ci-acceptance.json`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). - [ ] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). - [ ] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). - [ ] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). @@ -111,8 +112,9 @@ acceptance and task completion rules; review evidence is appended, never fabrica ## Evidence log -T001 and T002 are independently accepted at the revisions above. T003 candidate -preparation is captured in contracts/migration and awaits independent review; -no Go port or product runtime acceptance is claimed. Durable check and review +T001 and T002 are independently accepted at the revisions above. T003 is independently accepted at +`191c62baf227f64122770244d62d289900bb4d8d`. T004 is being implemented and +remains unchecked pending its independent review; no product runtime acceptance +is claimed. Durable check and review evidence, including earlier failed runs, is retained in [the pilot execution record](../../docs/exec-plans/active/installable-pilot.md). From a3b77b38d3fa7e614f229ccc043b612058ba32b3 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 17:01:02 +0200 Subject: [PATCH 10/51] docs(pilot): record accepted owner persistence contract Persist the independently reviewed native identity and atomic durability rules before implementation, and record exact T004 acceptance without claiming later runtime evidence. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 24 ++++ specs/006-whr-cli/contracts/owner-api.md | 127 ++++++++++++++++++++ specs/006-whr-cli/data-model.md | 43 +++++++ specs/006-whr-cli/plan.md | 23 ++++ specs/006-whr-cli/tasks.md | 2 +- 5 files changed, 218 insertions(+), 1 deletion(-) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 6ba7428c..b9ce5084 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -500,3 +500,27 @@ are identical, and existing application IDs retain their order with six addition The author worktree's ignored `.specify/feature.json` is preserved (SHA-256 `05caf1787466a6410ce0cc3d1cdb371eae6187e38babba78a0c08d21edf1af83`); source-context checks must run in a pristine validation clone instead. + + +## T004 independent acceptance and T005 foundation start + +Independent Sol exact-implementation CLEAR accepted +`6abb7419c77b3712a6ac276bc798b102503d3144`. All 159 frozen Go cases and independent +Unicode/native probes passed. All three Go modules passed format/vet/build/tests. +Pristine source-context/identity, manifest/collection (520 required / 846 discovered), +repository and whitespace checks passed; exact-head resolved-root validation took +7.442 seconds. Separate Python reproduction covered 159 cases and 15 controls; +author fuzz observed 197471 JSON and 42174 path executions. These are macOS results; +no full new-head canonical, Linux or runtime acceptance is claimed. + +Two author command errors remain evidence: library build with -o incorrectly +requested executable output, and logical /var versus physical /private validation +clone spelling caused GOWORK-root mismatch. Both were corrected without production +changes. Feature 006 T004 is checked; original Feature 001 markers remain untouched. + +T005 implementation GO followed independent Sol design CLEAR of consolidated V3 +with explicit native boundary, transport-neutral literal repository identity, +separate intent/runtime counters, start-existing-runtime restriction, immutable +requested/effective configs, guarded terminal results and atomic reservation audit. +The accepted contract is now persisted in Feature 006 owner API/data model/plan +before tests/behavior. T005 remains unchecked pending exact-candidate review. diff --git a/specs/006-whr-cli/contracts/owner-api.md b/specs/006-whr-cli/contracts/owner-api.md index 5787e0af..86903005 100644 --- a/specs/006-whr-cli/contracts/owner-api.md +++ b/specs/006-whr-cli/contracts/owner-api.md @@ -28,3 +28,130 @@ Server enforces Host/Origin/anti-forgery, scope, runtime generation and attachme bounds. Stateful terminal filtering spans chunks and bounds unterminated control strings; C1 introducers and arbitrary bytes are covered by negative fixtures. Audit identities/action/outcome, never raw input, grants or credential payloads. + +## Accepted T005 foundation contract + +Independent Sol design review accepted the consolidated V3 foundation and final +observation-API clarification before implementation. This section defines native +application operations, not an authenticated/live HTTP endpoint. Authentication, +strict raw HTTP decoding, protocol, trust/provider work and lifecycle dispatch +remain T006–T010. T005 has no provider effects, dispatcher, retries or activation. + +### Native lifecycle identity + +`accept(owner_id, key, request, channel)` receives a string-keyed native map. +Owner is separately scoped, not an authentication proof. T006 must derive it from +verified credentials. Exactly these request fields are admitted: + +```json +{"schema_version":1,"workspace_id":"","action":"create","parameters":{"host_id":"","config":{"repository_identity":"git@example.test:team/workspace.git","commit":"","submodules":[{"path":"project","commit":""}]}}} +``` + +| Action | Exact parameters | +| --- | --- | +| create | host_id, config | +| start, stop, remove_runtime | empty object | +| rebuild | config | +| delete_data | reserved separate action; rejected in T005 | + +Version is mandatory, exactly integer 1. Parameters may be omitted only for an +empty-parameter action and become `{}`. Config requires repository_identity and +commit; omitted submodules become `[]`. Explicit null, unknown fields and wrong +types fail recursively. UUIDs use lowercase hyphenated spelling. Keys are required, +case-sensitive ASCII `[A-Za-z0-9._:-]{1,128}`; no server default. Commits are full +lowercase 40- or 64-hex object IDs; existence is not checked. Submodule entries +contain exactly path/commit, use the accepted lexical path predicate with dot=false, +reject duplicate paths and sort by Unicode scalar path order. No other defaults, +names or arbitrary provider options exist. Never fetch or re-resolve on replay. + +The normalized hash object adds owner_id from the scope argument. Store schema, +canonical bytes and SHA-256 digest; compare bytes and digest on replay. Accepted +T003 canonical UTF-8/scalar-key/no-normalization/no-LF semantics apply, including +128-container depth and 4300-digit integers. Canonicalization has no parse-input +byte cap. Exclude key, operation ID, channel, credentials/confirmation/trust grants, +tracing, timestamps, retry counts, wait/no-wait, timeout, output/color and generated +intent/runtime versions. Exclusions are separate context, never silently dropped +request fields. Native maps cannot prove duplicate-free raw input. Future HTTP +integration must reject duplicates before map collapse, malformed UTF-8, +surrogates, floats and raw byte-limit violations; T005 claims no raw decoder. + +### Literal repository identity + +Admit HTTPS URL, SSH URL and scp-form spellings without fetching or rewriting. +These are lexical storage identities, not a fetch support matrix or canonical +remote/trust identity. Alias spellings remain distinct and never inherit trust. +Do not trim, case-fold, Unicode-normalize, percent-decode, strip .git/default ports, +follow redirects or resolve aliases. Actual fetch support and origin/trust +resolution remain T009. + +Reject empty strings, backslash, C0, DEL, `?`, `#` and Unicode White_Space: +U+0009–000D, U+0020, U+0085, U+00A0, U+1680, U+2000–200A, U+2028–2029, +U+202F, U+205F, U+3000. Every percent sign forms a two-ASCII-hex-digit triplet, +retained unchanged. Unbracketed hosts are nonempty dot-separated labels matching +`[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?`, optionally one trailing dot; no +underscores or interior empty labels. Numeric labels do not prove IPv4 validity; +there is no DNS length/network validity claim. Bracketed hosts are locally parsed +IPv6 literals yielding eight components, without zone IDs, preserving spelling. + +Recognize HTTPS:// and SSH:// ASCII-case-insensitively and retain scheme spelling. +Reject another scheme:// prefix rather than treating it as scp. URL shapes are +`https://host[:port]/suffix` (no userinfo) and +`ssh://[username@]host[:port]/suffix` (no password). Suffix may be empty. Username +matches `[A-Za-z0-9._-]+`, without colon or percent escapes. Port is nonempty +ASCII digits with value 1–65535; leading zeros remain distinct. Scp shape is +`[username@]host:nonempty-path`; delimiter is the first colon outside brackets, +there is no port syntax, and its path may be relative/absolute with later colons/@. + +### Atomic acceptance and metadata guards + +Use a short immediate SQLite transaction. Scope-authorized owner/workspace/key +lookup precedes busy, tombstone and current-state preconditions or mutations. +Equal normalized identity returns the same operation/status/outcome. Changed +identity conflicts. New-key acceptance enforces owner/host scope, stable create +identity, action preconditions and one unfinished operation/workspace. Busy +refusal consumes no version. Acceptance CAS-increments intent_version, inserts +queued operation, desired-state projection, required create storage metadata, +runtime bindings/reservations and audit in the same transaction. Any failure rolls +back all rows. Create/start/rebuild desire running; stop/remove_runtime desire +stopped. Commit precedes acceptance response or any later external effect. The +operation is durable reconciliation intent; T005 installs no effect consumer. + +Claim CAS allows queued->executing only with matching scope/current intent and +atomically audits. Repeated executing claim reads never authorize another effect. +Candidate reservation permits create/rebuild while queued/executing, derives +config only from immutable operation request, and reuses a matching reservation. +One candidate/operation; conflicting reservation is rejected. Initial reservations +and their audits are part of acceptance; any later reservation is independently +atomic before an external call. + +Terminal results require scoped operation, intent_version and exact target/candidate +runtime ID/generation bindings. Nonterminal writes also require workspace current +intent and expected status; matching runtime generation alone is insufficient. +Allow queued->failed, executing->succeeded/failed; forbid queued->succeeded and +terminal->nonterminal. Foundation outcomes are exactly status/code pairs: +`{"status":"succeeded","code":"ok"}` or +`{"status":"failed","code":"operation_failed"}`. Optional typed observation +metadata, terminal result/outcome and audit commit together. Provider ID cannot +change once set. Effective config must match candidate requested revisions; +existing-target start cannot change effective config. Terminal equal result replay +checks scope/bindings and returns immutable recorded outcome even after newer +intent; conflicting status/code/observation cannot overwrite or append audit. +No separate repeatable nonterminal observation API exists in T005. + +### Audit and later boundaries + +One-time accepted, claimed, terminal and runtime_reserved events contain generated +ID, owner/host/workspace/operation IDs, action, intent version, fixed channel enum +(cli/browser/internal), outcome/code and UTC time. Runtime events include runtime +ID/generation. Accepted event records created storage metadata IDs. Every new +candidate emits runtime_reserved atomically with allocator/runtime/binding writes, +including initial acceptance; equal replay emits none. Unique operation/event +applies only to these one-time events. Terminal audit covers its observation +updates. Exclude request contents, URLs, handles, raw names/messages/provider +payloads, input, tokens, grants and credentials. Admin metadata creation is audited. + +Runtime/data deletion have separate operation identities/keys. T005 refuses +reserved delete_data until later explicit confirmation/authorization. Runtime and +storage tombstones represent confirmed outcomes only, never queued removal. +Preserve workspace identity, keys, outcomes and audit indefinitely in the pilot; +no destructive cascades/identity reuse. T010/T011 define rm --data coordination. diff --git a/specs/006-whr-cli/data-model.md b/specs/006-whr-cli/data-model.md index 720624ab..820de0ab 100644 --- a/specs/006-whr-cli/data-model.md +++ b/specs/006-whr-cli/data-model.md @@ -18,3 +18,46 @@ Status: proposed extension of Feature 001 domain contracts. Retain Feature 001 ownership, separate storage lifecycle, desired/observed state, generation checks and operation outcomes. Restore resets observations to unknown and does not start runtime. Lost agent-input delivery is unknown, never auto-resend. + +## Accepted T005 persistence foundation + +[Native identity and guards](contracts/owner-api.md#accepted-t005-foundation-contract) +are authoritative. SQLite foreign keys enforce owner/host/workspace scope; +changesets alone are insufficient. Hosts have mandatory owner_id and cannot be +reassigned in the pilot. Workspace host/owner, operation workspace/owner, and +storage/runtime workspace/host references are composite pairs. Binding records +pair operation/workspace and runtime/workspace. Use pairwise references because +the locked adapter supports only two-column total composite FK helpers. + +Operations enforce unique (owner, workspace, key), unique (workspace, intent_version) +and one queued/executing operation/workspace. Runtime generations are unique per +workspace; a reservation belongs to one operation with at most one candidate. +One runtime may be marked current; pending candidates are not current. Opaque +provider handles are nullable until allocation and runtime IDs immutable once set. + +Workspace intent_version starts at zero and advances once/new accepted operation. +last_runtime_generation is a separate allocator: only a new RuntimeInstance +reservation advances it. Pending reservation is not a running/current replacement. +Replay allocates nothing; abandoned generations are never reused. Start/stop of +the same target keep runtime generation. Results bind operation+intent version +and runtime ID+generation, preventing old same-runtime results from overwriting +new intent. Desired states remain running/stopped, not deleted. + +Requested configuration is immutable in operation identity. Workspace requested +config is only desired projection. Runtime effective_config remains null until +validated observation, and never changes on an older runtime just because rebuild +was queued/failed. Candidate config comes solely from its requesting operation. +T005 start requires an existing current runtime with nonnull effective config, +captures that immutable execution_config_snapshot, and cannot reserve replacement; +reviewed replacement-start behavior remains T010. Failed rebuild workspace display +and session invalidation are later lifecycle work, not metadata success claims. + +Storage outlives runtime. Runtime removal preserves workspace/storage, and only +confirmed outcomes set tombstones. Retain IDs and operation key/outcome history +indefinitely for internal pilot. Data deletion remains unsupported in T005. + +Explicit file-backed WAL, foreign_keys=on, synchronous=full, busy_timeout=2000 ms +are required. Verify FK/synchronous on every writer connection. Race/crash tests +use independently committed connections outside wrapping SQL Sandbox transactions: +Exqlite turns nested immediate transactions into SAVEPOINT. Reopen/process-kill +results do not establish hardware power-loss resilience. diff --git a/specs/006-whr-cli/plan.md b/specs/006-whr-cli/plan.md index 6bf1e226..a54c60ef 100644 --- a/specs/006-whr-cli/plan.md +++ b/specs/006-whr-cli/plan.md @@ -150,3 +150,26 @@ disposable Linux host, TLS hostname/certificate and live-test scope remain owner-controlled inputs required for pilot acceptance; signer allowlist and release authorization are inputs to the later release exercise. A configured Docker context establishes none of them. + +## T005 accepted foundation and verification + +Independent Sol design CLEAR covers the consolidated V3 contract in the +[owner API](contracts/owner-api.md#accepted-t005-foundation-contract) and +[data model](data-model.md#accepted-t005-persistence-foundation). Implementation +is limited to native normalization/canonical bytes, scoped records/constraints, +atomic intent/reservations/audit, metadata claim/result guards and retained +storage/history. No auth, raw HTTP decoder, trust/fetch, provider, effect consumer, +replacement-start, delete_data acceptance or repeatable observation API is added. + +Source lives in apps/control_plane/lib/workharbor/foundation/, migrations in +apps/control_plane/priv/repo/migrations/, tests in apps/control_plane/test/workharbor/, +and isolated database helpers in apps/control_plane/test/support/. Scoped Repo +configuration pins durability settings; migration formatting includes the new files. +Meaningful Phoenix IDs append to the existing acceptance manifest without changing +prior IDs/policy. Write tests before behavior and record actual absent-implementation +RED. Verify native independent canonical anchors and all identity negatives; +direct-SQL constraints; independent writer races; same-generation stale intent; +immutable terminal replay; audit rollback/redaction; process crash and file reopen; +per-writer effective pragmas. Run Phoenix format/compile warnings-as-errors/tests +and pristine source/manifest/repository/whitespace checks. Assets need no change. +Full combined-head canonical and live pilot evidence remain later required gates. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index eb63aefb..9485be45 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -30,7 +30,7 @@ Goal: product boundary, durable owner state and reviewed Python-to-Go parity. Independent check: negative vectors, cross-language bytes and no runtime Python. - [x] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`, strict fixture schema, meaningful negative tests in `tests/test_go_vectors.py` and their required IDs in `docs/development/ci-acceptance.json`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). -- [ ] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). +- [x] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). - [ ] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). - [ ] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). - [ ] T007 Define/validate versioned host protocol in `contracts/host-protocol/`, `apps/control_plane/lib/workharbor/host_protocol.ex`, `apps/host_agent/internal/protocol/`; reject invalid version/scope/replay before provider calls and redact diagnostics in both suites (001 T003). From 3fad697301e2208f7aebd5eb29b3b1f3b6226650 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 17:41:14 +0200 Subject: [PATCH 11/51] feat(owner): persist scoped durable operation intent Commit normalized lifecycle identity, scoped storage/runtime reservations and audit before any future provider effect. Keep intent versions separate from runtime generations, guard terminal results and retain immutable replay history with SQLite constraints and real failure/restart evidence. Refs: #72 Co-authored-by: Codex --- apps/control_plane/README.md | 18 +- apps/control_plane/config/config.exs | 8 + .../lib/workharbor/foundation.ex | 552 +++++++++ .../lib/workharbor/foundation/canonical.ex | 58 + .../lib/workharbor/foundation/records.ex | 135 +++ .../foundation/repository_identity.ex | 75 ++ .../lib/workharbor/foundation/request.ex | 121 ++ .../20261014000000_owner_foundation.exs | 204 ++++ .../test/support/foundation_database.ex | 129 ++ .../workharbor/foundation_request_test.exs | 246 ++++ .../test/workharbor/foundation_test.exs | 1078 +++++++++++++++++ docs/development/ci-acceptance.json | 36 +- docs/exec-plans/active/installable-pilot.md | 74 +- specs/006-whr-cli/contracts/owner-api.md | 58 +- specs/006-whr-cli/plan.md | 3 +- specs/006-whr-cli/tasks.md | 11 +- 16 files changed, 2793 insertions(+), 13 deletions(-) create mode 100644 apps/control_plane/lib/workharbor/foundation.ex create mode 100644 apps/control_plane/lib/workharbor/foundation/canonical.ex create mode 100644 apps/control_plane/lib/workharbor/foundation/records.ex create mode 100644 apps/control_plane/lib/workharbor/foundation/repository_identity.ex create mode 100644 apps/control_plane/lib/workharbor/foundation/request.ex create mode 100644 apps/control_plane/priv/repo/migrations/20261014000000_owner_foundation.exs create mode 100644 apps/control_plane/test/support/foundation_database.ex create mode 100644 apps/control_plane/test/workharbor/foundation_request_test.exs create mode 100644 apps/control_plane/test/workharbor/foundation_test.exs diff --git a/apps/control_plane/README.md b/apps/control_plane/README.md index d370a04d..06d2ee2e 100644 --- a/apps/control_plane/README.md +++ b/apps/control_plane/README.md @@ -4,7 +4,7 @@ Generated with the official Phoenix 1.8.15 installer, retaining Ecto/SQLite, HTML, LiveView and assets. No mailer, dashboard, Gettext or generated agent instructions are included. Unused DNS clustering is omitted for the initial single-instance SQLite control plane. This is a development scaffold, not a usable platform; -no owner API or lifecycle/provider behavior exists yet. +no authenticated HTTP owner API or lifecycle/provider execution exists yet. The root `.tool-versions` pins the tested toolchains. `mix.lock` pins every resolved Hex dependency and the heroicons/daisyUI Git revisions. The esbuild and @@ -30,3 +30,19 @@ of product UI is claimed. `assets/tsconfig.json` supplies editor completion with Mix dependency paths; its generated comments were removed to comply with the repository's strict JSON gate. + + +## T005 metadata foundation + +Native contexts under lib/workharbor/foundation/ enforce scoped SQLite records, +canonical request identity, separate intent/runtime counters, atomic audit and +metadata result guards. The accepted contract is +[Feature 006 owner API](../../specs/006-whr-cli/contracts/owner-api.md). +There is no dispatcher/provider or raw HTTP decoder. Start requires an existing +current runtime with known effective config; replacement-start remains T010. + +Foundation tests use separate private file-backed databases and independent writer +connections, not wrapping Sandbox transactions. They include bounded subprocess +SIGKILL/reopen evidence and effective per-writer durability pragmas. These tests +are native persistence evidence, not live platform or hardware power-loss acceptance. +The existing scoped migration formatter covers priv/repo/migrations/*.exs. diff --git a/apps/control_plane/config/config.exs b/apps/control_plane/config/config.exs index 17d19ac0..d5a9a20a 100644 --- a/apps/control_plane/config/config.exs +++ b/apps/control_plane/config/config.exs @@ -12,6 +12,14 @@ config :workharbor, ecto_repos: [WorkHarbor.Repo], generators: [timestamp_type: :utc_datetime, binary_id: true] +# T005 metadata durability; no effect consumer or live server is enabled here. +config :workharbor, WorkHarbor.Repo, + journal_mode: :wal, + synchronous: :full, + foreign_keys: :on, + busy_timeout: 2000, + log: false + # Configure the endpoint config :workharbor, WorkHarborWeb.Endpoint, url: [host: "localhost"], diff --git a/apps/control_plane/lib/workharbor/foundation.ex b/apps/control_plane/lib/workharbor/foundation.ex new file mode 100644 index 00000000..a4fcdc4b --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation.ex @@ -0,0 +1,552 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation do + @moduledoc """ + Owner-scoped persistence only. No authentication, provider calls or effect consumer. + Mutations require a top-level transaction so returned acceptance is committed. + Owner context must be authenticated by the later API layer, never by request data. + """ + import Ecto.Query + alias WorkHarbor.Repo + + alias WorkHarbor.Foundation.{ + Owner, + Host, + Workspace, + Operation, + PersistentStorage, + RuntimeInstance, + RuntimeBinding, + AuditEvent, + Request, + Canonical + } + + @channels ["cli", "browser", "internal"] + @terminal ["succeeded", "failed"] + @counter_max 9_223_372_036_854_775_807 + + def create_owner(display_name) when is_binary(display_name) do + if String.valid?(display_name) and display_name != "" do + transaction(fn -> + owner = Repo.insert!(%Owner{display_name: display_name}) + + Repo.insert!(%AuditEvent{ + owner_id: owner.id, + event: "owner_created", + action: "owner_metadata", + channel: "internal", + outcome: "recorded" + }) + + owner + end) + else + {:error, :invalid_request} + end + end + + def create_owner(_), do: {:error, :invalid_request} + + def create_host(owner_id) do + transaction(fn -> + owner!(owner_id) + host = Repo.insert!(%Host{owner_id: owner_id}) + + Repo.insert!(%AuditEvent{ + owner_id: owner_id, + host_id: host.id, + event: "host_recorded", + action: "host_metadata", + channel: "internal", + outcome: "recorded" + }) + + host + end) + end + + def accept(owner_id, key, request, channel) do + with true <- channel in @channels, + {:ok, normalized} <- Request.normalize(owner_id, key, request) do + transaction(fn -> + owner!(owner_id) + identity = normalized.request + + existing = + Repo.get_by(Operation, + owner_id: owner_id, + workspace_id: identity["workspace_id"], + idempotency_key: key + ) + + if existing do + if existing.request_bytes == normalized.bytes and + existing.request_hash == normalized.hash, + do: {:replayed, existing}, + else: Repo.rollback(:idempotency_conflict) + else + accept_new!(owner_id, key, normalized, channel) + end + end) + else + false -> {:error, :invalid_channel} + error -> error + end + end + + def lookup(owner_id, workspace_id, key) do + with true <- Request.uuid?(owner_id) and Request.uuid?(workspace_id) and Request.key?(key), + %Operation{} = operation <- + Repo.get_by(Operation, + owner_id: owner_id, + workspace_id: workspace_id, + idempotency_key: key + ) do + {:ok, operation} + else + _ -> {:error, :not_found} + end + end + + def bindings(owner_id, host_id, operation_id) do + with true <- Enum.all?([owner_id, host_id, operation_id], &Request.uuid?/1), + {operation, _workspace} <- scoped_operation(owner_id, host_id, operation_id) do + bindings!(operation.id) + else + _ -> {:error, :not_found} + end + end + + def claim(owner_id, host_id, operation_id, intent_version) do + transaction(fn -> + {operation, workspace} = scoped_operation!(owner_id, host_id, operation_id) + current_intent!(operation, workspace, intent_version) + + case operation.status do + "queued" -> + updated = update_operation!(operation, status: "executing") + audit!(updated, workspace, "operation_claimed", "internal", "executing") + {:claimed, updated} + + "executing" -> + {:already_executing, operation} + + _ -> + Repo.rollback(:invalid_transition) + end + end) + end + + def reserve_candidate(owner_id, host_id, operation_id, intent_version) do + transaction(fn -> + {operation, workspace} = scoped_operation!(owner_id, host_id, operation_id) + current_intent!(operation, workspace, intent_version) + if operation.status not in ["queued", "executing"], do: Repo.rollback(:invalid_transition) + if operation.action not in ["create", "rebuild"], do: Repo.rollback(:invalid_action) + candidate!(operation, workspace) + end) + end + + def finish( + owner_id, + host_id, + operation_id, + intent_version, + expected_status, + expected_bindings, + outcome, + observation \\ nil + ) do + with {:ok, result} <- terminal_result(outcome, observation) do + transaction(fn -> + {operation, workspace} = scoped_operation!(owner_id, host_id, operation_id) + if operation.intent_version !== intent_version, do: Repo.rollback(:stale_intent) + actual = bindings!(operation.id) + if actual !== expected_bindings, do: Repo.rollback(:runtime_mismatch) + + if operation.status in @terminal do + if operation.terminal_result === result, + do: {:replayed, operation}, + else: Repo.rollback(:result_conflict) + else + current_intent!(operation, workspace, intent_version) + if operation.status != expected_status, do: Repo.rollback(:invalid_transition) + status = result["outcome"]["status"] + + if operation.status not in ["queued", "executing"] or + (status == "succeeded" and operation.status != "executing"), + do: Repo.rollback(:invalid_transition) + + observe_terminal!(operation, actual, result) + updated = update_operation!(operation, status: status, terminal_result: result) + + audit!(updated, workspace, "operation_terminal", "internal", status, + code: result["outcome"]["code"], + observation: result["observation"] + ) + + {:finished, updated} + end + end) + end + end + + defp accept_new!(owner_id, key, normalized, channel) do + request = normalized.request + parameters = request["parameters"] + action = request["action"] + workspace = Repo.get(Workspace, request["workspace_id"]) + if workspace && workspace.owner_id != owner_id, do: Repo.rollback(:not_found) + + if workspace && + Repo.exists?( + from o in Operation, + where: o.workspace_id == ^workspace.id and o.status in ["queued", "executing"] + ), + do: Repo.rollback(:workspace_busy) + + if action == "create" do + if workspace, do: Repo.rollback(:workspace_exists) + host = Repo.get_by(Host, id: parameters["host_id"], owner_id: owner_id) + if is_nil(host), do: Repo.rollback(:not_found) + else + if is_nil(workspace), do: Repo.rollback(:not_found) + end + + target = + if workspace, + do: Repo.get_by(RuntimeInstance, workspace_id: workspace.id, is_current: true), + else: nil + + if action == "start" and (is_nil(target) or is_nil(target.effective_config)), + do: Repo.rollback(:runtime_unavailable) + + if action == "rebuild" and + not Repo.exists?( + from s in PersistentStorage, + where: s.workspace_id == ^workspace.id and is_nil(s.deleted_at) + ), + do: Repo.rollback(:storage_unavailable) + + desired = if action in ["stop", "remove_runtime"], do: "stopped", else: "running" + config = parameters["config"] || (workspace && workspace.requested_config) + + workspace = + if workspace do + if workspace.intent_version >= @counter_max, do: Repo.rollback(:counter_exhausted) + + {count, _} = + Repo.update_all( + from(w in Workspace, + where: w.id == ^workspace.id and w.intent_version == ^workspace.intent_version + ), + set: [ + intent_version: workspace.intent_version + 1, + desired_state: desired, + requested_config: config, + updated_at: now() + ] + ) + + if count != 1, do: Repo.rollback(:stale_intent) + Repo.get!(Workspace, workspace.id) + else + Repo.insert!(%Workspace{ + id: request["workspace_id"], + owner_id: owner_id, + host_id: parameters["host_id"], + desired_state: desired, + intent_version: 1, + requested_config: config + }) + end + + snapshot = if action == "start", do: target.effective_config, else: parameters["config"] + + operation = + Repo.insert!(%Operation{ + owner_id: owner_id, + workspace_id: workspace.id, + idempotency_key: key, + action: action, + request_version: 1, + request_bytes: normalized.bytes, + request_hash: normalized.hash, + intent_version: workspace.intent_version, + status: "queued", + execution_config_snapshot: snapshot + }) + + storage_ids = + if action == "create" do + storage = + Repo.insert!(%PersistentStorage{ + workspace_id: workspace.id, + host_id: workspace.host_id, + reservation_operation_id: operation.id + }) + + [storage.id] + else + [] + end + + if target, do: bind!(operation, target, "target") + if action in ["create", "rebuild"], do: candidate!(operation, workspace) + + audit!(operation, workspace, "operation_accepted", channel, "queued", + storage_ids: storage_ids + ) + + {:accepted, operation} + end + + defp candidate!(operation, workspace) do + case Repo.get_by(RuntimeInstance, reservation_operation_id: operation.id) do + %RuntimeInstance{} = existing -> + binding = + Repo.get_by(RuntimeBinding, + operation_id: operation.id, + role: "candidate", + runtime_id: existing.id + ) + + if is_nil(binding), do: Repo.rollback(:runtime_mismatch) + {:replayed, existing} + + nil -> + if workspace.last_runtime_generation >= @counter_max, + do: Repo.rollback(:counter_exhausted) + + generation = workspace.last_runtime_generation + 1 + + {count, _} = + Repo.update_all( + from(w in Workspace, + where: + w.id == ^workspace.id and + w.last_runtime_generation == ^workspace.last_runtime_generation + ), + set: [last_runtime_generation: generation, updated_at: now()] + ) + + if count != 1, do: Repo.rollback(:stale_intent) + + runtime = + Repo.insert!(%RuntimeInstance{ + workspace_id: workspace.id, + host_id: workspace.host_id, + reservation_operation_id: operation.id, + generation: generation, + observed_state: "pending" + }) + + bind!(operation, runtime, "candidate") + audit!(operation, workspace, "runtime_reserved", "internal", "pending", runtime: runtime) + {:reserved, runtime} + end + end + + defp bind!(operation, runtime, role) do + Repo.insert!(%RuntimeBinding{ + operation_id: operation.id, + workspace_id: operation.workspace_id, + runtime_id: runtime.id, + role: role + }) + end + + defp bindings!(operation_id) do + rows = + Repo.all( + from b in RuntimeBinding, + join: r in RuntimeInstance, + on: r.id == b.runtime_id, + where: b.operation_id == ^operation_id, + select: {b.role, r.id, r.generation} + ) + + Enum.reduce(rows, %{"target" => nil, "candidate" => nil}, fn {role, id, generation}, acc -> + Map.put(acc, role, %{"id" => id, "generation" => generation}) + end) + end + + defp terminal_result(outcome, observation) do + allowed = [ + %{"status" => "succeeded", "code" => "ok"}, + %{"status" => "failed", "code" => "operation_failed"} + ] + + with true <- outcome in allowed, + {:ok, observation} <- observation(observation), + {:ok, _} <- Canonical.bytes(%{"outcome" => outcome, "observation" => observation}) do + {:ok, %{"outcome" => outcome, "observation" => observation}} + else + _ -> {:error, :invalid_result} + end + end + + defp observation(nil), do: {:ok, nil} + + defp observation(value) when is_map(value) and not is_struct(value) do + required = ["runtime_id", "generation", "observed_state"] + optional = ["provider_instance_id", "effective_config", "deleted"] + + valid = + Enum.all?(required, &Map.has_key?(value, &1)) and + Enum.all?(Map.keys(value), &(&1 in (required ++ optional))) and + Request.uuid?(value["runtime_id"]) and is_integer(value["generation"]) and + value["generation"] > 0 and + value["observed_state"] in [ + "pending", + "provisioning", + "running", + "stopping", + "stopped", + "failed", + "unknown" + ] and + Map.get(value, "deleted", false) in [true, false] and + (is_nil(value["provider_instance_id"]) or + (is_binary(value["provider_instance_id"]) and + String.valid?(value["provider_instance_id"]))) + + with true <- valid, + {:ok, effective} <- effective_config(value["effective_config"]) do + {:ok, + %{"provider_instance_id" => nil, "deleted" => false} + |> Map.merge(value) + |> Map.put("effective_config", effective)} + else + _ -> {:error, :invalid_result} + end + end + + defp observation(_), do: {:error, :invalid_result} + + defp effective_config(nil), do: {:ok, nil} + defp effective_config(value), do: Request.normalize_config(value) + + defp observe_terminal!(_operation, _bindings, %{"observation" => nil}), do: :ok + + defp observe_terminal!(operation, bindings, result) do + observed = result["observation"] + identity = %{"id" => observed["runtime_id"], "generation" => observed["generation"]} + role = Enum.find_value(bindings, fn {role, binding} -> if binding == identity, do: role end) + if is_nil(role), do: Repo.rollback(:runtime_mismatch) + runtime = Repo.get!(RuntimeInstance, identity["id"]) + provider = observed["provider_instance_id"] + effective = observed["effective_config"] + + if not is_nil(runtime.provider_instance_id) and not is_nil(provider) and + runtime.provider_instance_id != provider, + do: Repo.rollback(:runtime_mismatch) + + if role == "target" and not is_nil(effective) and is_nil(runtime.effective_config), + do: Repo.rollback(:runtime_mismatch) + + if not is_nil(runtime.effective_config) and not is_nil(effective) and + runtime.effective_config !== effective, + do: Repo.rollback(:runtime_mismatch) + + if not is_nil(effective) and role == "candidate" and + effective !== operation.execution_config_snapshot, + do: Repo.rollback(:runtime_mismatch) + + if operation.action == "start" and not is_nil(effective) and + effective !== operation.execution_config_snapshot, + do: Repo.rollback(:runtime_mismatch) + + deleted = observed["deleted"] + + if deleted and + (operation.action != "remove_runtime" or role != "target" or + result["outcome"]["status"] != "succeeded" or observed["observed_state"] != "stopped"), + do: Repo.rollback(:invalid_result) + + changes = [ + observed_state: observed["observed_state"], + provider_instance_id: + if(is_nil(provider), do: runtime.provider_instance_id, else: provider), + effective_config: if(is_nil(effective), do: runtime.effective_config, else: effective) + ] + + changes = if deleted, do: changes ++ [deleted_at: now(), is_current: false], else: changes + Repo.update!(Ecto.Changeset.change(runtime, changes)) + end + + defp update_operation!(operation, changes) do + {count, _} = + Repo.update_all( + from(o in Operation, where: o.id == ^operation.id and o.status == ^operation.status), + set: changes ++ [updated_at: now()] + ) + + if count != 1, do: Repo.rollback(:invalid_transition) + Repo.get!(Operation, operation.id) + end + + defp audit!(operation, workspace, event, channel, outcome, options \\ []) do + runtime = options[:runtime] + observed = options[:observation] + metadata = if options[:storage_ids], do: %{"storage_ids" => options[:storage_ids]}, else: %{} + + Repo.insert!(%AuditEvent{ + owner_id: operation.owner_id, + host_id: workspace.host_id, + workspace_id: workspace.id, + operation_id: operation.id, + event: event, + action: operation.action, + intent_version: operation.intent_version, + channel: channel, + outcome: outcome, + code: options[:code], + runtime_id: (runtime && runtime.id) || (observed && observed["runtime_id"]), + runtime_generation: (runtime && runtime.generation) || (observed && observed["generation"]), + metadata: metadata + }) + end + + defp owner!(id) do + if not Request.uuid?(id) or is_nil(Repo.get(Owner, id)), do: Repo.rollback(:not_found) + end + + defp scoped_operation(owner, host, id) do + Repo.one( + from o in Operation, + join: w in Workspace, + on: w.id == o.workspace_id, + where: + o.id == ^id and o.owner_id == ^owner and w.owner_id == ^owner and w.host_id == ^host, + select: {o, w} + ) + end + + defp scoped_operation!(owner, host, id) do + if not Enum.all?([owner, host, id], &Request.uuid?/1), do: Repo.rollback(:not_found) + scoped_operation(owner, host, id) || Repo.rollback(:not_found) + end + + defp current_intent!(operation, workspace, expected) do + if operation.intent_version !== expected or workspace.intent_version !== expected, + do: Repo.rollback(:stale_intent) + end + + defp now, do: DateTime.utc_now() + + defp transaction(work) do + if Repo.in_transaction?() do + {:error, :nested_transaction} + else + Repo.transaction(work, mode: :immediate) + end + rescue + error in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + # Never return adapter exceptions, SQL or their parameter-bearing diagnostics. + if String.contains?(Exception.message(error), ["database is locked", "database is busy"]), + do: {:error, :persistence_busy}, + else: {:error, :persistence_failed} + end +end diff --git a/apps/control_plane/lib/workharbor/foundation/canonical.ex b/apps/control_plane/lib/workharbor/foundation/canonical.ex new file mode 100644 index 00000000..3ff029b1 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/canonical.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.Canonical do + @moduledoc "Native integer-only canonical bytes; this module does not parse raw JSON." + @integer_limit Integer.pow(10, 4300) + + def bytes(value) do + {:ok, value |> encode(0) |> IO.iodata_to_binary()} + catch + :invalid_json -> {:error, :invalid_json} + end + + defp encode(nil, _), do: "null" + defp encode(true, _), do: "true" + defp encode(false, _), do: "false" + + defp encode(value, _) + when is_integer(value) and value > -@integer_limit and value < @integer_limit, + do: Integer.to_string(value) + + defp encode(value, _) when is_binary(value) do + if not String.valid?(value), do: throw(:invalid_json) + [34, for(<>, do: escape(point)), 34] + end + + defp encode(value, depth) when is_list(value) and depth < 128, + do: [91, list(value, depth + 1), 93] + + defp encode(value, depth) when is_map(value) and not is_struct(value) and depth < 128 do + keys = Map.keys(value) + if Enum.any?(keys, &(not is_binary(&1) or not String.valid?(&1))), do: throw(:invalid_json) + # UTF-8 lexical order agrees with Unicode scalar order for valid scalar strings. + entries = + for key <- Enum.sort(keys), + do: [encode(key, depth + 1), 58, encode(Map.fetch!(value, key), depth + 1)] + + [123, Enum.intersperse(entries, 44), 125] + end + + defp encode(_, _), do: throw(:invalid_json) + defp list([], _), do: [] + defp list([value], depth), do: encode(value, depth) + defp list([value | rest], depth), do: [encode(value, depth), 44, list(rest, depth)] + defp list(_, _), do: throw(:invalid_json) + defp escape(34), do: "\\\"" + defp escape(92), do: "\\\\" + defp escape(8), do: "\\b" + defp escape(12), do: "\\f" + defp escape(10), do: "\\n" + defp escape(13), do: "\\r" + defp escape(9), do: "\\t" + + defp escape(point) when point < 32, + do: + "\\u" <> (point |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(4, "0")) + + defp escape(point), do: <> +end diff --git a/apps/control_plane/lib/workharbor/foundation/records.ex b/apps/control_plane/lib/workharbor/foundation/records.ex new file mode 100644 index 00000000..89d97b20 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/records.ex @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.Owner do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "owners" do + field :display_name, :string + timestamps() + end +end + +defmodule WorkHarbor.Foundation.Host do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "hosts" do + field :owner_id, :binary_id + field :identity_fingerprint, :string + field :capabilities, {:array, :string}, default: [] + field :last_seen, :utc_datetime_usec + field :lease_expires_at, :utc_datetime_usec + timestamps() + end +end + +defmodule WorkHarbor.Foundation.Workspace do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "workspaces" do + field :owner_id, :binary_id + field :host_id, :binary_id + field :desired_state, :string + field :intent_version, :integer, default: 0 + field :last_runtime_generation, :integer, default: 0 + field :requested_config, :map + timestamps() + end +end + +defmodule WorkHarbor.Foundation.Operation do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "operations" do + field :owner_id, :binary_id + field :workspace_id, :binary_id + field :idempotency_key, :string + field :action, :string + field :request_version, :integer + field :request_bytes, :binary + field :request_hash, :string + field :intent_version, :integer + field :status, :string + field :execution_config_snapshot, :map + field :terminal_result, :map + timestamps() + end +end + +defmodule WorkHarbor.Foundation.PersistentStorage do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "persistent_storage" do + field :workspace_id, :binary_id + field :host_id, :binary_id + field :reservation_operation_id, :binary_id + field :provider_handle, :string + field :deleted_at, :utc_datetime_usec + timestamps() + end +end + +defmodule WorkHarbor.Foundation.RuntimeInstance do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "runtime_instances" do + field :workspace_id, :binary_id + field :host_id, :binary_id + field :reservation_operation_id, :binary_id + field :generation, :integer + field :provider_instance_id, :string + field :observed_state, :string + field :effective_config, :map + field :is_current, :boolean, default: false + field :deleted_at, :utc_datetime_usec + timestamps() + end +end + +defmodule WorkHarbor.Foundation.RuntimeBinding do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "operation_runtime_bindings" do + field :operation_id, :binary_id + field :workspace_id, :binary_id + field :runtime_id, :binary_id + field :role, :string + timestamps() + end +end + +defmodule WorkHarbor.Foundation.AuditEvent do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "audit_events" do + field :owner_id, :binary_id + field :host_id, :binary_id + field :workspace_id, :binary_id + field :operation_id, :binary_id + field :event, :string + field :action, :string + field :intent_version, :integer + field :channel, :string + field :outcome, :string + field :code, :string + field :runtime_id, :binary_id + field :runtime_generation, :integer + field :metadata, :map, default: %{} + timestamps() + end +end diff --git a/apps/control_plane/lib/workharbor/foundation/repository_identity.ex b/apps/control_plane/lib/workharbor/foundation/repository_identity.ex new file mode 100644 index 00000000..259c7946 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/repository_identity.ex @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.RepositoryIdentity do + @moduledoc "Literal lexical origin identity, without fetching or remote alias equivalence." + @username ~r/\A[A-Za-z0-9._-]+\z/ + @host ~r/\A[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.?\z/ + @forbidden ~r/[\x00-\x20\x7f\x{0085}\x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\\?#]/u + + def valid?(value) when is_binary(value) do + String.valid?(value) and value != "" and not Regex.match?(@forbidden, value) and + not String.contains?(Regex.replace(~r/%[0-9A-Fa-f]{2}/, value, ""), "%") and shape?(value) + end + + def valid?(_), do: false + + defp shape?(value) do + case Regex.run(~r/\A(https|ssh):\/\/([^\/]+)(\/.*)\z/is, value) do + [_, scheme, authority, _path] -> + authority?(authority, String.downcase(scheme) == "ssh", true) + + _ -> + not Regex.match?(~r/\A[A-Za-z][A-Za-z0-9+.-]*:\/\//, value) and scp?(value) + end + end + + defp scp?(value) do + case Regex.run(~r/\A((?:[A-Za-z0-9._-]+@)?(?:\[[^\]]+\]|[^:]+)):(.+)\z/s, value) do + [_, authority, _path] -> authority?(authority, true, false) + _ -> false + end + end + + defp authority?(authority, username_allowed, port_allowed) do + case String.split(authority, "@") do + [host] -> + host_port?(host, port_allowed) + + [username, host] -> + username_allowed and Regex.match?(@username, username) and host_port?(host, port_allowed) + + _ -> + false + end + end + + defp host_port?(authority, ports) do + captures = + if String.starts_with?(authority, "["), + do: Regex.run(~r/\A(\[[^\]]+\])(?::([0-9]+))?\z/, authority), + else: Regex.run(~r/\A([^:]+)(?::([0-9]+))?\z/, authority) + + case captures do + [_, host] -> host?(host) + [_, host, port] -> ports and host?(host) and port?(port) + _ -> false + end + end + + defp host?("[" <> rest) do + literal = String.trim_trailing(rest, "]") + + case :inet.parse_address(String.to_charlist(literal)) do + {:ok, tuple} when tuple_size(tuple) == 8 -> true + _ -> false + end + end + + defp host?(host), do: Regex.match?(@host, host) + + defp port?(port) do + # No enormous integer conversion is needed to classify a port with leading zeros. + significant = String.trim_leading(port, "0") + byte_size(significant) in 1..5 and String.to_integer(significant) <= 65535 + end +end diff --git a/apps/control_plane/lib/workharbor/foundation/request.ex b/apps/control_plane/lib/workharbor/foundation/request.ex new file mode 100644 index 00000000..0e93b8a5 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/request.ex @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.Request do + @moduledoc "Accepted native lifecycle schema. Raw HTTP decoding is a later boundary." + alias WorkHarbor.Foundation.{Canonical, RepositoryIdentity} + @empty_actions ["start", "stop", "remove_runtime"] + + def normalize(owner, key, request) do + valid!(uuid?(owner)) + valid!(key?(key)) + keys!(request, ["schema_version", "workspace_id", "action"], ["parameters"]) + valid!(request["schema_version"] === 1 and uuid?(request["workspace_id"])) + action = request["action"] + parameters = parameters!(action, Map.get(request, "parameters", :missing)) + + normalized = %{ + "schema_version" => 1, + "owner_id" => owner, + "workspace_id" => request["workspace_id"], + "action" => action, + "parameters" => parameters + } + + {:ok, bytes} = Canonical.bytes(normalized) + + {:ok, + %{ + request: normalized, + bytes: bytes, + hash: Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) + }} + catch + :invalid_request -> {:error, :invalid_request} + end + + @doc "Normalizes an immutable lexical config descriptor without fetching or trust resolution." + def normalize_config(value) do + {:ok, config!(value)} + catch + :invalid_request -> {:error, :invalid_request} + end + + def key?(value) when is_binary(value), do: Regex.match?(~r/\A[A-Za-z0-9._:-]{1,128}\z/, value) + def key?(_), do: false + + def uuid?(value) when is_binary(value), + do: Regex.match?(~r/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/, value) + + def uuid?(_), do: false + + defp parameters!(action, :missing) when action in @empty_actions, do: %{} + + defp parameters!(action, value) when action in @empty_actions do + keys!(value, []) + value + end + + defp parameters!("create", value) do + keys!(value, ["host_id", "config"]) + valid!(uuid?(value["host_id"])) + %{"host_id" => value["host_id"], "config" => config!(value["config"])} + end + + defp parameters!("rebuild", value) do + keys!(value, ["config"]) + %{"config" => config!(value["config"])} + end + + defp parameters!(_, _), do: throw(:invalid_request) + + defp config!(value) do + keys!(value, ["repository_identity", "commit"], ["submodules"]) + valid!(RepositoryIdentity.valid?(value["repository_identity"]) and commit?(value["commit"])) + entries = Map.get(value, "submodules", []) + valid!(proper_list?(entries)) + + entries = + Enum.map(entries, fn entry -> + keys!(entry, ["path", "commit"]) + valid!(path?(entry["path"]) and commit?(entry["commit"])) + entry + end) + + valid!(length(Enum.uniq_by(entries, & &1["path"])) == length(entries)) + + %{ + "repository_identity" => value["repository_identity"], + "commit" => value["commit"], + "submodules" => Enum.sort_by(entries, & &1["path"]) + } + end + + defp commit?(value) when is_binary(value), + do: Regex.match?(~r/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/, value) + + defp commit?(_), do: false + + defp path?(value) when is_binary(value) do + String.valid?(value) and value != "" and not String.starts_with?(value, "/") and + not String.contains?(value, [<<0>>, "\\", "\r", "\n", "\t", "*", "?", "[", "]"]) and + Enum.all?(String.split(value, "/"), &(&1 not in ["", ".", "..", ".git"])) + end + + defp path?(_), do: false + + defp keys!(value, required, optional \\ []) do + valid!(is_map(value) and not is_struct(value)) + keys = Map.keys(value) + + valid!( + Enum.all?(required, &Map.has_key?(value, &1)) and + Enum.all?(keys, &(&1 in (required ++ optional))) + ) + end + + defp proper_list?([]), do: true + defp proper_list?([_ | rest]), do: proper_list?(rest) + defp proper_list?(_), do: false + defp valid!(true), do: :ok + defp valid!(_), do: throw(:invalid_request) +end diff --git a/apps/control_plane/priv/repo/migrations/20261014000000_owner_foundation.exs b/apps/control_plane/priv/repo/migrations/20261014000000_owner_foundation.exs new file mode 100644 index 00000000..733e1876 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261014000000_owner_foundation.exs @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.OwnerFoundation do + use Ecto.Migration + + def up do + execute(""" + CREATE TABLE owners (id TEXT PRIMARY KEY NOT NULL, display_name TEXT NOT NULL, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL) + """) + + execute(""" + CREATE TABLE hosts (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), identity_fingerprint TEXT, + capabilities TEXT NOT NULL DEFAULT '[]', last_seen TEXT, lease_expires_at TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,owner_id)) + """) + + execute(""" + CREATE TABLE workspaces (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), host_id TEXT NOT NULL, + desired_state TEXT NOT NULL CHECK(desired_state IN ('running','stopped')), + intent_version INTEGER NOT NULL DEFAULT 0 CHECK(typeof(intent_version)='integer' AND intent_version>=0), + last_runtime_generation INTEGER NOT NULL DEFAULT 0 CHECK(typeof(last_runtime_generation)='integer' AND last_runtime_generation>=0), + requested_config TEXT NOT NULL, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,owner_id), UNIQUE(id,host_id), + FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id)) + """) + + execute(""" + CREATE TABLE operations (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), workspace_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, action TEXT NOT NULL CHECK(action IN ('create','start','stop','rebuild','remove_runtime')), + request_version INTEGER NOT NULL CHECK(request_version=1), request_bytes BLOB NOT NULL, request_hash TEXT NOT NULL, + intent_version INTEGER NOT NULL CHECK(typeof(intent_version)='integer' AND intent_version>0), + status TEXT NOT NULL CHECK(status IN ('queued','executing','succeeded','failed')), + execution_config_snapshot TEXT, terminal_result TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,workspace_id), UNIQUE(id,intent_version), UNIQUE(owner_id,workspace_id,idempotency_key), UNIQUE(workspace_id,intent_version), + FOREIGN KEY(workspace_id,owner_id) REFERENCES workspaces(id,owner_id), + CHECK((status IN ('queued','executing') AND terminal_result IS NULL) OR (status IN ('succeeded','failed') AND terminal_result IS NOT NULL))) + """) + + execute(""" + CREATE TABLE persistent_storage (id TEXT PRIMARY KEY NOT NULL, workspace_id TEXT NOT NULL, host_id TEXT NOT NULL, + reservation_operation_id TEXT NOT NULL UNIQUE, provider_handle TEXT, deleted_at TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(workspace_id,host_id) REFERENCES workspaces(id,host_id), + FOREIGN KEY(reservation_operation_id,workspace_id) REFERENCES operations(id,workspace_id)) + """) + + execute(""" + CREATE TABLE runtime_instances (id TEXT PRIMARY KEY NOT NULL, workspace_id TEXT NOT NULL, host_id TEXT NOT NULL, + reservation_operation_id TEXT NOT NULL UNIQUE, + generation INTEGER NOT NULL CHECK(typeof(generation)='integer' AND generation>0), provider_instance_id TEXT, + observed_state TEXT NOT NULL CHECK(observed_state IN ('pending','provisioning','running','stopping','stopped','failed','unknown')), + effective_config TEXT, is_current INTEGER NOT NULL DEFAULT 0 CHECK(is_current IN (0,1)), deleted_at TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,workspace_id), UNIQUE(id,generation), UNIQUE(workspace_id,generation), + FOREIGN KEY(workspace_id,host_id) REFERENCES workspaces(id,host_id), + FOREIGN KEY(reservation_operation_id,workspace_id) REFERENCES operations(id,workspace_id), + CHECK(deleted_at IS NULL OR is_current=0)) + """) + + execute(""" + CREATE TABLE operation_runtime_bindings (id TEXT PRIMARY KEY NOT NULL, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('target','candidate')), inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(operation_id,role), + FOREIGN KEY(operation_id,workspace_id) REFERENCES operations(id,workspace_id), + FOREIGN KEY(runtime_id,workspace_id) REFERENCES runtime_instances(id,workspace_id)) + """) + + execute(""" + CREATE TABLE audit_events (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), host_id TEXT, + workspace_id TEXT, operation_id TEXT, event TEXT NOT NULL, action TEXT NOT NULL, intent_version INTEGER, + channel TEXT NOT NULL CHECK(channel IN ('cli','browser','internal')), outcome TEXT NOT NULL, code TEXT, + runtime_id TEXT REFERENCES runtime_instances(id), runtime_generation INTEGER, metadata TEXT NOT NULL DEFAULT '{}', inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(operation_id,event), FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id), + FOREIGN KEY(workspace_id,owner_id) REFERENCES workspaces(id,owner_id), + FOREIGN KEY(operation_id,workspace_id) REFERENCES operations(id,workspace_id), + FOREIGN KEY(workspace_id,host_id) REFERENCES workspaces(id,host_id), + FOREIGN KEY(operation_id,intent_version) REFERENCES operations(id,intent_version), + FOREIGN KEY(runtime_id,workspace_id) REFERENCES runtime_instances(id,workspace_id), + FOREIGN KEY(runtime_id,runtime_generation) REFERENCES runtime_instances(id,generation), + CHECK((runtime_id IS NULL AND runtime_generation IS NULL) OR (runtime_id IS NOT NULL AND runtime_generation IS NOT NULL AND workspace_id IS NOT NULL)), + CHECK(workspace_id IS NULL OR host_id IS NOT NULL), + CHECK(operation_id IS NULL OR (workspace_id IS NOT NULL AND host_id IS NOT NULL AND intent_version IS NOT NULL))) + """) + + execute( + "CREATE UNIQUE INDEX one_unfinished_operation ON operations(workspace_id) WHERE status IN ('queued','executing')" + ) + + execute( + "CREATE UNIQUE INDEX one_current_runtime ON runtime_instances(workspace_id) WHERE is_current=1" + ) + + execute( + "CREATE UNIQUE INDEX one_project_storage ON persistent_storage(workspace_id) WHERE deleted_at IS NULL" + ) + + execute( + "CREATE TRIGGER workspace_scope_immutable BEFORE UPDATE ON workspaces WHEN NEW.owner_id IS NOT OLD.owner_id OR NEW.host_id IS NOT OLD.host_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER host_owner_immutable BEFORE UPDATE ON hosts WHEN NEW.owner_id IS NOT OLD.owner_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_identity_immutable BEFORE UPDATE ON operations WHEN NEW.owner_id IS NOT OLD.owner_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.idempotency_key IS NOT OLD.idempotency_key OR NEW.action IS NOT OLD.action OR NEW.request_version IS NOT OLD.request_version OR NEW.request_bytes IS NOT OLD.request_bytes OR NEW.request_hash IS NOT OLD.request_hash OR NEW.intent_version IS NOT OLD.intent_version OR NEW.execution_config_snapshot IS NOT OLD.execution_config_snapshot BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_terminal_immutable BEFORE UPDATE ON operations WHEN OLD.status IN ('succeeded','failed') BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_transition BEFORE UPDATE ON operations WHEN NOT ((OLD.status='queued' AND NEW.status IN ('queued','executing','failed')) OR (OLD.status='executing' AND NEW.status IN ('executing','succeeded','failed')) OR OLD.status IN ('succeeded','failed')) BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_identity_immutable BEFORE UPDATE ON runtime_instances WHEN NEW.workspace_id IS NOT OLD.workspace_id OR NEW.host_id IS NOT OLD.host_id OR NEW.reservation_operation_id IS NOT OLD.reservation_operation_id OR NEW.generation IS NOT OLD.generation BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_provider_immutable BEFORE UPDATE ON runtime_instances WHEN OLD.provider_instance_id IS NOT NULL AND NEW.provider_instance_id IS NOT OLD.provider_instance_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_effective_immutable BEFORE UPDATE ON runtime_instances WHEN OLD.effective_config IS NOT NULL AND NEW.effective_config IS NOT OLD.effective_config BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_tombstone_immutable BEFORE UPDATE ON runtime_instances WHEN OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NOT OLD.deleted_at BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER storage_identity_immutable BEFORE UPDATE ON persistent_storage WHEN NEW.workspace_id IS NOT OLD.workspace_id OR NEW.host_id IS NOT OLD.host_id OR NEW.reservation_operation_id IS NOT OLD.reservation_operation_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER storage_handle_immutable BEFORE UPDATE ON persistent_storage WHEN OLD.provider_handle IS NOT NULL AND NEW.provider_handle IS NOT OLD.provider_handle BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER storage_tombstone_immutable BEFORE UPDATE ON persistent_storage WHEN OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NOT OLD.deleted_at BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER workspaces_retained BEFORE DELETE ON workspaces WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operations_retained BEFORE DELETE ON operations WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_instances_retained BEFORE DELETE ON runtime_instances WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER persistent_storage_retained BEFORE DELETE ON persistent_storage WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_runtime_bindings_retained BEFORE DELETE ON operation_runtime_bindings WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER audit_events_retained BEFORE DELETE ON audit_events WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER bindings_immutable BEFORE UPDATE ON operation_runtime_bindings WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER audit_immutable BEFORE UPDATE ON audit_events WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER candidate_binding_owner BEFORE INSERT ON operation_runtime_bindings WHEN NEW.role='candidate' AND NOT EXISTS (SELECT 1 FROM runtime_instances r WHERE r.id=NEW.runtime_id AND r.reservation_operation_id=NEW.operation_id AND r.workspace_id=NEW.workspace_id) BEGIN SELECT RAISE(ABORT, 'candidate reservation mismatch'); END" + ) + end + + def down do + execute("DROP TRIGGER candidate_binding_owner") + execute("DROP TRIGGER workspace_scope_immutable") + execute("DROP TRIGGER host_owner_immutable") + execute("DROP TRIGGER operation_identity_immutable") + execute("DROP TRIGGER operation_terminal_immutable") + execute("DROP TRIGGER operation_transition") + execute("DROP TRIGGER runtime_identity_immutable") + execute("DROP TRIGGER runtime_provider_immutable") + execute("DROP TRIGGER runtime_effective_immutable") + execute("DROP TRIGGER runtime_tombstone_immutable") + execute("DROP TRIGGER storage_identity_immutable") + execute("DROP TRIGGER storage_handle_immutable") + execute("DROP TRIGGER storage_tombstone_immutable") + execute("DROP TRIGGER workspaces_retained") + execute("DROP TRIGGER operations_retained") + execute("DROP TRIGGER runtime_instances_retained") + execute("DROP TRIGGER persistent_storage_retained") + execute("DROP TRIGGER operation_runtime_bindings_retained") + execute("DROP TRIGGER audit_events_retained") + execute("DROP TRIGGER bindings_immutable") + execute("DROP TRIGGER audit_immutable") + execute("DROP TABLE audit_events") + execute("DROP TABLE operation_runtime_bindings") + execute("DROP TABLE runtime_instances") + execute("DROP TABLE persistent_storage") + execute("DROP TABLE operations") + execute("DROP TABLE workspaces") + execute("DROP TABLE hosts") + execute("DROP TABLE owners") + end +end diff --git a/apps/control_plane/test/support/foundation_database.ex b/apps/control_plane/test/support/foundation_database.ex new file mode 100644 index 00000000..da04160f --- /dev/null +++ b/apps/control_plane/test/support/foundation_database.ex @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.FoundationDatabase do + @moduledoc false + alias WorkHarbor.Repo + + def open!(path) do + {:ok, pid} = + Repo.start_link( + name: nil, + database: path, + pool: DBConnection.ConnectionPool, + pool_size: 1, + journal_mode: :wal, + foreign_keys: :on, + synchronous: :full, + busy_timeout: 2000, + log: false + ) + + Process.unlink(pid) + Repo.put_dynamic_repo(pid) + migration = WorkHarbor.Repo.Migrations.OwnerFoundation + + unless Code.ensure_loaded?(migration), + do: + Code.require_file( + Application.app_dir( + :workharbor, + "priv/repo/migrations/20261014000000_owner_foundation.exs" + ) + ) + + Ecto.Migrator.run(Repo, [{20_261_014_000_000, migration}], :up, all: true, log: false) + GenServer.stop(pid) + + {:ok, writers} = + Repo.start_link( + name: nil, + database: path, + pool: DBConnection.ConnectionPool, + pool_size: 5, + journal_mode: :wal, + foreign_keys: :on, + synchronous: :full, + busy_timeout: 2000, + log: false + ) + + Process.unlink(writers) + Repo.put_dynamic_repo(writers) + writers + end + + def sql(statement, parameters \\ []) do + Repo.query!(statement, parameters, log: false) + end + + def count(table), do: sql("SELECT count(*) FROM " <> table).rows |> hd() |> hd() + + def fixture! do + alias WorkHarbor.Foundation, as: F + {:ok, owner} = F.create_owner("private owner name") + {:ok, host} = F.create_host(owner.id) + {owner.id, host.id} + end + + def request(host, workspace \\ Ecto.UUID.generate(), action \\ "create") do + parameters = if action == "create", do: %{"host_id" => host, "config" => config()}, else: %{} + + %{ + "schema_version" => 1, + "workspace_id" => workspace, + "action" => action, + "parameters" => parameters + } + end + + def config(commit \\ String.duplicate("a", 40)) do + %{ + "repository_identity" => "git@example.test:private-repository.git", + "commit" => commit, + "submodules" => [] + } + end + + # A historical confirmed runtime fixture; no provider or lifecycle is invoked. + def current!(operation) do + alias WorkHarbor.Foundation, as: F + + candidate = + F.bindings( + operation.owner_id, + sql("SELECT host_id FROM workspaces WHERE id = ?", [operation.workspace_id]).rows + |> hd() + |> hd(), + operation.id + )["candidate"] + + sql( + "UPDATE runtime_instances SET is_current = 1, observed_state = 'running', effective_config = ? WHERE id = ?", + [Jason.encode!(config()), candidate["id"]] + ) + + candidate + end + + def finish!(owner, host, operation, status \\ "failed") do + alias WorkHarbor.Foundation, as: F + + outcome = + if status == "failed", + do: %{"status" => status, "code" => "operation_failed"}, + else: %{"status" => status, "code" => "ok"} + + {:ok, {:finished, result}} = + F.finish( + owner, + host, + operation.id, + operation.intent_version, + operation.status, + F.bindings(owner, host, operation.id), + outcome + ) + + result + end +end diff --git a/apps/control_plane/test/workharbor/foundation_request_test.exs b/apps/control_plane/test/workharbor/foundation_request_test.exs new file mode 100644 index 00000000..b7513f12 --- /dev/null +++ b/apps/control_plane/test/workharbor/foundation_request_test.exs @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.FoundationRequestTest do + use ExUnit.Case, async: true + alias WorkHarbor.Foundation.{Canonical, Request, RepositoryIdentity} + alias WorkHarbor.FoundationDatabase, as: DB + @owner "00000000-0000-4000-8000-000000000001" + @host "00000000-0000-4000-8000-000000000002" + @workspace "00000000-0000-4000-8000-000000000003" + + test "native canonical bytes match independent frozen anchors" do + anchors = [ + {0, "30", "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"}, + {%{"\uE000" => 1, "\u{10000}" => 2}, "7b22ee8080223a312c22f0908080223a327d", + "8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8"}, + {"\u2028\u2029", "22e280a8e280a922", + "e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c"}, + {"<>&/", "223c3e262f22", + "396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492"}, + {%{"b" => true, "i" => 1}, "7b2262223a747275652c2269223a317d", + "1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63"} + ] + + for {native, hex, digest} <- anchors do + assert {:ok, bytes} = Canonical.bytes(native) + assert Base.encode16(bytes, case: :lower) == hex + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == digest + end + + assert {:ok, ~s("\\u001f\\b\\f\\n\\r\\t\\\\\\\"")} = + Canonical.bytes(<<31, 8, 12, 10, 13, 9, 92, 34>>) + + refute Canonical.bytes("é") == Canonical.bytes("e\u0301") + end + + test "complete normalized envelope matches independent bytes and semantic field changes" do + request = DB.request(@host, @workspace) + {:ok, original} = Request.normalize(@owner, "key", request) + + assert original.bytes == + ~s({"action":"create","owner_id":"00000000-0000-4000-8000-000000000001","parameters":{"config":{"commit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","repository_identity":"git@example.test:private-repository.git","submodules":[]},"host_id":"00000000-0000-4000-8000-000000000002"},"schema_version":1,"workspace_id":"00000000-0000-4000-8000-000000000003"}) + + assert original.hash == "cf080204edf198973786c3e5e503dfcc5af4e45caa0909e2a93784fbfa83224f" + + variants = [ + put_in(request, ["parameters", "host_id"], @owner), + put_in( + request, + ["parameters", "config", "repository_identity"], + "git@EXAMPLE.test:private-repository.git" + ), + put_in(request, ["parameters", "config", "commit"], String.duplicate("b", 40)), + put_in(request, ["parameters", "config", "submodules"], [ + %{"path" => "project", "commit" => String.duplicate("a", 40)} + ]), + %{request | "action" => "rebuild", "parameters" => %{"config" => DB.config()}}, + DB.request(@host, @workspace, "start"), + DB.request(@host, @workspace, "stop"), + DB.request(@host, @workspace, "remove_runtime") + ] + + for changed <- variants do + assert {:ok, normalized} = Request.normalize(@owner, "key", changed) + refute normalized.hash == original.hash + end + + {:ok, foreign} = Request.normalize(@host, "key", request) + refute foreign.hash == original.hash + end + + test "native canonical domain enforces exact types depth and integer limits" do + for invalid <- [1.0, :atom, {1}, %{atom: 1}, <<255>>, %{"bad" => <<237, 160, 128>>}] do + assert {:error, :invalid_json} = Canonical.bytes(invalid) + end + + depth128 = Enum.reduce(1..128, nil, fn _, nested -> [nested] end) + assert {:ok, _} = Canonical.bytes(depth128) + assert {:error, :invalid_json} = Canonical.bytes([depth128]) + assert {:ok, bytes} = Canonical.bytes(Integer.pow(10, 4299)) + assert byte_size(bytes) == 4300 + assert {:error, :invalid_json} = Canonical.bytes(Integer.pow(10, 4300)) + assert {:error, :invalid_json} = Canonical.bytes(-Integer.pow(10, 4300)) + assert {:ok, bytes} = Canonical.bytes(String.duplicate("x", 1_048_577)) + assert byte_size(bytes) == 1_048_579 + end + + test "normalization defaults and submodule ordering preserve semantic identity" do + request = DB.request(@host, @workspace) + bare = update_in(request, ["parameters", "config"], &Map.delete(&1, "submodules")) + assert {:ok, first} = Request.normalize(@owner, "same", request) + assert {:ok, second} = Request.normalize(@owner, "different-key", bare) + assert first.bytes == second.bytes + assert first.hash == second.hash + assert first.request["owner_id"] == @owner + + entries = [ + %{"path" => "z", "commit" => String.duplicate("b", 40)}, + %{"path" => "a", "commit" => String.duplicate("c", 64)} + ] + + left = put_in(request, ["parameters", "config", "submodules"], entries) + right = put_in(request, ["parameters", "config", "submodules"], Enum.reverse(entries)) + assert {:ok, a} = Request.normalize(@owner, "key", left) + assert {:ok, b} = Request.normalize(@owner, "key", right) + assert a.bytes == b.bytes + + for action <- ["start", "stop", "remove_runtime"] do + explicit = DB.request(@host, @workspace, action) + + assert Request.normalize(@owner, "key", explicit) == + Request.normalize(@owner, "key", Map.delete(explicit, "parameters")) + end + end + + test "native request rejects unknown fields actions nulls and invalid scopes" do + request = DB.request(@host, @workspace) + + bad = [ + Map.delete(request, "schema_version"), + %{request | "schema_version" => 1.0}, + Map.put(request, "owner_id", @owner), + Map.put(request, "timeout", 1), + %{request | "action" => "delete_data"}, + %{request | "action" => "unknown"}, + %{request | "parameters" => nil}, + %{request | "workspace_id" => String.upcase(@workspace <> "a")}, + put_in(request, ["parameters", "host_id"], "foreign"), + put_in(request, ["parameters", "config", "commit"], "main"), + put_in(request, ["parameters", "config", "submodules"], nil), + put_in(request, ["parameters", "config", "secret"], "canary"), + Map.put(DB.request(@host, @workspace, "stop"), "parameters", %{"delete_data" => true}) + ] + + for input <- bad, + do: assert({:error, :invalid_request} = Request.normalize(@owner, "key", input)) + + for key <- ["", "white space", "é", String.duplicate("x", 129), nil], + do: assert({:error, :invalid_request} = Request.normalize(@owner, key, request)) + + assert {:ok, _} = Request.normalize(@owner, String.duplicate("x", 128), request) + assert {:error, :invalid_request} = Request.normalize("wrong-owner", "key", request) + + assert {:error, :invalid_request} = + Request.normalize(@owner, "key", ~s({"duplicate":1,"duplicate":2})) + + duplicate = List.duplicate(%{"path" => "a", "commit" => String.duplicate("a", 40)}, 2) + + assert {:error, :invalid_request} = + Request.normalize( + @owner, + "key", + put_in(request, ["parameters", "config", "submodules"], duplicate) + ) + + for path <- [".", "..", "a/../b", "/a", "a\\b", "a/.git/b", "a//b", "a\n"] do + entries = [%{"path" => path, "commit" => String.duplicate("a", 40)}] + + assert {:error, :invalid_request} = + Request.normalize( + @owner, + "key", + put_in(request, ["parameters", "config", "submodules"], entries) + ) + end + end + + test "native improper submodule lists return a fixed rejection" do + request = DB.request(@host, @workspace) + entry = %{"path" => "a", "commit" => String.duplicate("a", 40)} + + for entries <- [[entry | :bad_tail], [:bad | :tail]] do + assert {:error, :invalid_request} = + Request.normalize( + @owner, + "key", + put_in(request, ["parameters", "config", "submodules"], entries) + ) + end + + assert {:error, :invalid_json} = Canonical.bytes([1 | :bad_tail]) + end + + test "lexical repository identities preserve HTTPS SSH and scp without fetch claims" do + positives = [ + "HTTPS://Host:0443/Repo.git", + "ssh://git@Host:022/Repo.git", + "git@Host:Repo.git", + "git@[::1]:Repo.git", + "ssh://[2001:db8::1]/repo", + "https://host./", + "https://999.999/path", + "host:/absolute/path:part", + "host:path@name", + "https://host/%2fRepo" + ] + + negatives = [ + "", + "https://user:password@host/repo", + "https://user@host/repo", + "ssh://git:password@host/repo", + "https://host", + "ssh://host", + "git://host/repo", + "https://under_score/repo", + "https://a..b/repo", + "ssh://[:::]/repo", + "ssh://[fe80::1%eth0]/repo", + "ssh://host:0/repo", + "ssh://host:65536/repo", + "ssh://host:/repo", + "git@host:", + "host:path?secret", + "https://host/r#x", + "https://host/%xx", + "https://host/r\\x", + "https://host/a b", + "https://host/\u0085", + "https://host/\u3000", + <<255>> + ] + + for value <- positives, do: assert(RepositoryIdentity.valid?(value), inspect(value)) + for value <- negatives, do: refute(RepositoryIdentity.valid?(value), inspect(value)) + + hashes = + for origin <- [ + "https://HOST/repo", + "https://host/repo", + "https://host/repo.git", + "https://host:443/repo" + ] do + request = + put_in( + DB.request(@host, @workspace), + ["parameters", "config", "repository_identity"], + origin + ) + + {:ok, normalized} = Request.normalize(@owner, "key", request) + normalized.hash + end + + assert Enum.uniq(hashes) == hashes + end +end diff --git a/apps/control_plane/test/workharbor/foundation_test.exs b/apps/control_plane/test/workharbor/foundation_test.exs new file mode 100644 index 00000000..70c07303 --- /dev/null +++ b/apps/control_plane/test/workharbor/foundation_test.exs @@ -0,0 +1,1078 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.FoundationTest do + use ExUnit.Case, async: false + alias WorkHarbor.Foundation, as: F + alias WorkHarbor.FoundationDatabase, as: DB + alias WorkHarbor.Repo + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-foundation-#{Ecto.UUID.generate()}.db") + pid = DB.open!(path) + {owner, host} = DB.fixture!() + + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(pid) + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end) + + %{path: path, pid: pid, owner: owner, host: host} + end + + test "acceptance commits scoped intent reservations and redacted audit atomically", c do + assert {:ok, {:accepted, operation}} = F.accept(c.owner, "key", DB.request(c.host), "cli") + assert operation.status == "queued" + assert operation.intent_version == 1 + assert DB.count("persistent_storage") == 1 + assert DB.count("runtime_instances") == 1 + bindings = F.bindings(c.owner, c.host, operation.id) + assert bindings["candidate"]["generation"] == 1 + assert bindings["target"] == nil + + assert DB.sql( + "SELECT observed_state, provider_instance_id, effective_config, is_current FROM runtime_instances" + ).rows == [["pending", nil, nil, 0]] + + assert DB.sql("SELECT desired_state, intent_version, last_runtime_generation FROM workspaces").rows == + [["running", 1, 1]] + + assert DB.sql("SELECT event FROM audit_events WHERE operation_id = ? ORDER BY event", [ + operation.id + ]).rows == [["operation_accepted"], ["runtime_reserved"]] + + audit = DB.sql("SELECT metadata FROM audit_events").rows |> inspect() + refute audit =~ "private owner" + refute audit =~ "private-repository" + refute audit =~ "repository_identity" + assert {:error, :invalid_channel} = F.accept(c.owner, "other", DB.request(c.host), "canary") + end + + test "equal key replay precedes busy and state checks and changed payload conflicts", c do + request = DB.request(c.host) + {:ok, {:accepted, operation}} = F.accept(c.owner, "key", request, "cli") + assert {:ok, {:replayed, replay}} = F.accept(c.owner, "key", request, "browser") + assert replay.id == operation.id + changed = put_in(request, ["parameters", "config", "commit"], String.duplicate("b", 40)) + assert {:error, :idempotency_conflict} = F.accept(c.owner, "key", changed, "cli") + + assert {:error, :workspace_busy} = + F.accept(c.owner, "new", %{request | "action" => "stop", "parameters" => %{}}, "cli") + + assert DB.sql("SELECT intent_version FROM workspaces").rows == [[1]] + DB.finish!(c.owner, c.host, operation) + assert {:ok, {:replayed, terminal}} = F.accept(c.owner, "key", request, "cli") + assert terminal.status == "failed" + assert DB.count("operations") == 1 + assert DB.count("runtime_instances") == 1 + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_accepted'").rows == + [[1]] + end + + test "owner workspace key uniqueness and scope do not depend on repository owner", c do + {foreign, foreign_host} = DB.fixture!() + request = DB.request(c.host) + {:ok, {:accepted, operation}} = F.accept(c.owner, "same", request, "cli") + + assert {:error, :not_found} = + F.accept( + foreign, + "same", + %{request | "action" => "stop", "parameters" => %{}}, + "cli" + ) + + assert {:error, :not_found} = + F.accept(c.owner, "foreign-host", DB.request(foreign_host), "cli") + + assert {:error, :not_found} = F.claim(foreign, c.host, operation.id, 1) + assert {:error, :not_found} = F.claim(c.owner, foreign_host, operation.id, 1) + + assert {:ok, {:accepted, other_workspace}} = + F.accept(c.owner, "same", DB.request(c.host), "cli") + + refute other_workspace.id == operation.id + assert {:ok, {:accepted, _}} = F.accept(foreign, "same", DB.request(foreign_host), "cli") + end + + test "direct SQL constraints enforce scope active operation generations and retention", c do + {foreign, foreign_host} = DB.fixture!() + {:ok, {:accepted, operation}} = F.accept(c.owner, "key", DB.request(c.host), "cli") + runtime = F.bindings(c.owner, c.host, operation.id)["candidate"] + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE workspaces SET owner_id = ? WHERE id = ?", [foreign, operation.workspace_id]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE persistent_storage SET host_id = ?", [foreign_host]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE runtime_instances SET host_id = ?", [foreign_host]) + end + + assert_raise Exqlite.Error, fn -> DB.sql("UPDATE operations SET owner_id = ?", [foreign]) end + assert_raise Exqlite.Error, fn -> DB.sql("UPDATE runtime_instances SET generation = 0") end + assert_raise Exqlite.Error, fn -> DB.sql("UPDATE workspaces SET intent_version = -1") end + + assert_raise Exqlite.Error, fn -> + DB.sql( + "INSERT INTO operations SELECT ?, owner_id, workspace_id, 'different', action, request_version, request_bytes, request_hash, intent_version + 1, status, execution_config_snapshot, terminal_result, inserted_at, updated_at FROM operations WHERE id = ?", + [Ecto.UUID.generate(), operation.id] + ) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("DELETE FROM workspaces WHERE id = ?", [operation.workspace_id]) + end + + assert_raise Exqlite.Error, fn -> DB.sql("DELETE FROM owners WHERE id = ?", [c.owner]) end + + assert DB.sql("SELECT generation FROM runtime_instances WHERE id = ?", [runtime["id"]]).rows == + [[1]] + end + + test "audit failure rolls back all accepted graph and counter writes", c do + for event <- ["operation_accepted", "runtime_reserved"] do + DB.sql( + "CREATE TRIGGER reject_#{event} BEFORE INSERT ON audit_events WHEN NEW.event = '#{event}' BEGIN SELECT RAISE(ABORT, 'audit rejected'); END" + ) + + assert {:error, :persistence_failed} = F.accept(c.owner, "key", DB.request(c.host), "cli") + + for table <- [ + "workspaces", + "operations", + "persistent_storage", + "runtime_instances", + "operation_runtime_bindings" + ], + do: assert(DB.count(table) == 0) + + DB.sql("DROP TRIGGER reject_#{event}") + end + end + + test "start snapshots existing effective config and never allocates replacement", c do + request = DB.request(c.host) + {:ok, {:accepted, create}} = F.accept(c.owner, "create", request, "cli") + DB.finish!(c.owner, c.host, create) + start = DB.request(c.host, create.workspace_id, "start") + assert {:error, :runtime_unavailable} = F.accept(c.owner, "start", start, "cli") + target = DB.current!(create) + {:ok, {:accepted, operation}} = F.accept(c.owner, "start", start, "cli") + assert operation.execution_config_snapshot == DB.config() + assert F.bindings(c.owner, c.host, operation.id) == %{"target" => target, "candidate" => nil} + + assert {:error, :invalid_action} = + F.reserve_candidate(c.owner, c.host, operation.id, operation.intent_version) + + assert DB.sql("SELECT intent_version, last_runtime_generation FROM workspaces").rows == [ + [2, 1] + ] + + assert DB.count("runtime_instances") == 1 + end + + test "rebuild requested config never relabels existing effective config", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + old = DB.current!(create) + DB.finish!(c.owner, c.host, create) + new_config = DB.config(String.duplicate("b", 40)) + + request = %{ + DB.request(c.host, create.workspace_id, "rebuild") + | "parameters" => %{"config" => new_config} + } + + {:ok, {:accepted, rebuild}} = F.accept(c.owner, "rebuild", request, "cli") + candidate = F.bindings(c.owner, c.host, rebuild.id)["candidate"] + assert candidate["generation"] == 2 + + assert {:ok, {:replayed, reserved}} = + F.reserve_candidate(c.owner, c.host, rebuild.id, rebuild.intent_version) + + assert reserved.id == candidate["id"] + + assert DB.sql("SELECT effective_config FROM runtime_instances WHERE id = ?", [old["id"]]).rows == + [[Jason.encode!(DB.config())]] + + assert DB.sql("SELECT effective_config FROM runtime_instances WHERE id = ?", [candidate["id"]]).rows == + [[nil]] + + DB.finish!(c.owner, c.host, rebuild) + + assert DB.sql("SELECT effective_config FROM runtime_instances WHERE id = ?", [old["id"]]).rows == + [[Jason.encode!(DB.config())]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'runtime_reserved'").rows == [ + [2] + ] + + assert DB.count("persistent_storage") == 1 + end + + test "claim and results bind current intent exact runtime and immutable terminal outcome", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + bindings = F.bindings(c.owner, c.host, operation.id) + success = %{"status" => "succeeded", "code" => "ok"} + failed = %{"status" => "failed", "code" => "operation_failed"} + + assert {:error, :invalid_transition} = + F.finish(c.owner, c.host, operation.id, 1, "queued", bindings, success) + + assert {:error, :stale_intent} = F.claim(c.owner, c.host, operation.id, 2) + assert {:ok, {:claimed, executing}} = F.claim(c.owner, c.host, operation.id, 1) + assert executing.status == "executing" + assert {:ok, {:already_executing, _}} = F.claim(c.owner, c.host, operation.id, 1) + bad = put_in(bindings, ["candidate", "generation"], 99) + + assert {:error, :runtime_mismatch} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bad, success) + + assert {:ok, {:finished, terminal}} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success) + + assert {:ok, {:replayed, replay}} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success) + + assert replay.terminal_result == terminal.terminal_result + + assert {:error, :result_conflict} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, failed) + + assert {:error, :invalid_transition} = F.claim(c.owner, c.host, operation.id, 1) + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_claimed'").rows == [ + [1] + ] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[1]] + end + + test "same runtime generation cannot authorize stale operation results", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + DB.current!(create) + DB.finish!(c.owner, c.host, create) + + {:ok, {:accepted, stop}} = + F.accept(c.owner, "stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, stop.id, stop.intent_version) + + DB.sql("UPDATE workspaces SET intent_version = intent_version + 1 WHERE id = ?", [ + create.workspace_id + ]) + + assert {:error, :stale_intent} = + F.finish( + c.owner, + c.host, + stop.id, + stop.intent_version, + "executing", + F.bindings(c.owner, c.host, stop.id), + %{"status" => "succeeded", "code" => "ok"} + ) + + assert DB.sql("SELECT status FROM operations WHERE id = ?", [stop.id]).rows == [["executing"]] + assert DB.sql("SELECT last_runtime_generation FROM workspaces").rows == [[1]] + end + + test "confirmed runtime tombstone retains storage key history and terminal replay", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = DB.current!(create) + DB.finish!(c.owner, c.host, create) + request = DB.request(c.host, create.workspace_id, "remove_runtime") + {:ok, {:accepted, remove}} = F.accept(c.owner, "remove", request, "cli") + + assert DB.sql("SELECT deleted_at FROM runtime_instances WHERE id = ?", [target["id"]]).rows == + [[nil]] + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, remove.id, remove.intent_version) + + observation = %{ + "runtime_id" => target["id"], + "generation" => target["generation"], + "observed_state" => "stopped", + "deleted" => true + } + + args = [ + c.owner, + c.host, + remove.id, + remove.intent_version, + "executing", + F.bindings(c.owner, c.host, remove.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ] + + assert {:ok, {:finished, _}} = apply(F, :finish, args) + + assert DB.sql( + "SELECT deleted_at IS NOT NULL, is_current FROM runtime_instances WHERE id = ?", + [target["id"]] + ).rows == [[1, 0]] + + assert DB.count("persistent_storage") == 1 + assert DB.count("workspaces") == 1 + assert {:ok, {:replayed, _}} = F.accept(c.owner, "remove", request, "browser") + + {:ok, {:accepted, newer}} = + F.accept(c.owner, "later-stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + assert newer.intent_version == 3 + assert {:ok, {:replayed, _}} = apply(F, :finish, args) + + assert {:error, :invalid_request} = + F.accept(c.owner, "data", %{request | "action" => "delete_data"}, "cli") + end + + test "independent writers serialize equal changed and competing submissions", c do + request = DB.request(c.host) + + results = + race(c.pid, fn index -> + F.accept(c.owner, "equal", request, if(index == 1, do: "cli", else: "browser")) + end) + + assert Enum.sort(Enum.map(results, fn {:ok, {kind, _}} -> kind end)) == [:accepted, :replayed] + assert results |> Enum.map(fn {:ok, {_, op}} -> op.id end) |> Enum.uniq() |> length() == 1 + assert DB.count("runtime_instances") == 1 + assert DB.count("operations") == 1 + {_, {_, operation}} = hd(results) + DB.finish!(c.owner, c.host, operation) + other = DB.request(c.host) + changed = put_in(other, ["parameters", "config", "commit"], String.duplicate("b", 40)) + + results = + race(c.pid, fn index -> + F.accept(c.owner, "changed", if(index == 1, do: other, else: changed), "cli") + end) + + assert Enum.count(results, &match?({:ok, {:accepted, _}}, &1)) == 1 + assert {:error, :idempotency_conflict} in results + fresh = DB.request(c.host) + results = race(c.pid, fn index -> F.accept(c.owner, "different-#{index}", fresh, "cli") end) + assert Enum.count(results, &match?({:ok, {:accepted, _}}, &1)) == 1 + assert {:error, :workspace_busy} in results + assert DB.count("operations") == 3 + end + + test "every independent writer uses foreign keys full synchronous and WAL", c do + parent = self() + + tasks = + for _ <- 1..5 do + Task.async(fn -> + Repo.put_dynamic_repo(c.pid) + + Repo.checkout(fn -> + send(parent, :checked_out) + + receive do + :measure -> :ok + end + + for pragma <- ["foreign_keys", "synchronous", "journal_mode"], + do: DB.sql("PRAGMA " <> pragma).rows + end) + end) + end + + for _ <- tasks, do: assert_receive(:checked_out, 5000) + for task <- tasks, do: send(task.pid, :measure) + for task <- tasks, do: assert(Task.await(task) == [[[1]], [[2]], [["wal"]]]) + end + + test "acceptance rejects nested transactions before any writes", c do + assert {:ok, {:error, :nested_transaction}} = + Repo.transaction( + fn -> + F.accept(c.owner, "nested", DB.request(c.host), "cli") + end, + mode: :immediate + ) + + assert DB.count("operations") == 0 + assert DB.count("workspaces") == 0 + end + + test "process crash before commit rolls back and after commit survives reopen", c do + for phase <- ["before", "after", "claimed"] do + path = c.path <> ".#{phase}" + pid = DB.open!(path) + {owner, host} = DB.fixture!() + GenServer.stop(pid) + Repo.put_dynamic_repo(c.pid) + workspace = Ecto.UUID.generate() + + code = """ + Logger.configure(level: :error) + {:ok, _} = Application.ensure_all_started(:ecto_sqlite3) + {:ok, _} = Application.ensure_all_started(:ecto_sql) + {:ok, repo} = WorkHarbor.Repo.start_link(name: nil, database: #{inspect(path)}, pool: DBConnection.ConnectionPool, synchronous: :full, foreign_keys: :on, journal_mode: :wal, log: false) + WorkHarbor.Repo.put_dynamic_repo(repo) + work = fn -> + {:ok, {:accepted, _}} = WorkHarbor.Foundation.accept(#{inspect(owner)}, "crash-key", #{inspect(DB.request(host, workspace))}, "cli") + end + wait = fn -> IO.puts("T005_READY"); Process.sleep(:infinity) end + if #{inspect(phase)} == "before" do + :telemetry.attach("crash-before-commit", [:work_harbor, :repo, :query], fn _, _, metadata, _ -> + if String.starts_with?(metadata.query, ~s(INSERT INTO "audit_events")) and match?({:ok, %{num_rows: 1}}, metadata.result) and "operation_accepted" in metadata.params do + IO.puts("T005_READY") + Process.sleep(:infinity) + end + end, nil) + work.() + else + work.() + if #{inspect(phase)} == "claimed" do + {:ok, operation} = WorkHarbor.Foundation.lookup(#{inspect(owner)}, #{inspect(workspace)}, "crash-key") + {:ok, {:claimed, _}} = WorkHarbor.Foundation.claim(#{inspect(owner)}, #{inspect(host)}, operation.id, operation.intent_version) + end + wait.() + end + """ + + args = + Enum.flat_map(:code.get_path(), fn path -> ["-pa", List.to_string(path)] end) ++ + ["-e", code] + + port = + Port.open({:spawn_executable, System.find_executable("elixir")}, [ + :binary, + :exit_status, + :stderr_to_stdout, + args: args + ]) + + try do + wait_ready(port, "") + {:os_pid, os_pid} = Port.info(port, :os_pid) + assert {_, 0} = System.cmd("kill", ["-KILL", Integer.to_string(os_pid)]) + assert_receive {^port, {:exit_status, _}}, 10_000 + reopened = DB.open!(path) + + try do + if phase == "before" do + for table <- [ + "workspaces", + "operations", + "persistent_storage", + "runtime_instances", + "operation_runtime_bindings" + ], + do: assert(DB.count(table) == 0) + else + assert DB.count("operations") == 1 + + assert {:ok, {:replayed, operation}} = + F.accept(owner, "crash-key", DB.request(host, workspace), "cli") + + assert operation.status == if(phase == "claimed", do: "executing", else: "queued") + assert DB.count("persistent_storage") == 1 + assert DB.count("runtime_instances") == 1 + + assert DB.sql("SELECT count(*) FROM audit_events WHERE operation_id IS NOT NULL").rows == + [[if(phase == "claimed", do: 3, else: 2)]] + end + after + GenServer.stop(reopened) + Repo.put_dynamic_repo(c.pid) + end + after + case Port.info(port, :os_pid) do + {:os_pid, alive} -> + System.cmd("kill", ["-KILL", Integer.to_string(alive)], stderr_to_stdout: true) + + receive do + {^port, {:exit_status, _}} -> :ok + after + 1000 -> :ok + end + + _ -> + :ok + end + + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end + end + end + + test "target observations cannot initially populate effective config", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = F.bindings(c.owner, c.host, create.id)["candidate"] + DB.sql("UPDATE runtime_instances SET is_current = 1 WHERE id = ?", [target["id"]]) + DB.finish!(c.owner, c.host, create) + + for action <- ["stop", "remove_runtime"] do + {:ok, {:accepted, operation}} = + F.accept(c.owner, action, DB.request(c.host, create.workspace_id, action), "cli") + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, operation.intent_version) + + observation = %{ + "runtime_id" => target["id"], + "generation" => 1, + "observed_state" => "stopped", + "effective_config" => DB.config() + } + + assert {:error, :runtime_mismatch} = + F.finish( + c.owner, + c.host, + operation.id, + operation.intent_version, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ) + + assert DB.sql( + "SELECT effective_config, observed_state FROM runtime_instances WHERE id = ?", + [target["id"]] + ).rows == [[nil, "pending"]] + + assert DB.sql("SELECT status, terminal_result FROM operations WHERE id = ?", [operation.id]).rows == + [["executing", nil]] + + assert DB.sql( + "SELECT count(*) FROM audit_events WHERE operation_id = ? AND event = 'operation_terminal'", + [operation.id] + ).rows == [[0]] + + DB.finish!(c.owner, c.host, %{operation | status: "executing"}) + end + end + + test "candidate binding cannot borrow another operation runtime", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = DB.current!(create) + DB.finish!(c.owner, c.host, create) + + {:ok, {:accepted, stop}} = + F.accept(c.owner, "stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + assert_raise Exqlite.Error, fn -> + DB.sql( + "INSERT INTO operation_runtime_bindings (id, operation_id, workspace_id, runtime_id, role, inserted_at, updated_at) VALUES (?, ?, ?, ?, 'candidate', ?, ?)", + [ + Ecto.UUID.generate(), + stop.id, + stop.workspace_id, + target["id"], + DateTime.to_iso8601(DateTime.utc_now()), + DateTime.to_iso8601(DateTime.utc_now()) + ] + ) + end + end + + test "insert scope negatives reach foreign keys without update trigger masking", c do + {foreign, foreign_host} = DB.fixture!() + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql( + "INSERT INTO workspaces SELECT ?, ?, host_id, desired_state, 0, 0, requested_config, inserted_at, updated_at FROM workspaces WHERE id = ?", + [Ecto.UUID.generate(), foreign, create.workspace_id] + ) + end + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + clone_operation!(create, owner: foreign) + end + + reservation = clone_operation!(create) + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql( + "INSERT INTO runtime_instances SELECT ?, workspace_id, ?, ?, generation+1, NULL, 'pending', NULL, 0, NULL, inserted_at, updated_at FROM runtime_instances WHERE reservation_operation_id = ?", + [Ecto.UUID.generate(), foreign_host, reservation, create.id] + ) + end + + fresh = Ecto.UUID.generate() + + DB.sql( + "INSERT INTO workspaces SELECT ?, owner_id, host_id, 'stopped', 0, 0, requested_config, inserted_at, updated_at FROM workspaces WHERE id = ?", + [fresh, create.workspace_id] + ) + + fresh_op = clone_operation!(create, workspace: fresh, version: 1) + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql( + "INSERT INTO persistent_storage SELECT ?, ?, ?, ?, NULL, NULL, inserted_at, updated_at FROM persistent_storage WHERE workspace_id = ?", + [Ecto.UUID.generate(), fresh, foreign_host, fresh_op, create.workspace_id] + ) + end + + assert DB.sql("PRAGMA foreign_key_check").rows == [] + end + + test "direct operation key and current runtime constraints remain enforced", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + + assert_raise Exqlite.Error, ~r/UNIQUE constraint failed: operations.owner_id/, fn -> + clone_operation!(create, key: "create") + end + + old = DB.current!(create) + DB.finish!(c.owner, c.host, create) + + rebuild_request = %{ + DB.request(c.host, create.workspace_id, "rebuild") + | "parameters" => %{"config" => DB.config()} + } + + {:ok, {:accepted, rebuild}} = F.accept(c.owner, "rebuild", rebuild_request, "cli") + candidate = F.bindings(c.owner, c.host, rebuild.id)["candidate"] + + assert_raise Exqlite.Error, ~r/UNIQUE constraint failed/, fn -> + DB.sql("UPDATE runtime_instances SET is_current = 1 WHERE id = ?", [candidate["id"]]) + end + + assert DB.sql("SELECT id FROM runtime_instances WHERE is_current = 1").rows == [[old["id"]]] + + assert_raise Exqlite.Error, fn -> + DB.sql("DELETE FROM operations WHERE id = ?", [create.id]) + end + + assert_raise Exqlite.Error, fn -> DB.sql("DELETE FROM persistent_storage") end + end + + test "candidate effective config and terminal observation replay are immutable", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 1) + candidate = F.bindings(c.owner, c.host, operation.id)["candidate"] + + observation = %{ + "runtime_id" => candidate["id"], + "generation" => 1, + "observed_state" => "running", + "provider_instance_id" => "opaque-private-handle", + "effective_config" => DB.config() + } + + bad = %{observation | "effective_config" => DB.config(String.duplicate("b", 40))} + success = %{"status" => "succeeded", "code" => "ok"} + bindings = F.bindings(c.owner, c.host, operation.id) + + assert {:error, :runtime_mismatch} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success, bad) + + assert DB.sql("SELECT effective_config, provider_instance_id FROM runtime_instances").rows == + [[nil, nil]] + + assert {:ok, {:finished, _}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + bindings, + success, + observation + ) + + assert {:ok, {:replayed, _}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + bindings, + success, + observation + ) + + assert {:error, :result_conflict} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success, %{ + observation + | "provider_instance_id" => "changed" + }) + + audit = DB.sql("SELECT metadata, code FROM audit_events").rows |> inspect() + refute audit =~ "opaque-private-handle" + refute audit =~ "private-repository" + assert DB.sql("SELECT is_current FROM runtime_instances").rows == [[0]] + end + + test "later candidate reservation has atomic audit and replay allocates nothing", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + DB.finish!(c.owner, c.host, create) + # Committed unreserved intent fixture exercises the guarded later-reservation API. + operation_id = clone_operation!(create, status: "queued") + DB.sql("UPDATE workspaces SET intent_version = 2 WHERE id = ?", [create.workspace_id]) + + DB.sql( + "CREATE TRIGGER fail_later_audit BEFORE INSERT ON audit_events WHEN NEW.event = 'runtime_reserved' BEGIN SELECT RAISE(ABORT, 'audit rejected'); END" + ) + + assert {:error, :persistence_failed} = F.reserve_candidate(c.owner, c.host, operation_id, 2) + assert DB.sql("SELECT last_runtime_generation FROM workspaces").rows == [[1]] + assert DB.count("runtime_instances") == 1 + DB.sql("DROP TRIGGER fail_later_audit") + assert {:ok, {:reserved, runtime}} = F.reserve_candidate(c.owner, c.host, operation_id, 2) + assert runtime.generation == 2 + assert {:ok, {:replayed, replay}} = F.reserve_candidate(c.owner, c.host, operation_id, 2) + assert replay.id == runtime.id + + assert DB.sql( + "SELECT count(*) FROM audit_events WHERE operation_id = ? AND event = 'runtime_reserved'", + [operation_id] + ).rows == [[1]] + end + + test "writer lock contention is bounded and never falsely accepted", c do + parent = self() + + holder = + Task.async(fn -> + Repo.put_dynamic_repo(c.pid) + + Repo.transaction( + fn -> + send(parent, :holding_write_lock) + + receive do + :release -> :ok + end + end, + mode: :immediate + ) + end) + + assert_receive :holding_write_lock, 5000 + start = System.monotonic_time(:millisecond) + + try do + assert {:error, :persistence_busy} = F.accept(c.owner, "locked", DB.request(c.host), "cli") + assert System.monotonic_time(:millisecond) - start < 5000 + after + send(holder.pid, :release) + Task.await(holder) + end + + assert DB.count("operations") == 0 + end + + test "binding generations require exact integer types and complete role shape", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 1) + bindings = F.bindings(c.owner, c.host, operation.id) + + bad = [ + put_in(bindings, ["candidate", "generation"], 1.0), + Map.put(bindings, "extra", nil), + Map.delete(bindings, "target"), + %{"target" => bindings["candidate"], "candidate" => nil} + ] + + for malformed <- bad do + assert {:error, :runtime_mismatch} = + F.finish(c.owner, c.host, operation.id, 1, "executing", malformed, %{ + "status" => "succeeded", + "code" => "ok" + }) + + assert DB.sql("SELECT status FROM operations WHERE id = ?", [operation.id]).rows == [ + ["executing"] + ] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[0]] + end + end + + test "lookup rejects malformed keys without query exceptions or data disclosure", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "key", DB.request(c.host), "cli") + + for key <- [nil, %{"secret" => "canary"}, 1, "", "é", String.duplicate("x", 129)] do + assert {:error, :not_found} = F.lookup(c.owner, operation.workspace_id, key) + end + + assert {:ok, found} = F.lookup(c.owner, operation.workspace_id, "key") + assert found.id == operation.id + end + + test "audit identity references cannot mismatch host runtime or intent scope", c do + {:ok, other_host} = F.create_host(c.owner) + {:ok, {:accepted, operation}} = F.accept(c.owner, "one", DB.request(c.host), "cli") + {:ok, {:accepted, other}} = F.accept(c.owner, "two", DB.request(c.host), "cli") + foreign_runtime = F.bindings(c.owner, c.host, other.id)["candidate"] + + sql = + "INSERT INTO audit_events SELECT ?, owner_id, ?, workspace_id, operation_id, 'operation_claimed', action, ?, 'internal', 'executing', NULL, ?, ?, '{}', inserted_at, updated_at FROM audit_events WHERE operation_id = ? AND event = 'operation_accepted'" + + variants = [ + [other_host.id, 1, nil, nil], + [c.host, 1, foreign_runtime["id"], 1], + [c.host, 99, nil, nil], + [c.host, 1, F.bindings(c.owner, c.host, operation.id)["candidate"]["id"], 99] + ] + + for parts <- variants do + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql(sql, [Ecto.UUID.generate()] ++ parts ++ [operation.id]) + end + end + end + + test "native adapter errors and audit never expose request canaries", c do + request = + put_in( + DB.request(c.host), + ["parameters", "config", "repository_identity"], + "git@example.test:private-canary-value.git" + ) + + log = + ExUnit.CaptureLog.capture_log([level: :debug], fn -> + {:ok, {:accepted, _}} = F.accept(c.owner, "key", request, "cli") + + DB.sql( + "CREATE TRIGGER reject_canary BEFORE INSERT ON audit_events WHEN NEW.event = 'operation_accepted' BEGIN SELECT RAISE(ABORT, 'private-canary-value'); END" + ) + + assert {:error, :persistence_failed} = + F.accept(c.owner, "other", DB.request(c.host), "cli") + end) + + refute log =~ "private-canary-value" + refute log =~ "private owner" + audit = DB.sql("SELECT * FROM audit_events").rows |> inspect() + refute audit =~ "private-canary-value" + refute audit =~ "private owner" + end + + test "candidate rejects malformed effective config without any result mutation", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 1) + candidate = F.bindings(c.owner, c.host, operation.id)["candidate"] + + invalid = [ + false, + true, + 0, + "config", + [], + %{}, + %{"unexpected" => "private-canary"}, + Map.delete(DB.config(), "commit"), + %{DB.config() | "submodules" => [%{} | :bad_tail]} + ] + + for config <- invalid do + observation = %{ + "runtime_id" => candidate["id"], + "generation" => 1, + "observed_state" => "running", + "effective_config" => config + } + + assert {:error, :invalid_result} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ) + + assert DB.sql("SELECT observed_state, effective_config FROM runtime_instances").rows == [ + ["pending", nil] + ] + + assert DB.sql("SELECT status, terminal_result FROM operations").rows == [["executing", nil]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[0]] + end + + observation = %{ + "runtime_id" => candidate["id"], + "generation" => 1, + "observed_state" => "running" + } + + for {field, malformed} <- [{"provider_instance_id", false}, {"deleted", nil}] do + assert {:error, :invalid_result} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + Map.put(observation, field, malformed) + ) + end + + assert DB.sql("SELECT observed_state, effective_config FROM runtime_instances").rows == [ + ["pending", nil] + ] + + assert DB.sql("SELECT status, terminal_result FROM operations").rows == [["executing", nil]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[0]] + + normalized = Map.put(observation, "effective_config", DB.config()) + defaulted = Map.put(observation, "effective_config", Map.delete(DB.config(), "submodules")) + + assert {:ok, {:finished, finished}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + defaulted + ) + + assert finished.terminal_result["observation"]["effective_config"] == DB.config() + + assert {:ok, {:replayed, _}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + normalized + ) + end + + test "target rejects malformed effective config and retains known revisions", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = DB.current!(create) + DB.finish!(c.owner, c.host, create) + + {:ok, {:accepted, operation}} = + F.accept(c.owner, "stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 2) + + for config <- [false, 0, [], "config", %{}, %{"unexpected" => "private-canary"}] do + observation = %{ + "runtime_id" => target["id"], + "generation" => 1, + "observed_state" => "stopped", + "effective_config" => config + } + + assert {:error, :invalid_result} = + F.finish( + c.owner, + c.host, + operation.id, + 2, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ) + + assert DB.sql("SELECT observed_state, effective_config FROM runtime_instances").rows == [ + ["running", Jason.encode!(DB.config())] + ] + + assert DB.sql("SELECT status, terminal_result FROM operations WHERE id = ?", [operation.id]).rows == + [["executing", nil]] + + assert DB.sql( + "SELECT count(*) FROM audit_events WHERE operation_id = ? AND event = 'operation_terminal'", + [operation.id] + ).rows == [[0]] + end + end + + defp clone_operation!(source, options \\ []) do + id = Ecto.UUID.generate() + status = Keyword.get(options, :status, "failed") + + terminal = + if status == "failed", + do: + Jason.encode!(%{ + "outcome" => %{"status" => "failed", "code" => "operation_failed"}, + "observation" => nil + }), + else: nil + + DB.sql( + "INSERT INTO operations SELECT ?, ?, ?, ?, action, request_version, request_bytes, request_hash, ?, ?, execution_config_snapshot, ?, inserted_at, updated_at FROM operations WHERE id = ?", + [ + id, + Keyword.get(options, :owner, source.owner_id), + Keyword.get(options, :workspace, source.workspace_id), + Keyword.get(options, :key, Ecto.UUID.generate()), + Keyword.get(options, :version, source.intent_version + 1), + status, + terminal, + source.id + ] + ) + + id + end + + defp race(repo, action) do + parent = self() + + tasks = + for index <- 1..2 do + Task.async(fn -> + Repo.put_dynamic_repo(repo) + send(parent, :ready) + + receive do + :go -> action.(index) + end + end) + end + + for _ <- tasks, do: assert_receive(:ready, 5000) + for task <- tasks, do: send(task.pid, :go) + Enum.map(tasks, &Task.await(&1, 10_000)) + end + + defp wait_ready(port, output, deadline \\ System.monotonic_time(:millisecond) + 20_000) do + remaining = max(deadline - System.monotonic_time(:millisecond), 0) + + receive do + {^port, {:data, bytes}} -> + combined = output <> bytes + if byte_size(combined) > 65_536, do: flunk("child output exceeded test bound") + + if "T005_READY" in String.split(combined, "\n"), + do: :ok, + else: wait_ready(port, combined, deadline) + + {^port, {:exit_status, status}} -> + flunk("child exited #{status}: #{output}") + after + remaining -> flunk("child never reached crash boundary: #{output}") + end + end +end diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index d68d33a7..9c3d457a 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -543,7 +543,41 @@ "WorkHarborWeb.ErrorHTMLTest/test renders 500.html", "WorkHarborWeb.ErrorJSONTest/test renders 404", "WorkHarborWeb.ErrorJSONTest/test renders 500", - "WorkHarborWeb.PageControllerTest/test GET /" + "WorkHarborWeb.PageControllerTest/test GET /", + "WorkHarbor.FoundationRequestTest/test native canonical bytes match independent frozen anchors", + "WorkHarbor.FoundationRequestTest/test complete normalized envelope matches independent bytes and semantic field changes", + "WorkHarbor.FoundationRequestTest/test native canonical domain enforces exact types depth and integer limits", + "WorkHarbor.FoundationRequestTest/test normalization defaults and submodule ordering preserve semantic identity", + "WorkHarbor.FoundationRequestTest/test native request rejects unknown fields actions nulls and invalid scopes", + "WorkHarbor.FoundationRequestTest/test native improper submodule lists return a fixed rejection", + "WorkHarbor.FoundationRequestTest/test lexical repository identities preserve HTTPS SSH and scp without fetch claims", + "WorkHarbor.FoundationTest/test acceptance commits scoped intent reservations and redacted audit atomically", + "WorkHarbor.FoundationTest/test equal key replay precedes busy and state checks and changed payload conflicts", + "WorkHarbor.FoundationTest/test owner workspace key uniqueness and scope do not depend on repository owner", + "WorkHarbor.FoundationTest/test direct SQL constraints enforce scope active operation generations and retention", + "WorkHarbor.FoundationTest/test audit failure rolls back all accepted graph and counter writes", + "WorkHarbor.FoundationTest/test start snapshots existing effective config and never allocates replacement", + "WorkHarbor.FoundationTest/test rebuild requested config never relabels existing effective config", + "WorkHarbor.FoundationTest/test claim and results bind current intent exact runtime and immutable terminal outcome", + "WorkHarbor.FoundationTest/test same runtime generation cannot authorize stale operation results", + "WorkHarbor.FoundationTest/test confirmed runtime tombstone retains storage key history and terminal replay", + "WorkHarbor.FoundationTest/test independent writers serialize equal changed and competing submissions", + "WorkHarbor.FoundationTest/test every independent writer uses foreign keys full synchronous and WAL", + "WorkHarbor.FoundationTest/test acceptance rejects nested transactions before any writes", + "WorkHarbor.FoundationTest/test process crash before commit rolls back and after commit survives reopen", + "WorkHarbor.FoundationTest/test target observations cannot initially populate effective config", + "WorkHarbor.FoundationTest/test candidate binding cannot borrow another operation runtime", + "WorkHarbor.FoundationTest/test insert scope negatives reach foreign keys without update trigger masking", + "WorkHarbor.FoundationTest/test direct operation key and current runtime constraints remain enforced", + "WorkHarbor.FoundationTest/test candidate effective config and terminal observation replay are immutable", + "WorkHarbor.FoundationTest/test later candidate reservation has atomic audit and replay allocates nothing", + "WorkHarbor.FoundationTest/test writer lock contention is bounded and never falsely accepted", + "WorkHarbor.FoundationTest/test binding generations require exact integer types and complete role shape", + "WorkHarbor.FoundationTest/test lookup rejects malformed keys without query exceptions or data disclosure", + "WorkHarbor.FoundationTest/test audit identity references cannot mismatch host runtime or intent scope", + "WorkHarbor.FoundationTest/test native adapter errors and audit never expose request canaries", + "WorkHarbor.FoundationTest/test candidate rejects malformed effective config without any result mutation", + "WorkHarbor.FoundationTest/test target rejects malformed effective config and retains known revisions" ] } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index b9ce5084..f4ff3dca 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -13,7 +13,8 @@ correction cleared `0c2c135d8d82d8ed708095f7f1596760df38696b`. T001 scaffold is accepted after independent Sol CLEAR of `d3d84c8361a1389292fad96d02a828ac9fabc2e3`. T002 is independently accepted at `bc18034164597aa841bea561cf6a882e9c0715e2`. T003 is independently accepted at `191c62baf227f64122770244d62d289900bb4d8d`. -T004 Go primitives are being implemented and remain unchecked pending independent review. +T004 is independently accepted at `6abb7419c77b3712a6ac276bc798b102503d3144`. +T005 foundation implementation is in progress; exact-candidate review remains open. No product runtime acceptance is claimed. Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; @@ -524,3 +525,74 @@ separate intent/runtime counters, start-existing-runtime restriction, immutable requested/effective configs, guarded terminal results and atomic reservation audit. The accepted contract is now persisted in Feature 006 owner API/data model/plan before tests/behavior. T005 remains unchecked pending exact-candidate review. + + +## T005 — native scoped persistence (author candidate) + +The accepted V3 contract was committed first at +`f51d6fe14764d37c3128f3a6e17439db21f76580`. No behavior existed when the initial +18 meaningful tests were written. Actual initial RED used MIX_ENV=test and private +MIX_BUILD_PATH/database override, then Mix.Task.run("test", foundation test files): +exit 2, 0/18 passed; all failed for absent foundation modules. This is absent-module +RED, not execution of a manufactured later stub. + +First implementation runs exposed strict-boolean code errors, then a test helper +that accidentally queried the global Sandbox instead of its dynamic committed +Repo. These were corrected; 17/19 then passed, with test-only child-code quoting +and reused trigger-name failures. Correcting those fixtures yielded 19/19 in 1.2s. +A formatter path was initially redundantly listed despite the existing scoped +formatter; the duplicate was removed. These are author mistakes, not passing runs. + +Independent Sol preparatory inspection found target config population, improper +native lists, loose integer/float binding equality, and malformed lookup keys. +Real regressions preceded each correction. The expanded pre-fix run was exit 2, +24/29 passing and five failing: those four classes plus a direct-SQL candidate +binding borrowing another operation's runtime. The guards now reject these cases; +29/29 passed in 4.2s. Precommit crash evidence was also corrected to reject nested +acceptance and pause the actual top-level path only after successful final audit +insertion; child cleanup runs on assertion failure. Post-claim restart is covered. + +Additional audit scope insert negatives produced real RED (31/32, exit 2) before +adding workspace/host, operation/intent and runtime/workspace/generation FKs. +A test-only default-argument escape typo first caused compilation failure and was +corrected before this actual behavior RED. Complete envelope bytes/hash have an +independent stdlib-derived constant, alongside accepted frozen Unicode/control +anchors. All 32 meaningful Phoenix IDs are appended; existing Python/application +IDs and retained policy are preserved. T005 and original Feature 001 remain unchecked. + +The foundation adds no credentials/auth, enrollment, trust/fetch, provider, +dispatch/retry, replacement-start, data-deletion acceptance or repeatable +observation API. No product runtime or hardware power-loss result is claimed. +Pristine committed application/source/manifest/repository results follow after +candidate validation; no full new-head canonical suite is claimed here. + + +After audit scope constraints were added, the complete Phoenix suite passed 37/37 +in 4.3s (32 new foundation tests plus all 5 inherited tests), with actual ExUnit +outcomes accepted by the unchanged event validator. Format and warnings-as-errors +compile passed in the authorized native run. A later non-escalated lock check failed +EPERM at Mix's TCP lock, not production behavior; it is rerun natively below. +The repository link checker initially interpreted the host regex in inline Markdown +as a link. The exact same lexical rule is now written in prose; checker policy +was not changed. Frozen migration and original Feature 001 diffs remain empty. + + +Exact-candidate Sol review of b179f2959209afc26df0426d27ad35cea2f66c47 was +NOT CLEAR for one P2: false effective_config bypassed truthiness guards and could +commit a malformed terminal observation. Its independently passing 37/37, format +and warnings-as-errors results are superseded for acceptance by the correction. +Two regression tests preceded the fix: actual RED was exit 2, 25/27 foundation +tests passing and both new tests failing. The correction reuses strict config +normalization at the result boundary and explicit nil handling for optional fields; +malformed candidate and target results must leave runtime, operation and audit +unchanged. Both new IDs are appended without modifying prior acceptance IDs. + +The correction's first warnings-as-errors compile caught misplaced private-function +clauses. The test invocation then passed 39/39 but emitted that warning; this was +not a clean compile result. After grouping the clauses, warnings-as-errors compile +and format passed. The expanded complete suite passed 39/39 in 4.4s; the unchanged +actual-event validator accepted all 39 required outcomes. The new tests include +malformed optional provider/deletion fields, normalized config defaults and exact +immutable replay as well as scalar/list/map config rejection. Repository and +whitespace checks passed before commit. Pristine exact-head evidence remains a +post-commit check; independent corrected-head review is still required. diff --git a/specs/006-whr-cli/contracts/owner-api.md b/specs/006-whr-cli/contracts/owner-api.md index 86903005..e9cc6371 100644 --- a/specs/006-whr-cli/contracts/owner-api.md +++ b/specs/006-whr-cli/contracts/owner-api.md @@ -87,9 +87,9 @@ resolution remain T009. Reject empty strings, backslash, C0, DEL, `?`, `#` and Unicode White_Space: U+0009–000D, U+0020, U+0085, U+00A0, U+1680, U+2000–200A, U+2028–2029, U+202F, U+205F, U+3000. Every percent sign forms a two-ASCII-hex-digit triplet, -retained unchanged. Unbracketed hosts are nonempty dot-separated labels matching -`[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?`, optionally one trailing dot; no -underscores or interior empty labels. Numeric labels do not prove IPv4 validity; +retained unchanged. Unbracketed hosts are nonempty dot-separated labels. Each starts and ends with +an ASCII letter or digit; interior characters are ASCII letters, digits or hyphens. +One trailing dot is optional; underscores and interior empty labels are rejected. Numeric labels do not prove IPv4 validity; there is no DNS length/network validity claim. Bracketed hosts are locally parsed IPv6 literals yielding eight components, without zone IDs, preserving spelling. @@ -104,7 +104,8 @@ there is no port syntax, and its path may be relative/absolute with later colons ### Atomic acceptance and metadata guards -Use a short immediate SQLite transaction. Scope-authorized owner/workspace/key +Use a short top-level immediate SQLite transaction. Mutation primitives reject +nested transactions so acceptance never escapes an uncommitted outer savepoint. Scope-authorized owner/workspace/key lookup precedes busy, tombstone and current-state preconditions or mutations. Equal normalized identity returns the same operation/status/outcome. Changed identity conflicts. New-key acceptance enforces owner/host scope, stable create @@ -132,7 +133,9 @@ terminal->nonterminal. Foundation outcomes are exactly status/code pairs: `{"status":"succeeded","code":"ok"}` or `{"status":"failed","code":"operation_failed"}`. Optional typed observation metadata, terminal result/outcome and audit commit together. Provider ID cannot -change once set. Effective config must match candidate requested revisions; +change once set. Only a candidate may first populate effective config. A target with unknown/null +effective config cannot acquire config from a stop/remove observation. Effective +config must match candidate requested revisions; existing-target start cannot change effective config. Terminal equal result replay checks scope/bindings and returns immutable recorded outcome even after newer intent; conflicting status/code/observation cannot overwrite or append audit. @@ -155,3 +158,48 @@ reserved delete_data until later explicit confirmation/authorization. Runtime an storage tombstones represent confirmed outcomes only, never queued removal. Preserve workspace identity, keys, outcomes and audit indefinitely in the pilot; no destructive cascades/identity reuse. T010/T011 define rm --data coordination. + +Scoped lookup validates the same exact key domain before querying, returning not_found +for malformed keys. Native array fields require proper lists, including before Enum +traversal. Runtime binding comparison is strict structural equality, so integer +and float generations never compare equal. Candidate bindings cannot borrow another +operation's runtime, enforced in SQLite as well as the application context. + +### Foundation application results + +These are internal native APIs, not a published wire/output schema. accept returns +`{:ok, {:accepted | :replayed, operation}}`; scoped lookup(owner, workspace, key) +returns `{:ok, operation}` or not_found. bindings(owner, host, operation) returns +exact target/candidate keys, each nil or an id/integer-generation map. claim returns +claimed/already_executing; repeated claim never permits another effect. +reserve_candidate returns reserved/replayed. finish returns finished/replayed. + +finish takes scoped IDs, intent version, expected status, exact binding map, +status/code outcome, and optional native observation. Observation requires runtime_id, +positive integer generation and observed_state; optional provider_instance_id and +effective_config default null (preserve stored values), deleted defaults false. +A nonnull effective_config must be a strict native config descriptor with the same +required fields, unknown-field rejection, defaults and submodule ordering as request +config. Malformed native scalar/list/map values return invalid_result before any +runtime, operation or audit mutation. Optional fields use explicit null handling. +Unknown fields fail. deleted=true requires succeeded remove_runtime, target role +and stopped observation. Only a candidate can first populate effective config. +A target's nonnull config remains equal; a null target config cannot be populated. +No current-runtime promotion API or repeatable observation API exists in T005. +Host metadata rows alone claim no enrollment, cryptographic agent identity or +operational capability; those are T008. + +Stable native error atoms: invalid_request, invalid_channel, not_found, +idempotency_conflict, workspace_busy, workspace_exists, runtime_unavailable, +storage_unavailable, counter_exhausted, stale_intent, invalid_transition, +invalid_action, runtime_mismatch, result_conflict, invalid_result, +nested_transaction, persistence_busy, persistence_failed. Canonical native +rejection is invalid_json. Errors never include input, SQL or adapter exceptions. +CLI numeric mapping and HTTP responses remain later work. + +Audit scope is database-enforced for workspace/owner/host, operation/workspace/intent +and runtime/workspace/generation pairs. Native primitives emit only fixed audit +fields; direct SQL negatives verify the underlying constraints independently of +immutable-update triggers. Race/crash tests run outside wrapping Sandbox; the +precommit child pauses only after a successful final audit INSERT on the actual +acceptance path, before top-level commit, with exact bounded signaling and cleanup. diff --git a/specs/006-whr-cli/plan.md b/specs/006-whr-cli/plan.md index a54c60ef..47dbb6a5 100644 --- a/specs/006-whr-cli/plan.md +++ b/specs/006-whr-cli/plan.md @@ -4,7 +4,8 @@ Branch: `feat/installable-pilot` | Date: 2026-10-14 Spec: [spec.md](spec.md) Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` Status: execution in progress. T001 scaffold independently Sol CLEAR at -`d3d84c8361a1389292fad96d02a828ac9fabc2e3`; later work items remain open. +`d3d84c8361a1389292fad96d02a828ac9fabc2e3`; T002–T004 are accepted as recorded in +tasks/evidence. T005 is under implementation; later work remains open. Scaffold checks do not establish product runtime verification. ## Authorized objective diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index 9485be45..eff3f495 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -3,7 +3,9 @@ Status: execution in progress. T001 scaffold independently Sol CLEAR at `d3d84c8361a1389292fad96d02a828ac9fabc2e3`; T002 is independently CLEAR at `bc18034164597aa841bea561cf6a882e9c0715e2`; T003 independently CLEAR at -`191c62baf227f64122770244d62d289900bb4d8d`; T004 and later work items remain open. +`191c62baf227f64122770244d62d289900bb4d8d`; T004 independently CLEAR at +`6abb7419c77b3712a6ac276bc798b102503d3144`; T005 is under implementation and remains +unchecked pending independent exact-candidate review. Later work items remain open. No product runtime acceptance is claimed. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), @@ -113,8 +115,9 @@ acceptance and task completion rules; review evidence is appended, never fabrica ## Evidence log T001 and T002 are independently accepted at the revisions above. T003 is independently accepted at -`191c62baf227f64122770244d62d289900bb4d8d`. T004 is being implemented and -remains unchecked pending its independent review; no product runtime acceptance -is claimed. Durable check and review +`191c62baf227f64122770244d62d289900bb4d8d`. T004 is independently accepted at +`6abb7419c77b3712a6ac276bc798b102503d3144`. T005 remains unchecked pending +independent exact-candidate review; no product runtime acceptance is claimed. +Durable check and review evidence, including earlier failed runs, is retained in [the pilot execution record](../../docs/exec-plans/active/installable-pilot.md). From 6d676997493b89e4e5e21f10bd52777aa3b5942c Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 18:04:11 +0200 Subject: [PATCH 12/51] docs(auth): consolidate accepted browser security contract Bind owner authentication to separate owner, dashboard and preauth stores before behavior. Preserve read-only mutation denial, strict raw decoding and transport boundaries without automatic credential revocation. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 17 ++ specs/006-whr-cli/contracts/authentication.md | 203 ++++++++++++++++++ specs/006-whr-cli/contracts/owner-api.md | 2 + specs/006-whr-cli/tasks.md | 6 +- 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 specs/006-whr-cli/contracts/authentication.md diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index f4ff3dca..1db3ff29 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -596,3 +596,20 @@ malformed optional provider/deletion fields, normalized config defaults and exac immutable replay as well as scalar/list/map config rejection. Repository and whitespace checks passed before commit. Pristine exact-head evidence remains a post-commit check; independent corrected-head review is still required. + + +## T006 — accepted authentication design + +T005 independently Sol CLEAR at `1189c81ba81b79a47a0485a9d5148fb8286c8eb0` +with no open findings. Its pristine acceptance evidence includes 39/39 Phoenix +tests and malformed-config/nil probes; it establishes native persistence, not +authentication or strict raw JSON. Original Feature 001 completion markers and +frozen migration artifacts remain unchanged. + +The single [consolidated T006 contract](../../../specs/006-whr-cli/contracts/authentication.md) +was independently design/consolidation CLEAR before behavior at SHA-256 +`eeb455009081ec12a48eb3b64834b18ea9273fbd7a3bfd353f81cba8ebc29491`. +Three separate session stores, server-checked 600-second preauth expiry, explicit +owner/dashboard context and no automatic credential revocation replace prior +proposals. T006 implementation is authorized; its checkbox remains open until +exact-candidate tests and independent review. No activation is authorized. diff --git a/specs/006-whr-cli/contracts/authentication.md b/specs/006-whr-cli/contracts/authentication.md new file mode 100644 index 00000000..90fe2a9d --- /dev/null +++ b/specs/006-whr-cli/contracts/authentication.md @@ -0,0 +1,203 @@ +# T006 owner authentication and browser security + +Status: accepted design, implementation pending independent exact-revision review. +Prerequisite: T005 independently CLEAR at +`1189c81ba81b79a47a0485a9d5148fb8286c8eb0`. +This consolidated contract supersedes all earlier single-cookie, automatic +mode-switch/revocation and forced-reauthentication proposals. It preserves +Feature 001 T013 and Feature 006 FR-033/035–038. + +## 1. Scope and authority + +The pilot has one local owner authenticator. Existing Owner identities, foreign +keys and cross-owner guards remain authoritative. Trusted principals are separate +from native request bodies. Authentication does not confer host identity, trust, +provider capability or session grants. T007 and later work remain separate. + +## 2. Local bootstrap + +Local bootstrap generates 32 cryptographically random bytes, encoded as canonical +43-character unpadded base64url. A database singleton constraint atomically binds +one authenticator to an existing or newly created Owner. Only a domain-separated +SHA-256 verifier is stored. The secret is delivered once to a protected local +adapter, never HTTP, argv, URL, standard output or logs. If delivery fails after +initialization, preserve initialized state and fail closed for explicit local +recovery. Never silently replace an owner or verifier. Arbitrary user passwords, +email flows, cloud accounts and passkeys are not supported. Test-only delivery +artifacts do not establish an installed setup workflow. + +## 3. Credentials and lifetimes + +Opaque credentials are `whr1_` followed by canonical unpadded base64url of 32 +random bytes: 48 ASCII characters. Store only a distinct domain-separated SHA-256 +hash, owner foreign key, fixed kind/scopes, issued/expiry/revocation timestamps. +Kinds are `cli_owner` (owner read/write, 30 days), `owner_browser` (owner read/write, +eight hours) and `dashboard` (dashboard read only, at most eight hours). +Clients cannot increase expiry, choose arbitrary scopes or change owner. Expiry +at or before now rejects, with no grace or sliding renewal. Recheck the database +on every authenticated HTTP request and socket upgrade. Fresh primary login +creates a fresh credential; **no login or wrapping automatically revokes any +previous credential**. Explicit owner-write revocation and logout govern lifetime. +Status contains metadata only. Credential references never enter page scripts. + +## 4. Primary login and action authorization + +Anonymous `POST /api/v1/auth/login` accepts exactly `schema_version: 1`, +`owner_id` and `owner_secret`, and issues only `cli_owner`. It is a no-credential +CLI channel: no Authorization, auth cookies, Origin, Fetch-Site or context header. +A known dashboard credential calling this or any other mutating API receives an +authorization denial and safe audit before body errors can mask it. Other mixed +credential channels reject without issuance. Wrong owner/secret share one safe +401 response, including a dummy constant-time comparison for unknown owners. +Browser primary login is a separate explicit fresh-primary-proof flow. + +A principal contains server-derived owner/credential IDs, kind and fixed scopes. +Body/query owner fields never override it. Explicit closed action classification +(read/write/grant) precedes lookup and side effects; HTTP method alone does not +classify a grant as read. Unknown actions/scopes fail closed. Cross-owner calls +return safe denial and no foreign data. Audit denial is security evidence, not +permission for a business mutation. + +## 5. Two authentication contexts, three stores + +Auth cookies are `__Host-workharbor_owner` and `__Host-workharbor_dashboard`. +A third `__Host-workharbor_login` cookie is **preauth only**, never an owner +credential. All are encrypted and signed, use distinct salts, Secure, HttpOnly, +SameSite=Strict, Path=/ and no Domain. Auth Max-Age never exceeds stored expiry. + +Paths fix HTML/login/socket context. Cookie API calls require exactly one +`X-WorkHarbor-Context: owner|dashboard`. Missing/duplicate/unknown context rejects; +a supplied header on a path-fixed route must match its target context. One HTTP +session dispatcher invokes Plug.Session once for the selected store. Never union +scopes, decode another store as fallback or borrow owner authority when both +cookies are present. The database checks selected credential kind as well as scope. +This shared origin is **not** an XSS compartment; rendering and CSP remain required. + +## 6. Public navigation, preauth and dashboard provisioning + +Public GET `/login/owner`, `/login/dashboard` and `/login/read-token` select only +preauth state. Missing Origin is allowed even from a cross-site portal. Explicit +foreign/null/malformed Origin rejects. Public login returns only public forms, +ignores supplied auth for data and never touches either auth cookie on GET. This +preserves sign-ins withheld by SameSite on cross-site navigation. + +Preauth payload contains issued_at, expires_at = issued_at + 600 seconds and +library CSRF state, no credential. POST checks interval, future/malformed timestamps +and expires_at <= now using the injected clock; Max-Age alone is not replay defense. +GET may issue fresh preauth state without persistent credential mutation. + +Protected primary browser login explicitly targets owner or dashboard. Existing +cookies are not mint authority. Fresh primary proof plus preauth CSRF issues a +fixed-kind credential, writes only the target auth cookie and clears preauth state. +No existing credential is revoked. Owner-write may explicitly issue/install a +fixed dashboard credential while retaining owner context. CLI issuance returns the +secret once through HTTPS private client handling. A viewer may wrap the **same** +existing read token at `/login/read-token`; this never mints, updates, revokes or +extends a credential and may coexist with the untouched owner cookie. + +Auth-cookie installation/wrapping uses no copied CSRF state and no second session +fetch. Redirect to protected HTML; its ordinary selected-session safe GET initializes +fresh library CSRF state before API/socket use. Owner/preauth CSRF is never reused, +and the owner request's CSRF process state/before_send callback remains undisturbed. +Absent/expired/revoked dashboard context never falls back to owner. GET navigation +never issues credentials or changes persistent domain state. + +## 7. Logout and clear + +Owner-browser logout: **Sign out of owner management**. Revoke only the selected +owner credential and clear only owner cookie. CLI logout revokes its bearer; CLI +local deletion is T011. Dashboard credentials cannot mutate even their own token, +logout, issue credentials or mint grants. **Clear dashboard sign-in** at protected +`POST /login/dashboard/clear` selects the dashboard AUTH store (explicit override +to preauth login paths), validates own Origin and selected CSRF, and deletes only +that local cookie, with no implied server revocation or owner action. Explicit +owner-write dashboard-token revocation is separate. + +## 8. Gate order + +Remove generic endpoint body parsers. A stateless Endpoint.call admission guard +runs before super and hence before Phoenix socket dispatch: TLS, authority, +Origin/path/header admission only, no session/CSRF/database there. Normal HTTP +then runs request ID/telemetry, one selected Plug.Session, router/fetch_session, +credential authentication, explicit action scope, browser Origin/CSRF, bounded +body parser, handler and context object-owner check. Login credential admission +precedes parsing. No router pipeline is assumed to have run for socket dispatch. + +## 9. TLS and authority + +Direct HTTPS is first. Reject plaintext before body/auth; never redirect credential +requests. Active server configuration requires explicit bind, certificate/key, +allowlisted authorities/origin and a generated non-demo session key. No default +all-interface HTTP or trusted forwarded scheme/Host. Authorize effective host/port, +reject duplicate raw Host, and require any raw Host to parse/equal effective authority. +HTTP/1 origin-form requires Host; HTTP/2 authority need not have raw Host. Absolute +request-target schemes cannot establish TLS. Test authority mismatches explicitly. + +## 10. Origin and anti-forgery channels + +Origin must be exactly configured HTTPS scheme/host/effective port; reject null, +multiple values, userinfo/path/query/fragment and foreign origins. No permissive +CORS. Protected HTML/API/upgrades do not inherit public-login navigation exception. +Cookie API reads also require selected-session X-CSRF-Token; missing Origin is +permitted only with Sec-Fetch-Site=same-origin and valid CSRF. Browser mutations +require explicit own Origin and CSRF. Bearer-only CLI has no cookie, Origin, +Fetch-Site or context and cannot borrow browser authority. Protect login forms +with preauth CSRF. Public/sensitive HTML uses no-store, no-sniff, frame denial and CSP. + +## 11. Sockets + +`/owner/live` and `/dashboard/live` use constant respective session options and +check_csrf=true. Admission requires exact own Origin even for GET upgrades. +Phoenix decodes selected session/checks CSRF once; custom connect checks database +kind/scope/expiry/revocation before upgrade, never the other cookie. Disable longpoll. +Later views/attachments must reauthorize actions and enforce expiry/revocation in +their WIs; handshake protection does not claim their implementation. Browser and +actual TLS evidence is distinct from synthetic ConnTest scheme assertions. + +## 12. Fixed login forms + +Login pages contain no script (CSP script-src none). Isolated URLencoded parsing +accepts at most 4096 actual raw bytes and three pairs, UTF-8/absent charset only. +Owner/dashboard primary forms require exactly owner_id (canonical UUID), +owner_secret (43 characters) and _csrf_token (56 masked Plug characters). +Read-token form requires dashboard_token (48) and _csrf_token. Clear form requires +only _csrf_token. Reject missing/empty/extra, malformed percent triplets, decoded +invalid UTF-8, duplicate decoded names and unknown/nested names before map conversion. +Use stdlib component decoding only after percent validation, never collapsed query +maps. Forms are not an alternate API/lifecycle parser. + +## 13. Strict raw JSON + +Actual cumulative parse input is at most 1,048,576 bytes, with oversize precedence +before UTF-8/syntax. API accepts JSON only, rejects empty/nonobject/trailing input. +Preflight validates UTF-8/no BOM, at most 128 containers (root counts one), at most +4300 integer digits excluding minus and rejects fractions/exponents/nonfinite +before numeric conversion; string/escape contents do not count as containers. +Use locked Jason ordered_objects with string keys/copies, then reject decoded +duplicate keys recursively before native maps. Configure Jason integer digit limit +4301 at compile time before dependency build (its count includes minus). +No normalization, atoms or subprocess. Pass valid native bodies through T005 +schema/FrozenJSON hashing, retaining scalar ordering/no LF. Safe errors contain +only code/optional offset, never Jason raw data/token. + +## 14. Debug and secret boundaries + +All authenticated server profiles compile debug_errors=false and disable debugger +and live-reloader bypasses. Active server rejects demo/missing session secrets. +Test actual debugger-action/exception paths. Sensitive schema fields are redacted; +SQL/log/error/audit contain no raw secret, token, cookie, body, grant or query +payload. Filter all auth parameters and suppress sensitive route logging. Audit +only bounded server-known actions/codes/outcomes and IDs/request IDs. + +## 15. Evidence and completion + +Write failing behavior tests before implementation. Cover singleton/races/delivery, +wrong-owner/secret, exact expiry/revocation/reopen, every mutation under read scope, +malformed login masking, both-cookie selection/no fallback, context/CSRF substitution, +public cross-site login preservation, target-only before_send cookies, independent +logout, viewer wrap without DB changes, transport/socket/debugger negatives, form +boundaries/aliases and frozen JSON plus independent byte/depth/digit/Unicode cases. +Use secret canaries through actual errors/logs/audit/database. Append meaningful +required test IDs without changing existing IDs. Run focused application/repository +and pristine exact-candidate gates, record actual results and independent review. +No synthetic test establishes real browser, transport TLS or future host capability. diff --git a/specs/006-whr-cli/contracts/owner-api.md b/specs/006-whr-cli/contracts/owner-api.md index e9cc6371..4acdef76 100644 --- a/specs/006-whr-cli/contracts/owner-api.md +++ b/specs/006-whr-cli/contracts/owner-api.md @@ -203,3 +203,5 @@ fields; direct SQL negatives verify the underlying constraints independently of immutable-update triggers. Race/crash tests run outside wrapping Sandbox; the precommit child pauses only after a successful final audit INSERT on the actual acceptance path, before top-level commit, with exact bounded signaling and cleanup. + +Owner authentication/browser transport follows [the T006 contract](authentication.md). diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index eff3f495..28ffb44d 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -33,7 +33,7 @@ Independent check: negative vectors, cross-language bytes and no runtime Python. - [x] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`, strict fixture schema, meaningful negative tests in `tests/test_go_vectors.py` and their required IDs in `docs/development/ci-acceptance.json`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). - [x] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). -- [ ] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). +- [x] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). - [ ] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). - [ ] T007 Define/validate versioned host protocol in `contracts/host-protocol/`, `apps/control_plane/lib/workharbor/host_protocol.ex`, `apps/host_agent/internal/protocol/`; reject invalid version/scope/replay before provider calls and redact diagnostics in both suites (001 T003). @@ -121,3 +121,7 @@ independent exact-candidate review; no product runtime acceptance is claimed. Durable check and review evidence, including earlier failed runs, is retained in [the pilot execution record](../../docs/exec-plans/active/installable-pilot.md). + +T005 independently Sol CLEAR at `1189c81ba81b79a47a0485a9d5148fb8286c8eb0`; +its native persistence scope does not establish authentication or raw JSON decoding. +T006 follows [the consolidated authentication contract](contracts/authentication.md). From 0a37257c98c1a385865d12322194e9fe272bb406 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 18:58:55 +0200 Subject: [PATCH 13/51] feat(auth): enforce scoped owner and browser authentication Establish revocable owner credentials and explicit browser contexts so read-only viewers cannot inherit management authority. Validate transport and raw request boundaries before socket dispatch, map construction or secret-bearing handlers. Retain all inherited acceptance IDs and add failure-first scope, expiry, revocation, parser, cookie, ingress and redaction coverage. No listener activation or later host/lifecycle implementation is included. Refs: #72 Co-authored-by: Codex --- apps/control_plane/README.md | 78 +- apps/control_plane/config/config.exs | 4 + apps/control_plane/config/dev.exs | 10 +- apps/control_plane/config/prod.exs | 25 - apps/control_plane/config/runtime.exs | 129 +-- apps/control_plane/config/test.exs | 2 +- apps/control_plane/lib/workharbor/accounts.ex | 302 ++++++ .../lib/workharbor/application.ex | 5 + .../lib/workharbor/strict_json.ex | 94 ++ .../lib/workharbor_web/auth_controller.ex | 222 ++++ .../lib/workharbor_web/authority.ex | 72 ++ .../lib/workharbor_web/endpoint.ex | 63 +- .../lib/workharbor_web/login_form.ex | 48 + .../lib/workharbor_web/owner_socket.ex | 29 + .../lib/workharbor_web/plugs/require_owner.ex | 131 +++ .../lib/workharbor_web/router.ex | 123 ++- .../lib/workharbor_web/runtime_security.ex | 54 + .../lib/workharbor_web/security.ex | 347 +++++++ .../20261014010000_owner_authentication.exs | 36 + .../test/support/auth_database.ex | 36 + .../test/workharbor/accounts_test.exs | 215 ++++ .../test/workharbor/strict_json_test.exs | 140 +++ .../workharbor_web/authentication_test.exs | 956 ++++++++++++++++++ .../controllers/page_controller_test.exs | 2 +- docs/development/ci-acceptance.json | 49 +- docs/exec-plans/active/installable-pilot.md | 41 + specs/006-whr-cli/contracts/authentication.md | 24 + specs/006-whr-cli/contracts/owner-api.md | 11 + 28 files changed, 3024 insertions(+), 224 deletions(-) create mode 100644 apps/control_plane/lib/workharbor/accounts.ex create mode 100644 apps/control_plane/lib/workharbor/strict_json.ex create mode 100644 apps/control_plane/lib/workharbor_web/auth_controller.ex create mode 100644 apps/control_plane/lib/workharbor_web/authority.ex create mode 100644 apps/control_plane/lib/workharbor_web/login_form.ex create mode 100644 apps/control_plane/lib/workharbor_web/owner_socket.ex create mode 100644 apps/control_plane/lib/workharbor_web/plugs/require_owner.ex create mode 100644 apps/control_plane/lib/workharbor_web/runtime_security.ex create mode 100644 apps/control_plane/lib/workharbor_web/security.ex create mode 100644 apps/control_plane/priv/repo/migrations/20261014010000_owner_authentication.exs create mode 100644 apps/control_plane/test/support/auth_database.ex create mode 100644 apps/control_plane/test/workharbor/accounts_test.exs create mode 100644 apps/control_plane/test/workharbor/strict_json_test.exs create mode 100644 apps/control_plane/test/workharbor_web/authentication_test.exs diff --git a/apps/control_plane/README.md b/apps/control_plane/README.md index 06d2ee2e..3a6f2ac0 100644 --- a/apps/control_plane/README.md +++ b/apps/control_plane/README.md @@ -1,48 +1,50 @@ -# WorkHarbor control-plane scaffold +# WorkHarbor control plane -Generated with the official Phoenix 1.8.15 installer, retaining Ecto/SQLite, -HTML, LiveView and assets. No mailer, dashboard, Gettext or generated agent -instructions are included. Unused DNS clustering is omitted for the initial -single-instance SQLite control plane. This is a development scaffold, not a usable platform; -no authenticated HTTP owner API or lifecycle/provider execution exists yet. +The Phoenix/Ecto/SQLite control plane supplies durable metadata and an owner +authentication foundation. Lifecycle dispatch, providers, host enrollment and +product management/Spec Kit data views remain later work. The locked dependencies +and root `.tool-versions` define the tested toolchains. -The root `.tool-versions` pins the tested toolchains. `mix.lock` pins every -resolved Hex dependency and the heroicons/daisyUI Git revisions. The esbuild and -Tailwind executable versions are pinned in `config/config.exs`; assets use Mix -wrappers rather than an npm dependency graph. - -From this directory, verify without starting a server: +Verify from this directory without starting a server: ```sh mix deps.get --check-locked mix format --check-formatted mix compile --warnings-as-errors mix test -mix assets.setup -mix assets.build ``` -Tests use a local ignored SQLite database and `server: false`. Generated -development secrets are public local defaults, not deployment credentials. -Production authentication, TLS policy and installation remain later work items. -The generated Phoenix welcome page is retained unchanged; no browser acceptance -of product UI is claimed. - -`assets/tsconfig.json` supplies editor completion with Mix dependency paths; its -generated comments were removed to comply with the repository's strict JSON gate. - - -## T005 metadata foundation - -Native contexts under lib/workharbor/foundation/ enforce scoped SQLite records, -canonical request identity, separate intent/runtime counters, atomic audit and -metadata result guards. The accepted contract is -[Feature 006 owner API](../../specs/006-whr-cli/contracts/owner-api.md). -There is no dispatcher/provider or raw HTTP decoder. Start requires an existing -current runtime with known effective config; replacement-start remains T010. - -Foundation tests use separate private file-backed databases and independent writer -connections, not wrapping Sandbox transactions. They include bounded subprocess -SIGKILL/reopen evidence and effective per-writer durability pragmas. These tests -are native persistence evidence, not live platform or hardware power-loss acceptance. -The existing scoped migration formatter covers priv/repo/migrations/*.exs. +Tests use private file-backed databases and `server: false`. Foundation tests +include independent writers and bounded SIGKILL/reopen evidence; these do not +establish hardware power-loss or live-platform acceptance. + +## Authentication foundation + +[The authentication contract](../../specs/006-whr-cli/contracts/authentication.md) +defines primary secret delivery, opaque scoped credentials, strict raw JSON and +browser admission. Local `WorkHarbor.Accounts.bootstrap/1` takes a protected +one-time delivery callback; it persists hashes only and stays initialized if +delivery fails. There is no HTTP bootstrap/reset flow. Do not print the delivered +secret, place it in a URL/argument or put it in repository configuration. + +`/login/owner`, `/login/dashboard` and `/login/read-token` supply bounded no-script +forms. Owner and dashboard have separate selected cookies; public login uses a +third preauth cookie with server-checked 600-second expiry. Owner management can +explicitly install dashboard sign-in while retaining its owner session. Dashboard +sign-in cannot mutate, mint or revoke credentials. “Clear dashboard sign-in” only +clears that cookie; “Sign out of owner management” revokes the selected owner +credential. Raw read-token issuance is limited to the CLI bearer secret channel. + +No listener starts by default. An independently authorized operator activation +requires `PHX_SERVER=true`, `WORKHARBOR_ORIGIN`, `WORKHARBOR_BIND`, +`WORKHARBOR_TLS_CERT_FILE`, `WORKHARBOR_TLS_KEY_FILE`, and a private non-demo +`SECRET_KEY_BASE`; production also requires `DATABASE_PATH`. Direct TLS origin, +authority and listener port must agree. HTTP, proxy-forwarded TLS, debugger, +reloader and demo signing secrets are rejected for an active server. This document +records configuration requirements, not permission to activate or deploy. + +The inherited root welcome test retains its required ID and now verifies the +intentional plaintext rejection. Authenticated root navigation has separate +positive coverage. ConnTest and socket adapter tests establish application gates, +not actual network TLS or browser acceptance. Assets retain the pinned Mix wrappers; +no new runtime dependencies or Python/AI validation subprocesses were added. diff --git a/apps/control_plane/config/config.exs b/apps/control_plane/config/config.exs index d5a9a20a..0aa8ec58 100644 --- a/apps/control_plane/config/config.exs +++ b/apps/control_plane/config/config.exs @@ -68,4 +68,8 @@ config :phoenix, :json_library, Jason # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. +config :jason, :decoding_integer_digit_limit, 4301 +config :phoenix, :filter_parameters, {:keep, []} +config :workharbor, :owner_http, authorities: [{"localhost", 443}], origin: "https://localhost" +config :workharbor, WorkHarborWeb.Endpoint, debug_errors: false, code_reloader: false import_config "#{config_env()}.exs" diff --git a/apps/control_plane/config/dev.exs b/apps/control_plane/config/dev.exs index cedd22df..aa441583 100644 --- a/apps/control_plane/config/dev.exs +++ b/apps/control_plane/config/dev.exs @@ -5,7 +5,7 @@ config :workharbor, WorkHarbor.Repo, database: Path.expand("../workharbor_dev.db", __DIR__), pool_size: 5, stacktrace: true, - show_sensitive_data_on_connection_error: true + show_sensitive_data_on_connection_error: false # For development, we disable any cache and enable # debugging and code reloading. @@ -16,10 +16,10 @@ config :workharbor, WorkHarbor.Repo, config :workharbor, WorkHarborWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}], - check_origin: false, - code_reloader: true, - debug_errors: true, + http: false, + check_origin: ["https://localhost"], + code_reloader: false, + debug_errors: false, secret_key_base: "9kwSZZ3zqgPLCQHHHC7Q9KYXXSVttc/mmuUT4ZmFiV1BGre/cOXzP7jNrsMeH4N4", watchers: [ esbuild: {Esbuild, :install_and_run, [:workharbor, ~w(--sourcemap=inline --watch)]}, diff --git a/apps/control_plane/config/prod.exs b/apps/control_plane/config/prod.exs index 1c82a81c..92d6d006 100644 --- a/apps/control_plane/config/prod.exs +++ b/apps/control_plane/config/prod.exs @@ -1,27 +1,2 @@ import Config - -# Note we also include the path to a cache manifest -# containing the digested version of static files. This -# manifest is generated by the `mix assets.deploy` task, -# which you should run after static files are built and -# before starting your production server. -config :workharbor, WorkHarborWeb.Endpoint, - cache_static_manifest: "priv/static/cache_manifest.json" - -# Force using SSL in production. This also sets the "strict-security-transport" header, -# known as HSTS. If you have a health check endpoint, you may want to exclude it below. -# Note `:force_ssl` is required to be set at compile-time. -config :workharbor, WorkHarborWeb.Endpoint, - force_ssl: [ - rewrite_on: [:x_forwarded_proto], - exclude: [ - # paths: ["/health"], - hosts: ["localhost", "127.0.0.1"] - ] - ] - -# Do not print debug messages in production config :logger, level: :info - -# Runtime production configuration, including reading -# of environment variables, is done on config/runtime.exs. diff --git a/apps/control_plane/config/runtime.exs b/apps/control_plane/config/runtime.exs index 971f875e..cd544693 100644 --- a/apps/control_plane/config/runtime.exs +++ b/apps/control_plane/config/runtime.exs @@ -1,109 +1,40 @@ +# SPDX-License-Identifier: EUPL-1.2 import Config -# config/runtime.exs is executed for all environments, including -# during releases. It is executed after compilation and before the -# system starts, so it is typically used to load production configuration -# and secrets from environment variables or elsewhere. Do not define -# any compile-time configuration in here, as it won't be applied. -# The block below contains prod specific runtime configuration. - -# ## Using releases -# -# If you use `mix release`, you need to explicitly enable the server -# by passing the PHX_SERVER=true when you start it: -# -# PHX_SERVER=true bin/workharbor start -# -# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` -# script that automatically sets the env var above. -if System.get_env("PHX_SERVER") do - config :workharbor, WorkHarborWeb.Endpoint, server: true -end - -config :workharbor, WorkHarborWeb.Endpoint, - http: [port: String.to_integer(System.get_env("PORT", "4000"))] - -if config_env() == :dev do - # Reload browser tabs when matching files change. - config :workharbor, WorkHarborWeb.Endpoint, - live_reload: [ - web_console_logger: true, - patterns: [ - # Static assets, except user uploads - ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E, - # Router, Controllers, LiveViews and LiveComponents - ~r"lib/workharbor_web/router\.ex$"E, - ~r"lib/workharbor_web/(controllers|live|components)/.*\.(ex|heex)$"E - ] - ] -end +# No listener, demo-secret fallback, HTTP redirect or forwarded-header trust. +# Explicit activation remains an operator decision outside development checks. +config :workharbor, WorkHarborWeb.Endpoint, server: false, http: false if config_env() == :prod do - database_path = - System.get_env("DATABASE_PATH") || - raise """ - environment variable DATABASE_PATH is missing. - For example: /etc/workharbor/workharbor.db - """ + database = System.get_env("DATABASE_PATH") || raise "DATABASE_PATH is required" + config :workharbor, WorkHarbor.Repo, database: database, pool_size: 5 +end - config :workharbor, WorkHarbor.Repo, - database: database_path, - pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") +if System.get_env("PHX_SERVER") == "true" do + required = fn name -> + System.get_env(name) || raise "unsafe authentication server configuration" + end - # The secret key base is used to sign/encrypt cookies and other secrets. - # A default value is used in config/dev.exs and config/test.exs but you - # want to use a different value for prod and you most likely don't want - # to check this value into version control, so we use an environment - # variable instead. - secret_key_base = - System.get_env("SECRET_KEY_BASE") || - raise """ - environment variable SECRET_KEY_BASE is missing. - You can generate one by calling: mix phx.gen.secret - """ + origin = required.("WORKHARBOR_ORIGIN") + uri = URI.parse(origin) + {:ok, bind} = :inet.parse_address(String.to_charlist(required.("WORKHARBOR_BIND"))) - host = System.get_env("PHX_HOST") || "example.com" + config :workharbor, :owner_http, + origin: origin, + authorities: [{String.downcase(uri.host), uri.port}] config :workharbor, WorkHarborWeb.Endpoint, - url: [host: host, port: 443, scheme: "https"], - http: [ - # Enable IPv6 and bind on all interfaces. - # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. - # See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0 - # for details about using IPv6 vs IPv4 and loopback vs public addresses. - ip: {0, 0, 0, 0, 0, 0, 0, 0} - ], - secret_key_base: secret_key_base - - # ## SSL Support - # - # To get SSL working, you will need to add the `https` key - # to your endpoint configuration: - # - # config :workharbor, WorkHarborWeb.Endpoint, - # https: [ - # ..., - # port: 443, - # cipher_suite: :strong, - # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), - # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") - # ] - # - # The `cipher_suite` is set to `:strong` to support only the - # latest and more secure SSL ciphers. This means old browsers - # and clients may not be supported. You can set it to - # `:compatible` for wider support. - # - # `:keyfile` and `:certfile` expect an absolute path to the key - # and cert in disk or a relative path inside priv, for example - # "priv/ssl/server.key". For all supported SSL configuration - # options, see https://plug.hexdocs.pm/Plug.SSL.html#configure/1 - # - # We also recommend setting `force_ssl` in your config/prod.exs, - # ensuring no data is ever sent via http, always redirecting to https: - # - # config :workharbor, WorkHarborWeb.Endpoint, - # force_ssl: [hsts: true] - # - # Check `Plug.SSL` for all available options in `force_ssl`. + server: true, + http: false, + debug_errors: false, + code_reloader: false, + watchers: [], + secret_key_base: required.("SECRET_KEY_BASE"), + url: [scheme: "https", host: uri.host, port: uri.port], + https: [ + ip: bind, + port: uri.port, + certfile: required.("WORKHARBOR_TLS_CERT_FILE"), + keyfile: required.("WORKHARBOR_TLS_KEY_FILE") + ] end diff --git a/apps/control_plane/config/test.exs b/apps/control_plane/config/test.exs index 7c20c9c4..fad61cec 100644 --- a/apps/control_plane/config/test.exs +++ b/apps/control_plane/config/test.exs @@ -13,7 +13,7 @@ config :workharbor, WorkHarbor.Repo, # We don't run a server during test. If one is required, # you can enable the server option below. config :workharbor, WorkHarborWeb.Endpoint, - http: [ip: {127, 0, 0, 1}, port: 4002], + http: false, secret_key_base: "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl", server: false diff --git a/apps/control_plane/lib/workharbor/accounts.ex b/apps/control_plane/lib/workharbor/accounts.ex new file mode 100644 index 00000000..44d9adc5 --- /dev/null +++ b/apps/control_plane/lib/workharbor/accounts.ex @@ -0,0 +1,302 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Accounts.Authenticator do + use Ecto.Schema + @primary_key {:id, :integer, autogenerate: false} + schema "owner_authenticators" do + field :owner_id, :binary_id + field :secret_hash, :binary, redact: true + end +end + +defmodule WorkHarbor.Accounts.Credential do + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + schema "owner_credentials" do + field :owner_id, :binary_id + field :token_hash, :binary, redact: true + field :kind, :string + field :issued_at, :integer + field :expires_at, :integer + field :revoked_at, :integer + end +end + +defmodule WorkHarbor.Accounts.Principal do + @moduledoc "Trusted server-created identity, separate from request bodies." + @enforce_keys [:owner_id, :credential_id, :kind, :scopes, :issued_at, :expires_at] + defstruct @enforce_keys +end + +defmodule WorkHarbor.Accounts do + @moduledoc "Single-owner primary authentication and revocable opaque credentials." + alias WorkHarbor.Repo + alias WorkHarbor.Foundation.{Owner, AuditEvent, Request} + alias WorkHarbor.Accounts.{Authenticator, Credential, Principal} + @kinds ["cli_owner", "owner_browser", "dashboard"] + @writes [:logout, :revoke, :issue_dashboard, :primary_login] + @reads [:status, :owner_page, :dashboard_page, :credential_list] + def now, do: Application.get_env(:workharbor, :clock, fn -> System.system_time(:second) end).() + + def bootstrap(deliver, owner_id \\ nil) when is_function(deliver, 1) do + result = + transaction(fn -> + if Repo.get(Authenticator, 1) do + {:error, :already_initialized} + else + owner = + if is_nil(owner_id), + do: Repo.insert!(%Owner{display_name: "Owner"}, log: false), + else: if(Request.uuid?(owner_id), do: Repo.get(Owner, owner_id)) + + if is_nil(owner) do + {:error, :invalid_request} + else + if is_nil(owner_id), + do: + audit( + owner.id, + nil, + "owner_created", + "owner_metadata", + "internal", + "recorded", + "ok" + ) + + secret = random() + + Repo.insert!( + %Authenticator{id: 1, owner_id: owner.id, secret_hash: digest(:primary, secret)}, + log: false + ) + + audit(owner.id, nil, "owner_bootstrapped", "bootstrap", "internal", "recorded", "ok") + {:ok, owner, secret} + end + end + end) + + case result do + {:ok, owner, secret} -> + try do + if deliver.(secret) == :ok, do: {:ok, owner}, else: {:error, :delivery_failed} + rescue + _ -> {:error, :delivery_failed} + catch + _, _ -> {:error, :delivery_failed} + end + + other -> + other + end + end + + def login(owner, secret, kind) when kind in @kinds do + verifier = if Request.uuid?(owner), do: Repo.get_by(Authenticator, owner_id: owner) + expected = if verifier, do: verifier.secret_hash, else: <<0::256>> + candidate = digest(:primary, if(is_binary(secret), do: secret, else: "")) + valid = Plug.Crypto.secure_compare(expected, candidate) + + if verifier && valid && canonical?(secret, 43) do + transaction(fn -> issue!(owner, kind) end) + else + {:error, :unauthenticated} + end + end + + def login(_, _, _), do: {:error, :invalid_request} + + def authenticate(token, expected_kind \\ :any) do + with true <- token?(token), + %Credential{} = c <- Repo.get_by(Credential, token_hash: digest(:credential, token)), + true <- active?(c) and (expected_kind == :any or c.kind == expected_kind) do + {:ok, principal(c)} + else + _ -> {:error, :unauthenticated} + end + end + + def authorize(p, action, owner \\ nil) + + def authorize(%Principal{} = p, action, owner) do + current = Repo.get(Credential, p.credential_id) + owner = owner || p.owner_id + + valid = + current && active?(current) && current.owner_id == p.owner_id && current.kind == p.kind + + allowed = valid && owner == p.owner_id && action_allowed?(current.kind, action) + + if allowed do + :ok + else + if current && current.owner_id == p.owner_id, + do: + audit( + p.owner_id, + p.credential_id, + "authorization_denied", + safe_action(action), + channel(p.kind), + "denied", + "forbidden" + ) + + {:error, :forbidden} + end + end + + def authorize(_, _, _), do: {:error, :forbidden} + + def deny(%Principal{} = p, action) do + audit( + p.owner_id, + p.credential_id, + "authorization_denied", + safe_action(action), + channel(p.kind), + "denied", + "forbidden" + ) + + {:error, :forbidden} + end + + def issue_dashboard(p) do + transaction(fn -> + with :ok <- authorize(p, :issue_dashboard), do: issue!(p.owner_id, "dashboard") + end) + end + + def revoke(p, id) do + transaction(fn -> + with :ok <- authorize(p, :revoke), + true <- Request.uuid?(id), + %Credential{} = c <- Repo.get_by(Credential, id: id, owner_id: p.owner_id) do + if is_nil(c.revoked_at), + do: c |> Ecto.Changeset.change(revoked_at: now()) |> Repo.update!(log: false) + + audit( + p.owner_id, + nil, + "credential_revoked", + "revoke", + channel(p.kind), + "recorded", + "ok", + %{"actor_credential_id" => p.credential_id, "target_credential_id" => c.id} + ) + + :ok + else + {:error, _} = e -> + e + + _ -> + audit( + p.owner_id, + p.credential_id, + "authorization_denied", + "revoke", + channel(p.kind), + "denied", + "not_found" + ) + + {:error, :not_found} + end + end) + end + + def status(%Principal{} = p), do: Map.from_struct(p) + def token?(<<"whr1_", encoded::binary>>), do: canonical?(encoded, 43) + def token?(_), do: false + def primary?(secret), do: canonical?(secret, 43) + + defp canonical?(value, size) when is_binary(value) and byte_size(value) == size do + case Base.url_decode64(value, padding: false) do + {:ok, raw} -> byte_size(raw) == 32 and Base.url_encode64(raw, padding: false) == value + _ -> false + end + end + + defp canonical?(_, _), do: false + + defp issue!(owner, kind) do + token = "whr1_" <> random() + time = now() + + c = + Repo.insert!( + %Credential{ + owner_id: owner, + token_hash: digest(:credential, token), + kind: kind, + issued_at: time, + expires_at: time + if(kind == "cli_owner", do: 2_592_000, else: 28_800) + }, + log: false + ) + + audit(owner, c.id, "credential_issued", "issue", channel(kind), "recorded", "ok") + {:ok, token, principal(c)} + end + + defp active?(c), do: is_nil(c.revoked_at) and c.expires_at > now() and c.issued_at <= now() + + defp principal(c), + do: %Principal{ + owner_id: c.owner_id, + credential_id: c.id, + kind: c.kind, + scopes: + if(c.kind == "dashboard", do: ["dashboard:read"], else: ["owner:read", "owner:write"]), + issued_at: c.issued_at, + expires_at: c.expires_at + } + + defp action_allowed?(kind, a) when a in @writes, do: kind in ["cli_owner", "owner_browser"] + defp action_allowed?(kind, :owner_page), do: kind == "owner_browser" + defp action_allowed?(kind, :dashboard_page), do: kind == "dashboard" + defp action_allowed?(_, a) when a in [:status, :credential_list], do: true + defp action_allowed?(_, _), do: false + defp safe_action(a) when a in @writes or a in @reads, do: Atom.to_string(a) + defp safe_action(_), do: "unknown" + defp channel("cli_owner"), do: "cli" + defp channel(_), do: "browser" + + defp audit(owner, credential, event, action, channel, outcome, code, extra \\ %{}) do + Repo.insert!( + %AuditEvent{ + owner_id: owner, + event: event, + action: action, + channel: channel, + outcome: outcome, + code: code, + metadata: + Map.merge(if(credential, do: %{"credential_id" => credential}, else: %{}), extra) + }, + log: false + ) + end + + defp digest(:primary, v), do: :crypto.hash(:sha256, "WorkHarbor primary v1\0" <> v) + defp digest(:credential, v), do: :crypto.hash(:sha256, "WorkHarbor credential v1\0" <> v) + defp random, do: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + + defp transaction(work) do + if Repo.in_transaction?() do + {:error, :nested_transaction} + else + case Repo.transaction(work, mode: :immediate, log: false) do + {:ok, result} -> result + {:error, _} -> {:error, :persistence_failed} + end + end + rescue + _ in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + {:error, :persistence_failed} + end +end diff --git a/apps/control_plane/lib/workharbor/application.ex b/apps/control_plane/lib/workharbor/application.ex index 0205befb..193d069f 100644 --- a/apps/control_plane/lib/workharbor/application.ex +++ b/apps/control_plane/lib/workharbor/application.ex @@ -7,6 +7,11 @@ defmodule WorkHarbor.Application do @impl true def start(_type, _args) do + WorkHarborWeb.RuntimeSecurity.validate!( + Application.fetch_env!(:workharbor, WorkHarborWeb.Endpoint), + Application.fetch_env!(:workharbor, :owner_http) + ) + children = [ WorkHarborWeb.Telemetry, WorkHarbor.Repo, diff --git a/apps/control_plane/lib/workharbor/strict_json.ex b/apps/control_plane/lib/workharbor/strict_json.ex new file mode 100644 index 00000000..63eb8918 --- /dev/null +++ b/apps/control_plane/lib/workharbor/strict_json.ex @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.StrictJSON do + @moduledoc "Bounded raw JSON to native values, before duplicate keys can collapse." + @max_bytes 1_048_576 + def decode(raw) when is_binary(raw) and byte_size(raw) > @max_bytes, + do: {:error, :input_limit_exceeded} + + def decode(raw) when is_binary(raw) do + with true <- String.valid?(raw) and not String.starts_with?(raw, <<239, 187, 191>>), + :ok <- preflight(raw, false, false, [], 0), + {:ok, value} <- + Jason.decode(raw, objects: :ordered_objects, keys: :strings, strings: :copy) do + validate(value) + {:ok, native(value)} + else + _ -> {:error, :invalid_json} + end + catch + :invalid_json -> {:error, :invalid_json} + end + + def decode(_), do: {:error, :invalid_json} + + def decode_object(raw) do + case decode(raw) do + {:ok, v} when is_map(v) and not is_struct(v) -> {:ok, v} + {:ok, _} -> {:error, :invalid_json} + error -> error + end + end + + # This lexical budget pass deliberately leaves full syntax and scalar escapes + # to locked Jason. It builds neither numbers nor request objects. + defp preflight(<<>>, false, false, [], _), do: :ok + defp preflight(<<>>, _, _, _, _), do: {:error, :invalid_json} + + defp preflight(<<_, rest::binary>>, true, true, stack, _), + do: preflight(rest, true, false, stack, 0) + + defp preflight(<<92, rest::binary>>, true, false, stack, _), + do: preflight(rest, true, true, stack, 0) + + defp preflight(<<34, rest::binary>>, true, false, stack, _), + do: preflight(rest, false, false, stack, 0) + + defp preflight(<<_, rest::binary>>, true, false, stack, _), + do: preflight(rest, true, false, stack, 0) + + defp preflight(<<34, rest::binary>>, false, _, stack, _), + do: preflight(rest, true, false, stack, 0) + + defp preflight(<>, false, _, stack, _) when c in [123, 91] do + if length(stack) >= 128, + do: {:error, :invalid_json}, + else: preflight(rest, false, false, [c | stack], 0) + end + + defp preflight(<>, false, _, [o | stack], _) + when (c == 125 and o == 123) or (c == 93 and o == 91), + do: preflight(rest, false, false, stack, 0) + + defp preflight(<>, false, _, _, _) when c in [125, 93, 46], + do: {:error, :invalid_json} + + defp preflight(<>, false, _, stack, digits) when c in 48..57 do + if digits >= 4300, + do: {:error, :invalid_json}, + else: preflight(rest, false, false, stack, digits + 1) + end + + defp preflight(<>, false, _, _, digits) when c in [101, 69] and digits > 0, + do: {:error, :invalid_json} + + defp preflight(<<_, rest::binary>>, false, _, stack, _), + do: preflight(rest, false, false, stack, 0) + + defp validate(%Jason.OrderedObject{values: values}) do + keys = Enum.map(values, &elem(&1, 0)) + if length(keys) != MapSet.size(MapSet.new(keys)), do: throw(:invalid_json) + Enum.each(values, fn {_, value} -> validate(value) end) + end + + defp validate(v) when is_list(v), do: Enum.each(v, &validate/1) + defp validate(v) when is_float(v), do: throw(:invalid_json) + defp validate(_), do: :ok + + defp native(%Jason.OrderedObject{values: values}), + do: Map.new(values, fn {k, v} -> {k, native(v)} end) + + defp native(v) when is_list(v), do: Enum.map(v, &native/1) + defp native(v) when is_float(v), do: throw(:invalid_json) + defp native(v), do: v +end diff --git a/apps/control_plane/lib/workharbor_web/auth_controller.ex b/apps/control_plane/lib/workharbor_web/auth_controller.ex new file mode 100644 index 00000000..37de02bd --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/auth_controller.ex @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AuthController do + use WorkHarborWeb, :controller + import Plug.Conn + alias WorkHarbor.Accounts + alias WorkHarborWeb.{Security, BrowserSession, Body} + + def login(conn, _) do + case Body.parse(conn, :json) do + {:ok, %{"schema_version" => 1, "owner_id" => owner, "owner_secret" => secret} = body, conn} + when map_size(body) == 3 -> + case Accounts.login(owner, secret, "cli_owner") do + {:ok, token, p} -> json(conn, %{token: token, credential: Accounts.status(p)}) + {:error, e} -> error(conn, e) + end + + {:ok, _, conn} -> + error(conn, :invalid_request) + + {:error, e, conn} -> + error(conn, e) + end + end + + def status(conn, _), do: json(conn, %{credential: Accounts.status(conn.assigns.principal)}) + + def logout(conn, _) do + parsed(conn, :json, fn conn, body -> + with true <- map_size(body) == 0, + :ok <- Accounts.revoke(conn.assigns.principal, conn.assigns.principal.credential_id) do + conn = + if conn.assigns.auth_channel == :cookie, + do: configure_session(conn, drop: true), + else: conn + + json(conn, %{ok: true}) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :invalid_request) + end + end) + end + + def issue(conn, _), do: perform_issue(conn, false) + def install(conn, _), do: perform_issue(conn, true) + + defp perform_issue(conn, install?) do + parsed(conn, :json, fn conn, body -> + with true <- map_size(body) == 0, + {:ok, token, p} <- Accounts.issue_dashboard(conn.assigns.principal) do + if install?, + do: installed(conn, token, p), + else: json(conn, %{token: token, credential: Accounts.status(p)}) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :invalid_request) + end + end) + end + + def revoke(conn, _) do + parsed(conn, :json, fn conn, body -> + with %{"credential_id" => id} when map_size(body) == 1 <- body, + :ok <- Accounts.revoke(conn.assigns.principal, id) do + json(conn, %{ok: true}) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :invalid_request) + end + end) + end + + def form(conn, _) do + conn = BrowserSession.initialize_preauth(conn) + conn = Plug.CSRFProtection.call(conn, Plug.CSRFProtection.init([])) + token = Plug.CSRFProtection.get_csrf_token() + + fields = + if conn.request_path == "/login/read-token", + do: + ~s(), + else: + ~s() + + html( + conn, + ~s(WorkHarbor sign in

WorkHarbor sign in

#{fields}
) + ) + end + + def submit(conn, _) do + fields = + if conn.request_path == "/login/read-token", + do: ["dashboard_token", "_csrf_token"], + else: ["owner_id", "owner_secret", "_csrf_token"] + + if Security.own_origin?(conn) and BrowserSession.valid_preauth?(conn) do + parsed(conn, {:form, fields}, fn conn, body -> + with true <- + byte_size(body["_csrf_token"]) == 56 and + BrowserSession.selected_csrf?(conn, body["_csrf_token"]), + {:ok, token, p} <- browser_login(conn, body) do + conn + |> configure_session(drop: true) + |> BrowserSession.install(conn.private.auth_target, token, p.expires_at) + |> redirect_to(if(conn.private.auth_target == :owner, do: "/owner", else: "/dashboard")) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :forbidden) + end + end) + else + error(conn, :forbidden) + end + end + + defp browser_login(%{request_path: "/login/read-token"}, body) do + case Accounts.authenticate(body["dashboard_token"], "dashboard") do + {:ok, p} -> {:ok, body["dashboard_token"], p} + e -> e + end + end + + defp browser_login(conn, body), + do: + Accounts.login( + body["owner_id"], + body["owner_secret"], + if(conn.private.auth_target == :owner, do: "owner_browser", else: "dashboard") + ) + + def page(conn, _) do + conn = Plug.CSRFProtection.call(conn, Plug.CSRFProtection.init([])) + token = Plug.CSRFProtection.get_csrf_token() + owner? = conn.private.auth_target == :owner + title = if owner?, do: "Owner management", else: "Read-only dashboard" + + controls = + if owner? do + control_form("/owner/dashboard", token, "Open read-only dashboard") <> + control_form("/owner/logout", token, "Sign out of owner management") + else + control_form("/login/dashboard/clear", token, "Clear dashboard sign-in") + end + + html( + conn, + ~s(WorkHarbor

#{title}

#{controls}) + ) + end + + def clear(conn, _), + do: + browser_form(conn, fn conn -> + conn |> configure_session(drop: true) |> redirect_to("/login/read-token") + end) + + def browser_logout(conn, _) do + browser_form(conn, fn conn -> + case Accounts.revoke(conn.assigns.principal, conn.assigns.principal.credential_id) do + :ok -> conn |> configure_session(drop: true) |> redirect_to("/login/owner") + {:error, e} -> error(conn, e) + end + end) + end + + def browser_dashboard(conn, _) do + browser_form(conn, fn conn -> + case Accounts.issue_dashboard(conn.assigns.principal) do + {:ok, token, p} -> installed(conn, token, p) + {:error, e} -> error(conn, e) + end + end) + end + + defp browser_form(conn, work) do + if Security.own_origin?(conn) do + parsed(conn, {:form, ["_csrf_token"]}, fn conn, body -> + if BrowserSession.selected_csrf?(conn, body["_csrf_token"]), + do: work.(conn), + else: error(conn, :forbidden) + end) + else + error(conn, :forbidden) + end + end + + defp parsed(conn, type, work) do + case Body.parse(conn, type) do + {:ok, body, updated} -> work.(updated, body) + {:error, e, updated} -> error(updated, e) + end + end + + defp installed(conn, token, p), + do: + conn |> BrowserSession.install(:dashboard, token, p.expires_at) |> redirect_to("/dashboard") + + defp redirect_to(conn, path), + do: conn |> put_resp_header("location", path) |> send_resp(303, "") + + defp control_form(path, csrf, label), + do: + ~s(
) + + def error(conn, e), + do: + Security.deny( + conn, + case e do + :unauthenticated -> 401 + :forbidden -> 403 + :not_found -> 404 + :input_limit_exceeded -> 413 + :unsupported_media_type -> 415 + :persistence_failed -> 503 + _ -> 400 + end, + e + ) +end diff --git a/apps/control_plane/lib/workharbor_web/authority.ex b/apps/control_plane/lib/workharbor_web/authority.ex new file mode 100644 index 00000000..4c5f22b6 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/authority.ex @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Authority do + @moduledoc "Strict shared TLS authority/origin spelling and comparison." + def parse(raw) when is_binary(raw) and byte_size(raw) <= 320 do + case Regex.run(~r/\A(\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9.-]+)(?::([0-9]{1,5}))?\z/, raw, + capture: :all_but_first + ) do + [host] -> effective(host, 443) + [host, ""] -> effective(host, 443) + [host, port] -> effective(host, String.to_integer(port)) + _ -> :error + end + end + + def parse(_), do: :error + + def effective(host, port) when is_binary(host) and is_integer(port) and port in 1..65535 do + if byte_size(host) <= 320 and String.valid?(host) and Regex.match?(~r/\A[\x21-\x7e]+\z/, host) do + normalized(host, port) + else + :error + end + end + + def effective(_, _), do: :error + + defp normalized(host, port) do + host = String.downcase(host) + + cond do + String.starts_with?(host, "[") -> + if String.ends_with?(host, "]"), + do: ipv6(String.slice(host, 1, byte_size(host) - 2), port), + else: :error + + String.contains?(host, ":") -> + ipv6(host, port) + + byte_size(host) > 253 or host == "" -> + :error + + Enum.all?( + String.split(host, "."), + &Regex.match?(~r/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/, &1) + ) -> + {:ok, {host, port}} + + true -> + :error + end + end + + def origin(value) when is_binary(value) do + case Regex.run(~r/\Ahttps:\/\/(.+)\z/i, value, capture: :all_but_first) do + [authority] -> parse(authority) + _ -> :error + end + end + + def origin(_), do: :error + + defp ipv6(host, port) do + case :inet.parse_address(String.to_charlist(host)) do + {:ok, tuple} when tuple_size(tuple) == 8 -> + {:ok, {tuple |> :inet.ntoa() |> to_string(), port}} + + _ -> + :error + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/endpoint.ex b/apps/control_plane/lib/workharbor_web/endpoint.ex index b5921a95..7d7e7f01 100644 --- a/apps/control_plane/lib/workharbor_web/endpoint.ex +++ b/apps/control_plane/lib/workharbor_web/endpoint.ex @@ -1,51 +1,34 @@ +# SPDX-License-Identifier: EUPL-1.2 + defmodule WorkHarborWeb.Endpoint do use Phoenix.Endpoint, otp_app: :workharbor - # The session will be stored in the cookie and signed, - # this means its contents can be read but not tampered with. - # Set :encryption_salt if you would also like to encrypt it. - @session_options [ - store: :cookie, - key: "_workharbor_key", - signing_salt: "W6GBXzES", - same_site: "Lax" - ] - - socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] + socket "/owner/live", WorkHarborWeb.OwnerSocket, + websocket: [ + connect_info: [session: {WorkHarborWeb.BrowserSession, :options, [:owner]}], + check_csrf: true, + check_origin: {WorkHarborWeb.Security, :socket_origin, []} + ], + longpoll: false - # Serve at "/" the static files from "priv/static" directory. - # - # When code reloading is disabled (e.g., in production), - # the `gzip` option is enabled to serve compressed - # static files generated by running `phx.digest`. - plug Plug.Static, - at: "/", - from: :workharbor, - gzip: not code_reloading?, - only: WorkHarborWeb.static_paths(), - raise_on_missing_only: code_reloading? + socket "/dashboard/live", WorkHarborWeb.DashboardSocket, + websocket: [ + connect_info: [session: {WorkHarborWeb.BrowserSession, :options, [:dashboard]}], + check_csrf: true, + check_origin: {WorkHarborWeb.Security, :socket_origin, []} + ], + longpoll: false - # Code reloading can be explicitly enabled under the - # :code_reloader configuration of your endpoint. - if code_reloading? do - socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket - plug Phoenix.LiveReloader - plug Phoenix.CodeReloader - plug Phoenix.Ecto.CheckRepoStatus, otp_app: :workharbor + # Phoenix inserts socket_dispatch before user plugs. Wrap its entry explicitly. + def call(conn, opts) do + case WorkHarborWeb.Security.call(conn, []) do + %{halted: true} = conn -> conn + conn -> super(conn, opts) + end end plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] - - plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], - pass: ["*/*"], - json_decoder: Phoenix.json_library() - - plug Plug.MethodOverride - plug Plug.Head - plug Plug.Session, @session_options + plug WorkHarborWeb.BrowserSession plug WorkHarborWeb.Router end diff --git a/apps/control_plane/lib/workharbor_web/login_form.ex b/apps/control_plane/lib/workharbor_web/login_form.ex new file mode 100644 index 00000000..7e616361 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/login_form.ex @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.LoginForm do + @moduledoc false + def decode(raw, _) when is_binary(raw) and byte_size(raw) > 4096, + do: {:error, :input_limit_exceeded} + + def decode(raw, fields) when is_binary(raw) do + pairs = String.split(raw, "&") + + with true <- length(pairs) <= 3, + {:ok, values} <- decode_pairs(pairs, fields, []) do + {:ok, Map.new(values)} + else + _ -> {:error, :invalid_form} + end + end + + def decode(_, _), do: {:error, :invalid_form} + + defp decode_pairs([], fields, values) do + if Enum.sort(Enum.map(values, &elem(&1, 0))) == Enum.sort(fields), + do: {:ok, values}, + else: :error + end + + defp decode_pairs([pair | rest], fields, values) do + with [k, v] <- String.split(pair, "=", parts: 2), + true <- percent?(k) and percent?(v), + key <- URI.decode_www_form(k), + value <- URI.decode_www_form(v), + true <- String.valid?(key) and String.valid?(value) and value != "", + true <- key in fields and not List.keymember?(values, key, 0) do + decode_pairs(rest, fields, [{key, value} | values]) + else + _ -> :error + end + end + + defp percent?(<<>>), do: true + + defp percent?(<<37, a, b, rest::binary>>) + when a in ~c"0123456789abcdefABCDEF" and b in ~c"0123456789abcdefABCDEF", + do: percent?(rest) + + defp percent?(<<37, _::binary>>), do: false + defp percent?(<<_, rest::binary>>), do: percent?(rest) +end diff --git a/apps/control_plane/lib/workharbor_web/owner_socket.ex b/apps/control_plane/lib/workharbor_web/owner_socket.ex new file mode 100644 index 00000000..e8adda62 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/owner_socket.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.OwnerSocket do + use Phoenix.LiveView.Socket + + def connect(_params, socket, info) do + with %{"credential" => token} <- info[:session], + {:ok, p} <- WorkHarbor.Accounts.authenticate(token, "owner_browser"), + :ok <- WorkHarbor.Accounts.authorize(p, :owner_page) do + {:ok, Phoenix.Socket.assign(socket, :principal, p)} + else + _ -> :error + end + end +end + +defmodule WorkHarborWeb.DashboardSocket do + use Phoenix.LiveView.Socket + + def connect(_params, socket, info) do + with %{"credential" => token} <- info[:session], + {:ok, p} <- WorkHarbor.Accounts.authenticate(token, "dashboard"), + :ok <- WorkHarbor.Accounts.authorize(p, :dashboard_page) do + {:ok, Phoenix.Socket.assign(socket, :principal, p)} + else + _ -> :error + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/plugs/require_owner.ex b/apps/control_plane/lib/workharbor_web/plugs/require_owner.ex new file mode 100644 index 00000000..e75c99f5 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/plugs/require_owner.ex @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Plugs.RequireOwner do + @moduledoc false + import Plug.Conn + alias WorkHarbor.{Accounts} + alias WorkHarborWeb.{Security, BrowserSession} + def init(opts), do: opts + + def call(conn, action) do + conn = fetch_session(conn) + + with {:ok, p, channel} <- principal(conn), + :ok <- Accounts.authorize(p, action), + :ok <- channel_admission(conn, channel, action) do + conn |> assign(:principal, p) |> assign(:auth_channel, channel) + else + {:error, :forbidden} -> Security.deny(conn, 403, :forbidden) + {:error, :context} -> Security.deny(conn, 400, :invalid_context) + _ -> Security.deny(conn, 401, :unauthenticated) + end + end + + def principal(conn) do + cookie? = BrowserSession.auth_cookies?(conn) + + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> + with {:ok, p} <- Accounts.authenticate(token), + true <- p.kind in ["cli_owner", "dashboard"] do + if cookie?, do: known_mixed(p), else: {:ok, p, :bearer} + else + _ -> {:error, :unauthenticated} + end + + [] -> + target = conn.private.auth_target + + cond do + is_nil(target) and cookie? -> + {:error, :context} + + is_nil(target) -> + {:error, :unauthenticated} + + true -> + case Accounts.authenticate( + get_session(conn, "credential"), + if(target == :owner, do: "owner_browser", else: "dashboard") + ) do + {:ok, p} -> {:ok, p, :cookie} + e -> e + end + end + + _ -> + {:error, :unauthenticated} + end + end + + def primary_admission(conn) do + conn = fetch_session(conn) + + case principal(conn) do + {:ok, %{kind: "dashboard"} = p, _} -> + Accounts.authorize(p, :primary_login) + Security.deny(conn, 403, :forbidden) + + {:error, :forbidden} -> + Security.deny(conn, 403, :forbidden) + + _ -> + if get_req_header(conn, "authorization") != [] or BrowserSession.auth_cookies?(conn) or + Enum.any?( + ["origin", "sec-fetch-site", "x-workharbor-context"], + &(get_req_header(conn, &1) != []) + ), do: Security.deny(conn, 400, :invalid_channel), else: conn + end + end + + def cli_secret(conn, _) do + if conn.assigns.auth_channel == :bearer and conn.assigns.principal.kind == "cli_owner" do + conn + else + Accounts.deny(conn.assigns.principal, :issue_dashboard) + Security.deny(conn, 403, :forbidden) + end + end + + def cookie_csrf(conn) do + if conn.assigns[:auth_channel] == :cookie do + origin = Security.own_origin?(conn) + + allowed = + origin or + (conn.method in ["GET", "HEAD"] and get_req_header(conn, "origin") == [] and + get_req_header(conn, "sec-fetch-site") == ["same-origin"]) + + token = + case get_req_header(conn, "x-csrf-token") do + [v] -> v + _ -> nil + end + + if allowed and BrowserSession.selected_csrf?(conn, token), + do: conn, + else: Security.deny(conn, 403, :forbidden) + else + conn + end + end + + defp known_mixed(%{kind: "dashboard"} = p), do: Accounts.authorize(p, :primary_login) + defp known_mixed(_), do: {:error, :unauthenticated} + + defp channel_admission(conn, :bearer, _) do + if not BrowserSession.auth_cookies?(conn) and + Enum.all?( + ["origin", "sec-fetch-site", "x-workharbor-context"], + &(get_req_header(conn, &1) == []) + ), do: :ok, else: {:error, :forbidden} + end + + defp channel_admission(conn, :cookie, action) when action in [:owner_page, :dashboard_page] do + if get_req_header(conn, "sec-fetch-site") in [[], ["none"], ["same-origin"]], + do: :ok, + else: {:error, :forbidden} + end + + defp channel_admission(_, :cookie, _), do: :ok +end diff --git a/apps/control_plane/lib/workharbor_web/router.ex b/apps/control_plane/lib/workharbor_web/router.ex index 2b953552..66a59d37 100644 --- a/apps/control_plane/lib/workharbor_web/router.ex +++ b/apps/control_plane/lib/workharbor_web/router.ex @@ -1,27 +1,122 @@ +# SPDX-License-Identifier: EUPL-1.2 + defmodule WorkHarborWeb.Router do use WorkHarborWeb, :router + alias WorkHarborWeb.Plugs.RequireOwner - pipeline :browser do - plug :accepts, ["html"] + pipeline :session do plug :fetch_session - plug :fetch_live_flash - plug :put_root_layout, html: {WorkHarborWeb.Layouts, :root} - plug :protect_from_forgery - plug :put_secure_browser_headers end - pipeline :api do - plug :accepts, ["json"] + pipeline :primary do + plug :primary_admission + end + + pipeline :read do + plug RequireOwner, :status + plug :api_csrf + end + + pipeline :logout do + plug RequireOwner, :logout + plug :api_csrf + end + + pipeline :revoke do + plug RequireOwner, :revoke + plug :api_csrf + end + + pipeline :issue do + plug RequireOwner, :issue_dashboard + plug :api_csrf + end + + pipeline :issue_secret do + plug RequireOwner, :issue_dashboard + plug :cli_secret + end + + pipeline :browser_logout do + plug RequireOwner, :logout + end + + pipeline :browser_issue do + plug RequireOwner, :issue_dashboard + end + + pipeline :owner do + plug RequireOwner, :owner_page + end + + pipeline :dashboard do + plug RequireOwner, :dashboard_page end scope "/", WorkHarborWeb do - pipe_through :browser + pipe_through :session + get "/login/owner", AuthController, :form, log: false + get "/login/dashboard", AuthController, :form, log: false + get "/login/read-token", AuthController, :form, log: false + post "/login/owner", AuthController, :submit, log: false + post "/login/dashboard", AuthController, :submit, log: false + post "/login/read-token", AuthController, :submit, log: false + post "/login/dashboard/clear", AuthController, :clear, log: false + end - get "/", PageController, :home + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :primary] + post "/login", AuthController, :login, log: false end - # Other scopes may use custom stacks. - # scope "/api", WorkHarborWeb do - # pipe_through :api - # end + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :read] + get "/status", AuthController, :status, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :logout] + post "/logout", AuthController, :logout, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :revoke] + post "/revoke", AuthController, :revoke, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :issue_secret] + post "/dashboard-credentials", AuthController, :issue, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :issue] + post "/dashboard-install", AuthController, :install, log: false + end + + scope "/owner", WorkHarborWeb do + pipe_through [:session, :browser_logout] + post "/logout", AuthController, :browser_logout, log: false + end + + scope "/owner", WorkHarborWeb do + pipe_through [:session, :browser_issue] + post "/dashboard", AuthController, :browser_dashboard, log: false + end + + scope "/", WorkHarborWeb do + pipe_through [:session, :owner] + get "/", AuthController, :page + get "/owner", AuthController, :page + end + + scope "/", WorkHarborWeb do + pipe_through [:session, :dashboard] + get "/dashboard", AuthController, :page + end + + defp cli_secret(conn, opts), do: RequireOwner.cli_secret(conn, opts) + + defp primary_admission(conn, _), do: RequireOwner.primary_admission(conn) + defp api_csrf(conn, _), do: RequireOwner.cookie_csrf(conn) end diff --git a/apps/control_plane/lib/workharbor_web/runtime_security.ex b/apps/control_plane/lib/workharbor_web/runtime_security.ex new file mode 100644 index 00000000..4282fd9f --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/runtime_security.ex @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.RuntimeSecurity do + @moduledoc "Fail closed before an authenticated listener is started." + import Bitwise + + @demo [ + "9kwSZZ3zqgPLCQHHHC7Q9KYXXSVttc/mmuUT4ZmFiV1BGre/cOXzP7jNrsMeH4N4", + "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl" + ] + def validate!(config, owner_http) do + if config[:server] == true do + key = config[:secret_key_base] + tls = config[:https] + + valid = + is_binary(key) and byte_size(key) >= 64 and key not in @demo and + config[:debug_errors] == false and config[:code_reloader] == false and + config[:http] in [nil, false] and config[:force_ssl] in [nil, false] and is_list(tls) and + is_tuple(tls[:ip]) and is_integer(tls[:port]) and tls[:port] in 1..65535 and + regular?(tls[:certfile]) and private?(tls[:keyfile]) and + valid_origin?(owner_http, tls[:port]) + + if not valid, do: raise(ArgumentError, "unsafe authentication server configuration") + end + + :ok + rescue + _ -> raise ArgumentError, "unsafe authentication server configuration" + end + + defp regular?(p), do: is_binary(p) and Path.type(p) == :absolute and File.regular?(p) + + defp private?(p) do + with true <- regular?(p), {:ok, s} <- File.stat(p), do: (s.mode &&& 0o077) == 0 + end + + defp valid_origin?(owner_http, listener_port) do + with {:ok, {_, port} = authority} <- WorkHarborWeb.Authority.origin(owner_http[:origin]), + true <- port == listener_port, + authorities when is_list(authorities) <- owner_http[:authorities], + true <- + Enum.all?(authorities, fn + {host, p} -> match?({:ok, _}, WorkHarborWeb.Authority.effective(host, p)) + _ -> false + end) do + Enum.any?(authorities, fn {host, p} -> + WorkHarborWeb.Authority.effective(host, p) == {:ok, authority} + end) + else + _ -> false + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/security.ex b/apps/control_plane/lib/workharbor_web/security.ex new file mode 100644 index 00000000..35453bab --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/security.ex @@ -0,0 +1,347 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Security do + @moduledoc false + import Plug.Conn + def init(opts), do: opts + + def call(conn, _) do + config = Application.fetch_env!(:workharbor, :owner_http) + + with true <- conn.scheme == :https, + true <- authority?(conn, Keyword.fetch!(config, :authorities)), + true <- origin?(conn), + {:ok, target, store} <- context(conn), + true <- socket_origin?(conn), + :ok <- canonical_path(conn), + :ok <- query_admission(conn), + true <- upgrade_headers?(conn) do + conn |> put_private(:auth_target, target) |> put_private(:auth_store, store) |> headers() + else + {:error, :invalid_request} -> deny(conn, 400, :invalid_request) + _ -> deny(conn, 403, :forbidden) + end + end + + def headers(conn) do + conn + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("x-content-type-options", "nosniff") + |> put_resp_header("x-frame-options", "DENY") + |> put_resp_header( + "content-security-policy", + "default-src 'none'; script-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" + ) + |> put_resp_header("referrer-policy", "no-referrer") + |> put_resp_header("strict-transport-security", "max-age=31536000") + end + + def deny(conn, status, code), + do: + conn + |> headers() + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(%{error: %{code: code}})) + |> halt() + + def own_origin?(conn) do + expected = + WorkHarborWeb.Authority.origin( + Keyword.fetch!(Application.fetch_env!(:workharbor, :owner_http), :origin) + ) + + case get_req_header(conn, "origin") do + [origin] -> expected != :error and WorkHarborWeb.Authority.origin(origin) == expected + _ -> false + end + end + + def socket_origin(uri) do + WorkHarborWeb.Authority.origin(URI.to_string(uri)) == + WorkHarborWeb.Authority.origin( + Keyword.fetch!(Application.fetch_env!(:workharbor, :owner_http), :origin) + ) + end + + defp origin?(conn), do: get_req_header(conn, "origin") == [] or own_origin?(conn) + + defp socket?(conn), + do: conn.request_path in ["/owner/live/websocket", "/dashboard/live/websocket"] + + defp socket_origin?(conn), do: not socket?(conn) or own_origin?(conn) + + defp authority?(conn, allowed) do + with {:ok, effective} <- WorkHarborWeb.Authority.effective(conn.host, conn.port), + true <- + Enum.any?(allowed, fn {host, port} -> + WorkHarborWeb.Authority.effective(host, port) == {:ok, effective} + end) do + case get_req_header(conn, "host") do + [] -> get_http_protocol(conn) == :"HTTP/2" + [h] -> WorkHarborWeb.Authority.parse(h) == {:ok, effective} + _ -> false + end + else + _ -> false + end + end + + defp canonical_path(conn) do + path = conn.request_path + + if String.contains?(path, ["%", "//", "\\"]) or (path != "/" and String.ends_with?(path, "/")), + do: {:error, :invalid_request}, + else: :ok + end + + # Fixed current auth/control routes have no query parameters. Socket transport + # allows only its two non-credential parameters, decoded before duplicate loss. + defp query_admission(conn) do + cond do + socket?(conn) -> + case WorkHarborWeb.LoginForm.decode(conn.query_string, ["_csrf_token", "vsn"]) do + {:ok, %{"_csrf_token" => csrf, "vsn" => "2.0.0"}} when byte_size(csrf) == 56 -> :ok + _ -> {:error, :invalid_request} + end + + conn.query_string == "" -> + :ok + + true -> + {:error, :invalid_request} + end + end + + # The stock adapter reports malformed upgrade header values in its exception. + # Admit only known header shapes so those diagnostics cannot echo credentials. + defp upgrade_headers?(conn) do + not socket?(conn) or + (get_req_header(conn, "sec-websocket-version") == ["13"] and + header_token?(conn, "connection", "upgrade") and + header_token?(conn, "upgrade", "websocket") and + case get_req_header(conn, "sec-websocket-key") do + [key] -> + case Base.decode64(key) do + {:ok, bytes} -> byte_size(bytes) == 16 and Base.encode64(bytes) == key + _ -> false + end + + _ -> + false + end) + end + + defp header_token?(conn, name, expected) do + case get_req_header(conn, name) do + [value] -> expected in (value |> String.downcase() |> Plug.Conn.Utils.list()) + _ -> false + end + end + + defp context(conn) do + target = + cond do + conn.request_path == "/" -> + :owner + + conn.request_path == "/login/dashboard/clear" -> + :dashboard + + String.starts_with?(conn.request_path, "/login/owner") -> + :owner + + String.starts_with?(conn.request_path, "/login/dashboard") or + conn.request_path == "/login/read-token" -> + :dashboard + + String.starts_with?(conn.request_path, "/owner") -> + :owner + + String.starts_with?(conn.request_path, "/dashboard") -> + :dashboard + + true -> + nil + end + + values = get_req_header(conn, "x-workharbor-context") + + header = + case values do + [] -> nil + ["owner"] -> :owner + ["dashboard"] -> :dashboard + _ -> :invalid + end + + cond do + header == :invalid -> + {:error, :context} + + target && header && target != header -> + {:error, :context} + + true -> + target = target || header + + store = + if String.starts_with?(conn.request_path, "/login/") and + conn.request_path != "/login/dashboard/clear", + do: :preauth, + else: target || :preauth + + {:ok, target, store} + end + end +end + +defmodule WorkHarborWeb.BrowserSession do + @moduledoc false + import Plug.Conn + + @names %{ + owner: "__Host-workharbor_owner", + dashboard: "__Host-workharbor_dashboard", + preauth: "__Host-workharbor_login" + } + def options(context) do + [ + store: :cookie, + key: Map.fetch!(@names, context), + signing_salt: "workharbor-#{context}-sign-v1", + encryption_salt: "workharbor-#{context}-encrypt-v1", + secure: true, + http_only: true, + same_site: "Strict", + path: "/", + max_age: if(context == :preauth, do: 600, else: 28_800), + log: false + ] + end + + def init(opts), do: opts + + def call(conn, _) do + conn = register_before_send(conn, &cap_cookie/1) + Plug.Session.call(conn, Plug.Session.init(options(conn.private.auth_store))) + end + + defp cap_cookie(conn) do + store = conn.private.auth_store + key = Map.fetch!(@names, store) + + case {store, conn.assigns[:principal], conn.resp_cookies[key]} do + {s, %{expires_at: expiry}, %{max_age: age} = cookie} + when s in [:owner, :dashboard] and age > 0 -> + cookie = Map.put(cookie, :max_age, min(age, max(expiry - WorkHarbor.Accounts.now(), 0))) + %{conn | resp_cookies: Map.put(conn.resp_cookies, key, cookie)} + + _ -> + conn + end + end + + def install(conn, context, token, expiry) do + opts = options(context) + + value = + Plug.Session.COOKIE.put(conn, nil, %{"credential" => token}, Plug.Session.COOKIE.init(opts)) + + cookie_opts = + Keyword.take(opts, [:secure, :http_only, :same_site, :path]) + |> Keyword.put(:max_age, max(expiry - WorkHarbor.Accounts.now(), 0)) + + put_resp_cookie(conn, Keyword.fetch!(opts, :key), value, cookie_opts) + end + + def auth_cookies?(conn) do + conn = fetch_cookies(conn) + Enum.any?([:owner, :dashboard], &Map.has_key?(conn.req_cookies, @names[&1])) + end + + def selected_csrf?(conn, token) do + state = Plug.CSRFProtection.dump_state_from_session(get_session(conn, "_csrf_token")) + + is_binary(state) and is_binary(token) and + Plug.CSRFProtection.valid_state_and_csrf_token?(state, token) + end + + def initialize_preauth(conn) do + now = WorkHarbor.Accounts.now() + + conn + |> clear_session() + |> configure_session(renew: true) + |> put_session("issued_at", now) + |> put_session("expires_at", now + 600) + end + + def valid_preauth?(conn) do + issued = get_session(conn, "issued_at") + expiry = get_session(conn, "expires_at") + now = WorkHarbor.Accounts.now() + + is_integer(issued) and is_integer(expiry) and expiry - issued == 600 and issued <= now and + expiry > now + end +end + +defmodule WorkHarborWeb.Body do + @moduledoc false + import Plug.Conn + + def parse(conn, :json) do + parse( + conn, + 1_048_576, + ~r/^application\/json(?:;\s*charset=(?:utf-8|"utf-8"))?$/i, + &WorkHarbor.StrictJSON.decode_object/1 + ) + end + + def parse(conn, {:form, fields}) do + parse( + conn, + 4096, + ~r/^application\/x-www-form-urlencoded(?:;\s*charset=(?:utf-8|"utf-8"))?$/i, + &WorkHarborWeb.LoginForm.decode(&1, fields) + ) + end + + defp parse(conn, limit, type, decoder) do + with [header] <- get_req_header(conn, "content-type"), true <- Regex.match?(type, header) do + case read(conn, limit, [], 0, System.monotonic_time(:millisecond) + 15_000) do + {:ok, body, conn} -> + case decoder.(body) do + {:ok, value} -> {:ok, value, conn} + {:error, e} -> {:error, e, conn} + end + + error -> + error + end + else + _ -> {:error, :unsupported_media_type, conn} + end + end + + defp read(conn, limit, parts, size, deadline) do + if System.monotonic_time(:millisecond) > deadline do + {:error, :invalid_request, conn} + else + case read_body(conn, length: 16_384, read_length: 16_384, read_timeout: 5000) do + {status, data, conn} when status in [:more, :ok] -> + size = size + byte_size(data) + + cond do + size > limit -> {:error, :input_limit_exceeded, conn} + status == :ok -> {:ok, IO.iodata_to_binary(Enum.reverse([data | parts])), conn} + true -> read(conn, limit, [data | parts], size, deadline) + end + + _ -> + {:error, :invalid_request, conn} + end + end + end +end diff --git a/apps/control_plane/priv/repo/migrations/20261014010000_owner_authentication.exs b/apps/control_plane/priv/repo/migrations/20261014010000_owner_authentication.exs new file mode 100644 index 00000000..ae7ec276 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261014010000_owner_authentication.exs @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.OwnerAuthentication do + use Ecto.Migration + + def up do + execute( + "CREATE TABLE owner_authenticators (id INTEGER PRIMARY KEY CHECK(id=1), owner_id TEXT NOT NULL UNIQUE REFERENCES owners(id), secret_hash BLOB NOT NULL CHECK(length(secret_hash)=32))" + ) + + execute( + "CREATE TABLE owner_credentials (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), token_hash BLOB NOT NULL UNIQUE CHECK(length(token_hash)=32), kind TEXT NOT NULL CHECK(kind IN ('cli_owner','owner_browser','dashboard')), issued_at INTEGER NOT NULL CHECK(typeof(issued_at)='integer'), expires_at INTEGER NOT NULL CHECK(typeof(expires_at)='integer' AND expires_at>issued_at), revoked_at INTEGER CHECK(revoked_at IS NULL OR typeof(revoked_at)='integer'), CHECK(expires_at-issued_at<=CASE WHEN kind='cli_owner' THEN 2592000 ELSE 28800 END))" + ) + + execute( + "CREATE TRIGGER owner_authenticator_immutable BEFORE UPDATE ON owner_authenticators BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + + execute( + "CREATE TRIGGER owner_credential_identity_immutable BEFORE UPDATE ON owner_credentials WHEN NEW.id IS NOT OLD.id OR NEW.owner_id IS NOT OLD.owner_id OR NEW.token_hash IS NOT OLD.token_hash OR NEW.kind IS NOT OLD.kind OR NEW.issued_at IS NOT OLD.issued_at OR NEW.expires_at IS NOT OLD.expires_at OR (OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NOT OLD.revoked_at) BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + + execute( + "CREATE TRIGGER owner_authenticator_retained BEFORE DELETE ON owner_authenticators BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + + execute( + "CREATE TRIGGER owner_credentials_retained BEFORE DELETE ON owner_credentials BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + end + + def down do + drop table(:owner_credentials) + drop table(:owner_authenticators) + end +end diff --git a/apps/control_plane/test/support/auth_database.ex b/apps/control_plane/test/support/auth_database.ex new file mode 100644 index 00000000..c1a8199d --- /dev/null +++ b/apps/control_plane/test/support/auth_database.ex @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.AuthDatabase do + def open!(path) do + pid = WorkHarbor.FoundationDatabase.open!(path) + + Code.require_file( + Application.app_dir( + :workharbor, + "priv/repo/migrations/20261014010000_owner_authentication.exs" + ) + ) + + Ecto.Migrator.run( + WorkHarbor.Repo, + [{20_261_014_010_000, WorkHarbor.Repo.Migrations.OwnerAuthentication}], + :up, + all: true, + log: false + ) + + pid + end + + def fixture! do + {:ok, owner} = + WorkHarbor.Accounts.bootstrap(fn secret -> + send(self(), {:bootstrap_secret, secret}) + :ok + end) + + receive do + {:bootstrap_secret, secret} -> {owner.id, secret} + end + end +end diff --git a/apps/control_plane/test/workharbor/accounts_test.exs b/apps/control_plane/test/workharbor/accounts_test.exs new file mode 100644 index 00000000..7cdefc6a --- /dev/null +++ b/apps/control_plane/test/workharbor/accounts_test.exs @@ -0,0 +1,215 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.AccountsTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, Repo} + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-auth-#{Ecto.UUID.generate()}.db") + pid = WorkHarbor.AuthDatabase.open!(path) + Application.put_env(:workharbor, :clock, fn -> 2_000_000_000 end) + + on_exit(fn -> + Application.delete_env(:workharbor, :clock) + if Process.alive?(pid), do: GenServer.stop(pid) + for s <- ["", "-wal", "-shm"], do: File.rm(path <> s) + end) + + %{path: path, pid: pid} + end + + test "bootstrap delivery failure never reinitializes owner" do + assert {:error, :delivery_failed} = Accounts.bootstrap(fn _ -> raise "SECRET-CANARY" end) + assert DB.count("owner_authenticators") == 1 + assert DB.count("owners") == 1 + assert {:error, :already_initialized} = Accounts.bootstrap(fn _ -> flunk("redelivery") end) + end + + test "bootstrap writers serialize a single verifier" do + repo = Repo.get_dynamic_repo() + parent = self() + + results = + for _ <- 1..4 do + Task.async(fn -> + Repo.put_dynamic_repo(repo) + + Accounts.bootstrap(fn s -> + send(parent, {:delivered, s}) + :ok + end) + end) + end + |> Enum.map(&Task.await(&1, 5000)) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :already_initialized})) == 3 + assert_receive {:delivered, s} + assert byte_size(s) == 43 + refute_receive {:delivered, _} + end + + test "primary auth fixed scopes and hashes preserve secret safety" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + assert {:error, :unauthenticated} = Accounts.login(Ecto.UUID.generate(), secret, "cli_owner") + + assert {:error, :unauthenticated} = + Accounts.login(owner, String.duplicate("x", 43), "cli_owner") + + for {kind, ttl} <- [ + {"cli_owner", 2_592_000}, + {"owner_browser", 28_800}, + {"dashboard", 28_800} + ] do + assert {:ok, token, p} = Accounts.login(owner, secret, kind) + assert byte_size(token) == 48 + assert p.expires_at - p.issued_at == ttl + assert {:ok, _} = Accounts.authenticate(token, kind) + assert {:error, :unauthenticated} = Accounts.authenticate(token, "unknown") + refute inspect(Repo.all(Accounts.Credential)) =~ token + end + + refute inspect(DB.sql("SELECT * FROM owner_authenticators").rows) =~ secret + assert {:error, :invalid_request} = Accounts.login(owner, secret, "invented") + end + + test "exact expiry revoked reopen and independent credentials", c do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, p} = Accounts.login(owner, secret, "cli_owner") + {:ok, other, _} = Accounts.login(owner, secret, "cli_owner") + Application.put_env(:workharbor, :clock, fn -> p.expires_at - 1 end) + assert {:ok, _} = Accounts.authenticate(token) + Application.put_env(:workharbor, :clock, fn -> p.expires_at end) + assert {:error, :unauthenticated} = Accounts.authenticate(token) + Application.put_env(:workharbor, :clock, fn -> p.issued_at end) + assert :ok = Accounts.revoke(p, p.credential_id) + GenServer.stop(c.pid) + pid = WorkHarbor.AuthDatabase.open!(c.path) + assert {:error, :unauthenticated} = Accounts.authenticate(token) + assert {:ok, _} = Accounts.authenticate(other) + GenServer.stop(pid) + end + + test "read scope denies mutations grants unknown action and foreign ownership" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, p} = Accounts.login(owner, secret, "dashboard") + + for a <- [:logout, :revoke, :issue_dashboard, :primary_login, :unknown], + do: assert({:error, :forbidden} == Accounts.authorize(p, a, owner)) + + assert {:error, :forbidden} = Accounts.revoke(p, p.credential_id) + assert {:error, :forbidden} = Accounts.issue_dashboard(p) + assert DB.count("owner_credentials") == 1 + assert DB.sql("SELECT revoked_at FROM owner_credentials").rows == [[nil]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event='authorization_denied'").rows == + [[7]] + + {:ok, _, write} = Accounts.login(owner, secret, "cli_owner") + assert {:error, :forbidden} = Accounts.authorize(write, :revoke, Ecto.UUID.generate()) + end + + test "read issuance and fresh login do not automatically revoke previous tokens" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, first, p} = Accounts.login(owner, secret, "owner_browser") + assert {:ok, read, r} = Accounts.issue_dashboard(p) + assert r.scopes == ["dashboard:read"] + assert {:ok, _, _} = Accounts.login(owner, secret, "owner_browser") + assert {:ok, _} = Accounts.authenticate(first) + assert {:ok, _} = Accounts.authenticate(read) + assert :ok = Accounts.revoke(p, r.credential_id) + assert {:error, :unauthenticated} = Accounts.authenticate(read) + end + + test "revocation audit distinguishes actor target and unrelated viewer" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, actor} = Accounts.login(owner, secret, "cli_owner") + {:ok, first, target} = Accounts.issue_dashboard(actor) + {:ok, second, _} = Accounts.issue_dashboard(actor) + assert :ok = Accounts.revoke(actor, target.credential_id) + + [[metadata]] = + DB.sql("SELECT metadata FROM audit_events WHERE event='credential_revoked'").rows + + assert Jason.decode!(metadata) == %{ + "actor_credential_id" => actor.credential_id, + "target_credential_id" => target.credential_id + } + + assert {:error, :unauthenticated} = Accounts.authenticate(first) + assert {:ok, _} = Accounts.authenticate(second) + end + + test "direct SQL cannot relabel credentials extend expiry or delete authenticator" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, p} = Accounts.login(owner, secret, "dashboard") + + for sql <- [ + "UPDATE owner_credentials SET kind='cli_owner'", + "UPDATE owner_credentials SET expires_at=expires_at+1", + "UPDATE owner_authenticators SET owner_id='invented'", + "DELETE FROM owner_authenticators" + ] do + assert_raise Exqlite.Error, fn -> DB.sql(sql) end + end + + assert Repo.get(Accounts.Credential, p.credential_id).kind == "dashboard" + end + + test "foreign or malformed revoke denial is audited without raw target content" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, p} = Accounts.login(owner, secret, "cli_owner") + assert {:error, :not_found} = Accounts.revoke(p, "SECRET-CANARY") + + [[action, metadata]] = + DB.sql("SELECT action,metadata FROM audit_events WHERE event='authorization_denied'").rows + + assert action == "revoke" + refute metadata =~ "SECRET-CANARY" + assert Jason.decode!(metadata)["credential_id"] == p.credential_id + end + + test "bootstrap preserves owner metadata audit and rejects nested transactions" do + {owner, _} = WorkHarbor.AuthDatabase.fixture!() + + assert DB.sql("SELECT event FROM audit_events WHERE event='owner_created' AND owner_id=?", [ + owner + ]).rows == [["owner_created"]] + + assert {:ok, :ok} = + Repo.transaction(fn -> + assert {:error, :nested_transaction} = + Accounts.bootstrap(fn _ -> flunk("nested delivery") end) + + :ok + end) + end + + test "existing foreign credential revoke is concealed audited and unchanged" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, actor} = Accounts.login(owner, secret, "cli_owner") + {:ok, foreign} = WorkHarbor.Foundation.create_owner("foreign metadata fixture") + + target = + Repo.insert!(%Accounts.Credential{ + owner_id: foreign.id, + kind: "dashboard", + token_hash: :crypto.strong_rand_bytes(32), + issued_at: Accounts.now(), + expires_at: Accounts.now() + 100 + }) + + assert Repo.get(Accounts.Credential, target.id) == target + assert {:error, :not_found} = Accounts.revoke(actor, target.id) + assert Repo.get(Accounts.Credential, target.id) == target + + [[audit_owner, action, code, metadata]] = + DB.sql( + "SELECT owner_id,action,code,metadata FROM audit_events WHERE event='authorization_denied'" + ).rows + + assert audit_owner == owner and action == "revoke" and code == "not_found" + assert Jason.decode!(metadata) == %{"credential_id" => actor.credential_id} + end +end diff --git a/apps/control_plane/test/workharbor/strict_json_test.exs b/apps/control_plane/test/workharbor/strict_json_test.exs new file mode 100644 index 00000000..177a80e9 --- /dev/null +++ b/apps/control_plane/test/workharbor/strict_json_test.exs @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.StrictJSONTest do + use ExUnit.Case, async: true + alias WorkHarbor.StrictJSON, as: J + + test "decoded duplicate keys reject at every depth before maps" do + for raw <- [~S({"a":1,"\u0061":2}), ~S({"x":[{"a":1,"a":2}]}), ~S({"\ud800\udc00":1,"𐀀":2})], + do: assert({:error, :invalid_json} == J.decode(raw)) + + assert {:ok, %{"é" => 1, "é" => 2}} = J.decode(~S({"é":1,"é":2})) + end + + test "strict Unicode floats syntax and huge numeric forms reject safely" do + for raw <- [ + <<255>>, + <<239, 187, 191, 123, 125>>, + ~S("\ud800"), + ~S("\udc00"), + ~S("\udc00\ud800"), + "1.0", + "1e0", + "NaN", + "Infinity", + "{}{}", + "{", + ~S("\q"), + "1." <> String.duplicate("1", 100_000), + "1e" <> String.duplicate("1", 100_000) + ], + do: assert({:error, :invalid_json} == J.decode(raw)) + + assert {:ok, "𐀀"} = J.decode(~S("\ud800\udc00")) + end + + test "exact byte budget rejects oversize before invalid UTF8" do + assert {:ok, %{}} = J.decode("{}" <> String.duplicate(" ", 1_048_574)) + assert {:error, :input_limit_exceeded} = J.decode(<<255>> <> String.duplicate(" ", 1_048_576)) + assert {:error, :invalid_json} = J.decode(nil) + end + + test "integer and container boundaries retain minus sign semantics" do + for sign <- ["", "-"] do + raw = sign <> String.duplicate("1", 4300) + assert {:ok, n} = J.decode(raw) + assert Integer.to_string(n) == raw + assert {:error, :invalid_json} = J.decode(raw <> "1") + end + + for {o, c} <- [{"[", "]"}, {"{\"x\":", "}"}] do + assert {:ok, _} = J.decode(String.duplicate(o, 128) <> "0" <> String.duplicate(c, 128)) + + assert {:error, :invalid_json} = + J.decode(String.duplicate(o, 129) <> "0" <> String.duplicate(c, 129)) + end + + assert {:ok, 0} = J.decode("-0") + assert {:ok, _} = J.decode(~S({"x":"[12345{\\\"}"})) + end + + test "native values retain frozen scalar ordering and API object shape" do + assert {:ok, value} = J.decode(~S({"𐀀":2,"":1})) + assert {:ok, bytes} = WorkHarbor.Foundation.Canonical.bytes(value) + assert Base.encode16(bytes, case: :lower) == "7b22ee8080223a312c22f0908080223a327d" + + for raw <- ["", "[]", "1", "null"], + do: assert({:error, :invalid_json} == J.decode_object(raw)) + end + + test "fixed forms reject decoded aliases malformed escapes unknown fields and byte overflow" do + alias WorkHarborWeb.LoginForm, as: F + fields = ["owner_id", "owner_secret", "_csrf_token"] + + assert {:ok, %{"owner_id" => "x", "owner_secret" => "a b", "_csrf_token" => "x"}} = + F.decode("owner_id=x&owner_secret=a+b&_csrf_token=x", fields) + + for raw <- [ + "owner_id=x&%6fwner_id=y", + "owner_id=%GG", + "owner_id=%FF", + "owner_id=x&owner_secret[a]=b", + "owner_id=x&&owner_secret=y", + "owner_id=x&x=y", + "owner_id=x&owner_secret=y&_csrf_token=x&x=y" + ], + do: assert({:error, :invalid_form} == F.decode(raw, fields)) + + assert {:error, :input_limit_exceeded} = F.decode(String.duplicate("x", 4097), fields) + end + + test "all seventy four frozen raw byte parse vectors match canonical bytes and rejection" do + file = Path.expand("../../../../contracts/migration/vectors.json", __DIR__) + + cases = + File.read!(file) + |> Jason.decode!() + |> Map.fetch!("cases") + |> Enum.filter(&(&1["operation"] == "parse-json" and &1["input"]["kind"] == "bytes")) + + assert length(cases) == 74 + + for c <- cases do + raw = expand(c["input"]["data"]) + expected = c["product_expected"] + + case {J.decode(raw), expected["status"]} do + {{:ok, value}, "ok"} -> + assert {:ok, bytes} = WorkHarbor.Foundation.Canonical.bytes(value) + assert bytes == expand(expected["canonical"]), c["id"] + + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == expected["digest"], + c["id"] + + {{:error, code}, "reject"} -> + assert String.replace(Atom.to_string(code), "_", "-") == expected["code"], c["id"] + + {result, _} -> + flunk("vector mismatch #{c["id"]}: #{inspect(result, limit: 1)}") + end + end + end + + defp expand(%{"encoding" => "hex", "hex" => hex} = data) do + bytes = Base.decode16!(hex, case: :mixed) + assert byte_size(bytes) == data["length"] + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == data["sha256"] + bytes + end + + defp expand(%{"encoding" => "repeat"} = data) do + bytes = + Base.decode16!(data["prefix_hex"], case: :mixed) <> + :binary.copy(Base.decode16!(data["unit_hex"], case: :mixed), data["count"]) <> + Base.decode16!(data["suffix_hex"], case: :mixed) + + assert byte_size(bytes) == data["length"] + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == data["sha256"] + bytes + end +end diff --git a/apps/control_plane/test/workharbor_web/authentication_test.exs b/apps/control_plane/test/workharbor_web/authentication_test.exs new file mode 100644 index 00000000..ffb566cc --- /dev/null +++ b/apps/control_plane/test/workharbor_web/authentication_test.exs @@ -0,0 +1,956 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AuthenticationTest do + use ExUnit.Case, async: false + import Plug.Conn + alias WorkHarborWeb.Endpoint + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-http-auth-#{Ecto.UUID.generate()}.db") + pid = WorkHarbor.AuthDatabase.open!(path) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + Application.put_env(:workharbor, :clock, fn -> 2_000_000_000 end) + + on_exit(fn -> + Application.delete_env(:workharbor, :clock) + if Process.alive?(pid), do: GenServer.stop(pid) + for s <- ["", "-wal", "-shm"], do: File.rm(path <> s) + end) + + %{owner: owner, secret: secret} + end + + defp req(method, path, body \\ "", headers \\ []) do + conn = + Plug.Test.conn(method, "https://localhost" <> path, body) + + conn = %{conn | req_headers: headers ++ [{"host", "localhost"} | conn.req_headers]} + Endpoint.call(conn, Endpoint.init([])) + end + + defp cookie(conn, name), do: conn.resp_cookies[name].value + defp jar(pairs), do: Enum.map_join(pairs, "; ", fn {k, v} -> k <> "=" <> v end) + + defp csrf(conn), + do: Regex.run(~r/name="_csrf_token" value="([^"]+)"/, conn.resp_body) |> Enum.at(1) + + defp primary(c, kind) do + page = req(:get, "/login/" <> kind) + + form = + URI.encode_query(%{ + "owner_id" => c.owner, + "owner_secret" => c.secret, + "_csrf_token" => csrf(page) + }) + + req(:post, "/login/" <> kind, form, [ + {"content-type", "application/x-www-form-urlencoded"}, + {"origin", "https://localhost"}, + {"cookie", jar([{"__Host-workharbor_login", cookie(page, "__Host-workharbor_login")}])} + ]) + end + + test "actual endpoint rejects plaintext wrong host and foreign origin before login", c do + body = Jason.encode!(%{schema_version: 1, owner_id: c.owner, owner_secret: c.secret}) + + http = + Plug.Test.conn(:post, "http://localhost/api/v1/auth/login", body) + |> put_req_header("content-type", "application/json") + + assert Endpoint.call(http, Endpoint.init([])).status == 403 + + for h <- [ + {"host", "evil.test"}, + {"host", "localhost.evil"}, + {"origin", "https://evil.test"}, + {"origin", "null"}, + {"origin", "https://localhost/"} + ], + do: + assert( + req(:post, "/api/v1/auth/login", body, [{"content-type", "application/json"}, h]).status == + 403 + ) + + assert DB.count("owner_credentials") == 0 + end + + test "strict API login rejects alternate parsers duplicate fields and mixed credentials", c do + body = Jason.encode!(%{schema_version: 1, owner_id: c.owner, owner_secret: c.secret}) + good = req(:post, "/api/v1/auth/login", body, [{"content-type", "application/json"}]) + assert good.status == 200 + assert %{"token" => token} = Jason.decode!(good.resp_body) + + for h <- [ + {"origin", "https://localhost"}, + {"sec-fetch-site", "same-origin"}, + {"x-workharbor-context", "owner"}, + {"authorization", "Bearer " <> token} + ], + do: + assert( + req(:post, "/api/v1/auth/login", body, [{"content-type", "application/json"}, h]).status in [ + 400, + 403 + ] + ) + + for raw <- [ + "{}", + ~s({"owner_id":"a","owner_id":"b"}), + "[]", + "", + String.duplicate(" ", 1_048_577) + ], + do: + assert( + req(:post, "/api/v1/auth/login", raw, [{"content-type", "application/json"}]).status in [ + 400, + 413 + ] + ) + + assert req(:post, "/api/v1/auth/login", body, [ + {"content-type", "application/x-www-form-urlencoded"} + ]).status == 415 + end + + test "public cross site login creates preauth only and cookies deny scripts", c do + owner = primary(c, "owner") + dashboard = primary(c, "dashboard") + assert owner.status == 303 and dashboard.status == 303 + page = req(:get, "/login/owner", "", [{"sec-fetch-site", "cross-site"}]) + assert page.status == 200 + assert Map.keys(page.resp_cookies) == ["__Host-workharbor_login"] + refute page.resp_body =~ c.secret + refute page.resp_body =~ c.owner + refute page.resp_body =~ " c.owner, + "owner_secret" => c.secret, + "_csrf_token" => csrf(page) + }) + + headers = [ + {"content-type", "application/x-www-form-urlencoded"}, + {"origin", "https://localhost"}, + {"cookie", jar([{"__Host-workharbor_login", cookie(page, "__Host-workharbor_login")}])} + ] + + Application.put_env(:workharbor, :clock, fn -> 2_000_000_600 end) + assert req(:post, "/login/owner", form, headers).status == 403 + Application.put_env(:workharbor, :clock, fn -> 2_000_000_599 end) + assert req(:post, "/login/owner", form, headers).status == 303 + end + + test "both cookies never widen dashboard and read denial precedes malformed body", c do + own = primary(c, "owner") + dash = primary(c, "dashboard") + + cookies = + jar([ + {"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}, + {"__Host-workharbor_dashboard", cookie(dash, "__Host-workharbor_dashboard")} + ]) + + page = req(:get, "/dashboard", "", [{"cookie", cookies}]) + assert page.status == 200 + + cookies = + jar([ + {"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}, + {"__Host-workharbor_dashboard", cookie(page, "__Host-workharbor_dashboard")} + ]) + + count = DB.count("owner_credentials") + + for p <- ["login", "logout", "dashboard-credentials", "dashboard-install", "revoke"], + do: + assert( + req(:post, "/api/v1/auth/" <> p, "malformed", [ + {"cookie", cookies}, + {"x-workharbor-context", "dashboard"}, + {"origin", "https://localhost"} + ]).status == 403 + ) + + assert DB.count("owner_credentials") == count + assert req(:get, "/api/v1/auth/status", "", [{"cookie", cookies}]).status == 400 + + assert req(:get, "/api/v1/auth/status", "", [ + {"cookie", cookies}, + {"x-workharbor-context", "dashboard"}, + {"sec-fetch-site", "same-origin"}, + {"x-csrf-token", csrf(page)} + ]).status == 200 + + assert req(:get, "/owner", "", [{"cookie", cookies}, {"x-workharbor-context", "dashboard"}]).status == + 403 + end + + test "socket and debugger paths cannot bypass ingress or session checks" do + for p <- [ + "/owner/live/websocket", + "/dashboard/live/websocket", + "/__plug__/debugger/action", + "/phoenix/live_reload/socket/websocket" + ] do + assert req(:get, p, "", [{"origin", "https://evil.test"}]).status == 403 + assert req(:get, p).status in [401, 403, 404] + end + + assert Endpoint.config(:debug_errors) == false + assert Endpoint.config(:code_reloader) == false + end + + test "literal debugger POST never parses canary action or exposes session" do + for headers <- [[], [{"origin", "https://evil.test"}], [{"host", "evil.test"}]] do + conn = + req(:post, "/__plug__/debugger/action", "action=SECRET-CANARY&session=SECRET-CANARY", [ + {"content-type", "application/x-www-form-urlencoded"} | headers + ]) + + assert conn.status in [401, 403, 404] + refute conn.resp_body =~ "SECRET-CANARY" + end + + conn = Plug.Test.conn(:post, "http://localhost/__plug__/debugger/action", "SECRET-CANARY") + assert Endpoint.call(conn, Endpoint.init([])).status == 403 + end + + test "active authenticated profiles validate positive baseline then independent failures" do + alias WorkHarborWeb.RuntimeSecurity + dir = Path.join(System.tmp_dir!(), "workharbor-config-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + cert = Path.join(dir, "cert.pem") + key = Path.join(dir, "key.pem") + File.write!(cert, "test certificate configuration fixture") + File.write!(key, "test private key configuration fixture") + File.chmod!(key, 0o600) + on_exit(fn -> File.rm_rf!(dir) end) + + config = [ + server: true, + debug_errors: false, + code_reloader: false, + http: false, + secret_key_base: String.duplicate("x", 64), + https: [ip: {127, 0, 0, 1}, port: 443, certfile: cert, keyfile: key] + ] + + owner = [origin: "https://localhost", authorities: [{"localhost", 443}]] + assert :ok == RuntimeSecurity.validate!(config, owner) + + demos = [ + "9kwSZZ3zqgPLCQHHHC7Q9KYXXSVttc/mmuUT4ZmFiV1BGre/cOXzP7jNrsMeH4N4", + "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl" + ] + + for patch <- + [ + [debug_errors: true], + [code_reloader: true], + [http: [port: 4000]], + [https: nil], + [secret_key_base: "demo"], + [force_ssl: [rewrite_on: [:x_forwarded_proto]]] + ] ++ Enum.map(demos, &[secret_key_base: &1]) do + assert_raise ArgumentError, "unsafe authentication server configuration", fn -> + RuntimeSecurity.validate!(Keyword.merge(config, patch), owner) + end + end + + for bad <- ["https://localhost:", "https://localhost/path", "https://localhost:8443"] do + assert_raise ArgumentError, "unsafe authentication server configuration", fn -> + RuntimeSecurity.validate!(config, origin: bad, authorities: [{"localhost", 443}]) + end + end + + assert :ok == RuntimeSecurity.validate!([server: false], []) + end + + test "socket admission accepts only initialized selected kind expiry revocation and CSRF", c do + own = primary(c, "owner") + dash = primary(c, "dashboard") + owner_cookie = cookie(own, "__Host-workharbor_owner") + dashboard_cookie = cookie(dash, "__Host-workharbor_dashboard") + # The socket callback is the same one reached by Phoenix transport after its + # cookie/CSRF extraction; a real Endpoint positive probe follows below. + decoded = decode_cookie(:owner, owner_cookie) + + assert {:ok, _} = + WorkHarborWeb.OwnerSocket.connect(%{}, %Phoenix.Socket{}, %{session: decoded}) + + assert :error == + WorkHarborWeb.DashboardSocket.connect(%{}, %Phoenix.Socket{}, %{session: decoded}) + + token = decoded["credential"] + {:ok, p} = WorkHarbor.Accounts.authenticate(token) + Application.put_env(:workharbor, :clock, fn -> p.expires_at end) + + assert :error == + WorkHarborWeb.OwnerSocket.connect(%{}, %Phoenix.Socket{}, %{session: decoded}) + + Application.put_env(:workharbor, :clock, fn -> p.issued_at end) + assert :ok == WorkHarbor.Accounts.revoke(p, p.credential_id) + + assert :error == + WorkHarborWeb.OwnerSocket.connect(%{}, %Phoenix.Socket{}, %{session: decoded}) + + assert :error == + WorkHarborWeb.OwnerSocket.connect(%{}, %Phoenix.Socket{}, %{ + session: decode_cookie(:dashboard, dashboard_cookie) + }) + + assert :error == WorkHarborWeb.OwnerSocket.connect(%{}, %Phoenix.Socket{}, %{session: nil}) + end + + test "viewer wrapping initializes fresh dashboard CSRF without touching credentials or owner", + c do + own = primary(c, "owner") + {:ok, _, actor} = WorkHarbor.Accounts.login(c.owner, c.secret, "cli_owner") + {:ok, read, _} = WorkHarbor.Accounts.issue_dashboard(actor) + login = req(:get, "/login/read-token") + + cookies = + jar([ + {"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}, + {"__Host-workharbor_login", cookie(login, "__Host-workharbor_login")} + ]) + + rows = DB.sql("SELECT * FROM owner_credentials ORDER BY id").rows + form = URI.encode_query(%{"dashboard_token" => read, "_csrf_token" => csrf(login)}) + + wrap = + req(:post, "/login/read-token", form, [ + {"cookie", cookies}, + {"origin", "https://localhost"}, + {"content-type", "application/x-www-form-urlencoded"} + ]) + + assert wrap.status == 303 + refute Map.has_key?(wrap.resp_cookies, "__Host-workharbor_owner") + + assert decode_cookie(:dashboard, cookie(wrap, "__Host-workharbor_dashboard")) == %{ + "credential" => read + } + + assert rows == DB.sql("SELECT * FROM owner_credentials ORDER BY id").rows + + dash = + req(:get, "/dashboard", "", [ + {"cookie", + jar([{"__Host-workharbor_dashboard", cookie(wrap, "__Host-workharbor_dashboard")}])} + ]) + + refute csrf(dash) == csrf(login) + + cookies = + jar([ + {"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}, + {"__Host-workharbor_dashboard", cookie(dash, "__Host-workharbor_dashboard")} + ]) + + headers = [ + {"cookie", cookies}, + {"x-workharbor-context", "dashboard"}, + {"origin", "https://localhost"} + ] + + assert req(:get, "/api/v1/auth/status", "", [{"x-csrf-token", csrf(login)} | headers]).status == + 403 + + assert req(:get, "/api/v1/auth/status", "", [{"x-csrf-token", csrf(dash)} | headers]).status == + 200 + + clear = + req(:post, "/login/dashboard/clear", URI.encode_query(%{"_csrf_token" => csrf(dash)}), [ + {"cookie", cookies}, + {"origin", "https://localhost"}, + {"content-type", "application/x-www-form-urlencoded"} + ]) + + assert clear.status == 303 + assert clear.resp_cookies["__Host-workharbor_dashboard"].max_age == 0 + refute Map.has_key?(clear.resp_cookies, "__Host-workharbor_owner") + assert rows == DB.sql("SELECT * FROM owner_credentials ORDER BY id").rows + end + + test "owner installation preserves own cookie before_send and initializes only dashboard CSRF", + c do + own = primary(c, "owner") + + page = + req(:get, "/owner", "", [ + {"cookie", jar([{"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}])} + ]) + + ownercookie = cookie(page, "__Host-workharbor_owner") + + install = + req(:post, "/api/v1/auth/dashboard-install", "{}", [ + {"content-type", "application/json"}, + {"cookie", jar([{"__Host-workharbor_owner", ownercookie}])}, + {"x-workharbor-context", "owner"}, + {"origin", "https://localhost"}, + {"x-csrf-token", csrf(page)} + ]) + + assert install.status == 303 + refute Map.has_key?(install.resp_cookies, "__Host-workharbor_owner") + dashboardcookie = cookie(install, "__Host-workharbor_dashboard") + assert Map.keys(decode_cookie(:dashboard, dashboardcookie)) == ["credential"] + + cookies = + jar([ + {"__Host-workharbor_owner", ownercookie}, + {"__Host-workharbor_dashboard", dashboardcookie} + ]) + + assert req(:get, "/api/v1/auth/status", "", [ + {"cookie", cookies}, + {"x-workharbor-context", "dashboard"}, + {"origin", "https://localhost"}, + {"x-csrf-token", csrf(page)} + ]).status == 403 + + dash = req(:get, "/dashboard", "", [{"cookie", cookies}]) + assert dash.status == 200 + refute csrf(dash) == csrf(page) + + assert {:ok, _} = + WorkHarbor.Accounts.authenticate(decode_cookie(:owner, ownercookie)["credential"]) + end + + defp decode_cookie(context, value) do + options = WorkHarborWeb.BrowserSession.options(context) + + conn = %{ + Plug.Test.conn(:get, "https://localhost") + | secret_key_base: Endpoint.config(:secret_key_base) + } + + {_, session} = Plug.Session.COOKIE.get(conn, value, Plug.Session.COOKIE.init(options)) + session + end + + test "cookie rewrites never extend near expiry wrapped credentials", c do + {:ok, _, actor} = WorkHarbor.Accounts.login(c.owner, c.secret, "cli_owner") + {:ok, read, p} = WorkHarbor.Accounts.issue_dashboard(actor) + Application.put_env(:workharbor, :clock, fn -> p.expires_at - 10 end) + login = req(:get, "/login/read-token") + + wrap = + req( + :post, + "/login/read-token", + URI.encode_query(%{"dashboard_token" => read, "_csrf_token" => csrf(login)}), + [ + {"cookie", + jar([{"__Host-workharbor_login", cookie(login, "__Host-workharbor_login")}])}, + {"origin", "https://localhost"}, + {"content-type", "application/x-www-form-urlencoded"} + ] + ) + + assert wrap.resp_cookies["__Host-workharbor_dashboard"].max_age <= 10 + + page = + req(:get, "/dashboard", "", [ + {"cookie", + jar([{"__Host-workharbor_dashboard", cookie(wrap, "__Host-workharbor_dashboard")}])} + ]) + + assert page.status == 200 + assert page.resp_cookies["__Host-workharbor_dashboard"].max_age <= 10 + end + + test "logout unknown body never revokes and owner browser cannot become CLI bearer", c do + {:ok, token, p} = WorkHarbor.Accounts.login(c.owner, c.secret, "cli_owner") + before = DB.count("audit_events") + + assert req(:post, "/api/v1/auth/logout", ~s({"unexpected":true}), [ + {"authorization", "Bearer " <> token}, + {"content-type", "application/json"} + ]).status == 400 + + assert {:ok, _} = WorkHarbor.Accounts.authenticate(token) + assert DB.count("audit_events") == before + {:ok, browser, _} = WorkHarbor.Accounts.login(c.owner, c.secret, "owner_browser") + + assert req(:get, "/api/v1/auth/status", "", [{"authorization", "Bearer " <> browser}]).status == + 401 + + assert {:ok, _} = WorkHarbor.Accounts.authenticate(browser, "owner_browser") + assert p.kind == "cli_owner" + end + + test "credential URL query aliases and duplicates never reach login or echo", c do + body = Jason.encode!(%{schema_version: 1, owner_id: c.owner, owner_secret: c.secret}) + + for query <- [ + "owner_secret=SECRET-CANARY", + "%74oken=SECRET-CANARY", + "token=x&token=SECRET-CANARY", + "owner_secret=ok&%6fwner_secret=SECRET-CANARY" + ] do + conn = + req(:post, "/api/v1/auth/login?" <> query, body, [{"content-type", "application/json"}]) + + assert conn.status == 400 + refute conn.resp_body =~ "SECRET-CANARY" + end + + assert DB.count("owner_credentials") == 0 + end + + test "denial audit identifies each actual closed route action before malformed body", c do + {:ok, token, _} = WorkHarbor.Accounts.login(c.owner, c.secret, "dashboard") + + for {path, action} <- [ + {"login", "primary_login"}, + {"logout", "logout"}, + {"revoke", "revoke"}, + {"dashboard-credentials", "issue_dashboard"}, + {"dashboard-install", "issue_dashboard"} + ] do + count = DB.count("audit_events") + + conn = + req(:post, "/api/v1/auth/" <> path, "bad", [ + {"authorization", "Bearer " <> token}, + {"content-type", "application/json"} + ]) + + assert conn.status == 403 + assert DB.count("audit_events") == count + 1 + + [[actual]] = + DB.sql( + "SELECT action FROM audit_events WHERE event='authorization_denied' ORDER BY rowid DESC LIMIT 1" + ).rows + + assert actual == action + end + + assert DB.count("owner_credentials") == 1 + end + + test "malformed authority empty ports duplicates userinfo and bounds reject" do + for host <- [ + "localhost:", + "localhost:0", + "localhost:65536", + "user@localhost", + "localhost.", + "localhost:443/path", + "localhost:443?x=1", + "[::1", + "localhost:abc" + ] do + conn = Plug.Test.conn(:get, "https://localhost/login/owner") + conn = %{conn | req_headers: [{"host", host}]} + assert WorkHarborWeb.Security.call(conn, []).status == 403 + end + end + + test "actual bracketed IPv6 authority and HTTP2 authority without Host are accepted" do + previous = Application.fetch_env!(:workharbor, :owner_http) + on_exit(fn -> Application.put_env(:workharbor, :owner_http, previous) end) + + Application.put_env(:workharbor, :owner_http, + origin: "https://[::1]", + authorities: [{"::1", 443}] + ) + + conn = Plug.Test.conn(:get, "https://[::1]/login/owner") + conn = %{conn | host: "[::1]", req_headers: [{"host", "[::1]"}]} + refute WorkHarborWeb.Security.call(conn, []).halted + {adapter, data} = conn.adapter + + conn = %{ + conn + | host: "::1", + req_headers: [], + adapter: {adapter, Map.put(data, :http_protocol, :"HTTP/2")} + } + + refute WorkHarborWeb.Security.call(conn, []).halted + end + + test "protected HTML cannot borrow anonymous login cross site navigation exception", c do + owner = primary(c, "owner") + cookies = jar([{"__Host-workharbor_owner", cookie(owner, "__Host-workharbor_owner")}]) + + for site <- ["cross-site", "same-site"] do + assert req(:get, "/owner", "", [{"cookie", cookies}, {"sec-fetch-site", site}]).status == + 403 + end + + assert req(:get, "/owner", "", [{"cookie", cookies}, {"sec-fetch-site", "same-origin"}]).status == + 200 + + assert req(:get, "/login/owner", "", [{"sec-fetch-site", "cross-site"}]).status == 200 + end + + test "actual configured-origin socket handshake verifies selected CSRF and both cookies", c do + own = primary(c, "owner") + dash = primary(c, "dashboard") + + op = + req(:get, "/owner", "", [ + {"cookie", jar([{"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}])} + ]) + + dp = + req(:get, "/dashboard", "", [ + {"cookie", + jar([{"__Host-workharbor_dashboard", cookie(dash, "__Host-workharbor_dashboard")}])} + ]) + + cookies = + jar([ + {"__Host-workharbor_owner", cookie(op, "__Host-workharbor_owner")}, + {"__Host-workharbor_dashboard", cookie(dp, "__Host-workharbor_dashboard")} + ]) + + previous = Application.fetch_env!(:workharbor, :owner_http) + on_exit(fn -> Application.put_env(:workharbor, :owner_http, previous) end) + + Application.put_env(:workharbor, :owner_http, + origin: "https://plane.test:8443", + authorities: [{"plane.test", 8443}] + ) + + probe = fn path, token -> + query = URI.encode_query(%{"_csrf_token" => token, "vsn" => "2.0.0"}) + conn = Plug.Test.conn(:get, "https://plane.test:8443" <> path <> "?" <> query) + + conn = %{ + conn + | req_headers: [ + {"host", "plane.test:8443"}, + {"origin", "https://plane.test:8443"}, + {"cookie", cookies}, + {"connection", "upgrade"}, + {"upgrade", "websocket"}, + {"sec-websocket-key", Base.encode64(<<0::128>>)}, + {"sec-websocket-version", "13"} + ] + } + + Endpoint.call(conn, Endpoint.init([])) + end + + assert probe.("/owner/live/websocket", csrf(op)).state == :upgraded + assert probe.("/dashboard/live/websocket", csrf(dp)).state == :upgraded + assert probe.("/dashboard/live/websocket", csrf(op)).status == 403 + assert probe.("/owner/live/websocket", csrf(dp)).status == 403 + + {:ok, principal} = + WorkHarbor.Accounts.authenticate( + decode_cookie(:owner, cookie(op, "__Host-workharbor_owner"))["credential"] + ) + + Application.put_env(:workharbor, :clock, fn -> principal.expires_at end) + assert probe.("/owner/live/websocket", csrf(op)).status == 403 + Application.put_env(:workharbor, :clock, fn -> principal.issued_at end) + assert :ok = WorkHarbor.Accounts.revoke(principal, principal.credential_id) + assert probe.("/owner/live/websocket", csrf(op)).status == 403 + assert probe.("/dashboard/live/websocket", csrf(dp)).state == :upgraded + end + + test "owner browser token never authenticates as bearer independently", c do + {:ok, token, _} = WorkHarbor.Accounts.login(c.owner, c.secret, "owner_browser") + + assert req(:get, "/api/v1/auth/status", "", [{"authorization", "Bearer " <> token}]).status == + 401 + + assert {:ok, _} = WorkHarbor.Accounts.authenticate(token, "owner_browser") + end + + test "configured default HTTPS port and host case compare as canonical origin", c do + previous = Application.fetch_env!(:workharbor, :owner_http) + on_exit(fn -> Application.put_env(:workharbor, :owner_http, previous) end) + + Application.put_env(:workharbor, :owner_http, + origin: "https://LOCALHOST:443", + authorities: [{"localhost", 443}] + ) + + page = req(:get, "/login/owner", "", [{"origin", "https://localhost"}]) + assert page.status == 200 + + form = + URI.encode_query(%{ + "owner_id" => c.owner, + "owner_secret" => c.secret, + "_csrf_token" => csrf(page) + }) + + assert req(:post, "/login/owner", form, [ + {"origin", "https://localhost"}, + {"cookie", + jar([{"__Host-workharbor_login", cookie(page, "__Host-workharbor_login")}])}, + {"content-type", "application/x-www-form-urlencoded"} + ]).status == 303 + end + + test "upgrade errors never reflect attacker header canary", c do + own = primary(c, "owner") + + page = + req(:get, "/owner", "", [ + {"cookie", jar([{"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}])} + ]) + + query = URI.encode_query(%{"_csrf_token" => csrf(page), "vsn" => "2.0.0"}) + + conn = + req(:get, "/owner/live/websocket?" <> query, "", [ + {"origin", "https://localhost"}, + {"cookie", jar([{"__Host-workharbor_owner", cookie(page, "__Host-workharbor_owner")}])}, + {"connection", "upgrade"}, + {"upgrade", "websocket"}, + {"sec-websocket-key", Base.encode64(<<0::128>>)}, + {"sec-websocket-version", "SECRET-CANARY"} + ]) + + assert conn.status in [400, 403] + refute conn.resp_body =~ "SECRET-CANARY" + end + + test "encoded and noncanonical login paths never select a different store or mint", c do + page = req(:get, "/login/owner") + + form = + URI.encode_query(%{ + "owner_id" => c.owner, + "owner_secret" => c.secret, + "_csrf_token" => csrf(page) + }) + + headers = [ + {"cookie", jar([{"__Host-workharbor_login", cookie(page, "__Host-workharbor_login")}])}, + {"origin", "https://localhost"}, + {"content-type", "application/x-www-form-urlencoded"} + ] + + for path <- [ + "/login/%6fwner", + "/login/read%2Dtoken", + "/login/%64ashboard/clear", + "/login//owner", + "/login/owner/" + ] do + conn = req(:post, path, form, headers) + assert conn.status == 400 + assert conn.resp_cookies == %{} + refute conn.resp_body =~ c.secret + assert DB.count("owner_credentials") == 0 + end + end + + test "actual response logs database and audit never expose credential canaries", c do + {:ok, token, _} = WorkHarbor.Accounts.login(c.owner, c.secret, "cli_owner") + + log = + ExUnit.CaptureLog.capture_log([level: :debug], fn -> + for {path, body, headers} <- [ + {"/api/v1/auth/login", ~S({"owner_secret":"SECRET-CANARY\q"}), + [{"content-type", "application/json"}]}, + {"/api/v1/auth/login?token=SECRET-CANARY", "{}", + [{"content-type", "application/json"}]}, + {"/__plug__/debugger/action", "action=SECRET-CANARY", + [{"content-type", "application/x-www-form-urlencoded"}]}, + {"/api/v1/auth/logout", "{", + [{"content-type", "application/json"}, {"authorization", "Bearer " <> token}]} + ] do + conn = req(:post, path, body, headers) + for secret <- ["SECRET-CANARY", c.secret, token], do: refute(conn.resp_body =~ secret) + end + end) + + for secret <- ["SECRET-CANARY", c.secret, token] do + refute log =~ secret + refute inspect(DB.sql("SELECT * FROM audit_events").rows) =~ secret + refute inspect(DB.sql("SELECT * FROM owner_credentials").rows) =~ secret + end + end + + test "preauth rejects future malformed or wrong interval timestamps while preserving cookies", + c do + login = req(:get, "/login/owner") + original = decode_cookie(:preauth, cookie(login, "__Host-workharbor_login")) + + form = + URI.encode_query(%{ + "owner_id" => c.owner, + "owner_secret" => c.secret, + "_csrf_token" => csrf(login) + }) + + for fields <- [ + %{"issued_at" => 2_000_000_001, "expires_at" => 2_000_000_601}, + %{"issued_at" => "bad"}, + %{"expires_at" => 2_000_000_601} + ] do + options = WorkHarborWeb.BrowserSession.options(:preauth) + + conn = %{ + Plug.Test.conn(:get, "https://localhost") + | secret_key_base: Endpoint.config(:secret_key_base) + } + + value = + Plug.Session.COOKIE.put( + conn, + nil, + Map.merge(original, fields), + Plug.Session.COOKIE.init(options) + ) + + result = + req(:post, "/login/owner", form, [ + {"origin", "https://localhost"}, + {"content-type", "application/x-www-form-urlencoded"}, + {"cookie", jar([{"__Host-workharbor_login", value}])} + ]) + + assert result.status == 403 + assert result.resp_cookies == %{} + end + + assert DB.count("owner_credentials") == 0 + end + + test "malformed UTF8 effective Host fails closed without reflecting canary" do + for host <- [<<91, 255, 58, 93>>, <<255, 58>> <> "SECRET-CANARY"] do + conn = Plug.Test.conn(:post, "https://localhost/api/v1/auth/login", "SECRET-CANARY") + + conn = %{ + conn + | host: host, + req_headers: [{"host", host}, {"content-type", "application/json"}] + } + + result = Endpoint.call(conn, Endpoint.init([])) + assert result.status == 403 + refute result.resp_body =~ "SECRET-CANARY" + assert DB.count("owner_credentials") == 0 + end + end + + test "schema failure preserves consumed request adapter and never revokes", c do + {:ok, token, _} = WorkHarbor.Accounts.login(c.owner, c.secret, "cli_owner") + + result = + req(:post, "/api/v1/auth/logout", ~s({"unexpected":true}), [ + {"authorization", "Bearer " <> token}, + {"content-type", "application/json"} + ]) + + assert result.status == 400 + {_, adapter} = result.adapter + assert adapter.req_body == "" + assert {:ok, _} = WorkHarbor.Accounts.authenticate(token) + end + + test "owner root and no script controls install read context and logout independently", c do + owner = primary(c, "owner") + + page = + req(:get, "/", "", [ + {"cookie", jar([{"__Host-workharbor_owner", cookie(owner, "__Host-workharbor_owner")}])} + ]) + + assert page.status == 200 + assert page.resp_body =~ "Sign out of owner management" + assert page.resp_body =~ "Open read-only dashboard" + owncookie = cookie(page, "__Host-workharbor_owner") + + headers = [ + {"origin", "https://localhost"}, + {"content-type", "application/x-www-form-urlencoded"}, + {"cookie", jar([{"__Host-workharbor_owner", owncookie}])} + ] + + install = + req(:post, "/owner/dashboard", URI.encode_query(%{"_csrf_token" => csrf(page)}), headers) + + assert install.status == 303 + refute Map.has_key?(install.resp_cookies, "__Host-workharbor_owner") + dashcookie = cookie(install, "__Host-workharbor_dashboard") + + view = + req(:get, "/dashboard", "", [{"cookie", jar([{"__Host-workharbor_dashboard", dashcookie}])}]) + + assert view.resp_body =~ "Clear dashboard sign-in" + + result = + req(:post, "/owner/logout", URI.encode_query(%{"_csrf_token" => csrf(page)}), headers) + + assert result.status == 303 + assert result.resp_cookies["__Host-workharbor_owner"].max_age == 0 + refute Map.has_key?(result.resp_cookies, "__Host-workharbor_dashboard") + + assert {:error, :unauthenticated} = + WorkHarbor.Accounts.authenticate(decode_cookie(:owner, owncookie)["credential"]) + + assert {:ok, _} = + WorkHarbor.Accounts.authenticate(decode_cookie(:dashboard, dashcookie)["credential"]) + end + + test "browser issuance installs HttpOnly cookie and cannot expose raw credentials to scripts", + c do + own = primary(c, "owner") + + page = + req(:get, "/owner", "", [ + {"cookie", jar([{"__Host-workharbor_owner", cookie(own, "__Host-workharbor_owner")}])} + ]) + + headers = [ + {"cookie", jar([{"__Host-workharbor_owner", cookie(page, "__Host-workharbor_owner")}])}, + {"x-workharbor-context", "owner"}, + {"origin", "https://localhost"}, + {"x-csrf-token", csrf(page)}, + {"content-type", "application/json"} + ] + + count = DB.count("owner_credentials") + denied = req(:post, "/api/v1/auth/dashboard-credentials", "{}", headers) + assert denied.status == 403 + assert DB.count("owner_credentials") == count + {:ok, token, _} = WorkHarbor.Accounts.login(c.owner, c.secret, "cli_owner") + + issued = + req(:post, "/api/v1/auth/dashboard-credentials", "{}", [ + {"authorization", "Bearer " <> token}, + {"content-type", "application/json"} + ]) + + assert issued.status == 200 + assert %{"token" => read} = Jason.decode!(issued.resp_body) + assert {:ok, %{kind: "dashboard"}} = WorkHarbor.Accounts.authenticate(read) + end +end diff --git a/apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs b/apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs index 62cb502d..6dfb4f89 100644 --- a/apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs +++ b/apps/control_plane/test/workharbor_web/controllers/page_controller_test.exs @@ -3,6 +3,6 @@ defmodule WorkHarborWeb.PageControllerTest do test "GET /", %{conn: conn} do conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + assert json_response(conn, 403) == %{"error" => %{"code" => "forbidden"}} end end diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 9c3d457a..d23a5ba1 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -577,7 +577,54 @@ "WorkHarbor.FoundationTest/test audit identity references cannot mismatch host runtime or intent scope", "WorkHarbor.FoundationTest/test native adapter errors and audit never expose request canaries", "WorkHarbor.FoundationTest/test candidate rejects malformed effective config without any result mutation", - "WorkHarbor.FoundationTest/test target rejects malformed effective config and retains known revisions" + "WorkHarbor.FoundationTest/test target rejects malformed effective config and retains known revisions", + "WorkHarbor.AccountsTest/test bootstrap delivery failure never reinitializes owner", + "WorkHarbor.AccountsTest/test bootstrap preserves owner metadata audit and rejects nested transactions", + "WorkHarbor.AccountsTest/test bootstrap writers serialize a single verifier", + "WorkHarbor.AccountsTest/test direct SQL cannot relabel credentials extend expiry or delete authenticator", + "WorkHarbor.AccountsTest/test exact expiry revoked reopen and independent credentials", + "WorkHarbor.AccountsTest/test existing foreign credential revoke is concealed audited and unchanged", + "WorkHarbor.AccountsTest/test foreign or malformed revoke denial is audited without raw target content", + "WorkHarbor.AccountsTest/test primary auth fixed scopes and hashes preserve secret safety", + "WorkHarbor.AccountsTest/test read issuance and fresh login do not automatically revoke previous tokens", + "WorkHarbor.AccountsTest/test read scope denies mutations grants unknown action and foreign ownership", + "WorkHarbor.AccountsTest/test revocation audit distinguishes actor target and unrelated viewer", + "WorkHarbor.StrictJSONTest/test all seventy four frozen raw byte parse vectors match canonical bytes and rejection", + "WorkHarbor.StrictJSONTest/test decoded duplicate keys reject at every depth before maps", + "WorkHarbor.StrictJSONTest/test exact byte budget rejects oversize before invalid UTF8", + "WorkHarbor.StrictJSONTest/test fixed forms reject decoded aliases malformed escapes unknown fields and byte overflow", + "WorkHarbor.StrictJSONTest/test integer and container boundaries retain minus sign semantics", + "WorkHarbor.StrictJSONTest/test native values retain frozen scalar ordering and API object shape", + "WorkHarbor.StrictJSONTest/test strict Unicode floats syntax and huge numeric forms reject safely", + "WorkHarborWeb.AuthenticationTest/test active authenticated profiles validate positive baseline then independent failures", + "WorkHarborWeb.AuthenticationTest/test actual bracketed IPv6 authority and HTTP2 authority without Host are accepted", + "WorkHarborWeb.AuthenticationTest/test actual configured-origin socket handshake verifies selected CSRF and both cookies", + "WorkHarborWeb.AuthenticationTest/test actual endpoint rejects plaintext wrong host and foreign origin before login", + "WorkHarborWeb.AuthenticationTest/test actual response logs database and audit never expose credential canaries", + "WorkHarborWeb.AuthenticationTest/test both cookies never widen dashboard and read denial precedes malformed body", + "WorkHarborWeb.AuthenticationTest/test browser issuance installs HttpOnly cookie and cannot expose raw credentials to scripts", + "WorkHarborWeb.AuthenticationTest/test configured default HTTPS port and host case compare as canonical origin", + "WorkHarborWeb.AuthenticationTest/test cookie rewrites never extend near expiry wrapped credentials", + "WorkHarborWeb.AuthenticationTest/test copied signed preauth cookie expires exactly at ten minutes", + "WorkHarborWeb.AuthenticationTest/test credential URL query aliases and duplicates never reach login or echo", + "WorkHarborWeb.AuthenticationTest/test denial audit identifies each actual closed route action before malformed body", + "WorkHarborWeb.AuthenticationTest/test encoded and noncanonical login paths never select a different store or mint", + "WorkHarborWeb.AuthenticationTest/test literal debugger POST never parses canary action or exposes session", + "WorkHarborWeb.AuthenticationTest/test logout unknown body never revokes and owner browser cannot become CLI bearer", + "WorkHarborWeb.AuthenticationTest/test malformed UTF8 effective Host fails closed without reflecting canary", + "WorkHarborWeb.AuthenticationTest/test malformed authority empty ports duplicates userinfo and bounds reject", + "WorkHarborWeb.AuthenticationTest/test owner browser token never authenticates as bearer independently", + "WorkHarborWeb.AuthenticationTest/test owner installation preserves own cookie before_send and initializes only dashboard CSRF", + "WorkHarborWeb.AuthenticationTest/test owner root and no script controls install read context and logout independently", + "WorkHarborWeb.AuthenticationTest/test preauth rejects future malformed or wrong interval timestamps while preserving cookies", + "WorkHarborWeb.AuthenticationTest/test protected HTML cannot borrow anonymous login cross site navigation exception", + "WorkHarborWeb.AuthenticationTest/test public cross site login creates preauth only and cookies deny scripts", + "WorkHarborWeb.AuthenticationTest/test schema failure preserves consumed request adapter and never revokes", + "WorkHarborWeb.AuthenticationTest/test socket admission accepts only initialized selected kind expiry revocation and CSRF", + "WorkHarborWeb.AuthenticationTest/test socket and debugger paths cannot bypass ingress or session checks", + "WorkHarborWeb.AuthenticationTest/test strict API login rejects alternate parsers duplicate fields and mixed credentials", + "WorkHarborWeb.AuthenticationTest/test upgrade errors never reflect attacker header canary", + "WorkHarborWeb.AuthenticationTest/test viewer wrapping initializes fresh dashboard CSRF without touching credentials or owner" ] } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 1db3ff29..8893598e 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -613,3 +613,44 @@ Three separate session stores, server-checked 600-second preauth expiry, explici owner/dashboard context and no automatic credential revocation replace prior proposals. T006 implementation is authorized; its checkbox remains open until exact-candidate tests and independent review. No activation is authorized. + +### T006 behavior and finding ledger + +Failure-first behavior checkpoints included absent modules/migration (0/18), +revocation actor/target audit (12/20), runtime configuration (10/11), ingress and +cookie/channel boundaries (12/19 then 12/22), encoded route aliases (12/23), +retention/audit (39/41), bootstrap audit (7/10), malformed effective authority +(25/26), parsed connection/browser controls (37/39), and raw issuance channel +(27/29). Initial wrong-directory and test-sigil compilation mistakes were corrected; +they are not behavior evidence. Early shell-tail wrappers did not retain separate +Mix exit codes; their counts are observed outcomes, not saved process exits. + +Independent precommit findings were closed with focused regressions: distinct +actor/target revoke audit; real foreign credential preservation; literal debugger +POSTs; positive custom-origin socket handshake and kind/expiry/revocation/CSRF +negatives; fresh Jason compilation and integer boundaries; credential-lifetime +cookie rewrite cap; strict authority/default-port/IPv6/UTF8 admission; isolated +valid-baseline runtime negatives; empty logout schema; bearer-kind/query rejection; +closed per-route denial labels; protected navigation Fetch Metadata; canonical +route selection; root-context navigation; consumed adapter preservation; and +no-script scoped owner controls with CLI-only raw-token issuance. + +The complete author Phoenix suite passed 86/86 with subprocess exit 0 in 5.2s. +Fresh exclusive reporter outcomes were validated against the appended 86-ID +manifest by the unchanged actual-event validator. All 39 inherited IDs and other +application/Python inventories are retained. The prior 82-event artifact was stale +because the reporter refuses overwrite; it is superseded by the fresh verified +artifact, not represented as 86 events. A Foundation contention test emitted the +expected Exqlite BEGIN IMMEDIATE database-lock error while passing; it is not a +compile warning. The inherited public-root test ID now asserts plaintext denial. + +Author evidence is under `/private/tmp/workharbor-t006-author`; exact committed +pristine evidence and independent code disposition follow separately. No real +browser/network TLS, live listener, deployment, later-WI integration or full new-head +canonical acceptance is claimed here. T006 remains unchecked pending review. + +Author test-profile format and warnings-as-errors compilation passed, as did +repository/bootstrap and whitespace checks. Fresh dev-profile compilation exited +0 with the pinned Plug dependency's Mix xref deprecation warning; application +compilation had no warning. This dependency warning is recorded rather than +represented as warning-free output. No listener started during either compile. diff --git a/specs/006-whr-cli/contracts/authentication.md b/specs/006-whr-cli/contracts/authentication.md index 90fe2a9d..7e53010b 100644 --- a/specs/006-whr-cli/contracts/authentication.md +++ b/specs/006-whr-cli/contracts/authentication.md @@ -201,3 +201,27 @@ Use secret canaries through actual errors/logs/audit/database. Append meaningful required test IDs without changing existing IDs. Run focused application/repository and pristine exact-candidate gates, record actual results and independent review. No synthetic test establishes real browser, transport TLS or future host capability. + +## T006 presentation and transport clarification + +The registered root `/` is an owner-context alias of `/owner`. The no-script owner +page supplies fixed-context POST controls `/owner/logout` and `/owner/dashboard`. +They authorize the closed actions `logout` and `issue_dashboard` before bounded +form parsing, require exact own Origin and selected-session CSRF, and accept only +the `_csrf_token` field. They preserve the consumed connection on every later +schema, CSRF or authentication failure. `/owner/dashboard` installs a new read +cookie without exposing its raw token, then redirects to initialize fresh dashboard +CSRF. `/owner/logout` uses the label “Sign out of owner management”. The dashboard +clear remains cookie-only and uses “Clear dashboard sign-in”. None enables form +bodies on strict JSON API routes. + +Raw `/api/v1/auth/dashboard-credentials` issuance requires a `cli_owner` bearer +secret channel; owner cookies use the explicit cookie-install presentation or API +instead. Browser-readable raw credential responses are refused. These are auth +controls, not management or Spec Kit data pages. + +Current fixed auth/control paths reject percent-encoded aliases, backslashes, +duplicate slashes and trailing slashes before context/store selection. Non-socket +routes admit no query. Socket query is closed to unique decoded `_csrf_token` and +`vsn` fields with fixed bounds; credentials are never admitted in URLs. Upgrade +header shape is bounded before adapter dispatch to prevent input reflection. diff --git a/specs/006-whr-cli/contracts/owner-api.md b/specs/006-whr-cli/contracts/owner-api.md index 4acdef76..73e6dd89 100644 --- a/specs/006-whr-cli/contracts/owner-api.md +++ b/specs/006-whr-cli/contracts/owner-api.md @@ -205,3 +205,14 @@ precommit child pauses only after a successful final audit INSERT on the actual acceptance path, before top-level commit, with exact bounded signaling and cleanup. Owner authentication/browser transport follows [the T006 contract](authentication.md). + +## T006 implemented authentication boundary + +The authentication foundation implements the consolidated +[authentication contract](authentication.md): primary login, scoped status, +explicit logout/revocation and dashboard credential installation/issuance. It adds +owner-management and dashboard sign-in/control pages, not the later product views. +All other HTTP groups above remain proposed and absent. Accounts returns a trusted +server-created Principal; its owner ID and scopes are passed separately from the +strictly decoded native string-key request map. Native body fields cannot supply +or replace that authority. Owner credentials do not authenticate a host or agent. From c882e7e9a7d9579ccd3cc6fa6636ecf8ab7af359 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 19:01:10 +0200 Subject: [PATCH 14/51] fix(test): reuse loaded authentication migration module Fresh database validation loads the migration before test fixtures run. Reuse that module, matching the foundation fixture, to avoid a duplicate-load warning. Refs: #72 Co-authored-by: Codex --- apps/control_plane/test/support/auth_database.ex | 12 +++++++----- docs/exec-plans/active/installable-pilot.md | 8 ++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/control_plane/test/support/auth_database.ex b/apps/control_plane/test/support/auth_database.ex index c1a8199d..0393646a 100644 --- a/apps/control_plane/test/support/auth_database.ex +++ b/apps/control_plane/test/support/auth_database.ex @@ -4,12 +4,14 @@ defmodule WorkHarbor.AuthDatabase do def open!(path) do pid = WorkHarbor.FoundationDatabase.open!(path) - Code.require_file( - Application.app_dir( - :workharbor, - "priv/repo/migrations/20261014010000_owner_authentication.exs" + unless Code.ensure_loaded?(WorkHarbor.Repo.Migrations.OwnerAuthentication) do + Code.require_file( + Application.app_dir( + :workharbor, + "priv/repo/migrations/20261014010000_owner_authentication.exs" + ) ) - ) + end Ecto.Migrator.run( WorkHarbor.Repo, diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 8893598e..190dc396 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -654,3 +654,11 @@ repository/bootstrap and whitespace checks. Fresh dev-profile compilation exited 0 with the pinned Plug dependency's Mix xref deprecation warning; application compilation had no warning. This dependency warning is recorded rather than represented as warning-free output. No listener started during either compile. + +Initial pristine candidate `65af90c937a5f2e50a3651a0351611ca80b2bb8d` passed +source/context/identity, manifest/collection (846 collected, 520 required Python +IDs), repository, format, compile and 86/86 actual Phoenix outcomes. Its fresh +default test database exposed a migration module redefinition warning in the +auth test helper. The helper now follows the accepted foundation helper's loaded +module guard; the predecessor run remains truthful evidence, superseded for exact +acceptance by the corrective candidate. Product behavior is unchanged. From c4bd81af2e5e3feaaf3e5b36b30198b73d0a8c63 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 19:38:53 +0200 Subject: [PATCH 15/51] docs(protocol): consolidate accepted host validation contract Freeze the independently reviewed closed protocol and actual foundation/auth interfaces before failure-first behavior. Record exact T006 acceptance without claiming transport or lifecycle capability. Refs: #72 Co-authored-by: Codex --- contracts/host-protocol/README.md | 202 ++++++++++++++++++++ docs/exec-plans/active/installable-pilot.md | 18 ++ specs/006-whr-cli/tasks.md | 2 +- 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 contracts/host-protocol/README.md diff --git a/contracts/host-protocol/README.md b/contracts/host-protocol/README.md new file mode 100644 index 00000000..5dd3038a --- /dev/null +++ b/contracts/host-protocol/README.md @@ -0,0 +1,202 @@ +# Host protocol v1 — consolidated T007 R2 + +Status: independently Sol CLEAR R2 design, consolidated with accepted T005 +`1189c81ba81b79a47a0485a9d5148fb8286c8eb0` and T006 +`ef399f58f2d9343109e248a519231209a707a416`. Implementation has explicit coordinator +GO; this document does not claim implementation acceptance or host capability. + +## Scope and version + +T007 supplies only closed contracts, independent fixtures and pure decoders, +validators and replay classification. It creates no transport, listener, provider, +trust store, dispatcher, command ledger or background work. T008 owns enrollment, +mTLS, revocation, heartbeat and reconnect; T009 owns actual trust/config/capability +and provider validation; T010 owns durable claims, reconciliation, retry and effects. +Terminal and in-container agent-session contracts are separate later work. + +One UTF-8 JSON object per message. schema_version is exactly integer 1. Only known +kinds/actions/fields are admitted; objects are closed recursively. No extension +bag, executable argv, arbitrary provider options, signatures, bearer credentials, +nonce, timestamp authority or unauthenticated hello transport is added here. + +Unpublished v1 is a draft. An amendment requires authoritative review, consolidated +schema/fixtures/both validators and integration acceptance before use; older draft +builds receive no compatibility claim. Once declared supported/released, incompatible +shape or meaning changes require a new version and explicit negotiation or retirement. +Never silently reinterpret or downgrade. T008 binds exact supported-version negotiation +to authenticated peers. Agent-session ignore-unknown-field semantics do not apply. + +## Exact command + +All fields are required, and no others are allowed: + +`schema_version`, `kind`, `command_id`, `owner_id`, `host_id`, `workspace_id`, +`operation_id`, `idempotency_key`, `request_sha256`, `intent_version`, `request`, +`bindings`, `execution_config`, `approval_ref`. + +Version is 1; kind is command. IDs use lowercase hyphenated UUID spelling. Key is +case-sensitive ASCII [A-Za-z0-9._:-]{1,128}; hash is lowercase SHA-256 hex. +Bindings have exactly target and candidate, each null or an exact object with +runtime_id (UUID) and generation (positive bounded integer). + +Wire request has exactly schema_version, workspace_id, action, parameters, with +accepted defaults explicitly materialized; it has no owner_id. Actions are create, +start, stop, rebuild, remove_runtime; delete_data is rejected. Request and config +use the accepted [V3 schema](../../specs/006-whr-cli/contracts/owner-api.md), lexical +repository identity, full lowercase commit IDs, dot=false scalar-safe submodule +paths, duplicate-path refusal and scalar ordering. No aliases, normalization, +fetching or re-resolution occur. Wire request must equal normalized request after +removing only its server-added owner_id; omitted defaults or unsorted entries reject. + +Create/rebuild use immutable request config and MUST have execution_config null +and approval_ref nonnull UUID. Start has empty parameters and nonnull frozen +existing-target execution_config, and approval_ref null. Stop/remove_runtime have +empty parameters, execution_config null and approval_ref null. A create request's +host and every repeated workspace identity must match envelope and trusted scope. +Candidate is allowed only for create/rebuild. Start requires target and no candidate; +create requires candidate and no target; rebuild requires candidate and may have +target. Stop/remove no-target no-op is allowed only by the explicit expected binding. +Compare the entire target/candidate structure to authoritative expectation. + +Approval reference shape/matching against the supplied authoritative revision +expectation is validation only. T009 supplies actual approval lookup/enforcement; +config descriptors and UUIDs never themselves authorize hooks or provider calls. + +## Exact result and observations + +Required: schema_version, kind, command_id, owner_id, host_id, workspace_id, +operation_id, idempotency_key, request_sha256, intent_version, bindings, outcome. +Only optional field: observation. Version 1; kind result. Command-only request, +execution_config, approval_ref and all other fields reject. Identity/bindings match +immutable expected command/operation. Outcome is exactly succeeded/ok or +failed/operation_failed, in status/code fields. + +Observation omission means no observation (native nil); explicit wire null rejects. +A present closed object requires runtime_id, generation, observed_state. Allowed +states: pending, provisioning, running, stopping, stopped, failed, unknown. Optional +provider_instance_id is string/null, effective_config is strict normalized config/null, +and deleted is exact boolean. Omitted optionals normalize null/null/false before +terminal replay equality. Explicit deleted:null, false provider IDs, malformed config, +unknown fields, improper lists or float generations reject, without truthiness fallback. + +Observed runtime must equal an exact bound target/candidate. A known provider ID +cannot change. Null provider/config preserve stored values, never clear them. +Only candidate may first populate effective config, matching immutable requested +revisions; existing target config cannot change or be populated by stop/remove. +Start preserves the frozen snapshot. deleted=true requires succeeded remove_runtime, +bound target and stopped observation. It is confirmation data requiring later +provider/reconciliation evidence; missing/disconnected/failed observation never implies +a tombstone. No standalone repeatable nonterminal observation API is added. + +## Bounded native and raw values + +Strict raw limits remain 1,048,576 bytes, 128 containers (root counts one), 4,300 +integer magnitude digits, scalar UTF-8, no BOM/duplicates/surrogates/floats/exponents +or trailing JSON. Native validation independently checks exact closed types, proper +lists, UTF-8 and lexical domains. Counters are exact integers 1..9223372036854775807 +before conversion, agreeing with accepted foundation allocation bounds. Booleans, +float 1.0, loose comparison and unchecked big-integer narrowing are forbidden. + +Variable strings and array counts have only the whole canonical envelope <=1 MiB +bound; no speculative per-string/submodule caps or nonempty provider-ID restriction. +Perform bounded native canonical-size preflight BEFORE unbounded canonical allocation +or emission, using accepted escaping and depth semantics. Then reuse canonical bytes. +Schema annotations alone cannot establish raw/native byte/type semantics. + +An admitted operation whose envelope cannot fit receives fixed transport_refused, +with no partial emission/provider call. Preserve request bytes/hash/key/op/versions; +never truncate, renormalize, weaken limits or automatically retry changed payload/key. +The operation remains retrievable. T010 MUST atomically record current nonterminal +operation failed/operation_failed under exact scope/current-intent/binding/status guards, +with a fixed visible refusal reason in a reviewed typed diagnostic/audit projection. +Do not overwrite terminal results or silently queue forever. Admission should apply +dispatch limits where knowable; envelope overhead still needs this refusal path. + +## Trusted contexts, identity and replay + +Order: strict decode; version/kind/shape/types/budget; trusted peer/scope admission; +scope-authorized expected operation lookup; immutable identity/replay classification; +current intent/bindings/status for new work; typed disposition only. Wrong scope has +one fixed authorization diagnostic before existence/replay disclosure. Missing trusted +context rejects. Context is a separate native typed argument, never a decoded body. + +Control plane derives peer host from future verified mTLS and authoritative host +ownership/operation/workspace relations. Agent derives verified control-plane peer +and enrolled local host/owner assignment. Owner credentials/principals, body IDs and +JSON authenticated flags are not host authority. Revoked peer rejects even historical +lookup. T007 receives only a caller-supplied trusted Peer plus an Operation snapshot; +these types are not cryptographic enforcement and have no public body decoder. + +First create uses scoped accepted operation/request and candidate reservation. Agent +needs an appropriate trusted create-admission expectation from the future authenticated +admission layer, not copied body IDs. No existing local workspace is assumed. Missing +expectation fails closed. T008/T010 must review its actual production and atomically +establish the local ledger before effects. Fixtures inject expectations and prove no +mTLS. T007 creates no workspace/ledger/enrollment, and mutates no foundation records. + +Owner hash is normalized request plus separately verified owner; compare canonical +request bytes AND digest against immutable operation. Owner hash excludes generated +command/intent/runtime/approval/execution context per V3. Separate canonical WHOLE +command bytes include immutable scope/request/binding/execution/approval context for +replay equality. Never confuse these identities. + +One immutable lifecycle directive per operation. T010 generates/stores stable command_id +before transmission and preserves it across retry/reconnect. Enforce host+command and +operation-to-command uniqueness plus owner/workspace/key association. A different +command ID cannot bypass dedup. T007 consumes supplied snapshots, not a new store. +Dispositions: new_eligible, pending_lookup, completed_lookup, reconciliation_required. +New_eligible is NOT effect permission: durable unique claim and trust/capability gates +remain T009/T010. Exact pending replay only reads status/requires reconciliation; +completed replay returns immutable result, with no provider or new audit. Changed +identity conflicts. Old same-runtime intent cannot execute/update; exact historical +terminal replay remains lookup after newer intent. New explicit op/key/intent can +legitimately reuse runtime generation. Unknown lost response requires inspection, +not blind resend. T007 makes no atomic/restart-safe ledger claim. + +New result writes permit queued->failed, executing->succeeded/failed; queued success +and terminal overwrite reject. Exact terminal outcome/observation replay ignores newer +workspace intent after current peer authorization and exact operation/binding checks. +Expected status comes from trusted snapshot, never untrusted result fields. + +Fresh authenticated connection/certificate rotation never resets application histories, +keys or counters. T008 supplies version/identity/revocation/staleness; T010 reconciles +before bounded nonoverlapping retries. Restore retains identities/outcomes and resets +observations unknown. No added wire sequencing/signature/expiry mechanism. + +## Accepted implementation alignment + +Elixir reuses WorkHarbor.StrictJSON.decode_object/1 and Foundation.Request.normalize/3, +normalize_config/1 and Foundation.Canonical.bytes/1. Go reuses wirejson.Parse and +CanonicalBytes plus accepted config.CanonicalPath; independently validate V3 request +and lexical repository behavior. Both perform bounded native sizing before canonical +serialization. Wire runtime_id maps explicitly to native bindings id, preserving both +roles and null slots. Request.normalize adds owner_id only from trusted scope for hash +comparison. Native create/rebuild execution_config_snapshot equals request config while +wire execution_config remains null; start uses native frozen snapshot. Absent observation +maps to native nil; optional defaults match Foundation.finish, preserving exact nil/type +strictness. Terminal result comparison uses outcome plus normalized native observation. + +T006 Accounts.Principal is separately credential-derived; Accounts.authorize currently +has an auth-only closed registry. Do not widen it or alias lifecycle mutation to read +status. T007 takes a distinct trusted peer/context, performs no authentication/Repo +lookup/mutation, and does not call accept/claim/reserve_candidate/finish. command_id and +approval expectations are supplied, not persisted or manufactured from T005 rows. + +## Diagnostics and evidence + +Fixed codes/messages only: invalid_json, input_limit, unsupported_version, +invalid_message, unauthorized_scope, identity_conflict, stale_intent, stale_runtime, +invalid_status, transport_refused. No raw input/field values/URL/handle/credential/grant/ +provider exception/decoder message. Correlation logs require validated authorized context; +validators emit no logs. Protocol rejection is not fabricated provider completion. + +Independent shared raw fixtures/expected dispositions and canonical/hash anchors run +in both suites, alongside native tests: closed shape/null/version/action; escaped nested +duplicates, UTF-8/surrogates/floats/trailing/depth/digit limits; counter narrowing, improper +lists/types/cycles/native budget; all scope/op/key/hash/intent/runtime/role/status mismatches; +same-generation stale results, borrowed candidates, changed config/provider, queued +success/terminal overwrite; pending/completed/historical retries and alternate IDs/new +intent same generation; observation defaults/removal rules; admitted-envelope overflow +without identity change/partial emission; secret canaries. A test provider sentinel must +remain untouched for rejected/replayed/refused inputs. It proves pure gate order, not a +real dispatcher, durable restart/race, TLS, browser or Docker capability. diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 190dc396..37a7e4af 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -662,3 +662,21 @@ default test database exposed a migration module redefinition warning in the auth test helper. The helper now follows the accepted foundation helper's loaded module guard; the predecessor run remains truthful evidence, superseded for exact acceptance by the corrective candidate. Product behavior is unchanged. + +## T006 exact acceptance and T007 contract consolidation + +T006 is independently Sol CLEAR at ef399f58f2d9343109e248a519231209a707a416, +including author implementation 65af90c and helper fixture correction ef399f5; +accepted contract is 5b28644. Author pristine evidence: +/private/tmp/workharbor-t006-validation.ayhnvdv5/evidence.json. Independent reviewer +evidence: /private/tmp/workharbor-t006-review-hw7l_5me/review-checks.json. +Author source/context/identity/manifest/application evidence passed; independent +compile and full 86/86 tests passed (47 new IDs, all 39 retained). These are +synthetic application results, not full canonical, live TLS/browser/host evidence. +Only Feature 006 T006 is checked here; Feature 001 markers remain unchanged. + +Coordinator explicitly authorized T007 after accepted T005 1189c81 and T006 ef399f5. +The complete independently CLEAR R2 plus actual-interface alignment is consolidated +in contracts/host-protocol/README.md before failure-first tests or behavior. +Same Sol reviewer must confirm faithful consolidation. No T007 implementation +acceptance, transport, provider, dispatcher or durable replay ledger is claimed. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index 28ffb44d..a9a13ace 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -34,7 +34,7 @@ Independent check: negative vectors, cross-language bytes and no runtime Python. - [x] T003 Freeze the selected Python behavior inventory, source/tool digests and independently inspected vectors in `contracts/migration/`, with offline generator `scripts/generate_go_vectors.py`, strict fixture schema, meaningful negative tests in `tests/test_go_vectors.py` and their required IDs in `docs/development/ci-acceptance.json`; cover nested duplicate keys, malformed UTF-8, integer bounds, float/non-finite rejection, non-ASCII ordering and paths. Retain development-only rows in `specs/006-whr-cli/migration.md` (ADR 0010). - [x] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). - [x] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). -- [ ] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). +- [x] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). - [ ] T007 Define/validate versioned host protocol in `contracts/host-protocol/`, `apps/control_plane/lib/workharbor/host_protocol.ex`, `apps/host_agent/internal/protocol/`; reject invalid version/scope/replay before provider calls and redact diagnostics in both suites (001 T003). ## Phase 3: US1 — Authenticate and manage workspaces (P1) From af611cbe75ecefe9eff8afa72a4093c267e85204 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 20:00:35 +0200 Subject: [PATCH 16/51] feat(protocol): validate scoped immutable host messages Reject malformed, foreign, stale and conflicting host messages before future effects. Keep owner request identity distinct from immutable command replay, preserve strict observation types and bound both raw and normalized canonical envelopes. Add independent shared fixtures and failure-first native regressions in Go and Elixir. Pure dispositions confer no provider permission; transport, trust stores and durable execution remain later work. Refs: #72 Co-authored-by: Codex --- .../lib/workharbor/host_protocol.ex | 410 +++++++ .../test/workharbor/host_protocol_test.exs | 435 +++++++ apps/host_agent/internal/protocol/protocol.go | 598 ++++++++++ .../internal/protocol/protocol_test.go | 516 +++++++++ contracts/README.md | 7 + contracts/host-protocol/README.md | 25 + contracts/host-protocol/message.schema.json | 1022 +++++++++++++++++ contracts/host-protocol/vectors.json | 330 ++++++ docs/development/ci-acceptance.json | 44 +- docs/exec-plans/active/installable-pilot.md | 57 + 10 files changed, 3442 insertions(+), 2 deletions(-) create mode 100644 apps/control_plane/lib/workharbor/host_protocol.ex create mode 100644 apps/control_plane/test/workharbor/host_protocol_test.exs create mode 100644 apps/host_agent/internal/protocol/protocol.go create mode 100644 apps/host_agent/internal/protocol/protocol_test.go create mode 100644 contracts/host-protocol/message.schema.json create mode 100644 contracts/host-protocol/vectors.json diff --git a/apps/control_plane/lib/workharbor/host_protocol.ex b/apps/control_plane/lib/workharbor/host_protocol.ex new file mode 100644 index 00000000..cdd61908 --- /dev/null +++ b/apps/control_plane/lib/workharbor/host_protocol.ex @@ -0,0 +1,410 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostProtocol.Peer do + @moduledoc "Caller-supplied verified peer scope, never decoded from wire data." + defstruct [:kind, :owner_id, :host_id] +end + +defmodule WorkHarbor.HostProtocol.Snapshot do + @moduledoc "Authoritative operation/command expectation; not a durable ledger." + defstruct [ + :command, + :request_bytes, + :request_hash, + :native_bindings, + :current_intent, + :status, + :command_bytes, + :terminal_result, + runtimes: %{} + ] +end + +defmodule WorkHarbor.HostProtocol.Context do + @moduledoc "Trusted peer and separately supplied operation expectation." + defstruct [:peer, :snapshot] +end + +defmodule WorkHarbor.HostProtocol do + @moduledoc "Pure closed host protocol validation. No returned disposition permits effects." + alias WorkHarbor.{StrictJSON} + alias WorkHarbor.Foundation.{Request, Canonical} + alias __MODULE__.{Context, Peer, Snapshot} + @limit 1_048_576 + @max 9_223_372_036_854_775_807 + @integer_limit Integer.pow(10, 4300) + @common ~w(schema_version kind command_id owner_id host_id workspace_id operation_id idempotency_key request_sha256 intent_version bindings) + @command @common ++ ~w(request execution_config approval_ref) + @states ~w(pending provisioning running stopping stopped failed unknown) + + def decode(raw, context) do + case StrictJSON.decode_object(raw) do + {:ok, value} -> validate(value, context) + {:error, :input_limit_exceeded} -> {:error, :input_limit} + _ -> {:error, :invalid_json} + end + end + + def validate(value, context) do + budget!(value, 0, @limit) + message = shape!(value) + budget!(message, 0, @limit) + {peer, snapshot} = scope!(message, context) + budget!(snapshot.command, 0, @limit) + expected = command!(snapshot.command) + + valid!( + expected["owner_id"] == peer.owner_id and expected["host_id"] == peer.host_id, + :unauthorized_scope + ) + + valid!(message["workspace_id"] == expected["workspace_id"], :unauthorized_scope) + + for field <- @common -- ["kind"] do + valid!(message[field] === expected[field], :identity_conflict) + end + + {:ok, normalized} = + Request.normalize(peer.owner_id, expected["idempotency_key"], expected["request"]) + + valid!( + normalized.bytes === snapshot.request_bytes and normalized.hash === snapshot.request_hash and + expected["request_sha256"] === snapshot.request_hash, + :identity_conflict + ) + + native = native_bindings!(message["bindings"]) + valid!(native === snapshot.native_bindings, :stale_runtime) + {:ok, canonical} = Canonical.bytes(message) + + {disposition, result} = + if message["kind"] == "command" do + valid!(message === expected, :identity_conflict) + + if not is_nil(snapshot.command_bytes), + do: valid!(snapshot.command_bytes === canonical, :identity_conflict) + + {command_disposition!(snapshot, message["intent_version"]), nil} + else + result = %{ + "outcome" => message["outcome"], + "observation" => Map.get(message, "observation") + } + + if snapshot.status in ["succeeded", "failed"] do + valid!(snapshot.terminal_result === result, :identity_conflict) + {"completed_lookup", result} + else + current!(snapshot, message["intent_version"]) + + valid!( + snapshot.status in ["queued", "executing"] and + (message["outcome"]["status"] != "succeeded" or snapshot.status == "executing"), + :invalid_status + ) + + observe!(result, native, expected, snapshot) + {"new_eligible", result} + end + end + + {:ok, + %{ + message: message, + canonical: canonical, + native_bindings: native, + native_result: result, + disposition: disposition + }} + catch + code + when code in [ + :invalid_message, + :unsupported_version, + :unauthorized_scope, + :identity_conflict, + :stale_intent, + :stale_runtime, + :invalid_status, + :transport_refused + ] -> + {:error, code} + end + + defp shape!(v) do + valid!(is_map(v) and not is_struct(v)) + + if is_integer(v["schema_version"]) and v["schema_version"] !== 1, + do: throw(:unsupported_version) + + valid!(v["schema_version"] === 1) + + case v["kind"] do + "command" -> + command!(v) + + "result" -> + keys!(v, @common ++ ["outcome"], ["observation"]) + common!(v) + + valid!( + v["outcome"] === %{"status" => "succeeded", "code" => "ok"} or + v["outcome"] === %{"status" => "failed", "code" => "operation_failed"} + ) + + if Map.has_key?(v, "observation"), + do: Map.put(v, "observation", observation!(v["observation"])), + else: v + + _ -> + throw(:invalid_message) + end + end + + defp command!(v) do + keys!(v, @command) + valid!(v["schema_version"] === 1 and v["kind"] == "command") + common!(v) + + case Request.normalize(v["owner_id"], v["idempotency_key"], v["request"]) do + {:ok, normalized} -> valid!(Map.delete(normalized.request, "owner_id") === v["request"]) + _ -> throw(:invalid_message) + end + + req = v["request"] + valid!(req["workspace_id"] == v["workspace_id"]) + action = req["action"] + b = v["bindings"] + + case action do + "create" -> + valid!( + req["parameters"]["host_id"] == v["host_id"] and is_nil(b["target"]) and + not is_nil(b["candidate"]) + ) + + "rebuild" -> + valid!(not is_nil(b["candidate"])) + + "start" -> + valid!(not is_nil(b["target"]) and is_nil(b["candidate"])) + + _ -> + valid!(is_nil(b["candidate"])) + end + + if action == "start", + do: config!(v["execution_config"], true), + else: valid!(is_nil(v["execution_config"])) + + if action in ["create", "rebuild"], + do: valid!(Request.uuid?(v["approval_ref"])), + else: valid!(is_nil(v["approval_ref"])) + + v + end + + defp common!(v) do + for field <- ~w(command_id owner_id host_id workspace_id operation_id), + do: valid!(Request.uuid?(v[field])) + + valid!(Request.key?(v["idempotency_key"])) + + valid!( + is_binary(v["request_sha256"]) and Regex.match?(~r/\A[0-9a-f]{64}\z/, v["request_sha256"]) + ) + + counter!(v["intent_version"]) + native_bindings!(v["bindings"]) + end + + defp native_bindings!(b) do + keys!(b, ~w(target candidate)) + + Map.new(b, fn {role, value} -> + if is_nil(value) do + {role, nil} + else + keys!(value, ~w(runtime_id generation)) + valid!(Request.uuid?(value["runtime_id"])) + counter!(value["generation"]) + {role, %{"id" => value["runtime_id"], "generation" => value["generation"]}} + end + end) + end + + defp observation!(o) do + keys!( + o, + ~w(runtime_id generation observed_state), + ~w(provider_instance_id effective_config deleted) + ) + + valid!(Request.uuid?(o["runtime_id"]) and o["observed_state"] in @states) + counter!(o["generation"]) + provider = Map.get(o, "provider_instance_id") + valid!(is_nil(provider) or (is_binary(provider) and String.valid?(provider))) + deleted = Map.get(o, "deleted", false) + valid!(deleted === true or deleted === false) + + effective = + case Map.get(o, "effective_config") do + nil -> nil + config -> config!(config, true) + end + + o + |> Map.put("provider_instance_id", provider) + |> Map.put("effective_config", effective) + |> Map.put("deleted", deleted) + end + + defp observe!(%{"observation" => nil}, _, _, _), do: :ok + + defp observe!(%{"observation" => o, "outcome" => outcome}, bindings, command, snapshot) do + identity = %{"id" => o["runtime_id"], "generation" => o["generation"]} + role = Enum.find_value(bindings, fn {role, value} -> if value === identity, do: role end) + valid!(not is_nil(role), :stale_runtime) + runtime = Map.get(snapshot.runtimes, o["runtime_id"]) + valid!(is_map(runtime), :stale_runtime) + provider = o["provider_instance_id"] + effective = o["effective_config"] + + if not is_nil(provider) and not is_nil(runtime[:provider_id]), + do: valid!(provider === runtime[:provider_id], :stale_runtime) + + if not is_nil(effective) do + if role == "target", do: valid!(not is_nil(runtime[:effective_config]), :stale_runtime) + + if not is_nil(runtime[:effective_config]), + do: valid!(effective === runtime[:effective_config], :stale_runtime) + + expected = + if role == "candidate", + do: command["request"]["parameters"]["config"], + else: command["execution_config"] + + if role == "candidate" or command["request"]["action"] == "start", + do: valid!(effective === expected, :stale_runtime) + end + + if o["deleted"] do + valid!( + command["request"]["action"] == "remove_runtime" and role == "target" and + outcome["status"] == "succeeded" and o["observed_state"] == "stopped" + ) + end + end + + defp scope!(m, %Context{peer: %Peer{} = peer, snapshot: %Snapshot{} = snapshot}) do + kind = if m["kind"] == "command", do: :control_plane, else: :host + + valid!( + peer.kind == kind and Request.uuid?(peer.owner_id) and Request.uuid?(peer.host_id) and + peer.owner_id == m["owner_id"] and peer.host_id == m["host_id"], + :unauthorized_scope + ) + + {peer, snapshot} + end + + defp scope!(_, _), do: throw(:unauthorized_scope) + + defp command_disposition!(%Snapshot{status: status}, _) when status in ["succeeded", "failed"], + do: "completed_lookup" + + defp command_disposition!(s, intent) do + current!(s, intent) + + case s.status do + "executing" -> "reconciliation_required" + "queued" -> if is_nil(s.command_bytes), do: "new_eligible", else: "pending_lookup" + _ -> throw(:invalid_status) + end + end + + defp current!(s, intent), do: valid!(s.current_intent === intent, :stale_intent) + + defp config!(v, strict) do + case Request.normalize_config(v) do + {:ok, normalized} -> + if strict, do: valid!(v === normalized) + normalized + + _ -> + throw(:invalid_message) + end + end + + defp keys!(v, required, optional \\ []) do + valid!(is_map(v) and not is_struct(v)) + + valid!( + Enum.all?(required, &Map.has_key?(v, &1)) and + Enum.all?(Map.keys(v), &(&1 in (required ++ optional))) + ) + end + + defp counter!(v), do: valid!(is_integer(v) and v >= 1 and v <= @max) + defp valid!(v, code \\ :invalid_message) + defp valid!(true, _), do: :ok + defp valid!(_, code), do: throw(code) + + # Count exact canonical size while retaining the generic native profile. Never + # call the unbounded canonical allocator until this pass has succeeded. + defp budget!(_, _, left) when left < 0, do: throw(:transport_refused) + defp budget!(nil, _, left), do: spend!(left, 4) + defp budget!(true, _, left), do: spend!(left, 4) + defp budget!(false, _, left), do: spend!(left, 5) + + defp budget!(v, _, left) when is_integer(v) do + valid!(v > -@integer_limit and v < @integer_limit) + spend!(left, byte_size(Integer.to_string(v))) + end + + defp budget!(v, _, left) when is_binary(v) do + if byte_size(v) > left, do: throw(:transport_refused) + valid!(String.valid?(v)) + + string_budget!(v, spend!(left, 2)) + end + + defp budget!(v, depth, left) when is_list(v) and depth < 128 do + list_budget!(v, depth + 1, spend!(left, 2), false) + end + + defp budget!(v, depth, left) when is_map(v) and not is_struct(v) and depth < 128 do + Enum.reduce(v, {spend!(left, 2), false}, fn {k, value}, {remaining, comma} -> + valid!(is_binary(k)) + remaining = budget!(k, depth + 1, spend!(remaining, if(comma, do: 2, else: 1))) + {budget!(value, depth + 1, remaining), true} + end) + |> elem(0) + end + + defp budget!(_, _, _), do: throw(:invalid_message) + defp list_budget!([], _, left, _), do: left + + defp list_budget!([v | rest], depth, left, comma), + do: + list_budget!(rest, depth, budget!(v, depth, spend!(left, if(comma, do: 1, else: 0))), true) + + defp list_budget!(_, _, _, _), do: throw(:invalid_message) + + defp string_budget!(<<>>, left), do: left + + defp string_budget!(<>, left) do + n = + cond do + c in [34, 92, 8, 12, 10, 13, 9] -> 2 + c < 32 -> 6 + true -> byte_size(<>) + end + + string_budget!(rest, spend!(left, n)) + end + + defp spend!(left, n) do + if left < n, do: throw(:transport_refused), else: left - n + end +end diff --git a/apps/control_plane/test/workharbor/host_protocol_test.exs b/apps/control_plane/test/workharbor/host_protocol_test.exs new file mode 100644 index 00000000..79bdfdd4 --- /dev/null +++ b/apps/control_plane/test/workharbor/host_protocol_test.exs @@ -0,0 +1,435 @@ +# SPDX-License-Identifier: EUPL-1.2 +defmodule WorkHarbor.HostProtocolTest do + use ExUnit.Case, async: true + alias WorkHarbor.HostProtocol, as: P + alias WorkHarbor.HostProtocol.{Peer, Snapshot, Context} + defp id(n), do: "00000000-0000-0000-0000-" <> String.pad_leading(to_string(n), 12, "0") + + defp vectors do + raw = File.read!(Path.expand("../../../../contracts/host-protocol/vectors.json", __DIR__)) + + assert Base.encode16(:crypto.hash(:sha256, raw), case: :lower) == + "dedc629e6989fdfaaad8053fe14a0680b692495fdfff88eb1f4e5a29c534d66d" + + Jason.decode!(raw) + end + + defp config, + do: %{ + "repository_identity" => "git@example.test:team/workspace.git", + "commit" => String.duplicate("a", 40), + "submodules" => [] + } + + defp command do + %{ + "schema_version" => 1, + "kind" => "command", + "command_id" => id(5), + "owner_id" => id(1), + "host_id" => id(2), + "workspace_id" => id(3), + "operation_id" => id(4), + "idempotency_key" => "create:1", + "request_sha256" => vectors()["request_sha256"], + "intent_version" => 1, + "request" => %{ + "schema_version" => 1, + "workspace_id" => id(3), + "action" => "create", + "parameters" => %{"host_id" => id(2), "config" => config()} + }, + "bindings" => %{"target" => nil, "candidate" => %{"runtime_id" => id(6), "generation" => 1}}, + "execution_config" => nil, + "approval_ref" => id(7) + } + end + + defp context do + %Context{ + peer: %Peer{kind: :control_plane, owner_id: id(1), host_id: id(2)}, + snapshot: %Snapshot{ + command: command(), + request_bytes: vectors()["request_bytes"], + request_hash: vectors()["request_sha256"], + native_bindings: %{"target" => nil, "candidate" => %{"id" => id(6), "generation" => 1}}, + current_intent: 1, + status: "queued", + runtimes: %{id(6) => %{provider_id: nil, effective_config: nil}} + } + } + end + + defp result, + do: + command() + |> Map.drop(["request", "execution_config", "approval_ref"]) + |> Map.put("kind", "result") + |> Map.put("outcome", %{"status" => "succeeded", "code" => "ok"}) + + defp observation, do: %{"runtime_id" => id(6), "generation" => 1, "observed_state" => "running"} + + defp host(c), + do: %{c | peer: %{c.peer | kind: :host}, snapshot: %{c.snapshot | status: "executing"}} + + test "independent shared raw vectors preserve codes and dispositions" do + for v <- vectors()["cases"] do + case P.decode(v["raw"], context()) do + {:ok, g} -> + assert v["code"] == "ok", v["id"] + assert g.disposition == v["disposition"], v["id"] + + {:error, code} -> + assert Atom.to_string(code) == v["code"], v["id"] + end + end + end + + test "independent canonical anchors separate request hash and command identity" do + assert {:ok, g} = P.validate(command(), context()) + assert g.canonical == vectors()["command_canonical"] + + assert Base.encode16(:crypto.hash(:sha256, g.canonical), case: :lower) == + vectors()["command_sha256"] + + refute g.canonical == vectors()["request_bytes"] + assert g.native_bindings["candidate"] == %{"id" => id(6), "generation" => 1} + end + + test "native types exact counters and improper lists reject" do + for bad <- [nil, true, 1, 1.0, [], {:tuple}, self(), %URI{}], + do: assert(match?({:error, _}, P.validate(bad, context()))) + + for bad <- [true, 1.0, "1", 0, 9_223_372_036_854_775_808] do + assert {:error, :invalid_message} = + P.validate(Map.put(command(), "intent_version", bad), context()) + end + + bad = put_in(command(), ["request", "parameters", "config", "submodules"], [%{} | :bad_tail]) + assert {:error, :invalid_message} = P.validate(bad, context()) + end + + test "trusted peer and missing expectation reject before lookup disclosure" do + bad = context() + + bad = %{ + bad + | peer: %{bad.peer | owner_id: id(9)}, + snapshot: %{bad.snapshot | command: %{"secret" => "private-canary"}} + } + + assert {:error, :unauthorized_scope} = P.validate(command(), bad) + + for peer <- [ + nil, + %Peer{kind: :owner, owner_id: id(1), host_id: id(2)}, + %Peer{kind: :control_plane, owner_id: id(9), host_id: id(2)} + ] do + assert {:error, :unauthorized_scope} = + P.validate(command(), %Context{peer: peer, snapshot: nil}) + end + + assert {:error, :unauthorized_scope} = P.validate(command(), %Context{peer: context().peer}) + end + + test "pending replay and executing command never permit another effect" do + c = context() + c = %{c | snapshot: %{c.snapshot | command_bytes: vectors()["command_canonical"]}} + assert {:ok, %{disposition: "pending_lookup"}} = P.validate(command(), c) + + assert {:ok, %{disposition: "reconciliation_required"}} = + P.validate(command(), %{c | snapshot: %{c.snapshot | status: "executing"}}) + end + + test "completed historical command replay ignores newer intent" do + c = context() + + c = %{ + c + | snapshot: %{ + c.snapshot + | status: "succeeded", + current_intent: 2, + command_bytes: vectors()["command_canonical"] + } + } + + assert {:ok, %{disposition: "completed_lookup"}} = P.validate(command(), c) + end + + test "changed replay and same runtime stale intent reject" do + c = context() + + assert {:error, :identity_conflict} = + P.validate(command(), %{c | snapshot: %{c.snapshot | command_bytes: "changed"}}) + + assert {:error, :stale_intent} = + P.validate(command(), %{c | snapshot: %{c.snapshot | current_intent: 2}}) + end + + test "observation omission and optional defaults preserve native nil" do + c = host(context()) + m = Map.put(result(), "observation", observation()) + assert {:ok, g} = P.validate(m, c) + + assert g.native_result["observation"] == + Map.merge(observation(), %{ + "provider_instance_id" => nil, + "effective_config" => nil, + "deleted" => false + }) + + assert {:ok, %{native_result: %{"observation" => nil}}} = P.validate(result(), c) + assert {:error, :invalid_message} = P.validate(Map.put(result(), "observation", nil), c) + end + + test "malformed observation values never become null" do + for bad <- [ + false, + true, + 0, + "config", + [], + %{}, + %{"secret" => "private-canary"}, + %{config() | "submodules" => [%{} | :bad_tail]} + ] do + m = Map.put(result(), "observation", Map.put(observation(), "effective_config", bad)) + assert {:error, :invalid_message} = P.validate(m, host(context())) + end + + for {field, bad} <- [{"provider_instance_id", false}, {"deleted", nil}, {"generation", 1.0}] do + assert {:error, :invalid_message} = + P.validate( + Map.put(result(), "observation", Map.put(observation(), field, bad)), + host(context()) + ) + end + end + + test "result status and immutable terminal replay are guarded" do + c = context() + c = %{c | peer: %{c.peer | kind: :host}} + assert {:error, :invalid_status} = P.validate(result(), c) + c = host(c) + assert {:ok, g} = P.validate(result(), c) + + c = %{ + c + | snapshot: %{ + c.snapshot + | status: "succeeded", + current_intent: 2, + terminal_result: g.native_result + } + } + + assert {:ok, %{disposition: "completed_lookup"}} = P.validate(result(), c) + m = Map.put(result(), "outcome", %{"status" => "failed", "code" => "operation_failed"}) + assert {:error, :identity_conflict} = P.validate(m, c) + end + + test "runtime scope provider config and removal observations are guarded" do + for variant <- [:runtime, :provider, :config, :deleted] do + c = host(context()) + o = observation() + + {c, o} = + case variant do + :runtime -> + {c, Map.put(o, "runtime_id", id(9))} + + :provider -> + {%{ + c + | snapshot: %{ + c.snapshot + | runtimes: %{id(6) => %{provider_id: "known", effective_config: nil}} + } + }, Map.put(o, "provider_instance_id", "changed")} + + :config -> + {c, + Map.put(o, "effective_config", %{config() | "commit" => String.duplicate("b", 40)})} + + :deleted -> + {c, Map.put(o, "deleted", true)} + end + + assert match?({:error, _}, P.validate(Map.put(result(), "observation", o), c)) + end + end + + test "result closed shape excludes command only payload fields" do + for field <- ["request", "execution_config", "approval_ref"], + do: + assert( + {:error, :invalid_message} == + P.validate(Map.put(result(), field, nil), host(context())) + ) + end + + test "native canonical budget aborts before emission and redacts canaries" do + for text <- [String.duplicate("x", 1_048_576), String.duplicate("\\", 524_288)] do + m = + put_in( + command(), + ["request", "parameters", "config", "repository_identity"], + "git@example.test:" <> text + ) + + assert {:error, :transport_refused} = P.validate(m, context()) + end + + assert {:error, :invalid_message} = + P.validate(Map.put(command(), "secret", "private-canary"), context()) + end + + test "raw size wins invalid Unicode and native depth is bounded" do + assert {:error, :input_limit} = + P.decode(<<255>> <> String.duplicate(" ", 1_048_576), context()) + + deep = Enum.reduce(1..129, nil, fn _, v -> [v] end) + assert {:error, :invalid_message} = P.validate(deep, context()) + end + + test "provider sentinel remains untouched for rejection and lookup" do + for v <- vectors()["cases"], v["code"] != "ok" do + case P.decode(v["raw"], context()) do + {:ok, %{disposition: "new_eligible"}} -> send(self(), :provider) + _ -> :ok + end + end + + sentinel = fn value, c -> + case P.validate(value, c) do + {:ok, %{disposition: "new_eligible"}} -> send(self(), :provider) + _ -> :ok + end + end + + sentinel.(Map.put(command(), "argv", ["sh"]), context()) + c = context() + + sentinel.(command(), %{ + c + | snapshot: %{c.snapshot | command_bytes: vectors()["command_canonical"]} + }) + + refute_received :provider + end + + test "every lifecycle branch and new intent on existing runtime validate" do + for a <- vectors()["actions"] do + m = Jason.decode!(a["command_canonical"]) + c = context() + + c = %{ + c + | snapshot: %{ + c.snapshot + | command: m, + request_bytes: a["request_bytes"], + request_hash: a["request_sha256"], + native_bindings: a["native_bindings"], + current_intent: 2, + runtimes: %{ + id(6) => %{provider_id: nil, effective_config: config()}, + id(8) => %{provider_id: nil, effective_config: nil} + } + } + } + + assert {:ok, %{disposition: "new_eligible"}} = P.decode(a["command_canonical"], c) + + r = + m + |> Map.drop(["request", "execution_config", "approval_ref"]) + |> Map.put("kind", "result") + |> Map.put("outcome", %{"status" => "succeeded", "code" => "ok"}) + + o = %{ + "runtime_id" => id(6), + "generation" => 1, + "observed_state" => "stopped", + "effective_config" => config() + } + + o = + case a["action"] do + "start" -> + Map.put(o, "observed_state", "running") + + "rebuild" -> + %{o | "runtime_id" => id(8), "generation" => 2, "observed_state" => "running"} + + "remove_runtime" -> + Map.put(o, "deleted", true) + + _ -> + o + end + + assert {:ok, _} = P.validate(Map.put(r, "observation", o), host(c)) + end + end + + test "canonical exact byte budget and maximum counter are independent" do + for text <- [ + String.duplicate("x", 1_048_576 - 8), + String.duplicate("é", div(1_048_576 - 8, 2)) + ] do + assert {:error, :invalid_message} = P.validate(%{"x" => text}, context()) + assert {:error, :transport_refused} = P.validate(%{"x" => text <> "x"}, context()) + end + + c = context() + m = Map.put(command(), "intent_version", 9_223_372_036_854_775_807) + + assert {:ok, _} = + P.validate(m, %{ + c + | snapshot: %{c.snapshot | command: m, current_intent: 9_223_372_036_854_775_807} + }) + end + + test "normalized result budget includes optional default overhead" do + o = Map.put(observation(), "provider_instance_id", "") + m = Map.put(result(), "observation", o) + + normalized = + Map.put(m, "observation", Map.merge(o, %{"effective_config" => nil, "deleted" => false})) + + fill = 1_048_576 - byte_size(Jason.encode!(normalized)) + c = host(context()) + exact = put_in(m, ["observation", "provider_instance_id"], String.duplicate("x", fill)) + assert {:ok, g} = P.decode(Jason.encode!(exact), c) + assert byte_size(g.canonical) == 1_048_576 + over = put_in(m, ["observation", "provider_instance_id"], String.duplicate("x", fill + 1)) + raw = Jason.encode!(over) + assert byte_size(raw) <= 1_048_576 + assert {:error, :transport_refused} = P.decode(raw, c) + assert {:error, :transport_refused} = P.validate(over, c) + end + + test "wire observation effective config is already normalized" do + for cfg <- [ + Map.delete(config(), "submodules"), + %{ + config() + | "submodules" => [ + %{"path" => "z", "commit" => String.duplicate("a", 40)}, + %{"path" => "a", "commit" => String.duplicate("b", 40)} + ] + } + ] do + m = Map.put(result(), "observation", Map.put(observation(), "effective_config", cfg)) + assert {:error, :invalid_message} = P.validate(m, host(context())) + end + end + + test "workspace scope matches separately authorized expectation" do + m = command() |> Map.put("workspace_id", id(9)) |> put_in(["request", "workspace_id"], id(9)) + assert {:error, :unauthorized_scope} = P.validate(m, context()) + end +end diff --git a/apps/host_agent/internal/protocol/protocol.go b/apps/host_agent/internal/protocol/protocol.go new file mode 100644 index 00000000..4ec47840 --- /dev/null +++ b/apps/host_agent/internal/protocol/protocol.go @@ -0,0 +1,598 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package protocol validates the closed draft host contract without effects. +// Caller-supplied peer/snapshot types are not a cryptographic identity mechanism. +package protocol + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "math/big" + "net" + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +const limit = 1 << 20 + +// Peer is supplied by the future authenticated admission layer, never a wire body. +type Peer struct{ Kind, OwnerID, HostID string } + +// Runtime is an authoritative observation expectation, not provider state access. +type Runtime struct { + ProviderID any + EffectiveConfig any +} + +// Snapshot is a caller-supplied immutable operation expectation, not a ledger. +type Snapshot struct { + Command map[string]any + RequestBytes []byte + RequestHash string + NativeBindings map[string]any + CurrentIntent int64 + Status string + CommandBytes []byte + TerminalResult map[string]any + Runtimes map[string]Runtime +} +type Context struct { + Peer *Peer + Snapshot *Snapshot +} + +// Validated carries only data and a disposition. Even new_eligible permits no effect. +type Validated struct { + Message map[string]any + Canonical []byte + NativeBindings map[string]any + NativeResult map[string]any + Disposition string +} +type Error struct{ Code string } + +func (e *Error) Error() string { return e.Code } +func fail(code string) { panic(&Error{code}) } +func require(ok bool, code string) { + if !ok { + fail(code) + } +} + +const invalid = "invalid_message" + +var uuid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +var key = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`) +var hash = regexp.MustCompile(`^[0-9a-f]{64}$`) +var commit = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) +var common = []string{"schema_version", "kind", "command_id", "owner_id", "host_id", "workspace_id", "operation_id", "idempotency_key", "request_sha256", "intent_version", "bindings"} +var commandFields = append(append([]string{}, common...), "request", "execution_config", "approval_ref") +var states = map[string]bool{"pending": true, "provisioning": true, "running": true, "stopping": true, "stopped": true, "failed": true, "unknown": true} + +func Decode(raw any, ctx Context) (*Validated, error) { + v, e := wirejson.Parse(raw) + if e != nil { + if e.Error() == wirejson.CodeInputLimitExceeded { + return nil, &Error{"input_limit"} + } + return nil, &Error{"invalid_json"} + } + if _, ok := v.(map[string]any); !ok { + return nil, &Error{"invalid_json"} + } + return Validate(v, ctx) +} +func Validate(value any, ctx Context) (out *Validated, err error) { + defer func() { + if r := recover(); r != nil { + if e, ok := r.(*Error); ok { + out = nil + err = e + } else { + panic(r) + } + } + }() + budget(value, 0, limit) + m := shape(value) + budget(m, 0, limit) + peer, s := ctx.Peer, ctx.Snapshot + wantPeer := "control_plane" + if m["kind"] == "result" { + wantPeer = "host" + } + require(peer != nil && s != nil, "unauthorized_scope") + require(peer.Kind == wantPeer && uuid.MatchString(peer.OwnerID) && uuid.MatchString(peer.HostID) && m["owner_id"] == peer.OwnerID && m["host_id"] == peer.HostID, "unauthorized_scope") + budget(s.Command, 0, limit) + expected := commandShape(s.Command) + require(expected["owner_id"] == peer.OwnerID && expected["host_id"] == peer.HostID, "unauthorized_scope") + require(m["workspace_id"] == expected["workspace_id"], "unauthorized_scope") + for _, f := range common { + if f != "kind" { + require(equal(m[f], expected[f]), "identity_conflict") + } + } + _, requestBytes, requestHash := normalizeRequest(peer.OwnerID, str(expected["idempotency_key"]), expected["request"]) + require(bytes.Equal(requestBytes, s.RequestBytes) && requestHash == s.RequestHash && expected["request_sha256"] == s.RequestHash, "identity_conflict") + native := bindings(m["bindings"], "runtime_id") + // Native bindings retain exact id/generation/role types, not loose numeric equality. + require(nativeBindingsValid(s.NativeBindings) && equal(native, s.NativeBindings), "stale_runtime") + canonical := canonical(m) + out = &Validated{Message: m, Canonical: canonical, NativeBindings: native} + if m["kind"] == "command" { + require(equal(m, expected), "identity_conflict") + if s.CommandBytes != nil { + require(bytes.Equal(canonical, s.CommandBytes), "identity_conflict") + } + if terminal(s.Status) { + out.Disposition = "completed_lookup" + return out, nil + } + current(s, m) + switch s.Status { + case "queued": + if s.CommandBytes == nil { + out.Disposition = "new_eligible" + } else { + out.Disposition = "pending_lookup" + } + case "executing": + out.Disposition = "reconciliation_required" + default: + fail("invalid_status") + } + } else { + result := map[string]any{"outcome": m["outcome"], "observation": m["observation"]} + out.NativeResult = result + if terminal(s.Status) { + require(equal(result, s.TerminalResult), "identity_conflict") + out.Disposition = "completed_lookup" + return out, nil + } + current(s, m) + outcome := object(m["outcome"]) + require((s.Status == "queued" || s.Status == "executing") && (outcome["status"] != "succeeded" || s.Status == "executing"), "invalid_status") + observe(result, native, expected, s) + out.Disposition = "new_eligible" + } + return out, nil +} +func terminal(s string) bool { return s == "succeeded" || s == "failed" } +func current(s *Snapshot, m map[string]any) { + require(s.CurrentIntent == counter(m["intent_version"]), "stale_intent") +} +func shape(v any) map[string]any { + m := object(v) + if n, ok := integer(m["schema_version"]); ok && n.Cmp(big.NewInt(1)) != 0 { + fail("unsupported_version") + } + require(counter(m["schema_version"]) == 1, invalid) + switch m["kind"] { + case "command": + return commandShape(m) + case "result": + fields(m, append(append([]string{}, common...), "outcome"), []string{"observation"}) + commonShape(m) + o := object(m["outcome"]) + fields(o, []string{"status", "code"}, nil) + require((o["status"] == "succeeded" && o["code"] == "ok") || (o["status"] == "failed" && o["code"] == "operation_failed"), invalid) + out := copyMap(m) + if observation, present := m["observation"]; present { + out["observation"] = observationShape(observation) + } + return out + default: + fail(invalid) + } + return nil +} +func commandShape(v any) map[string]any { + m := object(v) + fields(m, commandFields, nil) + require(counter(m["schema_version"]) == 1 && m["kind"] == "command", invalid) + commonShape(m) + normalized, _, _ := normalizeRequest(str(m["owner_id"]), str(m["idempotency_key"]), m["request"]) + delete(normalized, "owner_id") + require(equal(normalized, m["request"]), invalid) + require(normalized["workspace_id"] == m["workspace_id"], invalid) + action := str(normalized["action"]) + parameters := object(normalized["parameters"]) + b := object(m["bindings"]) + switch action { + case "create": + require(parameters["host_id"] == m["host_id"] && b["target"] == nil && b["candidate"] != nil, invalid) + case "rebuild": + require(b["candidate"] != nil, invalid) + case "start": + require(b["target"] != nil && b["candidate"] == nil, invalid) + default: + require(b["candidate"] == nil, invalid) + } + if action == "start" { + require(equal(normalizeConfig(m["execution_config"]), m["execution_config"]), invalid) + } else { + require(m["execution_config"] == nil, invalid) + } + if action == "create" || action == "rebuild" { + require(uuid.MatchString(str(m["approval_ref"])), invalid) + } else { + require(m["approval_ref"] == nil, invalid) + } + return copyMap(m) +} +func commonShape(m map[string]any) { + for _, f := range []string{"command_id", "owner_id", "host_id", "workspace_id", "operation_id"} { + require(uuid.MatchString(str(m[f])), invalid) + } + require(key.MatchString(str(m["idempotency_key"])) && hash.MatchString(str(m["request_sha256"])), invalid) + counter(m["intent_version"]) + bindings(m["bindings"], "runtime_id") +} +func bindings(v any, idField string) map[string]any { + b := object(v) + fields(b, []string{"target", "candidate"}, nil) + out := map[string]any{} + for _, role := range []string{"target", "candidate"} { + v := b[role] + if v == nil { + out[role] = nil + continue + } + r := object(v) + fields(r, []string{idField, "generation"}, nil) + require(uuid.MatchString(str(r[idField])), invalid) + out[role] = map[string]any{"id": r[idField], "generation": counter(r["generation"])} + } + return out +} +func nativeBindingsValid(v any) (ok bool) { + defer func() { + if r := recover(); r != nil { + if _, known := r.(*Error); !known { + panic(r) + } + ok = false + } + }() + bindings(v, "id") + return true +} +func observationShape(v any) map[string]any { + o := object(v) + fields(o, []string{"runtime_id", "generation", "observed_state"}, []string{"provider_instance_id", "effective_config", "deleted"}) + require(uuid.MatchString(str(o["runtime_id"])) && states[str(o["observed_state"])], invalid) + counter(o["generation"]) + provider := o["provider_instance_id"] + if provider != nil { + str(provider) + } + deleted := any(false) + if value, exists := o["deleted"]; exists { + b, ok := value.(bool) + require(ok, invalid) + deleted = b + } + effective := o["effective_config"] + if effective != nil { + normalized := normalizeConfig(effective) + require(equal(effective, normalized), invalid) + effective = normalized + } + out := copyMap(o) + out["provider_instance_id"] = provider + out["effective_config"] = effective + out["deleted"] = deleted + return out +} +func observe(result, native, command map[string]any, s *Snapshot) { + if result["observation"] == nil { + return + } + o := object(result["observation"]) + identity := map[string]any{"id": o["runtime_id"], "generation": o["generation"]} + role := "" + for _, r := range []string{"target", "candidate"} { + if equal(native[r], identity) { + role = r + } + } + require(role != "", "stale_runtime") + runtime, ok := s.Runtimes[str(o["runtime_id"])] + require(ok, "stale_runtime") + provider, effective := o["provider_instance_id"], o["effective_config"] + if provider != nil && runtime.ProviderID != nil { + require(equal(provider, runtime.ProviderID), "stale_runtime") + } + req := object(command["request"]) + action := str(req["action"]) + if effective != nil { + if role == "target" { + require(runtime.EffectiveConfig != nil, "stale_runtime") + } + if runtime.EffectiveConfig != nil { + require(equal(effective, runtime.EffectiveConfig), "stale_runtime") + } + if role == "candidate" { + require(equal(effective, object(req["parameters"])["config"]), "stale_runtime") + } + if action == "start" { + require(equal(effective, command["execution_config"]), "stale_runtime") + } + } + if o["deleted"] == true { + require(action == "remove_runtime" && role == "target" && object(result["outcome"])["status"] == "succeeded" && o["observed_state"] == "stopped", invalid) + } +} +func normalizeRequest(owner, k string, v any) (map[string]any, []byte, string) { + require(uuid.MatchString(owner) && key.MatchString(k), invalid) + r := object(v) + fields(r, []string{"schema_version", "workspace_id", "action"}, []string{"parameters"}) + require(counter(r["schema_version"]) == 1 && uuid.MatchString(str(r["workspace_id"])), invalid) + action := str(r["action"]) + raw, exists := r["parameters"] + if !exists && (action == "start" || action == "stop" || action == "remove_runtime") { + raw = map[string]any{} + } + parameters := object(raw) + var normalized map[string]any + switch action { + case "start", "stop", "remove_runtime": + fields(parameters, nil, nil) + normalized = map[string]any{} + case "create": + fields(parameters, []string{"host_id", "config"}, nil) + require(uuid.MatchString(str(parameters["host_id"])), invalid) + normalized = map[string]any{"host_id": parameters["host_id"], "config": normalizeConfig(parameters["config"])} + case "rebuild": + fields(parameters, []string{"config"}, nil) + normalized = map[string]any{"config": normalizeConfig(parameters["config"])} + default: + fail(invalid) + } + identity := map[string]any{"schema_version": int64(1), "owner_id": owner, "workspace_id": r["workspace_id"], "action": action, "parameters": normalized} + b := canonical(identity) + digest := sha256.Sum256(b) + return identity, b, hex.EncodeToString(digest[:]) +} +func normalizeConfig(v any) map[string]any { + c := object(v) + fields(c, []string{"repository_identity", "commit"}, []string{"submodules"}) + require(repository(str(c["repository_identity"])) && commit.MatchString(str(c["commit"])), invalid) + list := []any{} + if v, ok := c["submodules"]; ok { + var valid bool + list, valid = v.([]any) + require(valid, invalid) + } + entries := make([]any, 0, len(list)) + seen := map[string]bool{} + for _, v := range list { + entry := object(v) + fields(entry, []string{"path", "commit"}, nil) + path := str(entry["path"]) + _, e := config.CanonicalPath(path) + require(e == nil && commit.MatchString(str(entry["commit"])) && !seen[path], invalid) + seen[path] = true + entries = append(entries, map[string]any{"path": path, "commit": entry["commit"]}) + } + sort.Slice(entries, func(i, j int) bool { return object(entries[i])["path"].(string) < object(entries[j])["path"].(string) }) + return map[string]any{"repository_identity": c["repository_identity"], "commit": c["commit"], "submodules": entries} +} + +var urlShape = regexp.MustCompile(`(?is)^(https|ssh)://([^/]+)(/.*)$`) +var otherScheme = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://`) +var scpShape = regexp.MustCompile(`(?s)^((?:[A-Za-z0-9._-]+@)?(?:\[[^\]]+\]|[^:]+)):(.+)$`) +var username = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) +var hostname = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.?$`) +var hostPort = regexp.MustCompile(`^([^:]+)(?::([0-9]+))?$`) +var ipv6Port = regexp.MustCompile(`^(\[[^\]]+\])(?::([0-9]+))?$`) + +func repository(s string) bool { + if s == "" || !utf8.ValidString(s) { + return false + } + for _, r := range s { + if r <= 32 || r == 127 || r == 0x85 || r == 0xa0 || r == 0x1680 || r >= 0x2000 && r <= 0x200a || r == 0x2028 || r == 0x2029 || r == 0x202f || r == 0x205f || r == 0x3000 || r == '\\' || r == '?' || r == '#' { + return false + } + } + for i := 0; i < len(s); i++ { + if s[i] == '%' { + if i+2 >= len(s) || !hexDigit(s[i+1]) || !hexDigit(s[i+2]) { + return false + } + i += 2 + } + } + if m := urlShape.FindStringSubmatch(s); m != nil { + return authority(m[2], strings.EqualFold(m[1], "ssh"), true) + } + if otherScheme.MatchString(s) { + return false + } + m := scpShape.FindStringSubmatch(s) + return m != nil && authority(m[1], true, false) +} +func hexDigit(b byte) bool { + return b >= '0' && b <= '9' || b >= 'a' && b <= 'f' || b >= 'A' && b <= 'F' +} +func authority(s string, user, ports bool) bool { + parts := strings.Split(s, "@") + if len(parts) == 2 { + if !user || !username.MatchString(parts[0]) { + return false + } + s = parts[1] + } else if len(parts) != 1 { + return false + } + re := hostPort + if strings.HasPrefix(s, "[") { + re = ipv6Port + } + m := re.FindStringSubmatch(s) + if m == nil { + return false + } + host := m[1] + if strings.HasPrefix(host, "[") { + literal := host[1 : len(host)-1] + if !strings.Contains(literal, ":") || net.ParseIP(literal) == nil { + return false + } + } else if !hostname.MatchString(host) { + return false + } + // An explicit port delimiter must have digits; the regex enforces this. + if m[2] != "" { + if !ports { + return false + } + significant := strings.TrimLeft(m[2], "0") + if len(significant) < 1 || len(significant) > 5 { + return false + } + n, e := strconv.Atoi(significant) + return e == nil && n <= 65535 + } + return true +} +func object(v any) map[string]any { m, ok := v.(map[string]any); require(ok, invalid); return m } +func str(v any) string { s, ok := v.(string); require(ok && utf8.ValidString(s), invalid); return s } +func fields(m map[string]any, required, optional []string) { + allowed := map[string]bool{} + for _, k := range required { + _, exists := m[k] + require(exists, invalid) + allowed[k] = true + } + for _, k := range optional { + allowed[k] = true + } + for k := range m { + require(allowed[k], invalid) + } +} +func copyMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} +func canonical(v any) []byte { + b, e := wirejson.CanonicalBytes(v) + require(e == nil, invalid) + return b +} +func equal(a, b any) bool { return bytes.Equal(canonical(a), canonical(b)) } +func counter(v any) int64 { + n, ok := integer(v) + require(ok && n.Sign() > 0 && n.IsInt64(), invalid) + return n.Int64() +} +func integer(v any) (*big.Int, bool) { + n := new(big.Int) + switch x := v.(type) { + case *big.Int: + if x != nil { + return x, true + } + case int: + return n.SetInt64(int64(x)), true + case int8: + return n.SetInt64(int64(x)), true + case int16: + return n.SetInt64(int64(x)), true + case int32: + return n.SetInt64(int64(x)), true + case int64: + return n.SetInt64(x), true + case uint: + return n.SetUint64(uint64(x)), true + case uint8: + return n.SetUint64(uint64(x)), true + case uint16: + return n.SetUint64(uint64(x)), true + case uint32: + return n.SetUint64(uint64(x)), true + case uint64: + return n.SetUint64(x), true + } + return nil, false +} +func spend(left, n int) int { + if n > left { + fail("transport_refused") + } + return left - n +} + +// budget checks exact generic native canonical size before any full serialization. +func budget(v any, depth, left int) int { + switch x := v.(type) { + case nil: + return spend(left, 4) + case bool: + if x { + return spend(left, 4) + } + return spend(left, 5) + case string: + if len(x) > left { + fail("transport_refused") + } + require(utf8.ValidString(x), invalid) + left = spend(left, 2) + for _, r := range x { + n := utf8.RuneLen(r) + switch r { + case '"', '\\', '\b', '\f', '\n', '\r', '\t': + n = 2 + default: + if r < 32 { + n = 6 + } + } + left = spend(left, n) + } + return left + case []any: + require(depth < 128, invalid) + left = spend(left, 2) + for i, item := range x { + if i > 0 { + left = spend(left, 1) + } + left = budget(item, depth+1, left) + } + return left + case map[string]any: + require(depth < 128, invalid) + left = spend(left, 2) + i := 0 + for k, item := range x { + if i > 0 { + left = spend(left, 1) + } + left = budget(k, depth+1, left) + left = spend(left, 1) + left = budget(item, depth+1, left) + i++ + } + return left + default: + n, ok := integer(v) + require(ok, invalid) + require(n.BitLen() <= 14285, invalid) + text := n.String() + digits := strings.TrimPrefix(text, "-") + require(len(digits) <= 4300, invalid) + return spend(left, len(text)) + } +} diff --git a/apps/host_agent/internal/protocol/protocol_test.go b/apps/host_agent/internal/protocol/protocol_test.go new file mode 100644 index 00000000..99016245 --- /dev/null +++ b/apps/host_agent/internal/protocol/protocol_test.go @@ -0,0 +1,516 @@ +// SPDX-License-Identifier: EUPL-1.2 +package protocol_test + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + p "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol" + "math/big" + "os" + "strings" + "testing" +) + +func id(n string) string { return "00000000-0000-0000-0000-" + strings.Repeat("0", 12-len(n)) + n } +func cfg() map[string]any { + return map[string]any{"repository_identity": "git@example.test:team/workspace.git", "commit": strings.Repeat("a", 40), "submodules": []any{}} +} +func command() map[string]any { + return map[string]any{"schema_version": 1, "kind": "command", "command_id": id("5"), "owner_id": id("1"), "host_id": id("2"), "workspace_id": id("3"), "operation_id": id("4"), "idempotency_key": "create:1", "request_sha256": fixtures().RequestHash, "intent_version": 1, "request": map[string]any{"schema_version": 1, "workspace_id": id("3"), "action": "create", "parameters": map[string]any{"host_id": id("2"), "config": cfg()}}, "bindings": map[string]any{"target": nil, "candidate": map[string]any{"runtime_id": id("6"), "generation": 1}}, "execution_config": nil, "approval_ref": id("7")} +} + +type vectors struct { + RequestBytes string `json:"request_bytes"` + RequestHash string `json:"request_sha256"` + CommandCanonical string `json:"command_canonical"` + CommandHash string `json:"command_sha256"` + Cases []struct{ ID, Raw, Code, Disposition string } +} + +func fixtures() vectors { + raw, e := os.ReadFile("../../../../contracts/host-protocol/vectors.json") + if e != nil { + panic(e) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != "dedc629e6989fdfaaad8053fe14a0680b692495fdfff88eb1f4e5a29c534d66d" { + panic("fixture provenance changed") + } + var v vectors + if e = json.Unmarshal(raw, &v); e != nil { + panic(e) + } + return v +} +func context() p.Context { + v := fixtures() + return p.Context{Peer: &p.Peer{Kind: "control_plane", OwnerID: id("1"), HostID: id("2")}, Snapshot: &p.Snapshot{Command: command(), RequestBytes: []byte(v.RequestBytes), RequestHash: v.RequestHash, NativeBindings: map[string]any{"target": nil, "candidate": map[string]any{"id": id("6"), "generation": 1}}, CurrentIntent: 1, Status: "queued", Runtimes: map[string]p.Runtime{id("6"): {}}}} +} +func code(err error) string { + if err == nil { + return "ok" + } + return err.Error() +} +func result() map[string]any { + m := command() + delete(m, "request") + delete(m, "execution_config") + delete(m, "approval_ref") + m["kind"] = "result" + m["outcome"] = map[string]any{"status": "succeeded", "code": "ok"} + return m +} +func observation() map[string]any { + return map[string]any{"runtime_id": id("6"), "generation": 1, "observed_state": "running"} +} +func TestSharedRawVectors(t *testing.T) { + for _, v := range fixtures().Cases { + t.Run(v.ID, func(t *testing.T) { + got, e := p.Decode([]byte(v.Raw), context()) + if code(e) != v.Code { + t.Fatalf("%s: want %s got %s", v.ID, v.Code, code(e)) + } + if e == nil && got.Disposition != v.Disposition { + t.Fatalf("disposition %s", got.Disposition) + } + }) + } +} +func TestIndependentIdentityAnchors(t *testing.T) { + v := fixtures() + got, e := p.Validate(command(), context()) + if e != nil { + t.Fatal(e) + } + if string(got.Canonical) != v.CommandCanonical { + t.Fatal("whole command bytes") + } + sum := sha256.Sum256(got.Canonical) + if hex.EncodeToString(sum[:]) != v.CommandHash { + t.Fatal("command digest") + } + if bytes.Equal(got.Canonical, []byte(v.RequestBytes)) { + t.Fatal("hash identities conflated") + } + if got.NativeBindings["candidate"].(map[string]any)["id"] != id("6") { + t.Fatal("binding translation") + } +} +func TestNativeClosedTypes(t *testing.T) { + for _, bad := range []any{nil, true, 1, 1.0, []any{}, map[int]any{}, []byte("{}")} { + if _, e := p.Validate(bad, context()); e == nil { + t.Fatal("accepted native type") + } + } + for _, bad := range []any{true, 1.0, "1", big.NewInt(0), new(big.Int).Lsh(big.NewInt(1), 63), uint64(1 << 63)} { + m := command() + m["intent_version"] = bad + if _, e := p.Validate(m, context()); code(e) != "invalid_message" { + t.Fatalf("bad counter %T %s", bad, code(e)) + } + } +} +func TestTrustedScopeBeforeSnapshotDisclosure(t *testing.T) { + bad := context() + bad.Peer.OwnerID = id("9") + bad.Snapshot.Command = map[string]any{"secret": "private-canary"} + if _, e := p.Validate(command(), bad); code(e) != "unauthorized_scope" { + t.Fatal("snapshot inspected before scope", code(e)) + } + for _, peer := range []*p.Peer{nil, {Kind: "owner", OwnerID: id("1"), HostID: id("2")}, {Kind: "control_plane", OwnerID: id("9"), HostID: id("2")}} { + c := context() + c.Peer = peer + c.Snapshot = nil + if _, e := p.Validate(command(), c); code(e) != "unauthorized_scope" { + t.Fatal(code(e)) + } + } + c := context() + c.Snapshot = nil + if _, e := p.Validate(command(), c); code(e) != "unauthorized_scope" { + t.Fatal(code(e)) + } +} +func TestPendingReplayNeverPermitsEffect(t *testing.T) { + c := context() + c.Snapshot.CommandBytes = []byte(fixtures().CommandCanonical) + g, e := p.Validate(command(), c) + if e != nil || g.Disposition != "pending_lookup" { + t.Fatal(g, e) + } + c.Snapshot.Status = "executing" + g, e = p.Validate(command(), c) + if e != nil || g.Disposition != "reconciliation_required" { + t.Fatal(g, e) + } +} +func TestCompletedReplayAfterNewerIntent(t *testing.T) { + c := context() + c.Snapshot.Status = "succeeded" + c.Snapshot.CurrentIntent = 2 + c.Snapshot.CommandBytes = []byte(fixtures().CommandCanonical) + g, e := p.Validate(command(), c) + if e != nil || g.Disposition != "completed_lookup" { + t.Fatal(g, e) + } +} +func TestChangedReplayAndSameRuntimeStaleIntent(t *testing.T) { + c := context() + c.Snapshot.CommandBytes = []byte("changed") + if _, e := p.Validate(command(), c); code(e) != "identity_conflict" { + t.Fatal(code(e)) + } + c = context() + c.Snapshot.CurrentIntent = 2 + if _, e := p.Validate(command(), c); code(e) != "stale_intent" { + t.Fatal(code(e)) + } +} +func TestResultsAndObservationDefaults(t *testing.T) { + m := result() + m["observation"] = observation() + c := context() + c.Peer.Kind = "host" + c.Snapshot.Status = "executing" + g, e := p.Validate(m, c) + if e != nil { + t.Fatal(e) + } + o := g.NativeResult["observation"].(map[string]any) + if o["provider_instance_id"] != nil || o["effective_config"] != nil || o["deleted"] != false { + t.Fatal("defaults") + } + delete(m, "observation") + g, e = p.Validate(m, c) + if e != nil || g.NativeResult["observation"] != nil { + t.Fatal(g, e) + } + m["observation"] = nil + if _, e = p.Validate(m, c); code(e) != "invalid_message" { + t.Fatal(code(e)) + } +} +func TestMalformedObservationDoesNotBecomeNull(t *testing.T) { + for _, bad := range []any{false, 0, "config", []any{}, map[string]any{}, map[string]any{"secret": "private-canary"}} { + m := result() + o := observation() + o["effective_config"] = bad + m["observation"] = o + c := context() + c.Peer.Kind = "host" + c.Snapshot.Status = "executing" + if _, e := p.Validate(m, c); code(e) != "invalid_message" { + t.Fatal(code(e)) + } + } + for field, bad := range map[string]any{"provider_instance_id": false, "deleted": nil, "generation": 1.0} { + m := result() + o := observation() + o[field] = bad + m["observation"] = o + c := context() + c.Peer.Kind = "host" + if _, e := p.Validate(m, c); code(e) != "invalid_message" { + t.Fatal(code(e)) + } + } +} +func TestResultScopeStatusAndTerminalImmutability(t *testing.T) { + c := context() + c.Peer.Kind = "host" + m := result() + if _, e := p.Validate(m, c); code(e) != "invalid_status" { + t.Fatal(code(e)) + } + c.Snapshot.Status = "executing" + g, e := p.Validate(m, c) + if e != nil { + t.Fatal(e) + } + c.Snapshot.Status = "succeeded" + c.Snapshot.CurrentIntent = 2 + c.Snapshot.TerminalResult = g.NativeResult + g, e = p.Validate(m, c) + if e != nil || g.Disposition != "completed_lookup" { + t.Fatal(g, e) + } + m["outcome"] = map[string]any{"status": "failed", "code": "operation_failed"} + if _, e = p.Validate(m, c); code(e) != "identity_conflict" { + t.Fatal(code(e)) + } +} +func TestObservationBindingsConfigAndProviderImmutable(t *testing.T) { + for _, variant := range []string{"runtime", "provider", "config", "deleted"} { + m := result() + o := observation() + c := context() + c.Peer.Kind = "host" + c.Snapshot.Status = "executing" + switch variant { + case "runtime": + o["runtime_id"] = id("9") + case "provider": + c.Snapshot.Runtimes[id("6")] = p.Runtime{ProviderID: "known"} + o["provider_instance_id"] = "changed" + case "config": + bad := cfg() + bad["commit"] = strings.Repeat("b", 40) + o["effective_config"] = bad + case "deleted": + o["deleted"] = true + } + m["observation"] = o + if _, e := p.Validate(m, c); e == nil { + t.Fatal(variant) + } + } +} +func TestResultCommandOnlyFieldsRejected(t *testing.T) { + for _, f := range []string{"request", "approval_ref", "execution_config"} { + m := result() + m[f] = nil + c := context() + c.Peer.Kind = "host" + if _, e := p.Validate(m, c); code(e) != "invalid_message" { + t.Fatal(code(e)) + } + } +} +func TestBoundedNativeCanonicalBudgetAndRedaction(t *testing.T) { + for _, bad := range []any{strings.Repeat("x", 1<<20), strings.Repeat("\\", 1<<19)} { + m := command() + m["request"].(map[string]any)["parameters"].(map[string]any)["config"].(map[string]any)["repository_identity"] = "git@example.test:" + bad.(string) + if _, e := p.Validate(m, context()); code(e) != "transport_refused" { + t.Fatal(code(e)) + } + } + m := command() + m["secret"] = "private-canary" + if _, e := p.Validate(m, context()); strings.Contains(code(e), "canary") { + t.Fatal("leak") + } +} +func TestRawLimitsAndNativeCycles(t *testing.T) { + for _, raw := range [][]byte{append([]byte{255}, bytes.Repeat([]byte{' '}, 1<<20)...), bytes.Repeat([]byte{' '}, (1<<20)+1)} { + if _, e := p.Decode(raw, context()); code(e) != "input_limit" { + t.Fatal(code(e)) + } + } + m := map[string]any{} + m["cycle"] = m + if _, e := p.Validate(m, context()); e == nil { + t.Fatal("cycle") + } +} +func TestNoProviderEffectForRejectedOrLookup(t *testing.T) { + calls := 0 + for _, v := range fixtures().Cases { + if v.Code != "ok" { + g, e := p.Decode([]byte(v.Raw), context()) + if e == nil && g.Disposition == "new_eligible" { + calls++ + } + } + } + gate := func(m any, c p.Context) { + v, e := p.Validate(m, c) + if e == nil && v.Disposition == "new_eligible" { + calls++ + } + } + m := command() + m["argv"] = []any{"sh"} + gate(m, context()) + c := context() + c.Snapshot.CommandBytes = []byte(fixtures().CommandCanonical) + gate(command(), c) + if calls != 0 { + t.Fatal("effect gate") + } +} + +func TestAllLifecycleBranchesAndSameRuntimeNewIntent(t *testing.T) { + raw, e := os.ReadFile("../../../../contracts/host-protocol/vectors.json") + if e != nil { + t.Fatal(e) + } + var f struct { + Actions []struct { + Action string + Command string `json:"command_canonical"` + Request string `json:"request_bytes"` + Hash string `json:"request_sha256"` + Bindings json.RawMessage `json:"native_bindings"` + } + } + if e = json.Unmarshal(raw, &f); e != nil { + t.Fatal(e) + } + decode := func(raw []byte) map[string]any { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + var v any + if e := d.Decode(&v); e != nil { + t.Fatal(e) + } + var native func(any) any + native = func(v any) any { + switch x := v.(type) { + case json.Number: + n, e := x.Int64() + if e != nil { + t.Fatal(e) + } + return n + case map[string]any: + for k, a := range x { + x[k] = native(a) + } + case []any: + for i, a := range x { + x[i] = native(a) + } + } + return v + } + return native(v).(map[string]any) + } + for _, a := range f.Actions { + t.Run(a.Action, func(t *testing.T) { + c := context() + m := decode([]byte(a.Command)) + c.Snapshot.Command = m + c.Snapshot.RequestBytes = []byte(a.Request) + c.Snapshot.RequestHash = a.Hash + c.Snapshot.NativeBindings = decode(a.Bindings) + c.Snapshot.CurrentIntent = 2 + c.Snapshot.Runtimes[id("6")] = p.Runtime{EffectiveConfig: cfg()} + c.Snapshot.Runtimes[id("8")] = p.Runtime{} + g, e := p.Decode([]byte(a.Command), c) + if e != nil || g.Disposition != "new_eligible" { + t.Fatal(g, e) + } + r := map[string]any{} + for k, v := range m { + r[k] = v + } + delete(r, "request") + delete(r, "approval_ref") + delete(r, "execution_config") + r["kind"] = "result" + r["outcome"] = map[string]any{"status": "succeeded", "code": "ok"} + o := map[string]any{"runtime_id": id("6"), "generation": 1, "observed_state": "stopped", "effective_config": cfg()} + if a.Action == "start" { + o["observed_state"] = "running" + } + if a.Action == "rebuild" { + o["runtime_id"] = id("8") + o["generation"] = 2 + o["observed_state"] = "running" + } + if a.Action == "remove_runtime" { + o["deleted"] = true + } + r["observation"] = o + c.Peer.Kind = "host" + c.Snapshot.Status = "executing" + if _, e = p.Validate(r, c); e != nil { + t.Fatal(e) + } + }) + } +} +func TestExactCanonicalBudgetAndCounterMaximum(t *testing.T) { + for _, text := range []string{strings.Repeat("x", (1<<20)-8), strings.Repeat("é", ((1<<20)-8)/2)} { + if _, e := p.Validate(map[string]any{"x": text}, context()); code(e) != "invalid_message" { + t.Fatal("exact budget", code(e)) + } + if _, e := p.Validate(map[string]any{"x": text + "x"}, context()); code(e) != "transport_refused" { + t.Fatal("over budget", code(e)) + } + } + c := context() + m := command() + m["intent_version"] = int64(9223372036854775807) + c.Snapshot.Command = m + c.Snapshot.CurrentIntent = 9223372036854775807 + if _, e := p.Validate(m, c); e != nil { + t.Fatal(e) + } +} + +func TestNormalizedResultBudgetIncludesOptionalDefaults(t *testing.T) { + m := result() + o := observation() + o["provider_instance_id"] = "" + m["observation"] = o + normalized := result() + no := observation() + no["provider_instance_id"] = "" + no["effective_config"] = nil + no["deleted"] = false + normalized["observation"] = no + base, e := json.Marshal(normalized) + if e != nil { + t.Fatal(e) + } + fill := (1 << 20) - len(base) + c := context() + c.Peer.Kind = "host" + c.Snapshot.Status = "executing" + o["provider_instance_id"] = strings.Repeat("x", fill) + raw, e := json.Marshal(m) + if e != nil { + t.Fatal(e) + } + g, e := p.Decode(raw, c) + if e != nil || len(g.Canonical) != (1<<20) { + t.Fatal("exact normalized budget", e) + } + o["provider_instance_id"] = strings.Repeat("x", fill+1) + raw, e = json.Marshal(m) + if e != nil { + t.Fatal(e) + } + if len(raw) > 1<<20 { + t.Fatal("regression fixture already oversize before defaults") + } + if _, e = p.Decode(raw, c); code(e) != "transport_refused" { + t.Fatal("normalized default overhead", code(e)) + } + if _, e = p.Validate(m, c); code(e) != "transport_refused" { + t.Fatal("native normalized overhead", code(e)) + } +} +func TestWireObservationConfigMustAlreadyBeNormalized(t *testing.T) { + for _, variant := range []string{"omitted", "unsorted"} { + m := result() + o := observation() + config := cfg() + if variant == "omitted" { + delete(config, "submodules") + } else { + config["submodules"] = []any{map[string]any{"path": "z", "commit": strings.Repeat("a", 40)}, map[string]any{"path": "a", "commit": strings.Repeat("b", 40)}} + } + o["effective_config"] = config + m["observation"] = o + c := context() + c.Peer.Kind = "host" + c.Snapshot.Status = "executing" + if _, e := p.Validate(m, c); code(e) != "invalid_message" { + t.Fatal(variant, code(e)) + } + } +} + +func TestWorkspaceScopeUsesExpectedAssignment(t *testing.T) { + m := command() + m["workspace_id"] = id("9") + m["request"].(map[string]any)["workspace_id"] = id("9") + if _, e := p.Validate(m, context()); code(e) != "unauthorized_scope" { + t.Fatal(code(e)) + } +} diff --git a/contracts/README.md b/contracts/README.md index 2616f30a..908b6499 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -15,6 +15,13 @@ in feature 001; no unvalidated JSON reaches provider operations. | BundleAttachment | workspace_id, artifact digest, platform, method, resolved paths | | Session | id, workspace_id, owner_id, expires_at, consumed_at; no reusable token in Git | +## Host protocol + +[Closed draft v1](host-protocol/README.md) and its +[structural schema](host-protocol/message.schema.json) define pure validation, +trusted-context and replay-disposition boundaries. No transport/provider/dispatcher +or durable ledger capability is implied. + ## Lifecycle and idempotency Desired states: running, stopped. Observed states: pending, provisioning, running, stopping, stopped, failed, unknown. Unknown is required on stale/disconnected host. diff --git a/contracts/host-protocol/README.md b/contracts/host-protocol/README.md index 5dd3038a..e501e290 100644 --- a/contracts/host-protocol/README.md +++ b/contracts/host-protocol/README.md @@ -200,3 +200,28 @@ intent same generation; observation defaults/removal rules; admitted-envelope ov without identity change/partial emission; secret canaries. A test provider sentinel must remain untouched for rejected/replayed/refused inputs. It proves pure gate order, not a real dispatcher, durable restart/race, TLS, browser or Docker capability. + +## Concrete pure API and fixture provenance + +Elixir `decode(raw, %Context{})` and `validate(native, %Context{})` return +`{:ok, %{message, canonical, native_bindings, native_result, disposition}}` or a +fixed `{:error, code}`. Go `Decode(raw, Context)` and `Validate(native, Context)` +return the same data via `Validated` or fixed `Error`. Neither API performs lookup. + +Peer has kind (control_plane for command, host for result), owner_id and host_id. +Snapshot supplies expected closed command, owner-inclusive request_bytes/request_hash, +exact native_bindings, current_intent, status, optional previously recorded command_bytes, +optional normalized terminal_result and per-runtime provider/effective-config expectations. +It is a trusted caller-created native type, with no wire decoder or credential fallback. +The expected command and supplied bytes/bindings remain independently compared. +Identity/scope negative tests use independent expectations; authored boundary/action +fixtures explicitly supply expected snapshots. These fixtures do not prove production +context construction. + +[Vectors](vectors.json) contain independently authored raw strings and expected codes, +plus Python-standard-library sorted/scalar UTF-8 canonical request/whole-command anchors +and SHA-256 digests. Python was used offline during authoring, never at product runtime. +The same artifact runs in both suites; native constructors are independently written in +each language. Four other lifecycle fixtures use distinct operation/key/command IDs and +reuse runtime generation only under a new explicit intent. Schema is structural; README +and validators additionally enforce byte budgets, normalization, scopes and state. diff --git a/contracts/host-protocol/message.schema.json b/contracts/host-protocol/message.schema.json new file mode 100644 index 00000000..59ca589d --- /dev/null +++ b/contracts/host-protocol/message.schema.json @@ -0,0 +1,1022 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:workharbor:host-protocol:draft:1", + "title": "WorkHarbor unpublished host protocol v1", + "description": "Closed structural profile only. README and native validators additionally enforce strict raw JSON, canonical 1 MiB budget, lexical identities, normalized ordering, trusted scope and immutable state/replay bindings. No provider effect is authorized.", + "oneOf": [ + { + "type": "object", + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "kind": { + "const": "command" + }, + "command_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "owner_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "host_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "workspace_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "idempotency_key": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "request_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "intent_version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "bindings": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "candidate": { + "anyOf": [ + { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "target", + "candidate" + ], + "additionalProperties": false + }, + "request": { + "oneOf": [ + { + "type": "object", + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "workspace_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "action": { + "const": "create" + }, + "parameters": { + "type": "object", + "properties": { + "host_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "config": { + "type": "object", + "properties": { + "repository_identity": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "submodules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + } + }, + "required": [ + "path", + "commit" + ], + "additionalProperties": false + } + } + }, + "required": [ + "repository_identity", + "commit", + "submodules" + ], + "additionalProperties": false + } + }, + "required": [ + "host_id", + "config" + ], + "additionalProperties": false + } + }, + "required": [ + "schema_version", + "workspace_id", + "action", + "parameters" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "workspace_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "action": { + "const": "rebuild" + }, + "parameters": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "repository_identity": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "submodules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + } + }, + "required": [ + "path", + "commit" + ], + "additionalProperties": false + } + } + }, + "required": [ + "repository_identity", + "commit", + "submodules" + ], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + }, + "required": [ + "schema_version", + "workspace_id", + "action", + "parameters" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "workspace_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "action": { + "const": "start" + }, + "parameters": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "schema_version", + "workspace_id", + "action", + "parameters" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "workspace_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "action": { + "const": "stop" + }, + "parameters": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "schema_version", + "workspace_id", + "action", + "parameters" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "workspace_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "action": { + "const": "remove_runtime" + }, + "parameters": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "schema_version", + "workspace_id", + "action", + "parameters" + ], + "additionalProperties": false + } + ] + }, + "execution_config": { + "anyOf": [ + { + "type": "object", + "properties": { + "repository_identity": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "submodules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + } + }, + "required": [ + "path", + "commit" + ], + "additionalProperties": false + } + } + }, + "required": [ + "repository_identity", + "commit", + "submodules" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "approval_ref": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "schema_version", + "kind", + "command_id", + "owner_id", + "host_id", + "workspace_id", + "operation_id", + "idempotency_key", + "request_sha256", + "intent_version", + "bindings", + "request", + "execution_config", + "approval_ref" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "request": { + "properties": { + "action": { + "const": "create" + } + } + } + } + }, + "then": { + "properties": { + "execution_config": { + "type": "null" + }, + "approval_ref": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "bindings": { + "type": "object", + "properties": { + "target": { + "type": "null" + }, + "candidate": { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + } + }, + "required": [ + "target", + "candidate" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "request": { + "properties": { + "action": { + "const": "rebuild" + } + } + } + } + }, + "then": { + "properties": { + "execution_config": { + "type": "null" + }, + "approval_ref": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "bindings": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "candidate": { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + } + }, + "required": [ + "target", + "candidate" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "request": { + "properties": { + "action": { + "const": "start" + } + } + } + } + }, + "then": { + "properties": { + "execution_config": { + "type": "object", + "properties": { + "repository_identity": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "submodules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + } + }, + "required": [ + "path", + "commit" + ], + "additionalProperties": false + } + } + }, + "required": [ + "repository_identity", + "commit", + "submodules" + ], + "additionalProperties": false + }, + "approval_ref": { + "type": "null" + }, + "bindings": { + "type": "object", + "properties": { + "target": { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + "candidate": { + "type": "null" + } + }, + "required": [ + "target", + "candidate" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "request": { + "properties": { + "action": { + "const": "stop" + } + } + } + } + }, + "then": { + "properties": { + "execution_config": { + "type": "null" + }, + "approval_ref": { + "type": "null" + }, + "bindings": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "candidate": { + "type": "null" + } + }, + "required": [ + "target", + "candidate" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "request": { + "properties": { + "action": { + "const": "remove_runtime" + } + } + } + } + }, + "then": { + "properties": { + "execution_config": { + "type": "null" + }, + "approval_ref": { + "type": "null" + }, + "bindings": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "candidate": { + "type": "null" + } + }, + "required": [ + "target", + "candidate" + ], + "additionalProperties": false + } + } + } + } + ] + }, + { + "type": "object", + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "kind": { + "const": "result" + }, + "command_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "owner_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "host_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "workspace_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "operation_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "idempotency_key": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "request_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "intent_version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "bindings": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "candidate": { + "anyOf": [ + { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + }, + "required": [ + "runtime_id", + "generation" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "target", + "candidate" + ], + "additionalProperties": false + }, + "outcome": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "const": "succeeded" + }, + "code": { + "const": "ok" + } + }, + "required": [ + "status", + "code" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "code": { + "const": "operation_failed" + } + }, + "required": [ + "status", + "code" + ], + "additionalProperties": false + } + ] + }, + "observation": { + "type": "object", + "properties": { + "runtime_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "observed_state": { + "enum": [ + "pending", + "provisioning", + "running", + "stopping", + "stopped", + "failed", + "unknown" + ] + }, + "provider_instance_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "effective_config": { + "anyOf": [ + { + "type": "object", + "properties": { + "repository_identity": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "submodules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + } + }, + "required": [ + "path", + "commit" + ], + "additionalProperties": false + } + } + }, + "required": [ + "repository_identity", + "commit", + "submodules" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "deleted": { + "type": "boolean" + } + }, + "required": [ + "runtime_id", + "generation", + "observed_state" + ], + "additionalProperties": false + } + }, + "required": [ + "schema_version", + "kind", + "command_id", + "owner_id", + "host_id", + "workspace_id", + "operation_id", + "idempotency_key", + "request_sha256", + "intent_version", + "bindings", + "outcome" + ], + "additionalProperties": false + } + ] +} diff --git a/contracts/host-protocol/vectors.json b/contracts/host-protocol/vectors.json new file mode 100644 index 00000000..54369941 --- /dev/null +++ b/contracts/host-protocol/vectors.json @@ -0,0 +1,330 @@ +{ + "schema_version": 1, + "request_bytes": "{\"action\":\"create\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_sha256": "c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7", + "command_canonical": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "command_sha256": "9b5ebebc9504dbf577ac3922358f4c2fb67fe33d513ceafd76c15fa69d143279", + "cases": [ + { + "id": "valid-create", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "ok", + "disposition": "new_eligible" + }, + { + "id": "schema_version-2", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":2,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "unsupported_version", + "disposition": null + }, + { + "id": "schema_version-1", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":\"1\",\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "kind-shell", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"shell\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "intent_version-True", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":true,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "intent_version-0", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":0,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "intent_version-9223372036854775808", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":9223372036854775808,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "owner_id-00000000-0000-0000-0000-000000000008", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000008\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "unauthorized_scope", + "disposition": null + }, + { + "id": "host_id-00000000-0000-0000-0000-000000000008", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000008\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000008\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "unauthorized_scope", + "disposition": null + }, + { + "id": "operation_id-00000000-0000-0000-0000-000000000008", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000008\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "command_id-00000000-0000-0000-0000-000000000008", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000008\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "request_sha256-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "unknown-privileged-bag", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"argv\":[\"sh\",\"-c\",\"private-canary\"],\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "required-null-slot", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "create-config-in-wrong-slot", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "missing-approval-ref", + "raw": "{\"approval_ref\":null,\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "float-generation", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1.0,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_json", + "disposition": null + }, + { + "id": "wrong-generation", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":2,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "native-binding-alias", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "null-submodules", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":null},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "wire-default-not-materialized", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\"},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "untrusted-request-owner", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "reserved-delete-data", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"delete_data\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "decoded-root-duplicate", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\",\"\\u006bind\":\"command\"}", + "code": "invalid_json", + "disposition": null + }, + { + "id": "nested-duplicate", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[],\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_json", + "disposition": null + }, + { + "id": "float", + "raw": "1.0", + "code": "invalid_json", + "disposition": null + }, + { + "id": "exponent", + "raw": "1e0", + "code": "invalid_json", + "disposition": null + }, + { + "id": "surrogate", + "raw": "{\"x\":\"\\ud800\"}", + "code": "invalid_json", + "disposition": null + }, + { + "id": "trailing", + "raw": "{}{}", + "code": "invalid_json", + "disposition": null + }, + { + "id": "empty", + "raw": "", + "code": "invalid_json", + "disposition": null + }, + { + "id": "nonobject", + "raw": "[]", + "code": "invalid_json", + "disposition": null + }, + { + "id": "unknown-field-canary", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"secret\":\"private-canary\",\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "bad-path", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[{\"path\":\"../secret\",\"commit\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"}]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "literal-repository-33", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"HTTPS://Example.test:000443/team.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "literal-repository-34", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"ssh://git@[2001:db8::1]:0022/team.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "literal-repository-35", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@[::ffff:192.0.2.1]:team.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "literal-repository-36", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"https://user:secret@example.test/team.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "literal-repository-37", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"https://example.test:0/team.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "literal-repository-38", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"https://example.test/%zz\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "literal-repository-39", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team.git?token=secret\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "literal-repository-40", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"https://example_test/team.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "invalid_message", + "disposition": null + }, + { + "id": "literal-repository-41", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"https://example.test./team.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "literal-repository-42", + "raw": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"},\"target\":null},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":1,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"create\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/é.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\"},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"c1b01b3257bff722713fb5014fd471caf59e14d27ec4d06fd33088a337dfdcb7\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + }, + { + "id": "same-operation-key-changed-action", + "raw": "{\"approval_ref\":null,\"bindings\":{\"candidate\":null,\"target\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"}},\"command_id\":\"00000000-0000-0000-0000-000000000005\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"create:1\",\"intent_version\":2,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000004\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"stop\",\"parameters\":{},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"fca641b8ef17a9e9ffcc7c035d94a5f85b880e5a2f45291eccac3b437105b556\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "code": "identity_conflict", + "disposition": null + } + ], + "actions": [ + { + "action": "start", + "command_canonical": "{\"approval_ref\":null,\"bindings\":{\"candidate\":null,\"target\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"}},\"command_id\":\"00000000-0000-0000-0000-000000000020\",\"execution_config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]},\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"start:2\",\"intent_version\":2,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000010\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"start\",\"parameters\":{},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"e93a5e46041a7920aff7748201f815167e795a643c533b7afae79a101b52af61\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_bytes": "{\"action\":\"start\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"parameters\":{},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_sha256": "e93a5e46041a7920aff7748201f815167e795a643c533b7afae79a101b52af61", + "native_bindings": { + "target": { + "id": "00000000-0000-0000-0000-000000000006", + "generation": 1 + }, + "candidate": null + } + }, + { + "action": "stop", + "command_canonical": "{\"approval_ref\":null,\"bindings\":{\"candidate\":null,\"target\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"}},\"command_id\":\"00000000-0000-0000-0000-000000000021\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"stop:2\",\"intent_version\":2,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000011\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"stop\",\"parameters\":{},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"fca641b8ef17a9e9ffcc7c035d94a5f85b880e5a2f45291eccac3b437105b556\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_bytes": "{\"action\":\"stop\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"parameters\":{},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_sha256": "fca641b8ef17a9e9ffcc7c035d94a5f85b880e5a2f45291eccac3b437105b556", + "native_bindings": { + "target": { + "id": "00000000-0000-0000-0000-000000000006", + "generation": 1 + }, + "candidate": null + } + }, + { + "action": "remove_runtime", + "command_canonical": "{\"approval_ref\":null,\"bindings\":{\"candidate\":null,\"target\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"}},\"command_id\":\"00000000-0000-0000-0000-000000000022\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"remove_runtime:2\",\"intent_version\":2,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000012\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"remove_runtime\",\"parameters\":{},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"4fe92094990dcc75b6fa9f020a1ea5d76917103e830e13a832de146903a432ad\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_bytes": "{\"action\":\"remove_runtime\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"parameters\":{},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_sha256": "4fe92094990dcc75b6fa9f020a1ea5d76917103e830e13a832de146903a432ad", + "native_bindings": { + "target": { + "id": "00000000-0000-0000-0000-000000000006", + "generation": 1 + }, + "candidate": null + } + }, + { + "action": "rebuild", + "command_canonical": "{\"approval_ref\":\"00000000-0000-0000-0000-000000000007\",\"bindings\":{\"candidate\":{\"generation\":2,\"runtime_id\":\"00000000-0000-0000-0000-000000000008\"},\"target\":{\"generation\":1,\"runtime_id\":\"00000000-0000-0000-0000-000000000006\"}},\"command_id\":\"00000000-0000-0000-0000-000000000023\",\"execution_config\":null,\"host_id\":\"00000000-0000-0000-0000-000000000002\",\"idempotency_key\":\"rebuild:2\",\"intent_version\":2,\"kind\":\"command\",\"operation_id\":\"00000000-0000-0000-0000-000000000013\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"request\":{\"action\":\"rebuild\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]}},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"},\"request_sha256\":\"9ea5a57c6cefd1c6da270e665d86a175fb10789c1bc8cf628e393d2d8704fa04\",\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_bytes": "{\"action\":\"rebuild\",\"owner_id\":\"00000000-0000-0000-0000-000000000001\",\"parameters\":{\"config\":{\"commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"repository_identity\":\"git@example.test:team/workspace.git\",\"submodules\":[]}},\"schema_version\":1,\"workspace_id\":\"00000000-0000-0000-0000-000000000003\"}", + "request_sha256": "9ea5a57c6cefd1c6da270e665d86a175fb10789c1bc8cf628e393d2d8704fa04", + "native_bindings": { + "target": { + "id": "00000000-0000-0000-0000-000000000006", + "generation": 1 + }, + "candidate": { + "id": "00000000-0000-0000-0000-000000000008", + "generation": 2 + } + } + } + ] +} diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index d23a5ba1..4c872992 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -530,7 +530,27 @@ "host_agent": [ "github.com/wstein/workharbor-nxt/apps/host_agent/cmd/workharbor-agent/TestScaffoldRefusesProductCommands", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestLexicalPathNativeDomain" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestLexicalPathNativeDomain", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestIndependentIdentityAnchors", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestNativeClosedTypes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestTrustedScopeBeforeSnapshotDisclosure", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestPendingReplayNeverPermitsEffect", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestCompletedReplayAfterNewerIntent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestChangedReplayAndSameRuntimeStaleIntent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestResultsAndObservationDefaults", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestMalformedObservationDoesNotBecomeNull", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestResultScopeStatusAndTerminalImmutability", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestObservationBindingsConfigAndProviderImmutable", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestResultCommandOnlyFieldsRejected", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestBoundedNativeCanonicalBudgetAndRedaction", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestRawLimitsAndNativeCycles", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestNoProviderEffectForRejectedOrLookup", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestAllLifecycleBranchesAndSameRuntimeNewIntent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestExactCanonicalBudgetAndCounterMaximum", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestNormalizedResultBudgetIncludesOptionalDefaults", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestWireObservationConfigMustAlreadyBeNormalized", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestWorkspaceScopeUsesExpectedAssignment" ], "api_client": [ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", @@ -624,7 +644,27 @@ "WorkHarborWeb.AuthenticationTest/test socket and debugger paths cannot bypass ingress or session checks", "WorkHarborWeb.AuthenticationTest/test strict API login rejects alternate parsers duplicate fields and mixed credentials", "WorkHarborWeb.AuthenticationTest/test upgrade errors never reflect attacker header canary", - "WorkHarborWeb.AuthenticationTest/test viewer wrapping initializes fresh dashboard CSRF without touching credentials or owner" + "WorkHarborWeb.AuthenticationTest/test viewer wrapping initializes fresh dashboard CSRF without touching credentials or owner", + "WorkHarbor.HostProtocolTest/test independent shared raw vectors preserve codes and dispositions", + "WorkHarbor.HostProtocolTest/test independent canonical anchors separate request hash and command identity", + "WorkHarbor.HostProtocolTest/test native types exact counters and improper lists reject", + "WorkHarbor.HostProtocolTest/test trusted peer and missing expectation reject before lookup disclosure", + "WorkHarbor.HostProtocolTest/test pending replay and executing command never permit another effect", + "WorkHarbor.HostProtocolTest/test completed historical command replay ignores newer intent", + "WorkHarbor.HostProtocolTest/test changed replay and same runtime stale intent reject", + "WorkHarbor.HostProtocolTest/test observation omission and optional defaults preserve native nil", + "WorkHarbor.HostProtocolTest/test malformed observation values never become null", + "WorkHarbor.HostProtocolTest/test result status and immutable terminal replay are guarded", + "WorkHarbor.HostProtocolTest/test runtime scope provider config and removal observations are guarded", + "WorkHarbor.HostProtocolTest/test result closed shape excludes command only payload fields", + "WorkHarbor.HostProtocolTest/test native canonical budget aborts before emission and redacts canaries", + "WorkHarbor.HostProtocolTest/test raw size wins invalid Unicode and native depth is bounded", + "WorkHarbor.HostProtocolTest/test provider sentinel remains untouched for rejection and lookup", + "WorkHarbor.HostProtocolTest/test every lifecycle branch and new intent on existing runtime validate", + "WorkHarbor.HostProtocolTest/test canonical exact byte budget and maximum counter are independent", + "WorkHarbor.HostProtocolTest/test normalized result budget includes optional default overhead", + "WorkHarbor.HostProtocolTest/test wire observation effective config is already normalized", + "WorkHarbor.HostProtocolTest/test workspace scope matches separately authorized expectation" ] } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 37a7e4af..f013a1db 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -680,3 +680,60 @@ The complete independently CLEAR R2 plus actual-interface alignment is consolida in contracts/host-protocol/README.md before failure-first tests or behavior. Same Sol reviewer must confirm faithful consolidation. No T007 implementation acceptance, transport, provider, dispatcher or durable replay ledger is claimed. + +## T007 pure host protocol — author candidate, review pending + +Contract consolidation was independently Sol CLEAR before tests/behavior and committed +at 5d81ae5. No owner-auth action registry, provider, transport, dispatcher or durable +command ledger is changed. First-create/mTLS/trust expectations remain supplied native +context; dispositions do not authorize execution. + +Actual initial RED: Elixir `mix test test/workharbor/host_protocol_test.exs` exited 1 +with undefined HostProtocol.Peer; Go private-cache `go test ./internal/protocol` exited 1 +with no non-test Go files. A preceding Go default-cache invocation failed permission +setup and is not counted as behavior RED. Shared fixtures and both native suites preceded +implementation. Two fixture-construction mistakes (inner create-host mismatch; escaped +duplicate spelled kkind) were corrected without weakening expectations. + +Early independent review found missing final normalized-message byte-budget check and +permissive observation effective-config normalization. New regressions failed first in +both languages: Go two failures/exit1; Elixir17/19/exit2. Fixed by budgeting the final +normalized message before canonical allocation and requiring already-normalized wire +effective config, including schema-required submodules. Legitimate runtime reuse fixtures +now have distinct operation/key/command IDs; changed same-key/action remains a negative. +Focused Go and Elixir19/19 then passed. + +The artifact has 44 independently specified raw cases, four additional action +fixtures, separate request/command hash anchors, 20 required Go IDs and 20 new required +Phoenix IDs; every previous required ID is retained. Exact-candidate pristine +source/manifest/application results and independent disposition follow separately. +No full combined canonical, live TLS/browser/host acceptance is claimed. + +Additional failure-first scope check classified a foreign workspace as identity_conflict +(Go exit1, selected Elixir exit2) rather than authorization denial. Explicit expected +workspace scope admission now precedes operation identity comparison. Wrong peer plus +nonnull malformed snapshot also proves peer rejection before expectation inspection. +One initial selected Elixir line ran the preceding green test; it is not counted as RED. +Fixture bytes are hash-pinned in both independently written native suites. + +Author-cache full Phoenix runs retained two inherited 4300-digit failures (103/105) +from stale test-environment Jason artifacts. Default-environment force compilation +did not repair the separate test build. Native Mix lock permission failures and a +Go whr default-output source write denial are setup failures, not behavior results. +Explicit private Go output succeeds; exact-candidate verification uses a fresh private +Phoenix build with accepted compile-time Jason limit4301, without source changes. + +Fresh private `MIX_ENV=test MIX_BUILD_PATH=/private/tmp/workharbor-t007-fresh-build` +format/compile warnings-as-errors/full tests passed: 106/106 including all86 inherited +and20 T007 cases. Locked Jason was freshly compiled with limit4301; no auth source +was changed. Exact committed-candidate source/application evidence remains the next +gate; this author-build result is not pristine-source or independent review evidence. + +Final author application gates all exited0; event inventory validation passed: {'host_agent': 132, 'api_client': 162, 'whr': 1, 'control_plane': 106}. +Logs/argv/exits: /private/tmp/workharbor-t007-local-final-_o3775ll/checks.json. +All20 new protocol IDs per language and every inherited ID are present. +An author Go multi-package build used a nonexistent non-directory output and failed; +canonical precreated-directory output corrected it. One gofmt invocation used the +wrong cwd-relative path and was corrected. Neither failed command is counted passing. +No helper policy/runner behavior changed; root reserves full make check for combined +head unless new concern requires it. Exact-candidate pristine gates follow externally. From 4b06a52c5e3c39bb5396111bd4b1ddf610fc4688 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 20:12:39 +0200 Subject: [PATCH 17/51] test(security): pin HTTP1-only authenticated transport contract Record accepted T007 scope and the independently reviewed S001 blocker before T008. Reproduce actual TLS HTTP2 exposure and unsafe configuration acceptance before mitigation while preserving real HTTP1 authentication and WSS gates. Refs: #72 Co-authored-by: Codex --- .../transport_security_test.exs | 423 ++++++++++++++++++ docs/development/ci-acceptance.json | 121 ++--- docs/exec-plans/active/installable-pilot.md | 32 ++ .../contracts/transport-security.md | 72 +++ specs/006-whr-cli/tasks.md | 10 +- 5 files changed, 598 insertions(+), 60 deletions(-) create mode 100644 apps/control_plane/test/workharbor_web/transport_security_test.exs create mode 100644 specs/006-whr-cli/contracts/transport-security.md diff --git a/apps/control_plane/test/workharbor_web/transport_security_test.exs b/apps/control_plane/test/workharbor_web/transport_security_test.exs new file mode 100644 index 00000000..b0022fa7 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/transport_security_test.exs @@ -0,0 +1,423 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.TransportSecurityTest do + use ExUnit.Case, async: false + alias WorkHarborWeb.{Endpoint, RuntimeSecurity} + + setup tags do + dir = Path.join(System.tmp_dir!(), "workharbor-transport-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + + certs = + :public_key.pkix_test_data(%{ + root: [digest: :sha256, key: {:rsa, 2048, 65537}], + peer: [ + digest: :sha256, + key: {:rsa, 2048, 65537}, + extensions: [{:Extension, {2, 5, 29, 17}, false, [dNSName: ~c"localhost"]}] + ] + }) + + certfile = Path.join(dir, "cert.pem") + keyfile = Path.join(dir, "key.pem") + File.write!(certfile, :public_key.pem_encode([{:Certificate, certs[:cert], :not_encrypted}])) + + File.write!( + keyfile, + :public_key.pem_encode([Tuple.insert_at(certs[:key], 2, :not_encrypted)]) + ) + + File.chmod!(keyfile, 0o600) + on_exit(fn -> File.rm_rf!(dir) end) + + ctx = %{ + dir: dir, + certfile: certfile, + keyfile: keyfile, + cert: certs[:cert], + cacerts: certs[:cacerts] + } + + if tags[:tls], do: start_tls(ctx), else: ctx + end + + # Each profile is evaluated from the real runtime file; the listener tests use + # these exact adapter child specs, so changing runtime protocol options is caught. + defp runtime(ctx, env, port) do + values = %{ + "PHX_SERVER" => "true", + "WORKHARBOR_ORIGIN" => "https://localhost:#{port}", + "WORKHARBOR_BIND" => "127.0.0.1", + "WORKHARBOR_TLS_CERT_FILE" => ctx.certfile, + "WORKHARBOR_TLS_KEY_FILE" => ctx.keyfile, + "SECRET_KEY_BASE" => String.duplicate("s", 64), + "DATABASE_PATH" => Path.join(ctx.dir, "prod.db") + } + + prior = Map.new(values, fn {k, _} -> {k, System.get_env(k)} end) + + try do + System.put_env(values) + base = Config.Reader.read!(Path.expand("../../config/config.exs", __DIR__), env: env) + cfg = Config.Reader.read!(Path.expand("../../config/runtime.exs", __DIR__), env: env) + + {Keyword.merge(base[:workharbor][Endpoint], cfg[:workharbor][Endpoint]), + cfg[:workharbor][:owner_http]} + after + Enum.each(prior, fn {k, v} -> if v, do: System.put_env(k, v), else: System.delete_env(k) end) + end + end + + defp independent(ctx) do + [ + server: true, + adapter: Bandit.PhoenixAdapter, + debug_errors: false, + code_reloader: false, + http: false, + secret_key_base: String.duplicate("x", 64), + https: [ + ip: {127, 0, 0, 1}, + port: 443, + certfile: ctx.certfile, + keyfile: ctx.keyfile, + http_1_options: [enabled: true], + http_2_options: [enabled: false] + ] + ] + end + + defp refused(config) do + assert_raise ArgumentError, "unsafe authentication server configuration", fn -> + RuntimeSecurity.validate!(config, + origin: "https://localhost", + authorities: [{"localhost", 443}] + ) + end + end + + test "active transport profile requires exact explicit protocol settings and adapter", c do + cfg = independent(c) + + assert :ok = + RuntimeSecurity.validate!(cfg, + origin: "https://localhost", + authorities: [{"localhost", 443}] + ) + + for {key, variants} <- [ + http_1_options: [ + nil, + [], + [enabled: false], + [enabled: true, enabled: false], + %{enabled: true} + ], + http_2_options: [ + nil, + [], + [enabled: true], + [enabled: false, enabled: true], + %{enabled: false} + ] + ], + value <- variants do + refused(Keyword.put(cfg, :https, Keyword.put(cfg[:https], key, value))) + end + + for key <- [:http_1_options, :http_2_options] do + refused(Keyword.put(cfg, :https, Keyword.delete(cfg[:https], key))) + refused(Keyword.put(cfg, :https, [{key, cfg[:https][key]} | cfg[:https]])) + end + + refused(Keyword.put(cfg, :adapter, nil)) + refused(Keyword.delete(cfg, :adapter)) + refused(Keyword.put(cfg, :adapter, __MODULE__)) + refused(Keyword.put(cfg, :https, %{http_2_options: [enabled: false]})) + assert :ok = RuntimeSecurity.validate!([server: false], []) + end + + test "active transport profile refuses lower layer protocol and listener overrides", c do + cfg = independent(c) + + for patch <- [ + [thousand_island_options: [handler_options: %{http_2_enabled: true}]], + [thousand_island_options: [handler_module: Bandit.HTTP2.Handler]], + [thousand_island_options: [transport_options: [alpn_preferred_protocols: ["h2"]]]], + [thousand_island_options: [transport_module: ThousandIsland.Transports.TCP]], + [scheme: :http], + [plug: __MODULE__], + [alpn_preferred_protocols: ["h2"]], + [unknown_option: true] + ] do + refused(Keyword.put(cfg, :https, cfg[:https] ++ patch)) + end + + refused(Keyword.put(cfg, :https, [{:port, 8443} | cfg[:https]])) + refused(Keyword.put(cfg, :https, Keyword.put(cfg[:https], :port, 0))) + end + + test "real runtime activation is HTTP1 only in every authenticated environment", c do + for env <- [:dev, :test, :prod] do + {cfg, owner} = runtime(c, env, 443) + assert cfg[:https][:http_1_options] == [enabled: true] + assert cfg[:https][:http_2_options] == [enabled: false] + + assert cfg[:adapter] == Bandit.PhoenixAdapter + assert :ok = RuntimeSecurity.validate!(cfg, owner) + end + end + + defp start_tls(c) do + prior_owner = Application.fetch_env!(:workharbor, :owner_http) + + prior_endpoint = + :ets.tab2list(Endpoint) + |> Enum.filter(&(tuple_size(&1) == 2)) + |> Keyword.delete(:__config__) + + {runtime, _} = runtime(c, :test, 0) + cfg = Application.fetch_env!(:workharbor, Endpoint) |> Keyword.merge(runtime) + [spec] = Bandit.PhoenixAdapter.child_specs(Endpoint, Keyword.put(cfg, :otp_app, :workharbor)) + pid = start_supervised!(spec) + {:ok, {{127, 0, 0, 1}, port}} = ThousandIsland.listener_info(pid) + origin = "https://localhost:#{port}" + owner = [origin: origin, authorities: [{"localhost", port}]] + + effective = + cfg + |> Keyword.put(:https, Keyword.put(cfg[:https], :port, port)) + |> Keyword.put(:url, scheme: "https", host: "localhost", port: port) + + assert :ok = RuntimeSecurity.validate!(effective, owner) + Application.put_env(:workharbor, :owner_http, owner) + Endpoint.config_change([{Endpoint, effective}], []) + # Network request processes use the named repository. Share its isolated + # sandbox transaction instead of relying on process-local dynamic_repo. + WorkHarbor.Repo.put_dynamic_repo(WorkHarbor.Repo) + :ok = Ecto.Adapters.SQL.Sandbox.checkout(WorkHarbor.Repo) + :ok = Ecto.Adapters.SQL.Sandbox.mode(WorkHarbor.Repo, {:shared, self()}) + {owner_id, secret} = WorkHarbor.AuthDatabase.fixture!() + + on_exit(fn -> + Application.put_env(:workharbor, :owner_http, prior_owner) + Endpoint.config_change([{Endpoint, prior_endpoint}], []) + end) + + Map.merge(c, %{port: port, origin: origin, owner_id: owner_id, secret: secret}) + end + + defp connect(c, alpn, hostname \\ ~c"localhost") do + opts = [ + mode: :binary, + active: false, + verify: :verify_peer, + cacerts: c.cacerts, + server_name_indication: hostname, + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] + ] + + opts = if alpn, do: Keyword.put(opts, :alpn_advertised_protocols, alpn), else: opts + :ssl.connect({127, 0, 0, 1}, c.port, opts, 2000) + end + + @tag :tls + test "actual TLS negotiates only HTTP1 and verifies certificate identity", c do + assert {:ok, sock} = connect(c, ["h2", "http/1.1"]) + + try do + assert {:ok, "http/1.1"} = :ssl.negotiated_protocol(sock) + assert {:ok, c.cert} == :ssl.peercert(sock) + after + :ssl.close(sock) + end + + assert {:error, _} = connect(c, ["http/1.1"], ~c"foreign.test") + + case connect(c, ["h2"]) do + {:error, _} -> + :ok + + {:ok, sock} -> + try do + refute :ssl.negotiated_protocol(sock) == {:ok, "h2"} + assert :ok = :ssl.send(sock, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") + assert {:error, :closed} = :ssl.recv(sock, 0, 2000) + after + :ssl.close(sock) + end + end + end + + @tag :tls + test "actual TLS rejects HTTP2 prior knowledge with and without ALPN", c do + for alpn <- [nil, ["http/1.1"]] do + assert {:ok, sock} = connect(c, alpn) + + try do + assert :ok = :ssl.send(sock, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") + assert {:error, :closed} = :ssl.recv(sock, 0, 2000) + after + :ssl.close(sock) + end + end + end + + defp response(sock, data \\ "") do + case :binary.split(data, "\r\n\r\n") do + [head, body] -> + [status | lines] = String.split(head, "\r\n") + + headers = + Enum.map(lines, fn line -> + [k, v] = String.split(line, ":", parts: 2) + {String.downcase(k), String.trim(v)} + end) + + code = status |> String.split(" ") |> Enum.at(1) |> String.to_integer() + + length = + headers + |> List.keyfind("content-length", 0, {"content-length", "0"}) + |> elem(1) + |> String.to_integer() + + if code == 101 or byte_size(body) >= length do + %{status: code, headers: headers, body: body} + else + assert {:ok, more} = :ssl.recv(sock, 0, 2000) + response(sock, data <> more) + end + + _ -> + assert {:ok, more} = :ssl.recv(sock, 0, 2000) + response(sock, data <> more) + end + end + + defp request(c, method, path, headers \\ [], body \\ "") do + assert {:ok, sock} = connect(c, ["http/1.1"]) + + try do + assert {:ok, "http/1.1"} = :ssl.negotiated_protocol(sock) + + headers = [ + {"host", "localhost:#{c.port}"}, + {"content-length", "#{byte_size(body)}"} | headers + ] + + wire = + method <> + " " <> + path <> + " HTTP/1.1\r\n" <> + Enum.map_join(headers, "", fn {k, v} -> k <> ": " <> v <> "\r\n" end) <> "\r\n" <> body + + assert :ok = :ssl.send(sock, wire) + response(sock) + after + :ssl.close(sock) + end + end + + defp cookie(r, name) do + {_, value} = + Enum.find(r.headers, fn {k, v} -> + k == "set-cookie" and String.starts_with?(v, name <> "=") + end) + + value |> String.split(";", parts: 2) |> hd() + end + + defp csrf(r), do: Regex.run(~r/name="_csrf_token" value="([^"]+)"/, r.body) |> Enum.at(1) + + defp browser(c, kind) do + login = request(c, "GET", "/login/" <> kind) + assert login.status == 200 + + form = + URI.encode_query(%{ + "owner_id" => c.owner_id, + "owner_secret" => c.secret, + "_csrf_token" => csrf(login) + }) + + signed = + request( + c, + "POST", + "/login/" <> kind, + [ + {"content-type", "application/x-www-form-urlencoded"}, + {"origin", c.origin}, + {"cookie", cookie(login, "__Host-workharbor_login")} + ], + form + ) + + assert signed.status == 303 + selected = cookie(signed, "__Host-workharbor_" <> kind) + page = request(c, "GET", "/" <> kind, [{"cookie", selected}]) + assert page.status == 200 + {cookie(page, "__Host-workharbor_" <> kind), csrf(page)} + end + + @tag :tls + test "actual HTTP1 preserves browser form and CLI bearer authentication", c do + {selected, token} = browser(c, "owner") + + assert request(c, "GET", "/api/v1/auth/status", [ + {"cookie", selected}, + {"x-workharbor-context", "owner"}, + {"origin", c.origin}, + {"x-csrf-token", token} + ]).status == 200 + + body = Jason.encode!(%{schema_version: 1, owner_id: c.owner_id, owner_secret: c.secret}) + login = request(c, "POST", "/api/v1/auth/login", [{"content-type", "application/json"}], body) + assert login.status == 200 + bearer = Jason.decode!(login.body)["token"] + + assert request(c, "GET", "/api/v1/auth/status", [{"authorization", "Bearer " <> bearer}]).status == + 200 + + assert request(c, "GET", "/api/v1/auth/status").status == 401 + end + + defp upgrade(c, kind, csrf, selected, origin) do + path = + "/#{kind}/live/websocket?" <> URI.encode_query(%{"_csrf_token" => csrf, "vsn" => "2.0.0"}) + + headers = [ + {"cookie", selected}, + {"connection", "upgrade"}, + {"upgrade", "websocket"}, + {"sec-websocket-key", Base.encode64(<<0::128>>)}, + {"sec-websocket-version", "13"} + ] + + headers = if origin, do: [{"origin", origin} | headers], else: headers + request(c, "GET", path, headers) + end + + @tag :tls + test "actual WSS preserves selected session CSRF origin and revocation gates", c do + {own, own_csrf} = browser(c, "owner") + {dash, dash_csrf} = browser(c, "dashboard") + assert upgrade(c, "owner", own_csrf, own, c.origin).status == 101 + assert upgrade(c, "dashboard", dash_csrf, dash, c.origin).status == 101 + assert upgrade(c, "owner", own_csrf, own, nil).status == 403 + assert upgrade(c, "owner", own_csrf, own, "https://foreign.test").status == 403 + assert upgrade(c, "owner", dash_csrf, own, c.origin).status == 403 + assert upgrade(c, "owner", own_csrf, dash, c.origin).status == 403 + raw_cookie = own |> String.split("=", parts: 2) |> Enum.at(1) |> URI.decode() + conn = %{Plug.Test.conn(:get, c.origin) | secret_key_base: Endpoint.config(:secret_key_base)} + options = WorkHarborWeb.BrowserSession.options(:owner) |> Plug.Session.COOKIE.init() + {_, session} = Plug.Session.COOKIE.get(conn, raw_cookie, options) + {:ok, actor} = WorkHarbor.Accounts.authenticate(session["credential"], "owner_browser") + assert :ok = WorkHarbor.Accounts.revoke(actor, actor.credential_id) + assert upgrade(c, "owner", own_csrf, own, c.origin).status == 403 + assert upgrade(c, "dashboard", dash_csrf, dash, c.origin).status == 101 + end +end diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 4c872992..72c2658a 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -559,56 +559,71 @@ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestIntegerAndContainerLimits" ], "control_plane": [ - "WorkHarborWeb.ErrorHTMLTest/test renders 404.html", - "WorkHarborWeb.ErrorHTMLTest/test renders 500.html", - "WorkHarborWeb.ErrorJSONTest/test renders 404", - "WorkHarborWeb.ErrorJSONTest/test renders 500", - "WorkHarborWeb.PageControllerTest/test GET /", - "WorkHarbor.FoundationRequestTest/test native canonical bytes match independent frozen anchors", + "WorkHarbor.AccountsTest/test bootstrap delivery failure never reinitializes owner", + "WorkHarbor.AccountsTest/test bootstrap preserves owner metadata audit and rejects nested transactions", + "WorkHarbor.AccountsTest/test bootstrap writers serialize a single verifier", + "WorkHarbor.AccountsTest/test direct SQL cannot relabel credentials extend expiry or delete authenticator", + "WorkHarbor.AccountsTest/test exact expiry revoked reopen and independent credentials", + "WorkHarbor.AccountsTest/test existing foreign credential revoke is concealed audited and unchanged", + "WorkHarbor.AccountsTest/test foreign or malformed revoke denial is audited without raw target content", + "WorkHarbor.AccountsTest/test primary auth fixed scopes and hashes preserve secret safety", + "WorkHarbor.AccountsTest/test read issuance and fresh login do not automatically revoke previous tokens", + "WorkHarbor.AccountsTest/test read scope denies mutations grants unknown action and foreign ownership", + "WorkHarbor.AccountsTest/test revocation audit distinguishes actor target and unrelated viewer", "WorkHarbor.FoundationRequestTest/test complete normalized envelope matches independent bytes and semantic field changes", + "WorkHarbor.FoundationRequestTest/test lexical repository identities preserve HTTPS SSH and scp without fetch claims", + "WorkHarbor.FoundationRequestTest/test native canonical bytes match independent frozen anchors", "WorkHarbor.FoundationRequestTest/test native canonical domain enforces exact types depth and integer limits", - "WorkHarbor.FoundationRequestTest/test normalization defaults and submodule ordering preserve semantic identity", - "WorkHarbor.FoundationRequestTest/test native request rejects unknown fields actions nulls and invalid scopes", "WorkHarbor.FoundationRequestTest/test native improper submodule lists return a fixed rejection", - "WorkHarbor.FoundationRequestTest/test lexical repository identities preserve HTTPS SSH and scp without fetch claims", + "WorkHarbor.FoundationRequestTest/test native request rejects unknown fields actions nulls and invalid scopes", + "WorkHarbor.FoundationRequestTest/test normalization defaults and submodule ordering preserve semantic identity", "WorkHarbor.FoundationTest/test acceptance commits scoped intent reservations and redacted audit atomically", - "WorkHarbor.FoundationTest/test equal key replay precedes busy and state checks and changed payload conflicts", - "WorkHarbor.FoundationTest/test owner workspace key uniqueness and scope do not depend on repository owner", - "WorkHarbor.FoundationTest/test direct SQL constraints enforce scope active operation generations and retention", + "WorkHarbor.FoundationTest/test acceptance rejects nested transactions before any writes", "WorkHarbor.FoundationTest/test audit failure rolls back all accepted graph and counter writes", - "WorkHarbor.FoundationTest/test start snapshots existing effective config and never allocates replacement", - "WorkHarbor.FoundationTest/test rebuild requested config never relabels existing effective config", + "WorkHarbor.FoundationTest/test audit identity references cannot mismatch host runtime or intent scope", + "WorkHarbor.FoundationTest/test binding generations require exact integer types and complete role shape", + "WorkHarbor.FoundationTest/test candidate binding cannot borrow another operation runtime", + "WorkHarbor.FoundationTest/test candidate effective config and terminal observation replay are immutable", + "WorkHarbor.FoundationTest/test candidate rejects malformed effective config without any result mutation", "WorkHarbor.FoundationTest/test claim and results bind current intent exact runtime and immutable terminal outcome", - "WorkHarbor.FoundationTest/test same runtime generation cannot authorize stale operation results", "WorkHarbor.FoundationTest/test confirmed runtime tombstone retains storage key history and terminal replay", - "WorkHarbor.FoundationTest/test independent writers serialize equal changed and competing submissions", + "WorkHarbor.FoundationTest/test direct SQL constraints enforce scope active operation generations and retention", + "WorkHarbor.FoundationTest/test direct operation key and current runtime constraints remain enforced", + "WorkHarbor.FoundationTest/test equal key replay precedes busy and state checks and changed payload conflicts", "WorkHarbor.FoundationTest/test every independent writer uses foreign keys full synchronous and WAL", - "WorkHarbor.FoundationTest/test acceptance rejects nested transactions before any writes", - "WorkHarbor.FoundationTest/test process crash before commit rolls back and after commit survives reopen", - "WorkHarbor.FoundationTest/test target observations cannot initially populate effective config", - "WorkHarbor.FoundationTest/test candidate binding cannot borrow another operation runtime", + "WorkHarbor.FoundationTest/test independent writers serialize equal changed and competing submissions", "WorkHarbor.FoundationTest/test insert scope negatives reach foreign keys without update trigger masking", - "WorkHarbor.FoundationTest/test direct operation key and current runtime constraints remain enforced", - "WorkHarbor.FoundationTest/test candidate effective config and terminal observation replay are immutable", "WorkHarbor.FoundationTest/test later candidate reservation has atomic audit and replay allocates nothing", - "WorkHarbor.FoundationTest/test writer lock contention is bounded and never falsely accepted", - "WorkHarbor.FoundationTest/test binding generations require exact integer types and complete role shape", "WorkHarbor.FoundationTest/test lookup rejects malformed keys without query exceptions or data disclosure", - "WorkHarbor.FoundationTest/test audit identity references cannot mismatch host runtime or intent scope", "WorkHarbor.FoundationTest/test native adapter errors and audit never expose request canaries", - "WorkHarbor.FoundationTest/test candidate rejects malformed effective config without any result mutation", + "WorkHarbor.FoundationTest/test owner workspace key uniqueness and scope do not depend on repository owner", + "WorkHarbor.FoundationTest/test process crash before commit rolls back and after commit survives reopen", + "WorkHarbor.FoundationTest/test rebuild requested config never relabels existing effective config", + "WorkHarbor.FoundationTest/test same runtime generation cannot authorize stale operation results", + "WorkHarbor.FoundationTest/test start snapshots existing effective config and never allocates replacement", + "WorkHarbor.FoundationTest/test target observations cannot initially populate effective config", "WorkHarbor.FoundationTest/test target rejects malformed effective config and retains known revisions", - "WorkHarbor.AccountsTest/test bootstrap delivery failure never reinitializes owner", - "WorkHarbor.AccountsTest/test bootstrap preserves owner metadata audit and rejects nested transactions", - "WorkHarbor.AccountsTest/test bootstrap writers serialize a single verifier", - "WorkHarbor.AccountsTest/test direct SQL cannot relabel credentials extend expiry or delete authenticator", - "WorkHarbor.AccountsTest/test exact expiry revoked reopen and independent credentials", - "WorkHarbor.AccountsTest/test existing foreign credential revoke is concealed audited and unchanged", - "WorkHarbor.AccountsTest/test foreign or malformed revoke denial is audited without raw target content", - "WorkHarbor.AccountsTest/test primary auth fixed scopes and hashes preserve secret safety", - "WorkHarbor.AccountsTest/test read issuance and fresh login do not automatically revoke previous tokens", - "WorkHarbor.AccountsTest/test read scope denies mutations grants unknown action and foreign ownership", - "WorkHarbor.AccountsTest/test revocation audit distinguishes actor target and unrelated viewer", + "WorkHarbor.FoundationTest/test writer lock contention is bounded and never falsely accepted", + "WorkHarbor.HostProtocolTest/test canonical exact byte budget and maximum counter are independent", + "WorkHarbor.HostProtocolTest/test changed replay and same runtime stale intent reject", + "WorkHarbor.HostProtocolTest/test completed historical command replay ignores newer intent", + "WorkHarbor.HostProtocolTest/test every lifecycle branch and new intent on existing runtime validate", + "WorkHarbor.HostProtocolTest/test independent canonical anchors separate request hash and command identity", + "WorkHarbor.HostProtocolTest/test independent shared raw vectors preserve codes and dispositions", + "WorkHarbor.HostProtocolTest/test malformed observation values never become null", + "WorkHarbor.HostProtocolTest/test native canonical budget aborts before emission and redacts canaries", + "WorkHarbor.HostProtocolTest/test native types exact counters and improper lists reject", + "WorkHarbor.HostProtocolTest/test normalized result budget includes optional default overhead", + "WorkHarbor.HostProtocolTest/test observation omission and optional defaults preserve native nil", + "WorkHarbor.HostProtocolTest/test pending replay and executing command never permit another effect", + "WorkHarbor.HostProtocolTest/test provider sentinel remains untouched for rejection and lookup", + "WorkHarbor.HostProtocolTest/test raw size wins invalid Unicode and native depth is bounded", + "WorkHarbor.HostProtocolTest/test result closed shape excludes command only payload fields", + "WorkHarbor.HostProtocolTest/test result status and immutable terminal replay are guarded", + "WorkHarbor.HostProtocolTest/test runtime scope provider config and removal observations are guarded", + "WorkHarbor.HostProtocolTest/test trusted peer and missing expectation reject before lookup disclosure", + "WorkHarbor.HostProtocolTest/test wire observation effective config is already normalized", + "WorkHarbor.HostProtocolTest/test workspace scope matches separately authorized expectation", "WorkHarbor.StrictJSONTest/test all seventy four frozen raw byte parse vectors match canonical bytes and rejection", "WorkHarbor.StrictJSONTest/test decoded duplicate keys reject at every depth before maps", "WorkHarbor.StrictJSONTest/test exact byte budget rejects oversize before invalid UTF8", @@ -645,26 +660,18 @@ "WorkHarborWeb.AuthenticationTest/test strict API login rejects alternate parsers duplicate fields and mixed credentials", "WorkHarborWeb.AuthenticationTest/test upgrade errors never reflect attacker header canary", "WorkHarborWeb.AuthenticationTest/test viewer wrapping initializes fresh dashboard CSRF without touching credentials or owner", - "WorkHarbor.HostProtocolTest/test independent shared raw vectors preserve codes and dispositions", - "WorkHarbor.HostProtocolTest/test independent canonical anchors separate request hash and command identity", - "WorkHarbor.HostProtocolTest/test native types exact counters and improper lists reject", - "WorkHarbor.HostProtocolTest/test trusted peer and missing expectation reject before lookup disclosure", - "WorkHarbor.HostProtocolTest/test pending replay and executing command never permit another effect", - "WorkHarbor.HostProtocolTest/test completed historical command replay ignores newer intent", - "WorkHarbor.HostProtocolTest/test changed replay and same runtime stale intent reject", - "WorkHarbor.HostProtocolTest/test observation omission and optional defaults preserve native nil", - "WorkHarbor.HostProtocolTest/test malformed observation values never become null", - "WorkHarbor.HostProtocolTest/test result status and immutable terminal replay are guarded", - "WorkHarbor.HostProtocolTest/test runtime scope provider config and removal observations are guarded", - "WorkHarbor.HostProtocolTest/test result closed shape excludes command only payload fields", - "WorkHarbor.HostProtocolTest/test native canonical budget aborts before emission and redacts canaries", - "WorkHarbor.HostProtocolTest/test raw size wins invalid Unicode and native depth is bounded", - "WorkHarbor.HostProtocolTest/test provider sentinel remains untouched for rejection and lookup", - "WorkHarbor.HostProtocolTest/test every lifecycle branch and new intent on existing runtime validate", - "WorkHarbor.HostProtocolTest/test canonical exact byte budget and maximum counter are independent", - "WorkHarbor.HostProtocolTest/test normalized result budget includes optional default overhead", - "WorkHarbor.HostProtocolTest/test wire observation effective config is already normalized", - "WorkHarbor.HostProtocolTest/test workspace scope matches separately authorized expectation" + "WorkHarborWeb.ErrorHTMLTest/test renders 404.html", + "WorkHarborWeb.ErrorHTMLTest/test renders 500.html", + "WorkHarborWeb.ErrorJSONTest/test renders 404", + "WorkHarborWeb.ErrorJSONTest/test renders 500", + "WorkHarborWeb.PageControllerTest/test GET /", + "WorkHarborWeb.TransportSecurityTest/test active transport profile refuses lower layer protocol and listener overrides", + "WorkHarborWeb.TransportSecurityTest/test active transport profile requires exact explicit protocol settings and adapter", + "WorkHarborWeb.TransportSecurityTest/test actual HTTP1 preserves browser form and CLI bearer authentication", + "WorkHarborWeb.TransportSecurityTest/test actual TLS negotiates only HTTP1 and verifies certificate identity", + "WorkHarborWeb.TransportSecurityTest/test actual TLS rejects HTTP2 prior knowledge with and without ALPN", + "WorkHarborWeb.TransportSecurityTest/test actual WSS preserves selected session CSRF origin and revocation gates", + "WorkHarborWeb.TransportSecurityTest/test real runtime activation is HTTP1 only in every authenticated environment" ] } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index f013a1db..ef8bc2a3 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -737,3 +737,35 @@ canonical precreated-directory output corrected it. One gofmt invocation used th wrong cwd-relative path and was corrected. Neither failed command is counted passing. No helper policy/runner behavior changed; root reserves full make check for combined head unless new concern requires it. Exact-candidate pristine gates follow externally. + +## T007 exact acceptance and S001 transport follow-up + +T007 is independently Sol CLEAR at exact candidate +`e10c6997c24407e0f472a7ea22ba2dd1b42067e3` for the pure protocol scope. +Author pristine evidence: `/private/tmp/workharbor-t007-validation-nbe2oy8a/evidence.json`; +independent evidence: `/var/folders/g3/fzgcpsts26j_bzpn6ymprbwm0000gn/T/workharbor-t007-independent-yof68sbl/summary.json`. +Actual application counts: host_agent132, api_client162, whr1, Phoenix106; all +inherited IDs retained. Source/manifest collection retains 846 Python / 520 required. +Only Feature006 T007 is checked; Feature001 markers are unchanged. Full canonical, +real-host/browser acceptance and dependency security clearance are not claimed. + +Locked Bandit1.12.5 emits EEF-CVE-2026-105659 / GHSA-69m6-wcv4-rwcf HIGH. +Primary upstream facts were checked on 2026-10-14: affected through1.12.5, +patched-version TODO, Hex latest1.12.5, fix in Unreleased. Root authorized S001 +as a blocking follow-up before T008; independent Sol transport reviewer cleared +[the finite contract](../../../specs/006-whr-cli/contracts/transport-security.md) +before tests/behavior. Same worktree/branch, sole S001 writer; T008 remains held. +No dependency update, proxy, persistent listener, release/deploy or publication. + +S001 failure-first suite is now actual RED before mitigation: +`MIX_ENV=test MIX_BUILD_PATH=/private/tmp/workharbor-t007-fresh-build mix test test/workharbor_web/transport_security_test.exs` +exited2, 2/7 passed and five behavior failures. Log: +`/private/tmp/workharbor-s001-linked-red.log`. HTTP1 browser/bearer authentication +and real WSS positives/negatives passed; guards accepted unsafe protocol/override +settings, runtime omitted restrictions, dual ALPN chose h2 and no-ALPN HTTP2 preface +received a SETTINGS frame. Initial disposable PEM encoding / request-process DB +ownership setup errors were corrected first and are not counted as behavior RED. +Actual tests use all three merged base/runtime environment configurations and +PhoenixAdapter child specs. Network request processes share the named repository's +sandbox transaction; generated certificates/keys and listeners are ephemeral. +Seven required transport IDs were appended without removing inherited IDs. diff --git a/specs/006-whr-cli/contracts/transport-security.md b/specs/006-whr-cli/contracts/transport-security.md new file mode 100644 index 00000000..3a455bca --- /dev/null +++ b/specs/006-whr-cli/contracts/transport-security.md @@ -0,0 +1,72 @@ +# S001 authenticated transport mitigation + +Status: finite design independently Sol CLEAR on 2026-10-14 before tests/behavior; +implementation and exact-candidate acceptance pending. This follow-up blocks T008. +It preserves the owner API, HTTPS origin/authority, browser sessions, WSS and CLI +contracts. It changes the current listener protocol profile only. + +## Advisory and boundary + +Locked Bandit 1.12.5 is affected by +[GHSA-69m6-wcv4-rwcf / CVE-2026-105659](https://github.com/mtrudel/bandit/security/advisories/GHSA-69m6-wcv4-rwcf) +(the dependency warning identifies EEF-CVE-2026-105659). Verified on 2026-10-14: +the advisory lists patched version TODO, [Hex](https://hex.pm/packages/bandit) +still lists 1.12.5 as latest, and the upstream +[changelog](https://github.com/mtrudel/bandit/blob/main/CHANGELOG.md) puts the fix +under Unreleased. The official mitigation disables HTTP/2 using +`http_2_options: [enabled: false]`. The locked dependency remains affected; this is +a deployment-profile mitigation, not a dependency security clearance. + +No unreleased dependency pin, upstream source copy, reverse proxy, host enrollment, +listener deployment or persistent service is introduced. Native tests may start +only disposable loopback TLS listeners with generated test certificates and cleanup. +Real host/browser pilot acceptance and signed-release SC-006 remain separate gates. + +## Current authenticated profile + +Every actual PHX_SERVER profile (dev, test and prod) uses Bandit.PhoenixAdapter, +HTTPS only, and explicit `http_1_options: [enabled: true]` plus +`http_2_options: [enabled: false]`. Inert `server: false` remains allowed. +RuntimeSecurity rejects active configurations that omit or alter these exact lists, +use another adapter, contain duplicate HTTPS keys, or contain HTTPS keys outside +`ip`, `port`, `certfile`, `keyfile`, `http_1_options`, `http_2_options`. +The existing secret/debug/reloader/plaintext/origin/private-key checks remain. +The production port remains 1..65535; port 0 is exclusively a test adapter fixture. + +Phoenix passes its HTTPS options directly to Bandit. Low-level ThousandIsland +handler, transport and ALPN overrides can bypass the ordinary protocol switches; +the narrow current profile therefore refuses them rather than trusting defaults. +Future T008 may extend the HTTPS allowlist only with separately reviewed narrow +client-CA / optional peer-certificate verification options. It must preserve this +HTTP/1-only protocol restriction and cannot allow generic handler/ALPN overrides. +No mTLS behavior is implemented by S001. + +## Required failure-first evidence + +Independently constructed valid active configuration and each real runtime env +must pass. Missing, enabled, malformed or duplicated protocol settings, wrong +adapter, and handler/transport/ALPN override attempts must fail closed. Preserve +all prior required Phoenix, Go and Python IDs; append meaningful transport IDs. + +Actual Phoenix-adapter TLS on an OS-assigned loopback port must prove dual ALPN +selects HTTP/1.1, h2-only never negotiates h2, and HTTP/2 prior-knowledge prefaces +close both without ALPN and with HTTP/1.1 ALPN. The effective bound production +configuration is validated before traffic probes. Verified certificate/hostname +HTTP/1 requests must preserve CLI bearer login/status, browser form sign-in and +owner/dashboard WSS upgrades; missing/foreign origin, wrong CSRF, wrong selected +cookie and revoked credentials remain denied. No synthetic ConnTest result is +reported as actual TLS evidence. + +Run focused and full application gates, source/manifest checks and pristine exact +candidate verification, then obtain independent Sol review for that exact SHA. +The root reserves full canonical Python execution for combined acceptance unless +a changed runner/policy or concrete regression concern requires an earlier rerun. + +## Re-enable conditions + +HTTP/2 remains disabled until a separately scoped change pins a released upstream +version documented as fixing this exact advisory, reconciles current advisories, +updates the contract and protocol tests, passes exact-candidate application / real +TLS checks, and receives independent review. Publication or deployment still needs +its own existing authorization. A TODO patched-version field or an unreleased main +commit does not satisfy this condition. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index a9a13ace..881b71cd 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -4,8 +4,10 @@ Status: execution in progress. T001 scaffold independently Sol CLEAR at `d3d84c8361a1389292fad96d02a828ac9fabc2e3`; T002 is independently CLEAR at `bc18034164597aa841bea561cf6a882e9c0715e2`; T003 independently CLEAR at `191c62baf227f64122770244d62d289900bb4d8d`; T004 independently CLEAR at -`6abb7419c77b3712a6ac276bc798b102503d3144`; T005 is under implementation and remains -unchecked pending independent exact-candidate review. Later work items remain open. +`6abb7419c77b3712a6ac276bc798b102503d3144`; T005 and T006 are accepted at +`1189c81` and `ef399f5`. T007 is independently CLEAR at +`e10c6997c24407e0f472a7ea22ba2dd1b42067e3` for its pure protocol scope. S001 blocks +T008 until its exact-candidate transport mitigation review and checks pass. No product runtime acceptance is claimed. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), @@ -35,7 +37,9 @@ Independent check: negative vectors, cross-language bytes and no runtime Python. - [x] T004 Port only accepted pure JSON/path behavior into `apps/api_client/wirejson/` and `apps/host_agent/internal/config/`; write Go negative/golden tests first and reproduce frozen vectors independently in Python; no caller switches before parity and independent review (T003). - [x] T005 Implement ownership, separate storage/runtime records, durable operations/canonical request hash, generation checks and audit in `apps/control_plane/lib/workharbor/` and migrations/tests in `apps/control_plane/`; prove owner/workspace key uniqueness, changed-payload conflicts and crash-safe intent-before-side-effect (001 T002). - [x] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). -- [ ] T007 Define/validate versioned host protocol in `contracts/host-protocol/`, `apps/control_plane/lib/workharbor/host_protocol.ex`, `apps/host_agent/internal/protocol/`; reject invalid version/scope/replay before provider calls and redact diagnostics in both suites (001 T003). +- [x] T007 Define/validate versioned host protocol in `contracts/host-protocol/`, `apps/control_plane/lib/workharbor/host_protocol.ex`, `apps/host_agent/internal/protocol/`; reject invalid version/scope/replay before provider calls and redact diagnostics in both suites (001 T003). + +- [ ] S001 Mitigate locked Bandit 1.12.5 HTTP/2 resource exhaustion advisory GHSA-69m6-wcv4-rwcf before T008: enforce the reviewed HTTP/1-only authenticated listener profile, refuse unsafe overrides, and prove actual ephemeral TLS protocol rejection plus HTTP/1 authentication and WSS success. Follow [the bounded transport contract](contracts/transport-security.md); retain the dependency advisory and require a released fixed version plus new independent review before re-enabling HTTP/2. No dependency update, proxy or T008 behavior belongs to this follow-up. ## Phase 3: US1 — Authenticate and manage workspaces (P1) From b39b13af04b116de1c35e10ac35adf9382e8279c Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 20:16:42 +0200 Subject: [PATCH 18/51] fix(security): enforce HTTP1-only authenticated listener profile Mitigate GHSA-69m6-wcv4-rwcf without an unreleased dependency pin. Reject ambiguous activation and unsafe protocol or low-level overrides before active listener startup; keep inert defaults and real HTTPS browser, bearer and WSS behavior. Record failure-first activation review fix and full native application checks. Locked Bandit remains advisory-affected; a released fix and separate review are required before HTTP2 re-enable. Refs: #72 Co-authored-by: Codex --- apps/control_plane/README.md | 17 ++- apps/control_plane/config/runtime.exs | 5 +- .../lib/workharbor_web/runtime_security.ex | 19 ++- .../workharbor_web/authentication_test.exs | 10 +- .../transport_security_test.exs | 24 +++- docs/development/ci-acceptance.json | 123 +++++++++--------- docs/exec-plans/active/installable-pilot.md | 30 +++++ specs/006-whr-cli/contracts/authentication.md | 6 + .../contracts/transport-security.md | 9 +- 9 files changed, 168 insertions(+), 75 deletions(-) diff --git a/apps/control_plane/README.md b/apps/control_plane/README.md index 3a6f2ac0..1bd5b3a4 100644 --- a/apps/control_plane/README.md +++ b/apps/control_plane/README.md @@ -14,7 +14,9 @@ mix compile --warnings-as-errors mix test ``` -Tests use private file-backed databases and `server: false`. Foundation tests +Tests use file-backed databases and `server: false` by default. The bounded +transport suite starts disposable loopback TLS listeners with generated test +certificates; authentication requests use a shared sandbox transaction. Foundation tests include independent writers and bounded SIGKILL/reopen evidence; these do not establish hardware power-loss or live-platform acceptance. @@ -40,11 +42,20 @@ requires `PHX_SERVER=true`, `WORKHARBOR_ORIGIN`, `WORKHARBOR_BIND`, `WORKHARBOR_TLS_CERT_FILE`, `WORKHARBOR_TLS_KEY_FILE`, and a private non-demo `SECRET_KEY_BASE`; production also requires `DATABASE_PATH`. Direct TLS origin, authority and listener port must agree. HTTP, proxy-forwarded TLS, debugger, -reloader and demo signing secrets are rejected for an active server. This document +reloader and demo signing secrets are rejected for an active server. Every active +environment uses the narrow explicit HTTP/1-only Bandit profile and rejects generic +listener/handler/ALPN overrides. Locked Bandit 1.12.5 remains affected by +[GHSA-69m6-wcv4-rwcf](https://github.com/mtrudel/bandit/security/advisories/GHSA-69m6-wcv4-rwcf); +[the transport contract](../../specs/006-whr-cli/contracts/transport-security.md) +records this temporary mitigation and the separate review required to re-enable +HTTP/2 after a released upstream fix. This document records configuration requirements, not permission to activate or deploy. The inherited root welcome test retains its required ID and now verifies the intentional plaintext rejection. Authenticated root navigation has separate positive coverage. ConnTest and socket adapter tests establish application gates, -not actual network TLS or browser acceptance. Assets retain the pinned Mix wrappers; +not actual network TLS or browser acceptance. The separate native transport suite +proves TLS certificate/hostname verification, HTTP/2 refusal, HTTP/1 authentication +and WSS admission on ephemeral loopback listeners. It does not establish a deployed +listener, browser walkthrough, real-host enrollment or pilot completion. Assets retain the pinned Mix wrappers; no new runtime dependencies or Python/AI validation subprocesses were added. diff --git a/apps/control_plane/config/runtime.exs b/apps/control_plane/config/runtime.exs index cd544693..884bc9fa 100644 --- a/apps/control_plane/config/runtime.exs +++ b/apps/control_plane/config/runtime.exs @@ -35,6 +35,9 @@ if System.get_env("PHX_SERVER") == "true" do ip: bind, port: uri.port, certfile: required.("WORKHARBOR_TLS_CERT_FILE"), - keyfile: required.("WORKHARBOR_TLS_KEY_FILE") + keyfile: required.("WORKHARBOR_TLS_KEY_FILE"), + # Temporary GHSA-69m6-wcv4-rwcf mitigation; see transport-security contract. + http_1_options: [enabled: true], + http_2_options: [enabled: false] ] end diff --git a/apps/control_plane/lib/workharbor_web/runtime_security.ex b/apps/control_plane/lib/workharbor_web/runtime_security.ex index 4282fd9f..3b6de35b 100644 --- a/apps/control_plane/lib/workharbor_web/runtime_security.ex +++ b/apps/control_plane/lib/workharbor_web/runtime_security.ex @@ -8,7 +8,14 @@ defmodule WorkHarborWeb.RuntimeSecurity do "9kwSZZ3zqgPLCQHHHC7Q9KYXXSVttc/mmuUT4ZmFiV1BGre/cOXzP7jNrsMeH4N4", "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl" ] + @https_keys ~w(ip port certfile keyfile http_1_options http_2_options)a + def validate!(config, owner_http) do + # Phoenix treats other truthy values as active and missing values can inherit + # serve_endpoints. Require an explicit, unambiguous activation decision. + unless Keyword.get_values(config, :server) in [[true], [false]], + do: raise(ArgumentError, "unsafe authentication server configuration") + if config[:server] == true do key = config[:secret_key_base] tls = config[:https] @@ -16,7 +23,8 @@ defmodule WorkHarborWeb.RuntimeSecurity do valid = is_binary(key) and byte_size(key) >= 64 and key not in @demo and config[:debug_errors] == false and config[:code_reloader] == false and - config[:http] in [nil, false] and config[:force_ssl] in [nil, false] and is_list(tls) and + config[:http] in [nil, false] and config[:force_ssl] in [nil, false] and + http1_profile?(config, tls) and is_tuple(tls[:ip]) and is_integer(tls[:port]) and tls[:port] in 1..65535 and regular?(tls[:certfile]) and private?(tls[:keyfile]) and valid_origin?(owner_http, tls[:port]) @@ -29,6 +37,15 @@ defmodule WorkHarborWeb.RuntimeSecurity do _ -> raise ArgumentError, "unsafe authentication server configuration" end + # GHSA-69m6-wcv4-rwcf: locked Bandit is mitigated, not patched. Keep this + # current profile narrow: lower-layer handler/ALPN overrides bypass switches. + # T008 client-CA options require separate review while preserving HTTP/1 only. + defp http1_profile?(config, tls) do + config[:adapter] == Bandit.PhoenixAdapter and Keyword.keyword?(tls) and + Enum.sort(Keyword.keys(tls)) == Enum.sort(@https_keys) and + tls[:http_1_options] == [enabled: true] and tls[:http_2_options] == [enabled: false] + end + defp regular?(p), do: is_binary(p) and Path.type(p) == :absolute and File.regular?(p) defp private?(p) do diff --git a/apps/control_plane/test/workharbor_web/authentication_test.exs b/apps/control_plane/test/workharbor_web/authentication_test.exs index ffb566cc..660dd01a 100644 --- a/apps/control_plane/test/workharbor_web/authentication_test.exs +++ b/apps/control_plane/test/workharbor_web/authentication_test.exs @@ -250,11 +250,19 @@ defmodule WorkHarborWeb.AuthenticationTest do config = [ server: true, + adapter: Bandit.PhoenixAdapter, debug_errors: false, code_reloader: false, http: false, secret_key_base: String.duplicate("x", 64), - https: [ip: {127, 0, 0, 1}, port: 443, certfile: cert, keyfile: key] + https: [ + ip: {127, 0, 0, 1}, + port: 443, + certfile: cert, + keyfile: key, + http_1_options: [enabled: true], + http_2_options: [enabled: false] + ] ] owner = [origin: "https://localhost", authorities: [{"localhost", 443}]] diff --git a/apps/control_plane/test/workharbor_web/transport_security_test.exs b/apps/control_plane/test/workharbor_web/transport_security_test.exs index b0022fa7..da8fb3b7 100644 --- a/apps/control_plane/test/workharbor_web/transport_security_test.exs +++ b/apps/control_plane/test/workharbor_web/transport_security_test.exs @@ -138,6 +138,20 @@ defmodule WorkHarborWeb.TransportSecurityTest do assert :ok = RuntimeSecurity.validate!([server: false], []) end + test "listener activation requires explicit true or false before protocol validation", c do + cfg = independent(c) + unsafe = Keyword.put(cfg, :https, Keyword.put(cfg[:https], :http_2_options, enabled: true)) + + for value <- [nil, :enabled, "true", 1, []] do + refused(Keyword.put(unsafe, :server, value)) + end + + refused(Keyword.delete(unsafe, :server)) + refused([{:server, false} | unsafe]) + refused([{:server, true} | unsafe]) + assert :ok = RuntimeSecurity.validate!([server: false], []) + end + test "active transport profile refuses lower layer protocol and listener overrides", c do cfg = independent(c) @@ -177,6 +191,11 @@ defmodule WorkHarborWeb.TransportSecurityTest do |> Enum.filter(&(tuple_size(&1) == 2)) |> Keyword.delete(:__config__) + on_exit(fn -> + Application.put_env(:workharbor, :owner_http, prior_owner) + Endpoint.config_change([{Endpoint, prior_endpoint}], []) + end) + {runtime, _} = runtime(c, :test, 0) cfg = Application.fetch_env!(:workharbor, Endpoint) |> Keyword.merge(runtime) [spec] = Bandit.PhoenixAdapter.child_specs(Endpoint, Keyword.put(cfg, :otp_app, :workharbor)) @@ -200,11 +219,6 @@ defmodule WorkHarborWeb.TransportSecurityTest do :ok = Ecto.Adapters.SQL.Sandbox.mode(WorkHarbor.Repo, {:shared, self()}) {owner_id, secret} = WorkHarbor.AuthDatabase.fixture!() - on_exit(fn -> - Application.put_env(:workharbor, :owner_http, prior_owner) - Endpoint.config_change([{Endpoint, prior_endpoint}], []) - end) - Map.merge(c, %{port: port, origin: origin, owner_id: owner_id, secret: secret}) end diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 72c2658a..21de6ca4 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -559,71 +559,56 @@ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestIntegerAndContainerLimits" ], "control_plane": [ - "WorkHarbor.AccountsTest/test bootstrap delivery failure never reinitializes owner", - "WorkHarbor.AccountsTest/test bootstrap preserves owner metadata audit and rejects nested transactions", - "WorkHarbor.AccountsTest/test bootstrap writers serialize a single verifier", - "WorkHarbor.AccountsTest/test direct SQL cannot relabel credentials extend expiry or delete authenticator", - "WorkHarbor.AccountsTest/test exact expiry revoked reopen and independent credentials", - "WorkHarbor.AccountsTest/test existing foreign credential revoke is concealed audited and unchanged", - "WorkHarbor.AccountsTest/test foreign or malformed revoke denial is audited without raw target content", - "WorkHarbor.AccountsTest/test primary auth fixed scopes and hashes preserve secret safety", - "WorkHarbor.AccountsTest/test read issuance and fresh login do not automatically revoke previous tokens", - "WorkHarbor.AccountsTest/test read scope denies mutations grants unknown action and foreign ownership", - "WorkHarbor.AccountsTest/test revocation audit distinguishes actor target and unrelated viewer", - "WorkHarbor.FoundationRequestTest/test complete normalized envelope matches independent bytes and semantic field changes", - "WorkHarbor.FoundationRequestTest/test lexical repository identities preserve HTTPS SSH and scp without fetch claims", + "WorkHarborWeb.ErrorHTMLTest/test renders 404.html", + "WorkHarborWeb.ErrorHTMLTest/test renders 500.html", + "WorkHarborWeb.ErrorJSONTest/test renders 404", + "WorkHarborWeb.ErrorJSONTest/test renders 500", + "WorkHarborWeb.PageControllerTest/test GET /", "WorkHarbor.FoundationRequestTest/test native canonical bytes match independent frozen anchors", + "WorkHarbor.FoundationRequestTest/test complete normalized envelope matches independent bytes and semantic field changes", "WorkHarbor.FoundationRequestTest/test native canonical domain enforces exact types depth and integer limits", - "WorkHarbor.FoundationRequestTest/test native improper submodule lists return a fixed rejection", - "WorkHarbor.FoundationRequestTest/test native request rejects unknown fields actions nulls and invalid scopes", "WorkHarbor.FoundationRequestTest/test normalization defaults and submodule ordering preserve semantic identity", + "WorkHarbor.FoundationRequestTest/test native request rejects unknown fields actions nulls and invalid scopes", + "WorkHarbor.FoundationRequestTest/test native improper submodule lists return a fixed rejection", + "WorkHarbor.FoundationRequestTest/test lexical repository identities preserve HTTPS SSH and scp without fetch claims", "WorkHarbor.FoundationTest/test acceptance commits scoped intent reservations and redacted audit atomically", - "WorkHarbor.FoundationTest/test acceptance rejects nested transactions before any writes", + "WorkHarbor.FoundationTest/test equal key replay precedes busy and state checks and changed payload conflicts", + "WorkHarbor.FoundationTest/test owner workspace key uniqueness and scope do not depend on repository owner", + "WorkHarbor.FoundationTest/test direct SQL constraints enforce scope active operation generations and retention", "WorkHarbor.FoundationTest/test audit failure rolls back all accepted graph and counter writes", - "WorkHarbor.FoundationTest/test audit identity references cannot mismatch host runtime or intent scope", - "WorkHarbor.FoundationTest/test binding generations require exact integer types and complete role shape", - "WorkHarbor.FoundationTest/test candidate binding cannot borrow another operation runtime", - "WorkHarbor.FoundationTest/test candidate effective config and terminal observation replay are immutable", - "WorkHarbor.FoundationTest/test candidate rejects malformed effective config without any result mutation", + "WorkHarbor.FoundationTest/test start snapshots existing effective config and never allocates replacement", + "WorkHarbor.FoundationTest/test rebuild requested config never relabels existing effective config", "WorkHarbor.FoundationTest/test claim and results bind current intent exact runtime and immutable terminal outcome", + "WorkHarbor.FoundationTest/test same runtime generation cannot authorize stale operation results", "WorkHarbor.FoundationTest/test confirmed runtime tombstone retains storage key history and terminal replay", - "WorkHarbor.FoundationTest/test direct SQL constraints enforce scope active operation generations and retention", - "WorkHarbor.FoundationTest/test direct operation key and current runtime constraints remain enforced", - "WorkHarbor.FoundationTest/test equal key replay precedes busy and state checks and changed payload conflicts", - "WorkHarbor.FoundationTest/test every independent writer uses foreign keys full synchronous and WAL", "WorkHarbor.FoundationTest/test independent writers serialize equal changed and competing submissions", + "WorkHarbor.FoundationTest/test every independent writer uses foreign keys full synchronous and WAL", + "WorkHarbor.FoundationTest/test acceptance rejects nested transactions before any writes", + "WorkHarbor.FoundationTest/test process crash before commit rolls back and after commit survives reopen", + "WorkHarbor.FoundationTest/test target observations cannot initially populate effective config", + "WorkHarbor.FoundationTest/test candidate binding cannot borrow another operation runtime", "WorkHarbor.FoundationTest/test insert scope negatives reach foreign keys without update trigger masking", + "WorkHarbor.FoundationTest/test direct operation key and current runtime constraints remain enforced", + "WorkHarbor.FoundationTest/test candidate effective config and terminal observation replay are immutable", "WorkHarbor.FoundationTest/test later candidate reservation has atomic audit and replay allocates nothing", + "WorkHarbor.FoundationTest/test writer lock contention is bounded and never falsely accepted", + "WorkHarbor.FoundationTest/test binding generations require exact integer types and complete role shape", "WorkHarbor.FoundationTest/test lookup rejects malformed keys without query exceptions or data disclosure", + "WorkHarbor.FoundationTest/test audit identity references cannot mismatch host runtime or intent scope", "WorkHarbor.FoundationTest/test native adapter errors and audit never expose request canaries", - "WorkHarbor.FoundationTest/test owner workspace key uniqueness and scope do not depend on repository owner", - "WorkHarbor.FoundationTest/test process crash before commit rolls back and after commit survives reopen", - "WorkHarbor.FoundationTest/test rebuild requested config never relabels existing effective config", - "WorkHarbor.FoundationTest/test same runtime generation cannot authorize stale operation results", - "WorkHarbor.FoundationTest/test start snapshots existing effective config and never allocates replacement", - "WorkHarbor.FoundationTest/test target observations cannot initially populate effective config", + "WorkHarbor.FoundationTest/test candidate rejects malformed effective config without any result mutation", "WorkHarbor.FoundationTest/test target rejects malformed effective config and retains known revisions", - "WorkHarbor.FoundationTest/test writer lock contention is bounded and never falsely accepted", - "WorkHarbor.HostProtocolTest/test canonical exact byte budget and maximum counter are independent", - "WorkHarbor.HostProtocolTest/test changed replay and same runtime stale intent reject", - "WorkHarbor.HostProtocolTest/test completed historical command replay ignores newer intent", - "WorkHarbor.HostProtocolTest/test every lifecycle branch and new intent on existing runtime validate", - "WorkHarbor.HostProtocolTest/test independent canonical anchors separate request hash and command identity", - "WorkHarbor.HostProtocolTest/test independent shared raw vectors preserve codes and dispositions", - "WorkHarbor.HostProtocolTest/test malformed observation values never become null", - "WorkHarbor.HostProtocolTest/test native canonical budget aborts before emission and redacts canaries", - "WorkHarbor.HostProtocolTest/test native types exact counters and improper lists reject", - "WorkHarbor.HostProtocolTest/test normalized result budget includes optional default overhead", - "WorkHarbor.HostProtocolTest/test observation omission and optional defaults preserve native nil", - "WorkHarbor.HostProtocolTest/test pending replay and executing command never permit another effect", - "WorkHarbor.HostProtocolTest/test provider sentinel remains untouched for rejection and lookup", - "WorkHarbor.HostProtocolTest/test raw size wins invalid Unicode and native depth is bounded", - "WorkHarbor.HostProtocolTest/test result closed shape excludes command only payload fields", - "WorkHarbor.HostProtocolTest/test result status and immutable terminal replay are guarded", - "WorkHarbor.HostProtocolTest/test runtime scope provider config and removal observations are guarded", - "WorkHarbor.HostProtocolTest/test trusted peer and missing expectation reject before lookup disclosure", - "WorkHarbor.HostProtocolTest/test wire observation effective config is already normalized", - "WorkHarbor.HostProtocolTest/test workspace scope matches separately authorized expectation", + "WorkHarbor.AccountsTest/test bootstrap delivery failure never reinitializes owner", + "WorkHarbor.AccountsTest/test bootstrap preserves owner metadata audit and rejects nested transactions", + "WorkHarbor.AccountsTest/test bootstrap writers serialize a single verifier", + "WorkHarbor.AccountsTest/test direct SQL cannot relabel credentials extend expiry or delete authenticator", + "WorkHarbor.AccountsTest/test exact expiry revoked reopen and independent credentials", + "WorkHarbor.AccountsTest/test existing foreign credential revoke is concealed audited and unchanged", + "WorkHarbor.AccountsTest/test foreign or malformed revoke denial is audited without raw target content", + "WorkHarbor.AccountsTest/test primary auth fixed scopes and hashes preserve secret safety", + "WorkHarbor.AccountsTest/test read issuance and fresh login do not automatically revoke previous tokens", + "WorkHarbor.AccountsTest/test read scope denies mutations grants unknown action and foreign ownership", + "WorkHarbor.AccountsTest/test revocation audit distinguishes actor target and unrelated viewer", "WorkHarbor.StrictJSONTest/test all seventy four frozen raw byte parse vectors match canonical bytes and rejection", "WorkHarbor.StrictJSONTest/test decoded duplicate keys reject at every depth before maps", "WorkHarbor.StrictJSONTest/test exact byte budget rejects oversize before invalid UTF8", @@ -660,18 +645,34 @@ "WorkHarborWeb.AuthenticationTest/test strict API login rejects alternate parsers duplicate fields and mixed credentials", "WorkHarborWeb.AuthenticationTest/test upgrade errors never reflect attacker header canary", "WorkHarborWeb.AuthenticationTest/test viewer wrapping initializes fresh dashboard CSRF without touching credentials or owner", - "WorkHarborWeb.ErrorHTMLTest/test renders 404.html", - "WorkHarborWeb.ErrorHTMLTest/test renders 500.html", - "WorkHarborWeb.ErrorJSONTest/test renders 404", - "WorkHarborWeb.ErrorJSONTest/test renders 500", - "WorkHarborWeb.PageControllerTest/test GET /", - "WorkHarborWeb.TransportSecurityTest/test active transport profile refuses lower layer protocol and listener overrides", + "WorkHarbor.HostProtocolTest/test independent shared raw vectors preserve codes and dispositions", + "WorkHarbor.HostProtocolTest/test independent canonical anchors separate request hash and command identity", + "WorkHarbor.HostProtocolTest/test native types exact counters and improper lists reject", + "WorkHarbor.HostProtocolTest/test trusted peer and missing expectation reject before lookup disclosure", + "WorkHarbor.HostProtocolTest/test pending replay and executing command never permit another effect", + "WorkHarbor.HostProtocolTest/test completed historical command replay ignores newer intent", + "WorkHarbor.HostProtocolTest/test changed replay and same runtime stale intent reject", + "WorkHarbor.HostProtocolTest/test observation omission and optional defaults preserve native nil", + "WorkHarbor.HostProtocolTest/test malformed observation values never become null", + "WorkHarbor.HostProtocolTest/test result status and immutable terminal replay are guarded", + "WorkHarbor.HostProtocolTest/test runtime scope provider config and removal observations are guarded", + "WorkHarbor.HostProtocolTest/test result closed shape excludes command only payload fields", + "WorkHarbor.HostProtocolTest/test native canonical budget aborts before emission and redacts canaries", + "WorkHarbor.HostProtocolTest/test raw size wins invalid Unicode and native depth is bounded", + "WorkHarbor.HostProtocolTest/test provider sentinel remains untouched for rejection and lookup", + "WorkHarbor.HostProtocolTest/test every lifecycle branch and new intent on existing runtime validate", + "WorkHarbor.HostProtocolTest/test canonical exact byte budget and maximum counter are independent", + "WorkHarbor.HostProtocolTest/test normalized result budget includes optional default overhead", + "WorkHarbor.HostProtocolTest/test wire observation effective config is already normalized", + "WorkHarbor.HostProtocolTest/test workspace scope matches separately authorized expectation", "WorkHarborWeb.TransportSecurityTest/test active transport profile requires exact explicit protocol settings and adapter", - "WorkHarborWeb.TransportSecurityTest/test actual HTTP1 preserves browser form and CLI bearer authentication", + "WorkHarborWeb.TransportSecurityTest/test listener activation requires explicit true or false before protocol validation", + "WorkHarborWeb.TransportSecurityTest/test active transport profile refuses lower layer protocol and listener overrides", + "WorkHarborWeb.TransportSecurityTest/test real runtime activation is HTTP1 only in every authenticated environment", "WorkHarborWeb.TransportSecurityTest/test actual TLS negotiates only HTTP1 and verifies certificate identity", "WorkHarborWeb.TransportSecurityTest/test actual TLS rejects HTTP2 prior knowledge with and without ALPN", - "WorkHarborWeb.TransportSecurityTest/test actual WSS preserves selected session CSRF origin and revocation gates", - "WorkHarborWeb.TransportSecurityTest/test real runtime activation is HTTP1 only in every authenticated environment" + "WorkHarborWeb.TransportSecurityTest/test actual HTTP1 preserves browser form and CLI bearer authentication", + "WorkHarborWeb.TransportSecurityTest/test actual WSS preserves selected session CSRF origin and revocation gates" ] } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index ef8bc2a3..447a59be 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -769,3 +769,33 @@ Actual tests use all three merged base/runtime environment configurations and PhoenixAdapter child specs. Network request processes share the named repository's sandbox transaction; generated certificates/keys and listeners are ephemeral. Seven required transport IDs were appended without removing inherited IDs. + +Independent early S001 review found a listener-activation bypass: Phoenix treats +truthy server values as active and missing values may inherit serve_endpoints, +while the guard originally validated only literal true. A new actual selected +regression failed first (exit2,0/1; `/private/tmp/workharbor-s001-activation-red.log`). +The guard now requires exactly one explicit boolean server setting; false remains +inert and true requires full validation. Duplicate activation is also rejected. +The eighth required transport ID is appended; inherited Phoenix relative order is +restored to minimize manifest churn. Endpoint fixture cleanup is registered before +later setup assertions so failed setup restores shared state. + +First normal author `make check-apps` exited2: all Go gates passed, Phoenix111/113 +with only the same two inherited 4300-digit stale Jason failures seen in T007. +Raw log `/private/tmp/workharbor-s001-apps.log` is retained. No tests are waived. +Root authorized recompilation of the disposable locked Jason test artifact under +the committed4301configuration before rerunning normal application checks; fresh +pristine exact-candidate validation remains separately required. + +S001 normal author application gate is now GREEN after explicit locked Jason +`MIX_ENV=test mix deps.compile jason --force` (exit0, log +`/private/tmp/workharbor-s001-jason-recompile.log`). Full normal +`make check-apps PYTHON=/opt/homebrew/opt/python@3.14/bin/python3.14` exited0; +log `/private/tmp/workharbor-s001-apps-repaired.log`: host_agent132, api_client162, +whr1, Phoenix114 including all106 inherited and eight new transport IDs. +All Go format/vet/build/uncached test and Phoenix format/compile/assets/test gates +ran through the unchanged fixed helper. The advisory warning remains visible. +Focused eight transport cases passed with warnings-as-errors. Source bootstrap and +`git diff --check` passed; Python collection846 / required520 and unchanged +Feature001 tasks were checked. Exact committed/pristine verification and independent +Sol candidate review follow; no full canonical Python or live-host/browser claim. diff --git a/specs/006-whr-cli/contracts/authentication.md b/specs/006-whr-cli/contracts/authentication.md index 7e53010b..2b7066ca 100644 --- a/specs/006-whr-cli/contracts/authentication.md +++ b/specs/006-whr-cli/contracts/authentication.md @@ -125,6 +125,12 @@ precedes parsing. No router pipeline is assumed to have run for socket dispatch. ## 9. TLS and authority +The current authenticated listener is explicitly HTTP/1-only as the temporary +Bandit advisory mitigation in [the transport contract](transport-security.md). +HTTP/2 authority parsing rules below remain defensive pure admission behavior; +they do not enable HTTP/2 on a listener. T008 client-certificate options need a +separately reviewed narrow profile extension preserving this restriction. + Direct HTTPS is first. Reject plaintext before body/auth; never redirect credential requests. Active server configuration requires explicit bind, certificate/key, allowlisted authorities/origin and a generated non-demo session key. No default diff --git a/specs/006-whr-cli/contracts/transport-security.md b/specs/006-whr-cli/contracts/transport-security.md index 3a455bca..52d317dd 100644 --- a/specs/006-whr-cli/contracts/transport-security.md +++ b/specs/006-whr-cli/contracts/transport-security.md @@ -1,7 +1,7 @@ # S001 authenticated transport mitigation Status: finite design independently Sol CLEAR on 2026-10-14 before tests/behavior; -implementation and exact-candidate acceptance pending. This follow-up blocks T008. +implementation candidate complete; exact-candidate acceptance pending. This follow-up blocks T008. It preserves the owner API, HTTPS origin/authority, browser sessions, WSS and CLI contracts. It changes the current listener protocol profile only. @@ -26,7 +26,10 @@ Real host/browser pilot acceptance and signed-release SC-006 remain separate gat Every actual PHX_SERVER profile (dev, test and prod) uses Bandit.PhoenixAdapter, HTTPS only, and explicit `http_1_options: [enabled: true]` plus -`http_2_options: [enabled: false]`. Inert `server: false` remains allowed. +`http_2_options: [enabled: false]`. Exactly one explicit `server: false` is +inert; exactly one `server: true` requires the complete secure profile. Missing, +nonboolean or duplicate activation settings are rejected because Phoenix treats +truthy values as active and missing values can inherit `serve_endpoints`. RuntimeSecurity rejects active configurations that omit or alter these exact lists, use another adapter, contain duplicate HTTPS keys, or contain HTTPS keys outside `ip`, `port`, `certfile`, `keyfile`, `http_1_options`, `http_2_options`. @@ -44,7 +47,7 @@ No mTLS behavior is implemented by S001. ## Required failure-first evidence Independently constructed valid active configuration and each real runtime env -must pass. Missing, enabled, malformed or duplicated protocol settings, wrong +must pass. Missing/nonboolean/duplicate activation, missing/enabled/malformed/duplicated protocol settings, wrong adapter, and handler/transport/ALPN override attempts must fail closed. Preserve all prior required Phoenix, Go and Python IDs; append meaningful transport IDs. From 3b371001bf644a13db999f1122ac08cc4e7a8fe9 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 20:24:02 +0200 Subject: [PATCH 19/51] docs(hosts): freeze reviewed enrollment and liveness contract Consolidate accepted T008 identity, renewal, crash recovery and scoped liveness rules before failure-first tests. Preserve the final empty enrollment CSR correction and narrowly extend the reviewed HTTP1-only transport profile for optional verified agent certificates. Record exact S001 acceptance without claiming dependency security or pilot runtime completion. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 26 ++ .../006-whr-cli/contracts/host-enrollment.md | 273 ++++++++++++++++++ .../contracts/transport-security.md | 3 +- specs/006-whr-cli/tasks.md | 15 +- 4 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 specs/006-whr-cli/contracts/host-enrollment.md diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 447a59be..eec3c2c6 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -799,3 +799,29 @@ Focused eight transport cases passed with warnings-as-errors. Source bootstrap a `git diff --check` passed; Python collection846 / required520 and unchanged Feature001 tasks were checked. Exact committed/pristine verification and independent Sol candidate review follow; no full canonical Python or live-host/browser claim. + + +## S001 exact acceptance and T008 contract-first start + +S001 independently Sol CLEAR at c88f33959a4c0a767f638afaccc446a8040c55b4. +Author: /private/tmp/workharbor-s001-validation-h_fb25q3/evidence.json; +independent: /private/tmp/workharbor-s001-independent-leo2snzz/summary.json. +Actual full application gates: Phoenix114,whr1,host_agent132,api_client162 green; +846 Python collected/520 required retained, source pristine and prior IDs retained. +Mitigation only: locked Bandit remains affected; no dependency security clearance, +full canonical/live browser/pilot claim. Only Feature006 S001 is checked. + +Root authorized T008 after T007/S001 exact acceptance. Its one complete +[accepted V2 contract](../../../specs/006-whr-cli/contracts/host-enrollment.md) +includes the final empty-subject enrollment CSR addendum and actual interface +alignment. Same retained Sol reviewer must confirm faithful consolidation before +failure-first tests/behavior. No superseded descriptor/manual-only issuer/owner +bearer renewal design applies. T008 remains unchecked; Feature001/migration and +ignored selector unchanged. Resource review precedes disposable OrbStack activation. + + +The retained independent Sol reviewer confirmed faithful consolidation of the +complete T008 contract before tests/behavior. No superseded alternatives remain. +T008 implementation/failure-first tests may now proceed under root GO; no runtime +acceptance is claimed. The S001 transport contract status is refreshed to exact +c88f339 acceptance without changing its mitigation/advisory boundary. diff --git a/specs/006-whr-cli/contracts/host-enrollment.md b/specs/006-whr-cli/contracts/host-enrollment.md new file mode 100644 index 00000000..e6c84be5 --- /dev/null +++ b/specs/006-whr-cli/contracts/host-enrollment.md @@ -0,0 +1,273 @@ +# T008 host enrollment and connection contract + +Status: consolidated V2 independently Sol DESIGN CLEAR, including the final +empty-subject enrollment CSR addendum. Implementation GO follows accepted +T005 1189c81, T006 ef399f5, T007 e10c6997 and S001 c88f339. This document +freezes one proposal before tests/behavior; it claims no implementation evidence. +Author: assigned Sol author; independent reviewer: sol_enrollment_review. + +## Authority and scope + +Feature006 T008 retains Feature001 T004/T014: five-minute atomic one-time +enrollment, encrypted scoped identity, rotation/revocation, ten-second heartbeats, +monotonic unknown at30seconds, real-host handshake/reconnect and later browser +stale display within five seconds. Feature006 FR020/021 and FR035-038 apply. +T009 trust/provider and T010 durable effects/reconciliation remain separate. + +Reuse the explicitly configured direct HTTPS listener/server identity. Owner API +/api/v1 keeps login as its only owner-authentication exemption. Separate agent +protocol paths are POST /agent/v1/enroll, POST /agent/v1/identity/renew, +POST /agent/v1/identity/activate and GET /agent/v1/connect (WSS). First enrollment +uses code+CSR; renewal uses current active mTLS; activation admits only its exact +pending/current replacement certificate; connect requires active mTLS. No owner +bearer, browser cookie, body ID, authenticated flag or forwarded certificate is +host authority. Compare actual TLS peer certificate against persisted assignment. +Reject Cookie, Authorization, Origin, Sec-Fetch-* and X-WorkHarbor-Context on agent +paths. Require direct TLS, configured effective/raw authority, canonical exact +path, no query, bounded upgrade headers and no redirects. Run agent ingress before +BrowserSession; preserve owner/browser Origin/CSRF/store behavior and safe errors. + +Owner host operations use explicit Accounts actions host_enrollment_issue, +host_revoke and host_status with target-owner association checks before disclosure; +never alias credential revoke/status. Routine renewal needs no owner bearer. +Owner-triggered rotation is optional presentation; automatic scoped renewal is the +required rotation path. Closed API routes: POST /api/v1/hosts/enrollments accepts +schema_version=1 and host_id null-or-UUID; POST /api/v1/hosts/:id/revoke accepts +schema_version=1; GET /api/v1/hosts/:id returns owner-scoped status. Mutations use +owner-write, status uses read; dashboard cannot mutate. Secret code delivery is +private HTTPS response/no-store, never diagnostic/log/CLI output. + +## Narrow S001 extension + +The accepted Bandit HTTP1-only mitigation remains mandatory: Bandit.PhoenixAdapter, +exact http_1_options=[enabled:true], http_2_options=[enabled:false], explicit +boolean activation, no HTTP/redirect, demo secret, debug or reloader. Existing +HTTPS allowlist ip/port/certfile/keyfile/http_1_options/http_2_options may gain only +cacerts (one validated internal issuer DER), verify=:verify_peer and +fail_if_no_peer_cert=false as one complete optional-peer profile. Reject partial, +duplicate, malformed or different values and all generic handler, transport, +ALPN, plug or arbitrary overrides. Client roots are loaded from validated native +issuer state, never token-selected input. No HTTP2 re-enable/dependency clearance +is claimed. Existing owner-only profile remains valid when identity is inactive. + +## Closed wire and decoding + +Enrollment JSON has exactly schema_version=1, code and csr. Code is whe1_ plus +43 canonical unpadded base64url characters representing32random bytes. csr is +canonical standard-base64 DER, at most4096decoded bytes. Renewal contains exactly +schema_version=1 and csr; activation exactly schema_version=1. Raw bodies<=16384 +bytes, enforced before shared strict decoding. JSON rejects duplicates, unknown +fields, malformed UTF8/surrogates, floats, wrong types, trailing input and bad +base64 before mutation. Certificate/issuer DER each<=4096bytes; whole response +<=16384bytes. Response fields exactly schema_version,owner_id,host_id,identity_id, +certificate,issuer_certificate,expires_at (integer UTC seconds equal leaf NotAfter). + +WSS raw/canonical messages<=4096bytes; one strict JSON text object per message, +no compression. Shapes are closed/all-required: +- hello: schema_version=1,kind=hello,owner_id,host_id,capabilities. +- hello_ack: schema_version=1,kind=hello_ack,owner_id,host_id. +- heartbeat: schema_version=1,kind=heartbeat,owner_id,host_id. +- heartbeat_ack: schema_version=1,kind=heartbeat_ack,owner_id,host_id. +IDs are canonical lowercase UUIDs. Capabilities are unique [] or [docker]; T008 +emits [] until T009 measured integration. Intervals are fixed contract constants. +Compare asserted scope against enrolled peer assignment before acknowledgement. +Negotiate exact version1 only after authentication; no unauthenticated hello or +downgrade. Unknown/unsupported fields/versions reject in both languages. Add a +separate connection schema/fixture; existing T007 lifecycle validators stay pure +and unchanged. Use StrictJSON.decode_object and Go wirejson behind smaller bounds. + +## Native issuer and installation + +Use OTP public_key/crypto Ed25519 issuer and Go crypto/ed25519,x509,TLS, with no +OpenSSL runtime or signing-service dependency. An explicit future T023 installer +calls the native initializer once with an operator-selected private directory. +Create one exclusive atomic closed versioned issuer-state file with self-signed +CA certificate and PKCS8 private key, service-owned0600 in0700 directory. CA=true, +certSign usage, validity5*365days. Restart auto-loads that exact state; never create +or replace it implicitly. Missing/malformed/unsafe/invalid profile/signature, +key mismatch, not-yet-valid, expired or <24h remaining issuer blocks active identity +startup with fixed remediation. Native initializer tests use temporary fixtures; +no deployed initialization is performed by this implementation task. + +Server TLS public/OS roots or explicit private SERVER CA are separate operator +inputs. Agents must verify configured CP hostname and roots before secrets and +never learn server trust from enrollment response/internal client issuer. + +Leaf: Ed25519, CA=false, digitalSignature and clientAuth only, issuer-generated +serial, CN host UUID. Authority remains exact fingerprint+database status. Fixed +24h leaf: NotBefore=floor(UTC issuance seconds), NotAfter=NotBefore+86400; require +issuer>=24h remaining, never silently shorten. No expiry grace or backdating; +now>=NotAfter rejects. Require synchronized host/CP clocks; validity skew fails +closed with fixed clock/identity remediation, no insecure override. Response leaf +must match saved key, issuer/profile/validity, expected known assignment and host +CN, with expires_at exactly leaf NotAfter, before local acceptance. + +Initial CSR has EMPTY subject, version0, Ed25519 key/signature and no requested +extensions. Verify possession; derive host/owner only from code-bound record. +Initial pending host/owner stay null until verified HTTPS response; compare leaf +CN to returned host_id. Renewal CSR uses known host UUID subject and no requested +extensions; compare to current mTLS assignment. Unknown CSR attributes/extensions +are diagnosed, never silently admitted. No enrollment descriptor is used. + +## Atomic enrollment and revocation + +Persist only domain-separated SHA256 code hash, owner/host binding, issued_at, +expires_at=issued_at+300, consumed_at/revoked_at. At exact expiry reject. Owner nil +target allocates new stable host metadata and audit in the same immediate code +transaction; existing target must belong to owner and be non-revoked. Do not nest +Foundation.create_host (it rejects nested transactions); use its existing record +constraints inside the T008 top-level transaction. + +Agent saves pending key+CSR before redemption. One short top-level immediate +transaction checks code AND host revocation, consumes it, persists active +fingerprint/assignment, retires any prior/pending identity on explicit same-host +re-enrollment, and appends fixed audit. Commit before response; one independent +writer wins. Expired/consumed/revoked codes issue/replay nothing. Lost response +preserves pending key and reports incomplete; explicit owner newcode for SAME +non-revoked host recovers. Revoked host is retained tombstone: issuance/redemption +cannot resurrect it; explicit new enrollment has a new host ID. + +Issue/redeem/renew/first activation/revoke use serialized immediate transactions. +Revoke atomically tombstones host, invalidates all codes/current/pending identities, +and audits. Revoke first prevents later issuance/activation; issuance first is +invalidated by later revoke. Delayed response confers no continuing authority. +Database negatives enforce no reactivation after revocation. Fence active/retired +sockets and recheck DB identity on every incoming/outgoing application message and +upgrade. No claim to reverse an already executed provider effect. + +## Renewal and crash recovery + +Automatic current-active-mTLS renewal begins with<=6h remaining under enrollment's +standing policy, without owner credentials or repeated approval. Persist one pending +replacement linked to exact predecessor and CSR SHA256. Exact staging retry under +valid active old cert returns same public certificate; different CSR conflicts. +Expired/retired/revoked old cert cannot stage, including historical result lookup. + +First activation through pending mTLS checks non-revoked host, exact pending/current +predecessor and BOTH old/new cert valid; atomically promotes new, retires old and +audits. Pending cert authorizes only activation, never connect/heartbeat/renew or +history. Retry with now-current valid new cert returns immutable already-activated +success even after old expiry. It cannot revive expired/revoked new identity. + +One atomic host identity.json contains closed version, endpoint/server-trust +reference, known owner/host assignment, active key/cert/issuer, pending null or +key/CSR/cert/issuer/predecessor identity. Initial assignment may be null and cannot +connect. Persist pending key/CSR before stage and returned cert/issuer before first +activation. Retain BOTH old/new until activation acknowledgement, then atomically +promote pending. Startup pending cert first attempts activation/replay; pending +CSR-only retries stage under valid active cert. Thus CP promotion followed by lost +ack/local crash recovers via saved new key. First staging/activation after old expiry +fails and requires explicit owner newcode recovery. Preserve all state and report +failure; never claim success from a local write alone. + +Installed Linux path /var/lib/workharbor/identity/identity.json, service-owned0600 +in0700 directory outside workspaces/Git/containerenv. Atomic writes require +restrictive create, file fsync, rename and parent fsync. Reject symlinks, unsafe +ownership/mode, malformed keys/certs and unknown private schemas; never reset or +regenerate silently. No private key leaves host. + +## Live expiry and reconnect + +Every admission/incoming/outgoing message checks min(leaf,issuer) expiry. Compute +monotonic close deadline at authentication from remaining validity; backwards UTC +change cannot extend it. Force close at deadline; <=1s watchdog catches forward +clock jumps on idle sockets. No message at/after wall expiry is admitted before +watchdog close. Agent mirrors expiry and stops expired active/pending use. + +Heartbeat immediately after hello then every10s; acknowledge each heartbeat. +Without authenticated heartbeat for>=30s mark unknown and close stale socket. +Only transport reconnect uses bounded1/2/4/8/16/30s backoff then30s maximum; no blind +provider resends or command-history resets. Health requires authenticated hello +and heartbeat acknowledgement. UTC last_seen/lease are display only. + +## Fenced conservative liveness overlay + +Preserve Foundation terminal results/runtime history, desired state, counters, +provider IDs/config and its no-repeatable-observation-API scope. Add host connection +slot (scoped host, current server-generated connection UUID or null, unknown/connected) +and runtime freshness annotation (runtime ID+immutable generation, workspace intent, +connection UUID). Connection UUID is internal CAS fencing, never a wire token, +cryptographic authority or lifecycle identity. + +Native HostConnections open(verified_peer_context), receive_hello(peer_ref,hello), +heartbeat(peer_ref,heartbeat), mark_stale(peer_ref,lease_ref), project_runtime(owner, +runtime), record_fresh_observation(peer_ref,runtime,generation,intent). Actual TLS +admission creates opaque context; no body IDs manufacture it. One worker serializes +current socket per host; fresh connection UUID is persisted before heartbeat. +Each authenticated heartbeat replaces opaque ephemeral timer lease_ref. + +Projection is unknown unless authorized association, current live worker/socket, +same persisted connection UUID, active non-revoked valid identity, heartbeat age<30s, +and annotation matching connection+current runtime generation+workspace current +intent all hold. Missing worker/persistence failure/annotation gives unknown, never +historical running. Heartbeat/reconnect cannot freshen runtimes. Startup invalidates +slots unknown/null before admission; worker restart invalidates its old slot. Old +annotations cannot match after restart/reconnect; no history is overwritten. + +Stale requires matching current socket/peer_ref/lease_ref and age>=30s, then +immediate CAS current connection UUID, unknown liveness and fixed audit. Close +stale socket, reject late heartbeat, require new authenticated connection. Old timer +cannot overwrite fresh reconnect. Persistence failure keeps worker projection +unknown and reports fixed failure, never claims durable audit success. + +record_fresh_observation annotates only: immediate transaction checks current +live valid peer, fresh heartbeat, authoritative owner/host/workspace/current runtime, +exact generation and workspace intent, then writes annotation+fixed audit. It +accepts no state/provider/config payload. T010 invokes ONLY after actual inspected/ +current guarded observation commits, never heartbeat/cached terminal replay/copied +body IDs. Crash before annotation remains unknown; intervening intent/connection +change rejects. T008 tests explicit metadata fixtures, not provider reconciliation. +T010 must review first-create admission/local ledger and actual observation path. + +T007 Peer/Snapshot remain separate: Elixir kinds :control_plane/:host, Go strings; +T008 constructs only authenticated peer scope. Missing authoritative operation +snapshot continues fail closed. Never create Snapshot by copying incoming command. +Reconnect/rotation preserve command IDs, op/key identity, intent and generations. + +## Offboard and redaction + +T015 confirms revocation through T006 owner-authenticated host status/idempotent +host revoke, including rerun after browser revocation. Revoked cert gains no broad +confirmation bypass. CLI owner credential remains separate private CLI profile, +never agent identity state/environment. Without confirmation keep agent stopped, +retain recoverable credentials/data and report incomplete/nonzero; absence is not +successful offboarding. Default project data retention remains unchanged. + +Audit every persisted connection/heartbeat/identity/freshness mutation with fixed +server-known IDs/action/outcome/code only; respect accepted scope constraints and +one-time operation audit uniqueness. Never raw codes, keys, CSR/certs, requests, +URLs, names, provider payloads, SQL or exceptions in logs/reports/audit. Secret input +is private file/stdin/pipe; T015 owns CLI integration. Error bodies are fixed. + +## Verification and resource boundary + +Failure-first tests: independent SQLite writer single-use and issue/redeem/renew/ +activation-vs-revoke races; expiry300s and cert/issuer boundaries; direct SQL no +resurrection; issuer init exclusivity/restart and bad/near-expiry startup; real CSR +key/host/root/EKU/algorithm negatives; file mode/owner/symlink/schema/fsync/rename +failures; every active/pending crash boundary and lost activation acknowledgement; +new-current replay after old expiry versus first activation after old expiry; +open socket expiry/clock changes; monotonic29.999/30/30.001s stale, replaced sockets/ +timers, restart, fresh annotations and wrong intent/generation; strict shared +fixtures/bounds; credential sentinel redaction. No test can treat fixtures as +cryptographic/provider proof. + +Genuine ephemeral macOS Elixir HTTPS/WSS + Go client tests must exercise enrollment, +hello/heartbeat, socket loss/reconnect, rotation, old/pending/revoked/expired refusal, +expiry/fencing and server root/name failure while preserving S001 HTTP1-only ALPN. +Separately recreate an authorized isolated Ubuntu OrbStack machine, both genuine +Elixir and Go endpoints INSIDE guest, no host-network isolation bypass, explicit +source inputs/pinned Linux toolchain/private fixtures, actual real Linux handshake/ +reconnect/rotation/revoke and full cleanup. Generic prior smoke/teardown is evidence +at /private/tmp/workharbor-systemd-fixture-iyZ4ILpH, not T008 acceptance. Review +resource plan before activation;2CPU/2GiB/16GiB was smoke sizing, not load capacity. +Systemd is for the Linux host, never workspace images. No persistent/deployed runtime +is authorized. Later browser<=5s, provider<=30s and five-workspace/eight-attachment +load remain mandatory; mocks/macOS loopback never replace Linux evidence. + +Run all scoped Go/Phoenix application gates, unchanged required-ID inventory, +source/manifest/repository/diff checks and fresh pristine exact-candidate checks, +then same independent Sol reviewer fixes/CLEAR. T008 stays unchecked until exact +acceptance; Feature001 markers/frozen migration remain unchanged. Full canonical +Python is reserved for final combined head unless helper/policy concern arises. +No push/PR/release/merge/deploy in this WI. diff --git a/specs/006-whr-cli/contracts/transport-security.md b/specs/006-whr-cli/contracts/transport-security.md index 52d317dd..a99f32ae 100644 --- a/specs/006-whr-cli/contracts/transport-security.md +++ b/specs/006-whr-cli/contracts/transport-security.md @@ -1,7 +1,8 @@ # S001 authenticated transport mitigation Status: finite design independently Sol CLEAR on 2026-10-14 before tests/behavior; -implementation candidate complete; exact-candidate acceptance pending. This follow-up blocks T008. +implementation independently Sol CLEAR at c88f33959a4c0a767f638afaccc446a8040c55b4. +The T008 prerequisite is satisfied; the mitigation-only advisory remains. It preserves the owner API, HTTPS origin/authority, browser sessions, WSS and CLI contracts. It changes the current listener protocol profile only. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index 881b71cd..ee6369f5 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -6,8 +6,9 @@ Status: execution in progress. T001 scaffold independently Sol CLEAR at `191c62baf227f64122770244d62d289900bb4d8d`; T004 independently CLEAR at `6abb7419c77b3712a6ac276bc798b102503d3144`; T005 and T006 are accepted at `1189c81` and `ef399f5`. T007 is independently CLEAR at -`e10c6997c24407e0f472a7ea22ba2dd1b42067e3` for its pure protocol scope. S001 blocks -T008 until its exact-candidate transport mitigation review and checks pass. +`e10c6997c24407e0f472a7ea22ba2dd1b42067e3` for its pure protocol scope. S001 is independently CLEAR at +`c88f33959a4c0a767f638afaccc446a8040c55b4`; T008 implementation is authorized +and remains unchecked pending its exact-candidate acceptance. No product runtime acceptance is claimed. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), @@ -39,7 +40,7 @@ Independent check: negative vectors, cross-language bytes and no runtime Python. - [x] T006 Implement owner bootstrap/login, revocable scoped credentials and browser request protections in `apps/control_plane/lib/workharbor/accounts.ex`, `apps/control_plane/lib/workharbor_web/` and tests; prove TLS-first auth, wrong owner, expired/revoked token, read-only dashboard scope, Host/Origin/anti-forgery rejection and secret redaction (001 T013; FR-035–038). - [x] T007 Define/validate versioned host protocol in `contracts/host-protocol/`, `apps/control_plane/lib/workharbor/host_protocol.ex`, `apps/host_agent/internal/protocol/`; reject invalid version/scope/replay before provider calls and redact diagnostics in both suites (001 T003). -- [ ] S001 Mitigate locked Bandit 1.12.5 HTTP/2 resource exhaustion advisory GHSA-69m6-wcv4-rwcf before T008: enforce the reviewed HTTP/1-only authenticated listener profile, refuse unsafe overrides, and prove actual ephemeral TLS protocol rejection plus HTTP/1 authentication and WSS success. Follow [the bounded transport contract](contracts/transport-security.md); retain the dependency advisory and require a released fixed version plus new independent review before re-enabling HTTP/2. No dependency update, proxy or T008 behavior belongs to this follow-up. +- [x] S001 Mitigate locked Bandit 1.12.5 HTTP/2 resource exhaustion advisory GHSA-69m6-wcv4-rwcf before T008: enforce the reviewed HTTP/1-only authenticated listener profile, refuse unsafe overrides, and prove actual ephemeral TLS protocol rejection plus HTTP/1 authentication and WSS success. Follow [the bounded transport contract](contracts/transport-security.md); retain the dependency advisory and require a released fixed version plus new independent review before re-enabling HTTP/2. No dependency update, proxy or T008 behavior belongs to this follow-up. ## Phase 3: US1 — Authenticate and manage workspaces (P1) @@ -129,3 +130,11 @@ evidence, including earlier failed runs, is retained in T005 independently Sol CLEAR at `1189c81ba81b79a47a0485a9d5148fb8286c8eb0`; its native persistence scope does not establish authentication or raw JSON decoding. T006 follows [the consolidated authentication contract](contracts/authentication.md). + +S001 independently Sol CLEAR at `c88f33959a4c0a767f638afaccc446a8040c55b4`. +Author evidence: `/private/tmp/workharbor-s001-validation-h_fb25q3/evidence.json`; +independent: `/private/tmp/workharbor-s001-independent-leo2snzz/summary.json`. +Actual gates: Phoenix114, whr1, host_agent132, api_client162 green; Python846 +collected/520required retained. HTTP1-only mitigation; Bandit remains affected. +No full canonical/live browser/pilot acceptance is claimed. T008 follows +[the consolidated enrollment contract](contracts/host-enrollment.md). From 8984da11e5faae21593c0cdb8b65219cb09cf7a9 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 22:46:18 +0200 Subject: [PATCH 20/51] feat(hosts): enroll and connect authenticated host identities Bind host enrollment, renewal and revocation to durable scoped identities, and fence stale or expired outbound connections without rewriting lifecycle history. Preserve HTTP/1-only ingress and fixed secret-safe diagnostics. Refs: #72 Co-authored-by: Codex --- apps/control_plane/config/runtime.exs | 5 + .../lib/mix/tasks/workharbor.hosts.init.ex | 18 + apps/control_plane/lib/workharbor/accounts.ex | 12 +- .../lib/workharbor/application.ex | 20 +- .../lib/workharbor/host_connections.ex | 449 ++++++++++++ .../workharbor/host_connections/protocol.ex | 37 + apps/control_plane/lib/workharbor/hosts.ex | 425 +++++++++++ .../lib/workharbor/hosts/issuer.ex | 311 ++++++++ .../lib/workharbor/hosts/private_file.ex | 105 +++ .../lib/workharbor_web/agent_ingress.ex | 184 +++++ .../lib/workharbor_web/agent_log_filter.ex | 58 ++ .../lib/workharbor_web/agent_socket.ex | 47 ++ .../lib/workharbor_web/endpoint.ex | 7 +- .../lib/workharbor_web/host_controller.ex | 58 ++ .../lib/workharbor_web/router.ex | 30 + .../lib/workharbor_web/runtime_security.ex | 64 +- .../20261014020000_host_identity.exs | 55 ++ .../test/support/host_database.ex | 23 + .../test/support/host_fixture.ex | 61 ++ .../test/support/host_log_capture.ex | 9 + .../test/support/host_transport_endpoint.ex | 14 + .../test/workharbor/host_connections_test.exs | 426 +++++++++++ .../test/workharbor/host_identity_test.exs | 448 ++++++++++++ .../workharbor_web/agent_ingress_test.exs | 83 +++ .../workharbor_web/agent_log_filter_test.exs | 102 +++ .../test/workharbor_web/host_api_test.exs | 126 ++++ .../workharbor_web/host_transport_test.exs | 662 ++++++++++++++++++ .../transport_security_test.exs | 52 ++ apps/host_agent/cmd/workharbor-agent/main.go | 15 +- .../cmd/workharbor-agent/main_test.go | 7 + apps/host_agent/go.mod | 2 + apps/host_agent/go.sum | 2 + apps/host_agent/internal/connection/client.go | 372 ++++++++++ .../internal/connection/client_test.go | 191 +++++ .../internal/connection/protocol.go | 59 ++ .../internal/connection/protocol_test.go | 75 ++ .../testdata/transport_client/main.go | 484 +++++++++++++ apps/host_agent/internal/identity/identity.go | 550 +++++++++++++++ .../internal/identity/identity_test.go | 213 ++++++ docs/development/ci-acceptance.json | 78 ++- docs/exec-plans/active/installable-pilot.md | 144 ++++ .../contracts/host-connection.fixtures.json | 61 ++ .../contracts/host-connection.schema.json | 74 ++ .../006-whr-cli/contracts/host-enrollment.md | 30 +- specs/006-whr-cli/tasks.md | 4 +- third_party/coder-websocket/LICENSE | 13 + third_party/coder-websocket/PROVENANCE.md | 14 + 47 files changed, 6263 insertions(+), 16 deletions(-) create mode 100644 apps/control_plane/lib/mix/tasks/workharbor.hosts.init.ex create mode 100644 apps/control_plane/lib/workharbor/host_connections.ex create mode 100644 apps/control_plane/lib/workharbor/host_connections/protocol.ex create mode 100644 apps/control_plane/lib/workharbor/hosts.ex create mode 100644 apps/control_plane/lib/workharbor/hosts/issuer.ex create mode 100644 apps/control_plane/lib/workharbor/hosts/private_file.ex create mode 100644 apps/control_plane/lib/workharbor_web/agent_ingress.ex create mode 100644 apps/control_plane/lib/workharbor_web/agent_log_filter.ex create mode 100644 apps/control_plane/lib/workharbor_web/agent_socket.ex create mode 100644 apps/control_plane/lib/workharbor_web/host_controller.ex create mode 100644 apps/control_plane/priv/repo/migrations/20261014020000_host_identity.exs create mode 100644 apps/control_plane/test/support/host_database.ex create mode 100644 apps/control_plane/test/support/host_fixture.ex create mode 100644 apps/control_plane/test/support/host_log_capture.ex create mode 100644 apps/control_plane/test/support/host_transport_endpoint.ex create mode 100644 apps/control_plane/test/workharbor/host_connections_test.exs create mode 100644 apps/control_plane/test/workharbor/host_identity_test.exs create mode 100644 apps/control_plane/test/workharbor_web/agent_ingress_test.exs create mode 100644 apps/control_plane/test/workharbor_web/agent_log_filter_test.exs create mode 100644 apps/control_plane/test/workharbor_web/host_api_test.exs create mode 100644 apps/control_plane/test/workharbor_web/host_transport_test.exs create mode 100644 apps/host_agent/go.sum create mode 100644 apps/host_agent/internal/connection/client.go create mode 100644 apps/host_agent/internal/connection/client_test.go create mode 100644 apps/host_agent/internal/connection/protocol.go create mode 100644 apps/host_agent/internal/connection/protocol_test.go create mode 100644 apps/host_agent/internal/connection/testdata/transport_client/main.go create mode 100644 apps/host_agent/internal/identity/identity.go create mode 100644 apps/host_agent/internal/identity/identity_test.go create mode 100644 specs/006-whr-cli/contracts/host-connection.fixtures.json create mode 100644 specs/006-whr-cli/contracts/host-connection.schema.json create mode 100644 third_party/coder-websocket/LICENSE create mode 100644 third_party/coder-websocket/PROVENANCE.md diff --git a/apps/control_plane/config/runtime.exs b/apps/control_plane/config/runtime.exs index 884bc9fa..94c79eae 100644 --- a/apps/control_plane/config/runtime.exs +++ b/apps/control_plane/config/runtime.exs @@ -41,3 +41,8 @@ if System.get_env("PHX_SERVER") == "true" do http_2_options: [enabled: false] ] end + +# The issuer must already exist; initialization is a separate explicit command. +if issuer_dir = System.get_env("WORKHARBOR_ISSUER_DIR") do + config :workharbor, :host_identity, issuer_dir: issuer_dir +end diff --git a/apps/control_plane/lib/mix/tasks/workharbor.hosts.init.ex b/apps/control_plane/lib/mix/tasks/workharbor.hosts.init.ex new file mode 100644 index 00000000..f878ee8d --- /dev/null +++ b/apps/control_plane/lib/mix/tasks/workharbor.hosts.init.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule Mix.Tasks.Workharbor.Hosts.Init do + @shortdoc "Explicitly initialize the private client certificate issuer" + use Mix.Task + @impl true + def run(["--issuer-dir", path]) do + Mix.Task.run("app.config") + {:ok, _} = Application.ensure_all_started(:public_key) + + case WorkHarbor.Hosts.Issuer.initialize(path) do + {:ok, _} -> Mix.shell().info("Client issuer initialized") + _ -> Mix.raise("Client issuer initialization refused") + end + end + + def run(_), do: Mix.raise("Usage: mix workharbor.hosts.init --issuer-dir PATH") +end diff --git a/apps/control_plane/lib/workharbor/accounts.ex b/apps/control_plane/lib/workharbor/accounts.ex index 44d9adc5..cd26b7e9 100644 --- a/apps/control_plane/lib/workharbor/accounts.ex +++ b/apps/control_plane/lib/workharbor/accounts.ex @@ -34,8 +34,15 @@ defmodule WorkHarbor.Accounts do alias WorkHarbor.Foundation.{Owner, AuditEvent, Request} alias WorkHarbor.Accounts.{Authenticator, Credential, Principal} @kinds ["cli_owner", "owner_browser", "dashboard"] - @writes [:logout, :revoke, :issue_dashboard, :primary_login] - @reads [:status, :owner_page, :dashboard_page, :credential_list] + @writes [ + :logout, + :revoke, + :issue_dashboard, + :primary_login, + :host_enrollment_issue, + :host_revoke + ] + @reads [:status, :owner_page, :dashboard_page, :credential_list, :host_status] def now, do: Application.get_env(:workharbor, :clock, fn -> System.system_time(:second) end).() def bootstrap(deliver, owner_id \\ nil) when is_function(deliver, 1) do @@ -259,6 +266,7 @@ defmodule WorkHarbor.Accounts do defp action_allowed?(kind, a) when a in @writes, do: kind in ["cli_owner", "owner_browser"] defp action_allowed?(kind, :owner_page), do: kind == "owner_browser" defp action_allowed?(kind, :dashboard_page), do: kind == "dashboard" + defp action_allowed?(kind, :host_status), do: kind in ["cli_owner", "owner_browser"] defp action_allowed?(_, a) when a in [:status, :credential_list], do: true defp action_allowed?(_, _), do: false defp safe_action(a) when a in @writes or a in @reads, do: Atom.to_string(a) diff --git a/apps/control_plane/lib/workharbor/application.ex b/apps/control_plane/lib/workharbor/application.ex index 193d069f..414ad0d6 100644 --- a/apps/control_plane/lib/workharbor/application.ex +++ b/apps/control_plane/lib/workharbor/application.ex @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: EUPL-1.2 + defmodule WorkHarbor.Application do # See https://elixir.hexdocs.pm/Application.html # for more information on OTP Applications @@ -7,23 +9,39 @@ defmodule WorkHarbor.Application do @impl true def start(_type, _args) do + identity = Application.get_env(:workharbor, :host_identity) + + {:ok, endpoint} = + WorkHarborWeb.RuntimeSecurity.identity_config( + Application.fetch_env!(:workharbor, WorkHarborWeb.Endpoint), + identity + ) + + Application.put_env(:workharbor, WorkHarborWeb.Endpoint, endpoint) + WorkHarborWeb.RuntimeSecurity.validate!( - Application.fetch_env!(:workharbor, WorkHarborWeb.Endpoint), + endpoint, Application.fetch_env!(:workharbor, :owner_http) ) + if identity, do: :ok = WorkHarborWeb.AgentLogFilter.install() + children = [ WorkHarborWeb.Telemetry, WorkHarbor.Repo, {Ecto.Migrator, repos: Application.fetch_env!(:workharbor, :ecto_repos), skip: skip_migrations?()}, {Phoenix.PubSub, name: WorkHarbor.PubSub}, + # Liveness is initialized before the listener when host identity is configured. # Start a worker by calling: WorkHarbor.Worker.start_link(arg) # {WorkHarbor.Worker, arg}, # Start to serve requests, typically the last entry WorkHarborWeb.Endpoint ] + children = + if identity, do: List.insert_at(children, -2, WorkHarbor.HostConnections), else: children + # See https://elixir.hexdocs.pm/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: WorkHarbor.Supervisor] diff --git a/apps/control_plane/lib/workharbor/host_connections.ex b/apps/control_plane/lib/workharbor/host_connections.ex new file mode 100644 index 00000000..71f33044 --- /dev/null +++ b/apps/control_plane/lib/workharbor/host_connections.ex @@ -0,0 +1,449 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostConnections do + @moduledoc "Serialized socket ownership and conservative liveness; no lifecycle dispatcher." + use GenServer + alias WorkHarbor.{Repo, Hosts, Accounts} + alias WorkHarbor.HostConnections.Protocol + alias WorkHarbor.Hosts.Peer + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + def open(peer, socket), do: call(__MODULE__, {:open, peer, socket}) + def receive_hello(ref, message), do: peer_call(ref, {:hello, message}) + def heartbeat(ref, message), do: peer_call(ref, {:heartbeat, message}) + def mark_stale(ref, lease), do: peer_call(ref, {:stale, lease}) + + def record_fresh_observation(ref, runtime, generation, intent), + do: peer_call(ref, {:observe, runtime, generation, intent}) + + def disconnect(ref), do: peer_call(ref, :disconnect) + + def project_runtime(principal, runtime) do + with :ok <- Accounts.authorize(principal, :host_status) do + case call(__MODULE__, {:project, principal.owner_id, runtime}) do + {:ok, _} = result -> result + {:error, :not_found} = result -> result + _ -> unknown_projection(principal.owner_id, runtime) + end + end + end + + defp runtime_row(owner, runtime), + do: + Hosts.one( + "SELECT r.*,w.owner_id,w.intent_version FROM runtime_instances r JOIN workspaces w ON r.workspace_id=w.id WHERE r.id=? AND w.owner_id=?", + [runtime, owner] + ) + + defp unknown_projection(owner, runtime) do + case runtime_row(owner, runtime) do + nil -> {:error, :not_found} + row -> {:ok, %{observed_state: "unknown", host_id: row["host_id"]}} + end + end + + def host_state(host), do: call(__MODULE__, {:host_state, host}) + + defp peer_call({server, ref}, message) when is_pid(server) and is_reference(ref), + do: call(server, {:peer, ref, message}) + + defp peer_call(_, _), do: {:error, :unauthorized_host} + + defp call(server, message) do + GenServer.call(server, message, 5000) + catch + :exit, _ -> {:error, :unauthorized_host} + end + + @impl true + def init(opts) do + Repo.put_dynamic_repo(Keyword.get(opts, :repo, Repo)) + + {:ok, _} = + Hosts.transaction(fn -> + rows = + Hosts.sql( + "SELECT host_id,owner_id FROM host_connection_slots WHERE connection_id IS NOT NULL" + ).rows + + Hosts.sql("UPDATE host_connection_slots SET state='unknown',connection_id=NULL") + + Enum.each(rows, fn [host, owner] -> + Hosts.audit(owner, host, "host_connection_reset", "host_restart", "unknown") + end) + end) + + Process.send_after(self(), :tick, 1000) + {:ok, %{peers: %{}, hosts: %{}}} + end + + @impl true + def handle_call({:open, %Peer{} = peer, socket}, _, state) when is_pid(socket) do + if Hosts.current_peer?(peer) do + state = + case state.hosts[peer.host_id] do + nil -> state + old -> close(state, old, :replaced) + end + + id = Ecto.UUID.generate() + ref = make_ref() + mono = monotonic() + + case Hosts.transaction(fn -> + if not Hosts.current_peer?(peer), do: Repo.rollback(:unauthorized_host) + + Hosts.sql( + "INSERT INTO host_connection_slots(host_id,owner_id,connection_id,state) VALUES(?,?,?,'unknown') ON CONFLICT(host_id) DO UPDATE SET connection_id=excluded.connection_id,state='unknown'", + [peer.host_id, peer.owner_id, id] + ) + + Hosts.audit(peer.owner_id, peer.host_id, "host_connected", "host_connect", "unknown") + end) do + {:ok, _} -> + Phoenix.PubSub.subscribe(WorkHarbor.PubSub, "host: " <> peer.host_id) + monitor = Process.monitor(socket) + + expiry = + mono + max(0, min(peer.expires_at, peer.issuer_expires_at) - Accounts.now()) * 1000 + + expiry_timer = Process.send_after(self(), {:expire, ref, id}, max(0, expiry - mono)) + + entry = %{ + peer: peer, + socket: socket, + monitor: monitor, + id: id, + negotiated: false, + opened: mono, + seen: nil, + lease: nil, + expiry: expiry, + expiry_timer: expiry_timer + } + + {:reply, {:ok, {self(), ref}}, + %{ + state + | peers: Map.put(state.peers, ref, entry), + hosts: Map.put(state.hosts, peer.host_id, ref) + }} + + _ -> + {:reply, {:error, :unauthorized_host}, state} + end + else + {:reply, {:error, :unauthorized_host}, state} + end + end + + def handle_call({:open, _, _}, _, state), do: {:reply, {:error, :unauthorized_host}, state} + + def handle_call({:peer, ref, message}, _, state) do + case state.peers[ref] do + nil -> + {:reply, {:error, :unauthorized_host}, state} + + entry -> + cond do + not valid_entry?(entry) -> + {:reply, {:error, :unauthorized_host}, close(state, ref, :identity_invalid)} + + stale?(entry) -> + {state, result} = close_result(state, ref, :stale) + reply = if result == :ok, do: {:error, :unauthorized_host}, else: result + {:reply, reply, state} + + true -> + peer_message(state, ref, entry, message) + end + end + end + + def handle_call({:project, owner, runtime}, _, state) do + result = + Hosts.transaction(fn -> + case runtime_row(owner, runtime) do + nil -> + {:error, :not_found} + + row -> + entry = state.peers[state.hosts[row["host_id"]]] + + annotation = + Hosts.one("SELECT * FROM runtime_freshness WHERE runtime_id=?", [runtime]) + + known = + entry && annotation && fresh?(entry) && row["is_current"] == 1 && + is_nil(row["deleted_at"]) && + annotation["connection_id"] == entry.id && + annotation["generation"] === row["generation"] && + annotation["intent_version"] === row["intent_version"] + + {:ok, + %{ + observed_state: if(known, do: row["observed_state"], else: "unknown"), + host_id: row["host_id"] + }} + end + end) + + reply = + case result do + {:ok, value} -> value + _ -> {:error, :persistence_failed} + end + + {:reply, reply, state} + end + + def handle_call({:host_state, host}, _, state) do + entry = state.peers[state.hosts[host]] + {:reply, {:ok, if(entry && fresh?(entry), do: "connected", else: "unknown")}, state} + end + + defp peer_message(state, ref, entry, {:hello, message}) do + with false <- entry.negotiated, + {:ok, %{"kind" => "hello"}} <- + Protocol.validate(message, entry.peer.owner_id, entry.peer.host_id) do + entry = %{entry | negotiated: true} + {:reply, {:ok, ack(entry, "hello_ack")}, put_entry(state, ref, entry)} + else + _ -> {:reply, {:error, :invalid_connection_message}, close(state, ref, :invalid_message)} + end + end + + defp peer_message(state, ref, entry, {:heartbeat, message}) do + with true <- entry.negotiated, + {:ok, %{"kind" => "heartbeat"}} <- + Protocol.validate(message, entry.peer.owner_id, entry.peer.host_id) do + case Hosts.transaction(fn -> + current_slot!(entry) + if not Hosts.current_peer?(entry.peer), do: Repo.rollback(:unauthorized_host) + now = DateTime.from_unix!(Accounts.now()) |> DateTime.to_iso8601() + lease = DateTime.from_unix!(Accounts.now() + 30) |> DateTime.to_iso8601() + + Hosts.sql("UPDATE hosts SET last_seen=?,lease_expires_at=? WHERE id=?", [ + now, + lease, + entry.peer.host_id + ]) + + Hosts.sql( + "UPDATE host_connection_slots SET state='connected' WHERE host_id=? AND connection_id=?", + [entry.peer.host_id, entry.id] + ) + + Hosts.audit( + entry.peer.owner_id, + entry.peer.host_id, + "host_heartbeat", + "host_heartbeat", + "authenticated" + ) + end) do + {:ok, _} -> + entry = %{entry | seen: monotonic(), lease: make_ref()} + Process.send_after(self(), {:stale, ref, entry.lease}, 30000) + {:reply, {:ok, ack(entry, "heartbeat_ack")}, put_entry(state, ref, entry)} + + _ -> + {:reply, {:error, :persistence_failed}, close(state, ref, :persistence_failed)} + end + else + _ -> {:reply, {:error, :invalid_connection_message}, close(state, ref, :invalid_message)} + end + end + + defp peer_message(state, ref, entry, {:stale, lease}) do + if entry.lease === lease and stale?(entry) do + {state, result} = close_result(state, ref, :stale) + {:reply, result, state} + else + {:reply, :ok, state} + end + end + + defp peer_message(state, _ref, entry, {:observe, runtime, generation, intent}) do + if fresh?(entry) and is_integer(generation) and generation > 0 and is_integer(intent) and + intent > 0 do + result = + Hosts.transaction(fn -> + current_slot!(entry) + if not fresh?(entry), do: Repo.rollback(:stale_observation) + + row = + Hosts.one( + "SELECT r.workspace_id FROM runtime_instances r JOIN workspaces w ON w.id=r.workspace_id WHERE r.id=? AND r.generation=? AND r.host_id=? AND w.owner_id=? AND w.intent_version=? AND r.is_current=1 AND r.deleted_at IS NULL", + [runtime, generation, entry.peer.host_id, entry.peer.owner_id, intent] + ) + + if is_nil(row), do: Repo.rollback(:stale_observation) + + Hosts.sql( + "INSERT INTO runtime_freshness(runtime_id,generation,intent_version,connection_id) VALUES(?,?,?,?) ON CONFLICT(runtime_id) DO UPDATE SET generation=excluded.generation,intent_version=excluded.intent_version,connection_id=excluded.connection_id", + [runtime, generation, intent, entry.id] + ) + + Hosts.audit( + entry.peer.owner_id, + entry.peer.host_id, + "runtime_freshness_recorded", + "runtime_observed", + "recorded", + workspace_id: row["workspace_id"], + runtime_id: runtime, + generation: generation, + intent_version: intent + ) + + :ok + end) + + {:reply, if(result == {:ok, :ok}, do: :ok, else: result), state} + else + {:reply, {:error, :stale_observation}, state} + end + end + + defp peer_message(state, ref, _entry, :disconnect) do + {state, result} = close_result(state, ref, :disconnected) + {:reply, result, state} + end + + defp peer_message(state, ref, _entry, _), + do: {:reply, {:error, :invalid_connection_message}, close(state, ref, :invalid_message)} + + @impl true + def handle_info(:tick, state) do + state = sweep(state) + Process.send_after(self(), :tick, 1000) + {:noreply, state} + end + + def handle_info(:identity_changed, state), do: {:noreply, sweep(state)} + + def handle_info({:stale, ref, lease}, state) do + entry = state.peers[ref] + + {:noreply, + if(entry && entry.lease === lease && stale?(entry), + do: close(state, ref, :stale), + else: state + )} + end + + def handle_info({:expire, ref, id}, state) do + entry = state.peers[ref] + + {:noreply, + if(entry && entry.id == id && monotonic() >= entry.expiry, + do: close(state, ref, :identity_invalid), + else: state + )} + end + + def handle_info({:DOWN, monitor, :process, _, _}, state) do + ref = + Enum.find_value(state.peers, fn {ref, entry} -> if entry.monitor == monitor, do: ref end) + + {:noreply, if(ref, do: close(state, ref, :disconnected), else: state)} + end + + defp sweep(state), + do: + Enum.reduce(Map.keys(state.peers), state, fn ref, acc -> + entry = acc.peers[ref] + + cond do + not valid_entry?(entry) -> close(acc, ref, :identity_invalid) + stale?(entry) -> close(acc, ref, :stale) + true -> acc + end + end) + + defp close(state, ref, reason), do: elem(close_result(state, ref, reason), 0) + + defp close_result(state, ref, reason) do + case state.peers[ref] do + nil -> + {state, :ok} + + entry -> + Process.demonitor(entry.monitor, [:flush]) + Process.cancel_timer(entry.expiry_timer) + + result = + Hosts.transaction(fn -> + changed = + Hosts.sql( + "UPDATE host_connection_slots SET state='unknown' WHERE host_id=? AND connection_id=?", + [entry.peer.host_id, entry.id] + ) + + if changed.num_rows == 1, + do: + Hosts.audit( + entry.peer.owner_id, + entry.peer.host_id, + "host_connection_unknown", + "host_disconnect", + "unknown" + ) + end) + + outcome = if match?({:ok, _}, result), do: :ok, else: {:error, :persistence_failed} + + send( + entry.socket, + {:host_connection_close, if(outcome == :ok, do: reason, else: :persistence_failed)} + ) + + {%{ + state + | peers: Map.delete(state.peers, ref), + hosts: Map.delete(state.hosts, entry.peer.host_id) + }, outcome} + end + end + + defp current_slot!(entry) do + row = + Hosts.one("SELECT host_id FROM host_connection_slots WHERE host_id=? AND connection_id=?", [ + entry.peer.host_id, + entry.id + ]) + + if is_nil(row), do: Repo.rollback(:unauthorized_host) + end + + defp valid_entry?(entry), + do: + monotonic() < entry.expiry and Hosts.current_peer?(entry.peer) and + not is_nil( + Hosts.one( + "SELECT host_id FROM host_connection_slots WHERE host_id=? AND connection_id=?", + [entry.peer.host_id, entry.id] + ) + ) + + defp fresh?(entry), + do: entry.negotiated and not is_nil(entry.seen) and not stale?(entry) and valid_entry?(entry) + + defp stale?(entry), do: monotonic() - (entry.seen || entry.opened) >= 30000 + + defp monotonic, + do: + Application.get_env(:workharbor, :host_monotonic_clock, fn -> + System.monotonic_time(:millisecond) + end).() + + defp ack(entry, kind), + do: %{ + "schema_version" => 1, + "kind" => kind, + "owner_id" => entry.peer.owner_id, + "host_id" => entry.peer.host_id + } + + defp put_entry(state, ref, entry), do: %{state | peers: Map.put(state.peers, ref, entry)} +end diff --git a/apps/control_plane/lib/workharbor/host_connections/protocol.ex b/apps/control_plane/lib/workharbor/host_connections/protocol.ex new file mode 100644 index 00000000..a9d2cdcf --- /dev/null +++ b/apps/control_plane/lib/workharbor/host_connections/protocol.ex @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostConnections.Protocol do + @moduledoc false + alias WorkHarbor.{StrictJSON} + alias WorkHarbor.Foundation.{Request, Canonical} + + def decode(raw, owner, host) when is_binary(raw) and byte_size(raw) <= 4096 do + with {:ok, value} <- StrictJSON.decode_object(raw), do: validate(value, owner, host) + end + + def decode(_, _, _), do: {:error, :invalid_connection_message} + + def validate(value, owner, host) do + keys = + if is_map(value) and value["kind"] == "hello", + do: ~w(schema_version kind owner_id host_id capabilities), + else: ~w(schema_version kind owner_id host_id) + + valid = + is_map(value) and not is_struct(value) and Enum.sort(Map.keys(value)) == Enum.sort(keys) and + value["schema_version"] === 1 and + value["kind"] in ~w(hello hello_ack heartbeat heartbeat_ack) and Request.uuid?(owner) and + Request.uuid?(host) and + value["owner_id"] === owner and value["host_id"] === host and + (value["kind"] != "hello" or value["capabilities"] in [[], ["docker"]]) + + if valid do + case Canonical.bytes(value) do + {:ok, bytes} when byte_size(bytes) <= 4096 -> {:ok, value} + _ -> {:error, :invalid_connection_message} + end + else + {:error, :invalid_connection_message} + end + end +end diff --git a/apps/control_plane/lib/workharbor/hosts.ex b/apps/control_plane/lib/workharbor/hosts.ex new file mode 100644 index 00000000..ffe5ada9 --- /dev/null +++ b/apps/control_plane/lib/workharbor/hosts.ex @@ -0,0 +1,425 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Hosts.Peer do + @moduledoc "Internal certificate-registry scope. Transport must prove private-key possession." + @enforce_keys [:owner_id, :host_id, :identity_id, :certificate, :expires_at, :issuer_expires_at] + defstruct @enforce_keys +end + +defmodule WorkHarbor.Hosts do + @moduledoc "Atomic one-time enrollment and retained scoped client identities." + alias WorkHarbor.{Repo, Accounts} + alias WorkHarbor.Foundation.{Host, AuditEvent, Request} + alias WorkHarbor.Hosts.{Issuer, Peer} + + def issue_enrollment(principal, host_id) do + with :ok <- Accounts.authorize(principal, :host_enrollment_issue) do + transaction(fn -> + authorize!(principal, :host_enrollment_issue) + + host = + if is_nil(host_id) do + host = Repo.insert!(%Host{owner_id: principal.owner_id}) + + audit(host.owner_id, host.id, "host_recorded", "host_metadata", "recorded", + channel: channel(principal) + ) + + host + else + scoped_host!(principal.owner_id, host_id) + end + + sql( + "INSERT INTO host_security(host_id,owner_id) VALUES(?,?) ON CONFLICT(host_id) DO NOTHING", + [host.id, host.owner_id] + ) + + not_revoked!(host.id, :host_revoked) + code = "whe1_" <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + now = Accounts.now() + + sql( + "INSERT INTO host_enrollments(id,host_id,owner_id,code_hash,issued_at,expires_at) VALUES(?,?,?,?,?,?)", + [Ecto.UUID.generate(), host.id, host.owner_id, digest(code), now, now + 300] + ) + + audit(host.owner_id, host.id, "host_enrollment_issued", "host_enrollment_issue", "issued", + channel: channel(principal) + ) + + %{host_id: host.id, owner_id: host.owner_id, code: code, expires_at: now + 300} + end) + end + end + + def redeem(code, csr) do + with true <- code?(code), {:ok, issuer} <- issuer() do + transaction(fn -> + enrollment = one("SELECT * FROM host_enrollments WHERE code_hash=?", [digest(code)]) + now = Accounts.now() + + if is_nil(enrollment) or enrollment["issued_at"] > now or enrollment["expires_at"] <= now or + not is_nil(enrollment["consumed_at"]) or not is_nil(enrollment["revoked_at"]), + do: Repo.rollback(:invalid_enrollment) + + not_revoked!(enrollment["host_id"], :invalid_enrollment) + + {:ok, cert} = + or_rollback(Issuer.issue(issuer, csr, enrollment["host_id"], :enrollment, now)) + + sql("UPDATE host_enrollments SET consumed_at=? WHERE id=? AND consumed_at IS NULL", [ + now, + enrollment["id"] + ]) + + sql( + "UPDATE host_identities SET status='retired' WHERE host_id=? AND status IN ('active','pending')", + [enrollment["host_id"]] + ) + + identity = + insert_identity( + enrollment["owner_id"], + enrollment["host_id"], + cert, + csr, + nil, + "active", + now + ) + + sql("UPDATE hosts SET identity_fingerprint=? WHERE id=?", [ + identity["fingerprint"], + identity["host_id"] + ]) + + audit( + identity["owner_id"], + identity["host_id"], + "host_enrolled", + "host_enrollment", + "issued" + ) + + response(identity, issuer) + end) + else + false -> {:error, :invalid_enrollment} + error -> error + end + end + + def resolve_identity(cert, mode) + when is_binary(cert) and byte_size(cert) <= 4096 and mode in [:active, :activation] do + with {:ok, issuer} <- issuer(), + identity when is_map(identity) <- + one("SELECT * FROM host_identities WHERE fingerprint=?", [fingerprint(cert)]), + true <- + identity["certificate"] == cert and allowed_status?(identity, mode) and + not_revoked?(identity["host_id"]), + true <- Issuer.valid_leaf?(cert, issuer, identity["host_id"], Accounts.now()) do + {:ok, + %Peer{ + owner_id: identity["owner_id"], + host_id: identity["host_id"], + identity_id: identity["id"], + certificate: cert, + expires_at: identity["expires_at"], + issuer_expires_at: issuer.expires_at + }} + else + _ -> {:error, :unauthorized_host} + end + rescue + _ -> {:error, :unauthorized_host} + catch + _ -> {:error, :unauthorized_host} + end + + def resolve_identity(_, _), do: {:error, :unauthorized_host} + + def current_peer?(%Peer{} = peer, mode \\ :active) do + case resolve_identity(peer.certificate, mode) do + {:ok, current} -> current === peer + _ -> false + end + end + + def renew(%Peer{} = peer, csr) do + with {:ok, issuer} <- issuer() do + transaction(fn -> + if not current_peer?(peer), do: Repo.rollback(:unauthorized_host) + {:ok, _} = or_rollback(Issuer.csr_public(csr, peer.host_id)) + + pending = + one("SELECT * FROM host_identities WHERE host_id=? AND status='pending'", [peer.host_id]) + + if pending do + if pending["predecessor_id"] != peer.identity_id or + pending["csr_hash"] != :crypto.hash(:sha256, csr), + do: Repo.rollback(:rotation_conflict) + + if pending["expires_at"] <= Accounts.now(), do: Repo.rollback(:unauthorized_host) + response(pending, issuer) + else + {:ok, cert} = or_rollback(Issuer.issue(issuer, csr, peer.host_id, :renewal)) + + identity = + insert_identity( + peer.owner_id, + peer.host_id, + cert, + csr, + peer.identity_id, + "pending", + Accounts.now() + ) + + audit(peer.owner_id, peer.host_id, "host_rotation_staged", "host_renew", "staged") + response(identity, issuer) + end + end) + end + end + + def renew(_, _), do: {:error, :unauthorized_host} + + def activate(%Peer{} = peer) do + result = + transaction(fn -> + if not current_peer?(peer, :activation), do: Repo.rollback(:unauthorized_host) + row = one("SELECT * FROM host_identities WHERE id=?", [peer.identity_id]) + + if row["status"] == "active" do + if is_nil(row["predecessor_id"]), do: Repo.rollback(:unauthorized_host) + else + old = + one("SELECT * FROM host_identities WHERE id=? AND host_id=? AND status='active'", [ + row["predecessor_id"], + peer.host_id + ]) + + if is_nil(old) or old["expires_at"] <= Accounts.now(), + do: Repo.rollback(:unauthorized_host) + + sql("UPDATE host_identities SET status='retired' WHERE id=?", [old["id"]]) + + sql("UPDATE host_identities SET status='active',activated_at=? WHERE id=?", [ + Accounts.now(), + row["id"] + ]) + + sql("UPDATE hosts SET identity_fingerprint=? WHERE id=?", [ + row["fingerprint"], + peer.host_id + ]) + + audit( + peer.owner_id, + peer.host_id, + "host_rotation_activated", + "host_activate", + "activated" + ) + end + + :ok + end) + + if result == {:ok, :ok}, + do: Phoenix.PubSub.broadcast(WorkHarbor.PubSub, "host: " <> peer.host_id, :identity_changed) + + normalize_ok(result) + end + + def activate(_), do: {:error, :unauthorized_host} + + def revoke(principal, host_id) do + with :ok <- Accounts.authorize(principal, :host_revoke) do + result = + transaction(fn -> + authorize!(principal, :host_revoke) + host = scoped_host!(principal.owner_id, host_id) + + sql( + "INSERT INTO host_security(host_id,owner_id) VALUES(?,?) ON CONFLICT(host_id) DO NOTHING", + [host.id, host.owner_id] + ) + + unless revoked?(host.id) do + now = Accounts.now() + sql("UPDATE host_security SET revoked_at=? WHERE host_id=?", [now, host.id]) + + sql( + "UPDATE host_enrollments SET revoked_at=? WHERE host_id=? AND revoked_at IS NULL", + [now, host.id] + ) + + sql( + "UPDATE host_identities SET status='revoked' WHERE host_id=? AND status != 'revoked'", + [host.id] + ) + + sql("UPDATE host_connection_slots SET state='unknown' WHERE host_id=?", [host.id]) + + audit(host.owner_id, host.id, "host_revoked", "host_revoke", "revoked", + channel: channel(principal) + ) + end + + :ok + end) + + if result == {:ok, :ok}, + do: Phoenix.PubSub.broadcast(WorkHarbor.PubSub, "host: " <> host_id, :identity_changed) + + normalize_ok(result) + end + end + + def status(principal, host_id) do + with :ok <- Accounts.authorize(principal, :host_status) do + transaction(fn -> + host = scoped_host!(principal.owner_id, host_id) + %{host_id: host.id, owner_id: host.owner_id, revoked: revoked?(host.id)} + end) + end + end + + def issuer do + config = Application.get_env(:workharbor, :host_identity, []) + + case config[:issuer_dir] do + path when is_binary(path) -> Issuer.load(path) + _ -> {:error, :issuer_unavailable} + end + end + + def fingerprint(cert), do: :crypto.hash(:sha256, cert) |> Base.encode16(case: :lower) + def sql(statement, parameters \\ []), do: Repo.query!(statement, parameters, log: false) + + def one(statement, parameters \\ []) do + case sql(statement, parameters) do + %{columns: cols, rows: [row]} -> Map.new(Enum.zip(cols, row)) + _ -> nil + end + end + + def transaction(fun) do + if Repo.in_transaction?(), + do: {:error, :nested_transaction}, + else: Repo.transaction(fun, mode: :immediate, log: false) + rescue + _ in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + {:error, :persistence_failed} + end + + def audit(owner, host, event, action, outcome, opts \\ []) do + Repo.insert!( + %AuditEvent{ + owner_id: owner, + host_id: host, + event: event, + action: action, + outcome: outcome, + channel: opts[:channel] || "internal", + workspace_id: opts[:workspace_id], + runtime_id: opts[:runtime_id], + runtime_generation: opts[:generation], + intent_version: opts[:intent_version] + }, + log: false + ) + end + + defp code?("whe1_" <> encoded) when byte_size(encoded) == 43 do + case Base.url_decode64(encoded, padding: false) do + {:ok, bytes} -> + byte_size(bytes) == 32 and Base.url_encode64(bytes, padding: false) == encoded + + _ -> + false + end + end + + defp code?(_), do: false + defp digest(code), do: :crypto.hash(:sha256, "WorkHarbor enrollment v1\0" <> code) + defp allowed_status?(row, :active), do: row["status"] == "active" + + defp allowed_status?(row, :activation), + do: + row["status"] == "pending" or + (row["status"] == "active" and not is_nil(row["predecessor_id"])) + + defp not_revoked?(host), + do: + not is_nil( + one("SELECT host_id FROM host_security WHERE host_id=? AND revoked_at IS NULL", [host]) + ) + + defp revoked?(host), + do: + not is_nil( + one("SELECT host_id FROM host_security WHERE host_id=? AND revoked_at IS NOT NULL", [host]) + ) + + defp not_revoked!(host, error), do: if(not not_revoked?(host), do: Repo.rollback(error)) + + defp scoped_host!(owner, id) do + if not Request.uuid?(id), do: Repo.rollback(:not_found) + Repo.get_by(Host, id: id, owner_id: owner) || Repo.rollback(:not_found) + end + + defp authorize!(p, action), + do: if(Accounts.authorize(p, action) != :ok, do: Repo.rollback(:forbidden)) + + defp channel(%{kind: "cli_owner"}), do: "cli" + defp channel(_), do: "browser" + defp or_rollback({:ok, _} = result), do: result + defp or_rollback({:error, error}), do: Repo.rollback(error) + defp normalize_ok({:ok, :ok}), do: :ok + defp normalize_ok(error), do: error + + defp insert_identity(owner, host, certificate, csr, predecessor, status, now) do + id = Ecto.UUID.generate() + times = Issuer.inspect_certificate(certificate) + + sql( + "INSERT INTO host_identities(id,host_id,owner_id,fingerprint,certificate,csr_hash,predecessor_id,status,issued_at,expires_at,activated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + [ + id, + host, + owner, + fingerprint(certificate), + certificate, + :crypto.hash(:sha256, csr), + predecessor, + status, + times.not_before, + times.not_after, + if(status == "active", do: now, else: nil) + ] + ) + + one("SELECT * FROM host_identities WHERE id=?", [id]) + end + + defp response(row, issuer) do + if byte_size(row["certificate"]) > 4096 or byte_size(issuer.certificate) > 4096, + do: Repo.rollback(:identity_response_refused) + + result = %{ + "schema_version" => 1, + "owner_id" => row["owner_id"], + "host_id" => row["host_id"], + "identity_id" => row["id"], + "certificate" => Base.encode64(row["certificate"]), + "issuer_certificate" => Base.encode64(issuer.certificate), + "expires_at" => row["expires_at"] + } + + if byte_size(Jason.encode!(result)) > 16384, do: Repo.rollback(:identity_response_refused) + result + end +end diff --git a/apps/control_plane/lib/workharbor/hosts/issuer.ex b/apps/control_plane/lib/workharbor/hosts/issuer.ex new file mode 100644 index 00000000..52fbe457 --- /dev/null +++ b/apps/control_plane/lib/workharbor/hosts/issuer.ex @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Hosts.Issuer do + @moduledoc "Native client identity issuer; initialization is explicit and exclusive." + require Record + + for {name, tag} <- [ + tbs: :OTPTBSCertificate, + cert: :OTPCertificate, + validity: :Validity, + signature: :SignatureAlgorithm, + public_algorithm: :PublicKeyAlgorithm, + spki: :OTPSubjectPublicKeyInfo, + extension: :Extension, + basic: :BasicConstraints + ] do + Record.defrecordp( + name, + tag, + Record.extract(tag, from_lib: "public_key/include/public_key.hrl") + ) + end + + @ed {1, 3, 101, 112} + @day 86400 + def initialize(dir) do + path = Path.join(dir, "issuer.json") + + with true <- Path.type(dir) == :absolute, + :ok <- WorkHarbor.Hosts.PrivateFile.traversal(dir, true), + :ok <- ensure_dir(dir), + :ok <- WorkHarbor.Hosts.PrivateFile.directory(dir) do + key = :public_key.generate_key({:namedCurve, @ed}) + public = elem(key, 4) + now = WorkHarbor.Accounts.now() + name = subject("WorkHarbor client issuer") + certificate = sign(key, public, name, name, now, now + 5 * 365 * @day, true) + + state = %{ + "schema_version" => 1, + "certificate" => Base.encode64(certificate), + "private_key" => Base.encode64(:public_key.der_encode(:PrivateKeyInfo, key)) + } + + case WorkHarbor.Hosts.PrivateFile.create(path, Jason.encode!(state)) do + :ok -> load(dir) + error -> error + end + else + _ -> {:error, :unsafe_identity} + end + rescue + _ -> {:error, :invalid_identity} + end + + def load(dir) do + path = Path.join(dir, "issuer.json") + + if not File.exists?(path) do + {:error, :invalid_identity} + else + with {:ok, bytes} <- WorkHarbor.Hosts.PrivateFile.read(path), + {:ok, + %{"schema_version" => 1, "certificate" => cert64, "private_key" => key64} = state} <- + WorkHarbor.StrictJSON.decode_object(bytes), + true <- map_size(state) == 3, + {:ok, der} <- Base.decode64(cert64), + {:ok, key_der} <- Base.decode64(key64), + true <- Base.encode64(der) == cert64 and Base.encode64(key_der) == key64 do + key = :public_key.der_decode(:PrivateKeyInfo, key_der) + if :public_key.der_encode(:PrivateKeyInfo, key) != key_der, do: throw(:invalid_identity) + public = elem(key, 4) + + public = + if public == :asn1_NOVALUE, + do: elem(:crypto.generate_key(:eddsa, :ed25519, elem(key, 2)), 0), + else: public + + info = inspect_certificate(der) + now = WorkHarbor.Accounts.now() + + if elem(key, 3) != {:namedCurve, @ed} or info.public != public or not root_profile?(info) or + info.not_before > now or info.not_after < now + @day or + info.subject != subject("WorkHarbor client issuer") or + not :public_key.pkix_verify(der, {{:ECPoint, public}, {:namedCurve, @ed}}), + do: throw(:invalid_identity) + + {:ok, + %{ + key: key, + certificate: der, + public: public, + subject: info.subject, + expires_at: info.not_after + }} + else + {:error, :unsafe_identity} = error -> error + _ -> {:error, :invalid_identity} + end + end + rescue + _ -> {:error, :invalid_identity} + catch + _ -> {:error, :invalid_identity} + end + + def valid_issuer?(der) do + info = inspect_certificate(der) + now = WorkHarbor.Accounts.now() + + root_profile?(info) and info.not_before <= now and info.not_after >= now + @day and + :public_key.pkix_verify(der, {{:ECPoint, info.public}, {:namedCurve, @ed}}) + rescue + _ -> false + catch + _ -> false + end + + def issue(issuer, csr, host, mode, now \\ WorkHarbor.Accounts.now()) do + with {:ok, public} <- csr_public(csr, if(mode == :enrollment, do: nil, else: host)), + true <- issuer.expires_at >= now + @day do + {:ok, sign(issuer.key, public, issuer.subject, subject(host), now, now + @day, false)} + else + {:error, _} = e -> e + _ -> {:error, :issuer_unavailable} + end + rescue + _ -> {:error, :invalid_csr} + end + + def csr_public(bytes, host) when is_binary(bytes) and byte_size(bytes) <= 4096 do + {:CertificationRequest, info, {:CertificationRequest_signatureAlgorithm, @ed, :asn1_NOVALUE}, + signature} = request = :public_key.der_decode(:CertificationRequest, bytes) + + if :public_key.der_encode(:CertificationRequest, request) != bytes, do: throw(:invalid_csr) + + {:CertificationRequestInfo, :v1, name, + {:CertificationRequestInfo_subjectPKInfo, + {:CertificationRequestInfo_subjectPKInfo_algorithm, @ed, :asn1_NOVALUE}, public}, []} = info + + expected = + if host, + do: {:rdnSequence, [[{:AttributeTypeAndValue, {2, 5, 4, 3}, {:utf8String, host}}]]}, + else: {:rdnSequence, []} + + if name != expected or byte_size(public) != 32 or + not :crypto.verify( + :eddsa, + :none, + :public_key.der_encode(:CertificationRequestInfo, info), + signature, + [public, :ed25519] + ), + do: throw(:invalid_csr) + + {:ok, public} + rescue + _ -> {:error, :invalid_csr} + catch + _ -> {:error, :invalid_csr} + end + + def csr_public(_, _), do: {:error, :invalid_csr} + + def inspect_certificate(der) do + if not is_binary(der) or byte_size(der) > 4096, do: throw(:invalid_identity) + decoded = :public_key.pkix_decode_cert(der, :otp) + + if :public_key.pkix_encode(:OTPCertificate, decoded, :otp) != der, + do: throw(:invalid_identity) + + value = cert(decoded, :tbsCertificate) + pub = tbs(value, :subjectPublicKeyInfo) + algorithm = spki(pub, :algorithm) + + if public_algorithm(algorithm, :algorithm) != @ed or + public_algorithm(algorithm, :parameters) != :asn1_NOVALUE, + do: throw(:invalid_identity) + + {:ECPoint, public} = spki(pub, :subjectPublicKey) + valid = tbs(value, :validity) + extensions = tbs(value, :extensions) + constraints = find_extension(extensions, {2, 5, 29, 19}) + ids = Enum.map(extensions, &extension(&1, :extnID)) + if length(ids) != length(Enum.uniq(ids)), do: throw(:invalid_identity) + + %{ + basic_present: + constraints != nil and basic(constraints, :pathLenConstraint) == :asn1_NOVALUE, + unknown_critical: + Enum.any?(extensions, fn ext -> + extension(ext, :critical) == true and + extension(ext, :extnID) not in [{2, 5, 29, 19}, {2, 5, 29, 15}, {2, 5, 29, 37}] + end), + public: public, + subject: tbs(value, :subject), + issuer: tbs(value, :issuer), + not_before: seconds(validity(valid, :notBefore)), + not_after: seconds(validity(valid, :notAfter)), + ca: constraints != nil and basic(constraints, :cA) == true, + usage: find_extension(extensions, {2, 5, 29, 15}) || [], + eku: find_extension(extensions, {2, 5, 29, 37}) || [] + } + end + + def valid_leaf?(der, issuer, host, now) do + info = inspect_certificate(der) + + root = inspect_certificate(issuer.certificate) + + root_profile?(root) and root.public == issuer.public and root.subject == issuer.subject and + root.not_after == issuer.expires_at and + :public_key.pkix_verify(issuer.certificate, {{:ECPoint, root.public}, {:namedCurve, @ed}}) and + info.basic_present and not info.unknown_critical and not info.ca and + info.usage == [:digitalSignature] and + info.eku == [{1, 3, 6, 1, 5, 5, 7, 3, 2}] and + info.not_after - info.not_before == @day and info.subject == subject(host) and + info.issuer == issuer.subject and info.not_before <= now and + now < info.not_after and + now < issuer.expires_at and info.not_after <= issuer.expires_at and + :public_key.pkix_verify(der, {{:ECPoint, issuer.public}, {:namedCurve, @ed}}) + rescue + _ -> false + catch + _ -> false + end + + defp root_profile?(info) do + info.ca and info.basic_present and not info.unknown_critical and + info.usage == [:keyCertSign] and info.eku == [] and + info.subject == subject("WorkHarbor client issuer") and + info.not_after - info.not_before == 5 * 365 * @day + end + + def subject(host), + do: {:rdnSequence, [[{:AttributeTypeAndValue, {2, 5, 4, 3}, {:utf8String, host}}]]} + + defp sign(key, public, issuer, subject, start, stop, ca) do + extensions = [ + extension(extnID: {2, 5, 29, 19}, critical: true, extnValue: basic(cA: ca)), + extension( + extnID: {2, 5, 29, 15}, + critical: true, + extnValue: if(ca, do: [:keyCertSign], else: [:digitalSignature]) + ) + ] + + extensions = + if ca, + do: extensions, + else: + extensions ++ + [ + extension( + extnID: {2, 5, 29, 37}, + critical: false, + extnValue: [{1, 3, 6, 1, 5, 5, 7, 3, 2}] + ) + ] + + record = + tbs( + version: :v3, + serialNumber: :binary.decode_unsigned(:crypto.strong_rand_bytes(16)) + 1, + signature: signature(algorithm: @ed), + issuer: issuer, + subject: subject, + validity: validity(notBefore: time(start), notAfter: time(stop)), + subjectPublicKeyInfo: + spki(algorithm: public_algorithm(algorithm: @ed), subjectPublicKey: {:ECPoint, public}), + extensions: extensions + ) + + :public_key.pkix_sign(record, key) + end + + defp find_extension(exts, oid) do + case Enum.find(exts, &(extension(&1, :extnID) == oid)) do + nil -> nil + ext -> extension(ext, :extnValue) + end + end + + defp time(seconds), + do: + {:generalTime, + DateTime.from_unix!(seconds) |> Calendar.strftime("%Y%m%d%H%M%SZ") |> String.to_charlist()} + + defp seconds({kind, chars}) do + s = List.to_string(chars) + + full = + if kind == :utcTime, + do: if(String.slice(s, 0, 2) >= "50", do: "19", else: "20") <> s, + else: s + + [y, m, d, h, n, s] = + for {offset, size} <- [{0, 4}, {4, 2}, {6, 2}, {8, 2}, {10, 2}, {12, 2}], + do: String.slice(full, offset, size) |> String.to_integer() + + :calendar.datetime_to_gregorian_seconds({{y, m, d}, {h, n, s}}) - 62_167_219_200 + end + + defp ensure_dir(dir) do + case File.mkdir(dir) do + :ok -> File.chmod(dir, 0o700) + {:error, :eexist} -> :ok + error -> error + end + end +end diff --git a/apps/control_plane/lib/workharbor/hosts/private_file.ex b/apps/control_plane/lib/workharbor/hosts/private_file.ex new file mode 100644 index 00000000..b41ddc58 --- /dev/null +++ b/apps/control_plane/lib/workharbor/hosts/private_file.ex @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Hosts.PrivateFile do + @moduledoc false + import Bitwise + + def traversal(path, missing_final \\ false) when is_binary(path) do + if Path.type(path) != :absolute or Path.expand(path) != path do + {:error, :unsafe_identity} + else + parts = Path.split(path) + + result = + Enum.with_index(parts) + |> Enum.reduce_while("", fn {part, index}, prefix -> + current = if part == "/", do: "/", else: Path.join(prefix, part) + + case File.lstat(current) do + {:ok, %{type: :symlink}} -> + {:halt, {:error, :unsafe_identity}} + + {:ok, _} -> + {:cont, current} + + {:error, :enoent} when missing_final and index == length(parts) - 1 -> + {:cont, current} + + _ -> + {:halt, {:error, :unsafe_identity}} + end + end) + + if is_binary(result), do: :ok, else: result + end + end + + def directory(path) when is_binary(path) do + with :ok <- traversal(path), + {:ok, stat} <- File.lstat(path), + true <- stat.type == :directory and (stat.mode &&& 0o777) == 0o700 and stat.uid == uid(), + do: :ok, + else: (_ -> {:error, :unsafe_identity}) + end + + def directory(_), do: {:error, :unsafe_identity} + + def read(path) do + with :ok <- traversal(path), + :ok <- directory(Path.dirname(path)), + {:ok, stat} <- File.lstat(path), + true <- stat.type == :regular and (stat.mode &&& 0o777) == 0o600 and stat.uid == uid(), + true <- stat.size <= 32768, + {:ok, bytes} <- File.read(path), + do: {:ok, bytes}, + else: (_ -> {:error, :unsafe_identity}) + end + + def create(path, bytes) do + with :ok <- traversal(path, true), :ok <- directory(Path.dirname(path)) do + temporary = path <> "." <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false) + + try do + with {:ok, fd} <- + :file.open(String.to_charlist(temporary), [:write, :binary, :exclusive, :raw]), + :ok <- File.chmod(temporary, 0o600), + result <- write_close(fd, bytes), + :ok <- result, + :ok <- :file.make_link(String.to_charlist(temporary), String.to_charlist(path)), + :ok <- sync_directory(Path.dirname(path)), + do: :ok, + else: ( + {:error, :eexist} -> {:error, :already_initialized} + _ -> {:error, :persistence_failed} + ) + after + File.rm(temporary) + end + end + end + + defp write_close(fd, bytes) do + try do + with :ok <- :file.write(fd, bytes), do: :file.sync(fd) + after + :file.close(fd) + end + end + + defp sync_directory(dir) do + with {:ok, fd} <- :file.open(String.to_charlist(dir), [:read, :raw, :directory]) do + try do + :file.sync(fd) + after + :file.close(fd) + end + end + end + + # OTP has no public geteuid BIF. Fixed POSIX id has no caller-controlled argv, + # shell, secret or network; it verifies the effective service account. + defp uid do + {value, 0} = System.cmd("/usr/bin/id", ["-u"]) + value |> String.trim() |> String.to_integer() + end +end diff --git a/apps/control_plane/lib/workharbor_web/agent_ingress.ex b/apps/control_plane/lib/workharbor_web/agent_ingress.ex new file mode 100644 index 00000000..653100d3 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/agent_ingress.ex @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentIngress do + @moduledoc "Raw agent transport boundary; browser credentials never prove a host identity." + import Plug.Conn + alias WorkHarbor.{Hosts, StrictJSON} + alias WorkHarborWeb.Security + + @routes [ + {"POST", "/agent/v1/enroll"}, + {"POST", "/agent/v1/identity/renew"}, + {"POST", "/agent/v1/identity/activate"}, + {"GET", "/agent/v1/connect"} + ] + def init(opts), do: opts + + def call(conn, _) do + conn = Security.call(conn, []) + + cond do + conn.halted -> + conn + + mixed_credentials?(conn) -> + Security.deny(conn, 403, :forbidden) + + {conn.method, conn.request_path} not in @routes -> + Security.deny(conn, 400, :invalid_request) + + conn.request_path == "/agent/v1/enroll" -> + enrollment(conn) + + true -> + authenticated(conn) + end + rescue + _ -> Security.deny(conn, 400, :invalid_request) + end + + defp mixed_credentials?(conn), + do: + Enum.any?(conn.req_headers, fn {name, _} -> + name in ["authorization", "cookie", "origin", "x-workharbor-context"] or + String.starts_with?(name, "sec-fetch-") + end) + + defp authenticated(conn) do + mode = if conn.request_path == "/agent/v1/identity/activate", do: :activation, else: :active + # Only the server adapter's verified TLS peer certificate is consulted. + cert = + if elem(conn.adapter, 0) == Bandit.Adapter, do: get_peer_data(conn)[:ssl_cert], else: nil + + case Hosts.resolve_identity(cert, mode) do + {:ok, peer} -> dispatch(conn, peer) + _ -> Security.deny(conn, 401, :unauthorized_host) + end + end + + defp enrollment(conn) do + parsed(conn, ~w(schema_version code csr), fn conn, body -> + with code when is_binary(code) <- body["code"], + {:ok, csr} <- der(body["csr"]), + {:ok, response} <- Hosts.redeem(code, csr) do + json(conn, response) + else + _ -> refuse_body(conn, :invalid_enrollment) + end + end) + end + + defp dispatch(%{request_path: "/agent/v1/identity/renew"} = conn, peer) do + parsed(conn, ~w(schema_version csr), fn conn, body -> + with {:ok, csr} <- der(body["csr"]), {:ok, response} <- Hosts.renew(peer, csr) do + json(conn, response) + else + _ -> refuse_body(conn, :identity_renewal_failed) + end + end) + end + + defp dispatch(%{request_path: "/agent/v1/identity/activate"} = conn, peer) do + parsed(conn, ~w(schema_version), fn conn, _ -> + with :ok <- Hosts.activate(peer), + {:ok, issuer} <- Hosts.issuer(), + true <- Hosts.current_peer?(peer) do + json(conn, %{ + "schema_version" => 1, + "owner_id" => peer.owner_id, + "host_id" => peer.host_id, + "identity_id" => peer.identity_id, + "certificate" => Base.encode64(peer.certificate), + "issuer_certificate" => Base.encode64(issuer.certificate), + "expires_at" => peer.expires_at + }) + else + _ -> refuse_body(conn, :identity_activation_failed) + end + end) + end + + defp dispatch(conn, peer) do + if upgrade?(conn) do + conn + |> WebSockAdapter.upgrade(WorkHarborWeb.AgentSocket, peer, + compress: false, + max_frame_size: 4110, + max_fragmented_message_size: 4096, + timeout: 30000 + ) + |> halt() + else + Security.deny(conn, 400, :invalid_request) + end + end + + defp parsed(conn, keys, callback) do + if get_req_header(conn, "content-type") == ["application/json"] do + case read_body(conn, length: 16384, read_length: 16384, read_timeout: 5000) do + {:ok, raw, conn} when byte_size(raw) <= 16384 -> + case StrictJSON.decode_object(raw) do + {:ok, body} -> + if Enum.sort(Map.keys(body)) == Enum.sort(keys) and body["schema_version"] === 1, + do: callback.(conn, body), + else: refuse_body(conn, :invalid_request) + + _ -> + refuse_body(conn, :invalid_request) + end + + {status, _, conn} when status in [:more, :ok] -> + refuse_body(conn, :invalid_request) + + {:error, _} -> + refuse_body(conn, :invalid_request) + end + else + refuse_body(conn, :invalid_request) + end + end + + defp refuse_body(conn, code), + do: conn |> put_resp_header("connection", "close") |> Security.deny(400, code) + + defp der(value) when is_binary(value) and byte_size(value) <= 5464 do + with {:ok, bytes} <- Base.decode64(value), + true <- byte_size(bytes) <= 4096 and Base.encode64(bytes) == value do + {:ok, bytes} + else + _ -> {:error, :invalid_request} + end + end + + defp der(_), do: {:error, :invalid_request} + + defp json(conn, body), + do: + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(body)) + |> halt() + + defp upgrade?(conn) do + get_req_header(conn, "sec-websocket-version") == ["13"] and + get_req_header(conn, "sec-websocket-extensions") == [] and + get_req_header(conn, "sec-websocket-protocol") == [] and + get_req_header(conn, "connection") |> token?("upgrade") and + get_req_header(conn, "upgrade") |> token?("websocket") and + case get_req_header(conn, "sec-websocket-key") do + [key] when byte_size(key) == 24 -> + case Base.decode64(key) do + {:ok, bytes} -> byte_size(bytes) == 16 and Base.encode64(bytes) == key + _ -> false + end + + _ -> + false + end + end + + defp token?([value], expected) when byte_size(value) <= 128, + do: expected in (value |> String.downcase() |> Plug.Conn.Utils.list()) + + defp token?(_, _), do: false +end diff --git a/apps/control_plane/lib/workharbor_web/agent_log_filter.ex b/apps/control_plane/lib/workharbor_web/agent_log_filter.ex new file mode 100644 index 00000000..7c0269a5 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/agent_log_filter.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentLogFilter do + @moduledoc "Rebuild only agent OTP crash reports before any logger handler receives raw data." + @id :workharbor_agent_transport + def install do + expected = {&__MODULE__.filter/2, []} + + case List.keyfind(:logger.get_primary_config().filters, @id, 0) do + {@id, ^expected} -> + :ok + + nil -> + case :logger.add_primary_filter(@id, expected) do + :ok -> + :ok + + _ -> + if List.keyfind(:logger.get_primary_config().filters, @id, 0) == {@id, expected}, + do: :ok, + else: {:error, :unsafe_agent_logging} + end + + _ -> + {:error, :unsafe_agent_logging} + end + rescue + _ -> {:error, :unsafe_agent_logging} + end + + def filter(%{msg: {:report, report}, meta: meta}, _) when is_map(report) and is_map(meta) do + if agent_report?(report, meta) do + safe = %{domain: [:workharbor, :agent_transport]} + safe = if is_pid(meta[:pid]), do: Map.put(safe, :pid, meta[:pid]), else: safe + safe = if is_integer(meta[:time]), do: Map.put(safe, :time, meta[:time]), else: safe + %{level: :error, msg: {:string, "WorkHarbor agent transport refused"}, meta: safe} + else + :ignore + end + end + + def filter(_, _), do: :ignore + + defp agent_report?(%{label: label} = report, meta) + when label in [{:gen_server, :terminate}, {:proc_lib, :crash}], + do: meta[:workharbor_agent_transport] === true or agent_state?(report) + + defp agent_report?(_, _), do: false + + defp agent_state?(%{ + label: {:gen_server, :terminate}, + state: + {_, %{connection: %Bandit.WebSocket.Connection{websock: WorkHarborWeb.AgentSocket}}} + }), + do: true + + defp agent_state?(_), do: false +end diff --git a/apps/control_plane/lib/workharbor_web/agent_socket.ex b/apps/control_plane/lib/workharbor_web/agent_socket.ex new file mode 100644 index 00000000..2ae68233 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/agent_socket.ex @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentSocket do + @behaviour WebSock + alias WorkHarbor.HostConnections + alias WorkHarbor.HostConnections.Protocol + @impl true + def init(peer) do + Logger.metadata(workharbor_agent_transport: true) + + case HostConnections.open(peer, self()) do + {:ok, {worker, _} = ref} -> + {:ok, %{ref: ref, peer: peer, hello: false, worker_monitor: Process.monitor(worker)}} + + _ -> + {:stop, :normal, 1008, %{ref: nil, peer: peer}} + end + end + + @impl true + def handle_in({raw, [opcode: :text]}, state) when byte_size(raw) <= 4096 do + with {:ok, message} <- Protocol.decode(raw, state.peer.owner_id, state.peer.host_id), + expected = if(state.hello, do: "heartbeat", else: "hello"), + true <- message["kind"] == expected, + {:ok, ack} <- + if(state.hello, + do: HostConnections.heartbeat(state.ref, message), + else: HostConnections.receive_hello(state.ref, message) + ) do + {:push, {:text, Jason.encode!(ack)}, %{state | hello: true}} + else + _ -> {:stop, :normal, 1008, state} + end + end + + def handle_in(_, state), do: {:stop, :normal, 1008, state} + @impl true + def handle_info({:host_connection_close, _}, state), + do: {:stop, :normal, 1008, state} + + def handle_info({:DOWN, monitor, :process, _, _}, %{worker_monitor: monitor} = state), + do: {:stop, :normal, 1008, state} + + def handle_info(_, state), do: {:ok, state} + @impl true + def terminate(_, state), do: HostConnections.disconnect(state.ref) +end diff --git a/apps/control_plane/lib/workharbor_web/endpoint.ex b/apps/control_plane/lib/workharbor_web/endpoint.ex index 7d7e7f01..086725ba 100644 --- a/apps/control_plane/lib/workharbor_web/endpoint.ex +++ b/apps/control_plane/lib/workharbor_web/endpoint.ex @@ -21,7 +21,12 @@ defmodule WorkHarborWeb.Endpoint do # Phoenix inserts socket_dispatch before user plugs. Wrap its entry explicitly. def call(conn, opts) do - case WorkHarborWeb.Security.call(conn, []) do + admission = + if String.starts_with?(conn.request_path, "/agent"), + do: WorkHarborWeb.AgentIngress.call(conn, []), + else: WorkHarborWeb.Security.call(conn, []) + + case admission do %{halted: true} = conn -> conn conn -> super(conn, opts) end diff --git a/apps/control_plane/lib/workharbor_web/host_controller.ex b/apps/control_plane/lib/workharbor_web/host_controller.ex new file mode 100644 index 00000000..37e72956 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/host_controller.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.HostController do + use WorkHarborWeb, :controller + alias WorkHarbor.Hosts + alias WorkHarborWeb.{Body, Security} + + def issue(conn, _) do + parsed(conn, fn conn, body -> + with %{"schema_version" => 1, "host_id" => host} <- body, + true <- + map_size(body) == 2 and (is_nil(host) or WorkHarbor.Foundation.Request.uuid?(host)), + {:ok, issued} <- Hosts.issue_enrollment(conn.assigns.principal, host) do + json(conn, Map.put(issued, :schema_version, 1)) + else + _ -> refuse(conn, :host_enrollment_failed) + end + end) + end + + def revoke(conn, %{"id" => host}) do + parsed(conn, fn conn, body -> + with %{"schema_version" => 1} <- body, + true <- map_size(body) == 1, + :ok <- Hosts.revoke(conn.assigns.principal, host) do + json(conn, %{schema_version: 1, host_id: host, revoked: true}) + else + _ -> refuse(conn, :host_revocation_failed) + end + end) + end + + defp parsed(conn, callback) do + case Body.parse(conn, :json) do + {:ok, body, conn} -> callback.(conn, body) + {:error, _, conn} -> refuse(conn, :invalid_request) + end + end + + defp refuse(conn, code), + do: conn |> Plug.Conn.put_resp_header("connection", "close") |> Security.deny(400, code) + + def status(conn, %{"id" => host}) do + case Hosts.status(conn.assigns.principal, host) do + {:ok, status} -> + connection = + case WorkHarbor.HostConnections.host_state(host) do + {:ok, "connected"} -> "connected" + _ -> "unknown" + end + + json(conn, Map.merge(status, %{schema_version: 1, connection: connection})) + + _ -> + Security.deny(conn, 404, :not_found) + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/router.ex b/apps/control_plane/lib/workharbor_web/router.ex index 66a59d37..2f7b38be 100644 --- a/apps/control_plane/lib/workharbor_web/router.ex +++ b/apps/control_plane/lib/workharbor_web/router.ex @@ -53,6 +53,36 @@ defmodule WorkHarborWeb.Router do plug RequireOwner, :dashboard_page end + pipeline :host_enrollment do + plug RequireOwner, :host_enrollment_issue + plug :api_csrf + end + + pipeline :host_revocation do + plug RequireOwner, :host_revoke + plug :api_csrf + end + + pipeline :host_status do + plug RequireOwner, :host_status + plug :api_csrf + end + + scope "/api/v1/hosts", WorkHarborWeb do + pipe_through [:session, :host_enrollment] + post "/enrollments", HostController, :issue, log: false + end + + scope "/api/v1/hosts", WorkHarborWeb do + pipe_through [:session, :host_revocation] + post "/:id/revoke", HostController, :revoke, log: false + end + + scope "/api/v1/hosts", WorkHarborWeb do + pipe_through [:session, :host_status] + get "/:id", HostController, :status, log: false + end + scope "/", WorkHarborWeb do pipe_through :session get "/login/owner", AuthController, :form, log: false diff --git a/apps/control_plane/lib/workharbor_web/runtime_security.ex b/apps/control_plane/lib/workharbor_web/runtime_security.ex index 3b6de35b..b50d1b56 100644 --- a/apps/control_plane/lib/workharbor_web/runtime_security.ex +++ b/apps/control_plane/lib/workharbor_web/runtime_security.ex @@ -9,6 +9,42 @@ defmodule WorkHarborWeb.RuntimeSecurity do "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl" ] @https_keys ~w(ip port certfile keyfile http_1_options http_2_options)a + @peer_keys ~w(cacerts verify fail_if_no_peer_cert)a + + def identity_config(config, nil), do: {:ok, config} + + def identity_config(config, opts) do + with true <- Keyword.keyword?(opts) and Keyword.keys(opts) == [:issuer_dir], + {:ok, issuer} <- WorkHarbor.Hosts.Issuer.load(opts[:issuer_dir]) do + tls = Keyword.get(config, :https, []) + + if Keyword.has_key?(tls, :thousand_island_options) or + Enum.any?(@peer_keys, &Keyword.has_key?(tls, &1)), + do: throw(:invalid_identity) + + {:ok, + Keyword.put( + config, + :https, + tls ++ + [ + thousand_island_options: [ + transport_options: [ + cacerts: [issuer.certificate], + verify: :verify_peer, + fail_if_no_peer_cert: false + ] + ] + ] + )} + else + _ -> {:error, :invalid_identity} + end + rescue + _ -> {:error, :invalid_identity} + catch + _ -> {:error, :invalid_identity} + end def validate!(config, owner_http) do # Phoenix treats other truthy values as active and missing values can inherit @@ -39,13 +75,37 @@ defmodule WorkHarborWeb.RuntimeSecurity do # GHSA-69m6-wcv4-rwcf: locked Bandit is mitigated, not patched. Keep this # current profile narrow: lower-layer handler/ALPN overrides bypass switches. - # T008 client-CA options require separate review while preserving HTTP/1 only. + # Optional client roots below are the exact reviewed T008 nested exception. defp http1_profile?(config, tls) do config[:adapter] == Bandit.PhoenixAdapter and Keyword.keyword?(tls) and - Enum.sort(Keyword.keys(tls)) == Enum.sort(@https_keys) and + tls_keys?(tls) and tls[:http_1_options] == [enabled: true] and tls[:http_2_options] == [enabled: false] end + defp tls_keys?(tls) do + keys = Enum.sort(Keyword.keys(tls)) + + keys == Enum.sort(@https_keys) or + (keys == Enum.sort(@https_keys ++ [:thousand_island_options]) and + peer_options?(tls[:thousand_island_options])) + end + + defp peer_options?(opts) do + Keyword.keyword?(opts) and Keyword.keys(opts) == [:transport_options] and + case opts[:transport_options] do + values when is_list(values) -> + Keyword.keyword?(values) and Enum.sort(Keyword.keys(values)) == Enum.sort(@peer_keys) and + values[:verify] == :verify_peer and values[:fail_if_no_peer_cert] == false and + case values[:cacerts] do + [der] when is_binary(der) -> WorkHarbor.Hosts.Issuer.valid_issuer?(der) + _ -> false + end + + _ -> + false + end + end + defp regular?(p), do: is_binary(p) and Path.type(p) == :absolute and File.regular?(p) defp private?(p) do diff --git a/apps/control_plane/priv/repo/migrations/20261014020000_host_identity.exs b/apps/control_plane/priv/repo/migrations/20261014020000_host_identity.exs new file mode 100644 index 00000000..03ff5287 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261014020000_host_identity.exs @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.HostIdentity do + use Ecto.Migration + + def up do + execute( + "CREATE TABLE host_security (host_id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL, revoked_at INTEGER, FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id))" + ) + + execute( + "CREATE TABLE host_enrollments (id TEXT PRIMARY KEY NOT NULL, host_id TEXT NOT NULL, owner_id TEXT NOT NULL, code_hash BLOB UNIQUE NOT NULL, issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER, revoked_at INTEGER, FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id), CHECK(expires_at=issued_at+300))" + ) + + execute( + "CREATE TABLE host_identities (id TEXT PRIMARY KEY NOT NULL, host_id TEXT NOT NULL, owner_id TEXT NOT NULL, fingerprint TEXT UNIQUE NOT NULL, certificate BLOB NOT NULL, csr_hash BLOB NOT NULL, predecessor_id TEXT, status TEXT NOT NULL CHECK(status IN ('active','pending','retired','revoked')), issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, activated_at INTEGER, UNIQUE(id,host_id), FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id), FOREIGN KEY(predecessor_id,host_id) REFERENCES host_identities(id,host_id), CHECK(expires_at=issued_at+86400))" + ) + + execute( + "CREATE UNIQUE INDEX one_active_host_identity ON host_identities(host_id) WHERE status='active'" + ) + + execute( + "CREATE UNIQUE INDEX one_pending_host_identity ON host_identities(host_id) WHERE status='pending'" + ) + + execute( + "CREATE TABLE host_connection_slots (host_id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL, connection_id TEXT, state TEXT NOT NULL CHECK(state IN ('unknown','connected')), FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id))" + ) + + execute( + "CREATE TABLE runtime_freshness (runtime_id TEXT PRIMARY KEY NOT NULL, generation INTEGER NOT NULL CHECK(typeof(generation)='integer' AND generation>0), intent_version INTEGER NOT NULL CHECK(typeof(intent_version)='integer' AND intent_version>0), connection_id TEXT NOT NULL, FOREIGN KEY(runtime_id,generation) REFERENCES runtime_instances(id,generation))" + ) + + execute( + "CREATE TRIGGER host_revocation_retained BEFORE UPDATE ON host_security WHEN NEW.host_id IS NOT OLD.host_id OR NEW.owner_id IS NOT OLD.owner_id OR (OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NOT OLD.revoked_at) BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + + execute( + "CREATE TRIGGER host_identity_revoked_insert BEFORE INSERT ON host_identities WHEN NEW.status IN ('active','pending') AND EXISTS(SELECT 1 FROM host_security WHERE host_id=NEW.host_id AND revoked_at IS NOT NULL) BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + + execute( + "CREATE TRIGGER host_identity_retained BEFORE UPDATE ON host_identities WHEN NEW.host_id IS NOT OLD.host_id OR NEW.owner_id IS NOT OLD.owner_id OR NEW.fingerprint IS NOT OLD.fingerprint OR NEW.certificate IS NOT OLD.certificate OR NEW.csr_hash IS NOT OLD.csr_hash OR NEW.predecessor_id IS NOT OLD.predecessor_id OR NEW.issued_at IS NOT OLD.issued_at OR NEW.expires_at IS NOT OLD.expires_at OR (OLD.status='revoked' AND NEW.status!='revoked') OR (OLD.status='retired' AND NEW.status NOT IN ('retired','revoked')) OR (NEW.status IN ('active','pending') AND EXISTS(SELECT 1 FROM host_security WHERE host_id=NEW.host_id AND revoked_at IS NOT NULL)) BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + + for table <- ~w(host_security host_enrollments host_identities) do + execute( + "CREATE TRIGGER #{table}_retained BEFORE DELETE ON #{table} BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + end + end + + def down, do: raise("identity/revocation history must not be discarded") +end diff --git a/apps/control_plane/test/support/host_database.ex b/apps/control_plane/test/support/host_database.ex new file mode 100644 index 00000000..e77d726b --- /dev/null +++ b/apps/control_plane/test/support/host_database.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostDatabase do + def open!(path) do + pid = WorkHarbor.AuthDatabase.open!(path) + + unless Code.ensure_loaded?(WorkHarbor.Repo.Migrations.HostIdentity) do + Code.require_file( + Application.app_dir(:workharbor, "priv/repo/migrations/20261014020000_host_identity.exs") + ) + end + + Ecto.Migrator.run( + WorkHarbor.Repo, + [{20_261_014_020_000, WorkHarbor.Repo.Migrations.HostIdentity}], + :up, + all: true, + log: false + ) + + pid + end +end diff --git a/apps/control_plane/test/support/host_fixture.ex b/apps/control_plane/test/support/host_fixture.ex new file mode 100644 index 00000000..d2665cf6 --- /dev/null +++ b/apps/control_plane/test/support/host_fixture.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostFixture do + @moduledoc false + def csr(host \\ nil) do + {public, private} = :crypto.generate_key(:eddsa, :ed25519) + {csr_bytes(public, private, host), public} + end + + def csr_with_key do + key = :public_key.generate_key({:namedCurve, {1, 3, 101, 112}}) + {csr_bytes(elem(key, 4), elem(key, 2), nil), key} + end + + defp csr_bytes(public, private, host) do + subject = + if host, + do: {:rdnSequence, [[{:AttributeTypeAndValue, {2, 5, 4, 3}, {:utf8String, host}}]]}, + else: {:rdnSequence, []} + + info = + {:CertificationRequestInfo, 0, subject, + {:CertificationRequestInfo_subjectPKInfo, + {:CertificationRequestInfo_subjectPKInfo_algorithm, {1, 3, 101, 112}, :asn1_NOVALUE}, + public}, []} + + bytes = :public_key.der_encode(:CertificationRequestInfo, info) + signature = :crypto.sign(:eddsa, :none, bytes, [private, :ed25519]) + + :public_key.der_encode( + :CertificationRequest, + {:CertificationRequest, info, + {:CertificationRequest_signatureAlgorithm, {1, 3, 101, 112}, :asn1_NOVALUE}, signature} + ) + end + + # Resolve trusted OS temp aliases only in fixtures; production private paths + # refuse every symlink component and never silently resolve them. + def physical_temp do + ["/" | parts] = Path.split(System.tmp_dir!()) + physical_parts("/", parts, 0) + end + + defp physical_parts(path, [], _), do: path + + defp physical_parts(path, [part | rest], depth) when depth < 40 do + next = Path.join(path, part) + + case File.read_link(next) do + {:ok, target} -> + ["/" | resolved] = Path.split(Path.expand(target, path)) + physical_parts("/", resolved ++ rest, depth + 1) + + {:error, :einval} -> + physical_parts(next, rest, depth) + + _ -> + raise("unavailable physical fixture directory") + end + end +end diff --git a/apps/control_plane/test/support/host_log_capture.ex b/apps/control_plane/test/support/host_log_capture.ex new file mode 100644 index 00000000..f8a50957 --- /dev/null +++ b/apps/control_plane/test/support/host_log_capture.ex @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostLogCapture do + @moduledoc "Test handler receives events only after all native primary filters." + def adding_handler(config), do: {:ok, config} + def removing_handler(_), do: :ok + def changing_config(_, _, config), do: {:ok, config} + def log(event, %{config: %{test: test}}), do: send(test, {:after_primary_log, event}) +end diff --git a/apps/control_plane/test/support/host_transport_endpoint.ex b/apps/control_plane/test/support/host_transport_endpoint.ex new file mode 100644 index 00000000..cf76f122 --- /dev/null +++ b/apps/control_plane/test/support/host_transport_endpoint.ex @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostTransportEndpoint do + @moduledoc "Test-only committed fixture repository wiring; production ingress is unchanged." + def init(opts), do: opts + + def call(conn, _) do + WorkHarbor.Repo.put_dynamic_repo( + Application.fetch_env!(:workharbor, :host_transport_fixture_repo) + ) + + WorkHarborWeb.Endpoint.call(conn, WorkHarborWeb.Endpoint.init([])) + end +end diff --git a/apps/control_plane/test/workharbor/host_connections_test.exs b/apps/control_plane/test/workharbor/host_connections_test.exs new file mode 100644 index 00000000..9d78903f --- /dev/null +++ b/apps/control_plane/test/workharbor/host_connections_test.exs @@ -0,0 +1,426 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostConnectionsTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, Hosts, HostFixture, HostConnections, Repo} + alias WorkHarbor.Hosts.Issuer + + setup do + dir = Path.join(HostFixture.physical_temp(), "whr-host-test-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + previous = Application.get_env(:workharbor, :host_identity) + clock = Application.get_env(:workharbor, :clock) + pid = WorkHarbor.HostDatabase.open!(Path.join(dir, "state.db")) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, _} = Accounts.login(owner, secret, "cli_owner") + {:ok, principal} = Accounts.authenticate(token) + issuer_dir = Path.join(dir, "issuer") + + on_exit(fn -> + if previous, + do: Application.put_env(:workharbor, :host_identity, previous), + else: Application.delete_env(:workharbor, :host_identity) + + if clock, + do: Application.put_env(:workharbor, :clock, clock), + else: Application.delete_env(:workharbor, :clock) + + if Process.alive?(pid), do: GenServer.stop(pid) + File.rm_rf!(dir) + end) + + assert {:ok, _} = Issuer.initialize(issuer_dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: issuer_dir) + {csr, _} = HostFixture.csr() + {:ok, issued} = Hosts.issue_enrollment(principal, nil) + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + {:ok, peer} = Hosts.resolve_identity(Base.decode64!(enrolled["certificate"]), :active) + {:ok, mono} = Agent.start_link(fn -> 0 end) + previous_mono = Application.get_env(:workharbor, :host_monotonic_clock) + Application.put_env(:workharbor, :host_monotonic_clock, fn -> Agent.get(mono, & &1) end) + + on_exit(fn -> + if previous_mono, + do: Application.put_env(:workharbor, :host_monotonic_clock, previous_mono), + else: Application.delete_env(:workharbor, :host_monotonic_clock) + end) + + server = start_supervised!({HostConnections, repo: pid}) + + %{ + dir: dir, + issuer_dir: issuer_dir, + principal: principal, + repo: pid, + peer: peer, + mono: mono, + server: server + } + end + + defp message(c, kind) do + m = %{ + "schema_version" => 1, + "kind" => kind, + "owner_id" => c.peer.owner_id, + "host_id" => c.peer.host_id + } + + if kind == "hello", do: Map.put(m, "capabilities", []), else: m + end + + defp connect(c) do + {:ok, ref} = HostConnections.open(c.peer, self()) + + assert {:ok, %{"kind" => "hello_ack"}} = + HostConnections.receive_hello(ref, message(c, "hello")) + + assert {:ok, %{"kind" => "heartbeat_ack"}} = + HostConnections.heartbeat(ref, message(c, "heartbeat")) + + ref + end + + defp runtime(c) do + {:ok, {:accepted, operation}} = + WorkHarbor.Foundation.accept( + c.peer.owner_id, + "connection-fixture", + WorkHarbor.FoundationDatabase.request(c.peer.host_id), + "internal" + ) + + binding = WorkHarbor.FoundationDatabase.current!(operation) + {operation, binding} + end + + test "authenticated heartbeat becomes stale exactly30seconds without changing history", c do + ref = connect(c) + {operation, binding} = runtime(c) + + assert :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + assert {:ok, %{observed_state: "running"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + Agent.update(c.mono, fn _ -> 29999 end) + + assert {:ok, %{observed_state: "running"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + Agent.update(c.mono, fn _ -> 30000 end) + send(c.server, :tick) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert_receive {:host_connection_close, :stale} + + assert Repo.get!(WorkHarbor.Foundation.RuntimeInstance, binding["id"]).observed_state == + "running" + + assert Repo.get!(WorkHarbor.Foundation.Operation, operation.id).status == "queued" + end + + test "reconnect and replaced socket cannot refresh old runtime annotation", c do + ref = connect(c) + {operation, binding} = runtime(c) + + assert :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + next = connect(c) + assert_receive {:host_connection_close, :replaced} + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert :ok = + HostConnections.record_fresh_observation( + next, + binding["id"], + binding["generation"], + operation.intent_version + ) + + assert {:ok, %{observed_state: "running"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert {:error, :stale_observation} = + HostConnections.record_fresh_observation( + next, + binding["id"], + binding["generation"] + 1, + operation.intent_version + ) + + assert {:error, :stale_observation} = + HostConnections.record_fresh_observation( + next, + binding["id"], + binding["generation"], + operation.intent_version + 1 + ) + end + + test "revocation and open socket certificate expiry fence existing connections", c do + ref = connect(c) + assert :ok = Hosts.revoke(c.principal, c.peer.host_id) + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + assert {:error, :unauthorized_host} = HostConnections.open(c.peer, self()) + assert_receive {:host_connection_close, _} + end + + test "closed connection messages reject malformed scope fields versions and bounds", c do + valid = message(c, "heartbeat") + + for bad <- [ + Map.put(valid, "schema_version", 2), + Map.put(valid, "schema_version", 1.0), + Map.put(valid, "host_id", c.peer.owner_id), + Map.put(valid, "token", "SECRET_SENTINEL") + ] do + assert {:error, _} = + WorkHarbor.HostConnections.Protocol.decode( + Jason.encode!(bad), + c.peer.owner_id, + c.peer.host_id + ) + end + + assert {:error, _} = + WorkHarbor.HostConnections.Protocol.decode( + Jason.encode!(valid) <> String.duplicate(" ", 4096), + c.peer.owner_id, + c.peer.host_id + ) + + assert {:error, _} = + WorkHarbor.HostConnections.Protocol.decode( + ~s({"schema_version":1,"schema_version":1}), + c.peer.owner_id, + c.peer.host_id + ) + end + + test "late heartbeat at stale boundary cannot revive an existing socket", c do + ref = connect(c) + Agent.update(c.mono, fn _ -> 30000 end) + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + assert_receive {:host_connection_close, :stale} + end + + test "renewed lease and replaced connection fence delayed stale timers", c do + ref = connect(c) + old_lease = :sys.get_state(c.server).peers[elem(ref, 1)].lease + Agent.update(c.mono, fn _ -> 29000 end) + {:ok, _} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + Agent.update(c.mono, fn _ -> 30000 end) + send(c.server, {:stale, elem(ref, 1), old_lease}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + next = connect(c) + send(c.server, {:stale, elem(ref, 1), old_lease}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + assert {:ok, _} = HostConnections.heartbeat(next, message(c, "heartbeat")) + end + + test "restart invalidates known observations without changing retained operation history", c do + ref = connect(c) + {operation, binding} = runtime(c) + + :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + :ok = stop_supervised(HostConnections) + start_supervised!({HostConnections, repo: c.repo}) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert Repo.get!(WorkHarbor.Foundation.RuntimeInstance, binding["id"]).observed_state == + "running" + + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + end + + test "open socket absolute certificate expiry rejects further traffic", c do + ref = connect(c) + Application.put_env(:workharbor, :clock, fn -> c.peer.expires_at end) + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + assert_receive {:host_connection_close, :identity_invalid} + assert {:error, :unauthorized_host} = HostConnections.open(c.peer, self()) + end + + test "failed persistence leaves conservative unknown and fixed failure", c do + ref = connect(c) + {operation, binding} = runtime(c) + + :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + Hosts.sql( + "CREATE TRIGGER fail_liveness BEFORE UPDATE ON host_connection_slots BEGIN SELECT RAISE(ABORT,'SECRET_SENTINEL'); END" + ) + + assert {:error, :persistence_failed} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + end + + defp await_projection(pid, remaining) do + found = + Enum.any?(elem(Process.info(pid, :messages), 1), fn + {:"$gen_call", _, message} when is_tuple(message) -> elem(message, 0) == :project + _ -> false + end) + + cond do + found -> + :ok + + remaining == 0 -> + flunk("projection did not reach worker") + + true -> + Process.sleep(1) + await_projection(pid, remaining - 1) + end + end + + test "projection rechecks authoritative current intent inside worker", c do + ref = connect(c) + {operation, binding} = runtime(c) + + :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + :sys.suspend(c.server) + + task = + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + HostConnections.project_runtime(c.principal, binding["id"]) + end) + + try do + await_projection(c.server, 1000) + WorkHarbor.FoundationDatabase.finish!(c.peer.owner_id, c.peer.host_id, operation) + + request = + WorkHarbor.FoundationDatabase.request(c.peer.host_id, operation.workspace_id, "stop") + + assert {:ok, {:accepted, _}} = + WorkHarbor.Foundation.accept(c.peer.owner_id, "newer-intent", request, "internal") + after + :sys.resume(c.server) + end + + assert {:ok, %{observed_state: "unknown"}} = Task.await(task) + end + + test "stale audit rollback reports fixed persistence failure", c do + ref = connect(c) + entry = :sys.get_state(c.server).peers[elem(ref, 1)] + + Hosts.sql( + "CREATE TRIGGER reject_stale_audit BEFORE INSERT ON audit_events WHEN NEW.event='host_connection_unknown' BEGIN SELECT RAISE(ABORT,'SECRET_SENTINEL'); END" + ) + + Agent.update(c.mono, fn _ -> 30000 end) + assert {:error, :persistence_failed} = HostConnections.mark_stale(ref, entry.lease) + end + + test "dedicated monotonic certificate expiry timer fences idle and replaced sockets", c do + ref = connect(c) + entry = :sys.get_state(c.server).peers[elem(ref, 1)] + assert is_reference(Map.get(entry, :expiry_timer)) + assert is_integer(Process.read_timer(entry.expiry_timer)) + send(c.server, {:expire, elem(ref, 1), entry.id}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + next = connect(c) + assert_receive {:host_connection_close, :replaced} + assert Process.read_timer(entry.expiry_timer) == false + send(c.server, {:expire, elem(ref, 1), entry.id}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + next_entry = :sys.get_state(c.server).peers[elem(next, 1)] + Agent.update(c.mono, fn _ -> next_entry.expiry end) + send(c.server, {:expire, elem(next, 1), next_entry.id}) + assert_receive {:host_connection_close, :identity_invalid} + assert {:ok, "unknown"} = HostConnections.host_state(c.peer.host_id) + end + + test "agent socket lifetime is fenced by owning connection worker death", c do + assert {:ok, state} = WorkHarborWeb.AgentSocket.init(c.peer) + assert is_reference(Map.get(state, :worker_monitor)) + monitor = state.worker_monitor + assert :ok = stop_supervised(HostConnections) + assert_receive {:DOWN, ^monitor, :process, _, _} = down + + assert {:stop, :normal, 1008, ^state} = + WorkHarborWeb.AgentSocket.handle_info(down, state) + end + + test "shared closed host connection fixtures match native validator" do + path = + Path.expand("../../specs/006-whr-cli/contracts/host-connection.fixtures.json", File.cwd!()) + + fixture = path |> File.read!() |> Jason.decode!() + + for c <- fixture["cases"] do + result = + WorkHarbor.HostConnections.Protocol.decode( + c["raw"], + fixture["owner_id"], + fixture["host_id"] + ) + + assert match?({:ok, _}, result) == c["accepted"], c["name"] + end + end + + test "rejected application data closes normally without crashing or echoing input", c do + assert {:ok, state} = WorkHarborWeb.AgentSocket.init(c.peer) + + raw = + Jason.encode!(%{ + schema_version: 1, + kind: "hello", + owner_id: c.peer.owner_id, + host_id: c.peer.host_id, + capabilities: [], + token: "SECRET_SENTINEL" + }) + + assert {:stop, :normal, 1008, ^state} = + WorkHarborWeb.AgentSocket.handle_in({raw, [opcode: :text]}, state) + end +end diff --git a/apps/control_plane/test/workharbor/host_identity_test.exs b/apps/control_plane/test/workharbor/host_identity_test.exs new file mode 100644 index 00000000..e5baae52 --- /dev/null +++ b/apps/control_plane/test/workharbor/host_identity_test.exs @@ -0,0 +1,448 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostIdentityTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, Hosts, HostFixture, Repo} + alias WorkHarbor.Hosts.Issuer + + setup do + dir = Path.join(HostFixture.physical_temp(), "whr-host-test-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + previous = Application.get_env(:workharbor, :host_identity) + clock = Application.get_env(:workharbor, :clock) + pid = WorkHarbor.HostDatabase.open!(Path.join(dir, "state.db")) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, _} = Accounts.login(owner, secret, "cli_owner") + {:ok, principal} = Accounts.authenticate(token) + issuer_dir = Path.join(dir, "issuer") + + on_exit(fn -> + if previous, + do: Application.put_env(:workharbor, :host_identity, previous), + else: Application.delete_env(:workharbor, :host_identity) + + if clock, + do: Application.put_env(:workharbor, :clock, clock), + else: Application.delete_env(:workharbor, :clock) + + if Process.alive?(pid), do: GenServer.stop(pid) + File.rm_rf!(dir) + end) + + assert {:ok, _} = Issuer.initialize(issuer_dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: issuer_dir) + %{dir: dir, issuer_dir: issuer_dir, principal: principal, repo: pid, secret: secret} + end + + test "native issuer initializes exclusively and reloads same private identity", c do + assert {:ok, first} = Issuer.load(c.issuer_dir) + assert {:error, :already_initialized} = Issuer.initialize(c.issuer_dir) + assert {:ok, second} = Issuer.load(c.issuer_dir) + assert first.certificate == second.certificate + assert Bitwise.band(File.stat!(Path.join(c.issuer_dir, "issuer.json")).mode, 0o777) == 0o600 + end + + test "issuer refuses unsafe missing future and malformed state without replacement", c do + path = Path.join(c.issuer_dir, "issuer.json") + File.chmod!(path, 0o644) + assert {:error, :unsafe_identity} = Issuer.load(c.issuer_dir) + File.chmod!(path, 0o600) + File.write!(path, ~s({"schema_version":2,"private_key":"SECRET_SENTINEL"})) + assert {:error, :invalid_identity} = Issuer.load(c.issuer_dir) + assert {:error, :invalid_identity} = Issuer.load(Path.join(c.dir, "missing")) + end + + test "single use enrollment derives host scope and code never persists", c do + assert {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + assert {:ok, enrolled} = Hosts.redeem(issued.code, csr) + assert enrolled["host_id"] == issued.host_id + assert enrolled["owner_id"] == c.principal.owner_id + assert {:error, :invalid_enrollment} = Hosts.redeem(issued.code, csr) + + refute inspect(Repo.query!("SELECT * FROM host_enrollments", [], log: false).rows) =~ + issued.code + + refute inspect(Repo.all(WorkHarbor.Foundation.AuditEvent)) =~ issued.code + end + + test "enrollment exact expiry issues no identities", c do + now = Accounts.now() + Application.put_env(:workharbor, :clock, fn -> now end) + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + Application.put_env(:workharbor, :clock, fn -> now + 300 end) + assert {:error, :invalid_enrollment} = Hosts.redeem(issued.code, csr) + assert WorkHarbor.FoundationDatabase.count("host_identities") == 0 + end + + test "independent enrollment writers have exactly one committed winner", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + parent = self() + + tasks = + for _ <- 1..2 do + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + send(parent, {:ready, self()}) + + receive do + :go -> Hosts.redeem(issued.code, csr) + end + end) + end + + for _ <- 1..2 do + assert_receive {:ready, _} + end + + Enum.each(tasks, &send(&1.pid, :go)) + results = Enum.map(tasks, &Task.await(&1, 5000)) + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :invalid_enrollment})) == 1 + assert WorkHarbor.FoundationDatabase.count("host_identities") == 1 + end + + test "explicit host actions reject dashboard and cross owner before mutation", c do + {:ok, token, _} = Accounts.login(c.principal.owner_id, c.secret, "dashboard") + {:ok, dashboard} = Accounts.authenticate(token) + assert {:error, :forbidden} = Hosts.issue_enrollment(dashboard, nil) + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {:ok, foreign} = WorkHarbor.Foundation.create_owner("other") + {:ok, host} = WorkHarbor.Foundation.create_host(foreign.id) + assert {:error, :not_found} = Hosts.issue_enrollment(c.principal, host.id) + assert {:error, :not_found} = Hosts.revoke(c.principal, host.id) + assert {:ok, _} = Hosts.status(c.principal, issued.host_id) + end + + test "revocation tombstones host codes identities and repeated revoke", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + cert = Base.decode64!(enrolled["certificate"]) + assert {:ok, _} = Hosts.resolve_identity(cert, :active) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + assert {:error, :unauthorized_host} = Hosts.resolve_identity(cert, :active) + assert {:error, :host_revoked} = Hosts.issue_enrollment(c.principal, issued.host_id) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + assert {:ok, %{revoked: true}} = Hosts.status(c.principal, issued.host_id) + end + + test "staged rotation pending activation and lost ack preserve one identity", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + old = Base.decode64!(enrolled["certificate"]) + {:ok, peer} = Hosts.resolve_identity(old, :active) + {renewal, _} = HostFixture.csr(issued.host_id) + assert {:ok, pending} = Hosts.renew(peer, renewal) + assert {:ok, ^pending} = Hosts.renew(peer, renewal) + new = Base.decode64!(pending["certificate"]) + assert {:error, :unauthorized_host} = Hosts.resolve_identity(new, :active) + {:ok, replacement} = Hosts.resolve_identity(new, :activation) + assert :ok = Hosts.activate(replacement) + assert :ok = Hosts.activate(replacement) + assert {:error, :unauthorized_host} = Hosts.resolve_identity(old, :active) + assert {:ok, _} = Hosts.resolve_identity(new, :active) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + assert {:error, :unauthorized_host} = Hosts.activate(replacement) + end + + test "CSR signature corruption and enrollment names cannot issue identity", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + size = byte_size(csr) - 1 + <> = csr + assert {:error, :invalid_csr} = Hosts.redeem(issued.code, prefix <> <>) + {named, _} = HostFixture.csr(issued.host_id) + assert {:error, :invalid_csr} = Hosts.redeem(issued.code, named) + assert WorkHarbor.FoundationDatabase.count("host_identities") == 0 + end + + test "certificate expiry and response remain identical across UTC tick redemption and renewal", + c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + base = Accounts.now() + Process.put(:t008_tick, 0) + + Application.put_env(:workharbor, :clock, fn -> + n = Process.get(:t008_tick, 0) + Process.put(:t008_tick, n + 1) + base + n + end) + + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + cert = Base.decode64!(enrolled["certificate"]) + assert Issuer.inspect_certificate(cert).not_after == enrolled["expires_at"] + {:ok, peer} = Hosts.resolve_identity(cert, :active) + {renewal, _} = HostFixture.csr(issued.host_id) + {:ok, pending} = Hosts.renew(peer, renewal) + + assert Issuer.inspect_certificate(Base.decode64!(pending["certificate"])).not_after == + pending["expires_at"] + end + + test "revoked tombstone scope cannot move clear delete or admit reenrollment", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + :ok = Hosts.revoke(c.principal, issued.host_id) + {:ok, other} = WorkHarbor.Foundation.create_host(c.principal.owner_id) + + for {statement, args} <- [ + {"UPDATE host_security SET host_id=? WHERE host_id=?", [other.id, issued.host_id]}, + {"UPDATE host_security SET revoked_at=NULL WHERE host_id=?", [issued.host_id]}, + {"DELETE FROM host_security WHERE host_id=?", [issued.host_id]} + ] do + assert_raise Exqlite.Error, fn -> Repo.query!(statement, args, log: false) end + end + + assert {:error, :host_revoked} = Hosts.issue_enrollment(c.principal, issued.host_id) + end + + test "CSR and private issuer DER suffixes reject before code consumption", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + + for invalid <- [csr <> <<0>>, csr <> csr] do + assert {:error, :invalid_csr} = Hosts.redeem(issued.code, invalid) + end + + assert {:ok, _} = Hosts.redeem(issued.code, csr) + path = Path.join(c.issuer_dir, "issuer.json") + state = Jason.decode!(File.read!(path)) + + for field <- ["certificate", "private_key"] do + bad = + Map.update!(state, field, fn value -> Base.encode64(Base.decode64!(value) <> <<0>>) end) + + File.write!(path, Jason.encode!(bad)) + assert {:error, :invalid_identity} = Issuer.load(c.issuer_dir) + end + end + + test "issuer refuses final and ancestor symlinks before initialization writes", c do + real = Path.join(c.dir, "real") + File.mkdir!(real) + File.chmod!(real, 0o700) + alias_path = Path.join(c.dir, "alias") + File.ln_s!(real, alias_path) + {:ok, _} = Issuer.initialize(Path.join(real, "issuer")) + assert {:error, :unsafe_identity} = Issuer.load(Path.join(alias_path, "issuer")) + assert {:error, :unsafe_identity} = Issuer.initialize(Path.join(alias_path, "fresh")) + refute File.exists?(Path.join(real, "fresh")) + end + + test "native leaf and issuer profiles reject extra purposes critical and duplicate extensions", + c do + {:ok, issuer} = Issuer.load(c.issuer_dir) + {csr, _} = HostFixture.csr() + host = Ecto.UUID.generate() + {:ok, leaf} = Issuer.issue(issuer, csr, host, :enrollment) + value = :public_key.pkix_decode_cert(leaf, :otp) |> elem(1) + extensions = elem(value, 10) + + for changed <- [ + extensions ++ [{:Extension, {1, 2, 3, 4}, true, <<5, 0>>}], + extensions ++ [{:Extension, {2, 5, 29, 37}, false, [{1, 3, 6, 1, 5, 5, 7, 3, 1}]}] + ] do + bad = :public_key.pkix_sign(put_elem(value, 10, changed), issuer.key) + accepted = Issuer.valid_leaf?(bad, issuer, host, Accounts.now()) + refute accepted + end + + ca = :public_key.pkix_decode_cert(issuer.certificate, :otp) |> elem(1) + + bad_ca = + :public_key.pkix_sign( + put_elem( + ca, + 10, + elem(ca, 10) ++ [{:Extension, {2, 5, 29, 37}, false, [{1, 3, 6, 1, 5, 5, 7, 3, 2}]}] + ), + issuer.key + ) + + accepted = Issuer.valid_leaf?(leaf, %{issuer | certificate: bad_ca}, host, Accounts.now()) + refute accepted + end + + test "issuer startup rejects signed critical path length and duration profile variants", c do + {:ok, issuer} = Issuer.load(c.issuer_dir) + ca = :public_key.pkix_decode_cert(issuer.certificate, :otp) |> elem(1) + path = Path.join(c.issuer_dir, "issuer.json") + state = Jason.decode!(File.read!(path)) + + variants = [ + elem(ca, 10) ++ [{:Extension, {1, 2, 3, 4}, true, <<5, 0>>}], + Enum.map(elem(ca, 10), fn + {:Extension, {2, 5, 29, 19}, critical, _} -> + {:Extension, {2, 5, 29, 19}, critical, {:BasicConstraints, true, 0}} + + ext -> + ext + end) + ] + + for extensions <- variants do + signed = :public_key.pkix_sign(put_elem(ca, 10, extensions), issuer.key) + File.write!(path, Jason.encode!(Map.put(state, "certificate", Base.encode64(signed)))) + result = Issuer.load(c.issuer_dir) + rejected = result == {:error, :invalid_identity} + assert rejected + end + + File.write!(path, Jason.encode!(state)) + {csr, _} = HostFixture.csr() + host = Ecto.UUID.generate() + {:ok, leaf} = Issuer.issue(issuer, csr, host, :enrollment) + tbs = :public_key.pkix_decode_cert(leaf, :otp) |> elem(1) + {:Validity, start, _} = elem(tbs, 5) + + stop = + {:generalTime, + (Accounts.now() + 3600) + |> DateTime.from_unix!() + |> Calendar.strftime("%Y%m%d%H%M%SZ") + |> String.to_charlist()} + + short = :public_key.pkix_sign(put_elem(tbs, 5, {:Validity, start, stop}), issuer.key) + accepted = Issuer.valid_leaf?(short, issuer, host, Accounts.now()) + refute accepted + end + + test "signed oversized issuer is refused before enrollment code consumption", c do + {:ok, issuer} = Issuer.load(c.issuer_dir) + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + path = Path.join(c.issuer_dir, "issuer.json") + bytes = File.read!(path) + original = Jason.decode!(bytes) + ca = :public_key.pkix_decode_cert(issuer.certificate, :otp) |> elem(1) + extensions = elem(ca, 10) ++ [{:Extension, {1, 2, 3, 4}, false, :binary.copy(<<0>>, 12000)}] + oversized = :public_key.pkix_sign(put_elem(ca, 10, extensions), issuer.key) + assert byte_size(oversized) > 4096 + File.write!(path, Jason.encode!(Map.put(original, "certificate", Base.encode64(oversized)))) + {csr, _} = HostFixture.csr() + assert elem(Issuer.load(c.issuer_dir), 0) == :error + refute Issuer.valid_issuer?(oversized) + assert {:error, _} = Hosts.redeem(issued.code, csr) + + assert Hosts.one("SELECT consumed_at FROM host_enrollments WHERE host_id=?", [issued.host_id])[ + "consumed_at" + ] == nil + + assert Hosts.one("SELECT count(*) AS n FROM host_identities WHERE host_id=?", [issued.host_id])[ + "n" + ] == 0 + + File.write!(path, bytes) + assert {:ok, response} = Hosts.redeem(issued.code, csr) + assert byte_size(Jason.encode!(response)) <= 16384 + end + + test "serialized owner revocation wins concurrent issuance redemption renewal and activation", + c do + for phase <- [:issue, :redeem, :renew, :activate] do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + + peer = + if phase in [:renew, :activate] do + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + {:ok, active} = Hosts.resolve_identity(Base.decode64!(enrolled["certificate"]), :active) + active + end + + {renewal, _} = HostFixture.csr(issued.host_id) + + pending = + if phase == :activate do + {:ok, staged} = Hosts.renew(peer, renewal) + + {:ok, pending} = + Hosts.resolve_identity(Base.decode64!(staged["certificate"]), :activation) + + pending + end + + parent = self() + + job = fn fun -> + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + send(parent, {:writer_ready, self()}) + + receive do + :write -> fun.() + after + 2000 -> raise "writer barrier timeout" + end + end) + end + + contender = + job.(fn -> + case phase do + :issue -> Hosts.issue_enrollment(c.principal, issued.host_id) + :redeem -> Hosts.redeem(issued.code, csr) + :renew -> Hosts.renew(peer, renewal) + :activate -> Hosts.activate(pending) + end + end) + + revoker = job.(fn -> Hosts.revoke(c.principal, issued.host_id) end) + + for _ <- 1..2 do + assert_receive {:writer_ready, _}, 2000 + end + + send(contender.pid, :write) + send(revoker.pid, :write) + assert :ok = Task.await(revoker, 5000) + result = Task.await(contender, 5000) + assert result == :ok or elem(result, 0) in [:ok, :error] + assert {:ok, %{revoked: true}} = Hosts.status(c.principal, issued.host_id) + + assert Hosts.one( + "SELECT count(*) AS n FROM host_identities WHERE host_id=? AND status IN ('active','pending')", + [issued.host_id] + )["n"] == 0 + + assert Hosts.one( + "SELECT count(*) AS n FROM host_enrollments WHERE host_id=? AND revoked_at IS NULL", + [issued.host_id] + )["n"] == 0 + + assert elem(Hosts.redeem(issued.code, csr), 0) == :error + if peer, do: refute(Hosts.current_peer?(peer)) + if pending, do: refute(Hosts.current_peer?(pending, :activation)) + end + end + + test "enrollment audit failure rolls back consumption identity and active fingerprint", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + + Hosts.sql( + "CREATE TRIGGER refuse_enrollment_audit BEFORE INSERT ON audit_events WHEN NEW.event='host_enrolled' BEGIN SELECT RAISE(ABORT,'SECRET_SENTINEL'); END" + ) + + assert {:error, :persistence_failed} = Hosts.redeem(issued.code, csr) + + assert Hosts.one("SELECT consumed_at FROM host_enrollments WHERE host_id=?", [issued.host_id])[ + "consumed_at" + ] == nil + + assert Hosts.one("SELECT count(*) AS n FROM host_identities WHERE host_id=?", [issued.host_id])[ + "n" + ] == 0 + + assert Hosts.one("SELECT identity_fingerprint FROM hosts WHERE id=?", [issued.host_id])[ + "identity_fingerprint" + ] == nil + + Hosts.sql("DROP TRIGGER refuse_enrollment_audit") + assert {:ok, _} = Hosts.redeem(issued.code, csr) + end +end diff --git a/apps/control_plane/test/workharbor_web/agent_ingress_test.exs b/apps/control_plane/test/workharbor_web/agent_ingress_test.exs new file mode 100644 index 00000000..494a6eb3 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/agent_ingress_test.exs @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentIngressTest do + use ExUnit.Case, async: false + import Plug.Conn + import Plug.Test + alias WorkHarborWeb.AgentIngress + + defp request(method, path, body \\ "") do + c = conn(method, "https://localhost" <> path, body) + + %{c | req_headers: [{"host", "localhost"} | c.req_headers]} + |> put_req_header("content-type", "application/json") + end + + test "agent namespace is admitted before browser session and rejects mixed credentials" do + for {header, value} <- [ + {"authorization", "Bearer SECRET_SENTINEL"}, + {"cookie", "SECRET_SENTINEL"}, + {"origin", "https://localhost"}, + {"sec-fetch-site", "same-origin"}, + {"x-workharbor-context", "owner"} + ] do + result = + request(:post, "/agent/v1/enroll", "{}") + |> put_req_header(header, value) + |> AgentIngress.call([]) + + assert result.status == 403 + refute Map.has_key?(result.private, :plug_session) + refute result.resp_body =~ "SECRET_SENTINEL" + end + end + + test "agent TLS authority exact path and query are checked before bodies" do + for c <- [ + request(:post, "/agent/v1/enroll?code=SECRET_SENTINEL"), + request(:post, "/agent/v1/enroll/"), + %{request(:post, "/agent/v1/enroll") | scheme: :http}, + %{request(:post, "/agent/v1/enroll") | req_headers: [{"host", "elsewhere"}]} + ] do + result = AgentIngress.call(c, []) + assert result.status in [400, 403] + refute result.resp_body =~ "SECRET_SENTINEL" + end + end + + test "assigned JSON and owner principal never substitute for actual peer certificate" do + for path <- ["/agent/v1/identity/renew", "/agent/v1/identity/activate", "/agent/v1/connect"] do + c = + request(if(path == "/agent/v1/connect", do: :get, else: :post), path, "{}") + |> assign(:peer, %{authenticated: true}) + |> assign(:principal, %{owner_id: Ecto.UUID.generate()}) + + result = AgentIngress.call(c, []) + assert result.status == 401 + assert Jason.decode!(result.resp_body) == %{"error" => %{"code" => "unauthorized_host"}} + end + end + + test "enrollment body is closed strict and bounded with fixed redacted errors" do + for raw <- [ + ~s({"schema_version":1,"schema_version":1}), + ~s({"schema_version":1.0,"code":"SECRET_SENTINEL","csr":""}), + ~s({"schema_version":1,"code":"SECRET_SENTINEL","csr":"","extra":true}), + String.duplicate(" ", 16385) + ] do + result = AgentIngress.call(request(:post, "/agent/v1/enroll", raw), []) + assert result.status == 400 + refute result.resp_body =~ "SECRET_SENTINEL" + end + end + + test "failed agent body validation preserves the consumed adapter state" do + result = + AgentIngress.call( + request(:post, "/agent/v1/enroll", ~s({"schema_version":1,"extra":"SECRET_SENTINEL"})), + [] + ) + + assert elem(result.adapter, 1).req_body == "" + end +end diff --git a/apps/control_plane/test/workharbor_web/agent_log_filter_test.exs b/apps/control_plane/test/workharbor_web/agent_log_filter_test.exs new file mode 100644 index 00000000..c8530205 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/agent_log_filter_test.exs @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentLogFilterTest do + use ExUnit.Case, async: false + alias WorkHarborWeb.{AgentLogFilter, AgentSocket} + + test "agent crash events rebuild all report and metadata to fixed safe values" do + for label <- [{:gen_server, :terminate}, {:proc_lib, :crash}] do + event = %{ + level: :error, + msg: + {:report, + %{ + label: label, + state: "SECRET_SENTINEL", + last_message: "SECRET_SENTINEL", + log: "SECRET_SENTINEL", + reason: "SECRET_SENTINEL", + client_info: "SECRET_SENTINEL", + extra: "SECRET_SENTINEL", + report: ["SECRET_SENTINEL"] + }}, + meta: %{ + workharbor_agent_transport: true, + pid: self(), + time: 123, + domain: [:otp], + report_cb: "SECRET_SENTINEL", + error_logger: "SECRET_SENTINEL", + crash_reason: "SECRET_SENTINEL", + extra: "SECRET_SENTINEL" + }, + extra: "SECRET_SENTINEL" + } + + assert AgentLogFilter.filter(event, []) == %{ + level: :error, + msg: {:string, "WorkHarbor agent transport refused"}, + meta: %{pid: self(), time: 123, domain: [:workharbor, :agent_transport]} + } + end + + fallback = %{ + level: :error, + msg: + {:report, + %{ + label: {:gen_server, :terminate}, + state: + {:socket, + %{ + connection: %Bandit.WebSocket.Connection{ + websock: AgentSocket, + websock_state: "SECRET_SENTINEL" + } + }} + }}, + meta: %{pid: "SECRET_SENTINEL", time: "SECRET_SENTINEL", report_cb: "SECRET_SENTINEL"} + } + + assert AgentLogFilter.filter(fallback, []) == %{ + level: :error, + msg: {:string, "WorkHarbor agent transport refused"}, + meta: %{domain: [:workharbor, :agent_transport]} + } + end + + test "filter is total and leaves unrelated events unchanged" do + for event <- [ + nil, + [], + %{}, + %{ + msg: {:report, %{label: {:gen_server, :terminate}, state: :other}}, + meta: %{pid: self()} + }, + %{msg: {:report, %{label: :unrelated}}, meta: %{workharbor_agent_transport: true}}, + %{msg: {:report, %{label: {:proc_lib, :crash}}}, meta: :invalid} + ] do + assert AgentLogFilter.filter(event, []) == :ignore + end + end + + test "owned filter installation is idempotent and refuses a conflicting owner" do + id = :workharbor_agent_transport + previous = List.keyfind(:logger.get_primary_config().filters, id, 0) + :logger.remove_primary_filter(id) + + on_exit(fn -> + :logger.remove_primary_filter(id) + if previous, do: :logger.add_primary_filter(id, elem(previous, 1)) + end) + + assert :ok = AgentLogFilter.install() + assert :ok = AgentLogFilter.install() + :logger.remove_primary_filter(id) + conflicting = {fn event, _ -> event end, []} + assert :ok = :logger.add_primary_filter(id, conflicting) + assert {:error, :unsafe_agent_logging} = AgentLogFilter.install() + assert {^id, ^conflicting} = List.keyfind(:logger.get_primary_config().filters, id, 0) + end +end diff --git a/apps/control_plane/test/workharbor_web/host_api_test.exs b/apps/control_plane/test/workharbor_web/host_api_test.exs new file mode 100644 index 00000000..a2e497b4 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/host_api_test.exs @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.HostAPITest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, HostFixture, Hosts} + + setup do + dir = Path.join(HostFixture.physical_temp(), "whr-host-api-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + repo = WorkHarbor.HostDatabase.open!(Path.join(dir, "state.db")) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, principal} = Accounts.login(owner, secret, "cli_owner") + previous = Application.get_env(:workharbor, :host_identity) + issuer_dir = Path.join(dir, "issuer") + {:ok, _} = WorkHarbor.Hosts.Issuer.initialize(issuer_dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: issuer_dir) + + on_exit(fn -> + if previous, + do: Application.put_env(:workharbor, :host_identity, previous), + else: Application.delete_env(:workharbor, :host_identity) + + if Process.alive?(repo), do: GenServer.stop(repo) + File.rm_rf!(dir) + end) + + %{token: token, principal: principal, dir: dir, issuer_dir: issuer_dir} + end + + defp request(method, path, body, token) do + c = Plug.Test.conn(method, "https://localhost" <> path, Jason.encode!(body)) + + c = %{ + c + | req_headers: [ + {"host", "localhost"}, + {"content-type", "application/json"}, + {"authorization", "Bearer " <> token} + ] + } + + WorkHarborWeb.Endpoint.call(c, WorkHarborWeb.Endpoint.init([])) + end + + test "explicit owner host endpoints issue revoke and confirm offboarding", c do + issued = + request(:post, "/api/v1/hosts/enrollments", %{schema_version: 1, host_id: nil}, c.token) + + assert issued.status == 200 + body = Jason.decode!(issued.resp_body) + assert String.starts_with?(body["code"], "whe1_") + host = body["host_id"] + revoked = request(:post, "/api/v1/hosts/#{host}/revoke", %{schema_version: 1}, c.token) + assert revoked.status == 200 + + assert request(:post, "/api/v1/hosts/#{host}/revoke", %{schema_version: 1}, c.token).status == + 200 + + status = request(:get, "/api/v1/hosts/#{host}", %{}, c.token) + assert status.status == 200 + assert Jason.decode!(status.resp_body)["revoked"] == true + + assert request( + :post, + "/api/v1/hosts/enrollments", + %{schema_version: 1, host_id: host}, + c.token + ).status == 400 + end + + test "dashboard cannot mint host enrollment or revoke even known host", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {:ok, dashboard, _} = Accounts.issue_dashboard(c.principal) + + assert request( + :post, + "/api/v1/hosts/enrollments", + %{schema_version: 1, host_id: nil}, + dashboard + ).status == 403 + + assert request( + :post, + "/api/v1/hosts/#{issued.host_id}/revoke", + %{schema_version: 1}, + dashboard + ).status == 403 + + assert request(:get, "/api/v1/hosts/#{issued.host_id}", %{}, dashboard).status == 403 + assert {:ok, %{revoked: false}} = Hosts.status(c.principal, issued.host_id) + end + + test "identity startup loads explicit initialized signer and never replaces missing state", c do + cfg = Application.fetch_env!(:workharbor, WorkHarborWeb.Endpoint) + + assert {:ok, config} = + WorkHarborWeb.RuntimeSecurity.identity_config(cfg, issuer_dir: c.issuer_dir) + + assert config[:https][:thousand_island_options][:transport_options][:verify] == :verify_peer + + assert config[:https][:thousand_island_options][:transport_options][:fail_if_no_peer_cert] == + false + + assert length(config[:https][:thousand_island_options][:transport_options][:cacerts]) == 1 + missing = Path.join(c.dir, "missing") + + assert {:error, :invalid_identity} = + WorkHarborWeb.RuntimeSecurity.identity_config(cfg, issuer_dir: missing) + + refute File.exists?(missing) + end + + test "failed owner body validation preserves consumed adapter state", c do + result = + request( + :post, + "/api/v1/hosts/enrollments", + %{schema_version: 1, host_id: nil, extra: "SECRET_SENTINEL"}, + c.token + ) + + assert result.status == 400 + assert elem(result.adapter, 1).req_body == "" + end +end diff --git a/apps/control_plane/test/workharbor_web/host_transport_test.exs b/apps/control_plane/test/workharbor_web/host_transport_test.exs new file mode 100644 index 00000000..02d07003 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/host_transport_test.exs @@ -0,0 +1,662 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.HostTransportTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, Hosts, HostFixture, HostConnections} + alias WorkHarbor.Hosts.Issuer + alias WorkHarborWeb.{Endpoint, RuntimeSecurity} + @moduletag :tls + @moduletag timeout: 120_000 + + setup tags do + dir = Path.join(HostFixture.physical_temp(), "whr-real-tls-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + + previous = + Map.new( + [:host_identity, :clock, :owner_http, :host_transport_fixture_repo], + &{&1, Application.get_env(:workharbor, &1)} + ) + + endpoint = + :ets.tab2list(Endpoint) + |> Enum.filter(&(tuple_size(&1) == 2)) + |> Keyword.delete(:__config__) + + on_exit(fn -> + Enum.each(previous, fn {k, v} -> + if v, + do: Application.put_env(:workharbor, k, v), + else: Application.delete_env(:workharbor, k) + end) + + Endpoint.config_change([{Endpoint, endpoint}], []) + File.rm_rf!(dir) + end) + + agent = Path.expand("../host_agent", File.cwd!()) + executable = Path.join(dir, "transport-client") + + assert {_, 0} = + System.cmd( + "go", + ["build", "-o", executable, "./internal/connection/testdata/transport_client"], + cd: agent, + stderr_to_stdout: true + ) + + if tags[:near_expiry], + do: + Application.put_env(:workharbor, :clock, fn -> + System.system_time(:second) - 86400 + 15 + end) + + repo = WorkHarbor.HostDatabase.open!(Path.join(dir, "state.db")) + on_exit(fn -> if Process.alive?(repo), do: GenServer.stop(repo) end) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, _} = Accounts.login(owner, secret, "cli_owner") + {:ok, principal} = Accounts.authenticate(token) + issuer_dir = Path.join(dir, "issuer") + {:ok, _} = Issuer.initialize(issuer_dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: issuer_dir) + Application.put_env(:workharbor, :host_transport_fixture_repo, repo) + start_supervised!({HostConnections, repo: repo}) + + tls = + :public_key.pkix_test_data(%{ + root: [digest: :sha256, key: {:namedCurve, :secp256r1}], + peer: [ + digest: :sha256, + key: {:namedCurve, :secp256r1}, + extensions: [{:Extension, {2, 5, 29, 17}, false, [dNSName: ~c"localhost"]}] + ] + }) + + certfile = Path.join(dir, "server.pem") + keyfile = Path.join(dir, "server-key.pem") + server_ca = Path.join(dir, "server-ca.pem") + File.write!(certfile, :public_key.pem_encode([{:Certificate, tls[:cert], :not_encrypted}])) + File.write!(keyfile, :public_key.pem_encode([Tuple.insert_at(tls[:key], 2, :not_encrypted)])) + File.chmod!(keyfile, 0o600) + + File.write!( + server_ca, + :public_key.pem_encode(Enum.map(tls[:cacerts], &{:Certificate, &1, :not_encrypted})) + ) + + File.chmod!(server_ca, 0o600) + preflight = Path.join(dir, "server-preflight.json") + File.write!(preflight, Jason.encode!(%{Dir: dir, ServerCA: server_ca})) + File.chmod!(preflight, 0o600) + + assert {"server_trust_preflight_ok\n", 0} = + System.cmd(executable, ["tls_preflight", preflight], stderr_to_stdout: true) + + cfg = + Application.fetch_env!(:workharbor, Endpoint) + |> Keyword.merge( + server: true, + adapter: Bandit.PhoenixAdapter, + http: false, + debug_errors: false, + code_reloader: false, + secret_key_base: String.duplicate("t", 64), + https: [ + ip: {127, 0, 0, 1}, + port: 0, + certfile: certfile, + keyfile: keyfile, + http_1_options: [enabled: true], + http_2_options: [enabled: false] + ] + ) + + {:ok, cfg} = RuntimeSecurity.identity_config(cfg, issuer_dir: issuer_dir) + + [spec] = + Bandit.PhoenixAdapter.child_specs( + WorkHarbor.HostTransportEndpoint, + Keyword.put(cfg, :otp_app, :workharbor) + ) + + assert :ok = WorkHarborWeb.AgentLogFilter.install() + listener = start_supervised!(spec) + {:ok, {{127, 0, 0, 1}, port}} = ThousandIsland.listener_info(listener) + origin = "https://localhost:#{port}" + authority = [origin: origin, authorities: [{"localhost", port}]] + effective = Keyword.update!(cfg, :https, &Keyword.put(&1, :port, port)) + assert :ok = RuntimeSecurity.validate!(effective, authority) + Application.put_env(:workharbor, :owner_http, authority) + Endpoint.config_change([{Endpoint, effective}], []) + + %{ + dir: dir, + repo: repo, + principal: principal, + token: token, + executable: executable, + origin: origin, + server_ca: server_ca, + port: port, + tls: tls + } + end + + defp tls_connect(c, peer \\ nil, protocols \\ ["h2", "http/1.1"]) do + opts = [ + mode: :binary, + active: false, + verify: :verify_peer, + cacerts: c.tls[:cacerts], + server_name_indication: ~c"localhost", + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] + ] + + opts = if protocols, do: opts ++ [alpn_advertised_protocols: protocols], else: opts + + opts = + if peer, do: opts ++ [cert: peer.certificate, key: {:PrivateKeyInfo, peer.key}], else: opts + + :ssl.connect({127, 0, 0, 1}, c.port, opts, 2000) + end + + defp http(sock, c, path, extra \\ "") do + assert :ok = + :ssl.send(sock, "GET #{path} HTTP/1.1\r\nHost: localhost:#{c.port}\r\n#{extra}\r\n") + + response(sock, "", System.monotonic_time(:millisecond) + 2000) + end + + defp response(sock, data, deadline) do + case :binary.split(data, "\r\n\r\n") do + [head, body] -> + length = + case Regex.run(~r/content-length:\s*(\d+)/i, head) do + [_, n] -> String.to_integer(n) + _ -> 0 + end + + if byte_size(body) >= length do + [_, status] = Regex.run(~r/^HTTP\/1\.1 (\d+)/, head) + {:ok, String.to_integer(status), body} + else + receive_response(sock, data, deadline) + end + + _ -> + receive_response(sock, data, deadline) + end + end + + defp receive_response(sock, data, deadline) do + if byte_size(data) > 16384, do: flunk("bounded HTTP response exceeded fixture limit") + + case :ssl.recv(sock, 0, max(1, deadline - System.monotonic_time(:millisecond))) do + {:ok, chunk} -> response(sock, data <> chunk, deadline) + _ -> {:error, :transport_closed} + end + end + + defp drain_log_events(events) do + receive do + {:after_primary_log, event} -> drain_log_events([event | events]) + after + 0 -> Enum.reverse(events) + end + end + + defp input(c, code, dir \\ nil) do + path = Path.join(c.dir, "input-#{Ecto.UUID.generate()}.json") + + File.write!( + path, + Jason.encode!(%{ + Dir: dir || Path.join(c.dir, "identity-#{Ecto.UUID.generate()}"), + Endpoint: c.origin, + ServerCA: c.server_ca, + Code: code, + Pending: false + }) + ) + + File.chmod!(path, 0o600) + path + end + + defp invoke(c, mode, input), do: System.cmd(c.executable, [mode, input], stderr_to_stdout: true) + + defp state(input), + do: + input + |> File.read!() + |> Jason.decode!() + |> Map.fetch!("Dir") + |> Path.join("identity.json") + |> File.read!() + |> Jason.decode!() + + defp enroll(c) do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + input = input(c, issued.code) + result = invoke(c, "enroll", input) + + if result != {"enrolled\n", 0} do + private = input |> File.read!() |> Jason.decode!() + saved = Path.join(private["Dir"], "identity.json") |> File.read!() |> Jason.decode!() + csr = Base.decode64!(saved["pending"]["csr"]) + csr_class = elem(Issuer.csr_public(csr, nil), 0) + diagnostic = invoke(c, "diagnose", input) + + flunk( + "fixed enrollment result #{inspect(result)}; committed identity count #{Hosts.one("SELECT count(*) AS n FROM host_identities", [])["n"]}; CSR class #{csr_class}; TLS/HTTP class #{inspect(diagnostic)}" + ) + end + + {issued, input} + end + + defp spawn_client(c, mode, input) do + port = + Port.open({:spawn_executable, c.executable}, [ + :binary, + :exit_status, + :stderr_to_stdout, + args: [mode, input] + ]) + + {:os_pid, pid} = Port.info(port, :os_pid) + on_exit(fn -> stop_owned(pid, c.executable) end) + port + end + + defp stop_owned(pid, executable) do + case System.cmd("ps", ["-p", Integer.to_string(pid), "-o", "command="], + stderr_to_stdout: true + ) do + {command, 0} -> + if String.starts_with?(String.trim(command), executable <> " ") do + System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true) + + wait(fn -> + elem(System.cmd("kill", ["-0", Integer.to_string(pid)], stderr_to_stdout: true), 1) != + 0 + end) + end + + _ -> + :ok + end + end + + defp await_line(port, line, timeout \\ 5000), + do: await_buffer(port, line, System.monotonic_time(:millisecond) + timeout) + + defp await_buffer(port, line, deadline) do + key = {__MODULE__, port} + buffer = Process.get(key, "") + parts = String.split(buffer, "\n") + complete = Enum.drop(parts, -1) + + case Enum.find_index(complete, &(&1 == line)) do + nil -> + receive do + {^port, {:data, data}} -> + Process.put(key, buffer <> data) + await_buffer(port, line, deadline) + + {^port, {:exit_status, status}} -> + flunk("fixture exited before expected event: #{status}") + after + max(0, deadline - System.monotonic_time(:millisecond)) -> flunk("fixture event timeout") + end + + index -> + Process.put(key, Enum.drop(parts, index + 1) |> Enum.join("\n")) + :ok + end + end + + defp wait(fun, n \\ 100) do + cond do + fun.() -> + :ok + + n == 0 -> + flunk("bounded fixture condition timeout") + + true -> + Process.sleep(50) + wait(fun, n - 1) + end + end + + test "unavailable endpoint is never evidence of server authorization refusal", c do + {_, input} = enroll(c) + private = input |> File.read!() |> Jason.decode!() + file = Path.join(private["Dir"], "identity.json") + state = file |> File.read!() |> Jason.decode!() + File.write!(file, Jason.encode!(Map.put(state, "endpoint", "https://localhost:1"))) + assert {"fixture_failed\n", 1} = invoke(c, "probe", input) + end + + test "genuine Go enrollment heartbeat rotation and revocation use verified native TLS", c do + {issued, input} = enroll(c) + old = state(input) + assert {"connected\n", 0} = invoke(c, "once", input) + + assert Hosts.one("SELECT last_seen FROM hosts WHERE id=?", [issued.host_id])["last_seen"] != + nil + + assert {"renewed\n", 0} = invoke(c, "renew", input) + current = state(input) + refute old["active"]["identity_id"] == current["active"]["identity_id"] + assert current["pending"] == nil + old_dir = Path.join(c.dir, "old-identity") + File.mkdir!(old_dir) + File.chmod!(old_dir, 0o700) + File.write!(Path.join(old_dir, "identity.json"), Jason.encode!(old)) + File.chmod!(Path.join(old_dir, "identity.json"), 0o600) + old_input = input(c, "", old_dir) + assert {"server_refused\n", 0} = invoke(c, "probe", old_input) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + assert {"server_refused\n", 0} = invoke(c, "probe", input) + assert {"fixture_failed\n", 1} = invoke(c, "renew", input) + assert {:ok, %{revoked: true}} = Hosts.status(c.principal, issued.host_id) + end + + test "genuine idle WSS closes on worker death and production client reconnects", c do + {issued, input} = enroll(c) + idle = spawn_client(c, "idle", input) + await_line(idle, "ready") + assert :ok = stop_supervised(HostConnections) + await_line(idle, "closed", 2000) + assert_receive {^idle, {:exit_status, 0}}, 2000 + server = start_supervised!({HostConnections, repo: c.repo}) + running = spawn_client(c, "run", input) + await_line(running, "connected") + entry = :sys.get_state(server).peers |> Map.values() |> hd() + ref = :sys.get_state(server).hosts[issued.host_id] + assert :ok = HostConnections.disconnect({server, ref}) + + wait(fn -> + current = + Hosts.one("SELECT connection_id FROM host_connection_slots WHERE host_id=?", [ + issued.host_id + ])["connection_id"] + + not is_nil(current) and current != entry.id and + HostConnections.host_state(issued.host_id) == {:ok, "connected"} + end) + + assert {:ok, "connected"} = HostConnections.host_state(issued.host_id) + {:os_pid, pid} = Port.info(running, :os_pid) + stop_owned(pid, c.executable) + assert_receive {^running, {:exit_status, 0}}, 2000 + end + + test "genuine transport ping cannot renew application heartbeat lease", c do + {issued, input} = enroll(c) + idle = spawn_client(c, "idle", input) + await_line(idle, "ready") + assert {:ok, "connected"} = HostConnections.host_state(issued.host_id) + await_line(idle, "closed", 32000) + assert_receive {^idle, {:exit_status, 0}}, 2000 + assert {:ok, "unknown"} = HostConnections.host_state(issued.host_id) + + assert Hosts.one("SELECT state FROM host_connection_slots WHERE host_id=?", [issued.host_id])[ + "state" + ] == "unknown" + end + + @tag :near_expiry + test "genuine activation crash recovers pending after retired predecessor expiry", c do + {issued, input} = enroll(c) + old = state(input) + + old_expiry = + Issuer.inspect_certificate(Base.decode64!(old["active"]["certificate"])).not_after + + Application.delete_env(:workharbor, :clock) + assert {"lost_ack\n", 73} = invoke(c, "stage_lost", input) + crashed = state(input) + assert crashed["active"] == old["active"] + assert crashed["pending"]["identity_id"] != nil + + assert Hosts.one("SELECT status FROM host_identities WHERE id=?", [ + crashed["pending"]["identity_id"] + ])["status"] == "active" + + wait(fn -> System.system_time(:second) >= old_expiry end, 400) + assert {"renewed\n", 0} = invoke(c, "renew", input) + recovered = state(input) + assert recovered["active"]["identity_id"] == crashed["pending"]["identity_id"] + assert recovered["pending"] == nil + assert {"connected\n", 0} = invoke(c, "once", input) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + end + + test "genuine optional client TLS preserves owner HTTP1 and rejects H2 and owner as host", c do + {:ok, sock} = tls_connect(c) + + try do + assert {:ok, "http/1.1"} = :ssl.negotiated_protocol(sock) + + assert {:ok, 200, _} = + http(sock, c, "/api/v1/auth/status", "Authorization: Bearer #{c.token}\r\n") + + assert {:ok, 401, _} = http(sock, c, "/agent/v1/connect") + + assert {:ok, 403, _} = + http(sock, c, "/agent/v1/connect", "Authorization: Bearer #{c.token}\r\n") + after + :ssl.close(sock) + end + + for protocols <- [nil, ["http/1.1"], ["h2"]] do + case tls_connect(c, nil, protocols) do + {:ok, h2} -> + try do + refute :ssl.negotiated_protocol(h2) == {:ok, "h2"} + assert :ok = :ssl.send(h2, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") + assert {:error, _} = :ssl.recv(h2, 0, 2000) + after + :ssl.close(h2) + end + + {:error, _} -> + assert protocols == ["h2"] + end + end + end + + @tag :peer_negative + test "genuine TLS rejects foreign issuer and unregistered signed identity", c do + {csr, key} = HostFixture.csr_with_key() + {:ok, issuer} = Hosts.issuer() + {:ok, certificate} = Issuer.issue(issuer, csr, Ecto.UUID.generate(), :enrollment) + path = input(c, "") + + values = + path + |> File.read!() + |> Jason.decode!() + |> Map.merge(%{ + "Certificate" => Base.encode64(certificate), + "PrivateKey" => Base.encode64(:public_key.der_encode(:PrivateKeyInfo, key)), + "IssuerCertificate" => Base.encode64(issuer.certificate) + }) + + File.write!(path, Jason.encode!(values)) + assert {"server_refused\n", 0} = invoke(c, "probe_cert", path) + foreign = Path.join(c.dir, "foreign-issuer") + {:ok, other} = Issuer.initialize(foreign) + {:ok, certificate} = Issuer.issue(other, csr, Ecto.UUID.generate(), :enrollment) + + values = + Map.merge(values, %{ + "Certificate" => Base.encode64(certificate), + "IssuerCertificate" => Base.encode64(other.certificate) + }) + + File.write!(path, Jason.encode!(values)) + assert {"server_tls_unknown_ca\n", 0} = invoke(c, "probe_cert", path) + end + + test "genuine pending identity has only activation authority and rotation revocation close live WSS", + c do + {issued, input} = enroll(c) + idle = spawn_client(c, "idle", input) + await_line(idle, "ready") + assert {"staged\n", 0} = invoke(c, "stage_only", input) + pending_input = input(c, "") + source = input |> File.read!() |> Jason.decode!() |> Map.put("Pending", true) + File.write!(pending_input, Jason.encode!(source)) + assert {"server_refused\n", 0} = invoke(c, "probe", pending_input) + assert {"renewed\n", 0} = invoke(c, "renew", input) + await_line(idle, "closed", 2000) + assert_receive {^idle, {:exit_status, 0}}, 2000 + next = spawn_client(c, "idle", input) + await_line(next, "ready") + assert :ok = Hosts.revoke(c.principal, issued.host_id) + await_line(next, "closed", 2000) + assert_receive {^next, {:exit_status, 0}}, 2000 + assert {"server_refused\n", 0} = invoke(c, "probe", input) + end + + test "genuine WSS enforces exact wire scope version JSON types and message budgets", c do + capture = :workharbor_test_agent_capture + + assert :ok = + :logger.add_handler(capture, WorkHarbor.HostLogCapture, %{ + level: :all, + config: %{test: self()} + }) + + on_exit(fn -> :logger.remove_handler(capture) end) + {_, input} = enroll(c) + assert {"boundary_ack\n", 0} = invoke(c, "boundary", input) + + for {mode, code} <- [ + {"fragment", 1009}, + {"oversize", 1008}, + {"wrong_scope", 1008}, + {"wrong_version", 1008}, + {"duplicate", 1008}, + {"binary", 1008}, + {"invalid_utf8", 1007} + ] do + assert invoke(c, mode, input) == {"wire_refused_#{code}\n", 0} + end + + Process.sleep(50) + events = drain_log_events([]) + redacted = Enum.filter(events, &(&1[:msg] == {:string, "WorkHarbor agent transport refused"})) + assert length(redacted) >= 2 + + for event <- redacted do + assert Enum.sort(Map.keys(event)) == [:level, :meta, :msg] + assert Enum.all?(Map.keys(event.meta), &(&1 in [:pid, :time, :domain])) + end + + refute Enum.any?(events, fn + %{msg: {:report, %{label: label}}} + when label in [{:gen_server, :terminate}, {:proc_lib, :crash}] -> + true + + _ -> + false + end) + end + + @tag :near_expiry + @tag :peer_negative + test "genuine idle client socket is forced closed at certificate expiry", c do + {_, input} = enroll(c) + Application.delete_env(:workharbor, :clock) + idle = spawn_client(c, "idle", input) + await_line(idle, "ready") + await_line(idle, "closed", 17000) + assert_receive {^idle, {:exit_status, 0}}, 2000 + saved = state(input) + + assert Issuer.inspect_certificate(Base.decode64!(saved["active"]["certificate"])).not_after <= + System.system_time(:second) + + material = saved["active"] + path = input(c, "") + + values = + path + |> File.read!() + |> Jason.decode!() + |> Map.merge(%{ + "Certificate" => material["certificate"], + "PrivateKey" => material["private_key"], + "IssuerCertificate" => material["issuer_certificate"] + }) + + File.write!(path, Jason.encode!(values)) + assert invoke(c, "probe_cert", path) in [{"server_tls_expired\n", 0}, {"server_refused\n", 0}] + end + + @tag :near_expiry + test "genuine first activation after predecessor expiry fails and owner code recovers same host", + c do + {issued, input} = enroll(c) + old = state(input) + expiry = Issuer.inspect_certificate(Base.decode64!(old["active"]["certificate"])).not_after + Application.delete_env(:workharbor, :clock) + assert {"staged\n", 0} = invoke(c, "stage_only", input) + wait(fn -> System.system_time(:second) >= expiry end, 400) + assert {"fixture_failed\n", 1} = invoke(c, "renew", input) + pending = state(input) + + assert Hosts.one("SELECT status FROM host_identities WHERE id=?", [ + pending["pending"]["identity_id"] + ])["status"] == "pending" + + {:ok, reissued} = Hosts.issue_enrollment(c.principal, issued.host_id) + recovered_input = input(c, reissued.code) + assert {"enrolled\n", 0} = invoke(c, "enroll", recovered_input) + recovered = state(recovered_input) + assert recovered["host_id"] == issued.host_id + assert {"connected\n", 0} = invoke(c, "once", recovered_input) + end + + test "genuine production Go enrollment verifies server root and name before code transmission", + c do + {:ok, issuer} = Hosts.issuer() + wrong_ca = Path.join(c.dir, "wrong-server-ca.pem") + + File.write!( + wrong_ca, + :public_key.pem_encode([{:Certificate, issuer.certificate, :not_encrypted}]) + ) + + File.chmod!(wrong_ca, 0o600) + + for {kind, expected} <- [{"root", "tls_unknown_authority\n"}, {"name", "tls_wrong_name\n"}] do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + input = input(c, issued.code) + values = input |> File.read!() |> Jason.decode!() + + values = + if kind == "root", + do: Map.put(values, "ServerCA", wrong_ca), + else: Map.put(values, "Endpoint", "https://127.0.0.1:#{c.port}") + + File.write!(input, Jason.encode!(values)) + assert {"fixture_failed\n", 1} = invoke(c, "enroll", input) + assert invoke(c, "diagnose", input) == {expected, 0} + + assert Hosts.one("SELECT consumed_at FROM host_enrollments WHERE host_id=?", [ + issued.host_id + ])["consumed_at"] == nil + + assert Hosts.one("SELECT count(*) AS n FROM host_identities WHERE host_id=?", [ + issued.host_id + ])["n"] == 0 + end + end +end diff --git a/apps/control_plane/test/workharbor_web/transport_security_test.exs b/apps/control_plane/test/workharbor_web/transport_security_test.exs index da8fb3b7..2716a70b 100644 --- a/apps/control_plane/test/workharbor_web/transport_security_test.exs +++ b/apps/control_plane/test/workharbor_web/transport_security_test.exs @@ -97,6 +97,58 @@ defmodule WorkHarborWeb.TransportSecurityTest do end end + test "optional client peer verification permits only validated native issuer trio", c do + dir = + Path.join( + WorkHarbor.HostFixture.physical_temp(), + "whr-transport-issuer-#{Ecto.UUID.generate()}" + ) + + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, issuer} = WorkHarbor.Hosts.Issuer.initialize(dir) + cfg = independent(c) + trio = [cacerts: [issuer.certificate], verify: :verify_peer, fail_if_no_peer_cert: false] + + valid = + Keyword.update!(cfg, :https, &(&1 ++ [thousand_island_options: [transport_options: trio]])) + + owner = [origin: "https://localhost", authorities: [{"localhost", 443}]] + assert :ok = RuntimeSecurity.validate!(valid, owner) + + for options <- [ + Keyword.delete(trio, :cacerts), + Keyword.put(trio, :verify, :verify_none), + Keyword.put(trio, :cacerts, c.cacerts), + trio ++ [verify: :verify_peer], + trio ++ [alpn_preferred_protocols: ["h2"]], + trio ++ [thousand_island_options: [handler_module: __MODULE__]] + ] do + refused( + Keyword.update!( + cfg, + :https, + &(&1 ++ [thousand_island_options: [transport_options: options]]) + ) + ) + end + + refused(Keyword.update!(cfg, :https, &(&1 ++ trio))) + + refused( + Keyword.update!( + cfg, + :https, + &(&1 ++ + [ + thousand_island_options: [ + transport_options: trio, + handler_module: Bandit.HTTP2.Handler + ] + ]) + ) + ) + end + test "active transport profile requires exact explicit protocol settings and adapter", c do cfg = independent(c) diff --git a/apps/host_agent/cmd/workharbor-agent/main.go b/apps/host_agent/cmd/workharbor-agent/main.go index 09c78f25..472f64ae 100644 --- a/apps/host_agent/cmd/workharbor-agent/main.go +++ b/apps/host_agent/cmd/workharbor-agent/main.go @@ -3,16 +3,29 @@ package main import ( + "context" "fmt" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection" "io" "os" + "os/signal" + "syscall" ) func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, out, errOut io.Writer) int { + if len(args) == 3 && args[0] == "run" && args[1] == "--identity-dir" { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if connection.Run(ctx, args[2], nil) != nil { + fmt.Fprintln(errOut, "workharbor-agent: host_transport_unavailable") + return 1 + } + return 0 + } if len(args) == 0 || (len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help")) { - fmt.Fprintln(out, "workharbor-agent scaffold: product commands are not implemented.\nUsage: workharbor-agent [--help | --version]") + fmt.Fprintln(out, "WorkHarbor host agent.\nUsage: workharbor-agent [--help | --version | run --identity-dir PATH]") return 0 } if len(args) == 1 && args[0] == "--version" { diff --git a/apps/host_agent/cmd/workharbor-agent/main_test.go b/apps/host_agent/cmd/workharbor-agent/main_test.go index 38edad44..68b01e55 100644 --- a/apps/host_agent/cmd/workharbor-agent/main_test.go +++ b/apps/host_agent/cmd/workharbor-agent/main_test.go @@ -19,3 +19,10 @@ func TestScaffoldRefusesProductCommands(t *testing.T) { } } } + +func TestRunRefusesMissingIdentityWithFixedDiagnostic(t *testing.T) { + var out, errOut bytes.Buffer + if code := run([]string{"run", "--identity-dir", "/missing/identity"}, &out, &errOut); code != 1 || out.Len() != 0 || errOut.String() != "workharbor-agent: host_transport_unavailable\n" { + t.Fatalf("unexpected run refusal: %d %q %q", code, out.String(), errOut.String()) + } +} diff --git a/apps/host_agent/go.mod b/apps/host_agent/go.mod index 40a14e5a..5f3ac729 100644 --- a/apps/host_agent/go.mod +++ b/apps/host_agent/go.mod @@ -1,3 +1,5 @@ module github.com/wstein/workharbor-nxt/apps/host_agent go 1.27.2 + +require github.com/coder/websocket v1.8.15 diff --git a/apps/host_agent/go.sum b/apps/host_agent/go.sum new file mode 100644 index 00000000..4e0a48d6 --- /dev/null +++ b/apps/host_agent/go.sum @@ -0,0 +1,2 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/apps/host_agent/internal/connection/client.go b/apps/host_agent/internal/connection/client.go new file mode 100644 index 00000000..107fb6a0 --- /dev/null +++ b/apps/host_agent/internal/connection/client.go @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: EUPL-1.2 +package connection + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "io" + "math/big" + "mime" + "net/http" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity" +) + +var ErrTransport = errors.New("host_transport_unavailable") + +func client(state *identity.State, pending bool) (*http.Client, error) { + cfg, e := identity.TLSConfig(state, pending) + if e != nil { + return nil, ErrTransport + } + transport := &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: false, TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, DisableCompression: true, ResponseHeaderTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second, MaxResponseHeaderBytes: 8192} + return &http.Client{Transport: transport, Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrTransport }}, nil +} +func exact(m map[string]any, keys ...string) bool { + if len(m) != len(keys) { + return false + } + for _, k := range keys { + if _, ok := m[k]; !ok { + return false + } + } + return true +} +func version(m map[string]any) bool { + v, ok := m["schema_version"].(*big.Int) + return ok && v.Cmp(big.NewInt(1)) == 0 +} +func decodeIdentityResponse(raw []byte) (identity.Response, error) { + var r identity.Response + if len(raw) > 16384 { + return r, ErrTransport + } + v, e := wirejson.Parse(raw) + if e != nil { + return r, ErrTransport + } + m, ok := v.(map[string]any) + if !ok || !exact(m, "schema_version", "owner_id", "host_id", "identity_id", "certificate", "issuer_certificate", "expires_at") || !version(m) { + return r, ErrTransport + } + for _, k := range []string{"owner_id", "host_id", "identity_id", "certificate", "issuer_certificate"} { + if _, ok := m[k].(string); !ok { + return r, ErrTransport + } + } + expiry, ok := m["expires_at"].(*big.Int) + if !ok || !expiry.IsInt64() || expiry.Sign() <= 0 { + return r, ErrTransport + } + if e = json.Unmarshal(raw, &r); e != nil { + return r, ErrTransport + } + return r, nil +} +func post(ctx context.Context, c *http.Client, endpoint, path string, body any) ([]byte, error) { + raw, e := json.Marshal(body) + if e != nil || len(raw) > 16384 { + return nil, ErrTransport + } + request, e := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+path, bytes.NewReader(raw)) + if e != nil { + return nil, ErrTransport + } + request.Header.Set("Content-Type", "application/json") + response, e := c.Do(request) + if e != nil { + return nil, ErrTransport + } + defer response.Body.Close() + media, _, e := mime.ParseMediaType(response.Header.Get("Content-Type")) + if e != nil || media != "application/json" || response.StatusCode != 200 || response.ProtoMajor != 1 { + return nil, ErrTransport + } + raw, e = io.ReadAll(io.LimitReader(response.Body, 16385)) + if e != nil || len(raw) > 16384 { + return nil, ErrTransport + } + return raw, nil +} +func Enroll(ctx context.Context, dir, code string) error { + if !strings.HasPrefix(code, "whe1_") || len(code) != 48 { + return ErrTransport + } + b, e := base64.RawURLEncoding.DecodeString(code[5:]) + if e != nil || len(b) != 32 || base64.RawURLEncoding.EncodeToString(b) != code[5:] { + return ErrTransport + } + state, e := identity.Load(dir) + if e != nil || state.Active != nil || state.Pending == nil { + return ErrTransport + } + c, e := client(state, false) + if e != nil { + return e + } + defer c.CloseIdleConnections() + raw, e := post(ctx, c, state.Endpoint, "/agent/v1/enroll", map[string]any{"schema_version": 1, "code": code, "csr": state.Pending.CSR}) + if e != nil { + return e + } + r, e := decodeIdentityResponse(raw) + if e != nil { + return e + } + if e = identity.AcceptEnrollment(dir, state, r); e != nil { + return ErrTransport + } + return nil +} +func Renew(ctx context.Context, dir string) error { + state, e := identity.Load(dir) + if e != nil || state.Active == nil { + return ErrTransport + } + // A saved issued pending credential may already be current on the server. + // Attempt its activation before checking the historical predecessor's expiry. + if state.Pending == nil || state.Pending.Certificate == "" { + if e = identity.PrepareRenewal(dir, state); e != nil { + return ErrTransport + } + c, e := client(state, false) + if e != nil { + return e + } + defer c.CloseIdleConnections() + raw, e := post(ctx, c, state.Endpoint, "/agent/v1/identity/renew", map[string]any{"schema_version": 1, "csr": state.Pending.CSR}) + if e != nil { + return e + } + r, e := decodeIdentityResponse(raw) + if e != nil { + return e + } + if e = identity.AcceptPending(dir, state, r); e != nil { + return ErrTransport + } + } + c, e := client(state, true) + if e != nil { + return e + } + defer c.CloseIdleConnections() + raw, e := post(ctx, c, state.Endpoint, "/agent/v1/identity/activate", map[string]any{"schema_version": 1}) + if e != nil { + return e + } + if !activationMatches(raw, state) { + return ErrTransport + } + if e = identity.Promote(dir, state); e != nil { + return ErrTransport + } + return nil +} + +func activationMatches(raw []byte, state *identity.State) bool { + r, e := decodeIdentityResponse(raw) + if e != nil || state == nil || state.Pending == nil { + return false + } + p := state.Pending + if r.OwnerID != state.OwnerID || r.HostID != state.HostID || r.IdentityID != p.IdentityID || r.Certificate != p.Certificate || r.IssuerCertificate != p.IssuerCertificate { + return false + } + der, e := base64.StdEncoding.DecodeString(p.Certificate) + if e != nil { + return false + } + cert, e := x509.ParseCertificate(der) + return e == nil && cert.NotAfter.Unix() == r.ExpiresAt +} + +// Event carries only fixed status and public host identity, never peer diagnostics. +type Event struct{ Kind, HostID string } + +func emit(f func(Event), kind, host string) { + if f != nil { + f(Event{kind, host}) + } +} +func send(ctx context.Context, c *websocket.Conn, state *identity.State, kind string) error { + if _, e := identity.Expires(state); e != nil { + return ErrTransport + } + m := map[string]any{"schema_version": 1, "kind": kind, "owner_id": state.OwnerID, "host_id": state.HostID} + if kind == "hello" { + m["capabilities"] = []string{} + } + raw, _ := json.Marshal(m) + if e := c.Write(ctx, websocket.MessageText, raw); e != nil { + return ErrTransport + } + return nil +} + +type incomingMessage struct { + kind string + err error +} + +func readMessages(ctx context.Context, c *websocket.Conn, state *identity.State, out chan<- incomingMessage) { + defer close(out) + for { + typ, raw, e := c.Read(ctx) + message := incomingMessage{err: ErrTransport} + if e == nil && typ == websocket.MessageText { + if _, validErr := identity.Expires(state); validErr == nil { + if m, decodeErr := DecodeMessage(raw, state.OwnerID, state.HostID); decodeErr == nil { + message = incomingMessage{kind: m["kind"].(string)} + } + } + } + select { + case out <- message: + case <-ctx.Done(): + return + } + if message.err != nil { + return + } + } +} +func ack(ctx context.Context, in <-chan incomingMessage, kind string) error { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + select { + case <-ctx.Done(): + return ErrTransport + case <-timer.C: + return ErrTransport + case m, ok := <-in: + if !ok || m.err != nil || m.kind != kind { + return ErrTransport + } + return nil + } +} +func session(ctx context.Context, state *identity.State, onEvent func(Event)) error { + expires, e := identity.Expires(state) + if e != nil { + return ErrTransport + } + // End an idle socket at actual expiry, or at renewal time, independently of IO. + deadline := expires.Add(-6 * time.Hour) + if !deadline.After(time.Now()) { + deadline = expires + } + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + c, e := client(state, false) + if e != nil { + return e + } + defer c.CloseIdleConnections() + ws, response, e := websocket.Dial(ctx, "wss"+strings.TrimPrefix(state.Endpoint, "https")+"/agent/v1/connect", &websocket.DialOptions{HTTPClient: c, CompressionMode: websocket.CompressionDisabled}) + if response != nil && response.Body != nil { + defer response.Body.Close() + } + if e != nil { + return ErrTransport + } + defer ws.CloseNow() + ws.SetReadLimit(4096) + incoming := make(chan incomingMessage, 1) + go readMessages(ctx, ws, state, incoming) + go watchValidity(ctx, func() bool { _, err := identity.Expires(state); return err == nil }, func() { cancel(); _ = ws.CloseNow() }) + if e = send(ctx, ws, state, "hello"); e != nil { + return e + } + if e = ack(ctx, incoming, "hello_ack"); e != nil { + return e + } + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + if e = send(ctx, ws, state, "heartbeat"); e != nil { + return e + } + if e = ack(ctx, incoming, "heartbeat_ack"); e != nil { + return e + } + emit(onEvent, "connected", state.HostID) + wait: + for { + select { + case <-ctx.Done(): + return ErrTransport + case <-incoming: + // A close or unsolicited application message ends this transport. + return ErrTransport + case <-ticker.C: + break wait + } + } + } +} + +// Validity monitoring runs independently of handshake acknowledgements and reads. +func watchValidity(ctx context.Context, valid func() bool, expire func()) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !valid() { + expire() + return + } + } + } +} + +// Run reconnects transport only. Lifecycle command admission and replay belong to T010. +func Run(ctx context.Context, dir string, onEvent func(Event)) error { + attempt := 0 + for { + if ctx.Err() != nil { + return nil + } + state, e := identity.Load(dir) + if e != nil || state.Active == nil { + return ErrTransport + } + expires, e := identity.Expires(state) + if (state.Pending != nil && state.Pending.Certificate != "") || (e == nil && time.Until(expires) <= 6*time.Hour) { + e = Renew(ctx, dir) + if e == nil { + state, e = identity.Load(dir) + } + } + if e == nil { + e = session(ctx, state, func(event Event) { attempt = 0; emit(onEvent, event.Kind, event.HostID) }) + } + if ctx.Err() != nil { + return nil + } + emit(onEvent, "disconnected", state.HostID) + delay := time.NewTimer(time.Duration(Backoff(attempt)) * time.Second) + attempt++ + select { + case <-ctx.Done(): + if !delay.Stop() { + <-delay.C + } + return nil + case <-delay.C: + } + } +} diff --git a/apps/host_agent/internal/connection/client_test.go b/apps/host_agent/internal/connection/client_test.go new file mode 100644 index 00000000..afe6cebe --- /dev/null +++ b/apps/host_agent/internal/connection/client_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: EUPL-1.2 +package connection + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity" + "math/big" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestIdentityResponseRequiresClosedExactFieldsAndIntegers(t *testing.T) { + valid := `{"schema_version":1,"owner_id":"` + owner + `","host_id":"` + host + `","identity_id":"33333333-3333-4333-8333-333333333333","certificate":"AA==","issuer_certificate":"AA==","expires_at":123}` + if _, e := decodeIdentityResponse([]byte(valid)); e != nil { + t.Fatal(e) + } + for _, raw := range []string{strings.Replace(valid, `"schema_version":1`, `"SCHEMA_VERSION":1`, 1), strings.Replace(valid, `"expires_at":123`, `"expires_at":123.0`, 1), strings.Replace(valid, `"certificate":"AA==",`, ``, 1), strings.Replace(valid, `"host_id"`, `"host_id":null,"host_id"`, 1), strings.TrimSuffix(valid, "}") + `,"secret":"SECRET_SENTINEL"}`, valid + strings.Repeat(" ", 16384)} { + if _, e := decodeIdentityResponse([]byte(raw)); e == nil || strings.Contains(e.Error(), "SECRET_SENTINEL") { + t.Fatal("unsafe response boundary") + } + } +} +func TestEnrollmentCodeAndUnsafeStateFailBeforeTransport(t *testing.T) { + for _, code := range []string{"", "SECRET_SENTINEL", "whe1_" + strings.Repeat("A", 42), "whe1_" + strings.Repeat("!", 43)} { + if e := Enroll(context.Background(), "/missing/identity", code); e == nil || strings.Contains(e.Error(), "SECRET_SENTINEL") { + t.Fatal("unsafe enrollment boundary") + } + } + if e := Renew(context.Background(), "/missing/identity"); e == nil { + t.Fatal("missing possession admitted") + } +} + +func TestActivationRejectsIncompleteOrMismatchedIssuedIdentity(t *testing.T) { + state, response := savedPending(t) + raw, _ := json.Marshal(response) + if !activationMatches(raw, state) { + t.Fatal("valid saved pending response refused") + } + var fields map[string]any + if e := json.Unmarshal(raw, &fields); e != nil { + t.Fatal(e) + } + for key := range fields { + t.Run("missing_"+key, func(t *testing.T) { + bad := map[string]any{} + for k, v := range fields { + if k != key { + bad[k] = v + } + } + bytes, _ := json.Marshal(bad) + if activationMatches(bytes, state) { + t.Fatal("missing field admitted") + } + }) + t.Run("changed_"+key, func(t *testing.T) { + bad := map[string]any{} + for k, v := range fields { + bad[k] = v + } + switch key { + case "schema_version": + bad[key] = 2 + case "expires_at": + bad[key] = fields[key].(float64) + 1 + default: + bad[key] = "SECRET_SENTINEL" + } + bytes, _ := json.Marshal(bad) + if activationMatches(bytes, state) { + t.Fatal("changed field admitted") + } + }) + } + if activationMatches([]byte(`{"schema_version":1,"owner_id":"`+owner+`","host_id":"`+host+`","identity_id":"33333333-3333-4333-8333-333333333333"}`), state) { + t.Fatal("short ack admitted") + } +} + +func savedPending(t *testing.T) (*identity.State, identity.Response) { + t.Helper() + temp, e := filepath.EvalSymlinks(t.TempDir()) + if e != nil { + t.Fatal(e) + } + dir := filepath.Join(temp, "identity") + state, e := identity.Prepare(dir, "https://localhost:8443", "") + if e != nil { + t.Fatal(e) + } + pub, key, e := ed25519.GenerateKey(rand.Reader) + if e != nil { + t.Fatal(e) + } + now := time.Now().Truncate(time.Second).Add(-time.Second) + root := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "WorkHarbor client issuer"}, NotBefore: now, NotAfter: now.Add(5 * 365 * 24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + ca, e := x509.CreateCertificate(rand.Reader, root, root, pub, key) + if e != nil { + t.Fatal(e) + } + issuer, e := x509.ParseCertificate(ca) + if e != nil { + t.Fatal(e) + } + sign := func(csr64, id string) identity.Response { + csrDER, e := base64.StdEncoding.DecodeString(csr64) + if e != nil { + t.Fatal(e) + } + csr, e := x509.ParseCertificateRequest(csrDER) + if e != nil { + t.Fatal(e) + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: host}, NotBefore: now, NotAfter: now.Add(24 * time.Hour), BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}} + der, e := x509.CreateCertificate(rand.Reader, leaf, issuer, csr.PublicKey, key) + if e != nil { + t.Fatal(e) + } + return identity.Response{SchemaVersion: 1, OwnerID: owner, HostID: host, IdentityID: id, Certificate: base64.StdEncoding.EncodeToString(der), IssuerCertificate: base64.StdEncoding.EncodeToString(ca), ExpiresAt: leaf.NotAfter.Unix()} + } + if e = identity.AcceptEnrollment(dir, state, sign(state.Pending.CSR, "33333333-3333-4333-8333-333333333333")); e != nil { + t.Fatal(e) + } + if e = identity.PrepareRenewal(dir, state); e != nil { + t.Fatal(e) + } + response := sign(state.Pending.CSR, "44444444-4444-4444-8444-444444444444") + if e = identity.AcceptPending(dir, state, response); e != nil { + t.Fatal(e) + } + state, e = identity.Load(dir) + if e != nil { + t.Fatal(e) + } + return state, response +} + +func TestExpiryWatchdogCancelsBlockedIO(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var valid atomic.Bool + valid.Store(true) + expired := make(chan struct{}) + go watchValidity(ctx, func() bool { return valid.Load() }, func() { cancel(); close(expired) }) + // No heartbeat/wait loop participates: the simulated IO remains blocked. + valid.Store(false) + select { + case <-expired: + case <-time.After(1500 * time.Millisecond): + t.Fatal("expiry watchdog waited for IO") + } + if ctx.Err() == nil { + t.Fatal("blocked IO context not cancelled") + } +} + +func TestAcknowledgementSequenceFailsClosed(t *testing.T) { + for _, name := range []string{"valid", "wrongkind", "closed", "error", "cancelled"} { + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + in := make(chan incomingMessage, 1) + switch name { + case "valid": + in <- incomingMessage{kind: "hello_ack"} + case "wrongkind": + in <- incomingMessage{kind: "heartbeat_ack"} + case "closed": + close(in) + case "error": + in <- incomingMessage{err: ErrTransport} + case "cancelled": + cancel() + } + e := ack(ctx, in, "hello_ack") + if (e == nil) != (name == "valid") { + t.Fatal("ack sequence boundary changed") + } + }) + } +} diff --git a/apps/host_agent/internal/connection/protocol.go b/apps/host_agent/internal/connection/protocol.go new file mode 100644 index 00000000..a93f5065 --- /dev/null +++ b/apps/host_agent/internal/connection/protocol.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: EUPL-1.2 +package connection + +import ( + "errors" + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "math/big" + "regexp" +) + +var ErrProtocol = errors.New("invalid_connection_message") +var uuid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +func DecodeMessage(raw []byte, owner, host string) (map[string]any, error) { + if len(raw) > 4096 || !uuid.MatchString(owner) || !uuid.MatchString(host) { + return nil, ErrProtocol + } + v, e := wirejson.Parse(raw) + if e != nil { + return nil, ErrProtocol + } + m, ok := v.(map[string]any) + if !ok { + return nil, ErrProtocol + } + version, ok := m["schema_version"].(*big.Int) + if !ok || version.Cmp(big.NewInt(1)) != 0 || m["owner_id"] != owner || m["host_id"] != host { + return nil, ErrProtocol + } + count := 4 + switch m["kind"] { + case "hello": + count = 5 + a, ok := m["capabilities"].([]any) + if !ok || len(a) > 1 || (len(a) == 1 && a[0] != "docker") { + return nil, ErrProtocol + } + case "hello_ack", "heartbeat", "heartbeat_ack": + default: + return nil, ErrProtocol + } + if len(m) != count { + return nil, ErrProtocol + } + b, e := wirejson.CanonicalBytes(m) + if e != nil || len(b) > 4096 { + return nil, ErrProtocol + } + return m, nil +} +func Backoff(attempt int) int { + if attempt < 0 { + attempt = 0 + } + if attempt >= 5 { + return 30 + } + return 1 << attempt +} diff --git a/apps/host_agent/internal/connection/protocol_test.go b/apps/host_agent/internal/connection/protocol_test.go new file mode 100644 index 00000000..154bec24 --- /dev/null +++ b/apps/host_agent/internal/connection/protocol_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: EUPL-1.2 +package connection + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +const owner = "11111111-1111-4111-8111-111111111111" +const host = "22222222-2222-4222-8222-222222222222" + +func TestClosedAuthenticatedConnectionMessages(t *testing.T) { + for _, kind := range []string{"hello", "hello_ack", "heartbeat", "heartbeat_ack"} { + raw := `{"schema_version":1,"kind":"` + kind + `","owner_id":"` + owner + `","host_id":"` + host + `"` + if kind == "hello" { + raw += `,"capabilities":[]` + } + raw += `}` + if _, err := DecodeMessage([]byte(raw), owner, host); err != nil { + t.Fatal(err) + } + if _, err := DecodeMessage([]byte(raw), host, owner); err == nil { + t.Fatal("accepted scope") + } + } +} +func TestConnectionDecoderRejectsClosedShapeVersionTypesAndBytes(t *testing.T) { + valid := `{"schema_version":1,"kind":"heartbeat","owner_id":"` + owner + `","host_id":"` + host + `"}` + cases := []string{strings.Replace(valid, `"schema_version":1`, `"schema_version":2`, 1), strings.Replace(valid, `"schema_version":1`, `"schema_version":1.0`, 1), strings.Replace(valid, `"kind":"heartbeat"`, `"kind":"heartbeat","kind":"heartbeat"`, 1), strings.TrimSuffix(valid, "}") + `,"token":"SECRET_SENTINEL"}`, valid + strings.Repeat(" ", 4096), strings.Replace(valid, "heartbeat", "command", 1)} + for _, raw := range cases { + _, err := DecodeMessage([]byte(raw), owner, host) + if err == nil { + t.Fatal("invalid accepted") + } + if strings.Contains(err.Error(), "SECRET_SENTINEL") { + t.Fatal("leaked diagnostic") + } + } +} +func TestReconnectBackoffIsBoundedAndIndependentOfLifecycle(t *testing.T) { + for i, n := range []int{1, 2, 4, 8, 16, 30, 30, 30} { + if got := Backoff(i); got != n { + t.Fatalf("attempt%d:%d", i, got) + } + } +} + +func TestSharedHostConnectionFixtures(t *testing.T) { + raw, e := os.ReadFile("../../../../specs/006-whr-cli/contracts/host-connection.fixtures.json") + if e != nil { + t.Fatal(e) + } + var fixture struct { + OwnerID string `json:"owner_id"` + HostID string `json:"host_id"` + Cases []struct { + Name string `json:"name"` + Accepted bool `json:"accepted"` + Raw string `json:"raw"` + } `json:"cases"` + } + if e = json.Unmarshal(raw, &fixture); e != nil { + t.Fatal(e) + } + for _, c := range fixture.Cases { + t.Run(c.Name, func(t *testing.T) { + _, e := DecodeMessage([]byte(c.Raw), fixture.OwnerID, fixture.HostID) + if (e == nil) != c.Accepted { + t.Fatal("shared fixture mismatch") + } + }) + } +} diff --git a/apps/host_agent/internal/connection/testdata/transport_client/main.go b/apps/host_agent/internal/connection/testdata/transport_client/main.go new file mode 100644 index 00000000..31b64ee2 --- /dev/null +++ b/apps/host_agent/internal/connection/testdata/transport_client/main.go @@ -0,0 +1,484 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Independent bounded test driver. Inputs are private fixture files, never argv secrets. +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/coder/websocket" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity" +) + +type input struct { + Dir, Endpoint, ServerCA, Code string + Certificate, PrivateKey, IssuerCertificate string + Pending bool +} + +func main() { + if e := run(); e != nil { + fmt.Fprintln(os.Stderr, "fixture_failed") + os.Exit(1) + } +} +func run() error { + if len(os.Args) != 3 { + return connection.ErrTransport + } + raw, e := os.ReadFile(os.Args[2]) + if e != nil { + return e + } + var in input + if e = json.Unmarshal(raw, &in); e != nil { + return e + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + switch os.Args[1] { + case "boundary", "fragment", "oversize", "wrong_scope", "wrong_version", "duplicate", "binary", "invalid_utf8": + return wireProbe(ctx, in, os.Args[1]) + case "probe_cert": + cfg, e := identity.TLSConfig(&identity.State{ServerCAFile: in.ServerCA}, false) + if e != nil { + return e + } + leaf, e := base64.StdEncoding.DecodeString(in.Certificate) + if e != nil { + return e + } + ca, e := base64.StdEncoding.DecodeString(in.IssuerCertificate) + if e != nil { + return e + } + keyDER, e := base64.StdEncoding.DecodeString(in.PrivateKey) + if e != nil { + return e + } + key, e := x509.ParsePKCS8PrivateKey(keyDER) + if e != nil { + return e + } + cert := tls.Certificate{Certificate: [][]byte{leaf, ca}, PrivateKey: key} + // Force the negative fixture certificate even when the server's CA list would + // otherwise make the client omit it. Server verification remains mandatory. + var presented atomic.Bool + cfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { presented.Store(true); return &cert, nil } + c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: false, Proxy: nil}, Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return connection.ErrTransport }} + defer c.CloseIdleConnections() + ws, response, e := websocket.Dial(ctx, "wss"+strings.TrimPrefix(in.Endpoint, "https")+"/agent/v1/connect", &websocket.DialOptions{HTTPClient: c, CompressionMode: websocket.CompressionDisabled}) + if e == nil { + ws.CloseNow() + return connection.ErrTransport + } + if !presented.Load() { + return connection.ErrTransport + } + if response != nil && response.StatusCode == 401 { + fmt.Println("server_refused") + return nil + } + if strings.Contains(e.Error(), "remote error: tls: unknown certificate authority") { + fmt.Println("server_tls_unknown_ca") + return nil + } + if strings.Contains(e.Error(), "remote error: tls: expired certificate") { + fmt.Println("server_tls_expired") + return nil + } + return connection.ErrTransport + case "tls_preflight": + ca, e := os.ReadFile(in.ServerCA) + if e != nil { + return e + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(ca) { + return connection.ErrTransport + } + peer, e := os.ReadFile(filepath.Join(in.Dir, "server.pem")) + if e != nil { + return e + } + block, _ := pem.Decode(peer) + if block == nil { + return connection.ErrTransport + } + cert, e := x509.ParseCertificate(block.Bytes) + if e != nil { + return e + } + if _, e = cert.Verify(x509.VerifyOptions{Roots: pool, DNSName: "localhost"}); e != nil { + return e + } + if _, e = cert.Verify(x509.VerifyOptions{Roots: pool, DNSName: "foreign.test"}); e == nil { + return connection.ErrTransport + } + fmt.Println("server_trust_preflight_ok") + return nil + case "diagnose": + state, e := identity.Load(in.Dir) + if e != nil { + fmt.Println("state_refused") + return nil + } + cfg, e := identity.TLSConfig(state, false) + if e != nil { + _, systemErr := x509.SystemCertPool() + caBytes, fileErr := os.ReadFile(state.ServerCAFile) + roots := x509.NewCertPool() + block, _ := pem.Decode(caBytes) + class := "no_pem_block" + if block != nil { + _, parseErr := x509.ParseCertificate(block.Bytes) + class = "parsed" + if parseErr != nil { + class = "other_parse_failure" + for _, known := range []string{"x509: negative serial number", "x509: malformed serial number", "x509: malformed certificate", "x509: malformed extensions", "x509: invalid RDNSequence", "x509: malformed validity", "x509: malformed time"} { + if parseErr.Error() == known { + class = strings.ReplaceAll(known, " ", "_") + } + } + } + } + fmt.Printf("tls_config_refused_system_ok_%t_explicit_file_ok_%t_explicit_pem_ok_%t_bytes_%d_class_%s\n", systemErr == nil, fileErr == nil, roots.AppendCertsFromPEM(caBytes), len(caBytes), class) + return nil + } + c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: false, Proxy: nil}, Timeout: 3 * time.Second} + defer c.CloseIdleConnections() + req, _ := http.NewRequestWithContext(ctx, "GET", in.Endpoint+"/agent/v1/connect", nil) + response, e := c.Do(req) + if e != nil { + var ca x509.UnknownAuthorityError + var name x509.HostnameError + var invalid x509.CertificateInvalidError + switch { + case errors.As(e, &ca): + fmt.Println("tls_unknown_authority") + case errors.As(e, &name): + fmt.Println("tls_wrong_name") + case errors.As(e, &invalid): + fmt.Printf("tls_invalid_certificate_%d\n", invalid.Reason) + default: + fmt.Printf("transport_failure_type_%T\n", e) + } + return nil + } + defer response.Body.Close() + fmt.Printf("http_status_%d\n", response.StatusCode) + return nil + case "enroll": + if _, e = identity.Prepare(in.Dir, in.Endpoint, in.ServerCA); e != nil { + return e + } + if e = connection.Enroll(ctx, in.Dir, in.Code); e != nil { + return e + } + fmt.Println("enrolled") + return nil + case "renew": + if e = connection.Renew(ctx, in.Dir); e != nil { + return e + } + fmt.Println("renewed") + return nil + case "once", "run": + once := os.Args[1] == "once" + return connection.Run(ctx, in.Dir, func(event connection.Event) { + if event.Kind == "connected" { + fmt.Println("connected") + if once { + cancel() + } + } else { + fmt.Println("disconnected") + } + }) + case "stage_lost", "stage_only": + state, e := identity.Load(in.Dir) + if e != nil { + return e + } + if e = identity.PrepareRenewal(in.Dir, state); e != nil { + return e + } + raw, e = post(ctx, state, false, "/agent/v1/identity/renew", map[string]any{"schema_version": 1, "csr": state.Pending.CSR}) + if e != nil { + return e + } + var response identity.Response + if e = json.Unmarshal(raw, &response); e != nil { + return e + } + if e = identity.AcceptPending(in.Dir, state, response); e != nil { + return e + } + if os.Args[1] == "stage_only" { + fmt.Println("staged") + return nil + } + // Exercise real TLS and actual server promotion, then deliberately discard + // the response without local promotion and exit the client process. + if _, e = post(ctx, state, true, "/agent/v1/identity/activate", map[string]any{"schema_version": 1}); e != nil { + return e + } + fmt.Println("lost_ack") + os.Exit(73) // injected abrupt client crash before local promotion + return nil + case "probe", "idle": + state, e := identity.Load(in.Dir) + if e != nil { + return e + } + cfg, e := identity.TLSConfig(state, in.Pending) + if e != nil { + if os.Args[1] == "probe" { + fmt.Println("client_refused") + return nil + } + return e + } + c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: false, Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return connection.ErrTransport }} + defer c.CloseIdleConnections() + ws, response, e := websocket.Dial(ctx, "wss"+strings.TrimPrefix(in.Endpoint, "https")+"/agent/v1/connect", &websocket.DialOptions{HTTPClient: c, CompressionMode: websocket.CompressionDisabled}) + if os.Args[1] == "probe" { + if e == nil { + ws.CloseNow() + return connection.ErrTransport + } + if response == nil || response.StatusCode != 401 { + return connection.ErrTransport + } + fmt.Println("server_refused") + return nil + } + if e != nil { + return e + } + defer ws.CloseNow() + ws.SetReadLimit(4096) + for _, kind := range []string{"hello", "heartbeat"} { + m := map[string]any{"schema_version": 1, "kind": kind, "owner_id": state.OwnerID, "host_id": state.HostID} + if kind == "hello" { + m["capabilities"] = []string{} + } + data, _ := json.Marshal(m) + if e = ws.Write(ctx, websocket.MessageText, data); e != nil { + return e + } + typ, data, e := ws.Read(ctx) + if e != nil || typ != websocket.MessageText { + return connection.ErrTransport + } + ack, e := connection.DecodeMessage(data, state.OwnerID, state.HostID) + if e != nil || ack["kind"] != kind+"_ack" { + return connection.ErrTransport + } + } + fmt.Println("ready") + closed := make(chan error, 1) + go func() { _, _, e := ws.Read(ctx); closed <- e }() + ticks := time.NewTicker(5 * time.Second) + defer ticks.Stop() + for { + select { + case e = <-closed: + if e == nil { + return connection.ErrTransport + } + fmt.Println("closed") + return nil + case <-ticks.C: + ping, cancelPing := context.WithTimeout(ctx, time.Second) + _ = ws.Ping(ping) + cancelPing() + case <-ctx.Done(): + return connection.ErrTransport + } + } + default: + return connection.ErrTransport + } +} +func post(ctx context.Context, state *identity.State, pending bool, path string, body any) ([]byte, error) { + cfg, e := identity.TLSConfig(state, pending) + if e != nil { + return nil, e + } + client := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: false, Proxy: nil}, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return connection.ErrTransport }} + defer client.CloseIdleConnections() + data, _ := json.Marshal(body) + req, e := http.NewRequestWithContext(ctx, "POST", state.Endpoint+path, bytes.NewReader(data)) + if e != nil { + return nil, e + } + req.Header.Set("Content-Type", "application/json") + r, e := client.Do(req) + if e != nil { + return nil, e + } + defer r.Body.Close() + if r.StatusCode != 200 { + return nil, connection.ErrTransport + } + return io.ReadAll(io.LimitReader(r.Body, 16385)) +} + +// Raw masked frames are independent negative test fixtures only; production uses Coder. +func wireProbe(ctx context.Context, in input, mode string) error { + state, e := identity.Load(in.Dir) + if e != nil { + return e + } + cfg, e := identity.TLSConfig(state, false) + if e != nil { + return e + } + u, e := url.Parse(state.Endpoint) + if e != nil { + return e + } + sock, e := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", u.Host, cfg) + if e != nil { + return e + } + defer sock.Close() + _ = sock.SetDeadline(time.Now().Add(5 * time.Second)) + key := make([]byte, 16) + if _, e = rand.Read(key); e != nil { + return e + } + request := "GET /agent/v1/connect HTTP/1.1\r\nHost: " + u.Host + "\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " + base64.StdEncoding.EncodeToString(key) + "\r\n\r\n" + if _, e = io.WriteString(sock, request); e != nil { + return e + } + reader := bufio.NewReader(sock) + response, e := http.ReadResponse(reader, &http.Request{Method: "GET"}) + if e != nil || response.StatusCode != 101 { + return connection.ErrTransport + } + message := map[string]any{"schema_version": 1, "kind": "hello", "owner_id": state.OwnerID, "host_id": state.HostID, "capabilities": []string{}} + opcode := byte(1) + expected := uint16(1008) + switch mode { + case "wrong_scope": + message["owner_id"] = state.HostID + case "wrong_version": + message["schema_version"] = 2 + case "binary": + opcode = 2 + case "invalid_utf8": + expected = 1007 + } + raw, _ := json.Marshal(message) + switch mode { + case "boundary": + raw = append(raw, bytes.Repeat([]byte(" "), 4096-len(raw))...) + case "oversize": + raw = append(raw, bytes.Repeat([]byte(" "), 4097-len(raw))...) + case "duplicate": + raw = append([]byte(`{"schema_version":1,`), raw[1:]...) + case "invalid_utf8": + raw = []byte{0xff} + case "fragment": + expected = 1009 + if e = writeFrame(sock, false, 1, bytes.Repeat([]byte(" "), 3000)); e != nil { + return e + } + opcode = 0 + raw = bytes.Repeat([]byte(" "), 3000) + } + if e = writeFrame(sock, true, opcode, raw); e != nil { + return e + } + code, payload, e := readFrame(reader) + if e != nil { + return e + } + if mode == "boundary" { + if code != 1 { + return connection.ErrTransport + } + m, e := connection.DecodeMessage(payload, state.OwnerID, state.HostID) + if e != nil || m["kind"] != "hello_ack" { + return connection.ErrTransport + } + fmt.Println("boundary_ack") + return nil + } + if code != 8 || len(payload) < 2 || binary.BigEndian.Uint16(payload) != expected { + return connection.ErrTransport + } + fmt.Printf("wire_refused_%d\n", expected) + return nil +} +func writeFrame(w io.Writer, fin bool, opcode byte, data []byte) error { + first := opcode + if fin { + first |= 0x80 + } + header := []byte{first} + if len(data) < 126 { + header = append(header, 0x80|byte(len(data))) + } else { + header = append(header, 0x80|126, byte(len(data)>>8), byte(len(data))) + } + mask := make([]byte, 4) + if _, e := rand.Read(mask); e != nil { + return e + } + header = append(header, mask...) + for n := range data { + data[n] ^= mask[n%4] + } + _, e := w.Write(append(header, data...)) + return e +} +func readFrame(r io.Reader) (byte, []byte, error) { + header := make([]byte, 2) + if _, e := io.ReadFull(r, header); e != nil { + return 0, nil, e + } + length := int(header[1] & 0x7f) + if header[1]&0x80 != 0 || length == 127 { + return 0, nil, connection.ErrTransport + } + if length == 126 { + b := make([]byte, 2) + if _, e := io.ReadFull(r, b); e != nil { + return 0, nil, e + } + length = int(binary.BigEndian.Uint16(b)) + } + if length > 4096 { + return 0, nil, connection.ErrTransport + } + payload := make([]byte, length) + _, e := io.ReadFull(r, payload) + return header[0] & 0xf, payload, e +} diff --git a/apps/host_agent/internal/identity/identity.go b/apps/host_agent/internal/identity/identity.go new file mode 100644 index 00000000..5c5d3c66 --- /dev/null +++ b/apps/host_agent/internal/identity/identity.go @@ -0,0 +1,550 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package identity persists host-owned key possession outside workspaces. +package identity + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" + + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" +) + +var ErrIdentity = errors.New("invalid_or_unsafe_identity") +var uuid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +type Material struct { + IdentityID string `json:"identity_id"` + PrivateKey string `json:"private_key"` + Certificate string `json:"certificate"` + IssuerCertificate string `json:"issuer_certificate"` +} +type Pending struct { + PrivateKey string `json:"private_key"` + CSR string `json:"csr"` + Certificate string `json:"certificate"` + IssuerCertificate string `json:"issuer_certificate"` + IdentityID string `json:"identity_id"` + PredecessorIdentityID string `json:"predecessor_identity_id"` +} +type State struct { + SchemaVersion int `json:"schema_version"` + Endpoint string `json:"endpoint"` + ServerCAFile string `json:"server_ca_file"` + OwnerID string `json:"owner_id"` + HostID string `json:"host_id"` + Active *Material `json:"active"` + Pending *Pending `json:"pending"` +} +type Response struct { + SchemaVersion int `json:"schema_version"` + OwnerID string `json:"owner_id"` + HostID string `json:"host_id"` + IdentityID string `json:"identity_id"` + Certificate string `json:"certificate"` + IssuerCertificate string `json:"issuer_certificate"` + ExpiresAt int64 `json:"expires_at"` +} + +func endpointOK(value string) bool { + u, e := url.Parse(value) + return e == nil && u.Scheme == "https" && u.Host != "" && u.User == nil && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.RawFragment == "" && (u.Path == "" || u.Path == "/") && u.RawPath == "" +} +func Prepare(dir, endpoint, serverCA string) (*State, error) { + if !endpointOK(endpoint) || noSymlinkAncestors(dir, true) != nil { + return nil, ErrIdentity + } + if _, e := os.Lstat(filepath.Join(dir, "identity.json")); e == nil { + return nil, ErrIdentity + } else if !os.IsNotExist(e) { + return nil, ErrIdentity + } + if e := os.Mkdir(dir, 0700); e != nil && !os.IsExist(e) { + return nil, ErrIdentity + } + if e := privatePath(dir, true); e != nil { + return nil, e + } + pending, e := newPending("", "") + if e != nil { + return nil, e + } + state := &State{SchemaVersion: 1, Endpoint: strings.TrimSuffix(endpoint, "/"), ServerCAFile: serverCA, Pending: pending} + if e = save(dir, state, true); e != nil { + return nil, e + } + return state, nil +} +func Load(dir string) (*State, error) { + if e := privatePath(dir, true); e != nil { + return nil, e + } + path := filepath.Join(dir, "identity.json") + if e := privatePath(path, false); e != nil { + return nil, e + } + data, e := os.ReadFile(path) + if e != nil || len(data) > 32768 { + return nil, ErrIdentity + } + native, parseErr := wirejson.Parse(data) + if parseErr != nil || !stateShape(native) { + return nil, ErrIdentity + } + var state State + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + if e = dec.Decode(&state); e != nil { + return nil, ErrIdentity + } + if e = validate(&state); e != nil { + return nil, e + } + return &state, nil +} +func Save(dir string, state *State) error { return save(dir, state, false) } +func save(dir string, state *State, exclusive bool) error { + if e := privatePath(dir, true); e != nil { + return e + } + if e := validate(state); e != nil { + return e + } + path := filepath.Join(dir, "identity.json") + if !exclusive { + if e := privatePath(path, false); e != nil { + return e + } + } + bytes, e := json.Marshal(state) + if e != nil || len(bytes) > 32768 { + return ErrIdentity + } + file, e := os.CreateTemp(dir, ".identity-") + if e != nil { + return ErrIdentity + } + temporary := file.Name() + defer os.Remove(temporary) + if e = file.Chmod(0600); e == nil { + _, e = file.Write(bytes) + } + if e == nil { + e = file.Sync() + } + closed := file.Close() + if e == nil { + e = closed + } + if e != nil { + return ErrIdentity + } + if exclusive { + e = os.Link(temporary, path) + } else { + e = os.Rename(temporary, path) + } + if e != nil { + return ErrIdentity + } + parent, e := os.Open(dir) + if e != nil { + return ErrIdentity + } + e = parent.Sync() + closeErr := parent.Close() + if e != nil || closeErr != nil { + return ErrIdentity + } + return nil +} +func privatePath(path string, directory bool) error { + if noSymlinkAncestors(path, false) != nil { + return ErrIdentity + } + st, e := os.Lstat(path) + if e != nil { + return ErrIdentity + } + want := os.FileMode(0600) + if directory { + want = 0700 + } + unix, ok := st.Sys().(*syscall.Stat_t) + if !ok || unix.Uid != uint32(os.Geteuid()) || st.Mode().Perm() != want || st.Mode()&os.ModeSymlink != 0 || st.IsDir() != directory || (!directory && !st.Mode().IsRegular()) || (!directory && st.Size() > 32768) { + return ErrIdentity + } + return nil +} +func newPending(host, predecessor string) (*Pending, error) { + _, key, e := ed25519.GenerateKey(rand.Reader) + if e != nil { + return nil, ErrIdentity + } + request := &x509.CertificateRequest{Subject: pkix.Name{CommonName: host}} + if host != "" { + raw, err := asn1.Marshal(pkix.RDNSequence{{pkix.AttributeTypeAndValue{Type: asn1.ObjectIdentifier{2, 5, 4, 3}, Value: asn1.RawValue{Tag: asn1.TagUTF8String, Bytes: []byte(host)}}}}) + if err != nil { + return nil, ErrIdentity + } + request.RawSubject = raw + } + der, e := x509.CreateCertificateRequest(rand.Reader, request, key) + if e != nil { + return nil, ErrIdentity + } + encoded, e := x509.MarshalPKCS8PrivateKey(key) + if e != nil { + return nil, ErrIdentity + } + return &Pending{PrivateKey: base64.StdEncoding.EncodeToString(encoded), CSR: base64.StdEncoding.EncodeToString(der), PredecessorIdentityID: predecessor}, nil +} +func decode64(value string, max int) ([]byte, error) { + if len(value) > 4*((max+2)/3) { + return nil, ErrIdentity + } + b, e := base64.StdEncoding.DecodeString(value) + if e != nil || len(b) > max || base64.StdEncoding.EncodeToString(b) != value { + return nil, ErrIdentity + } + return b, nil +} +func privateKey(value string) (ed25519.PrivateKey, error) { + der, e := decode64(value, 4096) + if e != nil { + return nil, e + } + key, e := x509.ParsePKCS8PrivateKey(der) + if e != nil { + return nil, ErrIdentity + } + ed, ok := key.(ed25519.PrivateKey) + canonical, canonicalErr := x509.MarshalPKCS8PrivateKey(key) + if !ok || canonicalErr != nil || !bytes.Equal(canonical, der) { + return nil, ErrIdentity + } + return ed, nil +} +func certificate(value string) (*x509.Certificate, error) { + b, e := decode64(value, 4096) + if e != nil { + return nil, e + } + c, e := x509.ParseCertificate(b) + if e != nil { + return nil, ErrIdentity + } + return c, nil +} +func material(m *Material, host string, now time.Time) (*x509.Certificate, error) { + if m == nil || !uuid.MatchString(m.IdentityID) { + return nil, ErrIdentity + } + key, e := privateKey(m.PrivateKey) + if e != nil { + return nil, e + } + cert, e := certificate(m.Certificate) + if e != nil { + return nil, e + } + issuer, e := certificate(m.IssuerCertificate) + if e != nil { + return nil, e + } + pub, ok := cert.PublicKey.(ed25519.PublicKey) + if !ok || !pub.Equal(key.Public()) || cert.Subject.CommonName != host || len(cert.Subject.Names) != 1 || !cert.BasicConstraintsValid || len(cert.UnknownExtKeyUsage) != 0 || len(cert.UnhandledCriticalExtensions) != 0 || cert.IsCA || cert.KeyUsage != x509.KeyUsageDigitalSignature || len(cert.ExtKeyUsage) != 1 || cert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth || cert.NotAfter.Sub(cert.NotBefore) != 24*time.Hour { + return nil, ErrIdentity + } + if issuer.PublicKeyAlgorithm != x509.Ed25519 || issuer.MaxPathLen >= 0 || issuer.MaxPathLenZero || issuer.NotAfter.Sub(issuer.NotBefore) != 5*365*24*time.Hour || len(issuer.ExtKeyUsage) != 0 || len(issuer.UnknownExtKeyUsage) != 0 || len(issuer.UnhandledCriticalExtensions) != 0 || issuer.Subject.CommonName != "WorkHarbor client issuer" || len(issuer.Subject.Names) != 1 || !bytes.Equal(cert.RawIssuer, issuer.RawSubject) || !issuer.IsCA || !issuer.BasicConstraintsValid || issuer.KeyUsage != x509.KeyUsageCertSign || issuer.CheckSignatureFrom(issuer) != nil || cert.CheckSignatureFrom(issuer) != nil || cert.NotAfter.After(issuer.NotAfter) || now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) || now.Before(issuer.NotBefore) || !now.Before(issuer.NotAfter) { + return nil, ErrIdentity + } + return cert, nil +} +func validate(s *State) error { + if s == nil || s.SchemaVersion != 1 || !endpointOK(s.Endpoint) { + return ErrIdentity + } + if s.Active == nil { + if s.OwnerID != "" || s.HostID != "" || s.Pending == nil || s.Pending.PredecessorIdentityID != "" || s.Pending.Certificate != "" || s.Pending.IssuerCertificate != "" || s.Pending.IdentityID != "" { + return ErrIdentity + } + } else { + if !uuid.MatchString(s.OwnerID) || !uuid.MatchString(s.HostID) { + return ErrIdentity + } + // Persisted expired possession must remain loadable for explicit recovery; + // transport and promotion separately validate current wall-clock validity. + cert, e := certificate(s.Active.Certificate) + if e != nil { + return e + } + if _, e = material(s.Active, s.HostID, cert.NotBefore); e != nil { + return e + } + } + if p := s.Pending; p != nil { + key, e := privateKey(p.PrivateKey) + if e != nil { + return e + } + bytes, e := decode64(p.CSR, 4096) + if e != nil { + return e + } + csr, e := x509.ParseCertificateRequest(bytes) + if e != nil || csr.CheckSignature() != nil || len(csr.Extensions) != 0 || len(csr.Attributes) != 0 { + return ErrIdentity + } + public, ok := csr.PublicKey.(ed25519.PublicKey) + if !ok || !public.Equal(key.Public()) || csr.Subject.CommonName != s.HostID { + return ErrIdentity + } + if s.Active != nil && p.PredecessorIdentityID != s.Active.IdentityID { + return ErrIdentity + } + if p.Certificate != "" { + if s.Active == nil { + return ErrIdentity + } + c, e := certificate(p.Certificate) + if e != nil { + return e + } + if _, e = material(p.material(), s.HostID, c.NotBefore); e != nil { + return e + } + } else if p.IssuerCertificate != "" || p.IdentityID != "" { + return ErrIdentity + } + } + return nil +} +func (p *Pending) material() *Material { + return &Material{IdentityID: p.IdentityID, PrivateKey: p.PrivateKey, Certificate: p.Certificate, IssuerCertificate: p.IssuerCertificate} +} +func acceptResponse(s *State, r Response) (*Material, error) { + if r.SchemaVersion != 1 || !uuid.MatchString(r.OwnerID) || !uuid.MatchString(r.HostID) || !uuid.MatchString(r.IdentityID) || s.Pending == nil { + return nil, ErrIdentity + } + if s.Active != nil && (s.OwnerID != r.OwnerID || s.HostID != r.HostID) { + return nil, ErrIdentity + } + m := &Material{IdentityID: r.IdentityID, PrivateKey: s.Pending.PrivateKey, Certificate: r.Certificate, IssuerCertificate: r.IssuerCertificate} + c, e := material(m, r.HostID, time.Now()) + if e != nil || c.NotAfter.Unix() != r.ExpiresAt { + return nil, ErrIdentity + } + return m, nil +} +func AcceptEnrollment(dir string, s *State, r Response) error { + if s.Active != nil { + return ErrIdentity + } + m, e := acceptResponse(s, r) + if e != nil { + return e + } + next := *s + next.OwnerID = r.OwnerID + next.HostID = r.HostID + next.Active = m + next.Pending = nil + if e = Save(dir, &next); e != nil { + return e + } + *s = next + return nil +} +func PrepareRenewal(dir string, s *State) error { + if _, e := material(s.Active, s.HostID, time.Now()); e != nil { + return e + } + if s.Pending != nil { + return validate(s) + } + p, e := newPending(s.HostID, s.Active.IdentityID) + if e != nil { + return e + } + next := *s + next.Pending = p + if e = Save(dir, &next); e != nil { + return e + } + *s = next + return nil +} +func AcceptPending(dir string, s *State, r Response) error { + if s.Active == nil { + return ErrIdentity + } + m, e := acceptResponse(s, r) + if e != nil { + return e + } + pending := *s.Pending + pending.Certificate = m.Certificate + pending.IssuerCertificate = m.IssuerCertificate + pending.IdentityID = m.IdentityID + next := *s + next.Pending = &pending + if e = Save(dir, &next); e != nil { + return e + } + *s = next + return nil +} +func Promote(dir string, s *State) error { + if s.Active == nil || s.Pending == nil || s.Pending.PredecessorIdentityID != s.Active.IdentityID { + return ErrIdentity + } + if _, e := material(s.Pending.material(), s.HostID, time.Now()); e != nil { + return e + } + next := *s + next.Active = s.Pending.material() + next.Pending = nil + if e := Save(dir, &next); e != nil { + return e + } + *s = next + return nil +} +func TLSConfig(s *State, pending bool) (*tls.Config, error) { + roots, e := x509.SystemCertPool() + if e != nil { + return nil, ErrIdentity + } + if s.ServerCAFile != "" { + if noSymlinkAncestors(s.ServerCAFile, false) != nil { + return nil, ErrIdentity + } + info, e := os.Lstat(s.ServerCAFile) + if e != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 32768 { + return nil, ErrIdentity + } + bytes, e := os.ReadFile(s.ServerCAFile) + if e != nil { + return nil, ErrIdentity + } + roots = x509.NewCertPool() + if !roots.AppendCertsFromPEM(bytes) { + return nil, ErrIdentity + } + } + cfg := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots, NextProtos: []string{"http/1.1"}} + m := s.Active + if pending { + if s.Pending == nil { + return nil, ErrIdentity + } + m = s.Pending.material() + } + if m != nil { + if _, e = material(m, s.HostID, time.Now()); e != nil { + return nil, e + } + key, e := privateKey(m.PrivateKey) + if e != nil { + return nil, e + } + leaf, _ := decode64(m.Certificate, 4096) + issuer, _ := decode64(m.IssuerCertificate, 4096) + cfg.Certificates = []tls.Certificate{{Certificate: [][]byte{leaf, issuer}, PrivateKey: key}} + } + return cfg, nil +} +func Expires(s *State) (time.Time, error) { + c, e := material(s.Active, s.HostID, time.Now()) + if e != nil { + return time.Time{}, e + } + issuer, _ := certificate(s.Active.IssuerCertificate) + if issuer.NotAfter.Before(c.NotAfter) { + return issuer.NotAfter, nil + } + return c.NotAfter, nil +} + +func noSymlinkAncestors(path string, missingFinal bool) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return ErrIdentity + } + parts := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator)) + current := string(os.PathSeparator) + for index, part := range parts { + current = filepath.Join(current, part) + info, e := os.Lstat(current) + if os.IsNotExist(e) && missingFinal && index == len(parts)-1 { + return nil + } + if e != nil || info.Mode()&os.ModeSymlink != 0 { + return ErrIdentity + } + } + return nil +} +func stateShape(value any) bool { + m, ok := value.(map[string]any) + if !ok || !exactKeys(m, []string{"schema_version", "endpoint", "server_ca_file", "owner_id", "host_id", "active", "pending"}) { + return false + } + version, ok := m["schema_version"].(*big.Int) + if !ok || version.Cmp(big.NewInt(1)) != 0 { + return false + } + for _, k := range []string{"endpoint", "server_ca_file", "owner_id", "host_id"} { + if _, ok := m[k].(string); !ok { + return false + } + } + for _, slot := range []string{"active", "pending"} { + if m[slot] == nil { + continue + } + obj, ok := m[slot].(map[string]any) + if !ok { + return false + } + keys := []string{"identity_id", "private_key", "certificate", "issuer_certificate"} + if slot == "pending" { + keys = append(keys, "csr", "predecessor_identity_id") + } + if !exactKeys(obj, keys) { + return false + } + for _, k := range keys { + if _, ok := obj[k].(string); !ok { + return false + } + } + } + return true +} +func exactKeys(m map[string]any, keys []string) bool { + if len(m) != len(keys) { + return false + } + for _, k := range keys { + if _, ok := m[k]; !ok { + return false + } + } + return true +} diff --git a/apps/host_agent/internal/identity/identity_test.go b/apps/host_agent/internal/identity/identity_test.go new file mode 100644 index 00000000..26c4fb2f --- /dev/null +++ b/apps/host_agent/internal/identity/identity_test.go @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: EUPL-1.2 +package identity + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPreparePersistsPrivatePossessionBeforeEnrollment(t *testing.T) { + dir := filepath.Join(physicalTemp(t), "identity") + state, err := Prepare(dir, "https://localhost:8443", "") + if err != nil { + t.Fatal(err) + } + if state.Active != nil || state.Pending == nil || state.OwnerID != "" || state.HostID != "" { + t.Fatal("invented assignment") + } + der, err := base64.StdEncoding.DecodeString(state.Pending.CSR) + if err != nil { + t.Fatal(err) + } + csr, err := x509.ParseCertificateRequest(der) + if err != nil { + t.Fatal(err) + } + if err = csr.CheckSignature(); err != nil { + t.Fatal(err) + } + if csr.Subject.CommonName != "" || len(csr.Subject.Names) != 0 || len(csr.Extensions) != 0 { + t.Fatal("CSR invented authority") + } + if _, ok := csr.PublicKey.(ed25519.PublicKey); !ok { + t.Fatal("wrong key") + } + st, err := os.Stat(filepath.Join(dir, "identity.json")) + if err != nil || st.Mode().Perm() != 0600 { + t.Fatal("unsafe file") + } + reloaded, err := Load(dir) + if err != nil || reloaded.Pending.PrivateKey != state.Pending.PrivateKey { + t.Fatal("lost pending possession") + } + if _, err = Prepare(dir, "https://localhost:8443", ""); err == nil { + t.Fatal("replaced identity") + } +} +func TestPrivateStateUnsafeFilesSymlinksAndSchemaFailClosed(t *testing.T) { + for _, tc := range []string{"mode", "symlink", "schema", "corrupt", "parent"} { + t.Run(tc, func(t *testing.T) { + dir := filepath.Join(physicalTemp(t), "identity") + if _, err := Prepare(dir, "https://localhost:8443", ""); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "identity.json") + switch tc { + case "mode": + os.Chmod(path, 0644) + case "symlink": + os.Rename(path, path+".saved") + os.Symlink(path+".saved", path) + case "schema": + os.WriteFile(path, []byte(`{"schema_version":99,"private_key":"SECRET_SENTINEL"}`), 0600) + case "corrupt": + os.WriteFile(path, []byte(`{"broken"`), 0600) + case "parent": + os.Chmod(dir, 0755) + } + _, err := Load(dir) + if err == nil { + t.Fatal("accepted unsafe state") + } + if strings.Contains(err.Error(), "SECRET_SENTINEL") { + t.Fatal("leaked state") + } + }) + } +} +func TestPlaintextAndCredentialEndpointsRefusedBeforeStateWrite(t *testing.T) { + for _, endpoint := range []string{"http://localhost:8443", "https://secret@localhost:8443", "https://localhost:8443/?token=secret", "https://localhost:8443/#secret"} { + dir := filepath.Join(physicalTemp(t), "identity") + if _, err := Prepare(dir, endpoint, ""); err == nil { + t.Fatal("unsafe endpoint") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("wrote before validation") + } + } +} + +func physicalTemp(t *testing.T) string { + t.Helper() + p, e := filepath.EvalSymlinks(t.TempDir()) + if e != nil { + t.Fatal(e) + } + return p +} +func TestPrivateStateRequiresExactRecursiveNamesAndPresence(t *testing.T) { + for _, name := range []string{"case", "missing", "collision", "null", "type", "decoded_duplicate"} { + t.Run(name, func(t *testing.T) { + dir := filepath.Join(physicalTemp(t), "identity") + if _, e := Prepare(dir, "https://localhost:8443", ""); e != nil { + t.Fatal(e) + } + path := filepath.Join(dir, "identity.json") + bytes, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + text := string(bytes) + switch name { + case "case": + text = strings.Replace(text, `"schema_version"`, `"SCHEMA_VERSION"`, 1) + case "missing": + text = strings.Replace(text, `"server_ca_file":"",`, "", 1) + case "null": + text = strings.Replace(text, `"server_ca_file":""`, `"server_ca_file":null`, 1) + case "type": + text = strings.Replace(text, `"server_ca_file":""`, `"server_ca_file":12`, 1) + case "decoded_duplicate": + text = strings.Replace(text, `"schema_version":1`, `"schema_version":1,"schema_\u0076ersion":1`, 1) + case "collision": + text = strings.Replace(text, `"schema_version":1`, `"schema_version":1,"SCHEMA_VERSION":1`, 1) + } + os.WriteFile(path, []byte(text), 0600) + if _, e = Load(dir); e == nil { + t.Fatal("accepted private schema alias/missing field") + } + }) + } +} +func TestAncestorSymlinkRefusedBeforeIdentityWrites(t *testing.T) { + root := physicalTemp(t) + real := filepath.Join(root, "real") + os.Mkdir(real, 0700) + alias := filepath.Join(root, "alias") + os.Symlink(real, alias) + if _, e := Prepare(filepath.Join(alias, "identity"), "https://localhost:8443", ""); e == nil { + t.Fatal("followed ancestor symlink") + } + if _, e := os.Stat(filepath.Join(real, "identity")); !os.IsNotExist(e) { + t.Fatal("wrote through alias") + } +} + +func signedMaterial(t *testing.T, change func(*x509.Certificate, *x509.Certificate)) *Material { + t.Helper() + issuerPub, issuerKey, _ := ed25519.GenerateKey(rand.Reader) + leafPub, leafKey, _ := ed25519.GenerateKey(rand.Reader) + start := time.Now().Add(-time.Minute).Truncate(time.Second) + ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "WorkHarbor client issuer"}, NotBefore: start.Add(-time.Hour), NotAfter: start.Add(-time.Hour).Add(5 * 365 * 24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "22222222-2222-4222-8222-222222222222"}, NotBefore: start, NotAfter: start.Add(24 * time.Hour), BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}} + if change != nil { + change(ca, leaf) + } + caDER, e := x509.CreateCertificate(rand.Reader, ca, ca, issuerPub, issuerKey) + if e != nil { + t.Fatal(e) + } + parsed, e := x509.ParseCertificate(caDER) + if e != nil { + t.Fatal(e) + } + leafDER, e := x509.CreateCertificate(rand.Reader, leaf, parsed, leafPub, issuerKey) + if e != nil { + t.Fatal(e) + } + keyDER, _ := x509.MarshalPKCS8PrivateKey(leafKey) + return &Material{IdentityID: "33333333-3333-4333-8333-333333333333", PrivateKey: base64.StdEncoding.EncodeToString(keyDER), Certificate: base64.StdEncoding.EncodeToString(leafDER), IssuerCertificate: base64.StdEncoding.EncodeToString(caDER)} +} +func TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions(t *testing.T) { + host := "22222222-2222-4222-8222-222222222222" + if _, e := material(signedMaterial(t, nil), host, time.Now()); e != nil { + t.Fatal(e) + } + cases := []struct { + name string + change func(*x509.Certificate, *x509.Certificate) + }{ + {"issuer_oversized", func(c, l *x509.Certificate) { + c.ExtraExtensions = []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 2, 3, 4}, Value: make([]byte, 12000)}} + }}, + {"leaf_oversized", func(c, l *x509.Certificate) { + l.ExtraExtensions = []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 2, 3, 4}, Value: make([]byte, 12000)}} + }}, + {"leaf_unknown_eku", func(c, l *x509.Certificate) { l.UnknownExtKeyUsage = []asn1.ObjectIdentifier{{1, 2, 3, 4}} }}, + {"issuer_eku", func(c, l *x509.Certificate) { c.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} }}, + {"critical", func(c, l *x509.Certificate) { + l.ExtraExtensions = []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 2, 3, 4}, Critical: true, Value: []byte{5, 0}}} + }}, + {"extra_subject", func(c, l *x509.Certificate) { l.Subject.Organization = []string{"foreign"} }}, + {"issuer_path", func(c, l *x509.Certificate) { c.MaxPathLen = 0; c.MaxPathLenZero = true }}, + {"issuer_duration", func(c, l *x509.Certificate) { c.NotAfter = c.NotAfter.Add(-time.Hour) }}, + {"leaf_duration", func(c, l *x509.Certificate) { l.NotAfter = l.NotBefore.Add(time.Hour) }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, e := material(signedMaterial(t, tc.change), host, time.Now()); e == nil { + t.Fatal("accepted extra identity purpose/profile") + } + }) + } +} diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 21de6ca4..570dcd22 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -550,7 +550,23 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestExactCanonicalBudgetAndCounterMaximum", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestNormalizedResultBudgetIncludesOptionalDefaults", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestWireObservationConfigMustAlreadyBeNormalized", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestWorkspaceScopeUsesExpectedAssignment" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestWorkspaceScopeUsesExpectedAssignment", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPreparePersistsPrivatePossessionBeforeEnrollment", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateUnsafeFilesSymlinksAndSchemaFailClosed", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPlaintextAndCredentialEndpointsRefusedBeforeStateWrite", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateRequiresExactRecursiveNamesAndPresence", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAncestorSymlinkRefusedBeforeIdentityWrites", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestClosedAuthenticatedConnectionMessages", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestConnectionDecoderRejectsClosedShapeVersionTypesAndBytes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestReconnectBackoffIsBoundedAndIndependentOfLifecycle", + "github.com/wstein/workharbor-nxt/apps/host_agent/cmd/workharbor-agent/TestRunRefusesMissingIdentityWithFixedDiagnostic", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestIdentityResponseRequiresClosedExactFieldsAndIntegers", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestEnrollmentCodeAndUnsafeStateFailBeforeTransport", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestExpiryWatchdogCancelsBlockedIO", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed" ], "api_client": [ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", @@ -672,7 +688,65 @@ "WorkHarborWeb.TransportSecurityTest/test actual TLS negotiates only HTTP1 and verifies certificate identity", "WorkHarborWeb.TransportSecurityTest/test actual TLS rejects HTTP2 prior knowledge with and without ALPN", "WorkHarborWeb.TransportSecurityTest/test actual HTTP1 preserves browser form and CLI bearer authentication", - "WorkHarborWeb.TransportSecurityTest/test actual WSS preserves selected session CSRF origin and revocation gates" + "WorkHarborWeb.TransportSecurityTest/test actual WSS preserves selected session CSRF origin and revocation gates", + "WorkHarbor.HostIdentityTest/test native issuer initializes exclusively and reloads same private identity", + "WorkHarbor.HostIdentityTest/test issuer refuses unsafe missing future and malformed state without replacement", + "WorkHarbor.HostIdentityTest/test single use enrollment derives host scope and code never persists", + "WorkHarbor.HostIdentityTest/test enrollment exact expiry issues no identities", + "WorkHarbor.HostIdentityTest/test independent enrollment writers have exactly one committed winner", + "WorkHarbor.HostIdentityTest/test explicit host actions reject dashboard and cross owner before mutation", + "WorkHarbor.HostIdentityTest/test revocation tombstones host codes identities and repeated revoke", + "WorkHarbor.HostIdentityTest/test staged rotation pending activation and lost ack preserve one identity", + "WorkHarbor.HostIdentityTest/test CSR signature corruption and enrollment names cannot issue identity", + "WorkHarbor.HostIdentityTest/test certificate expiry and response remain identical across UTC tick redemption and renewal", + "WorkHarbor.HostIdentityTest/test revoked tombstone scope cannot move clear delete or admit reenrollment", + "WorkHarbor.HostIdentityTest/test CSR and private issuer DER suffixes reject before code consumption", + "WorkHarbor.HostIdentityTest/test issuer refuses final and ancestor symlinks before initialization writes", + "WorkHarbor.HostIdentityTest/test native leaf and issuer profiles reject extra purposes critical and duplicate extensions", + "WorkHarbor.HostIdentityTest/test issuer startup rejects signed critical path length and duration profile variants", + "WorkHarbor.HostConnectionsTest/test authenticated heartbeat becomes stale exactly30seconds without changing history", + "WorkHarbor.HostConnectionsTest/test reconnect and replaced socket cannot refresh old runtime annotation", + "WorkHarbor.HostConnectionsTest/test revocation and open socket certificate expiry fence existing connections", + "WorkHarbor.HostConnectionsTest/test closed connection messages reject malformed scope fields versions and bounds", + "WorkHarbor.HostConnectionsTest/test late heartbeat at stale boundary cannot revive an existing socket", + "WorkHarbor.HostConnectionsTest/test renewed lease and replaced connection fence delayed stale timers", + "WorkHarbor.HostConnectionsTest/test restart invalidates known observations without changing retained operation history", + "WorkHarbor.HostConnectionsTest/test open socket absolute certificate expiry rejects further traffic", + "WorkHarbor.HostConnectionsTest/test failed persistence leaves conservative unknown and fixed failure", + "WorkHarbor.HostConnectionsTest/test projection rechecks authoritative current intent inside worker", + "WorkHarbor.HostConnectionsTest/test stale audit rollback reports fixed persistence failure", + "WorkHarbor.HostConnectionsTest/test dedicated monotonic certificate expiry timer fences idle and replaced sockets", + "WorkHarbor.HostConnectionsTest/test agent socket lifetime is fenced by owning connection worker death", + "WorkHarborWeb.AgentIngressTest/test agent namespace is admitted before browser session and rejects mixed credentials", + "WorkHarborWeb.AgentIngressTest/test agent TLS authority exact path and query are checked before bodies", + "WorkHarborWeb.AgentIngressTest/test assigned JSON and owner principal never substitute for actual peer certificate", + "WorkHarborWeb.AgentIngressTest/test enrollment body is closed strict and bounded with fixed redacted errors", + "WorkHarborWeb.AgentIngressTest/test failed agent body validation preserves the consumed adapter state", + "WorkHarborWeb.HostAPITest/test explicit owner host endpoints issue revoke and confirm offboarding", + "WorkHarborWeb.HostAPITest/test dashboard cannot mint host enrollment or revoke even known host", + "WorkHarborWeb.HostAPITest/test identity startup loads explicit initialized signer and never replaces missing state", + "WorkHarborWeb.HostAPITest/test failed owner body validation preserves consumed adapter state", + "WorkHarborWeb.TransportSecurityTest/test optional client peer verification permits only validated native issuer trio", + "WorkHarbor.HostConnectionsTest/test shared closed host connection fixtures match native validator", + "WorkHarbor.HostConnectionsTest/test rejected application data closes normally without crashing or echoing input", + "WorkHarbor.HostIdentityTest/test signed oversized issuer is refused before enrollment code consumption", + "WorkHarborWeb.HostTransportTest/test unavailable endpoint is never evidence of server authorization refusal", + "WorkHarborWeb.HostTransportTest/test genuine Go enrollment heartbeat rotation and revocation use verified native TLS", + "WorkHarborWeb.HostTransportTest/test genuine idle WSS closes on worker death and production client reconnects", + "WorkHarborWeb.HostTransportTest/test genuine transport ping cannot renew application heartbeat lease", + "WorkHarborWeb.HostTransportTest/test genuine activation crash recovers pending after retired predecessor expiry", + "WorkHarborWeb.HostTransportTest/test genuine optional client TLS preserves owner HTTP1 and rejects H2 and owner as host", + "WorkHarborWeb.HostTransportTest/test genuine TLS rejects foreign issuer and unregistered signed identity", + "WorkHarborWeb.HostTransportTest/test genuine pending identity has only activation authority and rotation revocation close live WSS", + "WorkHarborWeb.HostTransportTest/test genuine WSS enforces exact wire scope version JSON types and message budgets", + "WorkHarborWeb.HostTransportTest/test genuine idle client socket is forced closed at certificate expiry", + "WorkHarborWeb.HostTransportTest/test genuine first activation after predecessor expiry fails and owner code recovers same host", + "WorkHarborWeb.HostTransportTest/test genuine production Go enrollment verifies server root and name before code transmission", + "WorkHarborWeb.AgentLogFilterTest/test agent crash events rebuild all report and metadata to fixed safe values", + "WorkHarborWeb.AgentLogFilterTest/test filter is total and leaves unrelated events unchanged", + "WorkHarborWeb.AgentLogFilterTest/test owned filter installation is idempotent and refuses a conflicting owner", + "WorkHarbor.HostIdentityTest/test serialized owner revocation wins concurrent issuance redemption renewal and activation", + "WorkHarbor.HostIdentityTest/test enrollment audit failure rolls back consumption identity and active fingerprint" ] } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index eec3c2c6..9e0b02d9 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -825,3 +825,147 @@ complete T008 contract before tests/behavior. No superseded alternatives remain. T008 implementation/failure-first tests may now proceed under root GO; no runtime acceptance is claimed. The S001 transport contract status is refreshed to exact c88f339 acceptance without changing its mitigation/advisory boundary. + + +## T008 bounded identity progress and disposable resource plan + +Contract e99c170 was faithful-consolidation CLEAR before tests/behavior. Actual +initial RED: Phoenix0/9 exit2 absent Issuer; Go identity/connection exit1 undefined +implementation symbols. A wrong write cwd/reserved shell variable and Accounts +login tuple setup error were corrected first, never counted behavior RED. Logs: +/private/tmp/workharbor-t008-absent-phoenix-red.log and +/private/tmp/workharbor-t008-initial-go-red.log. Native ASN.1 version/subject and +strict-boolean errors were corrected before the initial9/9 focused identity pass. + +Independent early review identified six classes: cross-second certificate/response +expiry mismatch; movable revoked tombstone scope; accepted DER suffixes; ancestor +symlinks; permissive Go private-state case/default schema; incomplete issuer/leaf +profiles. Real regressions preceded corrections: native9/13 and13/14, then14/15; +Go case/missing/collision/ancestor and independently signed purpose/critical/subject/ +issuer path/duration negatives failed. Native15/15 and both Go packages now pass. +Retained reviewer independently CLOSED all six on captured correction scope; +/private/tmp/workharbor-t008-profile-closure-56i5mkf_ retains its evidence. This is +not T008 code/runtime acceptance. Automatic native permission review timed out once; +its explicitly permitted identical retry succeeded, separately from behavior. +New liveness tests produced real0/4 absent-module RED before behavior; first4/4 now +pass metadata/history/fencing cases. Further transport/clock/crash evidence remains. +Meaningful new IDs append to the unchanged114Phoenix/132host/162API/1CLI inventory. + +### Proposed resource plan (review required before activation) + +Read-only preflight: host16GiB RAM/12logical CPUs; physical /private/tmp filesystem +reported180000000KiB available. vm_stat free+inactive+speculative approximately +5.1GiB, with existing compressor/swap activity, so do not allocate a6GiB guest. +OrbStack inventory was empty. Repeat these checks immediately before create; defer +if free+inactive+speculative<3GiB, disk available<20GiB or inventory/ownership unclear. +One disposable owned Ubuntu24.04 arm64 guest only, exactly2CPU/2GiB/16GiB thin disk, +--isolated and --isolate-network retained. This is a bounded T008 transport fixture, +not five-workspace/eight-attachment load sizing or capacity evidence. Record immutable +created machine ID and verify identity/isolation before every mutation; never reuse +or stop an unrelated machine. No host Docker/BuildKit/context/cache changes. + +Transfer only explicit source/test inputs (exclude .git, host deps/build caches and +all existing credentials). Install exact .tool-versions Linux toolchain and locked +dependencies INSIDE guest. Bootstrap sequentially: build at most two jobs (OTP at +one job if source build is necessary), no listeners while toolchain builds. If2GiB +is insufficient, record failure and obtain reviewed revised sizing; no silent growth. +Docker29.1.3 from the independently exercised guest package route can establish +Linux Docker-host availability; T008 creates no workspace images or provider calls. +Systemd is the Linux HOST service manager, never placed in a workspace image. +Bootstrap may use up to45minutes; abort on capacity/identity/network failure. Tool +calls yield at<=60seconds with progress; no isolation bypass or host mount sharing. + +Both genuine Elixir CP and Go client run INSIDE guest using guest loopback only, +127.0.0.1 and explicitly verified localhost test TLS identity, no host port forward. +Use exact source digest and record OTP/Elixir/Go/Linux/systemd/Docker versions. +Private fixture root0700, keys/state/env/code inputs0600, dedicated fixture service +UID. No secret in argv/stdout/journal: protected EnvironmentFile and private input +files carry runtime material. Explicit native issuer initializer runs only in that +fixture. Reviewed execution amendment: CP/test harness and ALL Go/build descendants share +one transient dedicated-UID unit with MemoryMax=512M, CPUQuota=100%, +RuntimeMaxSec=600 and KillMode=control-group. This stricter aggregate replaces the +original separate512MiB CP/128MiB Go proposal; no child escape/unrestricted unit. +Run host_transport then transport_security sequentially; no toolchain-build/listener +overlap. OOM/timeouts are failures requiring review before any limit change. +Stop processes between bounded journeys, +record measurements and failures. No persistent enabled unit or deployed runtime. + +macOS first uses OS-assigned disposable loopback Elixir/Go TLS/WSS fixtures with +cleanup, separately from Linux real-host evidence. Preserve HTTP1 ALPN/H2 refusal +under narrow optional-client-cert extension. Linux journey must genuinely exercise +private-key TLS identity, enrollment, hello/heartbeat, socket loss/reconnect, staged +rotation/activation, old/pending/revoked/expired refusal. Lost activation ack plus +client process restart uses durably saved pending material after retired predecessor +expiry; label controlled clock/fixture setup explicitly and preserve real TLS. +No runtime/provider/browser/load acceptance is inferred from this finite journey. + +Finally stop only recorded owned transient units/child processes, remove guest private +fixture material, stop/delete owned machine by immutable ID, and verify inventory +no longer contains it. Verify host Docker context/daemon/container inventory unchanged +against entry. Cleanup runs on assertion/setup/timeout failure and records errors; +never report success while owned resources remain. Generic prior fixture evidence +/private/tmp/workharbor-systemd-fixture-iyZ4ILpH supplies smoke/cleanup precedent only. +Review this plan before any guest or disposable listener activation. + + +## T008 bounded corrections and actual transport progress + +All six early identity findings are independently closed. Three later liveness +findings (stale heartbeat revival, caller-snapshot projection race, discarded close +transaction failure) had actual8/11 RED, plus dedicated expiry timer made8/12 RED; +correction27/27 and independent31/31 passed. Evidence: +/private/tmp/workharbor-t008-liveness-closure-l0vh8_uy. Runtime/operation history +remains untouched by the conservative connection-fenced overlay. + +Transport corrections had actual19/22 RED for consumed-Conn propagation and socket +worker lifetime, then37/37 GREEN. Issuer signed oversized DER had15/16 RED, then +44 non-network tests GREEN; independent signed12,320-byte issuer refusal proves +code unconsumed/no identity. Go exact activation response uses independently +signed saved pending positive and per-field negatives; expiry watchdog runs during +blocked IO. First ingress Host-header fixture/setup errors and empty line-filter +runs establish no behavior evidence. Actual TLS startup exposed unsupported peer +options at Bandit top level; the reviewed exact nested placement is now the sole +contract. Default OTP RSA test certificates failed strict Go parsing due missing +RSA SPKI NULL; independent public-only P256 fixture diagnosis and Go CA/chain/ +positive+negative hostname preflight corrected test material without weakening +production verification. + +First genuine Mac Elixir/Go TLS slice4/5 passed; actual reconnect exposed missing +continuous reads during heartbeat wait. A bounded single reader plus cancellation +fixed the real regression (1/1 rerun) and independently passed source/Go review. +Expanded actual matrix10/12 passed; corrected foreign/expired explicit certificate +presentation then2/2 passed. Generic TLS/dial failures never count as server401. +Normal expected application closure is code1008. Actual malformed lower-level +frames exposed recoverable raw bytes in OTP reports; owned total primary redaction +rebuilds only exact agent crash/terminate events. Independent unit3/3 and actual +post-primary wire1/1 prove four fixed events across both OTP report channels. +Full native `mix test --warnings-as-errors` passed170/170 including all12 genuine +Mac journeys (/private/tmp/workharbor-t008-native-full-first.log). Two additional +transaction regressions passed18/18 identity tests: concurrent owner revocation +wins issuance/redemption/renewal/activation, and enrollment audit failure rolls +back code consumption/identity/fingerprint. Evidence: +/private/tmp/workharbor-t008-trust-transaction-races.log. Required inventory now172 +Phoenix tests. Logs remain under /private/tmp/workharbor-t008-*; pristine application +gates, exact candidate and separate Linux evidence remain pending. +T008 stays unchecked; no provider, browser, capacity or pilot acceptance is claimed. + +### Reviewed Linux fixture amendment and ownership + +The bounded outer orbstack Engine info/ps baselines timed out30s then20s; no guest +was created by the author. Root and independent Sol review allow the same isolated +2CPU/2GiB/16GiB fixture using fresh capacity+empty machine inventory and read-only +Docker context/config hashes/metadata while the outer Engine is unavailable. Outer +runtime/BuildKit state remains UNVERIFIED: no unchanged-state claim and no outer +restart/config/context/runtime mutation. Orb management works but shares resources; +management/capacity failure aborts. Repeat bounded outer probes after owned teardown. +Retained sol_systemd_fixture owns ALL guest mutations/bootstrap/ledger/teardown; +source author owns code/Mac tests and sends exact source/input digests separately. +Reviewed bootstrap script SHA256 d93f6a888217761a003cb2465a820da40a482a4fb89801d483ec2c4b6645601d, +/private/tmp/workharbor-t008-linux-bootstrap-1qylii8h/bootstrap.py. +No source transfer or Linux product test is inferred from infrastructure preparation. + +Outer Engine subsequently recovered without repair/restart: native ping/version/info/ps +responded, original BuildKit container retained and running, machine inventory empty. +Root authorized the retained helper to bootstrap the exact reviewed fixture; Linux +product evidence remains separate and pending. Earlier unavailable baseline and +UNVERIFIED evidence are retained as historical outcomes, not rewritten as success. diff --git a/specs/006-whr-cli/contracts/host-connection.fixtures.json b/specs/006-whr-cli/contracts/host-connection.fixtures.json new file mode 100644 index 00000000..52c16a3b --- /dev/null +++ b/specs/006-whr-cli/contracts/host-connection.fixtures.json @@ -0,0 +1,61 @@ +{ + "owner_id": "11111111-1111-4111-8111-111111111111", + "host_id": "22222222-2222-4222-8222-222222222222", + "cases": [ + { + "name": "hello", + "accepted": true, + "raw": "{\"schema_version\":1,\"kind\":\"hello\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\",\"capabilities\":[]}" + }, + { + "name": "hello_ack", + "accepted": true, + "raw": "{\"schema_version\":1,\"kind\":\"hello_ack\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\"}" + }, + { + "name": "heartbeat", + "accepted": true, + "raw": "{\"schema_version\":1,\"kind\":\"heartbeat\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\"}" + }, + { + "name": "heartbeat_ack", + "accepted": true, + "raw": "{\"schema_version\":1,\"kind\":\"heartbeat_ack\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\"}" + }, + { + "name": "version", + "accepted": false, + "raw": "{\"schema_version\":2,\"kind\":\"heartbeat\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\"}" + }, + { + "name": "float", + "accepted": false, + "raw": "{\"schema_version\":1.0,\"kind\":\"heartbeat\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\"}" + }, + { + "name": "foreign", + "accepted": false, + "raw": "{\"schema_version\":1,\"kind\":\"heartbeat\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"11111111-1111-4111-8111-111111111111\"}" + }, + { + "name": "unknown", + "accepted": false, + "raw": "{\"schema_version\":1,\"kind\":\"heartbeat\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\",\"token\":\"SECRET_SENTINEL\"}" + }, + { + "name": "missing", + "accepted": false, + "raw": "{\"schema_version\":1,\"kind\":\"heartbeat\",\"host_id\":\"22222222-2222-4222-8222-222222222222\"}" + }, + { + "name": "command", + "accepted": false, + "raw": "{\"schema_version\":1,\"kind\":\"command\",\"owner_id\":\"11111111-1111-4111-8111-111111111111\",\"host_id\":\"22222222-2222-4222-8222-222222222222\"}" + }, + { + "name": "decoded_duplicate", + "accepted": false, + "raw": "{\"schema_version\":1,\"schema_\\u0076ersion\":1}" + } + ] +} diff --git a/specs/006-whr-cli/contracts/host-connection.schema.json b/specs/006-whr-cli/contracts/host-connection.schema.json new file mode 100644 index 00000000..caf89586 --- /dev/null +++ b/specs/006-whr-cli/contracts/host-connection.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:workharbor:host-connection:1", + "description": "Draft version1; strict decoding, raw/canonical4KiB and trusted peer scope are additional required boundaries.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "kind", + "owner_id", + "host_id", + "capabilities" + ], + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "kind": { + "const": "hello" + }, + "owner_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "host_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "capabilities": { + "enum": [ + [], + [ + "docker" + ] + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "kind", + "owner_id", + "host_id" + ], + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "kind": { + "enum": [ + "hello_ack", + "heartbeat", + "heartbeat_ack" + ] + }, + "owner_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "host_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + } + } + } + ] +} diff --git a/specs/006-whr-cli/contracts/host-enrollment.md b/specs/006-whr-cli/contracts/host-enrollment.md index e6c84be5..ea4aeda3 100644 --- a/specs/006-whr-cli/contracts/host-enrollment.md +++ b/specs/006-whr-cli/contracts/host-enrollment.md @@ -43,10 +43,13 @@ The accepted Bandit HTTP1-only mitigation remains mandatory: Bandit.PhoenixAdapt exact http_1_options=[enabled:true], http_2_options=[enabled:false], explicit boolean activation, no HTTP/redirect, demo secret, debug or reloader. Existing HTTPS allowlist ip/port/certfile/keyfile/http_1_options/http_2_options may gain only -cacerts (one validated internal issuer DER), verify=:verify_peer and -fail_if_no_peer_cert=false as one complete optional-peer profile. Reject partial, -duplicate, malformed or different values and all generic handler, transport, -ALPN, plug or arbitrary overrides. Client roots are loaded from validated native +thousand_island_options containing exactly one transport_options keyword list, +which contains exactly cacerts=[one validated internal issuer DER<=4096bytes], +verify=:verify_peer and fail_if_no_peer_cert=false as one complete optional-peer +profile. Locked Bandit1.12.5 requires this nested placement; top-level peer aliases +are invalid. Reject partial, duplicate, malformed or different values, any other +nested key, preexisting ThousandIsland options in identity_config and all generic +handler, transport, ALPN, plug or arbitrary overrides. Client roots are loaded from validated native issuer state, never token-selected input. No HTTP2 re-enable/dependency clearance is claimed. Existing owner-only profile remains valid when identity is inactive. @@ -151,7 +154,7 @@ success even after old expiry. It cannot revive expired/revoked new identity. One atomic host identity.json contains closed version, endpoint/server-trust reference, known owner/host assignment, active key/cert/issuer, pending null or -key/CSR/cert/issuer/predecessor identity. Initial assignment may be null and cannot +key/CSR/cert/issuer/predecessor identity. Initial owner_id/host_id are empty strings until the verified enrollment response; they cannot connect. Persist pending key/CSR before stage and returned cert/issuer before first activation. Retain BOTH old/new until activation acknowledgement, then atomically promote pending. Startup pending cert first attempts activation/replay; pending @@ -271,3 +274,20 @@ then same independent Sol reviewer fixes/CLEAR. T008 stays unchecked until exact acceptance; Feature001 markers/frozen migration remain unchanged. Full canonical Python is reserved for final combined head unless helper/policy concern arises. No push/PR/release/merge/deploy in this WI. + + +## Runtime logging boundary + +Expected application policy/identity closure uses normal WSS closure with code1008, +without raw input or state in an OTP termination diagnostic. Locked Bandit can +emit non-shutdown gen_server terminate and proc_lib crash reports for malformed +frames; raw masked bytes plus mask key are recoverable. Install an owned, +idempotent native primary logger filter before an identity listener, failing +startup on conflicting filter ownership/install failure. AgentSocket sets one +internal process metadata marker. Only agent-marked gen_server terminate/proc_lib +crash reports, plus the exact locked AgentSocket-state terminate fallback, match. +Rebuild matched events as the fixed agent_transport_refused string, fixed domain, +and optional typed pid/integer time. Retain no report/state/message/log/callback/ +extra metadata. Unmatched events pass unchanged. Synthetic full-event sentinels +and genuine post-primary malformed-frame capture cover both OTP report channels. +This is redaction, never an authentication or generic listener override. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index ee6369f5..f9ee3e34 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -121,8 +121,8 @@ acceptance and task completion rules; review evidence is appended, never fabrica T001 and T002 are independently accepted at the revisions above. T003 is independently accepted at `191c62baf227f64122770244d62d289900bb4d8d`. T004 is independently accepted at -`6abb7419c77b3712a6ac276bc798b102503d3144`. T005 remains unchecked pending -independent exact-candidate review; no product runtime acceptance is claimed. +`6abb7419c77b3712a6ac276bc798b102503d3144`. T005 is independently accepted at +`1189c81ba81b79a47a0485a9d5148fb8286c8eb0`; no product runtime acceptance is claimed. Durable check and review evidence, including earlier failed runs, is retained in [the pilot execution record](../../docs/exec-plans/active/installable-pilot.md). diff --git a/third_party/coder-websocket/LICENSE b/third_party/coder-websocket/LICENSE new file mode 100644 index 00000000..7e79329f --- /dev/null +++ b/third_party/coder-websocket/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2025 Coder + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/third_party/coder-websocket/PROVENANCE.md b/third_party/coder-websocket/PROVENANCE.md new file mode 100644 index 00000000..19ea22ab --- /dev/null +++ b/third_party/coder-websocket/PROVENANCE.md @@ -0,0 +1,14 @@ +# Coder WebSocket provenance + +The host agent pins github.com/coder/websocket v1.8.15. Go standard crypto, TLS, +HTTP and filesystem APIs implement identity and HTTPS; the standard library has +no WebSocket framing client. This maintained context-aware library supplies that +transport, with compression disabled and a 4096-byte message read limit. It adds +no runtime module dependencies. Exact module hashes are retained in +apps/host_agent/go.sum; preserve this ISC notice in later T023 artifact assembly. + +Primary release: https://github.com/coder/websocket/releases/tag/v1.8.15 +Primary security page: https://github.com/coder/websocket/security +Independent Sol review checked both on 2026-10-14 and found no advisories published +on that upstream security page. This is a bounded source/provenance check, not a +vulnerability clearance. Existing Bandit mitigation remains separately scoped. From c4365c06bc5644732939028003ac034956bdbc88 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 23:03:02 +0200 Subject: [PATCH 21/51] fix(hosts): prove atomic identity persistence recovery Exercise native and Go identity transitions under deterministic syscall failures and fresh reloads. Propagate native close failures so uncertain durability never reports success, and record predecessor real-host evidence without accepting a changed candidate. Refs: #72 Co-authored-by: Codex --- .../lib/workharbor/hosts/private_file.ex | 46 ++- .../test/support/host_file_fault.ex | 43 +++ .../test/workharbor/host_identity_test.exs | 37 +++ .../internal/identity/durability_test.go | 309 ++++++++++++++++++ .../internal/identity/durable_file.go | 28 ++ apps/host_agent/internal/identity/identity.go | 8 +- docs/development/ci-acceptance.json | 7 +- docs/exec-plans/active/installable-pilot.md | 45 ++- docs/quality.md | 54 ++- 9 files changed, 554 insertions(+), 23 deletions(-) create mode 100644 apps/control_plane/test/support/host_file_fault.ex create mode 100644 apps/host_agent/internal/identity/durability_test.go create mode 100644 apps/host_agent/internal/identity/durable_file.go diff --git a/apps/control_plane/lib/workharbor/hosts/private_file.ex b/apps/control_plane/lib/workharbor/hosts/private_file.ex index b41ddc58..a9e9056d 100644 --- a/apps/control_plane/lib/workharbor/hosts/private_file.ex +++ b/apps/control_plane/lib/workharbor/hosts/private_file.ex @@ -61,11 +61,11 @@ defmodule WorkHarbor.Hosts.PrivateFile do try do with {:ok, fd} <- - :file.open(String.to_charlist(temporary), [:write, :binary, :exclusive, :raw]), + io().open(String.to_charlist(temporary), [:write, :binary, :exclusive, :raw]), :ok <- File.chmod(temporary, 0o600), result <- write_close(fd, bytes), :ok <- result, - :ok <- :file.make_link(String.to_charlist(temporary), String.to_charlist(path)), + :ok <- io().make_link(String.to_charlist(temporary), String.to_charlist(path)), :ok <- sync_directory(Path.dirname(path)), do: :ok, else: ( @@ -79,23 +79,43 @@ defmodule WorkHarbor.Hosts.PrivateFile do end defp write_close(fd, bytes) do - try do - with :ok <- :file.write(fd, bytes), do: :file.sync(fd) - after - :file.close(fd) - end + result = + try do + with :ok <- io().write(fd, bytes), do: io().sync(fd) + rescue + _ -> {:error, :eio} + catch + _, _ -> {:error, :eio} + end + + closed = io().close(fd) + if result == :ok, do: closed, else: result end defp sync_directory(dir) do - with {:ok, fd} <- :file.open(String.to_charlist(dir), [:read, :raw, :directory]) do - try do - :file.sync(fd) - after - :file.close(fd) - end + with {:ok, fd} <- io().open(String.to_charlist(dir), [:read, :raw, :directory]) do + result = + try do + io().sync(fd) + rescue + _ -> {:error, :eio} + catch + _, _ -> {:error, :eio} + end + + closed = io().close(fd) + if result == :ok, do: closed, else: result end end + # The process-local fault selector is compiled only into test builds. The + # production module has only the native :file implementation, no hook/config. + if Mix.env() == :test do + defp io, do: Process.get({__MODULE__, :test_io}, :file) + else + defp io, do: :file + end + # OTP has no public geteuid BIF. Fixed POSIX id has no caller-controlled argv, # shell, secret or network; it verifies the effective service account. defp uid do diff --git a/apps/control_plane/test/support/host_file_fault.ex b/apps/control_plane/test/support/host_file_fault.ex new file mode 100644 index 00000000..e8c0c144 --- /dev/null +++ b/apps/control_plane/test/support/host_file_fault.ex @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostFileFault do + def open(path, flags) do + result = + fail(if(:directory in flags, do: :parent_open, else: :file_open), fn -> + :file.open(path, flags) + end) + + case result do + {:ok, fd} -> + Process.put({__MODULE__, fd}, if(:directory in flags, do: :parent, else: :file)) + + _ -> + :ok + end + + result + end + + def write(fd, bytes) do + if Process.get({__MODULE__, :stage}) == :write do + :ok = :file.write(fd, binary_part(bytes, 0, div(byte_size(bytes), 2))) + {:error, :eio} + else + :file.write(fd, bytes) + end + end + + def sync(fd), do: fail({Process.get({__MODULE__, fd}), :sync}, fn -> :file.sync(fd) end) + + def close(fd) do + kind = Process.delete({__MODULE__, fd}) + result = :file.close(fd) + fail({kind, :close}, fn -> result end) + end + + def make_link(a, b), do: fail(:link, fn -> :file.make_link(a, b) end) + + defp fail(stage, run) do + if Process.get({__MODULE__, :stage}) == stage, do: {:error, :eio}, else: run.() + end +end diff --git a/apps/control_plane/test/workharbor/host_identity_test.exs b/apps/control_plane/test/workharbor/host_identity_test.exs index e5baae52..ba8beaee 100644 --- a/apps/control_plane/test/workharbor/host_identity_test.exs +++ b/apps/control_plane/test/workharbor/host_identity_test.exs @@ -445,4 +445,41 @@ defmodule WorkHarbor.HostIdentityTest do Hosts.sql("DROP TRIGGER refuse_enrollment_audit") assert {:ok, _} = Hosts.redeem(issued.code, csr) end + + test "native issuer persistence faults return fixed errors and restart only complete exclusive state", + c do + for stage <- [ + :file_open, + :write, + {:file, :sync}, + {:file, :close}, + :link, + :parent_open, + {:parent, :sync}, + {:parent, :close} + ] do + dir = Path.join(c.dir, "fault-#{Ecto.UUID.generate()}") + Process.put({WorkHarbor.Hosts.PrivateFile, :test_io}, WorkHarbor.HostFileFault) + Process.put({WorkHarbor.HostFileFault, :stage}, stage) + + try do + assert {:error, :persistence_failed} = Issuer.initialize(dir) + after + Process.delete({WorkHarbor.Hosts.PrivateFile, :test_io}) + Process.delete({WorkHarbor.HostFileFault, :stage}) + end + + if stage in [:parent_open, {:parent, :sync}, {:parent, :close}] do + assert {:ok, first} = Issuer.load(dir) + assert {:error, :already_initialized} = Issuer.initialize(dir) + assert {:ok, second} = Issuer.load(dir) + assert first.certificate == second.certificate and first.key == second.key + else + refute File.exists?(Path.join(dir, "issuer.json")) + assert {:ok, _} = Issuer.initialize(dir) + end + + assert File.ls!(dir) == ["issuer.json"] + end + end end diff --git a/apps/host_agent/internal/identity/durability_test.go b/apps/host_agent/internal/identity/durability_test.go new file mode 100644 index 00000000..e967ed75 --- /dev/null +++ b/apps/host_agent/internal/identity/durability_test.go @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: EUPL-1.2 +package identity + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// The fault wrappers retain real private files and syscall ordering. Only the +// selected syscall fails; no production environment or network hook exists. +type faultFile struct { + durableFile + stage, kind string +} + +func (f faultFile) Write(b []byte) (int, error) { + if f.stage == "write" { + n, _ := f.durableFile.Write(b[:len(b)/2]) + return n, os.ErrPermission + } + return f.durableFile.Write(b) +} +func (f faultFile) Sync() error { + if f.stage == f.kind+"_sync" { + return os.ErrPermission + } + return f.durableFile.Sync() +} +func (f faultFile) Close() error { + e := f.durableFile.Close() + if f.stage == f.kind+"_close" { + return os.ErrPermission + } + return e +} +func failFiles(t *testing.T, stage string) func() { + t.Helper() + before := identityFiles + ops := before + ops.createTemp = func(d, p string) (durableFile, error) { + if stage == "create" { + return nil, os.ErrPermission + } + f, e := before.createTemp(d, p) + return faultFile{f, stage, "file"}, e + } + ops.openDir = func(d string) (durableFile, error) { + if stage == "parent_open" { + return nil, os.ErrPermission + } + f, e := before.openDir(d) + return faultFile{f, stage, "parent"}, e + } + ops.rename = func(a, b string) error { + if stage == "rename" { + return os.ErrPermission + } + return before.rename(a, b) + } + ops.link = func(a, b string) error { + if stage == "link" { + return os.ErrPermission + } + return before.link(a, b) + } + identityFiles = ops + reset := func() { identityFiles = before } + t.Cleanup(reset) + return reset +} +func savedFixture(t *testing.T) (string, *State, func(*State) Response) { + t.Helper() + dir := filepath.Join(physicalTemp(t), "identity") + if e := os.Mkdir(dir, 0700); e != nil { + t.Fatal(e) + } + pub, key, _ := ed25519.GenerateKey(rand.Reader) + now := time.Now().Add(-time.Minute).Truncate(time.Second) + ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "WorkHarbor client issuer"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(-time.Hour).Add(5 * 365 * 24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key) + if e != nil { + t.Fatal(e) + } + issuer, e := x509.ParseCertificate(der) + if e != nil { + t.Fatal(e) + } + s := &State{SchemaVersion: 1, Endpoint: "https://localhost:8443", OwnerID: "11111111-1111-4111-8111-111111111111", HostID: "22222222-2222-4222-8222-222222222222"} + owner, host := s.OwnerID, s.HostID + response := func(v *State) Response { + csrDER, e := base64.StdEncoding.DecodeString(v.Pending.CSR) + if e != nil { + t.Fatal(e) + } + csr, e := x509.ParseCertificateRequest(csrDER) + if e != nil { + t.Fatal(e) + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: host}, NotBefore: now, NotAfter: now.Add(24 * time.Hour), BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}} + cert, e := x509.CreateCertificate(rand.Reader, leaf, issuer, csr.PublicKey, key) + if e != nil { + t.Fatal(e) + } + return Response{SchemaVersion: 1, OwnerID: owner, HostID: host, IdentityID: "44444444-4444-4444-8444-444444444444", Certificate: base64.StdEncoding.EncodeToString(cert), IssuerCertificate: base64.StdEncoding.EncodeToString(der), ExpiresAt: leaf.NotAfter.Unix()} + } + pending, e := newPending(s.HostID, "") + if e != nil { + t.Fatal(e) + } + s.Pending = pending + m, e := acceptResponse(s, response(s)) + if e != nil { + t.Fatal(e) + } + m.IdentityID = "33333333-3333-4333-8333-333333333333" + s.Active = m + s.Pending = nil + if e := save(dir, s, true); e != nil { + t.Fatal(e) + } + return dir, s, response +} +func TestAtomicIdentityPersistenceFailuresAndRestartTransitions(t *testing.T) { + // A separate test process has no caller memory or installed fault wrappers. + // Only the private path is passed, never credential bytes or private keys. + if dir := os.Getenv("WORKHARBOR_TEST_DURABLE_RELOAD"); dir != "" { + if _, e := Load(dir); e != nil { + t.Fatal("independent durable reload refused") + } + return + } + + for _, transition := range []string{"accept_enrollment", "prepare_renewal", "accept_pending", "promote"} { + for _, stage := range []string{"create", "write", "file_sync", "file_close", "rename", "parent_open", "parent_sync", "parent_close"} { + t.Run(transition+"/"+stage, func(t *testing.T) { + dir, s, response := savedFixture(t) + var e error + var r Response + if transition == "accept_enrollment" { + dir = filepath.Join(filepath.Dir(dir), "enrollment") + s, e = Prepare(dir, "https://localhost:8443", "") + if e != nil { + t.Fatal(e) + } + r = response(s) + } + if transition != "prepare_renewal" && transition != "accept_enrollment" { + if e := PrepareRenewal(dir, s); e != nil { + t.Fatal(e) + } + r = response(s) + } + if transition == "promote" { + if e := AcceptPending(dir, s, r); e != nil { + t.Fatal(e) + } + } + beforeBytes, e := os.ReadFile(filepath.Join(dir, "identity.json")) + if e != nil { + t.Fatal(e) + } + var before State + if e = json.Unmarshal(beforeBytes, &before); e != nil { + t.Fatal(e) + } + apply := func(v *State) error { + switch transition { + case "accept_enrollment": + return AcceptEnrollment(dir, v, r) + case "prepare_renewal": + return PrepareRenewal(dir, v) + case "accept_pending": + return AcceptPending(dir, v, r) + default: + return Promote(dir, v) + } + } + reset := failFiles(t, stage) + e = apply(s) + reset() + if !errors.Is(e, ErrIdentity) { + t.Fatalf("%s did not return fixed persistence refusal", stage) + } + if !reflect.DeepEqual(s, &before) { + t.Fatal("failed commit reported in-memory success") + } + executable, e := os.Executable() + if e != nil { + t.Fatal(e) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + process := exec.CommandContext(ctx, executable, "-test.run=^TestAtomicIdentityPersistenceFailuresAndRestartTransitions$") + process.Env = []string{"WORKHARBOR_TEST_DURABLE_RELOAD=" + dir} + output, childErr := process.CombinedOutput() + cancel() + if childErr != nil || !strings.Contains(string(output), "PASS") { + t.Fatal("independent restart did not load complete valid state") + } + loaded, e := Load(dir) + if e != nil { + t.Fatal("restart lost complete valid identity") + } + afterBytes, e := os.ReadFile(filepath.Join(dir, "identity.json")) + if e != nil { + t.Fatal(e) + } + committed := stage == "parent_open" || stage == "parent_sync" || stage == "parent_close" + if !committed { + if string(beforeBytes) != string(afterBytes) { + t.Fatal("pre-commit failure replaced durable identity") + } + } else { + switch transition { + case "accept_enrollment": + if loaded.Pending != nil || loaded.OwnerID != r.OwnerID || loaded.HostID != r.HostID || loaded.Active == nil || loaded.Active.IdentityID != r.IdentityID || loaded.Active.PrivateKey != before.Pending.PrivateKey { + t.Fatal("partial enrollment assignment/key promotion") + } + case "prepare_renewal": + if loaded.Pending == nil || loaded.Pending.PredecessorIdentityID != before.Active.IdentityID { + t.Fatal("lost complete pending possession") + } + case "accept_pending": + if loaded.Pending == nil || loaded.Pending.Certificate != r.Certificate || loaded.Pending.PrivateKey != before.Pending.PrivateKey { + t.Fatal("lost issued pending key") + } + case "promote": + if loaded.Pending != nil || loaded.Active.IdentityID != r.IdentityID || loaded.Active.PrivateKey != before.Pending.PrivateKey { + t.Fatal("partial promotion") + } + } + } + // Re-enter from disk, never the failed caller's old in-memory state. A fully + // promoted state is already recoverable; all other stages safely retry. + if !(committed && (transition == "promote" || transition == "accept_enrollment")) { + oldKey := "" + if loaded.Pending != nil { + oldKey = loaded.Pending.PrivateKey + } + if e = apply(loaded); e != nil { + t.Fatal("restart cannot retry transition") + } + again, e := Load(dir) + if e != nil { + t.Fatal(e) + } + if transition == "accept_enrollment" && (again.Active == nil || again.Active.PrivateKey != oldKey) { + t.Fatal("enrollment retry replaced pending possession") + } + if oldKey != "" && transition != "promote" && transition != "accept_enrollment" && (again.Pending == nil || again.Pending.PrivateKey != oldKey) { + t.Fatal("retry replaced saved possession") + } + } + files, e := filepath.Glob(filepath.Join(dir, ".identity-*")) + if e != nil || len(files) != 0 { + t.Fatal("temporary private material retained") + } + }) + } + } +} +func TestInitialPossessionDurabilityFailureNeverReportsSuccess(t *testing.T) { + for _, stage := range []string{"create", "write", "file_sync", "file_close", "link", "parent_open", "parent_sync", "parent_close"} { + t.Run(stage, func(t *testing.T) { + dir := filepath.Join(physicalTemp(t), "identity") + reset := failFiles(t, stage) + s, e := Prepare(dir, "https://localhost:8443", "") + reset() + if s != nil || !errors.Is(e, ErrIdentity) { + t.Fatal("failed initial persist claimed success") + } + post := stage == "parent_open" || stage == "parent_sync" || stage == "parent_close" + loaded, e := Load(dir) + if post { + if e != nil || loaded.Active != nil || loaded.Pending == nil { + t.Fatal("restart lost whole initial pending identity") + } + bytes, _ := os.ReadFile(filepath.Join(dir, "identity.json")) + if _, e = Prepare(dir, "https://localhost:8443", ""); e == nil { + t.Fatal("replaced uncertain committed identity") + } + after, _ := os.ReadFile(filepath.Join(dir, "identity.json")) + if string(bytes) != string(after) { + t.Fatal("exclusive retry overwrote identity") + } + } else { + if e == nil { + t.Fatal("pre-commit failure created identity") + } + if _, e = Prepare(dir, "https://localhost:8443", ""); e != nil { + t.Fatal("clean retry failed") + } + } + }) + } +} diff --git a/apps/host_agent/internal/identity/durable_file.go b/apps/host_agent/internal/identity/durable_file.go new file mode 100644 index 00000000..3c7b6d80 --- /dev/null +++ b/apps/host_agent/internal/identity/durable_file.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: EUPL-1.2 +package identity + +import "os" + +// These unexported native defaults have no environment, wire or configuration +// override. Package tests wrap individual syscalls to prove durability failures; +// they restore the table and never run those tests in parallel. +type durableFile interface { + Name() string + Chmod(os.FileMode) error + Write([]byte) (int, error) + Sync() error + Close() error +} +type durableFiles struct { + createTemp func(string, string) (durableFile, error) + openDir func(string) (durableFile, error) + rename func(string, string) error + link func(string, string) error +} + +var identityFiles = durableFiles{ + createTemp: func(dir, pattern string) (durableFile, error) { return os.CreateTemp(dir, pattern) }, + openDir: func(dir string) (durableFile, error) { return os.Open(dir) }, + rename: os.Rename, + link: os.Link, +} diff --git a/apps/host_agent/internal/identity/identity.go b/apps/host_agent/internal/identity/identity.go index 5c5d3c66..564159de 100644 --- a/apps/host_agent/internal/identity/identity.go +++ b/apps/host_agent/internal/identity/identity.go @@ -135,7 +135,7 @@ func save(dir string, state *State, exclusive bool) error { if e != nil || len(bytes) > 32768 { return ErrIdentity } - file, e := os.CreateTemp(dir, ".identity-") + file, e := identityFiles.createTemp(dir, ".identity-") if e != nil { return ErrIdentity } @@ -155,14 +155,14 @@ func save(dir string, state *State, exclusive bool) error { return ErrIdentity } if exclusive { - e = os.Link(temporary, path) + e = identityFiles.link(temporary, path) } else { - e = os.Rename(temporary, path) + e = identityFiles.rename(temporary, path) } if e != nil { return ErrIdentity } - parent, e := os.Open(dir) + parent, e := identityFiles.openDir(dir) if e != nil { return ErrIdentity } diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 570dcd22..0070d719 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -566,7 +566,9 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestExpiryWatchdogCancelsBlockedIO", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess" ], "api_client": [ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", @@ -746,7 +748,8 @@ "WorkHarborWeb.AgentLogFilterTest/test filter is total and leaves unrelated events unchanged", "WorkHarborWeb.AgentLogFilterTest/test owned filter installation is idempotent and refuses a conflicting owner", "WorkHarbor.HostIdentityTest/test serialized owner revocation wins concurrent issuance redemption renewal and activation", - "WorkHarbor.HostIdentityTest/test enrollment audit failure rolls back consumption identity and active fingerprint" + "WorkHarbor.HostIdentityTest/test enrollment audit failure rolls back consumption identity and active fingerprint", + "WorkHarbor.HostIdentityTest/test native issuer persistence faults return fixed errors and restart only complete exclusive state" ] } } diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 9e0b02d9..939b2ff5 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -14,7 +14,8 @@ accepted after independent Sol CLEAR of `d3d84c8361a1389292fad96d02a828ac9fabc2e T002 is independently accepted at `bc18034164597aa841bea561cf6a882e9c0715e2`. T003 is independently accepted at `191c62baf227f64122770244d62d289900bb4d8d`. T004 is independently accepted at `6abb7419c77b3712a6ac276bc798b102503d3144`. -T005 foundation implementation is in progress; exact-candidate review remains open. +T005 foundation independently accepted at1189c81ba81b79a47a0485a9d5148fb8286c8eb0; +T006/T007/S001 are accepted prerequisites. T008 exact-candidate review remains open. No product runtime acceptance is claimed. Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; @@ -969,3 +970,45 @@ responded, original BuildKit container retained and running, machine inventory e Root authorized the retained helper to bootstrap the exact reviewed fixture; Linux product evidence remains separate and pending. Earlier unavailable baseline and UNVERIFIED evidence are retained as historical outcomes, not rewritten as success. + +### T008 exact candidate and durability correction + +Candidate e42fe27ec353d262dcd2dd3496e9fbc98cc56937 passed fresh isolated fixed gates: +29commands, Phoenix172, Go host198/API162/CLI1 events, all prior required IDs retained, +Python846collection/520required. Author evidence: +/private/tmp/workharbor-t008-validation-psmexjz6/evidence.json; independent: +/private/tmp/workharbor-t008-independent-zxtay0dp/summary.json. All12 genuine Mac +TLS/WSS journeys passed in each full native run. Post-test tracked bytes and +pristine source passed after deleting only generated scratch nested Git dependencies; +initial cleanup-tool failure is retained. The wrapper's initially wrong foundation +filename proof was corrected against actual owner_foundation/owner_authentication +migrations plus001tasks; all unchanged. Fixed POSIX UID subprocess broken-pipe +output during intentional teardown is retained, not authentication evidence. + +Independent exact review found the required file-failure/restart proof missing. +Go now tests real temporary files and actual AcceptEnrollment/PrepareRenewal/AcceptPending/Promote +and initial possession across partial-write, file sync/close, rename/link, parent +open/sync/close failures. Unexported native-default syscall wrappers have no external +configuration. Initial missing-seam compile failure is explicitly instrumentation +absence, not executed behavior RED. Existing Go error handling passed all32 initial fault +subcases on first wiring; the final matrix has40 subcases including AcceptEnrollment +plus a separate fresh reload process at each transition boundary. Native test-only process-local +IO instrumentation exposed original ignored file-close error:18/19 behavior RED +(/private/tmp/workharbor-t008-durability-native-close-red.log). Close failures now +propagate fixed persistence_failed, including parent close;19/19 identity tests +pass. Production native module uses only :file, with no test selector. Restart +retains one whole OLD or NEW valid state at its atomic commit boundary, never +claims successful durability after a failed sync, and never replaces uncertain +exclusive issuer/key possession. Required inventory now Phoenix173/Go host41topIDs. + +Predecessor e42 genuine isolated Ubuntu Linux passed12 transport journeys (81.2s, +83.31s unit wall,216391680-byte memory peak) and9 transport-security tests (4.5s, +6.185s unit wall,141455360-byte peak); all349 archive file hashes/modes unchanged. +/private/tmp/workharbor-t008-linux-bootstrap-1qylii8h/amended-run/transport/assessment.json +records actual outcomes and limitations. Owned guest01M4Y2J3CZXQ4JR69E1QGEY0QV was +deleted and inventory empty; outer Engine/original BuildKit responding and config +metadata preserved in that observation window, historical hang continuity UNVERIFIED. +Effective UID/group and cgroup ancestor privilege/escape measurements were missing; +no complete resource isolation proof is claimed. Docker absent/N/A for this TLS-only +fixture. Corrected product runtime requires a fresh exact archive/Linux run and +same independent review; predecessor results do not accept the changed candidate. diff --git a/docs/quality.md b/docs/quality.md index 38b107e7..01ba1ff8 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -1,6 +1,6 @@ # Quality register -Reviewed: 2026-10-12. Owner: project maintainer. +Reviewed: 2026-10-14. Owner: project maintainer. | Area | Evidence | Status / next gate | | --- | --- | --- | @@ -8,11 +8,59 @@ Reviewed: 2026-10-12. Owner: project maintainer. | Domain and ownership | Constitution, ADRs, contracts, feature requirements | Design only | | Repository harness | Links/JSON/boundary checks plus negative regression tests | Executable | | Feature 004 local automation | [T017 evidence](../specs/004-speckit-symphony-automation/quickstart.md#t017-combined-local-acceptance): 422 qualified cases at `ebe4833`; raw 41/five failures separately | T001–T017 local tooling/fixture acceptance; T018–T020 pending, live guard/provider/isolation/safe-label gates blocked | -| Product runtime | No Phoenix/Go applications yet | Feature 001 implementation pending | +| Product runtime | Pinned Phoenix/Go applications; accepted006 foundation/auth/protocol and HTTP1 mitigation; T008 exact candidate tests below | Lifecycle/provider/browser/installable-pilot acceptance pending | | Docker / persistence / PTY | Acceptance scenarios only | Real-host tests required | | Symphony | Pinned workflow and operator runbook | Not installed or activated | -| Resource footprint | No comparable runtime workloads | Measure after T001 | +| Resource footprint | Bounded T008 loopback TLS fixtures measured below | No capacity/load acceptance | | License | EUPL-1.2 project license; upstream MIT notice retained | Recorded | Do not upgrade a design status to implemented without evidence. Review this register with every release; convert recurring drift into small, targeted fixes. + +## T008 genuine host transport evidence (candidate, not completion) + +Exact predecessor source `e42fe27ec353d262dcd2dd3496e9fbc98cc56937` has independent +fresh source/manifest/format/vet/build/compile/assets/full-test proof: +`/private/tmp/workharbor-t008-independent-zxtay0dp/summary.json`; author evidence +`/private/tmp/workharbor-t008-validation-psmexjz6/evidence.json`. Both full native +runs passed172Phoenix tests including12 genuine macOS Elixir/Go TLS/WSS journeys, +plus198host/162API/1CLI Go events. Python846collection/520required retained; full +canonical Python suite was not repeated for this WI. All prior IDs and frozen +Foundation migrations/Feature001tasks remain unchanged. + +The source-only Linux archive SHA256 is +`c87eae400c6cbffe838fc19a517efd13060babde505f27ad4c244f2446694acd`; +manifest SHA256 `771b5ad98856248e37a0076b7a0b6eb2e8b2a24bf0d35f21d32b592aece6783b`. +Authorized isolated Ubuntu24.04 arm64 guest ran OTP29.1.1, Elixir1.20.4-otp29 and +Go1.27.2 linux/arm64, with both endpoints on guest loopback. Exact commands: +`MIX_ENV=test mix test test/workharbor_web/host_transport_test.exs --warnings-as-errors --seed 0` +then `mix test test/workharbor_web/transport_security_test.exs --warnings-as-errors --seed 0`. +Actual Linux results:12/12 in81.2s and9/9 in4.5s. Their512MiB aggregate control groups +peaked at216391680 and141455360bytes respectively. All349 transferred file hashes +and modes matched before preparation and after tests. Evidence: +`/private/tmp/workharbor-t008-linux-bootstrap-1qylii8h/amended-run/transport/assessment.json`. + +Production Go enrollment, hello/heartbeat, worker-death reconnect with a new +non-null connected slot, stale after30s, renewal/activation/revoke socket fencing, +idle certificate expiry, and pending crash recovery after retired predecessor +expiry were exercised over real TLS/WSS. Actual negative cases cover absent, +pending, foreign, unregistered, retired, revoked and expired identities, wrong +server root/name, exact wire bounds/types/scope/version, owner-bearer refusal and +HTTP2 refusal with owner HTTP1 preserved. Explicit certificate presentation plus +actual401 or named remote TLS alert is required; generic dial failure is not +server refusal. Controlled near-expiry issuance and discarded activation ack are +labeled fixture injections; TLS peer verification is genuine. Native test P256 +server trust preflight corrected OTP RSA SPKI fixture incompatibility without +weakening production trust. Recoverable masked raw bytes in OTP crash reports +were fixed with narrowly scoped primary filtering and checked after the filter +across gen_server/proc_lib events; fixed error events and UID-child broken-pipe +teardown output are retained. + +The owned guest was deleted and inventory empty. Outer Engine/original BuildKit +responded afterward; historical hang continuity remains UNVERIFIED. This first +Linux run omitted effective UID/groups and ancestor cgroup privilege measurements; +no complete no-escape isolation proof is claimed. Docker is absent/N/A for TLS-only +transport; no Docker workspace/provider, browser, loaded capacity, release or pilot +acceptance follows. Independent review also required additional deterministic +filesystem durability/restart tests; those change runtime blobs, so a fresh exact +candidate/Linux run and final independent acceptance remain pending. T008 is unchecked. From 3822cb2e651513030a69ce4f30ca50985c321bce Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 23:42:17 +0200 Subject: [PATCH 22/51] docs(hosts): record accepted enrollment transport evidence Bind T008 completion to the independently accepted 64541c1 runtime and measured macOS/Linux transport evidence, preserving failed attempts and fixture limitations. Keep this later documentation head distinct from the Linux-executed source. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 61 ++++++++++++++- docs/quality.md | 75 ++++++++++++++++++- .../006-whr-cli/contracts/host-enrollment.md | 7 +- specs/006-whr-cli/tasks.md | 25 ++++++- 4 files changed, 157 insertions(+), 11 deletions(-) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 939b2ff5..89eed899 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -15,7 +15,9 @@ T002 is independently accepted at `bc18034164597aa841bea561cf6a882e9c0715e2`. T003 is independently accepted at `191c62baf227f64122770244d62d289900bb4d8d`. T004 is independently accepted at `6abb7419c77b3712a6ac276bc798b102503d3144`. T005 foundation independently accepted at1189c81ba81b79a47a0485a9d5148fb8286c8eb0; -T006/T007/S001 are accepted prerequisites. T008 exact-candidate review remains open. +T006/T007/S001 are accepted prerequisites. T008 implementation and bounded +macOS/Linux transport are independently accepted at +64541c1fa8b1f0d9f353272a0ea8ee6888ea5f5c; documentation handoff review is separate. No product runtime acceptance is claimed. Baseline: `f02250fc75b1e72a1d949edaee128e31fffbaa53` (current design baseline). The migration oracle remains `fd0019b7711438a2dbf7255ce3a43572d5a3a6fd`; @@ -1012,3 +1014,60 @@ Effective UID/group and cgroup ancestor privilege/escape measurements were missi no complete resource isolation proof is claimed. Docker absent/N/A for this TLS-only fixture. Corrected product runtime requires a fresh exact archive/Linux run and same independent review; predecessor results do not accept the changed candidate. + +### T008 accepted corrected runtime and documentation handoff + +Independent exact application/source review and separately measured Linux evidence +are CLEAR for64541c1fa8b1f0d9f353272a0ea8ee6888ea5f5c, with no open material code +findings. Author29-command pristine proof is +/private/tmp/workharbor-t008-validation-dho78ci4/evidence.json; independent proof: +/private/tmp/workharbor-t008-independent-645-kb8o8fqu/summary.json. Both passed +173Phoenix tests including all12 actual MacTLS journeys,240host/162API/1CLI Go +events, all required IDs,846Python collection/520required and unchanged frozen +migrations/tasks/vectors. Native full-suite times were94.0s author/94.4s independent. +Full canonical Python remains reserved for final combined head. + +Second Linux attempt for645 stopped BEFORE mix with guard125 because PolicyKit +was absent despite a system bus. Zero candidate tests ran; all352 source hashes/ +modes matched and owned guest01M4Y478XAVKYYJNJ0J7B8RRKS was deleted. Failure evidence: +/private/tmp/workharbor-t008-linux-rerun-plan/amended-run/transport/assessment.json. +No policy package or weakened/cap-increased workaround was used. A narrowly reviewed +systemd255.4 absent/nonactivatable-PolicyKit proof required typed AccessDenied for +empty properties on the exact owned loaded unit, unchanged required properties, +nonwritable cgroup ancestors and measured dedicated UID/groups/capabilities. + +Fresh corrected Linux actual12transport/9security tests then passed81.7s/4.7s +(83.573s/8.33s unit wall). Aggregate peaks269557760B/161996800B;321/23 live/postexit +samples and final OOM counters all zero. Both units measured UID/EUID999, GID990, +groups[990], zero effective/permitted/ambient caps,12 nonwritable ancestor paths, +memory.max536870912 and cpu.max100000/100000; root validated600s/512MiB/100%CPU/ +control-group limits and no delegation before release. NNP/ProtectControlGroups +were actuallyno, not claimed enabled. Guest root2CPU/2GiB caps were measured; +16GiB disk was configured but exhaustion not independently measured on shared backing. +This bounded fixture is not capacity/load or broad host-isolation acceptance. + +Exact input SHA256s: + +- source archive:1ff20623b9b1bafe5adcce9487a597784f655187409a7b3c68a4727d387f012a +- manifest:a8ebba13d392ccf85375796628d04d0456379875b23cd3b46c79c3fcbdcd60c4 +- bootstrap:d93f6a888217761a003cb2465a820da40a482a4fb89801d483ec2c4b6645601d +- executor:98f7de450e01c563128d774a44294146df0c31c89f44134abe69b68810010c6d +- guard:8bd2e99b085ffeb90c182d282da32e9d3ef7f3ed0d784f6308ba35502b9d1a21 + +All352 original source hashes/modes matched before/prepared/after/final. Assessment: +/private/tmp/workharbor-t008-linux-absent-pk-plan/amended-run/transport/assessment.json; +independent evidence:/private/tmp/workharbor-t008-independent-linux-645.json. +Owned guest01M4Y58JYJB5CEVCRHQD81R0ZP was deleted2026-10-14T21:37:44UTC, inventory[]. +Six outer probes passed; exact original BuildKit ID/image/rwvolume remained running +and context/config hashes matched in that window. Historical hang continuity stays +UNVERIFIED. Already-unloaded transient unit stop exit5 and UID-child broken-pipe +output are retained/classified; they do not establish auth or test failure. No +outer runtime/config repair or mutation occurred. + +Only Feature006 T008 is newly checked after independent runtime acceptance. The +final consolidation changes documentation only (quality/execution/tasks/contract +initial-ID wording); it does not claim Linux executed that later SHA. Exact final +head review must verify all exercised source/config/dependency/journey/input blobs +are unchanged from645, plus pristine/source/manifest/whitespace gates. Feature001 +markers/frozen migrations remain unchanged. S001 Bandit mitigation-only advisory, +provider/browser/load/installable-pilot/release gates remain separate. diff --git a/docs/quality.md b/docs/quality.md index 01ba1ff8..51f6e94d 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -5,10 +5,10 @@ Reviewed: 2026-10-14. Owner: project maintainer. | Area | Evidence | Status / next gate | | --- | --- | --- | | Spec Kit | Upstream CLI-generated assets, pinned provenance | Scaffold ready | -| Domain and ownership | Constitution, ADRs, contracts, feature requirements | Design only | +| Domain and ownership | Constitution, ADRs, contracts and accepted foundation/auth/protocol | Lifecycle/provider integration pending | | Repository harness | Links/JSON/boundary checks plus negative regression tests | Executable | | Feature 004 local automation | [T017 evidence](../specs/004-speckit-symphony-automation/quickstart.md#t017-combined-local-acceptance): 422 qualified cases at `ebe4833`; raw 41/five failures separately | T001–T017 local tooling/fixture acceptance; T018–T020 pending, live guard/provider/isolation/safe-label gates blocked | -| Product runtime | Pinned Phoenix/Go applications; accepted006 foundation/auth/protocol and HTTP1 mitigation; T008 exact candidate tests below | Lifecycle/provider/browser/installable-pilot acceptance pending | +| Product runtime | Accepted foundation/auth/protocol, HTTP1 mitigation and bounded T008 transport at64541c1; evidence below | Lifecycle/provider/browser/installable-pilot acceptance pending | | Docker / persistence / PTY | Acceptance scenarios only | Real-host tests required | | Symphony | Pinned workflow and operator runbook | Not installed or activated | | Resource footprint | Bounded T008 loopback TLS fixtures measured below | No capacity/load acceptance | @@ -17,7 +17,7 @@ Reviewed: 2026-10-14. Owner: project maintainer. Do not upgrade a design status to implemented without evidence. Review this register with every release; convert recurring drift into small, targeted fixes. -## T008 genuine host transport evidence (candidate, not completion) +## T008 genuine host transport evidence (historical predecessor) Exact predecessor source `e42fe27ec353d262dcd2dd3496e9fbc98cc56937` has independent fresh source/manifest/format/vet/build/compile/assets/full-test proof: @@ -63,4 +63,71 @@ no complete no-escape isolation proof is claimed. Docker is absent/N/A for TLS-o transport; no Docker workspace/provider, browser, loaded capacity, release or pilot acceptance follows. Independent review also required additional deterministic filesystem durability/restart tests; those change runtime blobs, so a fresh exact -candidate/Linux run and final independent acceptance remain pending. T008 is unchecked. +candidate/Linux run and final independent acceptance were pending at this predecessor +milestone. The accepted corrected runtime is recorded below. + +## T008 accepted corrected transport scope + +Implementation/runtime source **64541c1fa8b1f0d9f353272a0ea8ee6888ea5f5c** is +independently Sol CLEAR with no open material code findings. The later documentation +head retains identical exercised source/config/dependency/journey/input blobs; +Linux executed 64541c1, not that later SHA. Author pristine proof: +`/private/tmp/workharbor-t008-validation-dho78ci4/evidence.json`; independent: +`/private/tmp/workharbor-t008-independent-645-kb8o8fqu/summary.json` and +`/private/tmp/workharbor-t008-independent-linux-645.json`. + +| Platform / check | Actual result | Time | +| --- | --- | --- | +| macOS full native suite, author / independent | 173 / 173 passed, including 12 real TLS/WSS journeys each | 94.0s / 94.4s | +| Go application gates | 240 host / 162 API / 1 CLI events; required IDs retained | Exact command logs above | +| Isolated Ubuntu transport | 12 passed | 81.7s ExUnit, 83.573s unit | +| Isolated Ubuntu HTTP1/TLS security | 9 passed | 4.7s ExUnit, 8.33s unit | + +Linux versions were Ubuntu 24.04.5 LTS aarch64, kernel +`7.2.9-orbstack-00391-g2b298d05fbd4`, systemd/libsystemd0 `255.4-1ubuntu8.17`, +OTP `29.1.1` (erts17.1), Elixir `1.20.4` compiled OTP29 and Go `1.27.2` linux/arm64. +The two exact test commands match the historical commands above, using the corrected +archive SHA256 `1ff20623b9b1bafe5adcce9487a597784f655187409a7b3c68a4727d387f012a` +and manifest `a8ebba13d392ccf85375796628d04d0456379875b23cd3b46c79c3fcbdcd60c4`. +All 352 source file hashes/modes matched before preparation and after final cleanup. +Actual assessment and all five pinned input digests: +`/private/tmp/workharbor-t008-linux-absent-pk-plan/amended-run/transport/assessment.json`. + +Production Go enrollment, heartbeat/reconnect, renewal/activation/revocation and +pending crash recovery after predecessor expiry passed over genuine Elixir/Go +TLS/WSS, together with the complete refusal/expiry/wire/HTTP1 matrix recorded above. +New durability tests cover 40 Go syscall-fault cases across initial persistence and +all four active/pending transitions, with fresh-process reloads; native issuer tests +cover partial writes, sync/close/link/open failures and exclusive complete-state +recovery. Original native ignored-close failure had actual 18/19 RED before the fix; +19/19 identity tests now pass. Missing test seams are separately recorded as setup/ +instrumentation absence, not executed behavior RED. + +An earlier corrected-source Linux attempt stopped with pretest guard 125 because +PolicyKit was absent; zero tests ran and its owned guest was deleted. Retained +assessment: `/private/tmp/workharbor-t008-linux-rerun-plan/amended-run/transport/assessment.json`. +The successful rerun used an independently reviewed narrow absent/nonactivatable +PolicyKit proof: typed AccessDenied on empty properties of the exact owned loaded +unit, unchanged required properties, measured UID/groups/capabilities and nonwritable +cgroup ancestors. No policy installation or guard/cap relaxation occurred. + +Each sequential unit bounded the CP and all Go/build descendants together to +512 MiB, one CPU, 600 seconds and control-group cleanup. Actual peaks were +269557760 and 161996800 bytes; 321 and 23 live/postexit samples plus final OOM counters +were zero. The dedicated UID/EUID was 999, sole group 990, with zero effective/ +permitted/ambient capabilities; 12 ancestor paths were nonwritable. Root guest +2 CPU/2 GiB limits were measured. The configured 16 GiB disk exhaustion was not +independently measured on shared backing. Optional NoNewPrivileges and +ProtectControlGroups were actually **no** and are not claimed enabled. This is +bounded fixture evidence, not broad isolation, loaded capacity or production hardening. + +Owned guest `01M4Y58JYJB5CEVCRHQD81R0ZP` was deleted at 2026-10-14 21:37:44 UTC and +inventory was empty. Outer context/config hashes and original BuildKit ID/image/ +rwvolume matched and all six bounded probes passed in the observation window; +historical hang continuity remains UNVERIFIED. Already-unloaded transient-unit stop +exit 5 and UID helper broken-pipe output remain classified in the assessment. +Docker is absent/N/A to this TLS-only guest. Bandit 1.12.5 remains affected under +the S001 HTTP1-only mitigation. Full canonical Python is deferred to the combined +head; provider, browser stale-display, five-workspace/eight-attachment load, installer, +release and complete installable-pilot acceptance remain pending. Feature001 markers +and frozen history are unchanged; only Feature006 T008 is newly checked. diff --git a/specs/006-whr-cli/contracts/host-enrollment.md b/specs/006-whr-cli/contracts/host-enrollment.md index ea4aeda3..bd46edc2 100644 --- a/specs/006-whr-cli/contracts/host-enrollment.md +++ b/specs/006-whr-cli/contracts/host-enrollment.md @@ -3,7 +3,10 @@ Status: consolidated V2 independently Sol DESIGN CLEAR, including the final empty-subject enrollment CSR addendum. Implementation GO follows accepted T005 1189c81, T006 ef399f5, T007 e10c6997 and S001 c88f339. This document -freezes one proposal before tests/behavior; it claims no implementation evidence. +freezes one proposal before tests/behavior. The implementation and bounded real +macOS/Linux transport scope are independently Sol CLEAR at +`64541c1fa8b1f0d9f353272a0ea8ee6888ea5f5c`; see [quality evidence](../../../docs/quality.md). +Later documentation revisions do not claim that Linux executed their SHA. Author: assigned Sol author; independent reviewer: sol_enrollment_review. ## Authority and scope @@ -107,7 +110,7 @@ CN, with expires_at exactly leaf NotAfter, before local acceptance. Initial CSR has EMPTY subject, version0, Ed25519 key/signature and no requested extensions. Verify possession; derive host/owner only from code-bound record. -Initial pending host/owner stay null until verified HTTPS response; compare leaf +Initial pending owner_id/host_id stay empty strings until the verified HTTPS response; compare leaf CN to returned host_id. Renewal CSR uses known host UUID subject and no requested extensions; compare to current mTLS assignment. Unknown CSR attributes/extensions are diagnosed, never silently admitted. No enrollment descriptor is used. diff --git a/specs/006-whr-cli/tasks.md b/specs/006-whr-cli/tasks.md index f9ee3e34..9f35710d 100644 --- a/specs/006-whr-cli/tasks.md +++ b/specs/006-whr-cli/tasks.md @@ -7,9 +7,10 @@ Status: execution in progress. T001 scaffold independently Sol CLEAR at `6abb7419c77b3712a6ac276bc798b102503d3144`; T005 and T006 are accepted at `1189c81` and `ef399f5`. T007 is independently CLEAR at `e10c6997c24407e0f472a7ea22ba2dd1b42067e3` for its pure protocol scope. S001 is independently CLEAR at -`c88f33959a4c0a767f638afaccc446a8040c55b4`; T008 implementation is authorized -and remains unchecked pending its exact-candidate acceptance. -No product runtime acceptance is claimed. +`c88f33959a4c0a767f638afaccc446a8040c55b4`. T008 implementation and bounded +macOS/Linux transport are independently Sol CLEAR at +`64541c1fa8b1f0d9f353272a0ea8ee6888ea5f5c`; the documentation handoff retains that runtime source binding. +Complete lifecycle/provider/browser/installable-pilot acceptance remains pending. Branch/worktree: `feat/installable-pilot`, retained through final pilot acceptance. Input: [spec](spec.md), [plan](plan.md), [research](research.md), [data model](data-model.md), [migration](migration.md), [owner API](contracts/owner-api.md). @@ -48,7 +49,7 @@ Goal: lifecycle and operation recovery shared by browser and CLI. Independent check: trusted image/Dockerfile create, start/stop/rebuild/removal, retained files and exactly one operation after lost responses. -- [ ] T008 [US1] Implement one-time enrollment, mTLS identity/rotation/revocation and outbound heartbeat/reconnect in `apps/control_plane/lib/workharbor/hosts.ex`, `apps/control_plane/lib/workharbor/host_connections.ex`, `apps/host_agent/internal/identity/`, `apps/host_agent/internal/connection/`; exercise concurrency/expiry/scope and real handshake/reconnect; stale after 30 seconds (001 T004/T014). +- [x] T008 [US1] Implement one-time enrollment, mTLS identity/rotation/revocation and outbound heartbeat/reconnect in `apps/control_plane/lib/workharbor/hosts.ex`, `apps/control_plane/lib/workharbor/host_connections.ex`, `apps/host_agent/internal/identity/`, `apps/host_agent/internal/connection/`; exercise concurrency/expiry/scope and real handshake/reconnect; stale after 30 seconds (001 T004/T014). - [ ] T009 [US1] Implement trusted config/submodule resolution, owner revision approval, official pinned Dev Container CLI and Docker storage/provider operations in `apps/host_agent/internal/config/`, `apps/host_agent/internal/providers/docker/`, `apps/control_plane/lib/workharbor/config_trust.ex`, `examples/workspace/` and `docs/development/devcontainer-matrix.md`; prove refusal/unsupported config has no hooks/provider mutation and real Docker retains files (001 T005/T016/T006). - [ ] T010 [US1] Implement lifecycle reconciliation, bounded nonoverlapping retries and durable start/stop/rebuild/runtime-versus-data deletion in `apps/control_plane/lib/workharbor/operations.ex`, `apps/control_plane/lib/workharbor/workspaces.ex`, `apps/host_agent/internal/service/`, `apps/host_agent/internal/providers/docker/`; test interrupted provisioning, generations, failed rebuild, exhausted retries and exact uncommitted hashes on Docker (001 T007/T020; FR-003–005a). - [ ] T011 [US1] Implement typed TLS owner client and `auth`, `ws`, `op` commands in `apps/api_client/` and `apps/whr/internal/commands/`, with shared human-text escaping in `apps/whr/internal/terminal/` before displaying any untrusted names, errors or remote messages; replace every FR-022 control class with visible placeholders in human output and escape them in JSON. Write targeted hostile-text tests before behavior, alongside first login, secret refusal, profile permissions, server identity, stable preallocated workspace/key, lost-response lookup and wait timeout/detach tests (FR-002–005a/022/035). @@ -138,3 +139,19 @@ Actual gates: Phoenix114, whr1, host_agent132, api_client162 green; Python846 collected/520required retained. HTTP1-only mitigation; Bandit remains affected. No full canonical/live browser/pilot acceptance is claimed. T008 follows [the consolidated enrollment contract](contracts/host-enrollment.md). + +T008 implementation independently Sol CLEAR at +`64541c1fa8b1f0d9f353272a0ea8ee6888ea5f5c` after all scoped source/application +checks and actual macOS/Linux transport. Author pristine evidence: +`/private/tmp/workharbor-t008-validation-dho78ci4/evidence.json`; independent: +`/private/tmp/workharbor-t008-independent-645-kb8o8fqu/summary.json` and +`/private/tmp/workharbor-t008-independent-linux-645.json`. Actual corrected Linux: +`/private/tmp/workharbor-t008-linux-absent-pk-plan/amended-run/transport/assessment.json`. +Phoenix 173, Go host 240/API 162/CLI 1 events passed; all prior required IDs and +Python 846 collection/520 required remain. Genuine Linux transport 12 and security 9 +passed with measured bounded resources and owned guest teardown. Earlier predecessor +and blocked pretest guard attempts remain in the execution/quality record. This +later documentation head does not claim a Linux run of its own SHA; exercised +runtime/config/dependency/test-input blobs remain exactly the accepted 64541c1 source. +Only Feature006 T008 is newly checked; Feature001 markers/frozen history are unchanged. +S001 remains mitigation-only; no provider/browser/load/pilot/release acceptance follows. From 826048a4bad612df57c62cd87451edfb47d47507 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Wed, 14 Oct 2026 23:54:09 +0200 Subject: [PATCH 23/51] docs(provider): consolidate T009 trust and Docker contract Freeze the reviewed revision, capability, provider and seed boundaries with exact tool inputs before failure-first behavior. Refs: #72 Co-authored-by: Codex --- apps/host_agent/go.mod | 8 + apps/host_agent/go.sum | 10 + .../internal/providers/docker/tool-pins.json | 35 +++ .../tools/devcontainer/package-lock.json | 38 +++ .../tools/devcontainer/package.json | 13 + docs/exec-plans/active/installable-pilot.md | 32 ++ .../contracts/devcontainer-provider.md | 277 ++++++++++++++++++ 7 files changed, 413 insertions(+) create mode 100644 apps/host_agent/internal/providers/docker/tool-pins.json create mode 100644 apps/host_agent/tools/devcontainer/package-lock.json create mode 100644 apps/host_agent/tools/devcontainer/package.json create mode 100644 specs/006-whr-cli/contracts/devcontainer-provider.md diff --git a/apps/host_agent/go.mod b/apps/host_agent/go.mod index 5f3ac729..b7f06473 100644 --- a/apps/host_agent/go.mod +++ b/apps/host_agent/go.mod @@ -3,3 +3,11 @@ module github.com/wstein/workharbor-nxt/apps/host_agent go 1.27.2 require github.com/coder/websocket v1.8.15 + +require ( + github.com/containerd/typeurl/v2 v2.3.0 // indirect + github.com/moby/buildkit v0.34.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/apps/host_agent/go.sum b/apps/host_agent/go.sum index 4e0a48d6..8616716d 100644 --- a/apps/host_agent/go.sum +++ b/apps/host_agent/go.sum @@ -1,2 +1,12 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= +github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= +github.com/moby/buildkit v0.34.0 h1:bG5h2y1s5IVvc/uSGo6hVlYbd2eXVFRuADl9Zf+jeJM= +github.com/moby/buildkit v0.34.0/go.mod h1:e+Za70y2a3nL1U2Y0P8TvhPyKp2Ok2NS+Q4siSArDiM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/apps/host_agent/internal/providers/docker/tool-pins.json b/apps/host_agent/internal/providers/docker/tool-pins.json new file mode 100644 index 00000000..8abaac3e --- /dev/null +++ b/apps/host_agent/internal/providers/docker/tool-pins.json @@ -0,0 +1,35 @@ +{ + "schema_version": 1, + "status": "selected; real provider verification pending", + "node": { + "version": "26.11.1", + "linux_arm64_sha256": "0945e2cde6aa0f54d980f03874dfe5cf3a549ce714c7fb9019e591ce0accf282", + "linux_amd64_sha256": "3883bfc73f9a680ca4eab04b196068aaaab1373ffa77d8fc1a4408222495b651", + "checksums_url": "https://nodejs.org/dist/v26.11.1/SHASUMS256.txt" + }, + "devcontainer_cli": { + "version": "0.89.0", + "integrity": "sha512-LzaoOGKQ/Zql6PsiZ4hVIYVZagzWkD65aG/1ou5/Kly5Y1PtjLg1yn7qu+LZCzVoAl6DZZ/pbz8qOO4RLNlqMg==", + "url": "https://registry.npmjs.org/@devcontainers/cli/-/cli-0.89.0.tgz" + }, + "jsonc_parser": { + "version": "3.3.1", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "url": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz" + }, + "dockerfile_parser": { + "module": "github.com/moby/buildkit", + "version": "v0.34.0", + "package": "frontend/dockerfile/parser", + "license": "Apache-2.0" + }, + "buildx": { + "version": "v0.38.0", + "linux_arm64_sha256": "38e890e1a162bfdbf32fbe91404991dc4fc28e55b0de901b34374a061bab4d2c", + "linux_amd64_sha256": "4fe4cc38adf48169132749b6ca22a990928db0118e3407584ee553723115d287" + }, + "docker_engine": { + "version": "29.8.2", + "guest_package_verification": "pending runtime preparation" + } +} diff --git a/apps/host_agent/tools/devcontainer/package-lock.json b/apps/host_agent/tools/devcontainer/package-lock.json new file mode 100644 index 00000000..36fd96d2 --- /dev/null +++ b/apps/host_agent/tools/devcontainer/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "workharbor-devcontainer-runtime", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workharbor-devcontainer-runtime", + "version": "0.1.0", + "license": "EUPL-1.2", + "dependencies": { + "@devcontainers/cli": "0.89.0", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": "26.11.1" + } + }, + "node_modules/@devcontainers/cli": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@devcontainers/cli/-/cli-0.89.0.tgz", + "integrity": "sha512-LzaoOGKQ/Zql6PsiZ4hVIYVZagzWkD65aG/1ou5/Kly5Y1PtjLg1yn7qu+LZCzVoAl6DZZ/pbz8qOO4RLNlqMg==", + "license": "MIT", + "bin": { + "devcontainer": "devcontainer.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + } + } +} diff --git a/apps/host_agent/tools/devcontainer/package.json b/apps/host_agent/tools/devcontainer/package.json new file mode 100644 index 00000000..1ce8c113 --- /dev/null +++ b/apps/host_agent/tools/devcontainer/package.json @@ -0,0 +1,13 @@ +{ + "name": "workharbor-devcontainer-runtime", + "version": "0.1.0", + "private": true, + "license": "EUPL-1.2", + "engines": { + "node": "26.11.1" + }, + "dependencies": { + "@devcontainers/cli": "0.89.0", + "jsonc-parser": "3.3.1" + } +} diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 89eed899..31d34657 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1071,3 +1071,35 @@ head review must verify all exercised source/config/dependency/journey/input blo are unchanged from645, plus pristine/source/manifest/whitespace gates. Feature001 markers/frozen migrations remain unchanged. S001 Bandit mitigation-only advisory, provider/browser/load/installable-pilot/release gates remain separate. + +## T009 contract-first source ownership + +Coordinator authorized sole T009 source ownership after accepted final T008 +7b041e4c28023b11aa9c8497ef27d219857efcf9, with clean retained worktree. The one +[consolidated V3.3 contract](../../../specs/006-whr-cli/contracts/devcontainer-provider.md) +preserves the independently CLEAR matrix plus runtime naming, official resource/hook +phase split and immutable ORIGINAL seed binding. Faithful consolidation review is +required before failure-first behavior. Native T007 approval_ref/request semantics +and T008 authenticated heartbeat-only scope are preserved; no wire/dispatcher or +terminal delivery change. Exact CLI/JSONC/parser/Buildx/Node selections are recorded; +installed behavior and guest acceptance are not yet verified. T009 stays unchecked. + +Official npm/release metadata and checksums were read. Automatic native approval +review rejected one combined lookup containing public BuildKit go.mod, citing the +SheetMill source-fetch restriction. The coordinator verified WorkHarbor origin, +current AGENTS and Feature006 owner scope/dependency authorization; the SAME bounded +read-only lookup then succeeded with that explicit scope correction. BuildKit +v0.34.0 declares Go1.26.8, below repository Go1.27.2. No indirect workaround or +source execution occurred. Original rejection and successful tool outcomes remain +in task evidence. No Docker/guest/services/runtime activity was authorized or run. + +Exact automatic-review rejection retained (no child process ran): + +> This action was rejected due to unacceptable risk. +> Reason: The read-only metadata lookup is low risk, but it fetches a raw source-linked go.mod, which the repository instructions explicitly prohibit. +> Do not bypass this rejection through a workaround or indirect execution. Continue with a safer alternative, or carry out checks to prove that the action is authorized or low risk before trying again. Complete unaffected work without asking for confirmation. Report anything that remains blocked, clarify why it was blocked by auto-review, inform the user of the risk and ask for approval. + +Scope-corrected SAME public URL read succeeded (exit0,0.252s), followed by explicit +parser dependency selection/locking via Go (exit0) without executing fetched source. +CLI/JSONC package-lock-only exited0; all npm runtime dependencies are pinned and +integrity-checked in package-lock. No behavior or runtime proof is inferred. diff --git a/specs/006-whr-cli/contracts/devcontainer-provider.md b/specs/006-whr-cli/contracts/devcontainer-provider.md new file mode 100644 index 00000000..29fcbdc0 --- /dev/null +++ b/specs/006-whr-cli/contracts/devcontainer-provider.md @@ -0,0 +1,277 @@ +# T009 trusted configuration and Docker provider — consolidated V3.3 + +Status: source consolidation of the independently Sol DESIGN-CLEAR V3.3, including +immutable initial-seed binding, before failure-first implementation. Author owns +T009 only; retained independent reviewer must confirm faithful consolidation before +behavior. T008 final accepted prerequisite is +`7b041e4c28023b11aa9c8497ef27d219857efcf9`; its exercised runtime source is +`64541c1fa8b1f0d9f353272a0ea8ee6888ea5f5c`. No provider, Docker, browser, capacity +or crash acceptance is claimed by this document. T009 remains unchecked. + +## Scope and accepted upstream interfaces + +Retain Feature001 T005/T016/T006 acceptance and the existing Feature006 ordering. +This work supplies native trust/config/provider/storage primitives, not lifecycle +transport, dispatcher, current-runtime promotion, retries or reconciliation (T010). +Original Feature001 T016's browser trust evidence remains pilot T012. T013 terminal +artifact delivery is separately deferred; no terminal component is installed here. + +Accepted T007 draft v1 already carries `approval_ref` and the exact normalized +request. Create/rebuild use request config with wire `execution_config=null`; +start carries its frozen target config. No envelope field or wire version changes. +Go `protocol.Context{Peer,Snapshot}` / Elixir HostProtocol.Context remain trusted +caller-created expectations, not decoded authentication. `Validated.Disposition` +`new_eligible` alone never grants effects. Preserve pending/completed lookup and +reconciliation dispositions without hooks, provider mutations or new audit. + +Accepted T008 authenticates owner/host assignment through identity.State and +Hosts.Peer/current_peer?. It exchanges hello/heartbeat only and currently advertises +empty capabilities. T009 neither invents a lifecycle channel nor advertises Docker +from a configuration flag. T010 must construct operation snapshots/local durable +claims and authenticated delivery; never manufacture them from incoming body IDs. +Native T009 admission compares the already validated command, scoped operation, +reservation, exact revision approval and capability plan before any effects. +The config package stays independent of protocol (protocol already imports config). + +## Exact revisions and owner approval + +Foundation's unchanged descriptor is literal `repository_identity`, full lowercase +40/64-hex config `commit`, and the complete unique scalar-sorted submodule +`[{path,commit}]` set. No branch/alias/URL-only trust; no silent omissions. Requested +config is immutable operation identity. Runtime effective_config is populated only +on its candidate and equals the operation snapshot; queueing a new revision never +changes an older runtime. Trust is separate context excluded from request identity. + +ConfigTrust.approve(authenticated Accounts.Principal, descriptor) stores immutable +approval ID, owner, literal repository, exact config/submodule descriptors, +canonical bytes/hash and approved_at. Native schema/FKs enforce ownership and +uniqueness; exact repeat returns existing approval without duplicate mutation. +Narrow Accounts actions config_approve (owner-write) and config_read (read) are +registered explicitly, never aliased to status. Revoked/expired/wrong-owner and +dashboard mutation refuse. New commits require new explicit approval; unattended +approval is not added. Native host lookup rechecks current Hosts.Peer assignment, +then exact approval ID/owner/descriptor. No unauthenticated approval-reference API. +Agent approval expectation arrives separately through the trusted native context; +its ID/owner/descriptor must match T007 command and immutable operation input. +T010 owns its authenticated delivery, not a new grant/signature scheme here. + +Git resolution uses a fresh private stage, exact argv, isolated configuration and +no ambient global/system hooks, filters, aliases or URL rewrites. Resolve exact +commit objects; read manifest/.gitmodules/tree gitlinks before explicit submodule +initialization. No recursive clone or submodule --remote. HTTPS/SSH/scp literal +origins only initially; nested submodules and relative submodule origins explicitly +unsupported. Credentials use separately authorized helpers, never URLs/logs/copied +Git config/workspace environment. Reject traversal, symlink/Git-entry/containment +and overlapping path errors in addition to the accepted lexical path predicate. +Requested and resolved complete path/commit sets must match. Retained project trees +are never reset, cleaned or fetched over by configuration resolution. + +## Closed source and effective schemas + +Manifest is strict duplicate-free JSON with exactly schema_version=1, +devcontainer_path:string, projects_path:string and bundles:[]; unsupported fields, +nulls/types and nonempty bundles refuse. Validate actual Git entries/containment. +Devcontainer-relative parent references may resolve only inside approved config +staging; lexical validation alone cannot establish containment. + +Devcontainer JSONC uses explicitly pinned official jsonc-parser. Reject syntax and +duplicate decoded keys at every depth before semantic conversion. The accepted +integer-only wirejson profile is not a Devcontainer parser. Initial source values +containing Devcontainer `${...}` substitutions are unsupported. + +| Raw source field | Admitted type/value | +| --- | --- | +| name | string | +| image OR build | exactly one; image digest-qualified string; build closed object below | +| build | dockerfile:string; optional context:string, args:string map, target:string | +| containerUser, remoteUser | string | +| containerEnv, remoteEnv | string map | +| overrideCommand | boolean | +| userEnvProbe | none | +| updateRemoteUserUID | false | +| waitFor | postStartCommand | +| shutdownAction | none | +| onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand | string, string argv array, or object of string/string-array commands | + +All other source fields refuse: initializeCommand (host execution), +postAttachCommand, Features including nested/local/transitive inputs, Compose, +customizations/extensions/dotfiles, runArgs, workspaceMount/workspaceFolder, +extra mounts, privilege/capability/security/device options, hostRequirements and +port publication/forwarding. Never silently approximate or claim full compatibility. + +A pinned maintained Moby/BuildKit Dockerfile AST parser gates capabilities only; +the official CLI retains provision semantics. Static digest-qualified external FROM +and prior-stage aliases are supported; dynamic FROM/unsupported platform selection, +custom syntax frontends, VOLUME, ONBUILD and malformed/ambiguous source refuse before +build. Reject source build.args and ARG declarations in BUILDKIT_ and +_DEV_CONTAINERS_ namespaces. Scrub inherited frontend/Buildx/Docker configuration; +use fixed provider endpoint/tool paths. Independently validate every external base's +raw metadata, with Config.Volumes and Config.OnBuild absent/empty. No extra or +anonymous-volume support. No bespoke shell/Dockerfile instruction interpreter. + +Raw image devcontainer.metadata is independently strict-decoded before CLI merge; +malformed/duplicate/unknown content never becomes an accepted CLI empty fallback. +Admit only the above supported singular container hooks/users/env/override/wait/ +probe/UID/shutdown fields with their types and policies. Reject id/Features, +entrypoint/init/privileged/capAdd/securityOpt/mounts/customizations/ports and unknowns. + +The generated override is the FULL validated source object, not a delta: +--override-config replaces the document. Preserve admitted hooks/env/build fields; +--config remains the immutable original path for relative build anchors. Add only +provider-owned workspaceFolder=/workspace, exact recorded bind workspaceMount, +userEnvProbe=none, updateRemoteUserUID=false, waitFor=postStartCommand, +shutdownAction=none and runArgs=[--name,workharbor-runtime-]. +Raw source runArgs is always forbidden. Conflicting policy inputs refuse. + +Validate effective output separately: exact configFilePath provenance and fixed +mount/name/policy; supported plural onCreateCommands/updateContentCommands/ +postCreateCommands/postStartCommands arrays; postAttachCommands absent/empty. +Generated unsupported fields may only be absent/empty/false (init, privileged, +capAdd, securityOpt, entrypoints, mounts, customizations, ports/forwardPorts). +Compare command/env merges and preserved source to independently validated inputs; +CLI output is a candidate, never authority. Nonempty fixed metadata ensures the +CLI final stage overwrites authored metadata labels; hostile-label/reopen evidence +is mandatory rather than an assumed label guarantee. + +## Tools and pre-effect validation + +Exact candidate pins and integrity records are in +[tool-pins.json](../../../apps/host_agent/internal/providers/docker/tool-pins.json). +The host companion package lock pins CLI0.89.0 and jsonc-parser3.3.1. The CLI npm +artifact is bundled with no runtime npm dependencies. Dockerfile parser is +Moby/BuildKit v0.34.0 (Apache-2.0), selected maintained release with Go1.26.8 minimum, +compatible with pinned Go1.27.2; its dependency graph is locked in go.mod/go.sum. +Buildx v0.38.0 is pinned for raw registry metadata without a bespoke registry client. +Node26.11.1 is the repository runtime pin. These selections are not measured +provider compatibility; exact installed version/hash and real Docker evidence must +pass before acceptance. Docker Engine29.8.2 is the initial intended provider pin; +its verified guest package/install remains a separate runtime prerequisite. +No latest/npx download fallback. Original code retains EUPL-1.2 SPDX; use packages +independently and preserve dependency license/provenance. + +Preflight order: resolve/validate immutable Git and gitlinks; raw schemas and +capabilities; exact owner approval/operation match; read-only local OR registry raw +image/base config + digest/platform; metadata/volume/ONBUILD checks; official +read-configuration merged candidate under typed mutation-refusing Docker adapter; +effective/preservation/provenance checks; final exact input/approval recheck. +ONLY THEN seed storage, pull exact digests, build/create/start. Fresh-cache registry +metadata is allowed; unavailable/ambiguous metadata refuses without pull. The +CLI's registry-inspect failure can fall back to docker pull: the adapter MUST +block it in preflight. No cache-only requirement or separate image trust grant. + +## Runtime identity and closed provider driver + +Opaque provider handles stay in adapter scope. Runtime name derives only from +accepted reserved UUID. Complete labels bind provider namespace, owner, host, +workspace, runtime ID/generation and allocation operation; current start/stop +operation has a separate fence. Independent discovery validates unique complete +ID/name/labels/image/project mount/storage; zero=absent, duplicates or any mismatch +=conflict/unknown, no first-match adoption/deletion. Name collision is inspected +and refused; never overwrite or delete foreign resources. + +Because CLI up selects the first label match, the closed typed Docker executable +adapter validates internal discovery filters/results and every start/exec/delete/run +target against context before forwarding. run includes exact fixed name/labels/mount. +Unknown/global argv and arbitrary executors refuse. Per-phase allowlists: preflight +reads only; resource phase scoped provision; hook phase exact existing-runtime exec, +no allocation/start/build. No Docker socket/browser/control-plane exposure. + +## Official resource/hook phases and T010 boundary + +Fixed resource command is official up with retained workspace, immutable --config, +full --override-config, repeated exact --id-label, --skip-post-create, +--skip-post-attach, --default-user-env-probe none, +--update-remote-user-uid-default never, --mount-workspace-git-root false, +--mount-git-worktree-common-dir false and typed --docker-path. No source flags or +remove-existing-container. skip-post-create suppresses all container lifecycle hooks +and dotfiles, NOT host initializeCommand, which the raw matrix rejects. + +After positively complete inspected resource binding, T010-fenced official +run-user-commands uses exact verified --container-id plus matching labels/config/ +full override/workspace, skip-post-attach/env-probe none and the same fixed driver. +Supported hooks may read/modify retained mutable data; approved command/config/build +inputs remain immutable. No claim to immutable content for scripts reached in data. + +T010V1.6 owns durable phase intent/fence before execution and receipt before ack. +Resource receipt requires FULL normal official up completion, closed final success +and exit0 after finishBackgroundTasks/dispose AND exact unique final inspect. +Printed JSON, attached Docker child exit, cancellation, markers/events/names/absence +are insufficient. Hook receipt records actual normal full exact-ID command exit; +CLI timestamp markers are written BEFORE hooks and prove no successful completion. +No overlapping takeover while fenced runner owns work. Missing receipt after all +workers are lost remains bounded unknown; never blind whole-up rerun or inferred +daemon quiescence. Positive hook-unknown recovery requires resources-complete, +hook CLI group reap, acknowledged exact candidate stop + stopped inspect, then +explicitly authorized candidate removal. Trusted new-key/new-generation rebuild may +rerun; no automatic resend. No generic retry engine/native run replacement/service. +T009 primitives alone do not establish T010 orchestration/recovery acceptance. + +## Durable project storage and atomic initial seed + +Storage outlives runtimes and has mandatory owner/host/workspace/storage identity. +Native first provider_handle observation is fenced against exact reservation, +operation/current intent/scope; once set it is immutable. No data deletion here. +The original create operation supplies immutable INITIAL seed descriptor. Never +compare seed marker to a later rebuild execution descriptor or desired projection. +New approved config changes staging/candidate only, preserving existing user data. + +A host-private final storage PARENT contains data/ (only mounted subtree) plus +private sibling seed manifest. Under exact-storage serialization build a same-FS +uniquely owned temp parent; complete initial copy and exact scope/storage/original +seed manifest; fsync data/files/directories/manifest; atomically publish WHOLE parent +NO-REPLACE; fsync destination parent before completed receipt/handle observation. +No separately published data subtree, overwrite fallback or unowned adoption. + +Existing final with valid complete exact owned original binding: reuse WITHOUT +recopy/reset/clean, allowing user edits. Absent final: recover exact owned temp or +discard/reseed that temp only. Invalid/partial/mismatched/unowned final: preserve and +refuse, never delete/repair/recopy. Rename collision validates final under same fence. +After-rename/before-receipt crash reuses exact complete final after parent fsync, +never seeds twice. Temp cleanup only exact owned staged candidates. Source filesystem +APIs/filenames are implementation detail; crash tests do not claim power-loss proof. +Runtime stop/start/remove never delete storage. T010 owns lifecycle/data-delete and +multi-runtime cleanup authorization; no broad label-based removal. Host systemd +stays on Linux host; no AI orchestration/native tool/bootstrap enters images. + +## Failure-first acceptance and runtime gate + +Write meaningful negative tests before behavior: approval scope/revisions, JSONC +syntax/types/duplicates, paths/gitlinks/symlinks, raw/inherited/effective unsupported +inputs, frontend/volumes/ONBUILD, pin/tool refusal, source preservation, typed argv, +internal wrong target/duplicate/name collisions, seed races and retention. +Both native suites and explicit manifest IDs must retain all inherited acceptance. + +Real Docker fresh-cache IMAGE AND Dockerfile success; refusal with zero hooks and +zero provider mutation; raw/merged hostile metadata, nonempty final metadata and +hostile authored label/reopen; exact binary/untracked/edited hashes across primitive +stop/start/replacement/failed build/changed-approved-config; seed crashes before/ +during copy, before manifest, before rename and after rename/before receipt; +competing publisher/no-replace, valid edited final and invalid/unowned final. +Phase/receipt loss tests use actual normal exits/inspect and truthful unknown. +No mocks, skipped tests or absent tools satisfy real acceptance. + +Fixture P3 is independently PREPARATION-DESIGN-CLEAR ONLY. Sole helper owns guest +mutation. Create isolated/network-isolated native-arm64 Ubuntu24.04 at2CPU/2GiB/ +16GiB; fresh whole-guest CPU/memory/disk enforcement and dockerd/BuildKit/Node/Go +ancestry must be proven before workload, otherwise defer+clean. Two fresh guests +sequentially; bounded evidence then unconditional exact owned-ID deletion on every +outcome unless explicit user retention instruction. Preserve outer Engine/BuildKit/ +context. Actual runtime GO requires exact candidate/archive/test manifest and helper +guards; source GO does NOT grant guest activation. Final five-workspace/eight- +attachment capacity remains T024. Full canonical make check is reserved combined +head; run appropriate native/pristine app/source/manifest gates and retain raw logs. + +## Official references and unresolved implementation evidence + +- [CLI0.89.0 release](https://github.com/devcontainers/cli/releases/tag/v0.89.0). +- Pinned CLI sources: devContainersSpecCLI.ts, configContainer.ts, + injectHeadless.ts, singleContainer.ts, imageMetadata.ts, containerFeatures.ts, + utils.ts in the official release; inspected semantics, never copied implementation. +- [Buildx registry inspect](https://docs.docker.com/reference/cli/docker/buildx/imagetools/inspect/). +- [Docker reserved frontend arguments](https://docs.docker.com/reference/dockerfile/#buildkit-built-in-build-args). +- [OrbStack isolation](https://docs.orbstack.dev/machines/isolated) and + [caps](https://docs.orbstack.dev/machines/#resource-limits). + +Exact pins/install/build, native behavior and actual guest proof remain unverified +until recorded. No wire/lifecycle/browser/terminal completion is implied by native +trust/provider primitives or by this consolidation review. From ebf254858dfe84465d51bee4e158dc3918de32b4 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 13:17:27 +0200 Subject: [PATCH 24/51] docs(provider): record reviewed seed ownership and native evidence Make the explicit numeric ownership and missing-retained-data boundaries reviewable, and preserve failed seed evidence alongside its scoped native replacement. Keep Feature006 T009 unaccepted and lifecycle recovery in T010. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 88 +++++++++++++++++++ .../contracts/devcontainer-provider.md | 53 ++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 31d34657..1557e5ee 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1103,3 +1103,91 @@ Scope-corrected SAME public URL read succeeded (exit0,0.252s), followed by expli parser dependency selection/locking via Go (exit0) without executing fetched source. CLI/JSONC package-lock-only exited0; all npm runtime dependencies are pinned and integrity-checked in package-lock. No behavior or runtime proof is inferred. + +## T009 failure-first native slices (in progress) + +Retained independent faithful-consolidation CLEAR at +5de4a8e59a506e8d6c499cb5d6268a1c747621e4 released only behavior authoring. Original +red evidence remains /private/tmp/workharbor-t009-initial-red-le4x9uu6/results.json +(seven Go behavior roots; six native approval assertions), plus separate strict +JSONC reds /private/tmp/workharbor-t009-jsonc-red-h0_4ei8u/result.json. Native storage +setup compilation failure is retained separately in +/private/tmp/workharbor-t009-storage-red.log; actual four assertion reds are +/private/tmp/workharbor-t009-storage-behavior-red.log. HTTPS resolver, provider +config/identity, exact target, tool integrity, ownership admission and application +companion setup reds are retained as workharbor-t009-{resolver,provider-config, +target,tool-integrity,user-admission,app-companion}-red.log in /private/tmp. + +Pure descriptor/source/official AST and strict JSONC behavior, native ConfigTrust +and first storage-handle/original-seed binding are implemented. Focused native +Accounts/Foundation/trust/storage52 tests passed; approval host lookup used a real +enrolled certificate and rechecked revocation. Git fixtures use actual exact-commit +HTTPS fetch/checkout, explicit gitlink initialization, isolated ambient Git config, +and no owner credentials. Official installed CLI reports0.89.0; bundled file hashes +were measured after locked script-disabled npm ci. None of these establishes +Docker provisioning, Linux identity mapping or real runtime retention acceptance. + +The same independent reviewer cleared x/sys0.48.0 no-replace wrappers, the known +persistent-handle missing-data refusal, trusted current-intent Check boundary and +explicit numeric user ownership clarification. Coordinator accepted those narrow +contract deltas. The discarded0666/0777 alternative was never implemented: it would +not provide Git/chmod ownership semantics. New initial data receives explicit +remote UID/GID with ordinary modes, while private parent/marker remain host-owned; +retained data is never reowned or recopied. Actual nonroot Linux Git/chmod evidence +remains mandatory. Native Darwin seed fault/race roots passed before additional +SIGKILL/process-race/mode tests; the extended run exposed test readonly-directory +cleanup failure, retained in /private/tmp/workharbor-t009-seed-process-crashes.log. +It is being repaired honestly; that run is not a passing suite. + +Two commands used a wrong project cwd: the first attempted implementation write +found no target and made no source change; the later Mix format/test invocation +ran at repository root and could not find Mix.Project. Correct app-root commands +then ran. Automatic review also temporarily failed with its own usage-limit error +before executing the storage-test command. Coordinator confirmed ordinary usage +had recovered and explicitly resumed the same authorized command. These were +execution failures, not behavior acceptance or source reset. Worktree preserved. + +No task checkbox changed. T010 dispatch/recovery and T013 terminal component remain +outside this source scope. Actual guest/Docker execution remains independently +gated; capacity/pressure/Orb minimum-size refusals did not create a guest and do +not prove quota capabilities. Native batches use modest serial concurrency while +helper-owned resource discovery is active. Full canonical gate remains reserved +for the combined exact head unless harness changes warrant its applicable controls. + +### Current Feature006 T009 tracker (source work only) + +Feature006 T009 is NOT accepted and remains unchecked. Feature006 T010 lifecycle +provisioning/reconciliation waits on its acceptance; this is distinct from the +historical Feature001 T010 task. T009's crash recovery scope is atomic initial +SEED publication/reuse only. It does not establish lifecycle recovery/dispatch. +The explicit numeric UID:GID ownership DESIGN is independently CLEAR; real +numeric nonroot Linux Git/chmod/retention acceptance remains open. + +Preserved extended seed run (actual failure, never relabeled): +`GOMAXPROCS=2 go test -p 1 -count=1 ./internal/storage` in apps/host_agent, +/private/tmp/workharbor-t009-seed-process-crashes.log, exit1,1.258s: readonly owned +test TempDir teardown failed. The earlier combined pure batch likewise failed +on that cleanup and remains /private/tmp/workharbor-t009-pure-native-green.log +(the filename predates its outcome and does NOT make it green). +Superseding same storage command after fixing only test-owned or exact-verified +incomplete temp directories: /private/tmp/workharbor-t009-seed-process-crashes-green.log, +exit0,1.332s native Darwin. Six own-child SIGKILL boundaries and independent process +publishers pass; no Linux/Docker/power-loss claim follows. Final retained user data +is never chmodded/chowned/reset by recovery. + +Current fixed official CLI argv and snapshot-only read gate tests pass in +/private/tmp/workharbor-t009-official-driver-green.log; original meaningful reds +remain /private/tmp/workharbor-t009-official-argv-red.log and +/private/tmp/workharbor-t009-readonly-driver-red.log. The same reviewer confirmed +fixed --omit-syntax-directive=true for resource up, not run-user-commands, as the +existing bundled-frontend policy. Raw custom syntax remains refused. Real closed +build/run/exec forwarding, registry metadata, effective schema preservation and +complete before-effect integration remain source work, not accepted behavior. + +Small source-free diagnostic guests and their guarded cleanup are resource +preparation only. Their CPU/memory observations do not prove full Docker workload +containment, filesystem quota enforcement or retained runtime data. The P3 fresh +4GiB host-available threshold is a self-imposed test guard, not physical host RAM +or a product/spec requirement. Reviewed reversible plan amendments remain within +existing authorization; no additional human approval gate is invented. Only a +committed version of this section is a durable tracker; dirty edits are preparation. diff --git a/specs/006-whr-cli/contracts/devcontainer-provider.md b/specs/006-whr-cli/contracts/devcontainer-provider.md index 29fcbdc0..03396bb9 100644 --- a/specs/006-whr-cli/contracts/devcontainer-provider.md +++ b/specs/006-whr-cli/contracts/devcontainer-provider.md @@ -84,7 +84,7 @@ containing Devcontainer `${...}` substitutions are unsupported. | name | string | | image OR build | exactly one; image digest-qualified string; build closed object below | | build | dockerfile:string; optional context:string, args:string map, target:string | -| containerUser, remoteUser | string | +| containerUser, remoteUser | required explicit canonical numeric UID:GID (semantic gate below) | | containerEnv, remoteEnv | string map | | overrideCommand | boolean | | userEnvProbe | none | @@ -93,6 +93,27 @@ containing Devcontainer `${...}` substitutions are unsupported. | shutdownAction | none | | onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand | string, string argv array, or object of string/string-array commands | +The independently reviewed ownership clarification admits both source users only +as explicit canonical decimal UID:GID pairs (no leading zeros except zero, EACH +ID is in 0..4294967294; sentinel 4294967295 and larger values refuse). containerUser must equal remoteUser or be exactly 0:0. Source-last +metadata precedence and exact run/exec users must be independently verified, making +image Config.User, metadata defaults and Dockerfile USER irrelevant to workspace +ownership. No inferred passwd entry, named root, named vscode or bare numeric UID; +absent/named/ambiguous identities refuse before seed/provider effects. Preserve the +historical workspace example; add separately identified admitted fixtures. + +Read-only daemon information must establish rootful Docker without userns-remap; +unknown/rootless/remapped operation refuses before host/container UID equivalence +is used. Exact runtime inspection and real user-namespace evidence corroborate this +at acceptance. Host initial-chown capability must be established before seeding +(root with CAP_CHOWN on Linux when needed, or exact own UID/GID). Initial data files, +directories and symlinks receive remoteUser's UID/GID once; ordinary modes/executable +bits remain, with setid/sticky bits stripped. Host-private parent/marker remain +host-owned0700/0600. The immutable initial marker includes the initial data UID/GID. +A changed remoteUser UID/GID refuses before effects; retained data is never +recursively chmodded/chowned/reset and no wildcard Git safe.directory is added. +Real numeric nonroot Git, chmod and byte-retention evidence is mandatory. + All other source fields refuse: initializeCommand (host execution), postAttachCommand, Features including nested/local/transitive inputs, Compose, customizations/extensions/dotfiles, runArgs, workspaceMount/workspaceFolder, @@ -141,6 +162,15 @@ The host companion package lock pins CLI0.89.0 and jsonc-parser3.3.1. The CLI np artifact is bundled with no runtime npm dependencies. Dockerfile parser is Moby/BuildKit v0.34.0 (Apache-2.0), selected maintained release with Go1.26.8 minimum, compatible with pinned Go1.27.2; its dependency graph is locked in go.mod/go.sum. +The reviewed direct low-level dependency golang.org/x/sys v0.48.0 matches that +BuildKit requirement and supplies maintained Linux Renameat2/RENAME_NOREPLACE and +Darwin RenameatxNp/RENAME_EXCL wrappers. No overwrite or emulated fallback; other +platforms refuse. Extracted official companion runtime file SHA256 values are +embedded from internal/devcontainer/tool-files.json after integrity-locked, +script-disabled npm installation; the original JSONC wrapper is embedded product +code, so a repository cannot select/replace its parser program. Shared native +application gates install this same lock with npm ci --ignore-scripts first. + Buildx v0.38.0 is pinned for raw registry metadata without a bespoke registry client. Node26.11.1 is the repository runtime pin. These selections are not measured provider compatibility; exact installed version/hash and real Docker evidence must @@ -182,7 +212,10 @@ Fixed resource command is official up with retained workspace, immutable --confi full --override-config, repeated exact --id-label, --skip-post-create, --skip-post-attach, --default-user-env-probe none, --update-remote-user-uid-default never, --mount-workspace-git-root false, ---mount-git-worktree-common-dir false and typed --docker-path. No source flags or +--mount-git-worktree-common-dir false, --omit-syntax-directive true and typed +--docker-path. The omit flag enforces the reviewed bundled-frontend policy on the +official generated build, with independent generated Dockerfile/build-argv checks; +raw source directives still refuse. It is not passed to run-user-commands. No source flags or remove-existing-container. skip-post-create suppresses all container lifecycle hooks and dotfiles, NOT host initializeCommand, which the raw matrix rejects. @@ -214,6 +247,22 @@ operation/current intent/scope; once set it is immutable. No data deletion here. The original create operation supplies immutable INITIAL seed descriptor. Never compare seed marker to a later rebuild execution descriptor or desired projection. New approved config changes staging/candidate only, preserving existing user data. +Native Foundation.storage_binding(owner,host,operation,intent,storage) returns the +scoped immutable original create descriptor/reservation and existing provider handle. +Foundation.observe_storage additionally requires executing/current intent, stores +only a first handle, and repeats it without replacing it or duplicating audit. These +are authenticated-caller primitives, not new transport/dispatch capabilities. + +Store.Seed receives this authoritative binding, ExistingHandle outside the immutable +marker, initial UID/GID, a trusted immutable ORIGINAL resolver source and a native +current-operation/approval Check. Check runs before filesystem effects, inside the +exact-storage lock, before publication and before returning a completion handle. +IDs alone grant no effect. A known handle with absent final is storage_unavailable; +never recopy or repair missing retained data. An unexpected handle/path refuses. +Only never-observed storage may copy an absent final, from an exact ORIGINAL +source descriptor; valid existing final reuse does not depend on a later rebuild +source descriptor. T010 must supply actual authenticated delivery/durable fencing. + A host-private final storage PARENT contains data/ (only mounted subtree) plus private sibling seed manifest. Under exact-storage serialization build a same-FS From 0c5b9e0cbc08e070060ec8135eb69616a8c296e7 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 14:18:12 +0200 Subject: [PATCH 25/51] feat(trust): persist exact revision and initial storage binding Keep owner-approved revisions separate from request identity and retain the original seed descriptor and first provider handle across rebuilds. Native authenticated gates and immutable SQLite constraints prevent trust or retained storage from being relabeled. Refs: #72 Co-authored-by: Codex --- apps/control_plane/lib/workharbor/accounts.ex | 7 +- .../lib/workharbor/config_trust.ex | 131 ++++++++++++ .../lib/workharbor/foundation.ex | 85 ++++++++ .../20261015000000_revision_trust.exs | 30 +++ .../test/support/config_trust_database.ex | 23 +++ .../test/workharbor/config_trust_test.exs | 189 ++++++++++++++++++ .../test/workharbor/storage_binding_test.exs | 116 +++++++++++ docs/exec-plans/active/installable-pilot.md | 21 ++ 8 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 apps/control_plane/lib/workharbor/config_trust.ex create mode 100644 apps/control_plane/priv/repo/migrations/20261015000000_revision_trust.exs create mode 100644 apps/control_plane/test/support/config_trust_database.ex create mode 100644 apps/control_plane/test/workharbor/config_trust_test.exs create mode 100644 apps/control_plane/test/workharbor/storage_binding_test.exs diff --git a/apps/control_plane/lib/workharbor/accounts.ex b/apps/control_plane/lib/workharbor/accounts.ex index cd26b7e9..bbba2490 100644 --- a/apps/control_plane/lib/workharbor/accounts.ex +++ b/apps/control_plane/lib/workharbor/accounts.ex @@ -40,9 +40,10 @@ defmodule WorkHarbor.Accounts do :issue_dashboard, :primary_login, :host_enrollment_issue, - :host_revoke + :host_revoke, + :config_approve ] - @reads [:status, :owner_page, :dashboard_page, :credential_list, :host_status] + @reads [:status, :owner_page, :dashboard_page, :credential_list, :host_status, :config_read] def now, do: Application.get_env(:workharbor, :clock, fn -> System.system_time(:second) end).() def bootstrap(deliver, owner_id \\ nil) when is_function(deliver, 1) do @@ -267,7 +268,7 @@ defmodule WorkHarbor.Accounts do defp action_allowed?(kind, :owner_page), do: kind == "owner_browser" defp action_allowed?(kind, :dashboard_page), do: kind == "dashboard" defp action_allowed?(kind, :host_status), do: kind in ["cli_owner", "owner_browser"] - defp action_allowed?(_, a) when a in [:status, :credential_list], do: true + defp action_allowed?(_, a) when a in [:status, :credential_list, :config_read], do: true defp action_allowed?(_, _), do: false defp safe_action(a) when a in @writes or a in @reads, do: Atom.to_string(a) defp safe_action(_), do: "unknown" diff --git a/apps/control_plane/lib/workharbor/config_trust.ex b/apps/control_plane/lib/workharbor/config_trust.ex new file mode 100644 index 00000000..e44ac673 --- /dev/null +++ b/apps/control_plane/lib/workharbor/config_trust.ex @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.ConfigTrust.Approval do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "revision_trust_approvals" do + field :owner_id, :binary_id + field :descriptor, :map, redact: true + field :descriptor_bytes, :binary, redact: true + field :descriptor_sha256, :string + field :approved_at, :integer + timestamps() + end +end + +defmodule WorkHarbor.ConfigTrust do + @moduledoc "Native exact-revision approval; no HTTP, dispatch or provider effects." + alias WorkHarbor.{Accounts, Repo, Hosts} + alias WorkHarbor.Accounts.Principal + alias WorkHarbor.Foundation.{Request, Canonical, AuditEvent} + alias __MODULE__.Approval + + def approve(%Principal{} = principal, descriptor) do + with :ok <- Accounts.authorize(principal, :config_approve), + {:ok, normalized, bytes, digest} <- normalize(descriptor) do + transaction(fn -> + authorize!(principal, :config_approve) + + case Repo.get_by(Approval, [owner_id: principal.owner_id, descriptor_sha256: digest], + log: false + ) do + nil -> + approval = + Repo.insert!( + %Approval{ + owner_id: principal.owner_id, + descriptor: normalized, + descriptor_bytes: bytes, + descriptor_sha256: digest, + approved_at: Accounts.now() + }, + log: false + ) + + Repo.insert!( + %AuditEvent{ + owner_id: principal.owner_id, + event: "revision_approved", + action: "config_approve", + channel: channel(principal), + outcome: "recorded", + code: "ok", + metadata: %{"approval_id" => approval.id} + }, + log: false + ) + + {:ok, approval} + + approval -> + if approval.descriptor_bytes == bytes and approval.descriptor == normalized, + do: {:ok, approval}, + else: Repo.rollback(:trust_conflict) + end + end) + end + end + + def approve(_, _), do: {:error, :forbidden} + + def lookup(%Principal{} = principal, reference, descriptor) do + with :ok <- Accounts.authorize(principal, :config_read) do + lookup_owner(principal.owner_id, reference, descriptor) + end + end + + def lookup(_, _, _), do: {:error, :forbidden} + + def lookup_for_host(%Hosts.Peer{} = peer, reference, descriptor) do + if Hosts.current_peer?(peer), + do: lookup_owner(peer.owner_id, reference, descriptor), + else: {:error, :forbidden} + end + + def lookup_for_host(_, _, _), do: {:error, :forbidden} + + defp lookup_owner(owner, reference, descriptor) do + with true <- Request.uuid?(reference), + {:ok, normalized, bytes, digest} <- normalize(descriptor), + %Approval{} = row <- Repo.get_by(Approval, [id: reference, owner_id: owner], log: false), + true <- + row.descriptor_sha256 == digest and row.descriptor_bytes == bytes and + row.descriptor == normalized do + {:ok, row} + else + _ -> {:error, :trust_required} + end + end + + defp normalize(descriptor) do + with {:ok, normalized} <- Request.normalize_config(descriptor), + {:ok, bytes} <- Canonical.bytes(normalized) do + {:ok, normalized, bytes, Base.encode16(:crypto.hash(:sha256, bytes), case: :lower)} + else + _ -> {:error, :invalid_configuration} + end + end + + defp authorize!(principal, action) do + if Accounts.authorize(principal, action) != :ok, do: Repo.rollback(:forbidden) + end + + defp channel(%Principal{kind: "owner_browser"}), do: "browser" + defp channel(_), do: "cli" + + defp transaction(fun) do + if Repo.in_transaction?() do + {:error, :nested_transaction} + else + case Repo.transaction(fun, mode: :immediate) do + {:ok, value} -> value + {:error, code} -> {:error, code} + end + end + rescue + _ in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + {:error, :persistence_failed} + end +end diff --git a/apps/control_plane/lib/workharbor/foundation.ex b/apps/control_plane/lib/workharbor/foundation.ex index a4fcdc4b..a4145d0c 100644 --- a/apps/control_plane/lib/workharbor/foundation.ex +++ b/apps/control_plane/lib/workharbor/foundation.ex @@ -118,6 +118,91 @@ defmodule WorkHarbor.Foundation do end end + @doc "Fenced native storage scope and immutable ORIGINAL seed binding; no provider effects." + def storage_binding(owner, host, operation, intent, storage) do + transaction(fn -> + {op, workspace} = scoped_operation!(owner, host, operation) + current_intent!(op, workspace, intent) + storage_binding!(op, workspace, storage) + end) + end + + @doc "Record the first completed provider handle without replacing retained storage." + def observe_storage(owner, host, operation, intent, storage, handle) do + if is_binary(handle) and String.valid?(handle) and handle != "" and + not String.contains?(handle, <<0>>) do + transaction(fn -> + {op, workspace} = scoped_operation!(owner, host, operation) + current_intent!(op, workspace, intent) + if op.status != "executing", do: Repo.rollback(:invalid_transition) + binding = storage_binding!(op, workspace, storage) + + case binding.provider_handle do + nil -> + {count, _} = + Repo.update_all( + from(s in PersistentStorage, + where: s.id == ^storage and is_nil(s.provider_handle) + ), + set: [provider_handle: handle, updated_at: now()] + ) + + if count != 1, do: Repo.rollback(:storage_mismatch) + + audit!(op, workspace, "storage_observed", "internal", "recorded", + storage_ids: [storage] + ) + + %{binding | provider_handle: handle} + + ^handle -> + binding + + _ -> + Repo.rollback(:storage_mismatch) + end + end) + else + {:error, :invalid_request} + end + end + + defp storage_binding!(operation, workspace, id) do + if operation.action not in ["create", "rebuild"], do: Repo.rollback(:invalid_action) + if operation.status not in ["queued", "executing"], do: Repo.rollback(:invalid_transition) + + storage = + if Request.uuid?(id), + do: + Repo.get_by(PersistentStorage, + id: id, + workspace_id: workspace.id, + host_id: workspace.host_id + ) + + if is_nil(storage) or not is_nil(storage.deleted_at), do: Repo.rollback(:storage_unavailable) + + original = + Repo.get_by(Operation, + id: storage.reservation_operation_id, + owner_id: operation.owner_id, + workspace_id: workspace.id, + action: "create" + ) + + if is_nil(original), do: Repo.rollback(:storage_mismatch) + + %{ + storage_id: storage.id, + owner_id: operation.owner_id, + host_id: workspace.host_id, + workspace_id: workspace.id, + reservation_operation_id: original.id, + initial_config: original.execution_config_snapshot, + provider_handle: storage.provider_handle + } + end + def claim(owner_id, host_id, operation_id, intent_version) do transaction(fn -> {operation, workspace} = scoped_operation!(owner_id, host_id, operation_id) diff --git a/apps/control_plane/priv/repo/migrations/20261015000000_revision_trust.exs b/apps/control_plane/priv/repo/migrations/20261015000000_revision_trust.exs new file mode 100644 index 00000000..73ea2ad4 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261015000000_revision_trust.exs @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.RevisionTrust do + use Ecto.Migration + + def up do + execute(""" + CREATE TABLE revision_trust_approvals ( + id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), + descriptor TEXT NOT NULL, descriptor_bytes BLOB NOT NULL, descriptor_sha256 TEXT NOT NULL, + approved_at INTEGER NOT NULL CHECK(typeof(approved_at)='integer' AND approved_at>=0), + inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, + UNIQUE(owner_id,descriptor_sha256), UNIQUE(owner_id,descriptor_bytes)) + """) + + execute( + "CREATE TRIGGER revision_approval_immutable BEFORE UPDATE ON revision_trust_approvals WHEN 1 BEGIN SELECT RAISE(ABORT, 'immutable revision approval'); END" + ) + + execute( + "CREATE TRIGGER revision_approval_retained BEFORE DELETE ON revision_trust_approvals WHEN 1 BEGIN SELECT RAISE(ABORT, 'retained revision approval'); END" + ) + end + + def down do + execute("DROP TRIGGER revision_approval_retained") + execute("DROP TRIGGER revision_approval_immutable") + execute("DROP TABLE revision_trust_approvals") + end +end diff --git a/apps/control_plane/test/support/config_trust_database.ex b/apps/control_plane/test/support/config_trust_database.ex new file mode 100644 index 00000000..e9459132 --- /dev/null +++ b/apps/control_plane/test/support/config_trust_database.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.ConfigTrustDatabase do + def open!(path) do + pid = WorkHarbor.HostDatabase.open!(path) + + unless Code.ensure_loaded?(WorkHarbor.Repo.Migrations.RevisionTrust) do + Code.require_file( + Application.app_dir(:workharbor, "priv/repo/migrations/20261015000000_revision_trust.exs") + ) + end + + Ecto.Migrator.run( + WorkHarbor.Repo, + [{20_261_015_000_000, WorkHarbor.Repo.Migrations.RevisionTrust}], + :up, + all: true, + log: false + ) + + pid + end +end diff --git a/apps/control_plane/test/workharbor/config_trust_test.exs b/apps/control_plane/test/workharbor/config_trust_test.exs new file mode 100644 index 00000000..4cd3ecb0 --- /dev/null +++ b/apps/control_plane/test/workharbor/config_trust_test.exs @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.ConfigTrustTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, ConfigTrust, Repo} + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-trust-#{Ecto.UUID.generate()}.db") + pid = WorkHarbor.ConfigTrustDatabase.open!(path) + Application.put_env(:workharbor, :clock, fn -> 2_000_000_000 end) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _token, principal} = Accounts.login(owner, secret, "cli_owner") + + on_exit(fn -> + Application.delete_env(:workharbor, :clock) + if Process.alive?(pid), do: GenServer.stop(pid) + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end) + + %{owner: owner, secret: secret, principal: principal} + end + + test "exact approval repeats without a second approval or audit", ctx do + descriptor = DB.config() + assert {:ok, first} = ConfigTrust.approve(ctx.principal, descriptor) + assert first.owner_id == ctx.owner + assert first.descriptor == descriptor + assert first.approved_at == 2_000_000_000 + assert {:ok, second} = ConfigTrust.approve(ctx.principal, descriptor) + assert first.id == second.id + assert {:ok, ^first} = ConfigTrust.lookup(ctx.principal, first.id, descriptor) + assert DB.count("revision_trust_approvals") == 1 + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event='revision_approved'").rows == [ + [1] + ] + end + + test "changed config submodule origin and complete set require new approval", ctx do + d = + Map.put(DB.config(), "submodules", [ + %{"path" => "projects/app", "commit" => String.duplicate("b", 40)} + ]) + + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, d) + + for changed <- [ + Map.put(d, "commit", String.duplicate("c", 40)), + Map.put(d, "repository_identity", "git@EXAMPLE.test:private-repository.git"), + Map.put(d, "submodules", []), + put_in(d, ["submodules", Access.at(0), "commit"], String.duplicate("d", 40)) + ] do + assert {:error, :trust_required} = ConfigTrust.lookup(ctx.principal, approval.id, changed) + assert {:ok, next} = ConfigTrust.approve(ctx.principal, changed) + refute next.id == approval.id + end + end + + test "unknown null malformed descriptors never approve", ctx do + for bad <- [ + nil, + %{}, + Map.put(DB.config(), "owner_id", ctx.owner), + Map.put(DB.config(), "commit", "main"), + Map.put(DB.config(), "submodules", nil) + ] do + assert {:error, :invalid_configuration} = ConfigTrust.approve(ctx.principal, bad) + end + + assert DB.count("revision_trust_approvals") == 0 + end + + test "dashboard revoked and forged principals cannot approve", ctx do + {:ok, _token, dashboard} = Accounts.login(ctx.owner, ctx.secret, "dashboard") + assert {:error, :forbidden} = ConfigTrust.approve(dashboard, DB.config()) + forged = %{ctx.principal | owner_id: Ecto.UUID.generate()} + assert {:error, :forbidden} = ConfigTrust.approve(forged, DB.config()) + assert :ok = Accounts.revoke(ctx.principal, ctx.principal.credential_id) + assert {:error, :forbidden} = ConfigTrust.approve(ctx.principal, DB.config()) + end + + test "dashboard may read exact approval but cannot use a foreign owner", ctx do + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, DB.config()) + {:ok, _token, dashboard} = Accounts.login(ctx.owner, ctx.secret, "dashboard") + assert {:ok, _} = ConfigTrust.lookup(dashboard, approval.id, DB.config()) + + assert {:error, :forbidden} = + ConfigTrust.lookup( + %{dashboard | owner_id: Ecto.UUID.generate()}, + approval.id, + DB.config() + ) + end + + test "expired credentials cannot approve or read", ctx do + Application.put_env(:workharbor, :clock, fn -> ctx.principal.expires_at end) + assert {:error, :forbidden} = ConfigTrust.approve(ctx.principal, DB.config()) + + assert {:error, :forbidden} = + ConfigTrust.lookup(ctx.principal, Ecto.UUID.generate(), DB.config()) + end + + test "approval identity is immutable and owner FK is enforced", ctx do + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, DB.config()) + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE revision_trust_approvals SET approved_at=0 WHERE id=?", [approval.id]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("DELETE FROM revision_trust_approvals WHERE id=?", [approval.id]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql( + """ + INSERT INTO revision_trust_approvals + (id,owner_id,descriptor,descriptor_bytes,descriptor_sha256,approved_at,inserted_at,updated_at) + VALUES (?,?,'{}',X'00','foreign',0,'now','now') + """, + [Ecto.UUID.generate(), Ecto.UUID.generate()] + ) + end + + assert {:ok, ^approval} = ConfigTrust.lookup(ctx.principal, approval.id, DB.config()) + refute inspect(approval) =~ "private-repository" + end + + test "host approval lookup rechecks genuine current assignment and revocation", ctx do + dir = + Path.join( + WorkHarbor.HostFixture.physical_temp(), + "whr-trust-issuer-#{Ecto.UUID.generate()}" + ) + + previous = Application.get_env(:workharbor, :host_identity) + + on_exit(fn -> + if previous, + do: Application.put_env(:workharbor, :host_identity, previous), + else: Application.delete_env(:workharbor, :host_identity) + + File.rm_rf!(dir) + end) + + assert {:ok, _} = WorkHarbor.Hosts.Issuer.initialize(dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: dir) + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, DB.config()) + assert {:ok, issued} = WorkHarbor.Hosts.issue_enrollment(ctx.principal, nil) + {csr, _} = WorkHarbor.HostFixture.csr() + assert {:ok, enrolled} = WorkHarbor.Hosts.redeem(issued.code, csr) + + assert {:ok, peer} = + WorkHarbor.Hosts.resolve_identity(Base.decode64!(enrolled["certificate"]), :active) + + assert {:ok, ^approval} = ConfigTrust.lookup_for_host(peer, approval.id, DB.config()) + + assert {:error, :trust_required} = + ConfigTrust.lookup_for_host(peer, approval.id, DB.config(String.duplicate("c", 40))) + + assert {:error, :forbidden} = + ConfigTrust.lookup_for_host( + %{peer | owner_id: Ecto.UUID.generate()}, + approval.id, + DB.config() + ) + + assert :ok = WorkHarbor.Hosts.revoke(ctx.principal, peer.host_id) + assert {:error, :forbidden} = ConfigTrust.lookup_for_host(peer, approval.id, DB.config()) + end + + test "concurrent independent writers record one immutable revision approval", ctx do + repo = Repo.get_dynamic_repo() + + results = + for _ <- 1..4 do + Task.async(fn -> + Repo.put_dynamic_repo(repo) + ConfigTrust.approve(ctx.principal, DB.config()) + end) + end + |> Enum.map(&Task.await(&1, 5000)) + + assert Enum.all?(results, &match?({:ok, _}, &1)) + assert results |> Enum.map(fn {:ok, row} -> row.id end) |> Enum.uniq() |> length() == 1 + assert DB.count("revision_trust_approvals") == 1 + end +end diff --git a/apps/control_plane/test/workharbor/storage_binding_test.exs b/apps/control_plane/test/workharbor/storage_binding_test.exs new file mode 100644 index 00000000..c2de2753 --- /dev/null +++ b/apps/control_plane/test/workharbor/storage_binding_test.exs @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.StorageBindingTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Foundation, Repo} + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "whr-storage-#{Ecto.UUID.generate()}.db") + repo = DB.open!(path) + {owner, host} = DB.fixture!() + {:ok, {:accepted, op}} = Foundation.accept(owner, "create", DB.request(host), "cli") + [[storage]] = DB.sql("SELECT id FROM persistent_storage").rows + + on_exit(fn -> + if Process.alive?(repo), do: GenServer.stop(repo) + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end) + + %{repo: repo, owner: owner, host: host, op: op, storage: storage} + end + + defp storage_binding(c, op \\ nil), + do: + Foundation.storage_binding( + c.owner, + c.host, + (op || c.op).id, + (op || c.op).intent_version, + c.storage + ) + + defp observe(c, handle), + do: + Foundation.observe_storage(c.owner, c.host, c.op.id, c.op.intent_version, c.storage, handle) + + test "binding carries original create descriptor and reservation", c do + assert {:ok, b} = storage_binding(c) + + assert b == %{ + storage_id: c.storage, + owner_id: c.owner, + host_id: c.host, + workspace_id: c.op.workspace_id, + reservation_operation_id: c.op.id, + initial_config: DB.config(), + provider_handle: nil + } + end + + test "first handle requires executing current scope and repeats immutably", c do + assert {:error, :invalid_transition} = observe(c, "host-private-handle") + assert {:ok, {:claimed, _}} = Foundation.claim(c.owner, c.host, c.op.id, 1) + assert {:ok, b} = observe(c, "host-private-handle") + assert b.provider_handle == "host-private-handle" + assert {:ok, ^b} = observe(c, "host-private-handle") + assert {:error, :storage_mismatch} = observe(c, "different-handle") + assert {:error, :invalid_request} = observe(c, "") + assert {:ok, ^b} = storage_binding(c) + end + + test "foreign operation storage host owner and stale intent refuse", c do + for {owner, host, op, intent, storage} <- [ + {Ecto.UUID.generate(), c.host, c.op.id, 1, c.storage}, + {c.owner, Ecto.UUID.generate(), c.op.id, 1, c.storage}, + {c.owner, c.host, Ecto.UUID.generate(), 1, c.storage}, + {c.owner, c.host, c.op.id, 1, Ecto.UUID.generate()}, + {c.owner, c.host, c.op.id, 0, c.storage} + ] do + assert {:error, code} = Foundation.storage_binding(owner, host, op, intent, storage) + + assert {:error, other} = + Foundation.observe_storage(owner, host, op, intent, storage, "handle") + + assert code in [:not_found, :storage_unavailable, :stale_intent] + assert other in [:not_found, :invalid_transition, :stale_intent] + end + + assert [[nil]] = DB.sql("SELECT provider_handle FROM persistent_storage").rows + end + + test "later approved revision retains original seed and handle", c do + {:ok, {:claimed, _}} = Foundation.claim(c.owner, c.host, c.op.id, 1) + assert {:ok, _} = observe(c, "retained") + DB.finish!(c.owner, c.host, %{c.op | status: "executing"}) + + request = %{ + DB.request(c.host, c.op.workspace_id, "rebuild") + | "parameters" => %{"config" => DB.config(String.duplicate("b", 40))} + } + + {:ok, {:accepted, next}} = Foundation.accept(c.owner, "rebuild", request, "cli") + assert next.execution_config_snapshot != DB.config() + assert {:ok, b} = storage_binding(c, next) + assert b.initial_config == DB.config() + assert b.reservation_operation_id == c.op.id + assert b.provider_handle == "retained" + assert {:error, :stale_intent} = storage_binding(c) + end + + test "independent competing handles have one winner and never replace", c do + {:ok, {:claimed, _}} = Foundation.claim(c.owner, c.host, c.op.id, 1) + + results = + for handle <- ["one", "two"] do + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + observe(c, handle) + end) + end + |> Enum.map(&Task.await(&1, 5000)) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :storage_mismatch})) == 1 + end +end diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 1557e5ee..e9de9a23 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1191,3 +1191,24 @@ containment, filesystem quota enforcement or retained runtime data. The P3 fresh or a product/spec requirement. Reviewed reversible plan amendments remain within existing authorization; no additional human approval gate is invented. Only a committed version of this section is a durable tracker; dirty edits are preparation. + +### T009 native approval/storage binding checkpoint + +The native control-plane approval and storage-binding slice is implemented; it is +not Feature006 T009 acceptance. Exact-revision approval is separate from request +identity, authenticated at the native Accounts/Hosts boundary, and immutable in +SQLite. Storage binding retains the ORIGINAL create descriptor and first observed +provider handle; later configuration revisions never replace that initial binding. +No lifecycle dispatch, provider wire activation or terminal implementation is added. + +After formatting these files, bounded native `ERL_FLAGS='+S 2:2' MIX_ENV=test mix +test --warnings-as-errors` passed all187 tests in92.3s. Original complete log: +/private/tmp/workharbor-t009-control-plane-native-slices.log (exit0). Expected +transport refusal and SQLite lock-contention diagnostics remain in the raw log; +no failed test is hidden. Native host-agent `GOMAXPROCS=2 go test -p 1 -count=1 +./...` also passed its completed config/tool/storage/provider slice checkpoint: +/private/tmp/workharbor-t009-host-native-slices.log (exit0). Subsequent admission +scaffolding is separate unfinished source, so that checkpoint is not a claim for +the eventual complete candidate. Independent exact-source review, pristine/native +application and manifest gates, and required real Linux/Docker evidence remain +outstanding. Feature006 T009 stays unchecked and Feature006 T010 still waits. From ed902729ac019ff8ee4685bbced8e79bf679dfd2 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 15:09:22 +0200 Subject: [PATCH 26/51] feat(config): resolve and freeze approved container inputs Resolve exact Git object formats and approved submodules in private staging; reject ambiguous JSONC and Dockerfile capabilities before execution. Freeze configuration inputs separately from retained data and lock maintained parsers. Refs: #72 Co-authored-by: Codex --- apps/host_agent/go.mod | 13 +- apps/host_agent/go.sum | 14 + .../internal/config/dockerfile_plan.go | 64 +++ .../internal/config/dockerfile_plan_test.go | 27 ++ apps/host_agent/internal/config/frozen.go | 101 +++++ apps/host_agent/internal/config/jsonc.go | 50 +++ apps/host_agent/internal/config/jsonc_test.go | 50 +++ apps/host_agent/internal/config/resolver.go | 393 +++++++++++++++++ .../internal/config/resolver_test.go | 227 ++++++++++ apps/host_agent/internal/config/trusted.go | 407 ++++++++++++++++++ .../internal/config/trusted_test.go | 140 ++++++ .../internal/devcontainer/parse.cjs | 41 ++ .../internal/devcontainer/tool-files.json | 16 + .../host_agent/internal/devcontainer/tools.go | 92 ++++ .../internal/devcontainer/tools_test.go | 50 +++ docs/exec-plans/active/installable-pilot.md | 32 ++ 16 files changed, 1715 insertions(+), 2 deletions(-) create mode 100644 apps/host_agent/internal/config/dockerfile_plan.go create mode 100644 apps/host_agent/internal/config/dockerfile_plan_test.go create mode 100644 apps/host_agent/internal/config/frozen.go create mode 100644 apps/host_agent/internal/config/jsonc.go create mode 100644 apps/host_agent/internal/config/jsonc_test.go create mode 100644 apps/host_agent/internal/config/resolver.go create mode 100644 apps/host_agent/internal/config/resolver_test.go create mode 100644 apps/host_agent/internal/config/trusted.go create mode 100644 apps/host_agent/internal/config/trusted_test.go create mode 100644 apps/host_agent/internal/devcontainer/parse.cjs create mode 100644 apps/host_agent/internal/devcontainer/tool-files.json create mode 100644 apps/host_agent/internal/devcontainer/tools.go create mode 100644 apps/host_agent/internal/devcontainer/tools_test.go diff --git a/apps/host_agent/go.mod b/apps/host_agent/go.mod index b7f06473..66ccf56d 100644 --- a/apps/host_agent/go.mod +++ b/apps/host_agent/go.mod @@ -2,12 +2,21 @@ module github.com/wstein/workharbor-nxt/apps/host_agent go 1.27.2 -require github.com/coder/websocket v1.8.15 +require ( + github.com/coder/websocket v1.8.15 + github.com/moby/buildkit v0.34.0 + github.com/moby/docker-image-spec v1.3.1 + golang.org/x/sys v0.48.0 +) require ( + github.com/agext/levenshtein v1.2.3 // indirect github.com/containerd/typeurl/v2 v2.3.0 // indirect - github.com/moby/buildkit v0.34.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/apps/host_agent/go.sum b/apps/host_agent/go.sum index 8616716d..a2f1d66f 100644 --- a/apps/host_agent/go.sum +++ b/apps/host_agent/go.sum @@ -1,12 +1,26 @@ +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/moby/buildkit v0.34.0 h1:bG5h2y1s5IVvc/uSGo6hVlYbd2eXVFRuADl9Zf+jeJM= github.com/moby/buildkit v0.34.0/go.mod h1:e+Za70y2a3nL1U2Y0P8TvhPyKp2Ok2NS+Q4siSArDiM= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE= +github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/apps/host_agent/internal/config/dockerfile_plan.go b/apps/host_agent/internal/config/dockerfile_plan.go new file mode 100644 index 00000000..c12a2fee --- /dev/null +++ b/apps/host_agent/internal/config/dockerfile_plan.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "bytes" + "github.com/moby/buildkit/frontend/dockerfile/parser" + "strings" +) + +type DockerfilePlan struct { + Bases []string + SelectedBase, Stage string + LastStageUnnamed bool +} + +// AnalyzeDockerfile selects only a literal named stage or the final stage. The +// maintained AST remains the source of syntax; this does not execute a Dockerfile. +func AnalyzeDockerfile(raw []byte, target string) (DockerfilePlan, error) { + refs, e := ValidateDockerfile(raw) + if e != nil { + return DockerfilePlan{}, e + } + parsed, e := parser.Parse(bytes.NewReader(raw)) + if e != nil { + return DockerfilePlan{}, unsupportedConfiguration() + } + aliases := map[string]string{} + selected := DockerfilePlan{Bases: refs} + found := target == "" + for _, node := range parsed.AST.Children { + if !strings.EqualFold(node.Value, "from") { + continue + } + ref := node.Next.Value + base := ref + if inherited, ok := aliases[strings.ToLower(ref)]; ok { + base = inherited + } + name := "" + if node.Next.Next != nil { + name = node.Next.Next.Next.Value + } + if name == "dev_containers_target_stage" { + return DockerfilePlan{}, unsupportedConfiguration() + } + if name != "" { + aliases[strings.ToLower(name)] = base + } + if target == "" || name == target { + stage := name + if stage == "" { + stage = "dev_container_auto_added_stage_label" + } + selected.SelectedBase = base + selected.Stage = stage + selected.LastStageUnnamed = name == "" + found = true + } + } + if !found || selected.SelectedBase == "" { + return DockerfilePlan{}, unsupportedConfiguration() + } + return selected, nil +} diff --git a/apps/host_agent/internal/config/dockerfile_plan_test.go b/apps/host_agent/internal/config/dockerfile_plan_test.go new file mode 100644 index 00000000..e37d1674 --- /dev/null +++ b/apps/host_agent/internal/config/dockerfile_plan_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "strings" + "testing" +) + +func TestDockerfilePlanBindsSelectedStageToExternalBase(t *testing.T) { + a := "ubuntu@sha256:" + strings.Repeat("a", 64) + b := "debian@sha256:" + strings.Repeat("b", 64) + raw := []byte("FROM " + a + " AS base\nRUN true\nFROM base AS app\nFROM " + b + " AS unused\n") + for _, tt := range []struct{ target, base, stage string }{{"app", a, "app"}, {"", b, "unused"}} { + p, e := AnalyzeDockerfile(raw, tt.target) + if e != nil || p.SelectedBase != tt.base || p.Stage != tt.stage || len(p.Bases) != 2 { + t.Fatal(p, e) + } + } + for _, target := range []string{"missing", "0", "${TARGET}"} { + _, e := AnalyzeDockerfile(raw, target) + code(t, e, "unsupported_configuration") + } + p, e := AnalyzeDockerfile([]byte("FROM "+a+"\nRUN true\n"), "") + if e != nil || !p.LastStageUnnamed || p.Stage != "dev_container_auto_added_stage_label" { + t.Fatal(p, e) + } +} diff --git a/apps/host_agent/internal/config/frozen.go b/apps/host_agent/internal/config/frozen.go new file mode 100644 index 00000000..13048e19 --- /dev/null +++ b/apps/host_agent/internal/config/frozen.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" +) + +type frozenEntry struct { + Path string + Mode uint32 + Size int64 + Content string +} + +func fingerprint(ctx context.Context, root string) (string, error) { + physical, e := filepath.EvalSymlinks(root) + if e != nil || physical != root { + return "", invalidConfiguration() + } + h := sha256.New() + enc := json.NewEncoder(h) + e = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if e := ctx.Err(); e != nil { + return e + } + if walkErr != nil { + return walkErr + } + info, e := os.Lstat(path) + if e != nil { + return e + } + relative, e := filepath.Rel(root, path) + if e != nil { + return e + } + record := frozenEntry{Path: filepath.ToSlash(relative), Mode: uint32(info.Mode()), Size: info.Size()} + switch { + case info.Mode().IsRegular(): + f, e := os.Open(path) + if e != nil { + return e + } + actual, e := f.Stat() + if e != nil || !os.SameFile(info, actual) { + f.Close() + return invalidConfiguration() + } + digest := sha256.New() + _, e = io.Copy(digest, f) + ce := f.Close() + if e != nil { + return e + } + if ce != nil { + return ce + } + record.Content = hex.EncodeToString(digest.Sum(nil)) + case info.Mode()&os.ModeSymlink != 0: + record.Content, e = os.Readlink(path) + if e != nil { + return e + } + case info.IsDir(): + record.Size = 0 + default: + return unsupportedConfiguration() + } + return enc.Encode(record) + }) + if e != nil { + return "", e + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// Check verifies resolver-owned immutable configuration/build/initial-copy inputs. +// It never inspects retained project data or grants operation/approval authority. +func materializedFields(s *Resolved) []byte { + raw, _ := json.Marshal(map[string]any{"root": s.Root, "devcontainer": s.DevcontainerPath, "projects": s.ProjectsPath, "descriptor": s.Descriptor.Map(), "source": s.Source, "bases": s.Bases}) + return raw +} +func (s *Resolved) Check() error { return s.CheckContext(context.Background()) } +func (s *Resolved) CheckContext(ctx context.Context) error { + if s == nil || s.frozen == "" || s.privateParent == "" || len(s.sealed) == 0 || !bytes.Equal(s.sealed, materializedFields(s)) { + return &Error{Code: "configuration_changed"} + } + actual, e := fingerprint(ctx, s.Root) + if e != nil || actual != s.frozen { + return &Error{Code: "configuration_changed"} + } + return nil +} diff --git a/apps/host_agent/internal/config/jsonc.go b/apps/host_agent/internal/config/jsonc.go new file mode 100644 index 00000000..0436c24b --- /dev/null +++ b/apps/host_agent/internal/config/jsonc.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os/exec" + "unicode/utf8" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer" +) + +// JSONC executes only the installed product companion, never repository executables. +type JSONC struct{ NodePath, CompanionDir string } + +func (p JSONC) Parse(ctx context.Context, raw []byte, strict bool) (any, error) { + if !utf8.Valid(raw) { + return nil, invalidConfiguration() + } + tools := devcontainer.Tools{NodePath: p.NodePath, Root: p.CompanionDir} + if tools.Verify(ctx, false) != nil { + return nil, &Error{Code: "tool_unavailable"} + } + mode := "jsonc" + if strict { + mode = "strict" + } + cmd := exec.CommandContext(ctx, p.NodePath, "-e", devcontainer.ParserScript, mode) + cmd.Env = tools.Environment() + cmd.Dir = p.CompanionDir + cmd.Stdin = bytes.NewReader(raw) + cmd.Stderr = io.Discard + output, err := cmd.Output() + if err != nil { + return nil, invalidConfiguration() + } + decoder := json.NewDecoder(bytes.NewReader(output)) + decoder.UseNumber() + var value any + if decoder.Decode(&value) != nil { + return nil, invalidConfiguration() + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return nil, invalidConfiguration() + } + return value, nil +} diff --git a/apps/host_agent/internal/config/jsonc_test.go b/apps/host_agent/internal/config/jsonc_test.go new file mode 100644 index 00000000..8362bfaa --- /dev/null +++ b/apps/host_agent/internal/config/jsonc_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "context" + "os/exec" + "path/filepath" + "testing" +) + +func jsoncTool(t *testing.T) JSONC { + t.Helper() + node, e := exec.LookPath("node") + if e != nil { + t.Fatal(e) + } + dir, e := filepath.Abs("../../tools/devcontainer") + if e != nil { + t.Fatal(e) + } + return JSONC{NodePath: node, CompanionDir: dir} +} +func TestJSONCCommentsTrailingCommaAndScalarStrings(t *testing.T) { + parser := jsoncTool(t) + v, e := parser.Parse(context.Background(), []byte("{/*comment*/\"name\":\"\\ud800\\udc00\",}"), false) + if e != nil { + t.Fatal(e) + } + m, ok := v.(map[string]any) + if !ok || m["name"] != "\U00010000" { + t.Fatalf("value=%#v", v) + } +} +func TestJSONCRejectsDeepEscapedDuplicatesAndMalformedScalars(t *testing.T) { + parser := jsoncTool(t) + for _, raw := range [][]byte{[]byte(`{"a":1,"\u0061":2}`), []byte(`{"x":[{"a":1,"a":2}]}`), []byte(`{"x":"\ud800"}`), []byte(`{"\udc00":1}`), {255}, []byte(`{"x":`), []byte(`{"x":1}garbage`)} { + _, e := parser.Parse(context.Background(), raw, false) + code(t, e, "invalid_configuration") + } +} +func TestStrictMetadataJSONRefusesCommentsAndTrailingComma(t *testing.T) { + parser := jsoncTool(t) + for _, raw := range []string{`{/*comment*/"x":1}`, `{"x":1,}`} { + _, e := parser.Parse(context.Background(), []byte(raw), true) + code(t, e, "invalid_configuration") + } + if _, e := parser.Parse(context.Background(), []byte(`[{"remoteUser":"vscode"}]`), true); e != nil { + t.Fatal(e) + } +} diff --git a/apps/host_agent/internal/config/resolver.go b/apps/host_agent/internal/config/resolver.go new file mode 100644 index 00000000..632b3b1d --- /dev/null +++ b/apps/host_agent/internal/config/resolver.go @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "sort" + "strings" +) + +// Resolver owns fresh configuration staging only. It never opens retained data. +// Credential paths are explicit host configuration, never repository input. +type Resolver struct { + GitPath, StageRoot, CAFile, SSHPath, KnownHostsFile, IdentityFile string + Parser JSONC +} +type Resolved struct { + Root, DevcontainerPath, ProjectsPath string + Descriptor Descriptor + Source map[string]any + Bases []string + privateParent string + frozen string + sealed []byte +} + +func (s *Resolved) Close() error { + if s.privateParent == "" { + return nil + } + e := os.RemoveAll(s.privateParent) + if e == nil { + s.privateParent = "" + } + return e +} + +type treeEntry struct{ mode, hash, path string } + +func (r Resolver) Resolve(ctx context.Context, d Descriptor) (result *Resolved, err error) { + if _, e := DecodeDescriptor(d.Map()); e != nil { + return nil, e + } + if !filepath.IsAbs(r.GitPath) || !filepath.IsAbs(r.StageRoot) { + return nil, &Error{Code: "tool_unavailable"} + } + stageRoot, e := filepath.EvalSymlinks(r.StageRoot) + info, statErr := os.Stat(stageRoot) + if e != nil || statErr != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + return nil, &Error{Code: "staging_failed"} + } + stage, e := os.MkdirTemp(stageRoot, "config-") + if e != nil { + return nil, &Error{Code: "staging_failed"} + } + defer func() { + if err != nil { + os.RemoveAll(stage) + } + }() + home := filepath.Join(stage, "home") + root := filepath.Join(stage, "repository") + if os.Mkdir(home, 0700) != nil || os.Mkdir(root, 0700) != nil { + return nil, &Error{Code: "staging_failed"} + } + env := []string{"PATH=" + filepath.Dir(r.GitPath) + ":/usr/bin:/bin", "HOME=" + home, "XDG_CONFIG_HOME=" + home, "LANG=C.UTF-8", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "SSH_ASKPASS=/bin/false", "GIT_ALLOW_PROTOCOL=https:ssh", "GIT_LFS_SKIP_SMUDGE=1"} + flags := []string{"-c", "core.hooksPath=/dev/null", "-c", "init.templateDir=" + home, "-c", "credential.helper=", "-c", "core.attributesFile=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always"} + if r.CAFile != "" { + if !filepath.IsAbs(r.CAFile) { + return nil, &Error{Code: "tool_unavailable"} + } + flags = append(flags, "-c", "http.sslCAInfo="+r.CAFile) + } + // No ambient SSH config, agent, askpass or credentials. Explicit host-approved key only. + sshConfigured := r.SSHPath != "" || r.KnownHostsFile != "" || r.IdentityFile != "" + if sshConfigured || !strings.HasPrefix(strings.ToLower(d.RepositoryIdentity), "https://") { + if !filepath.IsAbs(r.SSHPath) || !filepath.IsAbs(r.KnownHostsFile) || !filepath.IsAbs(r.IdentityFile) { + return nil, &Error{Code: "credential_unavailable"} + } + ssh := []string{r.SSHPath, "-F", "/dev/null", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=none", "-o", "UserKnownHostsFile=" + r.KnownHostsFile, "-i", r.IdentityFile} + for i := range ssh { + ssh[i] = "'" + strings.ReplaceAll(ssh[i], "'", "'\\''") + "'" + } + flags = append(flags, "-c", "core.sshCommand="+strings.Join(ssh, " ")) + } else { + flags = append(flags, "-c", "core.sshCommand=/bin/false") + } + git := func(dir string, args ...string) ([]byte, error) { + c := exec.CommandContext(ctx, r.GitPath, append(append([]string{}, flags...), args...)...) + c.Dir = dir + c.Env = env + c.Stderr = io.Discard + b, e := c.Output() + if e != nil { + return nil, &Error{Code: "revision_unavailable"} + } + return b, nil + } + fetch := func(dir, origin, commit string) error { + if !strings.HasPrefix(strings.ToLower(origin), "https://") && !sshConfigured { + return &Error{Code: "credential_unavailable"} + } + format := "sha1" + if len(commit) == 64 { + format = "sha256" + } + for _, args := range [][]string{{"init", "--quiet", "--object-format=" + format}, {"remote", "add", "origin", origin}, {"fetch", "--no-tags", "--no-recurse-submodules", "origin", commit}} { + if _, e := git(dir, args...); e != nil { + return e + } + } + b, e := git(dir, "rev-parse", "--verify", commit+"^{commit}") + if e != nil || strings.TrimSpace(string(b)) != commit { + return &Error{Code: "revision_unavailable"} + } + return nil + } + if e := fetch(root, d.RepositoryIdentity, d.Commit); e != nil { + return nil, e + } + entries, e := readTree(git, root, d.Commit) + if e != nil { + return nil, e + } + modules, e := readModules(git, root, d.Commit, entries) + if e != nil { + return nil, e + } + actual := make([]Submodule, 0, len(modules)) + for _, m := range modules { + actual = append(actual, Submodule{m.path, m.commit}) + } + sort.Slice(actual, func(i, j int) bool { return actual[i].Path < actual[j].Path }) + if !reflect.DeepEqual(actual, d.Submodules) { + return nil, invalidConfiguration() + } + if _, e := git(root, "checkout", "--quiet", "--detach", d.Commit); e != nil { + return nil, e + } + for _, m := range modules { + dir, e := ContainedPath(root, m.path, true) + if e != nil { + return nil, e + } + // Every submodule is initialized explicitly at its approved gitlink, never recursively. + if e := fetch(dir, m.origin, m.commit); e != nil { + return nil, e + } + child, e := readTree(git, dir, m.commit) + if e != nil { + return nil, e + } + nested, e := readModules(git, dir, m.commit, child) + if e != nil || len(nested) != 0 { + return nil, unsupportedConfiguration() + } + if _, e := git(dir, "checkout", "--quiet", "--detach", m.commit); e != nil { + return nil, e + } + } + manifestPath, e := ContainedPath(root, "workharbor.json", false) + if e != nil { + return nil, e + } + raw, e := os.ReadFile(manifestPath) + if e != nil { + return nil, invalidConfiguration() + } + v, e := r.Parser.Parse(ctx, raw, true) + if e != nil { + return nil, e + } + manifest, ok := v.(map[string]any) + if !ok || !fields(manifest, []string{"schema_version", "devcontainer_path", "projects_path", "bundles"}) || manifest["schema_version"] != json.Number("1") { + return nil, invalidConfiguration() + } + bundles, ok := manifest["bundles"].([]any) + if !ok || len(bundles) != 0 { + return nil, unsupportedConfiguration() + } + dc, e := CanonicalPath(manifest["devcontainer_path"]) + if e != nil { + return nil, invalidConfiguration() + } + projects, e := CanonicalPath(manifest["projects_path"]) + if e != nil { + return nil, invalidConfiguration() + } + dcPath, e := ContainedPath(root, dc, false) + if e != nil { + return nil, e + } + projectsPath, e := ContainedPath(root, projects, true) + if e != nil { + return nil, e + } + raw, e = os.ReadFile(dcPath) + if e != nil { + return nil, invalidConfiguration() + } + v, e = r.Parser.Parse(ctx, raw, false) + if e != nil { + return nil, e + } + source, ok := v.(map[string]any) + if !ok { + return nil, invalidConfiguration() + } + if e := ValidateSource(source); e != nil { + return nil, e + } + bases := []string{} + if image, ok := source["image"].(string); ok { + bases = append(bases, image) + } else { + build := source["build"].(map[string]any) + file, e := relativeInput(root, filepath.Dir(dcPath), build["dockerfile"].(string), false) + if e != nil { + return nil, e + } + contextPath := "." + if value, ok := build["context"].(string); ok { + contextPath = value + } + if _, e := relativeInput(root, filepath.Dir(dcPath), contextPath, true); e != nil { + return nil, e + } + raw, e := os.ReadFile(file) + if e != nil { + return nil, invalidConfiguration() + } + bases, e = ValidateDockerfile(raw) + if e != nil { + return nil, e + } + } + digest, e := fingerprint(ctx, root) + if e != nil { + return nil, e + } + resolved := &Resolved{Root: root, DevcontainerPath: dcPath, ProjectsPath: projectsPath, Descriptor: d, Source: source, Bases: bases, privateParent: stage, frozen: digest} + resolved.sealed = materializedFields(resolved) + return resolved, nil +} +func relativeInput(root, base, path string, dir bool) (string, error) { + if path == "" || filepath.IsAbs(path) || strings.ContainsAny(path, "\\\x00\r\n\t") { + return "", invalidConfiguration() + } + absolute := filepath.Clean(filepath.Join(base, path)) + rel, e := filepath.Rel(root, absolute) + if e != nil || rel == ".." || strings.HasPrefix(rel, "../") { + return "", invalidConfiguration() + } + return ContainedPath(root, filepath.ToSlash(rel), dir) +} + +// ContainedPath checks each actual component without following symlinks or Git entries. +// The root must be the resolver-owned stage or another trusted private directory. +func ContainedPath(root, path string, dir bool) (string, error) { + if _, e := CanonicalPathWithDot(path, true); e != nil { + return "", invalidConfiguration() + } + current := root + parts := strings.Split(path, "/") + if path == "." { + parts = nil + } + for i, part := range parts { + if strings.EqualFold(part, ".git") { + return "", invalidConfiguration() + } + current = filepath.Join(current, part) + info, e := os.Lstat(current) + if e != nil || info.Mode()&os.ModeSymlink != 0 { + return "", invalidConfiguration() + } + if i < len(parts)-1 && !info.IsDir() { + return "", invalidConfiguration() + } + } + info, e := os.Lstat(current) + if e != nil || info.Mode()&os.ModeSymlink != 0 || info.IsDir() != dir || (!dir && !info.Mode().IsRegular()) { + return "", invalidConfiguration() + } + return current, nil +} +func readTree(git func(string, ...string) ([]byte, error), dir, commit string) ([]treeEntry, error) { + raw, e := git(dir, "ls-tree", "-rz", commit) + if e != nil { + return nil, e + } + entries := []treeEntry{} + for _, line := range bytes.Split(raw, []byte{0}) { + if len(line) == 0 { + continue + } + parts := bytes.SplitN(line, []byte{'\t'}, 2) + if len(parts) != 2 { + return nil, invalidConfiguration() + } + header := strings.Fields(string(parts[0])) + if len(header) != 3 { + return nil, invalidConfiguration() + } + path := string(parts[1]) + if _, e := CanonicalPath(path); e != nil { + return nil, invalidConfiguration() + } + for _, p := range strings.Split(path, "/") { + if strings.EqualFold(p, ".git") { + return nil, invalidConfiguration() + } + } + entries = append(entries, treeEntry{header[0], header[2], path}) + } + return entries, nil +} + +type gitModule struct{ path, origin, commit string } + +func readModules(git func(string, ...string) ([]byte, error), dir, commit string, entries []treeEntry) ([]gitModule, error) { + links := map[string]string{} + hasManifest := false + for _, entry := range entries { + if entry.mode == "160000" { + links[entry.path] = entry.hash + } + if entry.path == ".gitmodules" { + if entry.mode != "100644" && entry.mode != "100755" { + return nil, invalidConfiguration() + } + hasManifest = true + } + } + byName := map[string]map[string]string{} + if hasManifest { + raw, e := git(dir, "config", "--no-includes", "--blob", commit+":.gitmodules", "--null", "--list") + if e != nil { + return nil, invalidConfiguration() + } + for _, record := range bytes.Split(raw, []byte{0}) { + if len(record) == 0 { + continue + } + pair := bytes.SplitN(record, []byte{'\n'}, 2) + if len(pair) != 2 { + return nil, invalidConfiguration() + } + key := string(pair[0]) + last := strings.LastIndexByte(key, '.') + if !strings.HasPrefix(key, "submodule.") || last <= len("submodule.") { + return nil, unsupportedConfiguration() + } + name, field := key[len("submodule."):last], key[last+1:] + if field != "path" && field != "url" { + return nil, unsupportedConfiguration() + } + if byName[name] == nil { + byName[name] = map[string]string{} + } + if _, exists := byName[name][field]; exists { + return nil, invalidConfiguration() + } + byName[name][field] = string(pair[1]) + } + } + modules := []gitModule{} + used := map[string]bool{} + for _, m := range byName { + path, origin := m["path"], m["url"] + if _, e := CanonicalPath(path); e != nil || !validOrigin(origin) || links[path] == "" || used[path] { + return nil, invalidConfiguration() + } + for previous := range used { + if strings.HasPrefix(path, previous+"/") || strings.HasPrefix(previous, path+"/") { + return nil, invalidConfiguration() + } + } + used[path] = true + modules = append(modules, gitModule{path, origin, links[path]}) + } + if len(modules) != len(links) { + return nil, invalidConfiguration() + } + sort.Slice(modules, func(i, j int) bool { return modules[i].path < modules[j].path }) + return modules, nil +} diff --git a/apps/host_agent/internal/config/resolver_test.go b/apps/host_agent/internal/config/resolver_test.go new file mode 100644 index 00000000..06d6d5a7 --- /dev/null +++ b/apps/host_agent/internal/config/resolver_test.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "context" + "crypto/x509" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func gitFixture(t *testing.T, dir string, args ...string) string { + t.Helper() + git, e := exec.LookPath("git") + if e != nil { + t.Fatal(e) + } + c := exec.Command(git, args...) + c.Dir = dir + c.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Fixture", "GIT_AUTHOR_EMAIL=fixture@example.test", "GIT_COMMITTER_NAME=Fixture", "GIT_COMMITTER_EMAIL=fixture@example.test") + b, e := c.CombinedOutput() + if e != nil { + t.Fatalf("fixture git: %v %s", e, b) + } + return strings.TrimSpace(string(b)) +} +func resolverFixture(t *testing.T, mutate func(string)) (Resolver, Descriptor) { + t.Helper() + return resolverFixtureFormat(t, "sha1", mutate) +} +func resolverFixtureFormat(t *testing.T, format string, mutate func(string)) (Resolver, Descriptor) { + t.Helper() + repo := t.TempDir() + gitFixture(t, repo, "init", "--quiet", "--object-format="+format) + if e := os.MkdirAll(filepath.Join(repo, ".devcontainer"), 0700); e != nil { + t.Fatal(e) + } + os.MkdirAll(filepath.Join(repo, "projects"), 0700) + os.WriteFile(filepath.Join(repo, "projects", "original.bin"), []byte{0, 1, 255}, 0600) + os.WriteFile(filepath.Join(repo, "workharbor.json"), []byte(`{"schema_version":1,"devcontainer_path":".devcontainer/devcontainer.json","projects_path":"projects","bundles":[]}`), 0600) + os.WriteFile(filepath.Join(repo, ".devcontainer", "devcontainer.json"), []byte(`{"image":"ubuntu@sha256:`+strings.Repeat("a", 64)+`"}`), 0600) + if mutate != nil { + mutate(repo) + } + gitFixture(t, repo, "add", "--all") + gitFixture(t, repo, "commit", "--quiet", "-m", "fixture") + commit := gitFixture(t, repo, "rev-parse", "HEAD") + gitFixture(t, repo, "update-server-info") + server := httptest.NewTLSServer(http.FileServer(http.Dir(filepath.Join(repo, ".git")))) + t.Cleanup(server.Close) + ca := filepath.Join(t.TempDir(), "ca.pem") + os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600) + if _, e := x509.ParseCertificate(server.Certificate().Raw); e != nil { + t.Fatal(e) + } + git, _ := exec.LookPath("git") + stageRoot := t.TempDir() + if e := os.Chmod(stageRoot, 0700); e != nil { + t.Fatal(e) + } + return Resolver{GitPath: git, StageRoot: stageRoot, CAFile: ca, Parser: jsoncTool(t)}, Descriptor{RepositoryIdentity: server.URL + "/", Commit: commit, Submodules: []Submodule{}} +} +func TestResolverExactHTTPSCommitPrivateStageAndNoAmbientHooks(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "hook-ran") + hooks := t.TempDir() + os.WriteFile(filepath.Join(hooks, "post-checkout"), []byte("#!/bin/sh\ntouch '"+sentinel+"'\n"), 0700) + global := filepath.Join(t.TempDir(), "gitconfig") + os.WriteFile(global, []byte("[core]\nhooksPath = "+hooks+"\n[filter \"hostile\"]\nsmudge = touch '"+sentinel+"'\n"), 0600) + t.Setenv("GIT_CONFIG_GLOBAL", global) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooks) + r, d := resolverFixture(t, nil) + result, e := r.Resolve(context.Background(), d) + if e != nil { + t.Fatal(e) + } + if result.Descriptor.Commit != d.Commit || result.Source["image"] == nil || result.DevcontainerPath != filepath.Join(result.Root, ".devcontainer/devcontainer.json") { + t.Fatalf("resolved=%#v", result) + } + defer result.Close() + if _, e := os.Stat(sentinel); !os.IsNotExist(e) { + t.Fatal("ambient Git hook/filter executed") + } + info, e := os.Stat(result.Root) + if e != nil || info.Mode().Perm() != 0700 { + t.Fatalf("private stage=%v %v", info, e) + } + data, e := os.ReadFile(filepath.Join(result.ProjectsPath, "original.bin")) + if e != nil || string(data) != string([]byte{0, 1, 255}) { + t.Fatal("project changed") + } +} +func TestResolverRejectsManifestAmbiguitySymlinksAndHostHooks(t *testing.T) { + for _, mutate := range []func(string){ + func(p string) { + os.WriteFile(filepath.Join(p, "workharbor.json"), []byte(`{"schema_version":1,"devcontainer_path":".devcontainer/devcontainer.json","projects_path":"projects","bundles":[],"bundles":[]}`), 0600) + }, + func(p string) { + os.Remove(filepath.Join(p, ".devcontainer/devcontainer.json")) + os.Symlink("/etc/passwd", filepath.Join(p, ".devcontainer/devcontainer.json")) + }, + func(p string) { + os.WriteFile(filepath.Join(p, ".devcontainer/devcontainer.json"), []byte(`{"image":"ubuntu@sha256:`+strings.Repeat("a", 64)+`","initializeCommand":"touch MUST_NOT_RUN"}`), 0600) + }, + func(p string) { + os.WriteFile(filepath.Join(p, ".gitmodules"), []byte("[submodule \"missing\"]\npath=projects/missing\nurl=../relative\n"), 0600) + }, + } { + r, d := resolverFixture(t, mutate) + _, e := r.Resolve(context.Background(), d) + if e == nil { + t.Fatal("unsafe stage accepted") + } + if e.Error() == "unimplemented" { + t.Fatal("gate absent") + } + } +} +func TestResolverMissingExactCommitNeverFallsBackToBranch(t *testing.T) { + r, d := resolverFixture(t, nil) + d.Commit = strings.Repeat("f", 40) + _, e := r.Resolve(context.Background(), d) + code(t, e, "revision_unavailable") + entries, e := os.ReadDir(r.StageRoot) + if e != nil || len(entries) != 0 { + t.Fatal("failed private stage retained") + } +} + +func TestResolverExactCompleteSubmoduleAndNoNestedOrRelativeFallback(t *testing.T) { + child, project := resolverFixture(t, nil) + r, d := resolverFixture(t, func(p string) { + gitFixture(t, p, "-c", "http.sslCAInfo="+child.CAFile, "clone", "--quiet", project.RepositoryIdentity, "projects/app") + os.WriteFile(filepath.Join(p, ".gitmodules"), []byte("[submodule \"app\"]\npath=projects/app\nurl="+project.RepositoryIdentity+"\n"), 0600) + }) + d.Submodules = []Submodule{{Path: "projects/app", Commit: project.Commit}} + resolved, e := r.Resolve(context.Background(), d) + if e != nil { + t.Fatal(e) + } + defer resolved.Close() + raw, e := os.ReadFile(filepath.Join(resolved.ProjectsPath, "app/projects/original.bin")) + if e != nil || string(raw) != string([]byte{0, 1, 255}) { + t.Fatal("project gitlink not initialized exactly") + } + for _, set := range [][]Submodule{{}, {{Path: "projects/app", Commit: strings.Repeat("c", 40)}}, {{Path: "projects/other", Commit: project.Commit}}} { + wrong := d + wrong.Submodules = set + _, e := r.Resolve(context.Background(), wrong) + code(t, e, "invalid_configuration") + } +} + +func TestResolverFrozenInputsDetectContentModeAndSymlinkDrift(t *testing.T) { + for _, change := range []func(*Resolved){func(s *Resolved) { + os.WriteFile(s.DevcontainerPath, []byte(`{"initializeCommand":"touch MUST_NOT_RUN"}`), 0600) + }, func(s *Resolved) { os.Chmod(s.DevcontainerPath, 0777) }, func(s *Resolved) { os.Remove(s.DevcontainerPath); os.Symlink("/etc/passwd", s.DevcontainerPath) }} { + r, d := resolverFixture(t, nil) + s, e := r.Resolve(context.Background(), d) + if e != nil { + t.Fatal(e) + } + if e = s.Check(); e != nil { + t.Fatal(e) + } + change(s) + code(t, s.Check(), "configuration_changed") + if e = s.Close(); e != nil { + t.Fatal(e) + } + } +} + +func TestResolverSnapshotCannotBeRetargetedThroughExportedFields(t *testing.T) { + for _, change := range []func(*Resolved){func(s *Resolved) { s.Source["postStartCommand"] = "hostile" }, func(s *Resolved) { s.Descriptor.Commit = strings.Repeat("f", 40) }, func(s *Resolved) { s.DevcontainerPath = "/foreign/devcontainer.json" }, func(s *Resolved) { s.Bases = []string{"foreign@sha256:" + strings.Repeat("b", 64)} }} { + r, d := resolverFixture(t, nil) + s, e := r.Resolve(context.Background(), d) + if e != nil { + t.Fatal(e) + } + change(s) + code(t, s.Check(), "configuration_changed") + s.Close() + } +} + +func TestResolverExactSHA256RepositoryNeverUsesDefaultSHA1(t *testing.T) { + r, d := resolverFixtureFormat(t, "sha256", nil) + if len(d.Commit) != 64 { + t.Fatal("fixture hash format") + } + resolved, e := r.Resolve(context.Background(), d) + if e != nil { + t.Fatal(e) + } + defer resolved.Close() + if resolved.Descriptor.Commit != d.Commit { + t.Fatal("SHA256 exact revision changed") + } +} +func TestResolverHTTPSRootUsesExplicitSSHOnlyForApprovedGitlink(t *testing.T) { + child, project := resolverFixture(t, nil) + r, d := resolverFixture(t, func(p string) { + gitFixture(t, p, "-c", "http.sslCAInfo="+child.CAFile, "clone", "--quiet", project.RepositoryIdentity, "projects/app") + os.WriteFile(filepath.Join(p, ".gitmodules"), []byte("[submodule \"app\"]\npath=projects/app\nurl=git@ssh.example.test:projects/app.git\n"), 0600) + }) + d.Submodules = []Submodule{{Path: "projects/app", Commit: project.Commit}} + credentials := t.TempDir() + marker := filepath.Join(credentials, "ssh-used") + r.SSHPath = filepath.Join(credentials, "ssh") + r.KnownHostsFile = filepath.Join(credentials, "known_hosts") + r.IdentityFile = filepath.Join(credentials, "identity") + os.WriteFile(r.KnownHostsFile, []byte("fixture"), 0600) + os.WriteFile(r.IdentityFile, []byte("fixture"), 0600) + os.WriteFile(r.SSHPath, []byte("#!/bin/sh\ntouch '"+marker+"'\nexit 1\n"), 0700) + _, e := r.Resolve(context.Background(), d) + code(t, e, "revision_unavailable") + if _, e = os.Stat(marker); e != nil { + t.Fatal("explicit SSH transport never used", e) + } +} diff --git a/apps/host_agent/internal/config/trusted.go b/apps/host_agent/internal/config/trusted.go new file mode 100644 index 00000000..1f6c672d --- /dev/null +++ b/apps/host_agent/internal/config/trusted.go @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "bytes" + "net" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/moby/buildkit/frontend/dockerfile/instructions" + "github.com/moby/buildkit/frontend/dockerfile/linter" + "github.com/moby/buildkit/frontend/dockerfile/parser" +) + +// Descriptor is an exact normalized revision set; it confers no trust. +type Descriptor struct { + RepositoryIdentity, Commit string + Submodules []Submodule +} +type Submodule struct{ Path, Commit string } +type Approval struct { + ID, OwnerID string + Descriptor Descriptor +} + +var fullCommit = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) +var uuidValue = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +var imageDigest = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$`) +var hostName = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.?$`) +var userName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +func invalidConfiguration() error { return &Error{Code: "invalid_configuration"} } +func unsupportedConfiguration() error { return &Error{Code: "unsupported_configuration"} } +func fields(m map[string]any, required []string, optional ...string) bool { + if m == nil { + return false + } + allowed := map[string]bool{} + for _, k := range required { + if _, ok := m[k]; !ok { + return false + } + allowed[k] = true + } + for _, k := range optional { + allowed[k] = true + } + for k := range m { + if !allowed[k] { + return false + } + } + return true +} + +// DecodeDescriptor accepts only the materialized, sorted descriptor used by T007. +func DecodeDescriptor(value any) (Descriptor, error) { + m, ok := value.(map[string]any) + if !ok || !fields(m, []string{"repository_identity", "commit", "submodules"}) { + return Descriptor{}, invalidConfiguration() + } + origin, ok := m["repository_identity"].(string) + if !ok || !validOrigin(origin) { + return Descriptor{}, invalidConfiguration() + } + commit, ok := m["commit"].(string) + if !ok || !fullCommit.MatchString(commit) { + return Descriptor{}, invalidConfiguration() + } + entries, ok := m["submodules"].([]any) + if !ok { + return Descriptor{}, invalidConfiguration() + } + d := Descriptor{origin, commit, make([]Submodule, 0, len(entries))} + previous := "" + for _, entry := range entries { + s, ok := entry.(map[string]any) + if !ok || !fields(s, []string{"path", "commit"}) { + return Descriptor{}, invalidConfiguration() + } + p, e := CanonicalPath(s["path"]) + c, ok := s["commit"].(string) + if e != nil || !ok || !fullCommit.MatchString(c) || (previous != "" && p <= previous) { + return Descriptor{}, invalidConfiguration() + } + d.Submodules = append(d.Submodules, Submodule{p, c}) + previous = p + } + return d, nil +} +func (d Descriptor) Map() map[string]any { + entries := make([]any, 0, len(d.Submodules)) + for _, s := range d.Submodules { + entries = append(entries, map[string]any{"path": s.Path, "commit": s.Commit}) + } + return map[string]any{"repository_identity": d.RepositoryIdentity, "commit": d.Commit, "submodules": entries} +} +func MatchApproval(a Approval, owner, reference string, d Descriptor) error { + _, ae := DecodeDescriptor(a.Descriptor.Map()) + _, de := DecodeDescriptor(d.Map()) + if !uuidValue.MatchString(owner) || !uuidValue.MatchString(reference) || a.ID != reference || a.OwnerID != owner || ae != nil || de != nil || !reflect.DeepEqual(a.Descriptor.Map(), d.Map()) { + return &Error{Code: "trust_required"} + } + return nil +} +func validOrigin(s string) bool { + if s == "" || !utf8.ValidString(s) { + return false + } + for _, r := range s { + if r <= 32 || r == 127 || unicode.IsSpace(r) || strings.ContainsRune("\\?#", r) { + return false + } + } + for i := 0; i < len(s); i++ { + if s[i] == '%' { + if i+2 >= len(s) || !hexByte(s[i+1]) || !hexByte(s[i+2]) { + return false + } + i += 2 + } + } + if i := strings.Index(s, "://"); i >= 0 { + scheme := strings.ToLower(s[:i]) + if scheme != "https" && scheme != "ssh" { + return false + } + rest := s[i+3:] + slash := strings.IndexByte(rest, '/') + return slash >= 0 && validAuthority(rest[:slash], scheme == "ssh", true) + } + colon := -1 + bracket := false + for i, r := range s { + if r == '[' { + bracket = true + } + if r == ']' { + bracket = false + } + if r == ':' && !bracket { + colon = i + break + } + } + return colon > 0 && colon < len(s)-1 && validAuthority(s[:colon], true, false) +} +func hexByte(b byte) bool { + return b >= '0' && b <= '9' || b >= 'a' && b <= 'f' || b >= 'A' && b <= 'F' +} +func validAuthority(s string, allowUser, allowPort bool) bool { + if strings.Count(s, "@") > 1 { + return false + } + if i := strings.IndexByte(s, '@'); i >= 0 { + if !allowUser || !userName.MatchString(s[:i]) { + return false + } + s = s[i+1:] + } + host, port := s, "" + if strings.HasPrefix(s, "[") { + end := strings.IndexByte(s, ']') + if end < 0 { + return false + } + host = s[:end+1] + if len(s) > end+1 { + if s[end+1] != ':' { + return false + } + port = s[end+2:] + if port == "" { + return false + } + } + literal := s[1:end] + if strings.Contains(literal, "%") || net.ParseIP(literal) == nil || !strings.Contains(literal, ":") { + return false + } + } else { + if strings.Count(s, ":") > 1 { + return false + } + if i := strings.IndexByte(s, ':'); i >= 0 { + host, port = s[:i], s[i+1:] + if port == "" { + return false + } + } + if !hostName.MatchString(host) { + return false + } + } + if port != "" { + if !allowPort { + return false + } + for _, r := range port { + if r < '0' || r > '9' { + return false + } + } + p := strings.TrimLeft(port, "0") + if len(p) < 1 || len(p) > 5 { + return false + } + n, _ := strconv.Atoi(p) + if n > 65535 { + return false + } + } + return host != "" +} +func reservedArgument(s string) bool { + return strings.HasPrefix(s, "BUILDKIT_") || strings.HasPrefix(s, "_DEV_CONTAINERS_") +} +func noSubstitution(v any) bool { + switch x := v.(type) { + case string: + return utf8.ValidString(x) && !strings.ContainsRune(x, 0) && !strings.Contains(x, "${") + case map[string]any: + for k, v := range x { + if !noSubstitution(k) || !noSubstitution(v) { + return false + } + } + case []any: + for _, v := range x { + if !noSubstitution(v) { + return false + } + } + } + return true +} +func stringMap(v any) bool { + m, ok := v.(map[string]any) + if !ok { + return false + } + for k, v := range m { + s, ok := v.(string) + if !ok || k == "" || strings.ContainsAny(k, "=\x00") || !utf8.ValidString(k) || !utf8.ValidString(s) { + return false + } + } + return true +} +func commandLeaf(v any) bool { + switch x := v.(type) { + case string: + return utf8.ValidString(x) + case []any: + for _, v := range x { + if _, ok := v.(string); !ok { + return false + } + } + return true + } + return false +} +func command(v any) bool { + if commandLeaf(v) { + return true + } + m, ok := v.(map[string]any) + if !ok { + return false + } + for _, v := range m { + if !commandLeaf(v) { + return false + } + } + return true +} +func ValidateSource(m map[string]any) error { + allowed := []string{"name", "image", "build", "containerUser", "remoteUser", "containerEnv", "remoteEnv", "overrideCommand", "userEnvProbe", "updateRemoteUserUID", "waitFor", "shutdownAction", "onCreateCommand", "updateContentCommand", "postCreateCommand", "postStartCommand"} + if !fields(m, nil, allowed...) || !noSubstitution(m) { + return unsupportedConfiguration() + } + _, image := m["image"] + _, build := m["build"] + if image == build { + return unsupportedConfiguration() + } + for k, v := range m { + valid := false + switch k { + case "image": + s, ok := v.(string) + valid = ok && imageDigest.MatchString(s) + case "name", "containerUser", "remoteUser": + _, valid = v.(string) + case "containerEnv", "remoteEnv": + valid = stringMap(v) + case "overrideCommand": + _, valid = v.(bool) + case "userEnvProbe": + valid = v == "none" + case "waitFor": + valid = v == "postStartCommand" + case "shutdownAction": + valid = v == "none" + case "updateRemoteUserUID": + b, ok := v.(bool) + valid = ok && !b + case "onCreateCommand", "updateContentCommand", "postCreateCommand", "postStartCommand": + valid = command(v) + case "build": + b, ok := v.(map[string]any) + valid = ok && fields(b, []string{"dockerfile"}, "context", "args", "target") + if valid { + for key, value := range b { + if key == "args" { + valid = stringMap(value) + if valid { + for name := range value.(map[string]any) { + if reservedArgument(name) { + valid = false + break + } + } + } + } else { + _, valid = value.(string) + } + if !valid { + break + } + } + } + } + if !valid { + return unsupportedConfiguration() + } + } + return nil +} + +// ValidateDockerfile uses the upstream AST only to reject unsupported capabilities. +func ValidateDockerfile(raw []byte) ([]string, error) { + if !utf8.Valid(raw) || bytes.IndexByte(raw, 0) >= 0 { + return nil, unsupportedConfiguration() + } + if _, _, _, found := parser.DetectSyntax(raw); found { + return nil, unsupportedConfiguration() + } + result, err := parser.Parse(bytes.NewReader(raw)) + if err != nil || len(result.AST.Children) == 0 { + return nil, unsupportedConfiguration() + } + if _, _, e := instructions.Parse(result.AST, linter.New(&linter.Config{})); e != nil { + return nil, unsupportedConfiguration() + } + aliases := map[string]bool{} + seen := map[string]bool{} + refs := []string{} + from := false + for _, node := range result.AST.Children { + switch strings.ToLower(node.Value) { + case "volume", "onbuild": + return nil, unsupportedConfiguration() + case "arg": + if node.Next == nil || reservedArgument(strings.SplitN(node.Next.Value, "=", 2)[0]) { + return nil, unsupportedConfiguration() + } + case "from": + from = true + if len(node.Flags) > 0 || node.Next == nil { + return nil, unsupportedConfiguration() + } + ref := node.Next.Value + if !aliases[strings.ToLower(ref)] { + if !imageDigest.MatchString(ref) { + return nil, unsupportedConfiguration() + } + if !seen[ref] { + seen[ref] = true + refs = append(refs, ref) + } + } + if tail := node.Next.Next; tail != nil { + if !strings.EqualFold(tail.Value, "as") || tail.Next == nil || tail.Next.Next != nil { + return nil, unsupportedConfiguration() + } + alias := strings.ToLower(tail.Next.Value) + if alias == "" || aliases[alias] { + return nil, unsupportedConfiguration() + } + aliases[alias] = true + } + } + } + if !from { + return nil, unsupportedConfiguration() + } + sort.Strings(refs) + return refs, nil +} diff --git a/apps/host_agent/internal/config/trusted_test.go b/apps/host_agent/internal/config/trusted_test.go new file mode 100644 index 00000000..8f0b5379 --- /dev/null +++ b/apps/host_agent/internal/config/trusted_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: EUPL-1.2 +package config + +import ( + "reflect" + "strings" + "testing" +) + +func fixtureDescriptor() Descriptor { + return Descriptor{RepositoryIdentity: "https://example.test/workspace.git", Commit: strings.Repeat("a", 40), Submodules: []Submodule{{Path: "projects/app", Commit: strings.Repeat("b", 40)}}} +} +func descriptorMap(d Descriptor) map[string]any { + entries := []any{} + for _, s := range d.Submodules { + entries = append(entries, map[string]any{"path": s.Path, "commit": s.Commit}) + } + return map[string]any{"repository_identity": d.RepositoryIdentity, "commit": d.Commit, "submodules": entries} +} +func code(t *testing.T, err error, want string) { + t.Helper() + e, ok := err.(*Error) + if !ok || e.Code != want { + t.Fatalf("code=%v, want %s", err, want) + } +} +func TestDescriptorExactNormalizedRevisions(t *testing.T) { + want := fixtureDescriptor() + got, err := DecodeDescriptor(descriptorMap(want)) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("descriptor=%#v error=%v", got, err) + } + for _, mutate := range []func(map[string]any){func(m map[string]any) { m["commit"] = "main" }, func(m map[string]any) { m["extra"] = "SECRET-CANARY" }, func(m map[string]any) { m["submodules"] = nil }, func(m map[string]any) { + m["submodules"] = []any{map[string]any{"path": "../app", "commit": strings.Repeat("b", 40)}} + }, func(m map[string]any) { + m["submodules"] = []any{map[string]any{"path": "projects/app", "commit": strings.Repeat("b", 40)}, map[string]any{"path": "projects/app", "commit": strings.Repeat("b", 40)}} + }} { + m := descriptorMap(want) + mutate(m) + _, err := DecodeDescriptor(m) + code(t, err, "invalid_configuration") + } +} +func TestDescriptorOriginAliasesNeverNormalize(t *testing.T) { + for _, origin := range []string{"https://Host/Repo.git", "https://host/Repo.git", "ssh://git@host/Repo.git", "git@host:Repo.git"} { + d := fixtureDescriptor() + d.RepositoryIdentity = origin + got, err := DecodeDescriptor(descriptorMap(d)) + if err != nil || got.RepositoryIdentity != origin { + t.Fatalf("literal origin %q changed: %#v %v", origin, got, err) + } + } + for _, origin := range []string{"https://user:secret@host/repo", "file:///tmp/repo", "https://host/repo?secret=x", "https://host/repo\n"} { + d := fixtureDescriptor() + d.RepositoryIdentity = origin + _, err := DecodeDescriptor(descriptorMap(d)) + code(t, err, "invalid_configuration") + } +} +func TestApprovalRequiresExactOwnerReferenceAndCompleteSet(t *testing.T) { + d := fixtureDescriptor() + a := Approval{ID: "00000000-0000-4000-8000-000000000005", OwnerID: "00000000-0000-4000-8000-000000000001", Descriptor: d} + if err := MatchApproval(a, a.OwnerID, a.ID, d); err != nil { + t.Fatal(err) + } + for _, change := range []func(*Approval){func(a *Approval) { a.OwnerID = "00000000-0000-4000-8000-000000000009" }, func(a *Approval) { a.ID = "00000000-0000-4000-8000-000000000009" }, func(a *Approval) { a.Descriptor.Commit = strings.Repeat("c", 40) }, func(a *Approval) { a.Descriptor.Submodules = nil }, func(a *Approval) { a.Descriptor.RepositoryIdentity = "https://EXAMPLE.test/workspace.git" }} { + b := a + b.Descriptor.Submodules = append([]Submodule{}, d.Submodules...) + change(&b) + code(t, MatchApproval(b, a.OwnerID, a.ID, d), "trust_required") + } +} +func TestSourceImageAndDockerfileClosedMatrix(t *testing.T) { + for _, m := range []map[string]any{{"image": "ubuntu@sha256:" + strings.Repeat("a", 64)}, {"build": map[string]any{"dockerfile": "Dockerfile", "context": "..", "args": map[string]any{"APP_VERSION": "1"}}, "postCreateCommand": []any{"sh", "./prepare"}, "postStartCommand": map[string]any{"one": "true", "two": []any{"true"}}}} { + if err := ValidateSource(m); err != nil { + t.Fatal(err) + } + } + for _, m := range []map[string]any{{}, {"image": "ubuntu:latest"}, {"image": "ubuntu@sha256:" + strings.Repeat("a", 64), "build": map[string]any{"dockerfile": "Dockerfile"}}, {"image": false}, {"build": map[string]any{"dockerfile": "Dockerfile", "extra": "CANARY"}}, {"image": "ubuntu@sha256:" + strings.Repeat("a", 64), "remoteEnv": map[string]any{"X": nil}}, {"image": "ubuntu@sha256:" + strings.Repeat("a", 64), "postCreateCommand": []any{1}}} { + code(t, ValidateSource(m), "unsupported_configuration") + } +} +func TestUnsupportedSourceRefusesEveryEffectClass(t *testing.T) { + for _, field := range []string{"initializeCommand", "postAttachCommand", "features", "dockerComposeFile", "customizations", "runArgs", "mounts", "workspaceMount", "workspaceFolder", "privileged", "capAdd", "securityOpt", "hostRequirements", "appPort", "forwardPorts", "unknown"} { + t.Run(field, func(t *testing.T) { + m := map[string]any{"image": "ubuntu@sha256:" + strings.Repeat("a", 64), field: "SECRET-CANARY"} + code(t, ValidateSource(m), "unsupported_configuration") + }) + } + for _, field := range []string{"userEnvProbe", "updateRemoteUserUID", "waitFor", "shutdownAction"} { + m := map[string]any{"image": "ubuntu@sha256:" + strings.Repeat("a", 64), field: true} + code(t, ValidateSource(m), "unsupported_configuration") + } +} +func TestReservedFrontendArgumentsAndSubstitutionRefuse(t *testing.T) { + for _, name := range []string{"BUILDKIT_SYNTAX", "BUILDKIT_INLINE_CACHE", "_DEV_CONTAINERS_BASE_IMAGE"} { + m := map[string]any{"build": map[string]any{"dockerfile": "Dockerfile", "args": map[string]any{name: "evil/frontend"}}} + code(t, ValidateSource(m), "unsupported_configuration") + } + code(t, ValidateSource(map[string]any{"image": "ubuntu@sha256:" + strings.Repeat("a", 64), "containerEnv": map[string]any{"TOKEN": "${localEnv:SECRET}"}}), "unsupported_configuration") +} +func TestDockerfileASTRefusesBeforeBuild(t *testing.T) { + base := "ubuntu@sha256:" + strings.Repeat("a", 64) + data := "FROM " + base + " AS base\nARG APP_VERSION=1\nRUN printf ok\nFROM base AS final\nCOPY --from=base /etc/os-release /release\n" + refs, err := ValidateDockerfile([]byte(data)) + if err != nil || !reflect.DeepEqual(refs, []string{base}) { + t.Fatalf("refs=%v err=%v", refs, err) + } + for _, data := range []string{"# syntax=evil/frontend\nFROM " + base, "\xef\xbb\xbf# SyNtAx=evil/frontend\r\nFROM " + base, "FROM " + base + "\nVOLUME [\"/workspace/subdir\"]", "FROM " + base + "\nONBUILD RUN true", "ARG BASE=ubuntu\nFROM ${BASE}", "FROM --platform=linux/amd64 " + base, "FROM " + base + "\nARG BUILDKIT_SYNTAX=evil/frontend", "FROM " + base + "\nARG _DEV_CONTAINERS_BASE_IMAGE=evil", "FROM ubuntu:latest", ""} { + _, err := ValidateDockerfile([]byte(data)) + code(t, err, "unsupported_configuration") + } +} + +func TestSourceNULAndUnrepresentableEnvironmentNamesRefuse(t *testing.T) { + for _, field := range []string{"name", "postCreateCommand"} { + m := map[string]any{"image": "ubuntu@sha256:" + strings.Repeat("a", 64), field: "bad\x00value"} + code(t, ValidateSource(m), "unsupported_configuration") + } + for _, key := range []string{"", "A=B", "A\x00B"} { + m := map[string]any{"image": "ubuntu@sha256:" + strings.Repeat("a", 64), "containerEnv": map[string]any{key: "literal"}} + code(t, ValidateSource(m), "unsupported_configuration") + } +} + +func TestDockerfileMalformedInstructionsRefuseBeforeBuild(t *testing.T) { + base := "ubuntu@sha256:" + strings.Repeat("a", 64) + for _, raw := range []string{"FROM " + base + "\nBOGUS effect\n", "FROM " + base + "\nCOPY only-one-operand\n", "RUN before-first-stage\nFROM " + base + "\n", "FROM " + base + "\nENV only-key\n"} { + _, e := ValidateDockerfile([]byte(raw)) + code(t, e, "unsupported_configuration") + } +} + +func TestDockerfileMaintainedGrammarCheckCommentsNeverPanic(t *testing.T) { + base := "ubuntu@sha256:" + strings.Repeat("a", 64) + _, e := ValidateDockerfile([]byte("FROM " + base + " AS app\n#check=skip=JSONArgsRecommended\nRUN true\n")) + if e != nil { + t.Fatal(e) + } +} diff --git a/apps/host_agent/internal/devcontainer/parse.cjs b/apps/host_agent/internal/devcontainer/parse.cjs new file mode 100644 index 00000000..030e19cd --- /dev/null +++ b/apps/host_agent/internal/devcontainer/parse.cjs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: EUPL-1.2 +// The pinned reference JSONC parser supplies syntax; this wrapper rejects ambiguity. +'use strict'; +const fs = require('node:fs'); +const { parseTree, getNodeValue } = require('jsonc-parser'); +function scalarString(value) { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + } else if (c >= 0xdc00 && c <= 0xdfff) return false; + } + return true; +} +function validate(node) { + if (node.type === 'string' && !scalarString(node.value)) throw new Error(); + if (node.type === 'number' && !Number.isFinite(node.value)) throw new Error(); + if (node.type === 'object') { + const names = new Set(); + for (const property of node.children || []) { + const key = property.children[0].value; + if (names.has(key)) throw new Error(); + names.add(key); + } + } + for (const child of node.children || []) validate(child); +} +try { + if (!['strict', 'jsonc'].includes(process.argv[1]) || process.argv.length !== 2) throw new Error(); + const raw = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(0)); + const strict = process.argv[1] === 'strict'; + const errors = []; + const tree = parseTree(raw, errors, { allowTrailingComma: !strict, disallowComments: strict, allowEmptyContent: false }); + if (!tree || errors.length) throw new Error(); + validate(tree); + process.stdout.write(JSON.stringify(getNodeValue(tree))); +} catch (_) { + process.stderr.write('invalid_configuration\n'); + process.exitCode = 1; +} diff --git a/apps/host_agent/internal/devcontainer/tool-files.json b/apps/host_agent/internal/devcontainer/tool-files.json new file mode 100644 index 00000000..66e8f3e8 --- /dev/null +++ b/apps/host_agent/internal/devcontainer/tool-files.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "provenance": "npm ci --ignore-scripts from committed integrity lock; original package bytes", + "files": { + "@devcontainers/cli/devcontainer.js": "e6df20af860208e00365d7b73f64d3940bad087ea8049de12223d1e7376522e3", + "@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js": "22b5b3a7345608f552db2f1af589ca06c7274d116778d00aa67cbfe1e1cafc65", + "@devcontainers/cli/package.json": "6af5eeda6e4d9a2267a503c17816d74373f52005b1209dc92ab97249751c2971", + "jsonc-parser/lib/umd/impl/edit.js": "9ecd115039cf7a478f223e4a9649170c7f5e8fd8016df1b267723b57553f9483", + "jsonc-parser/lib/umd/impl/format.js": "f9e468e436bf1014a4395b1ac70374edb918739ba90b19e6e6a22ca0214564a9", + "jsonc-parser/lib/umd/impl/parser.js": "a35db1bfb9631dcba643fa42972e9ebe2a318b4de3cac239d29081577a43d688", + "jsonc-parser/lib/umd/impl/scanner.js": "581d82c4f1aa84b0605369c01f414fb0164ee462e03daa298cb960076a2a9e82", + "jsonc-parser/lib/umd/impl/string-intern.js": "e590d27909a7c132382fbf9b8c4668054ab425734c1e0789753063356826c30f", + "jsonc-parser/lib/umd/main.js": "6dc132e2e792a9616688ff93d10dd671c1fbcfa3301d76bff3e81193d049337d", + "jsonc-parser/package.json": "a7a9192caaac00d9330592f4fd572908bee037518128a0fc3db96d2cd652e2b5" + } +} diff --git a/apps/host_agent/internal/devcontainer/tools.go b/apps/host_agent/internal/devcontainer/tools.go new file mode 100644 index 00000000..b46763c3 --- /dev/null +++ b/apps/host_agent/internal/devcontainer/tools.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package devcontainer owns installed official tools; repository paths never select code. +package devcontainer + +import ( + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +//go:embed tool-files.json +var fileManifest []byte + +//go:embed parse.cjs +var ParserScript string + +type Tools struct{ NodePath, Root string } + +func (p Tools) Environment() []string { + return []string{"PATH=" + filepath.Dir(p.NodePath), "LANG=C.UTF-8"} +} +func unavailable() error { return errors.New("tool_unavailable") } +func (p Tools) Verify(ctx context.Context, cli bool) error { + if !filepath.IsAbs(p.NodePath) || !filepath.IsAbs(p.Root) { + return unavailable() + } + for _, base := range []string{p.Root, filepath.Join(p.Root, "node_modules")} { + info, e := os.Lstat(base) + if e != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return unavailable() + } + } + var manifest struct { + Files map[string]string `json:"files"` + } + if json.Unmarshal(fileManifest, &manifest) != nil || len(manifest.Files) == 0 { + return unavailable() + } + for relative, expected := range manifest.Files { + if !cli && !strings.HasPrefix(relative, "jsonc-parser/") { + continue + } + path := filepath.Join(p.Root, "node_modules") + for _, part := range strings.Split(relative, "/") { + path = filepath.Join(path, part) + info, e := os.Lstat(path) + if e != nil || info.Mode()&os.ModeSymlink != 0 { + return unavailable() + } + } + f, e := os.Open(path) + if e != nil { + return unavailable() + } + info, e := f.Stat() + if e != nil || !info.Mode().IsRegular() { + f.Close() + return unavailable() + } + h := sha256.New() + _, e = io.Copy(h, f) + ce := f.Close() + if e != nil || ce != nil || hex.EncodeToString(h.Sum(nil)) != expected { + return unavailable() + } + } + version := exec.CommandContext(ctx, p.NodePath, "--version") + version.Env = p.Environment() + version.Stderr = io.Discard + raw, e := version.Output() + if e != nil || strings.TrimSpace(string(raw)) != "v26.11.1" { + return unavailable() + } + if cli { + c := exec.CommandContext(ctx, p.NodePath, filepath.Join(p.Root, "node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js"), "--version") + c.Env = p.Environment() + c.Stderr = io.Discard + raw, e := c.Output() + if e != nil || strings.TrimSpace(string(raw)) != "0.89.0" { + return unavailable() + } + } + return nil +} diff --git a/apps/host_agent/internal/devcontainer/tools_test.go b/apps/host_agent/internal/devcontainer/tools_test.go new file mode 100644 index 00000000..b5a0131a --- /dev/null +++ b/apps/host_agent/internal/devcontainer/tools_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: EUPL-1.2 +package devcontainer + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func installed(t *testing.T) Tools { + t.Helper() + node, e := exec.LookPath("node") + if e != nil { + t.Fatal(e) + } + root, e := filepath.Abs("../../tools/devcontainer") + if e != nil { + t.Fatal(e) + } + return Tools{NodePath: node, Root: root} +} +func TestExactCompanionIntegrityAndCLIVersion(t *testing.T) { + p := installed(t) + if e := p.Verify(context.Background(), true); e != nil { + t.Fatal(e) + } +} +func TestCompanionHashTamperAndSymlinkRefuseBeforeExecution(t *testing.T) { + original := installed(t) + for _, symlink := range []bool{false, true} { + t.Run(map[bool]string{false: "changed", true: "symlink"}[symlink], func(t *testing.T) { + p := Tools{NodePath: original.NodePath, Root: t.TempDir()} + if e := os.CopyFS(p.Root, os.DirFS(original.Root)); e != nil { + t.Fatal(e) + } + target := filepath.Join(p.Root, "node_modules/jsonc-parser/lib/umd/main.js") + if symlink { + os.Remove(target) + os.Symlink(filepath.Join(original.Root, "node_modules/jsonc-parser/lib/umd/main.js"), target) + } else { + os.WriteFile(target, []byte("throw new Error('CANARY')"), 0600) + } + if e := p.Verify(context.Background(), false); e == nil { + t.Fatal("mutated dependency accepted") + } + }) + } +} diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index e9de9a23..8e516949 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1212,3 +1212,35 @@ scaffolding is separate unfinished source, so that checkpoint is not a claim for the eventual complete candidate. Independent exact-source review, pristine/native application and manifest gates, and required real Linux/Docker evidence remain outstanding. Feature006 T009 stays unchecked and Feature006 T010 still waits. + +### T009 immutable config/parser checkpoint (partial source only) + +Exact source follows accepted upstream CP checkpoint `ad3cb18` and the independently +accepted disjoint documentation commit `405f936`. The configuration resolver owns +fresh private Git staging, exact root/gitlink revisions and complete approved +submodule sets; it never resets retained project data. Strict JSONC rejects malformed +UTF-8, lone surrogates and decoded recursive duplicate keys before conversion. +The maintained BuildKit grammar parser uses a non-nil maintained linter: the inline +`#check=skip=JSONArgsRecommended` regression closes the reviewer's nil-linter finding. +This does not broaden the supported Dockerfile frontend or runtime capability set. + +Meaningful SHA256/SSH transport reds are preserved in +/private/tmp/workharbor-t009-resolver-hash-ssh-red.log (exit1, two assertions). +The superseding exact command `GOMAXPROCS=2 go -C apps/host_agent test -p 1 +-count=1 ./internal/config -run 'TestResolverExactSHA256|TestResolverHTTPSRootUsesExplicitSSH'` +passes in /private/tmp/workharbor-t009-resolver-hash-ssh-green.log (exit0,1.730s). +Each approved commit/gitlink chooses explicit SHA1 or SHA256 initialization. +The SHA256 fixture uses real HTTPS Git; the SSH fixture proves explicit approved +transport dispatch with an intentionally failing test command, not real SSH success. +No ambient SSH configuration, agent or credential fallback is admitted. + +Current serial native `GOMAXPROCS=2 go -C apps/host_agent test -p 1 -count=1 +./internal/config ./internal/devcontainer ./internal/storage` passes in +/private/tmp/workharbor-t009-pure-slices-final.log: config12.189s, +devcontainer0.454s, storage1.130s. Focused native vet passes in +/private/tmp/workharbor-t009-pure-slices-vet.log; whitespace checks pass. Earlier +raw RED/setup/cleanup failures retain their original logs and outcomes. Storage +source is a separate checkpoint; this record does not accept unfinished provider +integration. Real Linux/Docker, nonroot ownership/retention and source/pristine +application/manifest gates remain outstanding. Feature006 T009 remains unchecked; +Feature006 T010 waits, and lifecycle receipts/recovery remain outside this task. From 8c9a79a9f7662610ebed35c416a2344686090f16 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 15:16:10 +0200 Subject: [PATCH 27/51] feat(storage): publish initial project data atomically Keep retained files independent of runtime generations. Bind the original seed and numeric owner once, serialize publishers, and refuse missing established or foreign final storage instead of silently copying over user data. Refs: #72 Co-authored-by: Codex --- .../internal/storage/atomic_darwin.go | 16 + .../internal/storage/atomic_linux.go | 34 ++ .../internal/storage/atomic_other.go | 18 + .../internal/storage/atomic_unix.go | 48 ++ apps/host_agent/internal/storage/seed.go | 410 ++++++++++++++++++ apps/host_agent/internal/storage/seed_test.go | 360 +++++++++++++++ docs/exec-plans/active/installable-pilot.md | 22 + 7 files changed, 908 insertions(+) create mode 100644 apps/host_agent/internal/storage/atomic_darwin.go create mode 100644 apps/host_agent/internal/storage/atomic_linux.go create mode 100644 apps/host_agent/internal/storage/atomic_other.go create mode 100644 apps/host_agent/internal/storage/atomic_unix.go create mode 100644 apps/host_agent/internal/storage/seed.go create mode 100644 apps/host_agent/internal/storage/seed_test.go diff --git a/apps/host_agent/internal/storage/atomic_darwin.go b/apps/host_agent/internal/storage/atomic_darwin.go new file mode 100644 index 00000000..f567bd35 --- /dev/null +++ b/apps/host_agent/internal/storage/atomic_darwin.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: EUPL-1.2 +//go:build darwin + +package storage + +import ( + "golang.org/x/sys/unix" + "os" +) + +func renameNoReplace(old, new string) error { + return unix.RenameatxNp(unix.AT_FDCWD, old, unix.AT_FDCWD, new, unix.RENAME_EXCL) +} +func canChown(uid, gid uint32) bool { + return os.Geteuid() == 0 || (uint32(os.Geteuid()) == uid && uint32(os.Getegid()) == gid) +} diff --git a/apps/host_agent/internal/storage/atomic_linux.go b/apps/host_agent/internal/storage/atomic_linux.go new file mode 100644 index 00000000..7c1a110c --- /dev/null +++ b/apps/host_agent/internal/storage/atomic_linux.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EUPL-1.2 +//go:build linux + +package storage + +import ( + "golang.org/x/sys/unix" + "os" + "strconv" + "strings" +) + +func renameNoReplace(old, new string) error { + return unix.Renameat2(unix.AT_FDCWD, old, unix.AT_FDCWD, new, unix.RENAME_NOREPLACE) +} +func canChown(uid, gid uint32) bool { + if uint32(os.Geteuid()) == uid && uint32(os.Getegid()) == gid { + return true + } + if os.Geteuid() != 0 { + return false + } + raw, e := os.ReadFile("/proc/self/status") + if e != nil { + return false + } + for _, line := range strings.Split(string(raw), "\n") { + if strings.HasPrefix(line, "CapEff:") { + v, e := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "CapEff:")), 16, 64) + return e == nil && v&1 == 1 + } + } + return false +} diff --git a/apps/host_agent/internal/storage/atomic_other.go b/apps/host_agent/internal/storage/atomic_other.go new file mode 100644 index 00000000..d5593bd6 --- /dev/null +++ b/apps/host_agent/internal/storage/atomic_other.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: EUPL-1.2 +//go:build !linux && !darwin + +package storage + +import ( + "os" +) + +func platformSupported() bool { return false } +func privatePath(string, os.FileMode, bool) bool { return false } +func openSource(string) (*os.File, error) { return nil, os.ErrPermission } +func openLock(string) (*os.File, error) { return nil, os.ErrPermission } +func tryLock(*os.File) error { return os.ErrPermission } +func unlock(*os.File) error { return os.ErrPermission } +func lockBusy(error) bool { return false } +func renameNoReplace(string, string) error { return os.ErrPermission } +func canChown(uint32, uint32) bool { return false } diff --git a/apps/host_agent/internal/storage/atomic_unix.go b/apps/host_agent/internal/storage/atomic_unix.go new file mode 100644 index 00000000..3d994c71 --- /dev/null +++ b/apps/host_agent/internal/storage/atomic_unix.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: EUPL-1.2 +//go:build linux || darwin + +package storage + +import ( + "golang.org/x/sys/unix" + "os" + "syscall" +) + +func platformSupported() bool { return true } +func privatePath(path string, mode os.FileMode, dir bool) bool { + info, e := os.Lstat(path) + if e != nil { + return false + } + st, ok := info.Sys().(*syscall.Stat_t) + return ok && st.Uid == uint32(os.Geteuid()) && info.Mode().Perm() == mode && info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) == 0 && info.IsDir() == dir && info.Mode()&os.ModeSymlink == 0 && (dir || (info.Mode().IsRegular() && st.Nlink == 1)) +} +func openSource(path string) (*os.File, error) { + fd, e := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if e != nil { + return nil, e + } + return os.NewFile(uintptr(fd), path), nil +} +func openLock(path string) (*os.File, error) { + fd, e := unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0600) + if e != nil { + return nil, e + } + file := os.NewFile(uintptr(fd), path) + info, e := file.Stat() + if e != nil { + file.Close() + return nil, e + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 || st.Uid != uint32(os.Geteuid()) || st.Nlink != 1 { + file.Close() + return nil, os.ErrPermission + } + return file, nil +} +func tryLock(file *os.File) error { return unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) } +func unlock(file *os.File) error { return unix.Flock(int(file.Fd()), unix.LOCK_UN) } +func lockBusy(e error) bool { return e == unix.EWOULDBLOCK || e == unix.EAGAIN } diff --git a/apps/host_agent/internal/storage/seed.go b/apps/host_agent/internal/storage/seed.go new file mode 100644 index 00000000..bbc34b68 --- /dev/null +++ b/apps/host_agent/internal/storage/seed.go @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package storage owns durable project data independently of runtime instances. +package storage + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "io" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "time" +) + +type Binding struct { + OwnerID, HostID, WorkspaceID, StorageID, ReservationOperationID, ExistingHandle string + Initial config.Descriptor + DataUID, DataGID uint32 +} +type Handle struct { + Parent, Data string + Binding Binding +} +type Store struct { + Root string + checkpoint func(string) error +} + +// Check is a trusted native current-operation/approval fence, never wire input. +// IDs and a configuration descriptor alone confer no filesystem effect authority. +type Check func(context.Context) error + +var uuid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +func failure(code string) error { return &config.Error{Code: code} } +func (b Binding) valid() bool { + for _, id := range []string{b.OwnerID, b.HostID, b.WorkspaceID, b.StorageID, b.ReservationOperationID} { + if !uuid.MatchString(id) { + return false + } + } + _, e := config.DecodeDescriptor(b.Initial.Map()) + return e == nil && b.DataUID != 4294967295 && b.DataGID != 4294967295 +} +func marker(b Binding, complete bool) []byte { + raw, _ := json.Marshal(map[string]any{"schema_version": 1, "owner_id": b.OwnerID, "host_id": b.HostID, "workspace_id": b.WorkspaceID, "storage_id": b.StorageID, "reservation_operation_id": b.ReservationOperationID, "initial_config": b.Initial.Map(), "data_uid": b.DataUID, "data_gid": b.DataGID, "complete": complete}) + return raw +} +func (s Store) point(name string) error { + if s.checkpoint != nil { + return s.checkpoint(name) + } + return nil +} +func check(ctx context.Context, authorize Check) error { + if ctx.Err() != nil { + return ctx.Err() + } + if authorize == nil { + return failure("forbidden") + } + return authorize(ctx) +} + +// Seed consumes a trusted immutable ORIGINAL source. Existing valid data is never +// copied, traversed, cleaned, chmodded or chowned, even for later config revisions. +func (s Store) Seed(ctx context.Context, b Binding, source *config.Resolved, authorize Check) (h Handle, err error) { + if e := check(ctx, authorize); e != nil { + return h, e + } + if !platformSupported() || !b.valid() || !filepath.IsAbs(s.Root) || filepath.Clean(s.Root) != s.Root { + return h, failure("invalid_storage") + } + physical, e := filepath.EvalSymlinks(s.Root) + if e != nil || physical != s.Root || !privatePath(s.Root, 0700, true) { + return h, failure("invalid_storage") + } + final := filepath.Join(s.Root, b.StorageID) + if b.ExistingHandle != "" && b.ExistingHandle != final { + return h, failure("storage_conflict") + } + if !canChown(b.DataUID, b.DataGID) { + return h, failure("unsupported_configuration") + } + lock, e := openLock(filepath.Join(s.Root, ".lock-"+b.StorageID)) + if e != nil { + return h, failure("storage_failed") + } + defer lock.Close() + for { + e = tryLock(lock) + if e == nil { + break + } + if !lockBusy(e) { + return h, failure("storage_failed") + } + select { + case <-ctx.Done(): + return h, ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } + defer func() { + if e := unlock(lock); e != nil && err == nil { + h = Handle{} + err = failure("storage_failed") + } + }() + if e := check(ctx, authorize); e != nil { + return h, e + } + if _, e := os.Lstat(final); e == nil { + if !validFinal(final, b) { + return h, failure("storage_conflict") + } + if syncDir(s.Root) != nil { + return h, failure("storage_failed") + } + if e := check(ctx, authorize); e != nil { + return h, e + } + return Handle{final, filepath.Join(final, "data"), b}, nil + } else if !os.IsNotExist(e) { + return h, failure("storage_failed") + } + if b.ExistingHandle != "" { + return h, failure("storage_unavailable") + } + if source == nil || !reflect.DeepEqual(source.Descriptor.Map(), b.Initial.Map()) { + return h, failure("seed_mismatch") + } + info, e := os.Lstat(source.ProjectsPath) + if e != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return h, failure("invalid_seed") + } + if e := s.discardOwnedTemps(b); e != nil { + return h, e + } + temp, e := os.MkdirTemp(s.Root, ".seed-"+b.StorageID+"-") + if e != nil { + return h, failure("storage_failed") + } + published := false + defer func() { + if !published { + if e := removeTemp(temp); e != nil && err == nil { + h = Handle{} + err = failure("storage_failed") + } + } + }() + if writePrivate(filepath.Join(temp, "owner.json"), marker(b, false)) != nil || syncDir(temp) != nil || syncDir(s.Root) != nil { + return h, failure("storage_failed") + } + if e := s.point("owned_temp"); e != nil { + return h, e + } + if e := copyData(ctx, source.ProjectsPath, filepath.Join(temp, "data"), b, s.point); e != nil { + return h, e + } + if e := s.point("copy_complete"); e != nil { + return h, e + } + if writePrivate(filepath.Join(temp, "seed.json"), marker(b, true)) != nil || syncDir(temp) != nil { + return h, failure("storage_failed") + } + if e := s.point("manifest_synced"); e != nil { + return h, e + } + if e := check(ctx, authorize); e != nil { + return h, e + } + if e := s.point("before_rename"); e != nil { + return h, e + } + if e := renameNoReplace(temp, final); e != nil { + if !errors.Is(e, os.ErrExist) || !validFinal(final, b) { + return h, failure("storage_conflict") + } + } else { + published = true + } + if e := s.point("after_rename"); e != nil { + return h, e + } + if syncDir(s.Root) != nil { + return h, failure("storage_failed") + } + if e := check(ctx, authorize); e != nil { + return h, e + } + return Handle{final, filepath.Join(final, "data"), b}, nil +} +func validFinal(parent string, b Binding) bool { + if !privatePath(parent, 0700, true) || !privatePath(filepath.Join(parent, "seed.json"), 0600, false) { + return false + } + raw, e := os.ReadFile(filepath.Join(parent, "seed.json")) + if e != nil || !bytes.Equal(raw, marker(b, true)) { + return false + } + info, e := os.Lstat(filepath.Join(parent, "data")) + return e == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 +} +func (s Store) discardOwnedTemps(b Binding) error { + entries, e := os.ReadDir(s.Root) + if e != nil { + return failure("storage_failed") + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".seed-"+b.StorageID+"-") { + continue + } + parent := filepath.Join(s.Root, entry.Name()) + owner := filepath.Join(parent, "owner.json") + if !privatePath(parent, 0700, true) || !privatePath(owner, 0600, false) { + return failure("storage_conflict") + } + raw, e := os.ReadFile(owner) + if e != nil || !bytes.Equal(raw, marker(b, false)) { + return failure("storage_conflict") + } + if removeTemp(parent) != nil { + return failure("storage_failed") + } + } + return syncFixed(s.Root) +} +func syncFixed(path string) error { + if syncDir(path) != nil { + return failure("storage_failed") + } + return nil +} +func writePrivate(path string, raw []byte) error { + file, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if e != nil { + return e + } + _, e = file.Write(raw) + if e == nil { + e = file.Sync() + } + ce := file.Close() + if e == nil { + e = ce + } + return e +} +func syncDir(path string) error { + file, e := os.Open(path) + if e != nil { + return e + } + e = file.Sync() + ce := file.Close() + if e == nil { + e = ce + } + return e +} +func copyData(ctx context.Context, source, dest string, b Binding, point func(string) error) error { + type directory struct { + path string + mode os.FileMode + } + dirs := []directory{} + e := filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if walkErr != nil { + return failure("invalid_seed") + } + rel, e := filepath.Rel(source, path) + if e != nil { + return failure("invalid_seed") + } + target := filepath.Join(dest, rel) + info, e := entry.Info() + if e != nil { + return failure("invalid_seed") + } + mode := info.Mode().Perm() & 0777 + switch { + case entry.IsDir(): + if os.Mkdir(target, 0700) != nil { + return failure("storage_failed") + } + dirs = append(dirs, directory{target, mode}) + case info.Mode().IsRegular(): + in, e := openSource(path) + if e != nil { + return failure("invalid_seed") + } + out, e := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if e != nil { + in.Close() + return failure("storage_failed") + } + _, e = io.Copy(out, in) + ice := in.Close() + if e == nil { + e = ice + } + if e == nil { + e = out.Chown(int(b.DataUID), int(b.DataGID)) + } + if e == nil { + e = out.Chmod(mode) + } + if e == nil { + e = out.Sync() + } + ce := out.Close() + if e != nil || ce != nil { + return failure("storage_failed") + } + if e := point("copy_file"); e != nil { + return e + } + case info.Mode()&os.ModeSymlink != 0: + link, e := os.Readlink(path) + if e != nil || filepath.IsAbs(link) { + return failure("invalid_seed") + } + resolved := filepath.Clean(filepath.Join(filepath.Dir(rel), link)) + if resolved == ".." || strings.HasPrefix(resolved, "../") { + return failure("invalid_seed") + } + if os.Symlink(link, target) != nil || os.Lchown(target, int(b.DataUID), int(b.DataGID)) != nil { + return failure("storage_failed") + } + default: + return failure("invalid_seed") + } + return nil + }) + if e != nil { + return e + } + for i := len(dirs) - 1; i >= 0; i-- { + // Keep an open directory descriptor before changing its owner/mode. + f, e := os.Open(dirs[i].path) + if e != nil { + return failure("storage_failed") + } + e = f.Chown(int(b.DataUID), int(b.DataGID)) + if e == nil { + e = f.Chmod(dirs[i].mode) + } + if e == nil { + e = f.Sync() + } + ce := f.Close() + if e != nil || ce != nil { + return failure("storage_failed") + } + } + + return nil +} + +// CheckInitialOwner is a pre-effect capability predicate; it mutates no files. +func CheckInitialOwner(uid, gid uint32) error { + if !platformSupported() || uid == 4294967295 || gid == 4294967295 || !canChown(uid, gid) { + return failure("unsupported_configuration") + } + return nil +} + +// removeTemp is called only for this invocation's generated parent, or an exact +// privately owned incomplete marker validated under the storage lock. Never final. +func removeTemp(parent string) error { + var writable func(string) error + writable = func(path string) error { + info, e := os.Lstat(path) + if e != nil { + return e + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil + } + if os.Chown(path, os.Geteuid(), os.Getegid()) != nil || os.Chmod(path, 0700) != nil { + return os.ErrPermission + } + entries, e := os.ReadDir(path) + if e != nil { + return e + } + for _, entry := range entries { + if entry.IsDir() { + if e := writable(filepath.Join(path, entry.Name())); e != nil { + return e + } + } + } + return nil + } + if e := writable(parent); e != nil { + return e + } + return os.RemoveAll(parent) +} diff --git a/apps/host_agent/internal/storage/seed_test.go b/apps/host_agent/internal/storage/seed_test.go new file mode 100644 index 00000000..7183e498 --- /dev/null +++ b/apps/host_agent/internal/storage/seed_test.go @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: EUPL-1.2 +package storage + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func fixture(t *testing.T) (Store, Binding, *config.Resolved) { + t.Helper() + root, e := filepath.EvalSymlinks(t.TempDir()) + if e != nil { + t.Fatal(e) + } + os.Chmod(root, 0700) + source := t.TempDir() + os.WriteFile(filepath.Join(source, "binary"), []byte{0, 1, 255, 128}, 0600) + os.Mkdir(filepath.Join(source, "dir"), 0755) + os.Symlink("../binary", filepath.Join(source, "dir/link")) + d := config.Descriptor{RepositoryIdentity: "https://example.test/config.git", Commit: strings.Repeat("a", 40), Submodules: []config.Submodule{}} + b := Binding{OwnerID: "00000000-0000-4000-8000-000000000001", HostID: "00000000-0000-4000-8000-000000000002", WorkspaceID: "00000000-0000-4000-8000-000000000003", StorageID: "00000000-0000-4000-8000-000000000004", ReservationOperationID: "00000000-0000-4000-8000-000000000005", Initial: d, DataUID: uint32(os.Geteuid()), DataGID: uint32(os.Getegid())} + return Store{Root: root}, b, &config.Resolved{ProjectsPath: source, Descriptor: d} +} +func allow(context.Context) error { return nil } +func assertCode(t *testing.T, e error, code string) { + t.Helper() + var err *config.Error + if !errors.As(e, &err) || err.Code != code { + t.Fatalf("got %v want %s", e, code) + } +} +func TestSeedPublishesWholePrivateParentAndRetainsEditedBinaryUntracked(t *testing.T) { + s, b, source := fixture(t) + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + t.Fatal(e) + } + if h.Parent != filepath.Join(s.Root, b.StorageID) || h.Data != filepath.Join(h.Parent, "data") { + t.Fatal("handle mismatch") + } + info, e := os.Stat(h.Parent) + if e != nil || info.Mode().Perm() != 0700 { + t.Fatal("parent not private") + } + if _, e := os.Stat(filepath.Join(h.Parent, "seed.json")); e != nil { + t.Fatal(e) + } + edit := []byte{0, 9, 255, 0, 42} + os.WriteFile(filepath.Join(h.Data, "binary"), edit, 0640) + os.WriteFile(filepath.Join(h.Data, "untracked"), []byte("keep me"), 0600) + b.ExistingHandle = h.Parent + source.Descriptor.Commit = strings.Repeat("b", 40) + second, e := s.Seed(context.Background(), b, source, allow) + if e != nil || second.Parent != h.Parent { + t.Fatal("valid original seed not reused", e) + } + raw, e := os.ReadFile(filepath.Join(h.Data, "binary")) + if e != nil || sha256.Sum256(raw) != sha256.Sum256(edit) { + t.Fatal("binary reset") + } + if raw, e := os.ReadFile(filepath.Join(h.Data, "untracked")); e != nil || string(raw) != "keep me" { + t.Fatal("untracked reset") + } +} +func TestSeedKnownHandleMissingAndWrongOriginalNeverCopies(t *testing.T) { + s, b, source := fixture(t) + b.ExistingHandle = filepath.Join(s.Root, b.StorageID) + _, e := s.Seed(context.Background(), b, source, allow) + assertCode(t, e, "storage_unavailable") + if _, e := os.Stat(b.ExistingHandle); !os.IsNotExist(e) { + t.Fatal("known missing handle was repaired") + } + b.ExistingHandle = "" + source.Descriptor.Commit = strings.Repeat("b", 40) + _, e = s.Seed(context.Background(), b, source, allow) + assertCode(t, e, "seed_mismatch") +} +func TestSeedAuthorizationPrecedesAnyFilesystemEffect(t *testing.T) { + s, b, source := fixture(t) + denied := errors.New("denied") + _, e := s.Seed(context.Background(), b, source, func(context.Context) error { return denied }) + if !errors.Is(e, denied) { + t.Fatal(e) + } + entries, e := os.ReadDir(s.Root) + if e != nil || len(entries) != 0 { + t.Fatal("unauthorized storage effect") + } +} +func TestSeedInvalidFinalPreservedAndSymlinkEscapeRefused(t *testing.T) { + s, b, source := fixture(t) + final := filepath.Join(s.Root, b.StorageID) + os.Mkdir(final, 0700) + os.WriteFile(filepath.Join(final, "canary"), []byte("retained"), 0600) + _, e := s.Seed(context.Background(), b, source, allow) + assertCode(t, e, "storage_conflict") + if raw, e := os.ReadFile(filepath.Join(final, "canary")); e != nil || string(raw) != "retained" { + t.Fatal("foreign data changed") + } + s, b, source = fixture(t) + os.Symlink("/etc/passwd", filepath.Join(source.ProjectsPath, "escape")) + _, e = s.Seed(context.Background(), b, source, allow) + assertCode(t, e, "invalid_seed") + if _, e := os.Stat(filepath.Join(s.Root, b.StorageID)); !os.IsNotExist(e) { + t.Fatal("partial published") + } +} +func TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains(t *testing.T) { + for _, point := range []string{"owned_temp", "copy_file", "copy_complete", "manifest_synced", "before_rename", "after_rename"} { + t.Run(point, func(t *testing.T) { + s, b, source := fixture(t) + interrupted := errors.New("interrupted") + s.checkpoint = func(p string) error { + if p == point { + return interrupted + } + return nil + } + _, e := s.Seed(context.Background(), b, source, allow) + if !errors.Is(e, interrupted) { + t.Fatal(e) + } + final := filepath.Join(s.Root, b.StorageID) + if point != "after_rename" { + if _, e := os.Stat(final); !os.IsNotExist(e) { + t.Fatal("partial final visible") + } + } + s.checkpoint = nil + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + t.Fatal(e) + } + raw, e := os.ReadFile(filepath.Join(h.Data, "binary")) + if e != nil || string(raw) != string([]byte{0, 1, 255, 128}) { + t.Fatal("incomplete copy") + } + }) + } +} +func TestSeedCompetingPublishersHaveOnePersistentFinal(t *testing.T) { + s, b, source := fixture(t) + var wg sync.WaitGroup + results := make(chan Handle, 4) + failures := make(chan error, 4) + for range 4 { + wg.Go(func() { + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + failures <- e + } else { + results <- h + } + }) + } + wg.Wait() + close(results) + close(failures) + for e := range failures { + t.Error(e) + } + if len(results) != 4 { + t.Fatal("publisher failed") + } + for h := range results { + if h.Parent != filepath.Join(s.Root, b.StorageID) { + t.Fatal("multiple final parents") + } + } +} + +func TestSeedForeignPublisherCollisionNeverOverwrites(t *testing.T) { + s, b, source := fixture(t) + final := filepath.Join(s.Root, b.StorageID) + s.checkpoint = func(p string) error { + if p == "before_rename" { + os.Mkdir(final, 0700) + os.WriteFile(filepath.Join(final, "foreign"), []byte("keep"), 0600) + } + return nil + } + _, e := s.Seed(context.Background(), b, source, allow) + assertCode(t, e, "storage_conflict") + raw, e := os.ReadFile(filepath.Join(final, "foreign")) + if e != nil || string(raw) != "keep" { + t.Fatal("foreign final overwritten") + } +} +func TestSeedInitialModesOwnershipAndChangedOwnerRefusal(t *testing.T) { + s, b, source := fixture(t) + os.WriteFile(filepath.Join(source.ProjectsPath, "executable"), []byte("fixture"), 0755) + os.Mkdir(filepath.Join(source.ProjectsPath, "readonly"), 0700) + os.WriteFile(filepath.Join(source.ProjectsPath, "readonly/file"), []byte("keep"), 0644) + os.Chmod(filepath.Join(source.ProjectsPath, "readonly"), 0555) + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + t.Fatal(e) + } + info, e := os.Stat(filepath.Join(h.Data, "executable")) + if e != nil || info.Mode().Perm() != 0755 { + t.Fatal("executable mode lost") + } + t.Cleanup(func() { + os.Chmod(filepath.Join(source.ProjectsPath, "readonly"), 0700) + os.Chmod(filepath.Join(h.Data, "readonly"), 0700) + }) + info, e = os.Stat(filepath.Join(h.Data, "readonly")) + if e != nil || info.Mode().Perm() != 0555 { + t.Fatal("readonly directory mode lost") + } + b.DataGID++ + _, e = s.Seed(context.Background(), b, source, allow) + if e == nil { + t.Fatal("changed initial owner adopted") + } +} + +// This helper is test-only. Production storage has no environment crash switch. +func TestSeedProcessHelper(t *testing.T) { + if os.Getenv("WHR_SEED_HELPER") != "1" { + return + } + raw, e := base64.StdEncoding.DecodeString(os.Getenv("WHR_SEED_BINDING")) + if e != nil { + t.Fatal(e) + } + var b Binding + if json.Unmarshal(raw, &b) != nil { + t.Fatal("helper binding") + } + root, source, point := os.Getenv("WHR_SEED_ROOT"), os.Getenv("WHR_SEED_SOURCE"), os.Getenv("WHR_SEED_POINT") + s := Store{Root: root} + s.checkpoint = func(p string) error { + if p == point { + process, e := os.FindProcess(os.Getpid()) + if e != nil { + t.Fatal(e) + } + process.Kill() + select {} + } + return nil + } + if ready := os.Getenv("WHR_SEED_READY"); ready != "" { + os.WriteFile(ready, []byte("ready"), 0600) + deadline := time.Now().Add(5 * time.Second) + for { + if _, e := os.Stat(os.Getenv("WHR_SEED_START")); e == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("helper barrier deadline") + } + time.Sleep(10 * time.Millisecond) + } + } + _, e = s.Seed(context.Background(), b, &config.Resolved{ProjectsPath: source, Descriptor: b.Initial}, allow) + if e != nil { + t.Fatal(e) + } +} +func helper(ctx context.Context, s Store, b Binding, source *config.Resolved, point string) *exec.Cmd { + raw, _ := json.Marshal(b) + c := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestSeedProcessHelper$") + c.Env = []string{"WHR_SEED_HELPER=1", "WHR_SEED_BINDING=" + base64.StdEncoding.EncodeToString(raw), "WHR_SEED_ROOT=" + s.Root, "WHR_SEED_SOURCE=" + source.ProjectsPath, "WHR_SEED_POINT=" + point} + return c +} +func TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset(t *testing.T) { + for _, point := range []string{"owned_temp", "copy_file", "copy_complete", "manifest_synced", "before_rename", "after_rename"} { + t.Run(point, func(t *testing.T) { + s, b, source := fixture(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + c := helper(ctx, s, b, source, point) + e := c.Run() + var exit *exec.ExitError + if !errors.As(e, &exit) || ctx.Err() != nil || exit.ProcessState.ExitCode() != -1 { + t.Fatalf("helper was not killed at checkpoint: %v", e) + } + final := filepath.Join(s.Root, b.StorageID) + if point != "after_rename" { + if _, e := os.Lstat(final); !os.IsNotExist(e) { + t.Fatal("partial final exposed") + } + } else { + os.WriteFile(filepath.Join(final, "data/untracked"), []byte("retained-after-crash"), 0600) + } + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + t.Fatal(e) + } + raw, e := os.ReadFile(filepath.Join(h.Data, "binary")) + if e != nil || string(raw) != string([]byte{0, 1, 255, 128}) { + t.Fatal("partial data recovered") + } + if point == "after_rename" { + raw, e := os.ReadFile(filepath.Join(h.Data, "untracked")) + if e != nil || string(raw) != "retained-after-crash" { + t.Fatal("completed final recopied") + } + } + }) + } +} +func TestSeedIndependentProcessesSerializeOneFinal(t *testing.T) { + s, b, source := fixture(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + start := filepath.Join(t.TempDir(), "start") + commands := []*exec.Cmd{} + for _, name := range []string{"one", "two"} { + c := helper(ctx, s, b, source, "") + ready := filepath.Join(filepath.Dir(start), name) + c.Env = append(c.Env, "WHR_SEED_READY="+ready, "WHR_SEED_START="+start) + if e := c.Start(); e != nil { + t.Fatal(e) + } + commands = append(commands, c) + } + for _, name := range []string{"one", "two"} { + for { + if _, e := os.Stat(filepath.Join(filepath.Dir(start), name)); e == nil { + break + } + if ctx.Err() != nil { + t.Fatal("helper startup deadline") + } + time.Sleep(10 * time.Millisecond) + } + } + os.WriteFile(start, []byte("go"), 0600) + for _, c := range commands { + if e := c.Wait(); e != nil { + t.Fatal(e) + } + } + if _, e := s.Seed(context.Background(), b, source, allow); e != nil { + t.Fatal(e) + } + entries, e := os.ReadDir(s.Root) + if e != nil { + t.Fatal(e) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".seed-") { + t.Fatal("owned incomplete stage left") + } + } +} diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 8e516949..fea4e04f 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1244,3 +1244,25 @@ source is a separate checkpoint; this record does not accept unfinished provider integration. Real Linux/Docker, nonroot ownership/retention and source/pristine application/manifest gates remain outstanding. Feature006 T009 remains unchecked; Feature006 T010 waits, and lifecycle receipts/recovery remain outside this task. + +### T009 atomic initial-seed checkpoint (partial source only) + +The host-internal store publishes the complete private parent with a kernel +NO-REPLACE rename and parent fsync; only its data subtree is for workspace mounts. +The marker binds the ORIGINAL storage reservation/descriptor/numeric owner. A known +provider handle whose final path is missing refuses before copy or repair. Existing +valid final data is reused without traversal, recopy, reset, chmod or chown. Current +native authorization is checked before filesystem work, inside the storage lock +and before publication/receipt. Maintained x/sys v0.48.0 supplies Linux/Darwin +no-replace primitives; unsupported platforms/filesystems refuse without fallback. + +The native suite includes competing independent publishers, six actual own-child +SIGKILL checkpoints, retained edited binary/untracked hashes and invalid/foreign +final preservation. Latest serial package result is storage1.130s (exit0) in +/private/tmp/workharbor-t009-pure-slices-final.log; focused vet also exits0. The +original extended readonly test-cleanup failure and exact earlier superseding +GREEN remain recorded above and untouched. These are Darwin native tests, not +Linux/Docker or power-loss proof. Full provider integration/current immutable +source checks and real Linux numeric-nonroot Git/chmod/retention/crash acceptance +remain required. This checkpoint does not implement Feature006 T010 lifecycle +reconciliation and does not accept Feature006 T009 or change any task checkbox. From 1fede52a1cbbb560baa6996e81dfc35e6c24511d Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 15:20:12 +0200 Subject: [PATCH 28/51] fix(config): check every ARG and canonical stage name Close reserved frontend arguments hidden after earlier ARG operands and follow maintained case-insensitive Dockerfile stage matching, including generated-name collisions. Preserve the independent findings and red-to-green evidence. Refs: #72 Co-authored-by: Codex --- .../internal/config/dockerfile_plan.go | 6 +++--- .../internal/config/dockerfile_plan_test.go | 19 ++++++++++++++++++ apps/host_agent/internal/config/trusted.go | 7 ++++++- .../internal/config/trusted_test.go | 14 +++++++++++++ docs/exec-plans/active/installable-pilot.md | 20 +++++++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/apps/host_agent/internal/config/dockerfile_plan.go b/apps/host_agent/internal/config/dockerfile_plan.go index c12a2fee..fd24ad0d 100644 --- a/apps/host_agent/internal/config/dockerfile_plan.go +++ b/apps/host_agent/internal/config/dockerfile_plan.go @@ -38,15 +38,15 @@ func AnalyzeDockerfile(raw []byte, target string) (DockerfilePlan, error) { } name := "" if node.Next.Next != nil { - name = node.Next.Next.Next.Value + name = strings.ToLower(node.Next.Next.Next.Value) } - if name == "dev_containers_target_stage" { + if name == "dev_containers_target_stage" || name == "dev_container_auto_added_stage_label" { return DockerfilePlan{}, unsupportedConfiguration() } if name != "" { aliases[strings.ToLower(name)] = base } - if target == "" || name == target { + if target == "" || strings.EqualFold(name, target) { stage := name if stage == "" { stage = "dev_container_auto_added_stage_label" diff --git a/apps/host_agent/internal/config/dockerfile_plan_test.go b/apps/host_agent/internal/config/dockerfile_plan_test.go index e37d1674..36fa443b 100644 --- a/apps/host_agent/internal/config/dockerfile_plan_test.go +++ b/apps/host_agent/internal/config/dockerfile_plan_test.go @@ -25,3 +25,22 @@ func TestDockerfilePlanBindsSelectedStageToExternalBase(t *testing.T) { t.Fatal(p, e) } } + +func TestDockerfilePlanCanonicalizesMixedCaseStagesAndAliases(t *testing.T) { + base := "ubuntu@sha256:" + strings.Repeat("a", 64) + raw := []byte("FROM " + base + " AS Builder\nFROM BUILDER AS App\n") + for _, target := range []string{"builder", "BUILDER", "app", "APP", ""} { + plan, e := AnalyzeDockerfile(raw, target) + stage := "app" + if strings.EqualFold(target, "builder") { + stage = "builder" + } + if e != nil || plan.SelectedBase != base || plan.Stage != stage { + t.Fatal(target, plan, e) + } + } + for _, reserved := range []string{"DEV_CONTAINERS_TARGET_STAGE", "Dev_Container_Auto_Added_Stage_Label"} { + _, e := AnalyzeDockerfile([]byte("FROM "+base+" AS "+reserved+"\n"), "") + code(t, e, "unsupported_configuration") + } +} diff --git a/apps/host_agent/internal/config/trusted.go b/apps/host_agent/internal/config/trusted.go index 1f6c672d..23affed9 100644 --- a/apps/host_agent/internal/config/trusted.go +++ b/apps/host_agent/internal/config/trusted.go @@ -369,9 +369,14 @@ func ValidateDockerfile(raw []byte) ([]string, error) { case "volume", "onbuild": return nil, unsupportedConfiguration() case "arg": - if node.Next == nil || reservedArgument(strings.SplitN(node.Next.Value, "=", 2)[0]) { + if node.Next == nil { return nil, unsupportedConfiguration() } + for argument := node.Next; argument != nil; argument = argument.Next { + if reservedArgument(strings.SplitN(argument.Value, "=", 2)[0]) { + return nil, unsupportedConfiguration() + } + } case "from": from = true if len(node.Flags) > 0 || node.Next == nil { diff --git a/apps/host_agent/internal/config/trusted_test.go b/apps/host_agent/internal/config/trusted_test.go index 8f0b5379..08479f4a 100644 --- a/apps/host_agent/internal/config/trusted_test.go +++ b/apps/host_agent/internal/config/trusted_test.go @@ -138,3 +138,17 @@ func TestDockerfileMaintainedGrammarCheckCommentsNeverPanic(t *testing.T) { t.Fatal(e) } } + +func TestDockerfileEveryReservedARGDeclarationRefuses(t *testing.T) { + base := "ubuntu@sha256:" + strings.Repeat("a", 64) + for _, arg := range []string{"ARG OK BUILDKIT_SYNTAX=evil/frontend", "ARG FIRST SECOND _DEV_CONTAINERS_BASE_IMAGE=evil", "ARG OK=1 BUILDKIT_INLINE_CACHE=1 LAST=2"} { + for _, prefix := range []string{"FROM " + base + "\n", ""} { + raw := prefix + arg + "\n" + if prefix == "" { + raw += "FROM " + base + "\n" + } + _, e := ValidateDockerfile([]byte(raw)) + code(t, e, "unsupported_configuration") + } + } +} diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index fea4e04f..eb459d7f 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1266,3 +1266,23 @@ Linux/Docker or power-loss proof. Full provider integration/current immutable source checks and real Linux numeric-nonroot Git/chmod/retention/crash acceptance remain required. This checkpoint does not implement Feature006 T010 lifecycle reconciliation and does not accept Feature006 T009 or change any task checkbox. + +### T009 config/parser independent findings and corrections + +Independent review of exact `9a5be1535364e263503b62798ea4d01ee0bf2bc5` found +two P2 issues: later names in a multi-name ARG could bypass reserved-argument +refusal, and raw mixed-case AS names did not follow maintained case-insensitive +target semantics. Reviewer baseline/probes are preserved at +/private/tmp/workharbor-t009-config-review-vh0rswnl/ (baseline/probes/results). +Author regression RED is /private/tmp/workharbor-t009-config-review-fixes-red.log +(exit1, both assertions,0.340s). Every maintained AST ARG operand is now checked; +stage names/target matching and generated reserved-name collisions normalize +case consistently. Mixed-case alias chains and both generated reserved names +have independent regression cases. + +Full native config/devcontainer packages pass after the corrections in +/private/tmp/workharbor-t009-config-review-fixes-green.log (exit0,13.558s/0.505s), +and focused vet exits0 in /private/tmp/workharbor-t009-config-review-fixes-vet.log. +Exact independent correction review remains required; this is not a claim that +all T009 source is reviewed. Provider implementation and real Linux/Docker gates +remain unfinished and Feature006 T009 remains unchecked. From 4ab775c4c8fa8f6c57a4552c12ed4c05d102cc29 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 15:36:59 +0200 Subject: [PATCH 29/51] fix(storage): resolve complete initial symlink chains Lexical paths can hide escapes through earlier symlinks. Require resolvable initial chains inside the original project subtree before publication, preserve contained link bytes, and leave retained files untouched. Refs: #72 Co-authored-by: Codex --- apps/host_agent/internal/storage/seed.go | 12 +++++ apps/host_agent/internal/storage/seed_test.go | 46 +++++++++++++++++++ docs/exec-plans/active/installable-pilot.md | 22 +++++++++ .../contracts/devcontainer-provider.md | 7 +++ 4 files changed, 87 insertions(+) diff --git a/apps/host_agent/internal/storage/seed.go b/apps/host_agent/internal/storage/seed.go index bbc34b68..aab401c1 100644 --- a/apps/host_agent/internal/storage/seed.go +++ b/apps/host_agent/internal/storage/seed.go @@ -266,6 +266,10 @@ func syncDir(path string) error { return e } func copyData(ctx context.Context, source, dest string, b Binding, point func(string) error) error { + physicalSource, evalErr := filepath.EvalSymlinks(source) + if evalErr != nil { + return failure("invalid_seed") + } type directory struct { path string mode os.FileMode @@ -334,6 +338,14 @@ func copyData(ctx context.Context, source, dest string, b Binding, point func(st if resolved == ".." || strings.HasPrefix(resolved, "../") { return failure("invalid_seed") } + // Clean alone cannot resolve "same/.." when same is itself a link. + // Initial links must resolve completely inside the ORIGINAL copied + // project subtree. Dangling and cyclic inputs are deliberately refused. + actual, resolveErr := filepath.EvalSymlinks(path) + actualRelative, relativeErr := filepath.Rel(physicalSource, actual) + if resolveErr != nil || relativeErr != nil || !filepath.IsLocal(actualRelative) { + return failure("invalid_seed") + } if os.Symlink(link, target) != nil || os.Lchown(target, int(b.DataUID), int(b.DataGID)) != nil { return failure("storage_failed") } diff --git a/apps/host_agent/internal/storage/seed_test.go b/apps/host_agent/internal/storage/seed_test.go index 7183e498..ab486b59 100644 --- a/apps/host_agent/internal/storage/seed_test.go +++ b/apps/host_agent/internal/storage/seed_test.go @@ -358,3 +358,49 @@ func TestSeedIndependentProcessesSerializeOneFinal(t *testing.T) { } } } + +func TestSeedActualSymlinkChainsNeverEscapeDataOrRemainIndeterminate(t *testing.T) { + for _, kind := range []string{"escape-after-symlink", "cycle", "dangling"} { + t.Run(kind, func(t *testing.T) { + s, b, source := fixture(t) + external := filepath.Join(filepath.Dir(source.ProjectsPath), "seed.json") + os.WriteFile(external, []byte("private-canary"), 0600) + switch kind { + case "escape-after-symlink": + os.Symlink(".", filepath.Join(source.ProjectsPath, "same")) + os.Symlink("same/../seed.json", filepath.Join(source.ProjectsPath, "escape")) + case "cycle": + os.Symlink("b", filepath.Join(source.ProjectsPath, "a")) + os.Symlink("a", filepath.Join(source.ProjectsPath, "b")) + case "dangling": + os.Symlink("missing", filepath.Join(source.ProjectsPath, "dangling")) + } + _, e := s.Seed(context.Background(), b, source, allow) + assertCode(t, e, "invalid_seed") + if _, e = os.Lstat(filepath.Join(s.Root, b.StorageID)); !os.IsNotExist(e) { + t.Fatal("invalid link published") + } + raw, e := os.ReadFile(external) + if e != nil || string(raw) != "private-canary" { + t.Fatal("external data changed") + } + }) + } +} + +func TestSeedFullyResolvedContainedLinkChainKeepsOriginalLinkBytes(t *testing.T) { + s, b, source := fixture(t) + os.Symlink("dir/link", filepath.Join(source.ProjectsPath, "chain")) + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + t.Fatal(e) + } + link, e := os.Readlink(filepath.Join(h.Data, "chain")) + if e != nil || link != "dir/link" { + t.Fatal("link rewritten", link, e) + } + raw, e := os.ReadFile(filepath.Join(h.Data, "chain")) + if e != nil || string(raw) != string([]byte{0, 1, 255, 128}) { + t.Fatal("contained chain broken", e) + } +} diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index eb459d7f..0c0fae83 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1286,3 +1286,25 @@ and focused vet exits0 in /private/tmp/workharbor-t009-config-review-fixes-vet.l Exact independent correction review remains required; this is not a claim that all T009 source is reviewed. Provider implementation and real Linux/Docker gates remain unfinished and Feature006 T009 remains unchecked. + +### T009 storage independent symlink finding and correction + +Independent review of the c606 seed blobs found a P2: lexical Clean admitted +`same -> .` followed by `escape -> same/../seed.json`, whose actual host-side +resolution leaves data/ and reaches the private parent manifest. This violates +initial-data containment; no claim of escaping the container's bind mount follows. +Reviewer RED remains /private/tmp/workharbor-t009-config-fix-review-mnx__mnj/storage-probe.log. +Author escape/cycle/dangling RED remains +/private/tmp/workharbor-t009-storage-link-review-red.log (exit1,0.404s). + +The independently DESIGN-CLEAR narrowing requires relative INITIAL symlinks with +existing complete target chains inside canonical ORIGINAL ProjectsPath. Standard +EvalSymlinks plus Rel/IsLocal rejects actual escapes, cycles and dangling/indeterminate +inputs before publication; original link bytes for ../binary and contained chains +are preserved. Valid retained data is never traversed or rewritten. All TestSeed +roots pass in /private/tmp/workharbor-t009-storage-link-review-green-final.log +(exit0,1.358s), including six SIGKILL boundaries and independent publishers; +focused vet exits0 in /private/tmp/workharbor-t009-storage-link-review-vet.log. +This is author native Darwin correction evidence; exact independent source +re-review and all real Linux/Docker acceptance remain pending. The new read-only +Store.Inspect integration file/tests are separate WIP outside this checkpoint. diff --git a/specs/006-whr-cli/contracts/devcontainer-provider.md b/specs/006-whr-cli/contracts/devcontainer-provider.md index 03396bb9..8f80f25b 100644 --- a/specs/006-whr-cli/contracts/devcontainer-provider.md +++ b/specs/006-whr-cli/contracts/devcontainer-provider.md @@ -278,6 +278,13 @@ refuse, never delete/repair/recopy. Rename collision validates final under same After-rename/before-receipt crash reuses exact complete final after parent fsync, never seeds twice. Temp cleanup only exact owned staged candidates. Source filesystem APIs/filenames are implementation detail; crash tests do not claim power-loss proof. +Initial copied symlinks must be relative and their complete existing target chains +must resolve inside canonical ORIGINAL ProjectsPath. Absolute, escaping, cyclic, +dangling or otherwise indeterminate initial links refuse invalid_seed before +publication. Standard filesystem resolution checks actual chains, not only lexical +Clean. Preserve original bytes for legitimate ../binary and contained chains. +This explicit initial-input subset never traverses or rewrites valid retained data. + Runtime stop/start/remove never delete storage. T010 owns lifecycle/data-delete and multi-runtime cleanup authorization; no broad label-based removal. Host systemd stays on Linux host; no AI orchestration/native tool/bootstrap enters images. From 39f62cc0f089c6e02c4a5d53c23ecda81d309605 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 18:41:10 +0200 Subject: [PATCH 30/51] feat(provider): implement trusted Docker workspace primitives Resolve approved immutable configuration, preserve original seeded project data, and constrain official Dev Container execution to verified native Docker operations. Keep lifecycle dispatch and durable phase receipts for T010; real Linux acceptance remains pending. Refs: #72 Co-authored-by: Codex --- apps/host_agent/cmd/workharbor-agent/main.go | 28 ++ .../cmd/workharbor-agent/main_test.go | 10 + apps/host_agent/internal/config/jsonc_test.go | 35 ++ .../internal/devcontainer/parse.cjs | 17 +- .../internal/providers/docker/admission.go | 99 ++++ .../providers/docker/admission_test.go | 96 ++++ .../internal/providers/docker/allocation.go | 135 +++++ .../providers/docker/allocation_test.go | 163 ++++++ .../internal/providers/docker/bridge.go | 474 ++++++++++++++++++ .../providers/docker/bridge_cancel_test.go | 117 +++++ .../internal/providers/docker/bridge_test.go | 139 +++++ .../internal/providers/docker/cli.go | 60 +++ .../internal/providers/docker/cli_test.go | 70 +++ .../docker/completion_process_test.go | 62 +++ .../internal/providers/docker/config.go | 215 ++++++++ .../internal/providers/docker/config_test.go | 141 ++++++ .../docker/docker_acceptance_test.go | 413 +++++++++++++++ .../docker/docker_registry_acceptance_test.go | 209 ++++++++ .../internal/providers/docker/driver.go | 388 ++++++++++++++ .../providers/docker/driver_executable.go | 34 ++ .../internal/providers/docker/driver_test.go | 109 ++++ .../internal/providers/docker/effective.go | 186 +++++++ .../internal/providers/docker/engine_args.go | 98 ++++ .../providers/docker/engine_args_test.go | 63 +++ .../internal/providers/docker/frozen.go | 43 ++ .../internal/providers/docker/gateway.go | 271 ++++++++++ .../internal/providers/docker/gateway_test.go | 177 +++++++ .../internal/providers/docker/generated.go | 107 ++++ .../providers/docker/generated_test.go | 27 + .../providers/docker/metadata_expected.go | 43 ++ .../providers/docker/metadata_process.go | 51 ++ .../providers/docker/metadata_process_test.go | 74 +++ .../internal/providers/docker/native_build.go | 122 +++++ .../providers/docker/native_build_test.go | 93 ++++ .../providers/docker/native_decode.go | 106 ++++ .../providers/docker/native_decode_test.go | 55 ++ .../providers/docker/native_execution.go | 273 ++++++++++ .../providers/docker/native_execution_test.go | 112 +++++ .../internal/providers/docker/native_image.go | 105 ++++ .../providers/docker/native_image_test.go | 45 ++ .../docker/native_live_reads_test.go | 79 +++ .../internal/providers/docker/native_open.go | 169 +++++++ .../internal/providers/docker/native_port.go | 188 +++++++ .../providers/docker/native_port_test.go | 86 ++++ .../providers/docker/native_process.go | 40 ++ .../providers/docker/native_process_test.go | 66 +++ .../providers/docker/official_capture_test.go | 190 +++++++ .../providers/docker/official_phase.go | 168 +++++++ .../internal/providers/docker/owned_other.go | 14 + .../internal/providers/docker/owned_unix.go | 53 ++ .../internal/providers/docker/phase_result.go | 62 +++ .../providers/docker/phase_result_test.go | 25 + .../providers/docker/preflight_driver.go | 91 ++++ .../providers/docker/preflight_driver_test.go | 51 ++ .../internal/providers/docker/prepared.go | 382 ++++++++++++++ .../providers/docker/process_other.go | 8 + .../internal/providers/docker/process_unix.go | 26 + .../providers/docker/read_configuration.go | 104 ++++ .../docker/read_configuration_test.go | 144 ++++++ .../internal/providers/docker/registry.go | 370 ++++++++++++++ .../providers/docker/registry_numbers_test.go | 32 ++ .../providers/docker/registry_test.go | 127 +++++ .../providers/docker/registry_trust.go | 101 ++++ .../providers/docker/registry_trust_test.go | 78 +++ .../internal/providers/docker/tool-pins.json | 22 + apps/host_agent/internal/storage/inspect.go | 66 +++ .../internal/storage/inspect_test.go | 71 +++ docs/development/ci-acceptance.json | 411 ++++++++++++++- docs/development/devcontainer-matrix.md | 55 ++ docs/exec-plans/active/installable-pilot.md | 104 ++++ examples/workspace/README.md | 8 + .../.devcontainer/Dockerfile | 4 + .../.devcontainer/devcontainer.json | 20 + .../workspace/admitted-dockerfile/README.md | 13 + .../projects/app/README.md | 6 + .../admitted-dockerfile/projects/app/seed.bin | Bin 0 -> 4 bytes .../admitted-dockerfile/workharbor.json | 6 + .../.devcontainer/devcontainer.json | 16 + examples/workspace/admitted-image/README.md | 13 + .../admitted-image/projects/app/README.md | 6 + .../admitted-image/projects/app/seed.bin | Bin 0 -> 4 bytes .../workspace/admitted-image/workharbor.json | 6 + scripts/application_checks.py | 7 +- scripts/check_repo.py | 13 +- scripts/test_application_checks.py | 18 + .../contracts/devcontainer-provider.md | 101 +++- tests/test_harness.py | 20 + 87 files changed, 8696 insertions(+), 9 deletions(-) create mode 100644 apps/host_agent/internal/providers/docker/admission.go create mode 100644 apps/host_agent/internal/providers/docker/admission_test.go create mode 100644 apps/host_agent/internal/providers/docker/allocation.go create mode 100644 apps/host_agent/internal/providers/docker/allocation_test.go create mode 100644 apps/host_agent/internal/providers/docker/bridge.go create mode 100644 apps/host_agent/internal/providers/docker/bridge_cancel_test.go create mode 100644 apps/host_agent/internal/providers/docker/bridge_test.go create mode 100644 apps/host_agent/internal/providers/docker/cli.go create mode 100644 apps/host_agent/internal/providers/docker/cli_test.go create mode 100644 apps/host_agent/internal/providers/docker/completion_process_test.go create mode 100644 apps/host_agent/internal/providers/docker/config.go create mode 100644 apps/host_agent/internal/providers/docker/config_test.go create mode 100644 apps/host_agent/internal/providers/docker/docker_acceptance_test.go create mode 100644 apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go create mode 100644 apps/host_agent/internal/providers/docker/driver.go create mode 100644 apps/host_agent/internal/providers/docker/driver_executable.go create mode 100644 apps/host_agent/internal/providers/docker/driver_test.go create mode 100644 apps/host_agent/internal/providers/docker/effective.go create mode 100644 apps/host_agent/internal/providers/docker/engine_args.go create mode 100644 apps/host_agent/internal/providers/docker/engine_args_test.go create mode 100644 apps/host_agent/internal/providers/docker/frozen.go create mode 100644 apps/host_agent/internal/providers/docker/gateway.go create mode 100644 apps/host_agent/internal/providers/docker/gateway_test.go create mode 100644 apps/host_agent/internal/providers/docker/generated.go create mode 100644 apps/host_agent/internal/providers/docker/generated_test.go create mode 100644 apps/host_agent/internal/providers/docker/metadata_expected.go create mode 100644 apps/host_agent/internal/providers/docker/metadata_process.go create mode 100644 apps/host_agent/internal/providers/docker/metadata_process_test.go create mode 100644 apps/host_agent/internal/providers/docker/native_build.go create mode 100644 apps/host_agent/internal/providers/docker/native_build_test.go create mode 100644 apps/host_agent/internal/providers/docker/native_decode.go create mode 100644 apps/host_agent/internal/providers/docker/native_decode_test.go create mode 100644 apps/host_agent/internal/providers/docker/native_execution.go create mode 100644 apps/host_agent/internal/providers/docker/native_execution_test.go create mode 100644 apps/host_agent/internal/providers/docker/native_image.go create mode 100644 apps/host_agent/internal/providers/docker/native_image_test.go create mode 100644 apps/host_agent/internal/providers/docker/native_live_reads_test.go create mode 100644 apps/host_agent/internal/providers/docker/native_open.go create mode 100644 apps/host_agent/internal/providers/docker/native_port.go create mode 100644 apps/host_agent/internal/providers/docker/native_port_test.go create mode 100644 apps/host_agent/internal/providers/docker/native_process.go create mode 100644 apps/host_agent/internal/providers/docker/native_process_test.go create mode 100644 apps/host_agent/internal/providers/docker/official_capture_test.go create mode 100644 apps/host_agent/internal/providers/docker/official_phase.go create mode 100644 apps/host_agent/internal/providers/docker/owned_other.go create mode 100644 apps/host_agent/internal/providers/docker/owned_unix.go create mode 100644 apps/host_agent/internal/providers/docker/phase_result.go create mode 100644 apps/host_agent/internal/providers/docker/phase_result_test.go create mode 100644 apps/host_agent/internal/providers/docker/preflight_driver.go create mode 100644 apps/host_agent/internal/providers/docker/preflight_driver_test.go create mode 100644 apps/host_agent/internal/providers/docker/prepared.go create mode 100644 apps/host_agent/internal/providers/docker/process_other.go create mode 100644 apps/host_agent/internal/providers/docker/process_unix.go create mode 100644 apps/host_agent/internal/providers/docker/read_configuration.go create mode 100644 apps/host_agent/internal/providers/docker/read_configuration_test.go create mode 100644 apps/host_agent/internal/providers/docker/registry.go create mode 100644 apps/host_agent/internal/providers/docker/registry_numbers_test.go create mode 100644 apps/host_agent/internal/providers/docker/registry_test.go create mode 100644 apps/host_agent/internal/providers/docker/registry_trust.go create mode 100644 apps/host_agent/internal/providers/docker/registry_trust_test.go create mode 100644 apps/host_agent/internal/storage/inspect.go create mode 100644 apps/host_agent/internal/storage/inspect_test.go create mode 100644 docs/development/devcontainer-matrix.md create mode 100644 examples/workspace/admitted-dockerfile/.devcontainer/Dockerfile create mode 100644 examples/workspace/admitted-dockerfile/.devcontainer/devcontainer.json create mode 100644 examples/workspace/admitted-dockerfile/README.md create mode 100644 examples/workspace/admitted-dockerfile/projects/app/README.md create mode 100644 examples/workspace/admitted-dockerfile/projects/app/seed.bin create mode 100644 examples/workspace/admitted-dockerfile/workharbor.json create mode 100644 examples/workspace/admitted-image/.devcontainer/devcontainer.json create mode 100644 examples/workspace/admitted-image/README.md create mode 100644 examples/workspace/admitted-image/projects/app/README.md create mode 100644 examples/workspace/admitted-image/projects/app/seed.bin create mode 100644 examples/workspace/admitted-image/workharbor.json diff --git a/apps/host_agent/cmd/workharbor-agent/main.go b/apps/host_agent/cmd/workharbor-agent/main.go index 472f64ae..baa2033d 100644 --- a/apps/host_agent/cmd/workharbor-agent/main.go +++ b/apps/host_agent/cmd/workharbor-agent/main.go @@ -6,15 +6,26 @@ import ( "context" "fmt" "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker" "io" "os" "os/signal" + "strconv" "syscall" + "time" ) func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, out, errOut io.Writer) int { + if len(args) > 0 && args[0] == "__devcontainer-docker" { + if runPrivateDocker(args, out) != nil { + fmt.Fprintln(errOut, "workharbor-agent: provider_driver_unavailable") + return 1 + } + return 0 + } + if len(args) == 3 && args[0] == "run" && args[1] == "--identity-dir" { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -35,3 +46,20 @@ func run(args []string, out, errOut io.Writer) int { fmt.Fprintln(errOut, "workharbor-agent: product commands are not implemented; use --help") return 1 } + +// This private executable adapter opens only a host-owned Unix socket. It does +// not accept a Docker endpoint or activate a host/control-plane lifecycle channel. +func runPrivateDocker(args []string, out io.Writer) error { + if len(args) < 4 { + return fmt.Errorf("provider_driver_unavailable") + } + millis, e := strconv.ParseInt(args[2], 10, 64) + if e != nil || strconv.FormatInt(millis, 10) != args[2] || !time.UnixMilli(millis).After(time.Now()) { + return fmt.Errorf("provider_driver_unavailable") + } + ctx, cancel := context.WithDeadline(context.Background(), time.UnixMilli(millis)) + defer cancel() + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + return docker.ProxyDocker(ctx, args[1], args[3:], os.Stdin, out) +} diff --git a/apps/host_agent/cmd/workharbor-agent/main_test.go b/apps/host_agent/cmd/workharbor-agent/main_test.go index 68b01e55..a34ebe19 100644 --- a/apps/host_agent/cmd/workharbor-agent/main_test.go +++ b/apps/host_agent/cmd/workharbor-agent/main_test.go @@ -26,3 +26,13 @@ func TestRunRefusesMissingIdentityWithFixedDiagnostic(t *testing.T) { t.Fatalf("unexpected run refusal: %d %q %q", code, out.String(), errOut.String()) } } + +func TestPrivateDockerProxyModeRejectsMalformedLifetimeAndSocket(t *testing.T) { + for _, args := range [][]string{{"__devcontainer-docker"}, {"__devcontainer-docker", "/missing/socket", "0", "-v"}, {"__devcontainer-docker", "/missing/socket", "not-time", "exec"}} { + var out, diagnostic bytes.Buffer + code := run(args, &out, &diagnostic) + if code != 1 || out.Len() != 0 || diagnostic.String() != "workharbor-agent: provider_driver_unavailable\n" { + t.Fatal("private helper accepted or disclosed args", code, out.String(), diagnostic.String()) + } + } +} diff --git a/apps/host_agent/internal/config/jsonc_test.go b/apps/host_agent/internal/config/jsonc_test.go index 8362bfaa..1eb37b53 100644 --- a/apps/host_agent/internal/config/jsonc_test.go +++ b/apps/host_agent/internal/config/jsonc_test.go @@ -3,6 +3,8 @@ package config import ( "context" + "encoding/json" + "os" "os/exec" "path/filepath" "testing" @@ -48,3 +50,36 @@ func TestStrictMetadataJSONRefusesCommentsAndTrailingComma(t *testing.T) { t.Fatal(e) } } + +func TestJSONCPreservesExactNumericLexemes(t *testing.T) { + parser := jsoncTool(t) + for _, strict := range []bool{false, true} { + raw := []byte(`{"n":9007199254740993,"nested":[1.0000000000000001,{"n":1e2}],"negative":-0}`) + value, e := parser.Parse(context.Background(), raw, strict) + if e != nil { + t.Fatal(e) + } + object := value.(map[string]any) + nested := object["nested"].([]any) + if object["n"] != json.Number("9007199254740993") || nested[0] != json.Number("1.0000000000000001") || nested[1].(map[string]any)["n"] != json.Number("1e2") || object["negative"] != json.Number("-0") { + t.Fatalf("numeric lexemes changed: %#v", value) + } + } +} +func TestResolverRejectsNoncanonicalSchemaIntegerLexemes(t *testing.T) { + for _, number := range []string{"1.0000000000000001", "1e0", "9007199254740993", "18446744073709551616"} { + t.Run(number, func(t *testing.T) { + r, d := resolverFixture(t, func(root string) { + raw := `{"schema_version":` + number + `,"devcontainer_path":".devcontainer/devcontainer.json","projects_path":"projects","bundles":[]}` + if e := os.WriteFile(filepath.Join(root, "workharbor.json"), []byte(raw), 0600); e != nil { + t.Fatal(e) + } + }) + result, e := r.Resolve(context.Background(), d) + if result != nil { + result.Close() + } + code(t, e, "invalid_configuration") + }) + } +} diff --git a/apps/host_agent/internal/devcontainer/parse.cjs b/apps/host_agent/internal/devcontainer/parse.cjs index 030e19cd..f17cb612 100644 --- a/apps/host_agent/internal/devcontainer/parse.cjs +++ b/apps/host_agent/internal/devcontainer/parse.cjs @@ -2,7 +2,7 @@ // The pinned reference JSONC parser supplies syntax; this wrapper rejects ambiguity. 'use strict'; const fs = require('node:fs'); -const { parseTree, getNodeValue } = require('jsonc-parser'); +const { parseTree } = require('jsonc-parser'); function scalarString(value) { for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); @@ -26,6 +26,19 @@ function validate(node) { } for (const child of node.children || []) validate(child); } +// Serialize validated AST nodes without converting number lexemes through JS Number. +function render(node, raw) { + switch (node.type) { + case 'number': return raw.slice(node.offset, node.offset + node.length); + case 'object': + return '{' + (node.children || []).map(property => + JSON.stringify(property.children[0].value) + ':' + render(property.children[1], raw) + ).join(',') + '}'; + case 'array': return '[' + (node.children || []).map(child => render(child, raw)).join(',') + ']'; + case 'string': case 'boolean': case 'null': return JSON.stringify(node.value); + default: throw new Error(); + } +} try { if (!['strict', 'jsonc'].includes(process.argv[1]) || process.argv.length !== 2) throw new Error(); const raw = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(0)); @@ -34,7 +47,7 @@ try { const tree = parseTree(raw, errors, { allowTrailingComma: !strict, disallowComments: strict, allowEmptyContent: false }); if (!tree || errors.length) throw new Error(); validate(tree); - process.stdout.write(JSON.stringify(getNodeValue(tree))); + process.stdout.write(render(tree, raw)); } catch (_) { process.stderr.write('invalid_configuration\n'); process.exitCode = 1; diff --git a/apps/host_agent/internal/providers/docker/admission.go b/apps/host_agent/internal/providers/docker/admission.go new file mode 100644 index 00000000..07ce1dd7 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/admission.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "fmt" + "reflect" + "strconv" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage" +) + +// Admission is native preparation data with a separately supplied current fence. +// It adds no wire message, transport activation, lifecycle claim or trust grant. +// The lifecycle caller must recheck the fence before every filesystem/daemon effect. +type Admission struct { + scope Scope + descriptor config.Descriptor + binding storage.Binding + authorize func(context.Context) error +} + +func (a Admission) Scope() Scope { return a.scope } +func (a Admission) Descriptor() config.Descriptor { + d, _ := config.DecodeDescriptor(a.descriptor.Map()) + return d +} +func (a Admission) Binding() storage.Binding { + b := a.binding + b.Initial, _ = config.DecodeDescriptor(b.Initial.Map()) + return b +} +func (a Admission) Check(ctx context.Context) error { + if ctx.Err() != nil { + return ctx.Err() + } + if a.authorize == nil { + return fail("forbidden") + } + return a.authorize(ctx) +} + +// Admit independently revalidates T007 data against its native authenticated +// Context, then matches exact owner approval and authoritative CP storage binding. +// new_eligible alone is insufficient: authorize must verify current host assignment, +// operation/intent/approval and held native fence. It is never derived from body IDs. +func Admit(ctx context.Context, command map[string]any, trusted protocol.Context, b storage.Binding, approval config.Approval, authorize func(context.Context) error) (Admission, error) { + if authorize == nil { + return Admission{}, fail("forbidden") + } + validated, e := protocol.Validate(command, trusted) + if e != nil { + return Admission{}, e + } + if validated.Disposition != "new_eligible" || validated.Message["kind"] != "command" { + return Admission{}, fail("operation_not_eligible") + } + m := validated.Message + request := m["request"].(map[string]any) + action := request["action"] + if action != "create" && action != "rebuild" { + return Admission{}, fail("operation_not_eligible") + } + descriptor, e := config.DecodeDescriptor(request["parameters"].(map[string]any)["config"]) + if e != nil { + return Admission{}, e + } + owner, host, workspace, operation := m["owner_id"].(string), m["host_id"].(string), m["workspace_id"].(string), m["operation_id"].(string) + if e = config.MatchApproval(approval, owner, m["approval_ref"].(string), descriptor); e != nil { + return Admission{}, e + } + if b.OwnerID != owner || b.HostID != host || b.WorkspaceID != workspace || !uuid.MatchString(b.StorageID) || !uuid.MatchString(b.ReservationOperationID) { + return Admission{}, fail("storage_conflict") + } + initial, e := config.DecodeDescriptor(b.Initial.Map()) + if e != nil { + return Admission{}, fail("storage_conflict") + } + if action == "create" && (b.ReservationOperationID != operation || b.ExistingHandle != "" || !reflect.DeepEqual(initial.Map(), descriptor.Map())) { + return Admission{}, fail("storage_conflict") + } + candidate := validated.NativeBindings["candidate"].(map[string]any) + generation, e := strconv.ParseUint(fmt.Sprint(candidate["generation"]), 10, 64) + if e != nil { + return Admission{}, fail("runtime_conflict") + } + scope := Scope{OwnerID: owner, HostID: host, WorkspaceID: workspace, RuntimeID: candidate["id"].(string), AllocationOperationID: operation, StorageID: b.StorageID, Generation: generation} + if !scope.valid() { + return Admission{}, fail("runtime_conflict") + } + b.Initial = initial + a := Admission{scope: scope, descriptor: descriptor, binding: b, authorize: authorize} + if e = a.Check(ctx); e != nil { + return Admission{}, e + } + return a, nil +} diff --git a/apps/host_agent/internal/providers/docker/admission_test.go b/apps/host_agent/internal/providers/docker/admission_test.go new file mode 100644 index 00000000..26f2fd53 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/admission_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage" + "os" + "strings" + "testing" +) + +func admissionFixture(t *testing.T) (map[string]any, protocol.Context, storage.Binding, config.Approval) { + t.Helper() + raw, e := os.ReadFile("../../../../../contracts/host-protocol/vectors.json") + if e != nil { + t.Fatal(e) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != "dedc629e6989fdfaaad8053fe14a0680b692495fdfff88eb1f4e5a29c534d66d" { + t.Fatal("upstream vector provenance") + } + var v struct { + RequestBytes string `json:"request_bytes"` + RequestHash string `json:"request_sha256"` + Command string `json:"command_canonical"` + } + if json.Unmarshal(raw, &v) != nil { + t.Fatal("vectors") + } + value, e := wirejson.Parse([]byte(v.Command)) + if e != nil { + t.Fatal(e) + } + command := value.(map[string]any) + owner := command["owner_id"].(string) + host := command["host_id"].(string) + workspace := command["workspace_id"].(string) + candidate := command["bindings"].(map[string]any)["candidate"].(map[string]any) + ctx := protocol.Context{Peer: &protocol.Peer{Kind: "control_plane", OwnerID: owner, HostID: host}, Snapshot: &protocol.Snapshot{Command: command, RequestBytes: []byte(v.RequestBytes), RequestHash: v.RequestHash, NativeBindings: map[string]any{"target": nil, "candidate": map[string]any{"id": candidate["runtime_id"], "generation": candidate["generation"]}}, CurrentIntent: 1, Status: "queued"}} + d, e := config.DecodeDescriptor(command["request"].(map[string]any)["parameters"].(map[string]any)["config"]) + if e != nil { + t.Fatal(e) + } + b := storage.Binding{OwnerID: owner, HostID: host, WorkspaceID: workspace, StorageID: "00000000-0000-0000-0000-000000000008", ReservationOperationID: command["operation_id"].(string), Initial: d} + return command, ctx, b, config.Approval{ID: command["approval_ref"].(string), OwnerID: owner, Descriptor: d} +} +func TestNativeAdmissionEligibleDataNeedsExactApprovalStorageAndCurrentFence(t *testing.T) { + command, ctx, b, approval := admissionFixture(t) + validated, e := protocol.Validate(command, ctx) + if e != nil || validated.Disposition != "new_eligible" { + t.Fatal(validated, e) + } + _, e = Admit(context.Background(), command, ctx, b, approval, nil) + errorCode(t, e, "forbidden") + checks := 0 + plan, e := Admit(context.Background(), command, ctx, b, approval, func(context.Context) error { checks++; return nil }) + if e != nil || checks != 1 || plan.Scope().StorageID != b.StorageID || plan.Scope().AllocationOperationID != b.ReservationOperationID || plan.Descriptor().Commit != approval.Descriptor.Commit { + t.Fatal(plan, e, checks) + } + for _, mutate := range []func(*storage.Binding, *config.Approval){func(b *storage.Binding, a *config.Approval) { a.Descriptor.Commit = strings.Repeat("b", 40) }, func(b *storage.Binding, a *config.Approval) { a.OwnerID = b.StorageID }, func(b *storage.Binding, a *config.Approval) { b.OwnerID = b.StorageID }, func(b *storage.Binding, a *config.Approval) { b.Initial.Commit = strings.Repeat("c", 40) }} { + wrongB, wrongA := b, approval + mutate(&wrongB, &wrongA) + checks = 0 + if _, e = Admit(context.Background(), command, ctx, wrongB, wrongA, func(context.Context) error { checks++; return nil }); e == nil || checks != 0 { + t.Fatal("foreign trust/storage reached current effect fence", e, checks) + } + } + _, e = Admit(context.Background(), command, ctx, b, approval, func(context.Context) error { return errors.New("revoked") }) + if e == nil || e.Error() != "revoked" { + t.Fatal(e) + } +} +func TestNativeAdmissionReplayAndReconciliationNeverAuthorizeNewEffects(t *testing.T) { + command, ctx, b, a := admissionFixture(t) + validated, e := protocol.Validate(command, ctx) + if e != nil { + t.Fatal(e) + } + ctx.Snapshot.CommandBytes = validated.Canonical + for _, status := range []string{"queued", "executing", "succeeded"} { + ctx.Snapshot.Status = status + calls := 0 + _, e = Admit(context.Background(), command, ctx, b, a, func(context.Context) error { calls++; return nil }) + errorCode(t, e, "operation_not_eligible") + if calls != 0 { + t.Fatal("lookup/reconciliation reached effect fence") + } + } +} diff --git a/apps/host_agent/internal/providers/docker/allocation.go b/apps/host_agent/internal/providers/docker/allocation.go new file mode 100644 index 00000000..2dbc3d89 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/allocation.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// allocation binds identity only during its single live native run request. It +// cannot recover a lost request, report resource completion or adopt by labels. +type allocation struct { + mu sync.Mutex + cid, root, data, image string + scope Scope + deadline time.Time + check func(context.Context) error + begun, live, closed bool + target *Target +} + +func reserveAllocation(ctx context.Context, root string, scope Scope, data, image string, check func(context.Context) error) (*allocation, error) { + if check == nil { + return nil, fail("forbidden") + } + if e := ctx.Err(); e != nil { + return nil, e + } + if e := check(ctx); e != nil { + return nil, e + } + deadline, ok := ctx.Deadline() + if !ok || !deadline.After(time.Now()) || !ownedDirectory(root) || !scope.valid() || !validData(data) || !strings.HasPrefix(image, "sha256:") || !hexID.MatchString(strings.TrimPrefix(image, "sha256:")) { + return nil, fail("provider_refused") + } + private, e := os.MkdirTemp(root, "allocation-") + if e != nil { + return nil, fail("provider_unavailable") + } + a := &allocation{cid: filepath.Join(private, scope.RuntimeID+".cid"), root: private, scope: scope, data: data, image: image, deadline: deadline, check: check} + if e = check(ctx); e != nil { + os.RemoveAll(private) + return nil, e + } + return a, nil +} +func (a *allocation) authorized(ctx context.Context) error { + if e := ctx.Err(); e != nil { + return e + } + if !time.Now().Before(a.deadline) || a.check == nil { + return fail("runtime_unknown") + } + return a.check(ctx) +} +func (a *allocation) Begin(ctx context.Context) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed || a.begun { + return fail("runtime_unknown") + } + if e := a.authorized(ctx); e != nil { + return e + } + if !ownedDirectory(a.root) { + return fail("runtime_unknown") + } + if _, e := os.Lstat(a.cid); !os.IsNotExist(e) { + return fail("runtime_unknown") + } + a.begun = true + a.live = true + return nil +} +func (a *allocation) Observe(ctx context.Context, candidates []Container) (Target, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed || !a.live { + return Target{}, fail("runtime_unknown") + } + if e := a.authorized(ctx); e != nil { + return Target{}, e + } + if !ownedDirectory(a.root) { + return Target{}, fail("runtime_unknown") + } + info, e := os.Lstat(a.cid) + if e != nil || !ownedRegular(info) || info.Size() != 64 { + return Target{}, fail("runtime_unknown") + } + f, e := os.Open(a.cid) + if e != nil { + return Target{}, fail("runtime_unknown") + } + actual, se := f.Stat() + raw, re := io.ReadAll(io.LimitReader(f, 65)) + ce := f.Close() + current, le := os.Lstat(a.cid) + if se != nil || re != nil || ce != nil || le != nil || !os.SameFile(info, actual) || !os.SameFile(info, current) || !ownedRegular(current) || len(raw) != 64 || !hexID.Match(raw) { + return Target{}, fail("runtime_unknown") + } + target := Target{Scope: a.scope, ContainerID: string(raw), ImageID: a.image, DataPath: a.data} + if _, e = VerifyTarget(target, candidates); e != nil { + return Target{}, e + } + if a.target != nil && *a.target != target { + return Target{}, fail("runtime_conflict") + } + // End uses this same lock. Check/deadline and first binding are one live action. + if e = a.authorized(ctx); e != nil { + return Target{}, e + } + if a.target == nil { + a.target = &target + } + return *a.target, nil +} +func (a *allocation) End() { a.mu.Lock(); a.live = false; a.mu.Unlock() } +func (a *allocation) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + a.live = false + if a.closed { + return nil + } + a.closed = true + if !ownedDirectory(a.root) { + return fail("runtime_unknown") + } + return os.RemoveAll(a.root) +} diff --git a/apps/host_agent/internal/providers/docker/allocation_test.go b/apps/host_agent/internal/providers/docker/allocation_test.go new file mode 100644 index 00000000..ab92e24d --- /dev/null +++ b/apps/host_agent/internal/providers/docker/allocation_test.go @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func allocationFixture(t *testing.T) (*allocation, context.Context, Container) { + t.Helper() + g, p := gatewayFixture(t, Resources) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + a, e := reserveAllocation(ctx, bridgeRoot(t), g.Policy.Scope, g.Policy.DataPath, g.Target.ImageID, func(context.Context) error { return nil }) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { a.Close() }) + return a, ctx, p.candidates[0] +} +func TestAllocationCIDIsAbsentPrivateAndOnlyLiveExactIdentityBinds(t *testing.T) { + a, ctx, c := allocationFixture(t) + if _, e := os.Lstat(a.cid); !os.IsNotExist(e) { + t.Fatal("CID precreated", e) + } + info, e := os.Stat(filepath.Dir(a.cid)) + if e != nil || info.Mode().Perm() != 0700 { + t.Fatal("private reservation", e) + } + if e = a.Begin(ctx); e != nil { + t.Fatal(e) + } + if e = a.Begin(ctx); e == nil { + t.Fatal("duplicate allocation admitted") + } + if e = os.WriteFile(a.cid, []byte(c.ID), 0644); e != nil { + t.Fatal(e) + } + target, e := a.Observe(ctx, []Container{c}) + if e != nil || target.ContainerID != c.ID { + t.Fatal("live exact target", target, e) + } + c.ID = strings.Repeat("c", 64) + os.WriteFile(a.cid, []byte(c.ID), 0644) + if _, e = a.Observe(ctx, []Container{c}); e == nil { + t.Fatal("first target replaced") + } + a.End() + if _, e = a.Observe(ctx, []Container{c}); e == nil { + t.Fatal("closed allocation adopted") + } +} +func TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind(t *testing.T) { + for _, kind := range []string{"missing", "partial", "newline", "symlink", "duplicate", "foreign", "revoked", "expired"} { + t.Run(kind, func(t *testing.T) { + a, ctx, c := allocationFixture(t) + if e := a.Begin(ctx); e != nil { + t.Fatal(e) + } + raw := c.ID + candidates := []Container{c} + switch kind { + case "partial": + raw = c.ID[:10] + case "newline": + raw += "\n" + case "duplicate": + candidates = append(candidates, c) + case "foreign": + candidates[0].Labels["workharbor.owner"] = "foreign" + } + if kind != "missing" { + if kind == "symlink" { + p := filepath.Join(filepath.Dir(a.cid), "foreign") + os.WriteFile(p, []byte(raw), 0600) + os.Symlink(p, a.cid) + } else { + os.WriteFile(a.cid, []byte(raw), 0644) + } + } + if kind == "revoked" { + a.check = func(context.Context) error { return errors.New("revoked") } + } + if kind == "expired" { + canceled, cancel := context.WithCancel(ctx) + cancel() + ctx = canceled + } + if _, e := a.Observe(ctx, candidates); e == nil { + t.Fatal("unknown/foreign identity adopted") + } + }) + } +} +func TestAllocationEndWinsBeforeObservationAndDeniedReservationHasNoEffect(t *testing.T) { + a, ctx, c := allocationFixture(t) + if e := a.Begin(ctx); e != nil { + t.Fatal(e) + } + os.WriteFile(a.cid, []byte(c.ID), 0644) + a.End() + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + if _, e := a.Observe(ctx, []Container{c}); e == nil { + t.Error("bound after End") + } + }) + } + wg.Wait() + root := bridgeRoot(t) + _, e := reserveAllocation(ctx, root, cScope(), "/data", "sha256:"+strings.Repeat("b", 64), func(context.Context) error { return errors.New("denied") }) + if e == nil { + t.Fatal("denied reservation") + } + entries, e := os.ReadDir(root) + if e != nil || len(entries) != 0 { + t.Fatal("unauthorized filesystem effect") + } +} +func cScope() Scope { return policyFixture(Resources).Scope } + +func TestAllocationObserveAndEndSerializeWithoutLateBinding(t *testing.T) { + a, ctx, c := allocationFixture(t) + if e := a.Begin(ctx); e != nil { + t.Fatal(e) + } + os.WriteFile(a.cid, []byte(c.ID), 0644) + entered, release := make(chan struct{}), make(chan struct{}) + first := true + a.check = func(context.Context) error { + if first { + first = false + close(entered) + <-release + } + return nil + } + observed := make(chan error, 1) + go func() { _, e := a.Observe(ctx, []Container{c}); observed <- e }() + <-entered + ended := make(chan struct{}) + go func() { a.End(); close(ended) }() + select { + case <-ended: + t.Fatal("End crossed active atomic observation") + case <-time.After(10 * time.Millisecond): + } + close(release) + if e := <-observed; e != nil { + t.Fatal(e) + } + <-ended + if _, e := a.Observe(ctx, []Container{c}); e == nil { + t.Fatal("observation after End") + } +} diff --git a/apps/host_agent/internal/providers/docker/bridge.go b/apps/host_agent/internal/providers/docker/bridge.go new file mode 100644 index 00000000..f8227401 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/bridge.go @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "encoding/json" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" +) + +type streamPort interface { + InvokeStream(context.Context, Call, io.Reader, io.Writer) error +} + +// ForwardStream shares the same scope/capability/approval admission as Forward. +// Only the reconstructed typed union reaches the native port. No raw executor. +func (g *Gateway) ForwardStream(ctx context.Context, args []string, in io.Reader, out io.Writer) error { + c, e := g.prepareCall(ctx, args) + if e != nil { + return e + } + if port, ok := g.Port.(streamPort); ok { + return port.InvokeStream(ctx, c, in, out) + } + if c.Kind == "run" || c.Kind == "events" || c.Kind == "exec" { + return fail("provider_unavailable") + } + raw, e := g.Port.Invoke(ctx, c) + if e != nil { + return e + } + _, e = out.Write(raw) + return e +} + +// DriverServer is private local CLI plumbing, not a host/control-plane transport. +// Its socket and arguments confer no authority beyond Gateway's native fence. +type DriverServer struct { + Socket string + root string + server *http.Server + cancel context.CancelFunc + once sync.Once + mu sync.Mutex + closed bool + active sync.WaitGroup + err error + requests map[string]*driverRequest +} + +func (d *DriverServer) Close() error { + d.once.Do(func() { + d.mu.Lock() + d.closed = true + d.mu.Unlock() + d.cancel() + d.mu.Lock() + records := []*driverRequest{} + for _, r := range d.requests { + records = append(records, r) + } + d.mu.Unlock() + for _, r := range records { + d.abort(r) + } + d.server.Close() + d.active.Wait() + d.err = os.RemoveAll(d.root) + }) + return d.err +} + +type flushWriter struct{ w http.ResponseWriter } + +func (w flushWriter) Write(p []byte) (int, error) { + n, e := w.w.Write(p) + if e == nil { + e = http.NewResponseController(w.w).Flush() + } + return n, e +} +func driverArgs(body io.Reader) ([]string, error) { + var header [4]byte + if _, e := io.ReadFull(body, header[:]); e != nil { + return nil, fail("provider_refused") + } + length := binary.BigEndian.Uint32(header[:]) + if length == 0 || length > metadataLimit { + return nil, fail("provider_refused") + } + raw := make([]byte, length) + if _, e := io.ReadFull(body, raw); e != nil { + return nil, fail("provider_refused") + } + value, e := wirejson.Parse(raw) + if e != nil { + return nil, fail("provider_refused") + } + m, ok := value.(map[string]any) + if !ok || !exactKeys(m, []string{"args"}) { + return nil, fail("provider_refused") + } + values, ok := m["args"].([]any) + if !ok { + return nil, fail("provider_refused") + } + args := make([]string, len(values)) + for i, v := range values { + arg, ok := v.(string) + if !ok { + return nil, fail("provider_refused") + } + args[i] = arg + } + return args, nil +} +func StartDriver(ctx context.Context, g *Gateway, root string) (*DriverServer, error) { + if g == nil || g.Authorize == nil { + return nil, fail("forbidden") + } + return startDriver(ctx, root, g.Authorize, g.ForwardStream) +} +func startDriver(ctx context.Context, root string, authorize func(context.Context) error, forward func(context.Context, []string, io.Reader, io.Writer) error) (*DriverServer, error) { + if authorize == nil || forward == nil { + return nil, fail("forbidden") + } + if e := authorize(ctx); e != nil { + return nil, e + } + if _, ok := ctx.Deadline(); !ok || !ownedDirectory(root) { + return nil, fail("provider_refused") + } + private, e := os.MkdirTemp(root, "driver-") + if e != nil { + return nil, fail("provider_unavailable") + } + socket := filepath.Join(private, "driver.sock") + listener, e := net.Listen("unix", socket) + if e != nil { + os.RemoveAll(private) + return nil, fail("provider_unavailable") + } + if os.Chmod(socket, 0600) != nil || !ownedSocket(socket) { + listener.Close() + os.RemoveAll(private) + return nil, fail("provider_unavailable") + } + child, cancel := context.WithCancel(ctx) + d := &DriverServer{Socket: socket, root: private, cancel: cancel, requests: map[string]*driverRequest{}} + server := &http.Server{BaseContext: func(net.Listener) context.Context { return child }, ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return context.WithValue(ctx, driverConnectionKey{}, c) + }} + d.server = server + server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + http.Error(w, "provider_refused", http.StatusServiceUnavailable) + return + } + d.active.Add(1) + d.mu.Unlock() + defer d.active.Done() + if r.Method != "POST" || r.URL.RawPath != "" || r.URL.RawQuery != "" || r.Host != "workharbor-driver" || r.Header.Get("Content-Type") != "application/octet-stream" { + http.Error(w, "unknown", 400) + return + } + token := r.Header.Get("WorkHarbor-Request") + if !hexID.MatchString(token) { + http.Error(w, "unknown", 400) + return + } + if r.URL.Path == "/register" { + d.mu.Lock() + if d.closed || d.requests[token] != nil { + d.mu.Unlock() + http.Error(w, "unknown", 409) + return + } + recordCtx, recordCancel := context.WithCancel(child) + d.requests[token] = &driverRequest{ctx: recordCtx, cancel: recordCancel, done: make(chan struct{})} + d.mu.Unlock() + io.WriteString(w, "registered") + return + } + if r.URL.Path == "/cancel" { + d.mu.Lock() + record := d.requests[token] + if record != nil && !record.started { + record.cancel() + record.once.Do(func() { close(record.done) }) + } + d.mu.Unlock() + if record == nil { + io.WriteString(w, "unknown") + return + } + d.abort(record) + timer := time.NewTimer(time.Second) + defer timer.Stop() + select { + case <-record.done: + io.WriteString(w, "client_complete") + case <-timer.C: + io.WriteString(w, "unknown") + case <-r.Context().Done(): + return + } + return + } + if r.URL.Path != "/invoke" { + http.Error(w, "unknown", 400) + return + } + d.mu.Lock() + record := d.requests[token] + if d.closed || record == nil || record.started || record.ctx.Err() != nil || r.Context().Err() != nil { + d.mu.Unlock() + http.Error(w, "unknown", 409) + return + } + connection, _ := r.Context().Value(driverConnectionKey{}).(net.Conn) + if connection == nil { + d.mu.Unlock() + http.Error(w, "unknown", 409) + return + } + record.started = true + record.body = r.Body + record.connection = connection + d.mu.Unlock() + stop := context.AfterFunc(r.Context(), func() { d.abort(record) }) + defer func() { + stop() + record.cancel() + d.mu.Lock() + delete(d.requests, token) + record.once.Do(func() { close(record.done) }) + d.mu.Unlock() + }() + w.Header().Set("Trailer", "WorkHarbor-Result") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("WorkHarbor-Result", "refused") + if http.NewResponseController(w).EnableFullDuplex() != nil { + w.WriteHeader(http.StatusOK) + return + } + args, e := driverArgs(r.Body) + if e != nil { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + if e = record.ctx.Err(); e == nil { + e = authorize(record.ctx) + } + if e == nil { + e = forward(record.ctx, args, r.Body, flushWriter{w}) + } + if e == nil && record.ctx.Err() == nil { + w.Header().Set("WorkHarbor-Result", "ok") + } + + }) + go func() { server.Serve(listener) }() + go func() { <-child.Done(); d.Close() }() + return d, nil +} + +// ProxyDocker is the product-owned executable adapter's private socket client. +// The first bounded frame carries argv; the remaining body is container stdin. +// HTTP is used only over this owned Unix socket, with no proxies or redirects. +func ProxyDocker(ctx context.Context, socket string, args []string, in io.Reader, out io.Writer) error { + if _, ok := ctx.Deadline(); !ok || !filepath.IsAbs(socket) || filepath.Clean(socket) != socket || !ownedSocket(socket) { + return fail("provider_refused") + } + // A streaming input must be owned/closable so transport cancellation cannot + // leave net/http's request writer blocked in an arbitrary caller reader. + if in != nil { + switch in.(type) { + case io.ReadCloser, *bytes.Reader, *bytes.Buffer, *strings.Reader: + default: + return fail("provider_refused") + } + } + for _, arg := range args { + if !utf8.ValidString(arg) || strings.ContainsRune(arg, 0) { + return fail("provider_refused") + } + } + var tokenBytes [32]byte + if _, e := rand.Read(tokenBytes[:]); e != nil { + return fail("provider_unavailable") + } + token := hex.EncodeToString(tokenBytes[:]) + raw, e := json.Marshal(map[string]any{"args": args}) + if e != nil || len(raw) == 0 || len(raw) > metadataLimit { + return fail("provider_refused") + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(raw))) + if in == nil { + in = bytes.NewReader(nil) + } + closeInput := func() { + if closer, ok := in.(io.Closer); ok { + closer.Close() + } + } + transport := &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + connection, e := (&net.Dialer{}).DialContext(ctx, "unix", socket) + if e != nil { + return nil, e + } + return &proxyConnection{Conn: connection, closeInput: closeInput}, nil + }} + controlTransport := &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }} + defer controlTransport.CloseIdleConnections() + controlClient := &http.Client{Transport: controlTransport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + control := func(controlCtx context.Context, path string) string { + request, e := http.NewRequestWithContext(controlCtx, "POST", "http://workharbor-driver"+path, nil) + if e != nil { + return "unknown" + } + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("WorkHarbor-Request", token) + response, e := controlClient.Do(request) + if e != nil { + return "unknown" + } + defer response.Body.Close() + raw, e := io.ReadAll(io.LimitReader(response.Body, 33)) + if e != nil || response.StatusCode != 200 { + return "unknown" + } + return string(raw) + } + if control(ctx, "/register") != "registered" { + return fail("provider_unavailable") + } + cancelDone := make(chan struct{}) + var cancelOnce sync.Once + abort := func() { + cancelOnce.Do(func() { + if closer, ok := in.(io.Closer); ok { + closer.Close() + } + child, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + control(child, "/cancel") + close(cancelDone) + }) + } + stopCancel := context.AfterFunc(ctx, abort) + normal := false + defer func() { + stopCancel() + if !normal || ctx.Err() != nil { + abort() + <-cancelDone + } + }() + if ctx.Err() != nil { + return fail("provider_unavailable") + } + body := &proxyRequestBody{Reader: io.MultiReader(bytes.NewReader(header[:]), bytes.NewReader(raw), in)} + if closer, ok := in.(io.Closer); ok { + body.input = closer + } + request, e := http.NewRequestWithContext(ctx, "POST", "http://workharbor-driver/invoke", body) + if e != nil { + return fail("provider_refused") + } + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("WorkHarbor-Request", token) + response, e := client.Do(request) + if e != nil { + return fail("provider_unavailable") + } + defer func() { + if !normal || ctx.Err() != nil { + abort() + <-cancelDone + } + response.Body.Close() + }() + if response.StatusCode != http.StatusOK { + return fail("provider_refused") + } + if _, e = io.Copy(out, response.Body); e != nil { + return fail("provider_unavailable") + } + if response.Trailer.Get("WorkHarbor-Result") != "ok" { + return fail("provider_refused") + } + if ctx.Err() != nil { + return fail("provider_unavailable") + } + normal = true + return nil +} + +type driverConnectionKey struct{} +type driverRequest struct { + ctx context.Context + cancel context.CancelFunc + done chan struct{} + once sync.Once + started bool + body io.ReadCloser + connection net.Conn +} + +func (d *DriverServer) abort(record *driverRequest) { + record.cancel() + d.mu.Lock() + connection, body := record.connection, record.body + d.mu.Unlock() + // Closing only this request's dedicated private connection unblocks body.Close. + // No Docker socket/resource is controlled by this transport cancellation. + if connection != nil { + connection.Close() + } + if body != nil { + body.Close() + } +} + +// net/http must be able to interrupt its own request writer when the exact +// server connection disappears before response headers, even without ctx cancel. +type proxyRequestBody struct { + io.Reader + input io.Closer +} + +func (b *proxyRequestBody) Close() error { + if b.input != nil { + return b.input.Close() + } + return nil +} + +// A peer close must unblock the request writer's owned stdin even while +// net/http is waiting for that writer before returning a response-header error. +type proxyConnection struct { + net.Conn + closeInput func() +} + +func (c *proxyConnection) Read(p []byte) (int, error) { + n, e := c.Conn.Read(p) + if e != nil { + c.closeInput() + } + return n, e +} +func (c *proxyConnection) Close() error { c.closeInput(); return c.Conn.Close() } diff --git a/apps/host_agent/internal/providers/docker/bridge_cancel_test.go b/apps/host_agent/internal/providers/docker/bridge_cancel_test.go new file mode 100644 index 00000000..15a117d3 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/bridge_cancel_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "strings" + "testing" + "time" +) + +func privateControl(t *testing.T, ctx context.Context, socket, path, token string) string { + t.Helper() + transport := &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }} + defer transport.CloseIdleConnections() + request, e := http.NewRequestWithContext(ctx, "POST", "http://workharbor-driver"+path, nil) + if e != nil { + t.Fatal(e) + } + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("WorkHarbor-Request", token) + response, e := (&http.Client{Transport: transport}).Do(request) + if e != nil { + t.Fatal(e) + } + defer response.Body.Close() + raw, e := io.ReadAll(io.LimitReader(response.Body, 64)) + if e != nil { + t.Fatal(e) + } + return string(raw) +} +func TestPrivateDriverCancellationRegistrationIsOneUseAndUnknownStaysUnknown(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + effects := 0 + driver, e := startDriver(ctx, bridgeRoot(t), func(context.Context) error { return nil }, func(context.Context, []string, io.Reader, io.Writer) error { effects++; return nil }) + if e != nil { + t.Fatal(e) + } + defer driver.Close() + token := strings.Repeat("a", 64) + if got := privateControl(t, ctx, driver.Socket, "/cancel", token); got != "unknown" { + t.Fatal(got) + } + if got := privateControl(t, ctx, driver.Socket, "/register", token); got != "registered" { + t.Fatal(got) + } + if got := privateControl(t, ctx, driver.Socket, "/register", token); got == "registered" { + t.Fatal("duplicate registration") + } + if got := privateControl(t, ctx, driver.Socket, "/cancel", token); got != "client_complete" { + t.Fatal(got) + } + if got := privateControl(t, ctx, driver.Socket, "/invoke", token); got == "ok" || effects != 0 { + t.Fatal("canceled unstarted token invoked", got, effects) + } +} +func TestPrivateDriverUnreadPipeCancellationAndCloseReapExactHandler(t *testing.T) { + for _, closeServer := range []bool{false, true} { + t.Run(map[bool]string{false: "cancel", true: "close"}[closeServer], func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + started, complete := make(chan struct{}), make(chan struct{}) + driver, e := startDriver(ctx, bridgeRoot(t), func(ctx context.Context) error { return ctx.Err() }, func(ctx context.Context, _ []string, in io.Reader, out io.Writer) error { + close(started) + out.Write([]byte("ready\n")) + <-ctx.Done() + _, readError := io.Copy(io.Discard, in) + close(complete) + return readError + }) + if e != nil { + t.Fatal(e) + } + defer driver.Close() + input, writer := io.Pipe() + defer input.Close() + defer writer.Close() + child, stop := context.WithCancel(ctx) + defer stop() + done := make(chan error, 1) + var output bytes.Buffer + go func() { done <- ProxyDocker(child, driver.Socket, []string{"-v"}, input, &output) }() + select { + case <-started: + case <-ctx.Done(): + t.Fatal("not started") + } + if closeServer { + if e := driver.Close(); e != nil { + t.Fatal(e) + } + } else { + stop() + } + select { + case <-complete: + case <-ctx.Done(): + t.Fatal("owned unread input did not unblock/reap") + } + select { + case e := <-done: + if e == nil { + t.Fatal("cancellation succeeded") + } + case <-ctx.Done(): + t.Fatal("client still blocked") + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/bridge_test.go b/apps/host_agent/internal/providers/docker/bridge_test.go new file mode 100644 index 00000000..dde0a681 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/bridge_test.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type streamingEngine struct { + *recordingEngine + started, canceled chan struct{} +} + +func (e *streamingEngine) InvokeStream(ctx context.Context, c Call, in io.Reader, out io.Writer) error { + if c.Kind != "exec" { + return fail("provider_refused") + } + e.effects = append(e.effects, c) + io.WriteString(out, "ready\n") + close(e.started) + <-ctx.Done() + close(e.canceled) + return ctx.Err() +} + +type notifyWriter struct { + buffer bytes.Buffer + ready chan struct{} +} + +func (w *notifyWriter) Write(p []byte) (int, error) { + n, e := w.buffer.Write(p) + select { + case <-w.ready: + default: + close(w.ready) + } + return n, e +} +func bridgeRoot(t *testing.T) string { + t.Helper() + base, e := filepath.EvalSymlinks("/tmp") + if e != nil { + t.Fatal(e) + } + root, e := os.MkdirTemp(base, "whr-driver-") + if e == nil { + t.Cleanup(func() { os.RemoveAll(root) }) + } + root, e = filepath.EvalSymlinks(root) + if e != nil { + t.Fatal(e) + } + if os.Chmod(root, 0700) != nil { + t.Fatal("private root") + } + return root +} +func TestPrivateDriverBridgeStreamsBeforeExitAndCancelsExactClient(t *testing.T) { + g, port := gatewayFixture(t, Hooks) + stream := &streamingEngine{recordingEngine: port, started: make(chan struct{}), canceled: make(chan struct{})} + g.Port = stream + ctx, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + driver, e := StartDriver(ctx, g, bridgeRoot(t)) + if e != nil { + t.Fatal(e) + } + defer driver.Close() + child, cancel := context.WithCancel(ctx) + out := ¬ifyWriter{ready: make(chan struct{})} + done := make(chan error, 1) + go func() { + done <- ProxyDocker(child, driver.Socket, []string{"exec", "-i", "-u", "1000:1000", g.Target.ContainerID, "true"}, strings.NewReader(""), out) + }() + select { + case <-out.ready: + case <-ctx.Done(): + t.Fatal("stream buffered until exit") + } + cancel() + select { + case <-done: + case <-ctx.Done(): + t.Fatal("proxy did not reap client") + } + select { + case <-stream.canceled: + case <-ctx.Done(): + t.Fatal("disconnect did not cancel exact native command") + } + if out.buffer.String() != "ready\n" || len(port.effects) != 1 { + t.Fatal("stream changed") + } +} +func TestPrivateDriverBridgeRejectsUnknownArgvBeforeInvoke(t *testing.T) { + g, port := gatewayFixture(t, Hooks) + ctx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + driver, e := StartDriver(ctx, g, bridgeRoot(t)) + if e != nil { + t.Fatal(e) + } + defer driver.Close() + var out bytes.Buffer + e = ProxyDocker(ctx, driver.Socket, []string{"--host", "unix:///foreign.sock", "exec"}, strings.NewReader(""), &out) + if e == nil || out.Len() != 0 || len(port.effects) != 0 { + t.Fatal("unknown args forwarded", e) + } +} + +type instantEngine struct{ *recordingEngine } + +func (e *instantEngine) InvokeStream(_ context.Context, c Call, _ io.Reader, _ io.Writer) error { + e.effects = append(e.effects, c) + return nil +} +func TestPrivateDriverNeverNormalizesInvalidArgumentBytes(t *testing.T) { + g, port := gatewayFixture(t, Hooks) + g.Port = &instantEngine{port} + ctx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + driver, e := StartDriver(ctx, g, bridgeRoot(t)) + if e != nil { + t.Fatal(e) + } + defer driver.Close() + var out bytes.Buffer + e = ProxyDocker(ctx, driver.Socket, []string{"exec", "-i", "-u", "1000:1000", g.Target.ContainerID, "bad\xff"}, strings.NewReader(""), &out) + if e == nil || len(port.effects) != 0 { + t.Fatal("invalid bytes normalized into executable command", e) + } +} diff --git a/apps/host_agent/internal/providers/docker/cli.go b/apps/host_agent/internal/providers/docker/cli.go new file mode 100644 index 00000000..649c18e0 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/cli.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "path/filepath" + "strings" +) + +// Phase separates read-only capability preflight, resource setup and trusted hooks. +type Phase string + +const ( + Preflight Phase = "preflight" + Resources Phase = "resources" + Hooks Phase = "hooks" +) + +type Invocation struct { + Phase Phase + Scope Scope + Workspace, Config, Override, Driver, UserData, ContainerID, DataPath string +} + +func (i Invocation) Arguments() ([]string, error) { + if !i.Scope.valid() { + return nil, fail("invalid_configuration") + } + for _, path := range []string{i.Workspace, i.Config, i.Override, i.Driver, i.UserData} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsRune(path, 0) { + return nil, fail("invalid_configuration") + } + } + var name string + switch i.Phase { + case Preflight: + name = "read-configuration" + case Resources: + name = "up" + case Hooks: + name = "run-user-commands" + if !hexID.MatchString(i.ContainerID) { + return nil, fail("invalid_configuration") + } + default: + return nil, fail("invalid_configuration") + } + args := []string{name, "--workspace-folder", i.Workspace, "--config", i.Config, "--override-config", i.Override, "--docker-path", i.Driver, "--mount-workspace-git-root", "false", "--mount-git-worktree-common-dir", "false", "--log-format", "json", "--log-level", "info"} + for _, label := range i.Scope.LabelList() { + args = append(args, "--id-label", label) + } + switch i.Phase { + case Preflight: + args = append(args, "--include-merged-configuration", "true") + case Resources: + args = append(args, "--user-data-folder", i.UserData, "--skip-post-create", "true", "--skip-post-attach", "true", "--default-user-env-probe", "none", "--update-remote-user-uid-default", "never", "--omit-syntax-directive", "true", "--gpu-availability", "none", "--include-configuration", "true", "--include-merged-configuration", "true") + case Hooks: + args = append(args, "--user-data-folder", i.UserData, "--container-id", i.ContainerID, "--skip-post-attach", "true", "--default-user-env-probe", "none") + } + return args, nil +} diff --git a/apps/host_agent/internal/providers/docker/cli_test.go b/apps/host_agent/internal/providers/docker/cli_test.go new file mode 100644 index 00000000..7c4d5f98 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/cli_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "reflect" + "strings" + "testing" +) + +func invocation(p Phase) Invocation { + return Invocation{Phase: p, Scope: scopeFixture(), Workspace: "/private/storage/data", Config: "/private/config/.devcontainer/devcontainer.json", Override: "/private/operation/override.json", Driver: "/product/workharbor-agent-driver", UserData: "/private/operation/cli", ContainerID: strings.Repeat("a", 64)} +} +func option(args []string, key string) string { + for i, v := range args { + if v == key && i+1 < len(args) { + return args[i+1] + } + } + return "" +} +func TestOfficialResourceAndHookArgumentsStaySeparated(t *testing.T) { + for _, phase := range []Phase{Preflight, Resources, Hooks} { + i := invocation(phase) + args, e := i.Arguments() + if e != nil { + t.Fatal(e) + } + if option(args, "--config") != i.Config || option(args, "--override-config") != i.Override || option(args, "--docker-path") != i.Driver || option(args, "--mount-workspace-git-root") != "false" || option(args, "--mount-git-worktree-common-dir") != "false" { + t.Fatal("immutable path or mount policy missing") + } + labels := []string{} + for n, v := range args { + if v == "--id-label" && n+1 < len(args) { + labels = append(labels, args[n+1]) + } + } + if len(labels) != len(i.Scope.Labels()) { + t.Fatal("scope labels missing") + } + switch phase { + case Preflight: + if args[0] != "read-configuration" || option(args, "--include-merged-configuration") != "true" || option(args, "--container-id") != "" { + t.Fatal("preflight not fresh read-only config") + } + case Resources: + if args[0] != "up" || option(args, "--skip-post-create") != "true" || option(args, "--skip-post-attach") != "true" || option(args, "--omit-syntax-directive") != "true" || option(args, "--update-remote-user-uid-default") != "never" { + t.Fatal("resource policy missing") + } + case Hooks: + if args[0] != "run-user-commands" || option(args, "--container-id") != i.ContainerID || option(args, "--skip-post-create") != "" || option(args, "--skip-post-attach") != "true" { + t.Fatal("hook phase wrong") + } + } + if phase != Preflight && option(args, "--default-user-env-probe") != "none" { + t.Fatal("host environment probe permitted") + } + again, e := i.Arguments() + if e != nil || !reflect.DeepEqual(args, again) { + t.Fatal("argv unstable") + } + } +} +func TestOfficialInvocationRejectsMissingIdentityPathsAndPhase(t *testing.T) { + for _, mutate := range []func(*Invocation){func(i *Invocation) { i.Phase = "any" }, func(i *Invocation) { i.Driver = "docker" }, func(i *Invocation) { i.Scope.RuntimeID = "foreign" }, func(i *Invocation) { i.ContainerID = "short" }, func(i *Invocation) { i.Override = "/bad\x00path" }} { + i := invocation(Hooks) + mutate(&i) + _, e := i.Arguments() + errorCode(t, e, "invalid_configuration") + } +} diff --git a/apps/host_agent/internal/providers/docker/completion_process_test.go b/apps/host_agent/internal/providers/docker/completion_process_test.go new file mode 100644 index 00000000..b44f3746 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/completion_process_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestOfficialCompletionProcessHelper(t *testing.T) { + mode := os.Getenv("WORKHARBOR_COMPLETION_HELPER") + if mode == "" { + return + } + fmt.Fprintln(os.Stdout, `{"outcome":"success","result":"done"}`) + switch mode { + case "exit0": + os.Exit(0) + case "exit17": + os.Exit(17) + case "wait": + for { + time.Sleep(time.Second) + } + default: + os.Exit(18) + } +} +func TestOfficialCompletionRequiresNormalWaitedExitAfterSuccessStdout(t *testing.T) { + executable, e := os.Executable() + if e != nil { + t.Fatal(e) + } + executable, e = filepath.EvalSymlinks(executable) + if e != nil { + t.Fatal(e) + } + for _, mode := range []string{"exit0", "exit17", "wait"} { + t.Run(mode, func(t *testing.T) { + duration := 3 * time.Second + if mode == "wait" { + duration = 200 * time.Millisecond + } + ctx, cancel := context.WithTimeout(context.Background(), duration) + defer cancel() + raw, e := captureMetadata(ctx, executable, []string{"-test.run=^TestOfficialCompletionProcessHelper$"}, bridgeRoot(t), []string{"WORKHARBOR_COMPLETION_HELPER=" + mode}, 1024) + if mode == "exit0" { + if e != nil { + t.Fatal(e) + } + if _, e := phaseResult(raw, Hooks, ""); e != nil { + t.Fatal(e) + } + } else if e == nil || len(raw) != 0 { + t.Fatal("success stdout promoted abnormal/noncompleted child", mode, e, string(raw)) + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/config.go b/apps/host_agent/internal/providers/docker/config.go new file mode 100644 index 00000000..1ea6228c --- /dev/null +++ b/apps/host_agent/internal/providers/docker/config.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package docker implements a closed provider boundary; no general Docker executor. +package docker + +import ( + "context" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +type Scope struct { + OwnerID, HostID, WorkspaceID, RuntimeID, AllocationOperationID, StorageID string + Generation uint64 +} + +var uuid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +var hexID = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func fail(code string) error { return &config.Error{Code: code} } +func (s Scope) valid() bool { + for _, id := range []string{s.OwnerID, s.HostID, s.WorkspaceID, s.RuntimeID, s.AllocationOperationID, s.StorageID} { + if !uuid.MatchString(id) { + return false + } + } + return s.Generation > 0 && s.Generation <= 9223372036854775807 +} +func (s Scope) Name() string { return "workharbor-runtime-" + s.RuntimeID } +func (s Scope) Labels() map[string]string { + return map[string]string{"workharbor.provider": "docker", "workharbor.owner": s.OwnerID, "workharbor.host": s.HostID, "workharbor.workspace": s.WorkspaceID, "workharbor.runtime": s.RuntimeID, "workharbor.generation": strconv.FormatUint(s.Generation, 10), "workharbor.allocation-operation": s.AllocationOperationID, "workharbor.storage": s.StorageID} +} + +type ImageConfig struct { + Metadata string + Volumes map[string]any + OnBuild []string +} +type Mount struct { + Type, Source, Destination string + RW bool +} +type Container struct { + ID, Name, Image, User, UsernsMode string + Running bool + Labels map[string]string + Mounts []Mount +} + +func validData(path string) bool { + return filepath.IsAbs(path) && filepath.Clean(path) == path && !strings.ContainsAny(path, ",\x00\r\n") +} +func clone(v any) any { + switch x := v.(type) { + case map[string]any: + m := map[string]any{} + for k, v := range x { + m[k] = clone(v) + } + return m + case []any: + a := make([]any, len(x)) + for i, v := range x { + a[i] = clone(v) + } + return a + default: + return v + } +} + +// FullOverride replaces the CLI document, preserving every admitted source field. +func FullOverride(source map[string]any, s Scope, data string) (map[string]any, error) { + if e := config.ValidateSource(source); e != nil { + return nil, e + } + if !s.valid() || !validData(data) { + return nil, fail("invalid_configuration") + } + m := clone(source).(map[string]any) + for k, v := range map[string]any{"workspaceFolder": "/workspace", "workspaceMount": "type=bind,source=" + data + ",target=/workspace", "userEnvProbe": "none", "updateRemoteUserUID": false, "waitFor": "postStartCommand", "shutdownAction": "none", "runArgs": []any{"--name", s.Name()}} { + m[k] = v + } + return m, nil +} + +// ValidateImage rejects raw inherited capabilities before the CLI can discard them. +func ValidateImage(ctx context.Context, p config.JSONC, image ImageConfig) ([]map[string]any, error) { + if len(image.Volumes) != 0 || len(image.OnBuild) != 0 { + return nil, fail("unsupported_configuration") + } + if image.Metadata == "" { + return []map[string]any{}, nil + } + value, e := p.Parse(ctx, []byte(image.Metadata), true) + if e != nil { + return nil, e + } + var values []any + switch v := value.(type) { + case []any: + values = v + case map[string]any: + values = []any{v} + default: + return nil, fail("unsupported_configuration") + } + entries := make([]map[string]any, 0, len(values)) + for _, v := range values { + m, ok := v.(map[string]any) + if !ok { + return nil, fail("unsupported_configuration") + } + if e := validateMetadataEntry(m); e != nil { + return nil, e + } + + entries = append(entries, clone(m).(map[string]any)) + } + return entries, nil +} + +// VerifyRuntime treats discovery as a closed exact-match operation, never first-match. +func VerifyRuntime(s Scope, data string, containers []Container) (Container, error) { + if !s.valid() || !validData(data) { + return Container{}, fail("runtime_conflict") + } + if len(containers) == 0 { + return Container{}, fail("runtime_absent") + } + if len(containers) != 1 { + return Container{}, fail("runtime_conflict") + } + c := containers[0] + if !hexID.MatchString(c.ID) || c.Name != "/"+s.Name() || !hexID.MatchString(strings.TrimPrefix(c.Image, "sha256:")) { + return Container{}, fail("runtime_conflict") + } + for key, value := range s.Labels() { + if c.Labels[key] != value { + return Container{}, fail("runtime_conflict") + } + } + if len(c.Mounts) != 1 { + return Container{}, fail("runtime_conflict") + } + m := c.Mounts[0] + if m.Type != "bind" || m.Source != data || m.Destination != "/workspace" || !m.RW { + return Container{}, fail("runtime_conflict") + } + return c, nil +} + +type Target struct { + Scope Scope + ContainerID, ImageID, DataPath string +} + +func VerifyTarget(target Target, containers []Container) (Container, error) { + if !hexID.MatchString(target.ContainerID) || !hexID.MatchString(strings.TrimPrefix(target.ImageID, "sha256:")) { + return Container{}, fail("runtime_conflict") + } + c, e := VerifyRuntime(target.Scope, target.DataPath, containers) + if e != nil { + return Container{}, e + } + if c.ID != target.ContainerID || strings.TrimPrefix(c.Image, "sha256:") != strings.TrimPrefix(target.ImageID, "sha256:") { + return Container{}, fail("runtime_conflict") + } + return c, nil +} + +type EngineInfo struct { + OSType, Architecture, ServerVersion string + SecurityOptions []string +} +type User struct { + Container, Remote string + UID, GID uint32 +} + +var numericPair = regexp.MustCompile(`^(0|[1-9][0-9]*):(0|[1-9][0-9]*)$`) + +func parseUser(value string) (uint32, uint32, bool) { + if !numericPair.MatchString(value) { + return 0, 0, false + } + parts := strings.Split(value, ":") + uid, e := strconv.ParseUint(parts[0], 10, 32) + gid, ge := strconv.ParseUint(parts[1], 10, 32) + return uint32(uid), uint32(gid), e == nil && ge == nil && uid != 4294967295 && gid != 4294967295 +} + +// AdmitUsers derives ownership only from explicit enforced source pairs. Image, +// metadata and Dockerfile user defaults confer no filesystem ownership authority. +func AdmitUsers(source, merged map[string]any, engine EngineInfo, initial *User) (User, error) { + if e := config.ValidateSource(source); e != nil { + return User{}, e + } + container, cok := source["containerUser"].(string) + remote, rok := source["remoteUser"].(string) + _, _, cv := parseUser(container) + uid, gid, rv := parseUser(remote) + if !cok || !rok || !cv || !rv || (container != remote && container != "0:0") || merged["containerUser"] != container || merged["remoteUser"] != remote { + return User{}, fail("unsupported_configuration") + } + if !supportedEngine(engine) { + return User{}, fail("unsupported_configuration") + } + if initial != nil && (initial.UID != uid || initial.GID != gid) { + return User{}, fail("unsupported_configuration") + } + return User{Container: container, Remote: remote, UID: uid, GID: gid}, nil +} diff --git a/apps/host_agent/internal/providers/docker/config_test.go b/apps/host_agent/internal/providers/docker/config_test.go new file mode 100644 index 00000000..413ef51c --- /dev/null +++ b/apps/host_agent/internal/providers/docker/config_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func scopeFixture() Scope { + return Scope{"00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002", "00000000-0000-4000-8000-000000000003", "00000000-0000-4000-8000-000000000004", "00000000-0000-4000-8000-000000000005", "00000000-0000-4000-8000-000000000006", 1} +} +func imageSource() map[string]any { + return map[string]any{"image": "ubuntu@sha256:" + strings.Repeat("a", 64)} +} +func errorCode(t *testing.T, e error, want string) { + t.Helper() + v, ok := e.(*config.Error) + if !ok || v.Code != want { + t.Fatalf("error=%v want %s", e, want) + } +} +func TestFullOverridePreservesSourceAndAddsOnlyProviderPolicy(t *testing.T) { + source := imageSource() + source["remoteEnv"] = map[string]any{"APP": "literal"} + source["postCreateCommand"] = []any{"sh", "./script"} + override, e := FullOverride(source, scopeFixture(), "/private/data/storage/data") + if e != nil { + t.Fatal(e) + } + for key, value := range source { + if !reflect.DeepEqual(value, override[key]) { + t.Fatalf("lost %s", key) + } + } + if _, found := source["workspaceMount"]; found { + t.Fatal("source mutated") + } + if override["workspaceFolder"] != "/workspace" || override["workspaceMount"] != "type=bind,source=/private/data/storage/data,target=/workspace" || !reflect.DeepEqual(override["runArgs"], []any{"--name", "workharbor-runtime-00000000-0000-4000-8000-000000000004"}) { + t.Fatal("provider identity missing") + } + override["remoteEnv"].(map[string]any)["APP"] = "changed" + if source["remoteEnv"].(map[string]any)["APP"] != "literal" { + t.Fatal("override aliases source") + } + source["runArgs"] = []any{"--name", "foreign"} + _, e = FullOverride(source, scopeFixture(), "/private/data/storage/data") + errorCode(t, e, "unsupported_configuration") +} +func TestRawMetadataStrictGateBeforeCLIFallback(t *testing.T) { + node, e := exec.LookPath("node") + if e != nil { + t.Fatal(e) + } + dir, e := filepath.Abs("../../../tools/devcontainer") + if e != nil { + t.Fatal(e) + } + p := config.JSONC{NodePath: node, CompanionDir: dir} + entries, e := ValidateImage(context.Background(), p, ImageConfig{Metadata: `[{"remoteEnv":{"A":"one"}},{"postStartCommand":["true"]}]`}) + if e != nil || len(entries) != 2 { + t.Fatalf("%v %v", entries, e) + } + for _, raw := range []string{`not-json`, `null`, `[] trailing`, `{"remoteUser":"x","remoteUser":"y"}`, `{"unknown":"CANARY"}`, `{"initializeCommand":"touch SHOULD_NOT_RUN"}`, `{"mounts":[]}`, `{"features":{}}`, `{"remoteEnv":{"A":"${localEnv:SECRET}"}}`, `{"remoteUser":"\ud800"}`} { + _, e := ValidateImage(context.Background(), p, ImageConfig{Metadata: raw}) + if e == nil || e.Error() == "unimplemented" { + t.Fatalf("raw accepted/gate absent: %v", e) + } + } + for _, image := range []ImageConfig{{Volumes: map[string]any{"/workspace/subdir": map[string]any{}}}, {OnBuild: []string{"RUN true"}}} { + _, e := ValidateImage(context.Background(), p, image) + errorCode(t, e, "unsupported_configuration") + } +} +func TestRuntimeIdentityRejectsDuplicatesAndWrongMountOrName(t *testing.T) { + s := scopeFixture() + runtime := Container{ID: strings.Repeat("a", 64), Name: "/workharbor-runtime-" + s.RuntimeID, Image: strings.Repeat("b", 64), Labels: s.Labels(), Mounts: []Mount{{Type: "bind", Source: "/private/storage/data", Destination: "/workspace", RW: true}}} + if _, e := VerifyRuntime(s, "/private/storage/data", []Container{runtime}); e != nil { + t.Fatal(e) + } + for _, cases := range [][]Container{{runtime, runtime}, {func() Container { c := runtime; c.Name = "/foreign"; return c }()}, {func() Container { + c := runtime + c.Mounts = append(c.Mounts, Mount{Type: "volume", Destination: "/other"}) + return c + }()}, {func() Container { c := runtime; c.Labels = map[string]string{}; return c }()}} { + _, e := VerifyRuntime(s, "/private/storage/data", cases) + errorCode(t, e, "runtime_conflict") + } + _, e := VerifyRuntime(s, "/private/storage/data", nil) + errorCode(t, e, "runtime_absent") +} + +func TestVerifiedTargetRequiresExactRecordedImageAndContainerID(t *testing.T) { + s := scopeFixture() + c := Container{ID: strings.Repeat("a", 64), Name: "/" + s.Name(), Image: "sha256:" + strings.Repeat("b", 64), Labels: s.Labels(), Mounts: []Mount{{Type: "bind", Source: "/private/storage/data", Destination: "/workspace", RW: true}}} + target := Target{Scope: s, ContainerID: c.ID, ImageID: c.Image, DataPath: "/private/storage/data"} + if _, e := VerifyTarget(target, []Container{c}); e != nil { + t.Fatal(e) + } + for _, mutate := range []func(*Container){func(c *Container) { c.Image = "sha256:" + strings.Repeat("c", 64) }, func(c *Container) { c.ID = strings.Repeat("c", 64) }} { + wrong := c + mutate(&wrong) + _, e := VerifyTarget(target, []Container{wrong}) + errorCode(t, e, "runtime_conflict") + } +} + +func TestNumericUsersAndUnmappedEngineRequiredBeforeSeed(t *testing.T) { + source := imageSource() + source["containerUser"] = "0:0" + source["remoteUser"] = "1000:1000" + engine := EngineInfo{OSType: "linux", Architecture: "arm64", ServerVersion: "29.8.2", SecurityOptions: []string{"name=seccomp,profile=builtin", "name=cgroupns"}} + merged := map[string]any{"containerUser": "0:0", "remoteUser": "1000:1000"} + u, e := AdmitUsers(source, merged, engine, nil) + if e != nil || u.UID != 1000 || u.GID != 1000 { + t.Fatalf("%#v %v", u, e) + } + for _, value := range []string{"root", "vscode", "1000", "01:1", "1:01", "4294967295:1", "1:4294967295", "-1:1", "1:2:3", ""} { + bad := clone(source).(map[string]any) + bad["remoteUser"] = value + _, e := AdmitUsers(bad, merged, engine, nil) + errorCode(t, e, "unsupported_configuration") + } + for _, options := range [][]string{nil, {"name=rootless"}, {"name=userns"}, {"name=unknown"}} { + bad := engine + bad.SecurityOptions = options + _, e := AdmitUsers(source, merged, bad, nil) + errorCode(t, e, "unsupported_configuration") + } + for _, remote := range []string{"0:0", "1000:999"} { + _, e := AdmitUsers(source, map[string]any{"containerUser": "0:0", "remoteUser": remote}, engine, nil) + errorCode(t, e, "unsupported_configuration") + } + existing := User{UID: 1001, GID: 1000} + _, e = AdmitUsers(source, merged, engine, &existing) + errorCode(t, e, "unsupported_configuration") +} diff --git a/apps/host_agent/internal/providers/docker/docker_acceptance_test.go b/apps/host_agent/internal/providers/docker/docker_acceptance_test.go new file mode 100644 index 00000000..fe670d5f --- /dev/null +++ b/apps/host_agent/internal/providers/docker/docker_acceptance_test.go @@ -0,0 +1,413 @@ +//go:build docker_integration + +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage" +) + +// These explicit real-Docker roots NEVER skip or substitute a recording port. +// They run only in the separately reviewed disposable Linux fixture. The helper +// owns whole-guest containment/ancestry/teardown; these tests do not attest it. +func TestDockerAcceptanceImageFreshCache(t *testing.T) { dockerAcceptance(t, "admitted-image") } +func TestDockerAcceptanceDockerfileFreshCache(t *testing.T) { + dockerAcceptance(t, "admitted-dockerfile") +} +func realEnv(t *testing.T, key string) string { + t.Helper() + v := os.Getenv(key) + if !filepath.IsAbs(v) || filepath.Clean(v) != v { + t.Fatalf("missing/invalid required real-fixture input %s", key) + } + return v +} +func realGit(t *testing.T, ctx context.Context, dir string, args ...string) string { + t.Helper() + git, e := exec.LookPath("git") + if e != nil { + t.Fatal(e) + } + if e := os.MkdirAll(filepath.Join(filepath.Dir(dir), "git-templates"), 0700); e != nil { + t.Fatal(e) + } + c := exec.CommandContext(ctx, git, append([]string{"-c", "core.hooksPath=/dev/null", "-c", "init.templateDir=" + filepath.Join(filepath.Dir(dir), "git-templates")}, args...)...) + c.Dir = dir + c.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + dir, "LANG=C.UTF-8", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Fixture", "GIT_AUTHOR_EMAIL=fixture@example.test", "GIT_COMMITTER_NAME=Fixture", "GIT_COMMITTER_EMAIL=fixture@example.test", "GIT_TERMINAL_PROMPT=0"} + raw, e := c.CombinedOutput() + if e != nil { + t.Fatalf("fixture Git %q: %v %s", args, e, raw) + } + return strings.TrimSpace(string(raw)) +} +func realWrite(t *testing.T, path string, raw []byte) { + t.Helper() + if e := os.MkdirAll(filepath.Dir(path), 0700); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(path, raw, 0600); e != nil { + t.Fatal(e) + } +} +func realHash(t *testing.T, path string) string { + t.Helper() + raw, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} +func fixtureConfig(t *testing.T, ctx context.Context, root, example string) (config.Resolver, config.Descriptor, func(map[string]any) config.Descriptor) { + t.Helper() + repository := filepath.Join(root, "fixture-config") + project := filepath.Join(root, "fixture-project") + stage := filepath.Join(root, "staging") + for _, dir := range []string{repository, project, stage} { + if e := os.Mkdir(dir, 0700); e != nil { + t.Fatal(e) + } + } + realGit(t, ctx, project, "init", "--quiet") + realWrite(t, filepath.Join(project, "README.md"), []byte("independently authored project\n")) + realWrite(t, filepath.Join(project, "seed.bin"), []byte{0, 1, 255, 128}) + realGit(t, ctx, project, "add", "--all") + realGit(t, ctx, project, "commit", "--quiet", "-m", "project fixture") + projectCommit := realGit(t, ctx, project, "rev-parse", "HEAD") + realGit(t, ctx, project, "update-server-info") + mux := http.NewServeMux() + mux.Handle("/config.git/", http.StripPrefix("/config.git/", http.FileServer(http.Dir(filepath.Join(repository, ".git"))))) + mux.Handle("/project.git/", http.StripPrefix("/project.git/", http.FileServer(http.Dir(filepath.Join(project, ".git"))))) + server := httptest.NewTLSServer(mux) + t.Cleanup(server.Close) + ca := filepath.Join(root, "git-ca.pem") + realWrite(t, ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})) + realGit(t, ctx, repository, "init", "--quiet") + os.MkdirAll(filepath.Join(repository, "projects"), 0700) + realGit(t, ctx, repository, "-c", "protocol.file.allow=always", "submodule", "add", "--quiet", project, "projects/app") + realWrite(t, filepath.Join(repository, ".gitmodules"), []byte("[submodule \"app\"]\npath=projects/app\nurl="+server.URL+"/project.git/\n")) + examples, _ := filepath.Abs("../../../../../examples/workspace/" + example) + for _, file := range []string{"workharbor.json", ".devcontainer/devcontainer.json"} { + raw, e := os.ReadFile(filepath.Join(examples, file)) + if e != nil { + t.Fatal(e) + } + realWrite(t, filepath.Join(repository, file), raw) + } + if example == "admitted-dockerfile" { + raw, e := os.ReadFile(filepath.Join(examples, ".devcontainer/Dockerfile")) + if e != nil { + t.Fatal(e) + } + hostile, _ := json.Marshal([]map[string]any{{"initializeCommand": "touch " + filepath.Join(root, "AUTHORED_LABEL_MUST_NOT_RUN")}}) + quoted, _ := json.Marshal(string(hostile)) + raw = append(raw, []byte("\nLABEL devcontainer.metadata="+string(quoted)+"\n")...) + realWrite(t, filepath.Join(repository, ".devcontainer/Dockerfile"), raw) + } + commit := func() config.Descriptor { + realGit(t, ctx, repository, "add", "--all") + realGit(t, ctx, repository, "commit", "--quiet", "-m", "config fixture") + revision := realGit(t, ctx, repository, "rev-parse", "HEAD") + realGit(t, ctx, repository, "update-server-info") + return config.Descriptor{RepositoryIdentity: server.URL + "/config.git/", Commit: revision, Submodules: []config.Submodule{{Path: "projects/app", Commit: projectCommit}}} + } + descriptor := commit() + git, _ := exec.LookPath("git") + parser := config.JSONC{NodePath: realEnv(t, "WORKHARBOR_NODE_PATH"), CompanionDir: realEnv(t, "WORKHARBOR_DEVCONTAINER_ROOT")} + resolver := config.Resolver{GitPath: git, StageRoot: stage, CAFile: ca, Parser: parser} + mutate := func(source map[string]any) config.Descriptor { + raw, _ := json.Marshal(source) + realWrite(t, filepath.Join(repository, ".devcontainer/devcontainer.json"), raw) + return commit() + } + return resolver, descriptor, mutate +} +func realHost(t *testing.T, root string, r config.Resolver) Provider { + t.Helper() + for _, name := range []string{"private", "storage"} { + if e := os.Mkdir(filepath.Join(root, name), 0700); e != nil { + t.Fatal(e) + } + } + node := realEnv(t, "WORKHARBOR_NODE_PATH") + tools := realEnv(t, "WORKHARBOR_DEVCONTAINER_ROOT") + buildx := realEnv(t, "WORKHARBOR_BUILDX_PATH") + return Provider{Resolver: r, Tools: devcontainer.Tools{NodePath: node, Root: tools}, Registry: Registry{BuildxPath: buildx, CranePath: realEnv(t, "WORKHARBOR_CRANE_PATH"), PrivateDirectory: filepath.Join(root, "private"), Architecture: runtime.GOARCH, Parser: r.Parser}, Store: storage.Store{Root: filepath.Join(root, "storage")}, DockerPath: realEnv(t, "WORKHARBOR_DOCKER_PATH"), Socket: realEnv(t, "WORKHARBOR_DOCKER_SOCKET"), BuildxPath: buildx, AgentPath: realEnv(t, "WORKHARBOR_DOCKER_TEST_AGENT"), PrivateDirectory: filepath.Join(root, "private")} +} +func realAdmission(d config.Descriptor, b storage.Binding, generation uint64) Admission { + scope := scopeFixture() + scope.StorageID = b.StorageID + scope.Generation = generation + if generation > 1 { + scope.RuntimeID = fmt.Sprintf("00000000-0000-4000-8000-%012x", 64+generation*2) + scope.AllocationOperationID = fmt.Sprintf("00000000-0000-4000-8000-%012x", 65+generation*2) + } + return Admission{scope: scope, descriptor: d, binding: b, authorize: func(ctx context.Context) error { return ctx.Err() }} +} +func realExec(t *testing.T, ctx context.Context, p *Prepared, command ...string) []byte { + t.Helper() + p.port.stateMu.Lock() + policy := copyPolicy(p.port.policy) + policy.Phase = Hooks + p.port.policy = copyPolicy(policy) + target := *p.port.target + images := copyImages(p.port.images) + p.port.stateMu.Unlock() + gateway := &Gateway{Parser: p.port.parser, Policy: policy, Port: p.port, Authorize: p.Check, Target: &target, Images: images} + args := []string{"exec", "-i", "-u", policy.RemoteUser, "-w", "/workspace", target.ContainerID} + args = append(args, command...) + var out bytes.Buffer + if e := gateway.ForwardStream(ctx, args, bytes.NewReader(nil), &out); e != nil { + t.Fatal("exact runtime exec", e) + } + return out.Bytes() +} +func fixtureRuntimeAction(t *testing.T, ctx context.Context, p *Prepared, target Target, action string) { + t.Helper() + all, e := p.port.Candidates(ctx, target.Scope) + if e != nil { + t.Fatal(e) + } + if _, e = VerifyTarget(target, all); e != nil { + t.Fatal(e) + } + var args []string + switch action { + case "stop": + args = []string{"stop", "--time", "5", target.ContainerID} + case "start": + args = []string{"start", target.ContainerID} + case "remove": + if all[0].Running { + t.Fatal("remove running fixture target") + } + args = []string{"rm", target.ContainerID} + default: + t.Fatal("unknown fixture action") + } + // Test-owned fixture lifecycle only; this is not product T010 dispatch/recovery. + if _, e = p.port.read(ctx, args); e != nil { + t.Fatal("fixture runtime action", action, e) + } + if action != "remove" { + all, e = p.port.Candidates(ctx, target.Scope) + if e != nil { + t.Fatal(e) + } + actual, e := VerifyTarget(target, all) + if e != nil || actual.Running != (action == "start") { + t.Fatal("unacknowledged fixture state", actual, e) + } + } +} +func dockerAcceptance(t *testing.T, example string) { + if runtime.GOOS != "linux" || os.Geteuid() != 0 { + t.Fatal("real acceptance requires reviewed rootful Linux fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + evidenceRoot := realEnv(t, "WORKHARBOR_T009_ARTIFACT_DIRECTORY") + if !ownedDirectory(evidenceRoot) { + t.Fatal("evidence root is not privately owned") + } + root, e := os.MkdirTemp(evidenceRoot, example+"-") + if e != nil { + t.Fatal(e) + } + artifactManifest := map[string]any{"schema_version": 1, "redacted": true, "files": []any{map[string]any{"path": "outcome.json", "max_bytes": 4096}, map[string]any{"path": "proof.json", "max_bytes": 65536}, map[string]any{"path": "raw-registry-proof.json", "max_bytes": 65536}}, "max_files": 3, "max_total_bytes": 135168} + manifestBytes, _ := json.Marshal(artifactManifest) + realWrite(t, filepath.Join(root, "artifact-manifest.json"), append(manifestBytes, '\n')) + finished := false + defer func() { + outcome := "failed_or_unknown" + if finished && !t.Failed() { + outcome = "passed" + } + raw, _ := json.Marshal(map[string]any{"schema_version": 1, "example": example, "outcome": outcome, "feature006_t010": "not executed", "containment": "separate helper evidence required"}) + os.WriteFile(filepath.Join(root, "outcome.json"), append(raw, '\n'), 0600) + }() + resolver, descriptor, mutate := fixtureConfig(t, ctx, root, example) + host := realHost(t, root, resolver) + scope := scopeFixture() + binding := storage.Binding{OwnerID: scope.OwnerID, HostID: scope.HostID, WorkspaceID: scope.WorkspaceID, StorageID: scope.StorageID, ReservationOperationID: scope.AllocationOperationID, Initial: descriptor} + originalBytes, e := os.ReadFile(filepath.Join(root, "fixture-config/.devcontainer/devcontainer.json")) + if e != nil { + t.Fatal(e) + } + var originalSource map[string]any + if json.Unmarshal(originalBytes, &originalSource) != nil { + t.Fatal("fixture source malformed") + } + rawRegistryRefusals(t, ctx, host, binding, mutate, originalSource, root) + p, e := host.Prepare(ctx, realAdmission(descriptor, binding, 1)) + if e != nil { + t.Fatal("real preparation", e) + } + defer p.Close() + resource, e := p.Resources(ctx) + if e != nil { + t.Fatal("real official resource phase", e) + } + hooks, e := p.Hooks(ctx) + if e != nil || hooks.Target != resource.Target { + t.Fatal("real official hooks", e) + } + if _, e := p.Resources(ctx); e == nil { + t.Fatal("resource phase automatically replayed") + } + // Explicit fixture-only official reopen of the already verified exact target + // proves container-label reuse. This does not add a production retry/recovery API. + reopened, e := p.runPhase(ctx, Resources, resource.Target.ContainerID) + if e != nil || reopened.Target != resource.Target { + t.Fatal("official exact-target reopen", e) + } + realExec(t, ctx, p, "sh", "-c", "cd /workspace/app && git status --porcelain && chmod +x seed.bin && printf '\\000\\377\\200\\001' > seed.bin && printf '\\000untracked\\377' > untracked.bin && git status --porcelain") + data := p.Storage().Data + binary := filepath.Join(data, "app/seed.bin") + untracked := filepath.Join(data, "app/untracked.bin") + hashes := map[string]string{"binary": realHash(t, binary), "untracked": realHash(t, untracked)} + if raw, e := os.ReadFile(binary); e != nil || !bytes.Equal(raw, []byte{0, 255, 128, 1}) { + t.Fatal("nonroot write/chmod", raw, e) + } + if info, e := os.Stat(binary); e != nil || info.Mode().Perm()&0111 == 0 { + t.Fatal("nonroot executable bit", e) + } + assertRetained := func() { + if realHash(t, binary) != hashes["binary"] || realHash(t, untracked) != hashes["untracked"] { + t.Fatal("retained exact binary/untracked hashes changed") + } + } + fixtureRuntimeAction(t, ctx, p, resource.Target, "stop") + assertRetained() + fixtureRuntimeAction(t, ctx, p, resource.Target, "start") + assertRetained() + fixtureRuntimeAction(t, ctx, p, resource.Target, "stop") + // Changed approved execution config must retain ORIGINAL seeded data/owner. + source := clone(p.source.Source).(map[string]any) + source["postStartCommand"] = []any{"sh", "-c", "test -f /workspace/app/untracked.bin"} + changed := mutate(source) + binding.ExistingHandle = p.Storage().Parent + replacement, e := host.Prepare(ctx, realAdmission(changed, binding, 2)) + if e != nil { + t.Fatal("changed config reuse", e) + } + defer replacement.Close() + assertRetained() + replacementResource, e := replacement.Resources(ctx) + if e != nil { + t.Fatal("real replacement", e) + } + if _, e = replacement.Hooks(ctx); e != nil { + t.Fatal(e) + } + assertRetained() + fixtureRuntimeAction(t, ctx, replacement, replacementResource.Target, "stop") + fixtureRuntimeAction(t, ctx, replacement, replacementResource.Target, "remove") + fixtureRuntimeAction(t, ctx, p, resource.Target, "remove") + assertRetained() + // An admitted but unsuccessful build cannot reset existing data or register + // its generated tag as a usable runtime image. + failedSource := clone(source).(map[string]any) + delete(failedSource, "image") + failedSource["build"] = map[string]any{"dockerfile": "Dockerfile", "context": "."} + pinned := "docker.io/library/buildpack-deps@sha256:4163ac2c58660c533f3cf3d178551287ddd0ac18269ef84f3a382d0698c89254" + realWrite(t, filepath.Join(root, "fixture-config/.devcontainer/Dockerfile"), []byte("FROM "+pinned+"\nRUN exit 17\n")) + failedDescriptor := mutate(failedSource) + failed, e := host.Prepare(ctx, realAdmission(failedDescriptor, binding, 3)) + if e != nil { + t.Fatal("failed build preparation", e) + } + if _, e = failed.Resources(ctx); e == nil { + failed.Close() + t.Fatal("failed build reported success") + } + failed.Close() + assertRetained() + if _, e := os.Lstat(filepath.Join(root, "AUTHORED_LABEL_MUST_NOT_RUN")); !os.IsNotExist(e) { + t.Fatal("authored image metadata host hook ran", e) + } + // Independently authored unsupported source cases must refuse before any + // new image/container/volume mutation or hook, preserving retained bytes. + inventory := func() string { + var out strings.Builder + for _, args := range [][]string{{"image", "ls", "-q", "--no-trunc"}, {"ps", "-aq", "--no-trunc"}, {"volume", "ls", "-q"}} { + raw, e := p.port.read(ctx, args) + if e != nil { + t.Fatal(e) + } + out.Write(raw) + out.WriteByte('\n') + } + return out.String() + } + before := inventory() + for _, kind := range []string{"initializeCommand", "features", "mounts", "named-user", "VOLUME", "ONBUILD", "custom-frontend", "reserved-build-arg", "reserved-later-ARG"} { + t.Run("unsupported-"+kind, func(t *testing.T) { + bad := clone(source).(map[string]any) + switch kind { + case "initializeCommand": + bad["initializeCommand"] = "touch " + filepath.Join(root, "SOURCE_HOST_MUST_NOT_RUN") + case "features": + bad["features"] = map[string]any{"ghcr.io/workharbor-fixture/must-not-fetch": map[string]any{}} + case "mounts": + bad["mounts"] = []any{"/var/run/docker.sock"} + case "named-user": + bad["remoteUser"] = "vscode" + default: + delete(bad, "image") + b := map[string]any{"dockerfile": "Dockerfile", "context": "."} + bad["build"] = b + dockerfile := "FROM " + pinned + "\n" + switch kind { + case "VOLUME": + dockerfile += "VOLUME /workspace/app\n" + case "ONBUILD": + dockerfile += "ONBUILD RUN touch /workspace/MUST_NOT_RUN\n" + case "custom-frontend": + dockerfile = "# syntax=evil.example/frontend\n" + dockerfile + case "reserved-build-arg": + b["args"] = map[string]any{"BUILDKIT_SYNTAX": "evil.example/frontend"} + case "reserved-later-ARG": + dockerfile += "ARG OK BUILDKIT_SYNTAX=evil.example/frontend\n" + } + realWrite(t, filepath.Join(root, "fixture-config/.devcontainer/Dockerfile"), []byte(dockerfile)) + } + unsupported := mutate(bad) + if candidate, e := host.Prepare(ctx, realAdmission(unsupported, binding, 4)); e == nil { + candidate.Close() + t.Fatal("unsupported input admitted", kind) + } + if inventory() != before { + t.Fatal("refusal changed Docker inventory", kind) + } + assertRetained() + if _, e := os.Lstat(filepath.Join(root, "SOURCE_HOST_MUST_NOT_RUN")); !os.IsNotExist(e) { + t.Fatal("source host hook ran", e) + } + }) + } + + proof := map[string]any{"example": example, "initial_config": descriptor.Map(), "replacement_config": changed.Map(), "target": resource.Target, "replacement_target": replacementResource.Target, "hashes": hashes, "owner_uid": p.Storage().Binding.DataUID, "owner_gid": p.Storage().Binding.DataGID, "real_normal_resource_hooks": true, "explicit_fixture_official_reopen": true, "automatic_resource_replay_refused": true, "retention_stop_start_replacement": true, "failed_build_retained": true, "authored_label_host_hook_absent": true, "unsupported_host_hook_refused": true, "guest_containment": "separate helper evidence required", "feature006_t010": "not executed"} + raw, _ := json.MarshalIndent(proof, "", " ") + realWrite(t, filepath.Join(root, "proof.json"), append(raw, '\n')) + finished = true +} diff --git a/apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go b/apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go new file mode 100644 index 00000000..fc4c4c4b --- /dev/null +++ b/apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go @@ -0,0 +1,209 @@ +//go:build docker_integration + +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage" +) + +func rawRegistryRefusals(t *testing.T, ctx context.Context, host Provider, binding storage.Binding, mutate func(map[string]any) config.Descriptor, original map[string]any, root string) { + t.Helper() + caPath, keyPath := realEnv(t, "WORKHARBOR_T009_REGISTRY_CA_FILE"), realEnv(t, "WORKHARBOR_T009_REGISTRY_KEY_FILE") + caHash, keyHash := os.Getenv("WORKHARBOR_T009_REGISTRY_CA_SHA256"), os.Getenv("WORKHARBOR_T009_REGISTRY_KEY_SHA256") + if !hexID.MatchString(caHash) || !hexID.MatchString(keyHash) || realHash(t, caPath) != caHash || realHash(t, keyPath) != keyHash { + t.Fatal("unbound real fixture TLS assets") + } + for _, path := range []string{caPath, keyPath} { + actual, e := filepath.EvalSymlinks(path) + info, se := os.Lstat(path) + if e != nil || se != nil || actual != path || !ownedRegular(info) || info.Mode().Perm() != 0600 { + t.Fatal("unsafe fixture TLS asset", path) + } + } + certificateBytes, e := os.ReadFile(caPath) + if e != nil { + t.Fatal(e) + } + block, _ := pem.Decode(certificateBytes) + if block == nil { + t.Fatal("fixture CA malformed") + } + certificate, e := x509.ParseCertificate(block.Bytes) + if e != nil || certificate.VerifyHostname("registry.workharbor.test") != nil || !certificate.IsCA { + t.Fatal("wrong registry fixture SAN/CA", e) + } + certificatePair, e := tls.LoadX509KeyPair(caPath, keyPath) + if e != nil { + t.Fatal(e) + } + addresses, e := net.LookupIP("registry.workharbor.test") + if e != nil || len(addresses) == 0 { + t.Fatal("missing reviewed guest-only registry hosts mapping", e) + } + for _, ip := range addresses { + if !ip.Equal(net.IPv4(127, 0, 0, 1)) { + t.Fatal("registry DNS leaves owned loopback fixture") + } + } + port, e := openNativePort(ctx, nativeOptions{Policy: DriverPolicy{Phase: Preflight, Scope: scopeFixture()}, Scope: scopeFixture(), Check: func(ctx context.Context) error { return ctx.Err() }, DockerPath: host.DockerPath, Socket: host.Socket, BuildxPath: host.BuildxPath, PrivateDirectory: host.PrivateDirectory}) + if e != nil { + t.Fatal(e) + } + defer port.Close() + snapshot := func() string { + var result strings.Builder + for _, args := range [][]string{{"ps", "-aq", "--no-trunc"}, {"image", "ls", "-q", "--no-trunc"}, {"volume", "ls", "-q"}} { + raw, e := port.read(ctx, args) + if e != nil { + t.Fatal(e) + } + result.Write(raw) + result.WriteByte('\n') + } + return result.String() + } + baseline := snapshot() + proof := map[string]any{} + for _, kind := range []string{"duplicate-raw-key", "onbuild", "volumes", "host-metadata", "malformed-metadata", "unknown-raw-field"} { + t.Run("raw-"+kind, func(t *testing.T) { + cfg := map[string]any{"architecture": runtimeArchitecture(), "os": "linux", "rootfs": map[string]any{"type": "layers", "diff_ids": []any{}}, "config": map[string]any{"User": "0:0", "Labels": map[string]any{}}} + properties := cfg["config"].(map[string]any) + switch kind { + case "onbuild": + properties["OnBuild"] = []any{"RUN touch /workspace/RAW_MUST_NOT_RUN"} + case "volumes": + properties["Volumes"] = map[string]any{"/workspace/app": map[string]any{}} + case "host-metadata": + properties["Labels"] = map[string]any{"devcontainer.metadata": `[{"initializeCommand":"touch ` + filepath.Join(root, "RAW_HOST_MUST_NOT_RUN") + `"}]`} + case "malformed-metadata": + properties["Labels"] = map[string]any{"devcontainer.metadata": "["} + case "unknown-raw-field": + properties["WorkHarborUnknown"] = true + } + configBytes, _ := json.Marshal(cfg) + if kind == "duplicate-raw-key" { + configBytes = bytes.Replace(configBytes, []byte(`"User":"0:0"`), []byte(`"User":"0:0","User":"0:0"`), 1) + } + configSum := sha256.Sum256(configBytes) + configDigest := "sha256:" + hex.EncodeToString(configSum[:]) + manifest := map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": map[string]any{"mediaType": "application/vnd.oci.image.config.v1+json", "digest": configDigest, "size": len(configBytes)}, "layers": []any{}} + manifestBytes, _ := json.Marshal(manifest) + manifestSum := sha256.Sum256(manifestBytes) + manifestDigest := "sha256:" + hex.EncodeToString(manifestSum[:]) + var mu sync.Mutex + requests := map[string]int{} + unexpected := []string{} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests[r.Method+" "+r.URL.Path]++ + mu.Unlock() + if r.TLS == nil || r.Method != "GET" && r.Method != "HEAD" || r.URL.RawQuery != "" { + mu.Lock() + unexpected = append(unexpected, r.Method+" "+r.URL.Path) + mu.Unlock() + http.Error(w, "refused", 400) + return + } + var raw []byte + digest, contentType := "", "application/json" + switch r.URL.Path { + case "/v2/": + raw = []byte("{}") + case "/v2/fixture/manifests/" + manifestDigest: + raw = manifestBytes + digest = manifestDigest + contentType = "application/vnd.oci.image.manifest.v1+json" + case "/v2/fixture/blobs/" + configDigest: + raw = configBytes + digest = configDigest + default: + mu.Lock() + unexpected = append(unexpected, r.Method+" "+r.URL.Path) + mu.Unlock() + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") + w.Header().Set("Content-Length", fmt.Sprint(len(raw))) + if digest != "" { + w.Header().Set("Docker-Content-Digest", digest) + } + if r.Method == "GET" { + w.Write(raw) + } + }) + server := httptest.NewUnstartedServer(handler) + server.TLS = &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{certificatePair}} + server.StartTLS() + defer server.Close() + _, number, e := net.SplitHostPort(server.Listener.Addr().String()) + if e != nil { + t.Fatal(e) + } + reference := "registry.workharbor.test:" + number + "/fixture@" + manifestDigest + source := clone(original).(map[string]any) + delete(source, "build") + source["image"] = reference + descriptor := mutate(source) + caseHost := host + caseHost.Registry.CAFile = caPath + caseHost.Registry.CASHA256 = caHash + caseBinding := binding + caseBinding.Initial = descriptor + prepared, e := caseHost.Prepare(ctx, realAdmission(descriptor, caseBinding, 1)) + if e == nil { + prepared.Close() + t.Fatal("hostile raw metadata admitted") + } + mu.Lock() + blobReads := requests["GET /v2/fixture/blobs/"+configDigest] + bad := append([]string{}, unexpected...) + record := map[string]int{} + for k, v := range requests { + record[k] = v + } + mu.Unlock() + if blobReads == 0 { + t.Fatalf("raw config not reached: error=%v requests=%v", e, record) + } + if len(bad) != 0 { + t.Fatal("non-metadata/plaintext/layer request", bad) + } + if snapshot() != baseline { + t.Fatal("raw refusal changed daemon inventory") + } + if _, e := os.Lstat(filepath.Join(host.Store.Root, binding.StorageID)); !os.IsNotExist(e) { + t.Fatal("raw refusal published data", e) + } + if _, e := os.Lstat(filepath.Join(root, "RAW_HOST_MUST_NOT_RUN")); !os.IsNotExist(e) { + t.Fatal("raw metadata host hook executed", e) + } + proof[kind] = map[string]any{"raw_config_sha256": hex.EncodeToString(configSum[:]), "manifest_digest": manifestDigest, "config_size": len(configBytes), "metadata_requests": record, "daemon_unchanged": true, "seed_absent": true, "hooks_absent": true} + }) + } + raw, _ := json.MarshalIndent(map[string]any{"ca_sha256": caHash, "server_name": "registry.workharbor.test", "cases": proof}, "", " ") + realWrite(t, filepath.Join(root, "raw-registry-proof.json"), append(raw, '\n')) +} +func runtimeArchitecture() string { return strings.TrimSpace(runtime.GOARCH) } diff --git a/apps/host_agent/internal/providers/docker/driver.go b/apps/host_agent/internal/providers/docker/driver.go new file mode 100644 index 00000000..0335f32c --- /dev/null +++ b/apps/host_agent/internal/providers/docker/driver.go @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "strings" + "unicode/utf8" +) + +// DriverPolicy is trusted native expectation data. A classified call still needs +// current approval/fence and fresh full resource inspection before any forwarding. +type DriverPolicy struct { + Phase Phase + OverrideCommand bool + Scope Scope + KnownIDs, ImageRefs map[string]bool + DataPath, ContainerUser, RemoteUser, StartupSHA256, RuntimeMetadata string + StartupTail []string + ContainerEnv, RemoteEnv map[string]string + Build *BuildPolicy +} +type BuildPolicy struct { + Context, Scratch, Tag, Target string + Args map[string]string +} +type RunCall struct { + Name, Image, ImageID, User, Mount string + Labels, Env map[string]string + Command []string +} +type ExecCall struct { + ContainerID, User, Directory string + Env map[string]string + Command []string +} +type BuildCall struct { + Context, Dockerfile, Tag, Target string + Args map[string]string +} +type Call struct { + Scope Scope + Kind string + IDs []string + Reference string + Run *RunCall + Exec *ExecCall + Build *BuildCall +} + +func refused() (Call, error) { return Call{}, fail("provider_refused") } +func knownID(p DriverPolicy, id string) bool { return hexID.MatchString(id) && p.KnownIDs[id] } +func discovery(args []string, s Scope) bool { + if len(args) < 3 || !exactArgs(args[:3], "ps", "-q", "-a") { + return false + } + expected := s.LabelList() + if len(args) != 3+2*len(expected) { + return false + } + seen := map[string]bool{} + for i := 3; i < len(args); i += 2 { + if args[i] != "--filter" || !strings.HasPrefix(args[i+1], "label=") { + return false + } + label := strings.TrimPrefix(args[i+1], "label=") + if seen[label] { + return false + } + seen[label] = true + } + for _, label := range expected { + if !seen[label] { + return false + } + } + return true +} +func Classify(p DriverPolicy, args []string) (Call, error) { + if !p.Scope.valid() || len(args) == 0 || (p.Phase != Preflight && p.Phase != Resources && p.Phase != Hooks) { + return refused() + } + if p.Phase != Preflight { + _, _, cv := parseUser(p.ContainerUser) + _, _, rv := parseUser(p.RemoteUser) + if !cv || !rv || (p.ContainerUser != p.RemoteUser && p.ContainerUser != "0:0") { + return refused() + } + } + for _, arg := range args { + if !utf8.ValidString(arg) || strings.ContainsRune(arg, 0) { + return refused() + } + } + switch { + case exactArgs(args, "-v"): + return Call{Kind: "version"}, nil + case exactArgs(args, "version", "--format", "{{.Server.Version}}"): + return Call{Kind: "engine_version"}, nil + case exactArgs(args, "buildx", "version"): + return Call{Kind: "buildx_version"}, nil + case discovery(args, p.Scope): + return Call{Kind: "discovery"}, nil + case len(args) >= 4 && exactArgs(args[:3], "inspect", "--type", "image"): + if len(args) != 4 || !p.ImageRefs[args[3]] { + return refused() + } + return Call{Kind: "image_inspect", Reference: args[3]}, nil + case len(args) >= 4 && exactArgs(args[:3], "inspect", "--type", "container"): + ids := []string{} + seen := map[string]bool{} + for _, id := range args[3:] { + if !knownID(p, id) || seen[id] { + return refused() + } + seen[id] = true + ids = append(ids, id) + } + return Call{Kind: "container_inspect", IDs: ids}, nil + } + if p.Phase == Preflight { + return refused() + } + if args[0] == "exec" { + return classifyExec(p, args) + } + if p.Phase != Resources { + return refused() + } + switch { + case len(args) == 2 && args[0] == "pull" && p.ImageRefs[args[1]]: + return Call{Kind: "pull", Reference: args[1]}, nil + case len(args) == 2 && args[0] == "start" && knownID(p, args[1]): + return Call{Kind: "start", IDs: []string{args[1]}}, nil + case exactArgs(args, "events", "--format", "{{json .}}", "--filter", "event=start"): + return Call{Kind: "events"}, nil + case args[0] == "run": + return classifyRun(p, args) + case len(args) >= 2 && exactArgs(args[:2], "buildx", "build"): + return classifyBuild(p, args) + default: + return refused() + } +} +func pair(value string) (string, string, bool) { + parts := strings.SplitN(value, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return "", "", false + } + return parts[0], parts[1], true +} +func equalMap(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if value, ok := b[k]; !ok || value != v { + return false + } + } + return true +} +func classifyExec(p DriverPolicy, args []string) (Call, error) { + c := &ExecCall{Env: map[string]string{}} + interactive := false + seen := map[string]bool{} + i := 1 + for i < len(args) && strings.HasPrefix(args[i], "-") { + key := args[i] + if key == "-i" { + if interactive { + return refused() + } + interactive = true + i++ + continue + } + if key != "-u" && key != "-e" && key != "-w" || i+1 >= len(args) { + return refused() + } + value := args[i+1] + i += 2 + switch key { + case "-u": + if seen[key] || (value != p.RemoteUser && value != p.ContainerUser && value != "root") { + return refused() + } + c.User = value + seen[key] = true + case "-w": + if seen[key] || value != "/workspace" { + return refused() + } + c.Directory = value + seen[key] = true + case "-e": + name, val, ok := pair(value) + if !ok { + return refused() + } + expected, allowed := p.RemoteEnv[name] + if _, duplicate := c.Env[name]; duplicate || !allowed || val != expected { + return refused() + } + c.Env[name] = val + } + } + if !interactive || !seen["-u"] || i+1 >= len(args) || !knownID(p, args[i]) { + return refused() + } + c.ContainerID = args[i] + c.Command = append([]string{}, args[i+1:]...) + if c.Command[0] == "" { + return refused() + } + return Call{Kind: "exec", Exec: c}, nil +} +func classifyRun(p DriverPolicy, args []string) (Call, error) { + c := &RunCall{Labels: map[string]string{}, Env: map[string]string{}} + seen := map[string]bool{} + attach := map[string]bool{} + i := 1 + for i < len(args) && strings.HasPrefix(args[i], "-") { + key := args[i] + if key == "--sig-proxy=false" { + if seen[key] { + return refused() + } + seen[key] = true + i++ + continue + } + if i+1 >= len(args) { + return refused() + } + value := args[i+1] + i += 2 + switch key { + case "-a": + if (value != "STDOUT" && value != "STDERR") || attach[value] { + return refused() + } + attach[value] = true + case "--mount", "-u", "--name", "--entrypoint": + if seen[key] { + return refused() + } + seen[key] = true + switch key { + case "--mount": + if value != "type=bind,source="+p.DataPath+",target=/workspace" || !validData(p.DataPath) { + return refused() + } + c.Mount = value + case "-u": + if value != p.ContainerUser { + return refused() + } + c.User = value + case "--name": + if value != p.Scope.Name() { + return refused() + } + c.Name = value + case "--entrypoint": + if value != "/bin/sh" { + return refused() + } + } + case "-l": + name, val, ok := pair(value) + if !ok { + return refused() + } + if _, duplicate := c.Labels[name]; duplicate { + return refused() + } + expected, mandatory := p.Scope.Labels()[name] + if (!mandatory && name != "devcontainer.metadata") || (mandatory && val != expected) { + return refused() + } + c.Labels[name] = val + case "-e": + name, val, ok := pair(value) + if !ok { + return refused() + } + if _, duplicate := c.Env[name]; duplicate { + return refused() + } + c.Env[name] = val + default: + return refused() + } + } + if !seen["--sig-proxy=false"] || len(attach) != 2 || !seen["--mount"] || !seen["-u"] || !seen["--name"] || !seen["--entrypoint"] || i >= len(args) || !p.ImageRefs[args[i]] || !equalMap(c.Env, p.ContainerEnv) { + return refused() + } + for name, val := range p.Scope.Labels() { + if c.Labels[name] != val { + return refused() + } + } + c.Image = args[i] + c.Command = append([]string{}, args[i+1:]...) + if len(c.Command) != 3+len(p.StartupTail) || c.Command[0] != "-c" || c.Command[2] != "-" { + return refused() + } + sum := sha256.Sum256([]byte(c.Command[1])) + if hex.EncodeToString(sum[:]) != p.StartupSHA256 { + return refused() + } + for i, value := range p.StartupTail { + if c.Command[i+3] != value { + return refused() + } + } + return Call{Kind: "run", Run: c}, nil +} +func classifyBuild(p DriverPolicy, args []string) (Call, error) { + if p.Build == nil || !filepath.IsAbs(p.Build.Context) || !filepath.IsAbs(p.Build.Scratch) { + return refused() + } + b := &BuildCall{Args: map[string]string{}} + seen := map[string]bool{} + i := 2 + for i < len(args) && strings.HasPrefix(args[i], "-") { + key := args[i] + if key == "--load" { + if seen[key] { + return refused() + } + seen[key] = true + i++ + continue + } + if i+1 >= len(args) { + return refused() + } + value := args[i+1] + i += 2 + switch key { + case "-f", "-t", "--target": + if seen[key] { + return refused() + } + seen[key] = true + switch key { + case "-f": + b.Dockerfile = value + case "-t": + b.Tag = value + case "--target": + b.Target = value + } + case "--build-arg": + name, val, ok := pair(value) + if !ok { + return refused() + } + if _, duplicate := b.Args[name]; duplicate { + return refused() + } + if strings.HasPrefix(name, "BUILDKIT_") && (name != "BUILDKIT_INLINE_CACHE" || val != "1") { + return refused() + } + if strings.HasPrefix(name, "_DEV_CONTAINERS_") && name != "_DEV_CONTAINERS_BASE_IMAGE" { + return refused() + } + b.Args[name] = val + default: + return refused() + } + } + if !seen["--load"] || i != len(args)-1 || args[i] != p.Build.Context || b.Tag != p.Build.Tag || b.Target != p.Build.Target || !equalMap(b.Args, p.Build.Args) { + return refused() + } + if !filepath.IsAbs(b.Dockerfile) || filepath.Clean(b.Dockerfile) != b.Dockerfile || filepath.Base(b.Dockerfile) != "Dockerfile-with-features" { + return refused() + } + rel, e := filepath.Rel(p.Build.Scratch, b.Dockerfile) + if e != nil || rel == ".." || strings.HasPrefix(rel, "../") { + return refused() + } + b.Context = args[i] + return Call{Kind: "build", Build: b}, nil +} diff --git a/apps/host_agent/internal/providers/docker/driver_executable.go b/apps/host_agent/internal/providers/docker/driver_executable.go new file mode 100644 index 00000000..3ab66eb5 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/driver_executable.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" +) + +func quoteNative(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + +// Executable selects only the installed product agent and its fixed private mode. +// Source configuration never supplies this path, socket, deadline or argv prefix. +func (d *DriverServer) Executable(ctx context.Context, agent string) (string, error) { + deadline, ok := ctx.Deadline() + if !ok || ctx.Err() != nil || !physicalExecutable(agent) || !ownedSocket(d.Socket) || !ownedDirectory(d.root) { + return "", fail("provider_refused") + } + path := filepath.Join(d.root, "docker-driver") + script := "#!/bin/sh\nexec " + quoteNative(agent) + " __devcontainer-docker " + quoteNative(d.Socket) + " " + strconv.FormatInt(deadline.UnixMilli(), 10) + " \"$@\"\n" + f, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0700) + if e != nil { + return "", fail("provider_unavailable") + } + _, we := f.WriteString(script) + se := f.Sync() + ce := f.Close() + if we != nil || se != nil || ce != nil { + return "", fail("provider_unavailable") + } + return path, nil +} diff --git a/apps/host_agent/internal/providers/docker/driver_test.go b/apps/host_agent/internal/providers/docker/driver_test.go new file mode 100644 index 00000000..61ff95b6 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/driver_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func policyFixture(phase Phase) DriverPolicy { + script := sha256.Sum256([]byte("approved startup")) + s := scopeFixture() + id := strings.Repeat("a", 64) + return DriverPolicy{Phase: phase, Scope: s, KnownIDs: map[string]bool{id: true}, ImageRefs: map[string]bool{imageSource()["image"].(string): true}, DataPath: "/private/storage/data", ContainerUser: "0:0", RemoteUser: "1000:1000", ContainerEnv: map[string]string{"APP": "literal"}, RemoteEnv: map[string]string{"REMOTE": "literal"}, StartupSHA256: hex.EncodeToString(script[:]), Build: &BuildPolicy{Context: "/private/config", Scratch: "/private/operation/tmp", Tag: "vsc-data-pinned", Target: "dev_containers_target_stage", Args: map[string]string{"APP_VERSION": "1", "BUILDKIT_INLINE_CACHE": "1", "_DEV_CONTAINERS_BASE_IMAGE": "app"}}} +} +func runFixture(p DriverPolicy) []string { + args := []string{"run", "--sig-proxy=false", "-a", "STDOUT", "-a", "STDERR", "--mount", "type=bind,source=" + p.DataPath + ",target=/workspace"} + for _, label := range p.Scope.LabelList() { + args = append(args, "-l", label) + } + return append(args, "-e", "APP=literal", "-u", "0:0", "--name", p.Scope.Name(), "--entrypoint", "/bin/sh", imageSource()["image"].(string), "-c", "approved startup", "-") +} +func TestTypedDriverRunPreservesExactScopeMountUsersAndKnownImage(t *testing.T) { + p := policyFixture(Resources) + call, e := Classify(p, runFixture(p)) + if e != nil || call.Kind != "run" || call.Run == nil || call.Run.Name != p.Scope.Name() { + t.Fatal(call, e) + } + for _, mutate := range []func([]string) []string{ + func(a []string) []string { return append(a[:1], append([]string{"--privileged"}, a[1:]...)...) }, + func(a []string) []string { + for i, v := range a { + if v == "--mount" { + a[i+1] = "type=bind,source=/var/run/docker.sock,target=/workspace" + } + } + return a + }, + func(a []string) []string { + for i, v := range a { + if v == "--name" { + a[i+1] = "foreign" + } + } + return a + }, + func(a []string) []string { + for i, v := range a { + if v == "-u" { + a[i+1] = "root" + } + } + return a + }, + func(a []string) []string { + for i, v := range a { + if strings.HasPrefix(v, "workharbor.owner=") { + a[i] = "workharbor.owner=foreign" + } + } + return a + }, + func(a []string) []string { + for i, v := range a { + if v == "approved startup" { + a[i] = "hostile startup" + } + } + return a + }, + } { + _, e := Classify(p, mutate(runFixture(p))) + errorCode(t, e, "provider_refused") + } +} +func TestTypedDriverHooksOnlyExecKnownTargetAndApprovedEnvironment(t *testing.T) { + p := policyFixture(Hooks) + id := strings.Repeat("a", 64) + call, e := Classify(p, []string{"exec", "-i", "-u", "1000:1000", "-e", "REMOTE=literal", "-w", "/workspace", id, "/bin/sh", "-c", "./trusted-hook"}) + if e != nil || call.Exec == nil || call.Exec.ContainerID != id { + t.Fatal(call, e) + } + for _, args := range [][]string{{"start", id}, {"pull", imageSource()["image"].(string)}, runFixture(p), {"exec", "--privileged", "-i", "-u", "1000:1000", id, "true"}, {"exec", "-i", "-u", "1000:1000", strings.Repeat("b", 64), "true"}, {"exec", "-i", "-u", "1000:1000", "-e", "HOST_SECRET=CANARY", id, "true"}} { + _, e := Classify(p, args) + errorCode(t, e, "provider_refused") + } +} +func TestTypedDriverBuildClosedArgsContextAndGeneratedFile(t *testing.T) { + p := policyFixture(Resources) + args := []string{"buildx", "build", "--load", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-f", "/private/operation/tmp/owned/Dockerfile-with-features", "-t", p.Build.Tag, "--target", p.Build.Target, "--build-arg", "APP_VERSION=1", "--build-arg", "_DEV_CONTAINERS_BASE_IMAGE=app", p.Build.Context} + call, e := Classify(p, args) + if e != nil || call.Build == nil || call.Build.Context != p.Build.Context { + t.Fatal(call, e) + } + for _, bad := range [][]string{append(append([]string{}, args[:2]...), append([]string{"--push"}, args[2:]...)...), {"buildx", "build", "--load", "-f", "/etc/Dockerfile", "-t", p.Build.Tag, p.Build.Context}, {"buildx", "build", "--load", "--build-arg", "BUILDKIT_SYNTAX=evil/frontend", p.Build.Context}} { + _, e := Classify(p, bad) + errorCode(t, e, "provider_refused") + } +} +func TestTypedDriverRefusesGlobalUnknownAndLookupMutations(t *testing.T) { + for _, phase := range []Phase{Preflight, Hooks, Resources} { + p := policyFixture(phase) + for _, args := range [][]string{{"--host", "unix:///foreign.sock", "version"}, {"--context", "foreign", "ps"}, {"system", "prune", "-af"}, {"volume", "rm", p.Scope.StorageID}, {"rm", "-f", strings.Repeat("a", 64)}, {"exec", "-i", "--env-file", "/private/secret", strings.Repeat("a", 64), "true"}, {"run", "--name", p.Scope.Name(), "ubuntu:latest"}} { + _, e := Classify(p, args) + errorCode(t, e, "provider_refused") + } + } +} diff --git a/apps/host_agent/internal/providers/docker/effective.go b/apps/host_agent/internal/providers/docker/effective.go new file mode 100644 index 00000000..17ba1b0d --- /dev/null +++ b/apps/host_agent/internal/providers/docker/effective.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "encoding/json" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "net/url" + "reflect" +) + +func exactKeys(m map[string]any, required []string, optional ...string) bool { + if m == nil { + return false + } + allowed := map[string]bool{} + for _, key := range required { + if _, ok := m[key]; !ok { + return false + } + allowed[key] = true + } + for _, key := range optional { + allowed[key] = true + } + for key := range m { + if !allowed[key] { + return false + } + } + return true +} +func provenance(v any, path string) bool { + m, ok := v.(map[string]any) + if !ok || !exactKeys(m, []string{"$mid", "scheme", "path"}, "fsPath", "external", "authority", "query", "fragment") || m["$mid"] != json.Number("1") || m["scheme"] != "file" || m["path"] != path { + return false + } + for _, key := range []string{"authority", "query", "fragment"} { + if value, present := m[key]; present && value != "" { + return false + } + } + if value, present := m["fsPath"]; present && value != path { + return false + } + if value, present := m["external"]; present { + raw, ok := value.(string) + if !ok { + return false + } + u, e := url.Parse(raw) + if e != nil || u.Scheme != "file" || u.Host != "" || u.Path != path || u.RawQuery != "" || u.Fragment != "" { + return false + } + } + return true +} +func emptyGenerated(v any) bool { + switch x := v.(type) { + case []any: + return len(x) == 0 + case map[string]any: + return len(x) == 0 + case bool: + return !x + } + return false +} + +// ValidateEffective compares the CLI candidate with independently admitted raw +// inputs. It never promotes an unknown field or the CLI's image-metadata fallback. +func ValidateEffective(result, source map[string]any, metadata []map[string]any, input Invocation) error { + if !exactKeys(result, []string{"configuration", "mergedConfiguration", "workspace", "ociAuthDiagnostics"}, "featuresConfiguration") { + return fail("unsupported_configuration") + } + if value, present := result["featuresConfiguration"]; present && !emptyGenerated(value) { + return fail("unsupported_configuration") + } + diagnostics, ok := result["ociAuthDiagnostics"].(map[string]any) + if !ok || !exactKeys(diagnostics, []string{"authLookupWouldBeBlocked", "authServerRedirect", "registryRedirectWouldPreventCredentialForwarding"}) { + return fail("unsupported_configuration") + } + for _, value := range diagnostics { + if value != false { + return fail("metadata_unavailable") + } + } + expected, e := FullOverride(source, input.Scope, input.DataPath) + if e != nil { + return e + } + raw, ok := result["configuration"].(map[string]any) + if !ok || !provenance(raw["configFilePath"], input.Config) { + return fail("unsupported_configuration") + } + copy := clone(raw).(map[string]any) + delete(copy, "configFilePath") + if !reflect.DeepEqual(copy, expected) { + return fail("unsupported_configuration") + } + workspace, ok := result["workspace"].(map[string]any) + if !ok || !reflect.DeepEqual(workspace, map[string]any{"workspaceFolder": "/workspace", "workspaceMount": expected["workspaceMount"]}) { + return fail("unsupported_configuration") + } + entries := append(append([]map[string]any{}, metadata...), source) + for _, entry := range metadata { + if e := validateMetadataEntry(entry); e != nil { + return e + } + } + mergedExpected := clone(expected).(map[string]any) + for _, hook := range []string{"onCreateCommand", "updateContentCommand", "postCreateCommand", "postStartCommand"} { + delete(mergedExpected, hook) + commands := []any{} + for _, entry := range entries { + if value, present := entry[hook]; present { + if text, ok := value.(string); ok && text == "" { + continue + } + commands = append(commands, clone(value)) + } + } + mergedExpected[hook+"s"] = commands + } + for _, key := range []string{"containerEnv", "remoteEnv"} { + env := map[string]any{} + for _, entry := range entries { + if values, ok := entry[key].(map[string]any); ok { + for name, value := range values { + env[name] = value + } + } + } + mergedExpected[key] = env + } + for _, entry := range entries { + if value, present := entry["overrideCommand"]; present { + mergedExpected["overrideCommand"] = value + } + } + mergedExpected["init"] = false + mergedExpected["privileged"] = false + mergedExpected["portsAttributes"] = map[string]any{} + mergedExpected["postAttachCommands"] = []any{} + merged, ok := result["mergedConfiguration"].(map[string]any) + if !ok || !provenance(merged["configFilePath"], input.Config) { + return fail("unsupported_configuration") + } + actual := clone(merged).(map[string]any) + delete(actual, "configFilePath") + for _, key := range []string{"capAdd", "securityOpt", "entrypoints", "mounts", "customizations", "forwardPorts", "otherPortsAttributes", "hostRequirements"} { + if value, present := actual[key]; present { + valid := false + switch key { + case "customizations", "otherPortsAttributes", "hostRequirements": + v, ok := value.(map[string]any) + valid = ok && len(v) == 0 + default: + v, ok := value.([]any) + valid = ok && len(v) == 0 + } + if !valid { + return fail("unsupported_configuration") + } + delete(actual, key) + } + } + if !reflect.DeepEqual(actual, mergedExpected) { + return fail("unsupported_configuration") + } + return nil +} +func validateMetadataEntry(m map[string]any) error { + if m == nil { + return fail("unsupported_configuration") + } + for key := range m { + switch key { + case "containerUser", "remoteUser", "containerEnv", "remoteEnv", "overrideCommand", "userEnvProbe", "updateRemoteUserUID", "waitFor", "shutdownAction", "onCreateCommand", "updateContentCommand", "postCreateCommand", "postStartCommand": + default: + return fail("unsupported_configuration") + } + } + check := clone(m).(map[string]any) + check["image"] = "workharbor-validation@sha256:0000000000000000000000000000000000000000000000000000000000000000" + return config.ValidateSource(check) +} diff --git a/apps/host_agent/internal/providers/docker/engine_args.go b/apps/host_agent/internal/providers/docker/engine_args.go new file mode 100644 index 00000000..4a56559f --- /dev/null +++ b/apps/host_agent/internal/providers/docker/engine_args.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "path/filepath" + "sort" + "strings" +) + +func appendPairs(args []string, flag string, values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + args = append(args, flag, key+"="+values[key]) + } + return args +} + +// nativeArguments reconstructs closed typed calls; it never forwards CLI argv. +// The private CID path is generated by the native phase owner, never source input. +func nativeArguments(c Call, cid string) ([]string, error) { + if !c.Scope.valid() { + return nil, fail("provider_refused") + } + switch c.Kind { + case "version": + return []string{"-v"}, nil + case "engine_version": + return []string{"version", "--format", "{{.Server.Version}}"}, nil + case "buildx_version": + return []string{"buildx", "version"}, nil + case "image_inspect": + if c.Reference == "" { + return nil, fail("provider_refused") + } + return []string{"inspect", "--type", "image", c.Reference}, nil + case "container_inspect", "start": + if len(c.IDs) != 1 || !hexID.MatchString(c.IDs[0]) { + return nil, fail("provider_refused") + } + if c.Kind == "start" { + return []string{"start", c.IDs[0]}, nil + } + return []string{"inspect", "--type", "container", c.IDs[0]}, nil + case "pull": + if _, _, ok := registryRef(c.Reference); !ok { + return nil, fail("provider_refused") + } + return []string{"pull", c.Reference}, nil + case "discovery", "events": + args := []string{"ps", "-q", "-a", "--no-trunc"} + if c.Kind == "events" { + args = []string{"events", "--format", "{{json .}}", "--filter", "event=start"} + } + for _, label := range c.Scope.LabelList() { + args = append(args, "--filter", "label="+label) + } + return args, nil + case "run": + r := c.Run + if r == nil || r.Name != c.Scope.Name() || !strings.HasPrefix(r.ImageID, "sha256:") || !hexID.MatchString(strings.TrimPrefix(r.ImageID, "sha256:")) || !filepath.IsAbs(cid) || filepath.Clean(cid) != cid || filepath.Base(cid) != c.Scope.RuntimeID+".cid" { + return nil, fail("provider_refused") + } + args := []string{"run", "--sig-proxy=false", "-a", "STDOUT", "-a", "STDERR", "--pull", "never", "--cidfile", cid, "--mount", r.Mount} + args = appendPairs(args, "-l", r.Labels) + args = appendPairs(args, "-e", r.Env) + args = append(args, "-u", r.User, "--name", r.Name, "--entrypoint", "/bin/sh", r.ImageID) + return append(args, r.Command...), nil + case "exec": + x := c.Exec + if x == nil || !hexID.MatchString(x.ContainerID) || len(x.Command) == 0 { + return nil, fail("provider_refused") + } + args := []string{"exec", "-i", "-u", x.User} + args = appendPairs(args, "-e", x.Env) + if x.Directory != "" { + if x.Directory != "/workspace" { + return nil, fail("provider_refused") + } + args = append(args, "-w", x.Directory) + } + args = append(args, x.ContainerID) + return append(args, x.Command...), nil + case "build": + b := c.Build + if b == nil || !filepath.IsAbs(b.Context) || !filepath.IsAbs(b.Dockerfile) || b.Tag == "" || b.Target == "" { + return nil, fail("provider_refused") + } + args := []string{"buildx", "build", "--load", "-f", b.Dockerfile, "-t", b.Tag, "--target", b.Target} + args = appendPairs(args, "--build-arg", b.Args) + return append(args, b.Context), nil + default: + return nil, fail("provider_refused") + } +} diff --git a/apps/host_agent/internal/providers/docker/engine_args_test.go b/apps/host_agent/internal/providers/docker/engine_args_test.go new file mode 100644 index 00000000..43f4305d --- /dev/null +++ b/apps/host_agent/internal/providers/docker/engine_args_test.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "strings" + "testing" +) + +func TestNativeRunUsesVerifiedImageIDAndPrivateCIDOnly(t *testing.T) { + p := policyFixture(Resources) + c, e := Classify(p, runFixture(p)) + if e != nil { + t.Fatal(e) + } + c.Scope = p.Scope + c.Run.ImageID = "sha256:" + strings.Repeat("b", 64) + cid := "/private/operation/" + p.Scope.RuntimeID + ".cid" + args, e := nativeArguments(c, cid) + if e != nil { + t.Fatal(e) + } + if !containsPair(args, "--cidfile", cid) || !containsPair(args, "--pull", "never") || !containsPair(args, "--name", p.Scope.Name()) { + t.Fatal(args) + } + foundID := false + for _, arg := range args { + if arg == c.Run.Image { + t.Fatal("mutable/reference run image forwarded") + } + if arg == c.Run.ImageID { + foundID = true + } + } + if !foundID { + t.Fatal("verified image ID absent") + } + if _, e = nativeArguments(c, "/foreign/cid"); e == nil { + t.Fatal("unbound CID path accepted") + } +} +func containsPair(args []string, k, v string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == k && args[i+1] == v { + return true + } + } + return false +} +func TestNativeEventsAlwaysCarryCompleteReservedScopeFilters(t *testing.T) { + s := scopeFixture() + args, e := nativeArguments(Call{Kind: "events", Scope: s}, "") + if e != nil { + t.Fatal(e) + } + for _, label := range s.LabelList() { + if !containsPair(args, "--filter", "label="+label) { + t.Fatal("event escaped complete scope", args) + } + } + if _, e = nativeArguments(Call{Kind: "events"}, ""); e == nil { + t.Fatal("unscoped events accepted") + } +} diff --git a/apps/host_agent/internal/providers/docker/frozen.go b/apps/host_agent/internal/providers/docker/frozen.go new file mode 100644 index 00000000..0f2ff944 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/frozen.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +func copyMap[K comparable, V any](m map[K]V) map[K]V { + if m == nil { + return nil + } + out := make(map[K]V, len(m)) + for k, v := range m { + out[k] = v + } + return out +} +func copyPolicy(p DriverPolicy) DriverPolicy { + p.KnownIDs = copyMap(p.KnownIDs) + p.ImageRefs = copyMap(p.ImageRefs) + p.ContainerEnv = copyMap(p.ContainerEnv) + p.RemoteEnv = copyMap(p.RemoteEnv) + p.StartupTail = append([]string(nil), p.StartupTail...) + if p.Build != nil { + b := *p.Build + b.Args = copyMap(b.Args) + p.Build = &b + } + return p +} +func copyImage(i AdmittedImage) AdmittedImage { + if i.Document != nil { + i.Document = clone(i.Document).(map[string]any) + } + if i.Config.Volumes != nil { + i.Config.Volumes = clone(i.Config.Volumes).(map[string]any) + } + i.Config.OnBuild = append([]string(nil), i.Config.OnBuild...) + return i +} +func copyImages(images map[string]AdmittedImage) map[string]AdmittedImage { + out := make(map[string]AdmittedImage, len(images)) + for k, v := range images { + out[k] = copyImage(v) + } + return out +} diff --git a/apps/host_agent/internal/providers/docker/gateway.go b/apps/host_agent/internal/providers/docker/gateway.go new file mode 100644 index 00000000..4f1b9023 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/gateway.go @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "reflect" + "strings" + "sync" +) + +// AdmittedImage is frozen read-only preflight evidence. It is compared with a +// fresh image/registry inspection; CLI output is never its authority. +type AdmittedImage struct { + ID string + Config ImageConfig + Document map[string]any +} + +// DockerPort accepts only the closed typed union. Candidates must return the +// union of deterministic-name and reserved runtime-label matches, including +// partial/foreign matches. Implementations must not return a first match. +// Image may query registry metadata, but may never pull as an inspection fallback. +type DockerPort interface { + Candidates(context.Context, Scope) ([]Container, error) + Information(context.Context) (EngineInfo, error) + Image(context.Context, string) (AdmittedImage, error) + Invoke(context.Context, Call) ([]byte, error) +} + +// Gateway is an internal native boundary. Authorize must check the current +// operation, owner approval and held host fence; policy/IDs alone grant no effect. +// Durable phase receipts and takeover/recovery belong to the lifecycle caller. +type Gateway struct { + mu sync.Mutex + Parser config.JSONC + Policy DriverPolicy + Port DockerPort + Authorize func(context.Context) error + Target *Target + Images map[string]AdmittedImage + CheckBuild func(context.Context, BuildCall) error +} + +func supportedEngine(e EngineInfo) bool { + if e.OSType != "linux" || (e.Architecture != "arm64" && e.Architecture != "amd64" && e.Architecture != "aarch64" && e.Architecture != "x86_64") || e.ServerVersion != "29.8.2" || e.SecurityOptions == nil { + return false + } + for _, option := range e.SecurityOptions { + switch strings.SplitN(option, ",", 2)[0] { + case "name=seccomp", "name=apparmor", "name=selinux", "name=cgroupns": + default: + return false + } + } + return true +} +func (g *Gateway) image(ctx context.Context, ref string) error { + expected, ok := g.Images[ref] + if !ok || !hexID.MatchString(strings.TrimPrefix(expected.ID, "sha256:")) { + return fail("configuration_changed") + } + actual, e := g.Port.Image(ctx, ref) + if e != nil { + return e + } + if !reflect.DeepEqual(actual, expected) { + return fail("configuration_changed") + } + return nil +} +func (g *Gateway) target(ctx context.Context, candidates []Container, id string) error { + if g.Target == nil || g.Target.Scope != g.Policy.Scope || g.Target.DataPath != g.Policy.DataPath || id != g.Target.ContainerID { + return fail("runtime_conflict") + } + c, e := VerifyTarget(*g.Target, candidates) + if e != nil { + return e + } + return validateActualRuntime(ctx, g.Parser, g.Policy, c) +} +func (g *Gateway) Forward(ctx context.Context, args []string) ([]byte, error) { + c, e := g.prepareCall(ctx, args) + if e != nil { + return nil, e + } + return g.Port.Invoke(ctx, c) +} +func (g *Gateway) prepareCall(ctx context.Context, args []string) (Call, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.Port == nil || g.Authorize == nil { + return Call{}, fail("forbidden") + } + if e := g.Authorize(ctx); e != nil { + return Call{}, e + } + // Only the native same-live-CID port can supply a first target. Labels/names + // alone never grant adoption, and the full union is verified before Classify. + if observer, ok := g.Port.(interface { + ObserveAllocation(context.Context, []Container) (*Target, error) + }); ok && len(args) > 0 && (args[0] == "ps" || args[0] == "inspect" && len(args) > 2 && args[2] == "container" || args[0] == "exec" || args[0] == "start" || args[0] == "events") { + info, err := g.Port.Information(ctx) + if err != nil { + return Call{}, err + } + if !supportedEngine(info) { + return Call{}, fail("unsupported_configuration") + } + all, err := g.Port.Candidates(ctx, g.Policy.Scope) + if err != nil { + return Call{}, err + } + target, err := observer.ObserveAllocation(ctx, all) + if err != nil { + return Call{}, err + } + if target != nil { + if g.Target != nil && *g.Target != *target { + return Call{}, fail("runtime_conflict") + } + g.Target = target + if g.Policy.KnownIDs == nil { + g.Policy.KnownIDs = map[string]bool{} + } + g.Policy.KnownIDs[target.ContainerID] = true + } + } + if len(args) == 4 && exactArgs(args[:3], "inspect", "--type", "image") && g.Policy.Build != nil && args[3] == g.Policy.Build.Tag { + if source, ok := g.Port.(interface { + BuiltImage(context.Context, string) (AdmittedImage, bool, error) + }); ok { + image, found, err := source.BuiltImage(ctx, args[3]) + if err != nil { + return Call{}, err + } + if found { + if g.Images == nil { + g.Images = map[string]AdmittedImage{} + } + if prior, exists := g.Images[args[3]]; exists && !reflect.DeepEqual(prior, image) { + return Call{}, fail("configuration_changed") + } + g.Images[args[3]] = image + if !g.Policy.OverrideCommand { + g.Policy.StartupTail = imageCommand(image) + } + if g.Policy.ImageRefs == nil { + g.Policy.ImageRefs = map[string]bool{} + } + g.Policy.ImageRefs[args[3]] = true + } + } + } + c, e := Classify(g.Policy, args) + if e != nil { + return Call{}, e + } + c.Scope = g.Policy.Scope + // Version/image reads remain closed typed reads. Every container read validates + // the whole candidate set, so official CLI first-match discovery cannot escape. + switch c.Kind { + case "version", "engine_version", "buildx_version": + return c, nil + case "image_inspect": + if e = g.image(ctx, c.Reference); e != nil { + return Call{}, e + } + return c, nil + } + info, e := g.Port.Information(ctx) + if e != nil { + return Call{}, e + } + if !supportedEngine(info) { + return Call{}, fail("unsupported_configuration") + } + candidates, e := g.Port.Candidates(ctx, g.Policy.Scope) + if e != nil { + return Call{}, e + } + switch c.Kind { + case "discovery": + if len(candidates) != 0 { + if g.Target == nil { + return Call{}, fail("runtime_conflict") + } + if e = g.target(ctx, candidates, g.Target.ContainerID); e != nil { + return Call{}, e + } + } + case "container_inspect": + if len(c.IDs) != 1 { + return Call{}, fail("runtime_conflict") + } + if e = g.target(ctx, candidates, c.IDs[0]); e != nil { + return Call{}, e + } + case "exec": + if e = g.target(ctx, candidates, c.Exec.ContainerID); e != nil { + return Call{}, e + } + case "start": + if e = g.target(ctx, candidates, c.IDs[0]); e != nil { + return Call{}, e + } + case "run": + if len(candidates) != 0 || g.Target != nil { + return Call{}, fail("runtime_conflict") + } + if e = g.image(ctx, c.Run.Image); e != nil { + return Call{}, e + } + metadata, present := c.Run.Labels["devcontainer.metadata"] + if !present { + metadata = g.Images[c.Run.Image].Config.Metadata + } + expected, parseErr := ValidateImage(ctx, g.Parser, ImageConfig{Metadata: g.Policy.RuntimeMetadata}) + if parseErr != nil || len(expected) == 0 { + return Call{}, fail("configuration_changed") + } + actual, parseErr := ValidateImage(ctx, g.Parser, ImageConfig{Metadata: metadata}) + if parseErr != nil || !reflect.DeepEqual(actual, expected) { + return Call{}, fail("configuration_changed") + } + c.Run.ImageID = g.Images[c.Run.Image].ID + case "pull": + if len(candidates) != 0 || g.Target != nil { + return Call{}, fail("runtime_conflict") + } + if e = g.image(ctx, c.Reference); e != nil { + return Call{}, e + } + case "build": + if len(candidates) != 0 || g.Target != nil { + return Call{}, fail("runtime_conflict") + } + if g.CheckBuild == nil { + return Call{}, fail("configuration_changed") + } + for ref := range g.Images { + if e = g.image(ctx, ref); e != nil { + return Call{}, e + } + } + if len(g.Images) == 0 { + return Call{}, fail("configuration_changed") + } + if e = g.CheckBuild(ctx, *c.Build); e != nil { + return Call{}, e + } + case "events": + if len(candidates) != 0 { + if g.Target == nil { + return Call{}, fail("runtime_conflict") + } + if e = g.target(ctx, candidates, g.Target.ContainerID); e != nil { + return Call{}, e + } + } + default: + return Call{}, fail("provider_refused") + } + // Recheck immediately before forwarding. The concrete port additionally owns + // the fixed daemon binding; it must not use ambient Docker contexts/options. + if e = g.Authorize(ctx); e != nil { + return Call{}, e + } + c.Scope = g.Policy.Scope + return c, nil +} diff --git a/apps/host_agent/internal/providers/docker/gateway_test.go b/apps/host_agent/internal/providers/docker/gateway_test.go new file mode 100644 index 00000000..9404c371 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/gateway_test.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" +) + +type recordingEngine struct { + candidates []Container + info EngineInfo + image AdmittedImage + effects []Call + reads []string +} + +func (e *recordingEngine) Candidates(_ context.Context, _ Scope) ([]Container, error) { + e.reads = append(e.reads, "candidates") + return e.candidates, nil +} +func (e *recordingEngine) Information(context.Context) (EngineInfo, error) { + e.reads = append(e.reads, "info") + return e.info, nil +} +func (e *recordingEngine) Image(_ context.Context, _ string) (AdmittedImage, error) { + e.reads = append(e.reads, "image") + return e.image, nil +} +func (e *recordingEngine) Invoke(_ context.Context, c Call) ([]byte, error) { + e.effects = append(e.effects, c) + return []byte("result"), nil +} +func gatewayFixture(t *testing.T, phase Phase) (*Gateway, *recordingEngine) { + t.Helper() + p := policyFixture(phase) + p.RuntimeMetadata = `[{"userEnvProbe":"none","updateRemoteUserUID":false}]` + id := strings.Repeat("a", 64) + imageID := "sha256:" + strings.Repeat("b", 64) + c := Container{ID: id, User: p.ContainerUser, Name: "/" + p.Scope.Name(), Image: imageID, Labels: p.Scope.Labels(), Mounts: []Mount{{Type: "bind", Source: p.DataPath, Destination: "/workspace", RW: true}}} + c.Labels["devcontainer.metadata"] = p.RuntimeMetadata + im := AdmittedImage{ID: imageID, Config: ImageConfig{}} + e := &recordingEngine{candidates: []Container{c}, image: im, info: EngineInfo{OSType: "linux", Architecture: "arm64", ServerVersion: "29.8.2", SecurityOptions: []string{}}} + return &Gateway{Parser: registryParser(t), Policy: p, Port: e, Authorize: func(context.Context) error { return nil }, Target: &Target{Scope: p.Scope, ContainerID: id, ImageID: imageID, DataPath: p.DataPath}, Images: map[string]AdmittedImage{imageSource()["image"].(string): im}}, e +} +func TestGatewayRechecksIdentityAndAuthorityBeforeEveryExec(t *testing.T) { + g, e := gatewayFixture(t, Hooks) + args := []string{"exec", "-i", "-u", "1000:1000", g.Target.ContainerID, "true"} + if _, err := g.Forward(context.Background(), args); err != nil { + t.Fatal(err) + } + if len(e.effects) != 1 || !reflect.DeepEqual(e.reads, []string{"info", "candidates"}) { + t.Fatal(e) + } + e.candidates[0].Labels["workharbor.owner"] = "foreign" + _, err := g.Forward(context.Background(), args) + errorCode(t, err, "runtime_conflict") + if len(e.effects) != 1 { + t.Fatal("foreign exec forwarded") + } + g.Authorize = func(context.Context) error { return errors.New("revoked") } + e.reads = nil + _, err = g.Forward(context.Background(), args) + if err == nil || len(e.reads) != 0 || len(e.effects) != 1 { + t.Fatal("authorization not first", e, err) + } +} +func TestGatewayRefusesDuplicateNameAndRootlessBeforeRun(t *testing.T) { + g, e := gatewayFixture(t, Resources) + g.Target = nil + for _, candidates := range [][]Container{e.candidates, append(e.candidates, e.candidates[0])} { + e.candidates = candidates + _, err := g.Forward(context.Background(), runFixture(g.Policy)) + errorCode(t, err, "runtime_conflict") + } + e.candidates = nil + e.info.SecurityOptions = []string{"name=rootless"} + _, err := g.Forward(context.Background(), runFixture(g.Policy)) + errorCode(t, err, "unsupported_configuration") + if len(e.effects) != 0 { + t.Fatal("run forwarded") + } +} +func TestGatewayFrozenImageAndSecondAuthorizationBeforePull(t *testing.T) { + g, e := gatewayFixture(t, Resources) + e.candidates = nil + ref := imageSource()["image"].(string) + g.Target = nil + e.image.Config.Volumes = map[string]any{"/workspace": map[string]any{}} + _, err := g.Forward(context.Background(), []string{"pull", ref}) + errorCode(t, err, "configuration_changed") + if len(e.effects) != 0 { + t.Fatal("changed image forwarded") + } + e.image = g.Images[ref] + calls := 0 + g.Authorize = func(context.Context) error { + calls++ + if calls == 2 { + return errors.New("fence lost") + } + return nil + } + _, err = g.Forward(context.Background(), []string{"pull", ref}) + if err == nil || calls != 2 || len(e.effects) != 0 { + t.Fatal("second authorization absent", calls, err) + } +} + +func TestGatewayRunChecksEffectiveMetadataAndFinalImageID(t *testing.T) { + for _, imageLabel := range []bool{false, true} { + g, e := gatewayFixture(t, Resources) + g.Target = nil + e.candidates = nil + source := imageSource() + source["containerUser"] = "0:0" + source["remoteUser"] = "1000:1000" + raw, _, err := ExpectedMetadata(source, nil) + if err != nil { + t.Fatal(err) + } + g.Policy.RuntimeMetadata = raw + g.Parser = registryParser(t) + var pretty bytes.Buffer + if err = json.Indent(&pretty, []byte(raw), "", " "); err != nil { + t.Fatal(err) + } + args := runFixture(g.Policy) + if imageLabel { + ref := source["image"].(string) + im := g.Images[ref] + im.Config.Metadata = pretty.String() + g.Images[ref] = im + e.image = im + } else { + args = append(args[:1], append([]string{"-l", "devcontainer.metadata=" + pretty.String()}, args[1:]...)...) + } + if _, err = g.Forward(context.Background(), args); err != nil { + t.Fatal("equivalent approved final metadata refused", imageLabel, err) + } + if len(e.effects) != 1 || e.effects[0].Run.ImageID != e.image.ID { + t.Fatal("verified final imageID not bound") + } + g.Policy.RuntimeMetadata = `[{"userEnvProbe":"none","updateRemoteUserUID":false,"postStartCommand":"changed"}]` + if _, err = g.Forward(context.Background(), args); err == nil || len(e.effects) != 1 { + t.Fatal("foreign metadata forwarded") + } + } +} + +func TestGatewayRuntimeUserNamespaceAndMetadataRecheckedBeforeExec(t *testing.T) { + for _, kind := range []string{"user", "namespace", "metadata"} { + t.Run(kind, func(t *testing.T) { + g, p := gatewayFixture(t, Hooks) + g.Parser = registryParser(t) + g.Policy.RuntimeMetadata = `[{"userEnvProbe":"none","updateRemoteUserUID":false}]` + p.candidates[0].User = g.Policy.ContainerUser + p.candidates[0].Labels["devcontainer.metadata"] = g.Policy.RuntimeMetadata + switch kind { + case "user": + p.candidates[0].User = "0:1" + case "namespace": + p.candidates[0].UsernsMode = "host" + case "metadata": + p.candidates[0].Labels["devcontainer.metadata"] = `{"initializeCommand":"hostile"}` + } + _, e := g.Forward(context.Background(), []string{"exec", "-i", "-u", g.Policy.RemoteUser, g.Target.ContainerID, "true"}) + if e == nil || len(p.effects) != 0 { + t.Fatal("unsafe actual runtime forwarded", kind, e) + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/generated.go b/apps/host_agent/internal/providers/docker/generated.go new file mode 100644 index 00000000..31c76cbb --- /dev/null +++ b/apps/host_agent/internal/providers/docker/generated.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "github.com/moby/buildkit/frontend/dockerfile/parser" + "github.com/moby/buildkit/frontend/dockerfile/shell" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "reflect" + "strings" + "unicode/utf8" +) + +type emptyBuildEnv struct{} + +func (emptyBuildEnv) Get(string) (string, bool) { return "", false } +func (emptyBuildEnv) Keys() []string { return nil } + +type normalizedNode struct { + Value string + Flags []string + Attributes map[string]bool + Heredocs []parser.Heredoc + Next *normalizedNode + Children []*normalizedNode +} + +func normalized(n *parser.Node) *normalizedNode { + if n == nil { + return nil + } + out := &normalizedNode{Value: n.Value, Flags: n.Flags, Attributes: n.Attributes, Heredocs: n.Heredocs, Next: normalized(n.Next)} + for _, child := range n.Children { + out.Children = append(out.Children, normalized(child)) + } + return out +} + +// ValidateGeneratedDockerfile admits only the official no-Features wrapper, +// byte-preserved source semantics and a nonempty independently expected label. +// It runs before build forwarding, never as a post-build repair. +func ValidateGeneratedDockerfile(ctx context.Context, p config.JSONC, source, generated []byte, target string, metadata []map[string]any) error { + plan, e := config.AnalyzeDockerfile(source, target) + if e != nil { + return e + } + if !utf8.Valid(generated) || bytes.IndexByte(generated, 0) >= 0 { + return fail("configuration_changed") + } + if _, _, _, found := parser.DetectSyntax(generated); found { + return fail("configuration_changed") + } + original, e := parser.Parse(bytes.NewReader(source)) + if e != nil { + return fail("configuration_changed") + } + candidate, e := parser.Parse(bytes.NewReader(generated)) + if e != nil { + return fail("configuration_changed") + } + a, b := original.AST.Children, candidate.AST.Children + if len(b) != len(a)+3 { + return fail("configuration_changed") + } + prefix, _ := parser.Parse(strings.NewReader("ARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n")) + wrapper, _ := parser.Parse(strings.NewReader("FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage\n")) + if !reflect.DeepEqual(normalized(b[0]), normalized(prefix.AST.Children[0])) || !reflect.DeepEqual(normalized(b[len(b)-2]), normalized(wrapper.AST.Children[0])) { + return fail("configuration_changed") + } + lastFrom := -1 + for i, n := range a { + if strings.EqualFold(n.Value, "from") { + lastFrom = i + } + } + for i, n := range a { + expected := normalized(n) + if i == lastFrom && plan.LastStageUnnamed && target == "" { + expected.Next.Next = &normalizedNode{Value: "AS", Next: &normalizedNode{Value: plan.Stage}} + } + actual := normalized(b[i+1]) + if !reflect.DeepEqual(expected, actual) { + return fail("configuration_changed") + } + } + label := b[len(b)-1] + if !strings.EqualFold(label.Value, "label") || len(label.Flags) != 0 || len(label.Heredocs) != 0 || label.Next == nil || label.Next.Value != "devcontainer.metadata" || label.Next.Next == nil || label.Next.Next.Next == nil || label.Next.Next.Next.Value != "=" || label.Next.Next.Next.Next != nil { + return fail("configuration_changed") + } + value, unmatched, e := shell.NewLex(candidate.EscapeToken).ProcessWord(label.Next.Next.Value, emptyBuildEnv{}) + if e != nil || len(unmatched) != 0 { + return fail("configuration_changed") + } + actual, e := ValidateImage(ctx, p, ImageConfig{Metadata: value}) + if e != nil { + return e + } + if len(actual) == 0 || !reflect.DeepEqual(actual, metadata) { + return fail("configuration_changed") + } + last := actual[len(actual)-1] + if last["userEnvProbe"] != "none" || last["updateRemoteUserUID"] != false { + return fail("configuration_changed") + } + return nil +} diff --git a/apps/host_agent/internal/providers/docker/generated_test.go b/apps/host_agent/internal/providers/docker/generated_test.go new file mode 100644 index 00000000..5e15fde3 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/generated_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestGeneratedDockerfilePreservesSourceAndOverridesHostileAuthoredMetadata(t *testing.T) { + p := registryParser(t) + base := "ubuntu@sha256:" + strings.Repeat("a", 64) + source := []byte("FROM " + base + " AS app\nRUN printf original\nLABEL devcontainer.metadata=\"{\\\"initializeCommand\\\":\\\"bad\\\"}\"\n") + metadata := []map[string]any{{"remoteUser": "1000:1000", "containerUser": "0:0", "userEnvProbe": "none", "updateRemoteUserUID": false, "waitFor": "postStartCommand", "shutdownAction": "none"}} + raw, _ := json.Marshal(metadata) + quoted, _ := json.Marshal(string(raw)) + generated := []byte("ARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" + string(source) + "\nFROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage\nLABEL devcontainer.metadata=" + string(quoted) + "\n") + if e := ValidateGeneratedDockerfile(context.Background(), p, source, generated, "app", metadata); e != nil { + t.Fatal(e) + } + for _, bad := range []string{strings.Replace(string(generated), "printf original", "printf changed", 1), strings.Replace(string(generated), "LABEL devcontainer.metadata="+string(quoted), "", 1), string(generated) + "RUN touch added\n", "# syntax=evil/frontend\n" + string(generated), strings.Replace(string(generated), "AS dev_containers_target_stage", "AS unexpected", 1)} { + if e := ValidateGeneratedDockerfile(context.Background(), p, source, []byte(bad), "app", metadata); e == nil { + t.Fatal("generated mutation admitted", bad) + } + } +} diff --git a/apps/host_agent/internal/providers/docker/metadata_expected.go b/apps/host_agent/internal/providers/docker/metadata_expected.go new file mode 100644 index 00000000..b3389cd1 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/metadata_expected.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "encoding/json" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" +) + +// ExpectedMetadata independently derives the admitted no-Features metadata, not +// authority from CLI output. Its fixed probe/UID policy makes the final label +// nonempty even when the original source has no lifecycle commands. +func ExpectedMetadata(source map[string]any, base []map[string]any) (string, []map[string]any, error) { + if e := config.ValidateSource(source); e != nil { + return "", nil, e + } + entries := []map[string]any{} + for _, entry := range base { + if e := validateMetadataEntry(entry); e != nil { + return "", nil, e + } + if len(entry) != 0 { + entries = append(entries, clone(entry).(map[string]any)) + } + } + last := map[string]any{} + for key, value := range source { + if key != "image" && key != "build" && key != "name" { + last[key] = clone(value) + } + } + for key, value := range map[string]any{"userEnvProbe": "none", "updateRemoteUserUID": false, "waitFor": "postStartCommand", "shutdownAction": "none"} { + last[key] = value + } + if e := validateMetadataEntry(last); e != nil { + return "", nil, e + } + entries = append(entries, last) + raw, e := json.Marshal(entries) + if e != nil { + return "", nil, fail("invalid_configuration") + } + return string(raw), entries, nil +} diff --git a/apps/host_agent/internal/providers/docker/metadata_process.go b/apps/host_agent/internal/providers/docker/metadata_process.go new file mode 100644 index 00000000..47f98c21 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/metadata_process.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "io" + "os/exec" + "time" +) + +type metadataBuffer struct { + buffer bytes.Buffer + limit int64 + cancel context.CancelFunc + overflow bool +} + +func (b *metadataBuffer) Write(p []byte) (int, error) { + if int64(b.buffer.Len())+int64(len(p)) > b.limit { + b.overflow = true + b.cancel() + return 0, io.ErrShortBuffer + } + return b.buffer.Write(p) +} + +// captureMetadata is private to the fixed registry tool commands. Cancellation +// and overflow kill the owned child; Run waits/reaps it before returning. Tools +// receive no credential helpers or executable repository-controlled source. +func captureMetadata(ctx context.Context, program string, args []string, dir string, env []string, limit int64) ([]byte, error) { + if _, ok := ctx.Deadline(); !ok || limit <= 0 || limit > metadataLimit { + return nil, fail("metadata_unavailable") + } + child, cancel := context.WithCancel(ctx) + defer cancel() + out := &metadataBuffer{limit: limit, cancel: cancel} + c := exec.CommandContext(child, program, args...) + c.Dir = dir + c.Env = env + c.Stdout = out + c.Stderr = io.Discard + c.WaitDelay = time.Second + if e := ownedProcessGroup(c); e != nil { + return nil, e + } + if e := c.Run(); e != nil || out.overflow || child.Err() != nil { + return nil, fail("metadata_unavailable") + } + return append([]byte{}, out.buffer.Bytes()...), nil +} diff --git a/apps/host_agent/internal/providers/docker/metadata_process_test.go b/apps/host_agent/internal/providers/docker/metadata_process_test.go new file mode 100644 index 00000000..1c5379ea --- /dev/null +++ b/apps/host_agent/internal/providers/docker/metadata_process_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestMetadataBufferCancelsBeforeCaptureExceedsLimit(t *testing.T) { + canceled := false + b := metadataBuffer{limit: 3, cancel: func() { canceled = true }} + if n, e := b.Write([]byte("abc")); n != 3 || e != nil { + t.Fatal(n, e) + } + if _, e := b.Write([]byte("d")); e == nil || !canceled || !b.overflow || b.buffer.String() != "abc" { + t.Fatal("overflow not canceled before capture") + } +} +func TestMetadataOwnedChildHelper(t *testing.T) { + if os.Getenv("WHR_TEST_METADATA_CHILD") != "overflow" { + return + } + os.Stdout.Write([]byte(strings.Repeat("x", metadataLimit+1))) + for { + time.Sleep(time.Second) + } +} +func TestMetadataOverflowKillsAndReapsOwnedChild(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, e := captureMetadata(ctx, os.Args[0], []string{"-test.run=^TestMetadataOwnedChildHelper$"}, t.TempDir(), []string{"WHR_TEST_METADATA_CHILD=overflow"}, metadataLimit) + errorCode(t, e, "metadata_unavailable") + if ctx.Err() != nil { + t.Fatal("overflow waited for outer deadline") + } +} +func TestRegistryPrivateEnvironmentCannotUseAmbientCredentialFallback(t *testing.T) { + root, e := filepath.EvalSymlinks(t.TempDir()) + if e != nil { + t.Fatal(e) + } + if e = os.Chmod(root, 0700); e != nil { + t.Fatal(e) + } + t.Setenv("REGISTRY_AUTH_FILE", "/host/secret") + t.Setenv("DOCKER_CONFIG", "/host/docker") + t.Setenv("AWS_ACCESS_KEY_ID", "CANARY") + t.Setenv("HTTPS_PROXY", "http://host-proxy") + stage, e := registryPrivate(root) + if e != nil { + t.Fatal(e) + } + defer os.RemoveAll(stage) + for _, file := range []string{"home/.docker/config.json", "docker/config.json"} { + raw, e := os.ReadFile(filepath.Join(stage, file)) + if e != nil || string(raw) != "{}" { + t.Fatal(file, e) + } + info, _ := os.Stat(filepath.Join(stage, file)) + if info.Mode().Perm() != 0600 { + t.Fatal("nonprivate config") + } + } + env := registryEnvironment(stage, "/tools/crane") + expected := []string{"PATH=/tools", "HOME=" + filepath.Join(stage, "home"), "DOCKER_CONFIG=" + filepath.Join(stage, "docker"), "XDG_CONFIG_HOME=" + filepath.Join(stage, "config"), "XDG_RUNTIME_DIR=" + filepath.Join(stage, "runtime"), "LANG=C.UTF-8"} + if !reflect.DeepEqual(env, expected) { + t.Fatal("ambient environment escaped", env) + } +} diff --git a/apps/host_agent/internal/providers/docker/native_build.go b/apps/host_agent/internal/providers/docker/native_build.go new file mode 100644 index 00000000..df358bd4 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_build.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "io" + "os" + "path/filepath" + "reflect" + "strings" +) + +// buildIID is allocation evidence only, never an official up completion receipt. +func buildIID(path string) (string, error) { + if !ownedDirectory(filepath.Dir(path)) { + return "", fail("configuration_changed") + } + info, e := os.Lstat(path) + if e != nil || !ownedRegular(info) || info.Size() != 71 { + return "", fail("configuration_changed") + } + f, e := os.Open(path) + if e != nil { + return "", fail("configuration_changed") + } + actual, se := f.Stat() + raw, re := io.ReadAll(io.LimitReader(f, 72)) + ce := f.Close() + current, le := os.Lstat(path) + if se != nil || re != nil || ce != nil || le != nil || !ownedRegular(current) || !os.SameFile(info, actual) || !os.SameFile(info, current) || len(raw) != 71 || !strings.HasPrefix(string(raw), "sha256:") || !hexID.Match(raw[7:]) { + return "", fail("configuration_changed") + } + return string(raw), nil +} +func (p *nativePort) build(ctx context.Context, c Call, in io.Reader, out io.Writer) error { + if p.lifetime == nil || p.checkBuild == nil || c.Build == nil { + return fail("configuration_changed") + } + if e := p.checkBuild(ctx, *c.Build); e != nil { + return e + } + if e := p.authorize(ctx); e != nil { + return e + } + p.stateMu.Lock() + if p.buildStarted || p.allocation != nil || p.target != nil { + p.stateMu.Unlock() + return fail("runtime_unknown") + } + p.buildStarted = true + p.stateMu.Unlock() + dir, e := os.MkdirTemp(p.lifetime.root, "build-") + if e != nil { + return fail("provider_unavailable") + } + defer os.RemoveAll(dir) // Private build witness only; never project or Docker data. + iid := filepath.Join(dir, "image.iid") + args, e := nativeArguments(c, "") + if e != nil { + return e + } + args = append(args[:len(args)-1], "--iidfile", iid, c.Build.Context) + if e = p.authorize(ctx); e != nil { + return e + } + if e = p.stream(ctx, args, in, out); e != nil { + return e + } + if e = p.authorize(ctx); e != nil { + return e + } + id, e := buildIID(iid) + if e != nil { + return e + } + actual, _, e := p.localImage(ctx, id) + if e != nil { + return e + } + if actual.ID != id { + return fail("configuration_changed") + } + p.stateMu.Lock() + policy := copyPolicy(p.policy) + p.stateMu.Unlock() + if e = verifyMetadata(ctx, p.parser, policy.RuntimeMetadata, actual.Config.Metadata); e != nil { + return e + } + tag, _, e := p.localImage(ctx, c.Build.Tag) + if e != nil { + return e + } + if !reflect.DeepEqual(actual, tag) { + return fail("configuration_changed") + } + if e = p.authorize(ctx); e != nil { + return e + } + p.stateMu.Lock() + defer p.stateMu.Unlock() + if _, exists := p.images[c.Build.Tag]; exists { + return fail("configuration_changed") + } + p.images[c.Build.Tag] = copyImage(actual) + if !p.policy.OverrideCommand { + p.policy.StartupTail = imageCommand(actual) + } + p.policy.ImageRefs[c.Build.Tag] = true + return nil +} +func (p *nativePort) BuiltImage(ctx context.Context, ref string) (AdmittedImage, bool, error) { + if e := p.authorize(ctx); e != nil { + return AdmittedImage{}, false, e + } + p.stateMu.Lock() + defer p.stateMu.Unlock() + if p.policy.Build == nil || ref != p.policy.Build.Tag { + return AdmittedImage{}, false, nil + } + image, ok := p.images[ref] + return copyImage(image), ok, nil +} diff --git a/apps/host_agent/internal/providers/docker/native_build_test.go b/apps/host_agent/internal/providers/docker/native_build_test.go new file mode 100644 index 00000000..6a084f27 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_build_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "strings" + "testing" +) + +func TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag(t *testing.T) { + for _, kind := range []string{"success", "missing", "partial", "foreign-tag", "failed-build", "cancelled", "hostile-metadata", "symlink", "revoked-after-build", "cancel-after-build"} { + t.Run(kind, func(t *testing.T) { + p, _, ctx := nativeExecutionFixture(t) + policy := p.policy + b := policy.Build + c := Call{Scope: p.scope, Kind: "build", Build: &BuildCall{Context: b.Context, Dockerfile: b.Scratch + "/one/Dockerfile-with-features", Tag: b.Tag, Target: b.Target, Args: b.Args}} + p.checkBuild = func(context.Context, BuildCall) error { return nil } + finalID := "sha256:" + strings.Repeat("d", 64) + oldQuery := p.query + p.query = func(ctx context.Context, a []string) ([]byte, error) { + if a[0] != "inspect" { + return oldQuery(ctx, a) + } + id := finalID + if a[len(a)-1] == b.Tag && kind == "foreign-tag" { + id = "sha256:" + strings.Repeat("e", 64) + } + metadata := policy.RuntimeMetadata + if kind == "hostile-metadata" { + metadata = `[{"initializeCommand":"false"}]` + } + return json.Marshal([]any{map[string]any{"Id": id, "Architecture": "arm64", "Os": "linux", "Config": map[string]any{"User": policy.ContainerUser, "Cmd": []any{"/bin/echo", "final-built-command"}, "Labels": map[string]any{"devcontainer.metadata": metadata}}, "RootFS": map[string]any{"Type": "layers", "Layers": []any{}}}}) + } + p.stream = func(_ context.Context, a []string, _ io.Reader, _ io.Writer) error { + iid := "" + for i, v := range a { + if v == "--iidfile" && i+1 < len(a) { + iid = a[i+1] + } + } + if iid == "" { + t.Fatal("missing provider IID witness", a) + } + if _, e := os.Lstat(iid); !os.IsNotExist(e) { + t.Fatal("IID precreated", e) + } + switch kind { + case "failed-build": + return errors.New("independent build failure") + case "cancelled": + return context.Canceled + case "missing": + return nil + case "partial": + return os.WriteFile(iid, []byte("sha256:bad"), 0600) + case "symlink": + other := iid + ".foreign" + if e := os.WriteFile(other, []byte(finalID), 0600); e != nil { + return e + } + return os.Symlink(other, iid) + case "revoked-after-build": + if e := os.WriteFile(iid, []byte(finalID), 0600); e != nil { + return e + } + p.check = func(context.Context) error { return errors.New("revoked") } + return nil + case "cancel-after-build": + if e := os.WriteFile(iid, []byte(finalID), 0600); e != nil { + return e + } + p.lifetime.cancel() + return nil + default: + return os.WriteFile(iid, []byte(finalID), 0600) + } + } + e := p.InvokeStream(ctx, c, nil, io.Discard) + _, registered := p.images[b.Tag] + if kind == "success" { + if e != nil || !registered || p.images[b.Tag].ID != finalID || strings.Join(p.policy.StartupTail, " ") != "/bin/echo final-built-command" { + t.Fatal("valid build not frozen", e, registered) + } + } else if e == nil || registered { + t.Fatal("unproven build registered", e, registered) + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/native_decode.go b/apps/host_agent/internal/providers/docker/native_decode.go new file mode 100644 index 00000000..c181c8cd --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_decode.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "strings" +) + +func stringField(m map[string]any, key string) (string, bool) { v, ok := m[key].(string); return v, ok } +func objectField(m map[string]any, key string) (map[string]any, bool) { + v, ok := m[key].(map[string]any) + return v, ok +} +func nativeContainers(raw []byte) ([]Container, error) { + value, e := wirejson.Parse(raw) + if e != nil { + return nil, fail("runtime_unknown") + } + values, ok := value.([]any) + if !ok { + return nil, fail("runtime_unknown") + } + all := make([]Container, 0, len(values)) + for _, value := range values { + m, ok := value.(map[string]any) + if !ok { + return nil, fail("runtime_unknown") + } + var c Container + var id, name, image, user, userns, running, mountsOK bool + c.ID, id = stringField(m, "Id") + c.Name, name = stringField(m, "Name") + c.Image, image = stringField(m, "Image") + cfg, cfgOK := objectField(m, "Config") + host, hostOK := objectField(m, "HostConfig") + state, stateOK := objectField(m, "State") + c.User, user = stringField(cfg, "User") + c.UsernsMode, userns = stringField(host, "UsernsMode") + c.Running, running = state["Running"].(bool) + mounts, mountsOK := m["Mounts"].([]any) + if !id || !name || !image || !cfgOK || !hostOK || !stateOK || !user || !userns || !running || !mountsOK || !hexID.MatchString(c.ID) || !strings.HasPrefix(c.Image, "sha256:") || !hexID.MatchString(strings.TrimPrefix(c.Image, "sha256:")) { + return nil, fail("runtime_unknown") + } + c.Labels = map[string]string{} + if value, exists := cfg["Labels"]; exists && value != nil { + labels, ok := value.(map[string]any) + if !ok { + return nil, fail("runtime_unknown") + } + for key, value := range labels { + label, ok := value.(string) + if !ok { + return nil, fail("runtime_unknown") + } + c.Labels[key] = label + } + } + c.Mounts = make([]Mount, 0, len(mounts)) + for _, value := range mounts { + m, ok := value.(map[string]any) + if !ok { + return nil, fail("runtime_unknown") + } + var mount Mount + var typ, source, destination, rw bool + mount.Type, typ = stringField(m, "Type") + mount.Source, source = stringField(m, "Source") + mount.Destination, destination = stringField(m, "Destination") + mount.RW, rw = m["RW"].(bool) + if !typ || !source || !destination || !rw { + return nil, fail("runtime_unknown") + } + c.Mounts = append(c.Mounts, mount) + } + all = append(all, c) + } + return all, nil +} +func nativeInformation(raw []byte) (EngineInfo, error) { + value, e := wirejson.Parse(raw) + if e != nil { + return EngineInfo{}, fail("provider_unavailable") + } + m, ok := value.(map[string]any) + if !ok { + return EngineInfo{}, fail("provider_unavailable") + } + var info EngineInfo + var os, arch, version bool + info.OSType, os = stringField(m, "OSType") + info.Architecture, arch = stringField(m, "Architecture") + info.ServerVersion, version = stringField(m, "ServerVersion") + options, ok := m["SecurityOptions"].([]any) + if !os || !arch || !version || !ok { + return EngineInfo{}, fail("provider_unavailable") + } + info.SecurityOptions = make([]string, 0, len(options)) + for _, value := range options { + s, ok := value.(string) + if !ok { + return EngineInfo{}, fail("provider_unavailable") + } + info.SecurityOptions = append(info.SecurityOptions, s) + } + return info, nil +} diff --git a/apps/host_agent/internal/providers/docker/native_decode_test.go b/apps/host_agent/internal/providers/docker/native_decode_test.go new file mode 100644 index 00000000..696dd9d4 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_decode_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "encoding/json" + "strings" + "testing" +) + +func nativeContainerFixture() map[string]any { + p := policyFixture(Resources) + return map[string]any{"Id": strings.Repeat("a", 64), "Name": "/" + p.Scope.Name(), "Image": "sha256:" + strings.Repeat("b", 64), "Config": map[string]any{"User": "1000:1000", "Labels": p.Scope.Labels()}, "HostConfig": map[string]any{"UsernsMode": ""}, "Mounts": []any{map[string]any{"Type": "bind", "Source": p.DataPath, "Destination": "/workspace", "RW": true}}, "State": map[string]any{"Running": true}} +} +func TestNativeInspectionPreservesFullCandidateSetAndRuntimeUser(t *testing.T) { + c := nativeContainerFixture() + raw, _ := json.Marshal([]any{c, c}) + all, e := nativeContainers(raw) + if e != nil || len(all) != 2 { + t.Fatal("candidate truncation", e, all) + } + if all[0].User != "1000:1000" || all[0].UsernsMode != "" || !all[0].Running { + t.Fatal("runtime owner/state omitted", all[0]) + } + if _, e = VerifyRuntime(policyFixture(Resources).Scope, policyFixture(Resources).DataPath, all); e == nil { + t.Fatal("duplicate adopted") + } +} +func TestNativeInspectionRejectsAmbiguousOrPartialSecurityFields(t *testing.T) { + for _, field := range []string{"Id", "Config", "HostConfig", "Mounts", "State"} { + c := nativeContainerFixture() + delete(c, field) + raw, _ := json.Marshal([]any{c}) + if _, e := nativeContainers(raw); e == nil { + t.Fatal("partial accepted", field) + } + } + for _, raw := range []string{`[{"Id":"x","Id":"y"}]`, `[{"Config":{"User":"0:0","User":"1000:1000"}}]`, `[]garbage`} { + if _, e := nativeContainers([]byte(raw)); e == nil { + t.Fatal("ambiguous accepted") + } + } +} +func TestNativeInformationRequiresActualExplicitSecurityOptions(t *testing.T) { + raw := []byte(`{"OSType":"linux","Architecture":"aarch64","ServerVersion":"29.8.2","SecurityOptions":[]}`) + info, e := nativeInformation(raw) + if e != nil || !supportedEngine(info) { + t.Fatal("valid actual info", e, info) + } + for _, raw := range []string{`{"OSType":"linux","Architecture":"aarch64","ServerVersion":"29.8.2"}`, `{"OSType":"linux","Architecture":"aarch64","ServerVersion":"29.8.2","SecurityOptions":null}`, `{"OSType":"linux","Architecture":"aarch64","ServerVersion":"29.8.2","SecurityOptions":["name=userns"]}`} { + info, e := nativeInformation([]byte(raw)) + if e == nil && supportedEngine(info) { + t.Fatal("unproved daemon accepted") + } + } +} diff --git a/apps/host_agent/internal/providers/docker/native_execution.go b/apps/host_agent/internal/providers/docker/native_execution.go new file mode 100644 index 00000000..c4367604 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_execution.go @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "io" + "reflect" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" +) + +func verifyMetadata(ctx context.Context, parser config.JSONC, expected, actual string) error { + want, e := ValidateImage(ctx, parser, ImageConfig{Metadata: expected}) + if e != nil || len(want) == 0 { + return fail("configuration_changed") + } + got, e := ValidateImage(ctx, parser, ImageConfig{Metadata: actual}) + if e != nil || !reflect.DeepEqual(want, got) { + return fail("configuration_changed") + } + return nil +} +func validateActualRuntime(ctx context.Context, parser config.JSONC, policy DriverPolicy, c Container) error { + if c.User != policy.ContainerUser || c.UsernsMode != "" { + return fail("runtime_conflict") + } + return verifyMetadata(ctx, parser, policy.RuntimeMetadata, c.Labels["devcontainer.metadata"]) +} +func (p *nativePort) classified(c Call) error { + p.stateMu.Lock() + policy := copyPolicy(p.policy) + p.stateMu.Unlock() + if c.Scope != p.scope { + return fail("provider_refused") + } + var args []string + switch c.Kind { + case "run": + if c.Run == nil { + return fail("provider_refused") + } + r := c.Run + args = []string{"run", "--sig-proxy=false", "-a", "STDOUT", "-a", "STDERR", "--mount", r.Mount} + args = appendPairs(args, "-l", r.Labels) + args = appendPairs(args, "-e", r.Env) + args = append(args, "-u", r.User, "--name", r.Name, "--entrypoint", "/bin/sh", r.Image) + args = append(args, r.Command...) + case "discovery": + args = []string{"ps", "-q", "-a"} + for _, label := range p.scope.LabelList() { + args = append(args, "--filter", "label="+label) + } + case "events": + args = []string{"events", "--format", "{{json .}}", "--filter", "event=start"} + default: + var e error + args, e = nativeArguments(c, "") + if e != nil { + return e + } + } + expected, e := Classify(policy, args) + if e != nil { + return e + } + expected.Scope = p.scope + if c.Kind == "run" { + expected.Run.ImageID = c.Run.ImageID + } + if !reflect.DeepEqual(expected, c) { + return fail("provider_refused") + } + return nil +} + +// ObserveAllocation never adopts from a label/name match. The initial target +// comes only from the same live native run's CID plus complete fresh inspection. +func (p *nativePort) ObserveAllocation(ctx context.Context, candidates []Container) (*Target, error) { + if e := p.authorize(ctx); e != nil { + return nil, e + } + p.stateMu.Lock() + a, target, policy := p.allocation, p.target, copyPolicy(p.policy) + p.stateMu.Unlock() + if target != nil { + actual, e := VerifyTarget(*target, candidates) + if e != nil { + return nil, e + } + if e = validateActualRuntime(ctx, p.parser, policy, actual); e != nil { + return nil, e + } + copy := *target + return ©, nil + } + if a == nil || len(candidates) == 0 { + return nil, nil + } + if len(candidates) != 1 { + return nil, fail("runtime_conflict") + } + if e := validateActualRuntime(ctx, p.parser, policy, candidates[0]); e != nil { + return nil, e + } + observed, e := a.Observe(ctx, candidates) + if e != nil { + return nil, e + } + p.stateMu.Lock() + defer p.stateMu.Unlock() + if p.target != nil && *p.target != observed { + return nil, fail("runtime_conflict") + } + if p.target == nil { + p.target = &observed + } + if p.policy.KnownIDs == nil { + p.policy.KnownIDs = map[string]bool{} + } + p.policy.KnownIDs[observed.ContainerID] = true + copy := observed + return ©, nil +} +func (p *nativePort) freshState(ctx context.Context) ([]Container, error) { + info, e := p.Information(ctx) + if e != nil { + return nil, e + } + if !supportedEngine(info) { + return nil, fail("unsupported_configuration") + } + return p.Candidates(ctx, p.scope) +} +func (p *nativePort) InvokeStream(ctx context.Context, c Call, in io.Reader, out io.Writer) error { + if e := p.authorize(ctx); e != nil { + return e + } + if e := p.classified(c); e != nil { + return e + } + if p.stream == nil { + return fail("provider_unavailable") + } + candidates, e := p.freshState(ctx) + if e != nil { + return e + } + var cid string + switch c.Kind { + case "run": + if len(candidates) != 0 { + return fail("runtime_conflict") + } + p.stateMu.Lock() + expected, ok := p.images[c.Run.Image] + existing := p.allocation != nil || p.target != nil + p.stateMu.Unlock() + if !ok || expected.ID != c.Run.ImageID { + return fail("configuration_changed") + } + if existing { + return fail("runtime_unknown") + } + if _, e = p.requireLocal(ctx, c.Run.Image, expected); e != nil { + return e + } + metadata, present := c.Run.Labels["devcontainer.metadata"] + if !present { + metadata = expected.Config.Metadata + } + if e = verifyMetadata(ctx, p.parser, p.policy.RuntimeMetadata, metadata); e != nil { + return e + } + if p.lifetime == nil { + return fail("provider_unavailable") + } + p.stateMu.Lock() + if p.allocation != nil || p.target != nil { + p.stateMu.Unlock() + return fail("runtime_unknown") + } + a, err := reserveAllocation(ctx, p.lifetime.root, p.scope, p.policy.DataPath, expected.ID, p.check) + if err == nil { + err = a.Begin(ctx) + } + if err != nil { + if a != nil { + a.Close() + } + p.stateMu.Unlock() + return err + } + p.allocation = a + cid = a.cid + p.stateMu.Unlock() + defer a.End() + case "exec", "start", "container_inspect": + target, err := p.ObserveAllocation(ctx, candidates) + if err != nil { + return err + } + if target == nil { + return fail("runtime_unknown") + } + id := "" + if c.Exec != nil { + id = c.Exec.ContainerID + } else if len(c.IDs) == 1 { + id = c.IDs[0] + } + if id != target.ContainerID { + return fail("runtime_conflict") + } + case "events": + if len(candidates) != 0 { + if target, err := p.ObserveAllocation(ctx, candidates); err != nil || target == nil { + if err != nil { + return err + } + return fail("runtime_unknown") + } + } + case "pull": + if len(candidates) != 0 { + return fail("runtime_conflict") + } + if _, e = p.Image(ctx, c.Reference); e != nil { + return e + } + case "build": + if len(candidates) != 0 { + return fail("runtime_conflict") + } + return p.build(ctx, c, in, out) + default: + raw, err := p.Invoke(ctx, c) + if err != nil { + return err + } + _, err = out.Write(raw) + return err + } + args, e := nativeArguments(c, cid) + if e != nil { + return e + } + if e = p.authorize(ctx); e != nil { + return e + } + if e = p.stream(ctx, args, in, out); e != nil { + return e + } + if c.Kind == "pull" { + p.stateMu.Lock() + expected := p.images[c.Reference] + p.stateMu.Unlock() + _, e = p.requireLocal(ctx, c.Reference, expected) + return e + } + return nil +} +func (p *nativePort) Close() error { + p.stateMu.Lock() + a := p.allocation + p.stateMu.Unlock() + if a != nil { + a.End() + } + if p.lifetime != nil { + return p.lifetime.Close() + } + return nil +} diff --git a/apps/host_agent/internal/providers/docker/native_execution_test.go b/apps/host_agent/internal/providers/docker/native_execution_test.go new file mode 100644 index 00000000..2f4e6d0e --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_execution_test.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "strings" + "testing" + "time" +) + +func nativeExecutionFixture(t *testing.T) (*nativePort, Call, context.Context) { + t.Helper() + p := policyFixture(Resources) + p.RuntimeMetadata = `[{"userEnvProbe":"none","updateRemoteUserUID":false}]` + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + port := &nativePort{scope: p.Scope, policy: p, check: func(context.Context) error { return nil }, parser: registryParser(t), lifetime: &nativeLifetime{ctx: ctx, cancel: cancel, root: bridgeRoot(t)}} + image := AdmittedImage{ID: "sha256:" + strings.Repeat("b", 64), Config: ImageConfig{}} + ref := imageSource()["image"].(string) + port.images = map[string]AdmittedImage{ref: image} + port.query = func(_ context.Context, args []string) ([]byte, error) { + switch args[0] { + case "ps": + return nil, nil + case "info": + return []byte(`{"OSType":"linux","Architecture":"arm64","ServerVersion":"29.8.2","SecurityOptions":[]}`), nil + case "inspect": + raw, _ := json.Marshal([]any{map[string]any{"Id": image.ID, "Architecture": "arm64", "Os": "linux", "Config": map[string]any{}, "RootFS": map[string]any{"Type": "layers", "Layers": []any{}}}}) + return raw, nil + } + return nil, fail("provider_refused") + } + call, e := Classify(p, runFixture(p)) + if e != nil { + t.Fatal(e) + } + call.Run.Labels["devcontainer.metadata"] = p.RuntimeMetadata + call.Scope = p.Scope + call.Run.ImageID = image.ID + return port, call, ctx +} +func TestNativeRunUsesVerifiedIDNeverPullAndBindsOnlyLiveCID(t *testing.T) { + p, c, ctx := nativeExecutionFixture(t) + forwarded := 0 + p.stream = func(ctx context.Context, args []string, _ io.Reader, _ io.Writer) error { + forwarded++ + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--pull never --cidfile ") || !strings.Contains(joined, c.Run.ImageID) || strings.Contains(joined, c.Run.Image+" ") { + t.Fatal("native run identity", args) + } + a := p.allocation + if a == nil { + t.Fatal("run not reserved") + } + if _, e := os.Lstat(a.cid); !os.IsNotExist(e) { + t.Fatal("precreated cid") + } + id := strings.Repeat("a", 64) + os.WriteFile(a.cid, []byte(id), 0644) + target, e := p.ObserveAllocation(ctx, []Container{{ID: id, Name: "/" + p.scope.Name(), Image: c.Run.ImageID, User: p.policy.ContainerUser, Labels: c.Run.Labels, Mounts: []Mount{{Type: "bind", Source: p.policy.DataPath, Destination: "/workspace", RW: true}}}}) + if e != nil || target == nil || target.ContainerID != id { + t.Fatal("live identity binding", target, e) + } + return nil + } + if e := p.InvokeStream(ctx, c, bytes.NewReader(nil), io.Discard); e != nil { + t.Fatal(e) + } + if forwarded != 1 { + t.Fatal("run not forwarded") + } + if e := p.InvokeStream(ctx, c, nil, io.Discard); e == nil || forwarded != 1 { + t.Fatal("run replayed") + } + if _, e := p.allocation.Observe(ctx, nil); e == nil { + t.Fatal("closed allocation reopened") + } +} +func TestNativeRunMissingImageWrongIdentityAndMountRefuseBeforeForward(t *testing.T) { + for _, kind := range []string{"missing-image", "wrong-image-id", "wrong-user", "foreign-mount"} { + t.Run(kind, func(t *testing.T) { + p, c, ctx := nativeExecutionFixture(t) + effects := 0 + p.stream = func(context.Context, []string, io.Reader, io.Writer) error { effects++; return nil } + switch kind { + case "missing-image": + p.query = func(_ context.Context, a []string) ([]byte, error) { + if a[0] == "ps" { + return nil, nil + } + if a[0] == "info" { + return []byte(`{"OSType":"linux","Architecture":"arm64","ServerVersion":"29.8.2","SecurityOptions":[]}`), nil + } + return nil, fail("metadata_unavailable") + } + case "wrong-image-id": + c.Run.ImageID = "sha256:" + strings.Repeat("c", 64) + case "wrong-user": + c.Run.User = "0:1" + case "foreign-mount": + c.Run.Mount = "type=bind,source=/var/run/docker.sock,target=/workspace" + } + if e := p.InvokeStream(ctx, c, nil, io.Discard); e == nil || effects != 0 { + t.Fatal("unsafe native run forwarded", e, effects) + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/native_image.go b/apps/host_agent/internal/providers/docker/native_image.go new file mode 100644 index 00000000..5a5fc177 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_image.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "reflect" + "strings" +) + +// localImage is post-pull/build corroboration, never raw registry authority. +// Its ID must equal the independently frozen config digest/build IID witness. +func (p *nativePort) localImage(ctx context.Context, ref string) (AdmittedImage, []byte, error) { + raw, e := p.read(ctx, []string{"inspect", "--type", "image", ref}) + if e != nil { + return AdmittedImage{}, nil, e + } + value, e := wirejson.Parse(raw) + if e != nil { + return AdmittedImage{}, nil, fail("metadata_unavailable") + } + values, ok := value.([]any) + if !ok || len(values) != 1 { + return AdmittedImage{}, nil, fail("metadata_unavailable") + } + m, ok := values[0].(map[string]any) + if !ok || !canonicalDockerKeys(m, []string{"Id", "Architecture", "Os", "Config", "RootFS"}) { + return AdmittedImage{}, nil, fail("metadata_unavailable") + } + id, idOK := stringField(m, "Id") + arch, archOK := stringField(m, "Architecture") + os, osOK := stringField(m, "Os") + cfg, cfgOK := objectField(m, "Config") + rootfs, rootOK := objectField(m, "RootFS") + if !idOK || !archOK || !osOK || !cfgOK || !rootOK || !strings.HasPrefix(id, "sha256:") || !hexID.MatchString(strings.TrimPrefix(id, "sha256:")) || os != "linux" || (arch != "arm64" && arch != "amd64") { + return AdmittedImage{}, nil, fail("metadata_unavailable") + } + image, e := admitDockerConfig(ctx, p.parser, cfg) + if e != nil { + return AdmittedImage{}, nil, e + } + return AdmittedImage{ID: id, Config: image, Document: map[string]any{"architecture": arch, "os": os, "config": clone(cfg), "rootfs": clone(rootfs)}}, raw, nil +} +func (p *nativePort) Image(ctx context.Context, ref string) (AdmittedImage, error) { + if e := p.authorize(ctx); e != nil { + return AdmittedImage{}, e + } + p.stateMu.Lock() + expected, ok := p.images[ref] + p.stateMu.Unlock() + if !ok { + return AdmittedImage{}, fail("configuration_changed") + } + if _, _, digest := registryRef(ref); digest { + if p.registry == nil { + return AdmittedImage{}, fail("metadata_unavailable") + } + actual, e := p.registry.Inspect(ctx, ref) + if e != nil { + return AdmittedImage{}, e + } + if !reflect.DeepEqual(actual, expected) { + return AdmittedImage{}, fail("configuration_changed") + } + return actual, nil + } + actual, _, e := p.localImage(ctx, ref) + if e != nil { + return AdmittedImage{}, e + } + if !reflect.DeepEqual(actual, expected) { + return AdmittedImage{}, fail("configuration_changed") + } + return actual, nil +} +func (p *nativePort) requireLocal(ctx context.Context, ref string, expected AdmittedImage) ([]byte, error) { + actual, raw, e := p.localImage(ctx, ref) + if e != nil { + return nil, e + } + // The digest binds original raw bytes; Docker may normalize empty collections + // in inspect. Never normalize the raw registry input before its own admission. + if actual.ID != expected.ID || actual.Config.Metadata != expected.Config.Metadata || len(actual.Config.Volumes) != 0 || len(actual.Config.OnBuild) != 0 { + return nil, fail("configuration_changed") + } + return raw, nil +} + +func imageCommand(image AdmittedImage) []string { + out := []string{} + cfg, ok := image.Document["config"].(map[string]any) + if !ok { + return out + } + for _, key := range []string{"Entrypoint", "Cmd"} { + if values, ok := cfg[key].([]any); ok { + for _, value := range values { + if text, ok := value.(string); ok { + out = append(out, text) + } + } + } + } + return out +} diff --git a/apps/host_agent/internal/providers/docker/native_image_test.go b/apps/host_agent/internal/providers/docker/native_image_test.go new file mode 100644 index 00000000..ebc22aff --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_image_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func localImageFixture(t *testing.T) (*nativePort, map[string]any) { + t.Helper() + m := map[string]any{"Id": "sha256:" + strings.Repeat("b", 64), "Architecture": "arm64", "Os": "linux", "Config": map[string]any{"User": "image-default", "Labels": map[string]any{"devcontainer.metadata": `[{"userEnvProbe":"none","updateRemoteUserUID":false}]`}}, "RootFS": map[string]any{"Type": "layers", "Layers": []any{}}} + p := &nativePort{scope: scopeFixture(), check: func(context.Context) error { return nil }, parser: registryParser(t)} + p.query = func(context.Context, []string) ([]byte, error) { raw, _ := json.Marshal([]any{m}); return raw, nil } + return p, m +} +func TestNativeLocalImageRequiresExactIDAndStrictActualMetadata(t *testing.T) { + p, m := localImageFixture(t) + im, raw, e := p.localImage(context.Background(), "vsc-fixture") + if e != nil || im.ID != m["Id"] || len(raw) == 0 || im.Config.Metadata == "" { + t.Fatal("local proof missing", im, e) + } + for _, bad := range []map[string]any{{"OnBuild": []any{"RUN hook"}}, {"Volumes": map[string]any{"/workspace": map[string]any{}}}, {"Labels": map[string]any{"devcontainer.metadata": "{bad"}}} { + m["Config"] = bad + if _, _, e = p.localImage(context.Background(), "vsc-fixture"); e == nil { + t.Fatal("unsafe final image admitted") + } + } +} +func TestNativeBuiltTagCannotRetargetFrozenFinalImage(t *testing.T) { + p, m := localImageFixture(t) + expected, _, e := p.localImage(context.Background(), "vsc-fixture") + if e != nil { + t.Fatal(e) + } + p.images = map[string]AdmittedImage{"vsc-fixture": expected} + if _, e = p.Image(context.Background(), "vsc-fixture"); e != nil { + t.Fatal(e) + } + m["Id"] = "sha256:" + strings.Repeat("c", 64) + if _, e = p.Image(context.Background(), "vsc-fixture"); e == nil { + t.Fatal("retargeted image adopted") + } +} diff --git a/apps/host_agent/internal/providers/docker/native_live_reads_test.go b/apps/host_agent/internal/providers/docker/native_live_reads_test.go new file mode 100644 index 00000000..f6969213 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_live_reads_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" +) + +func TestNativeLiveCIDEnablesExactOfficialDiscoveryAndInspection(t *testing.T) { + p, run, ctx := nativeExecutionFixture(t) + a, e := reserveAllocation(ctx, p.lifetime.root, p.scope, p.policy.DataPath, run.Run.ImageID, p.check) + if e != nil { + t.Fatal(e) + } + defer a.Close() + if e = a.Begin(ctx); e != nil { + t.Fatal(e) + } + p.allocation = a + id := strings.Repeat("a", 64) + if e = os.WriteFile(a.cid, []byte(id), 0644); e != nil { + t.Fatal(e) + } + c := nativeContainerFixture() + c["Config"].(map[string]any)["User"] = p.policy.ContainerUser + c["Config"].(map[string]any)["Labels"] = run.Run.Labels + c["Mounts"] = []any{map[string]any{"Type": "bind", "Source": p.policy.DataPath, "Destination": "/workspace", "RW": true}} + raw, _ := json.Marshal([]any{c}) + old := p.query + p.query = func(ctx context.Context, args []string) ([]byte, error) { + if args[0] == "ps" { + return []byte(id + "\n"), nil + } + if args[0] == "inspect" && args[2] == "container" { + return raw, nil + } + return old(ctx, args) + } + gateway := &Gateway{Parser: p.parser, Policy: copyPolicy(p.policy), Port: p, Authorize: p.check, Images: copyImages(p.images)} + discovery := []string{"ps", "-q", "-a"} + for _, l := range p.scope.LabelList() { + discovery = append(discovery, "--filter", "label="+l) + } + out, e := gateway.Forward(ctx, discovery) + if e != nil || string(out) != id+"\n" { + t.Fatal("live allocation undiscoverable", string(out), e) + } + out, e = gateway.Forward(ctx, []string{"inspect", "--type", "container", id}) + if e != nil || len(out) == 0 { + t.Fatal("exact live inspection unavailable", e) + } + a.End() + if gateway.Target == nil || gateway.Target.ContainerID != id { + t.Fatal("target not frozen") + } + // Once bound, a foreign label/name collision must refuse instead of returning a first match. + p.query = func(ctx context.Context, args []string) ([]byte, error) { + if args[0] == "ps" { + return []byte(id + "\n" + strings.Repeat("c", 64) + "\n"), nil + } + return old(ctx, args) + } + if _, e = gateway.Forward(ctx, discovery); e == nil { + t.Fatal("ambiguous discovery accepted") + } +} +func TestFrozenPolicyDoesNotShareMutableAdmissionMaps(t *testing.T) { + original := policyFixture(Resources) + frozen := copyPolicy(original) + original.KnownIDs[strings.Repeat("d", 64)] = true + original.Build.Args["BUILDKIT_SYNTAX"] = "evil" + original.ContainerEnv["LEAK"] = "secret" + if len(frozen.KnownIDs) != 1 || frozen.Build.Args["BUILDKIT_SYNTAX"] != "" || frozen.ContainerEnv["LEAK"] != "" { + t.Fatal("mutable caller retargeted frozen policy") + } +} diff --git a/apps/host_agent/internal/providers/docker/native_open.go b/apps/host_agent/internal/providers/docker/native_open.go new file mode 100644 index 00000000..141815f3 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_open.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" +) + +// nativeOptions is trusted host setup, never protocol/config-repository input. +// Opening prepares private host plumbing only; no Docker mutation is performed. +type nativeOptions struct { + Policy DriverPolicy + Images map[string]AdmittedImage + Registry *Registry + Parser config.JSONC + Target *Target + CheckBuild func(context.Context, BuildCall) error + DockerPath, Socket, BuildxPath, PrivateDirectory string + Scope Scope + Check func(context.Context) error +} +type nativeLifetime struct { + once sync.Once + err error + mu sync.Mutex + closed bool + active sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + root, program, socket string + env []string +} + +func physicalExecutable(path string) bool { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsAny(path, "\x00\r\n") { + return false + } + actual, e := filepath.EvalSymlinks(path) + info, se := os.Lstat(path) + return e == nil && se == nil && actual == path && info.Mode().IsRegular() && info.Mode().Perm()&0111 != 0 && info.Mode().Perm()&0022 == 0 +} +func (l *nativeLifetime) enter(ctx context.Context) (context.Context, func(), error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed || l.ctx.Err() != nil || ctx.Err() != nil { + return nil, nil, fail("provider_unavailable") + } + l.active.Add(1) + child, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(l.ctx, cancel) + return child, func() { stop(); cancel(); l.active.Done() }, nil +} +func (l *nativeLifetime) arguments(args []string) []string { + return append([]string{"--config", filepath.Join(l.root, "docker"), "--host", "unix://" + l.socket}, args...) +} +func (l *nativeLifetime) Close() error { + l.once.Do(func() { + l.mu.Lock() + l.closed = true + l.cancel() + l.mu.Unlock() + l.active.Wait() + if !ownedDirectory(l.root) { + l.err = fail("provider_unavailable") + return + } + l.err = os.RemoveAll(l.root) + }) + return l.err +} + +func openNativePort(ctx context.Context, o nativeOptions) (*nativePort, error) { + if o.Check == nil { + return nil, fail("forbidden") + } + if e := o.Check(ctx); e != nil { + return nil, e + } + if _, ok := ctx.Deadline(); !ok || ctx.Err() != nil || runtime.GOOS != "linux" || (runtime.GOARCH != "arm64" && runtime.GOARCH != "amd64") || !o.Scope.valid() || !physicalExecutable(o.DockerPath) || !nativeDockerSocket(o.Socket) { + return nil, fail("provider_unavailable") + } + if o.Target != nil { + target := *o.Target + o.Target = &target + } + root, e := registryPrivate(o.PrivateDirectory) + if e != nil { + return nil, e + } + failed := true + defer func() { + if failed { + os.RemoveAll(root) + } + }() + env := append(registryEnvironment(root, o.DockerPath), "DOCKER_BUILDKIT=1") + version, e := captureMetadata(ctx, o.DockerPath, []string{"-v"}, root, env, 1024) + if e != nil || !strings.HasPrefix(strings.TrimSpace(string(version)), "Docker version 29.8.2,") { + return nil, fail("tool_unavailable") + } + var pins struct{ Buildx map[string]string } + if json.Unmarshal(toolPins, &pins) != nil { + return nil, fail("tool_unavailable") + } + if e = verifyRegistryTool(ctx, o.BuildxPath, pins.Buildx["linux_"+runtime.GOARCH+"_sha256"], pins.Buildx["version"], root, true); e != nil { + return nil, e + } + // Docker's plugin lookup is confined to the private config, with pinned bytes. + plugins := filepath.Join(root, "docker", "cli-plugins") + if os.Mkdir(plugins, 0700) != nil { + return nil, fail("tool_unavailable") + } + raw, e := os.ReadFile(o.BuildxPath) + sum := sha256.Sum256(raw) + if e != nil || hex.EncodeToString(sum[:]) != pins.Buildx["linux_"+runtime.GOARCH+"_sha256"] { + return nil, fail("tool_unavailable") + } + plugin := filepath.Join(plugins, "docker-buildx") + f, e := os.OpenFile(plugin, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0700) + if e != nil { + return nil, fail("tool_unavailable") + } + _, we := f.Write(raw) + se := f.Sync() + ce := f.Close() + if we != nil || se != nil || ce != nil { + return nil, fail("tool_unavailable") + } + parent, cancel := context.WithCancel(ctx) + life := &nativeLifetime{ctx: parent, cancel: cancel, root: root, program: o.DockerPath, socket: o.Socket, env: env} + p := &nativePort{scope: o.Scope, check: o.Check, lifetime: life, policy: copyPolicy(o.Policy), images: copyImages(o.Images), registry: o.Registry, parser: o.Parser, target: o.Target, checkBuild: o.CheckBuild} + p.query = func(ctx context.Context, args []string) ([]byte, error) { + child, done, e := life.enter(ctx) + if e != nil { + return nil, e + } + defer done() + if e = o.Check(child); e != nil { + return nil, e + } + return captureMetadata(child, life.program, life.arguments(args), life.root, life.env, metadataLimit) + } + p.stream = func(ctx context.Context, args []string, in io.Reader, out io.Writer) error { + child, done, e := life.enter(ctx) + if e != nil { + return e + } + defer done() + if e = o.Check(child); e != nil { + return e + } + return nativeProcess(child, life.program, life.arguments(args), life.root, life.env, in, out) + } + if e = o.Check(ctx); e != nil { + life.Close() + return nil, e + } + failed = false + return p, nil +} diff --git a/apps/host_agent/internal/providers/docker/native_port.go b/apps/host_agent/internal/providers/docker/native_port.go new file mode 100644 index 00000000..8332550b --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_port.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "io" + "sort" + "strings" + "sync" +) + +type nativePort struct { + stateMu sync.Mutex + buildStarted bool + policy DriverPolicy + images map[string]AdmittedImage + registry *Registry + parser config.JSONC + target *Target + allocation *allocation + checkBuild func(context.Context, BuildCall) error + lifetime *nativeLifetime + stream func(context.Context, []string, io.Reader, io.Writer) error + scope Scope + check func(context.Context) error + query func(context.Context, []string) ([]byte, error) +} + +func (p *nativePort) authorize(ctx context.Context) error { + if e := ctx.Err(); e != nil { + return e + } + if p.check == nil || p.query == nil || !p.scope.valid() { + return fail("forbidden") + } + return p.check(ctx) +} +func (p *nativePort) read(ctx context.Context, args []string) ([]byte, error) { + if e := p.authorize(ctx); e != nil { + return nil, e + } + return p.query(ctx, args) +} +func candidateIDs(raw []byte) ([]string, error) { + text := strings.TrimSuffix(string(raw), "\n") + if text == "" { + return []string{}, nil + } + out := []string{} + seen := map[string]bool{} + for _, id := range strings.Split(text, "\n") { + if !hexID.MatchString(id) || seen[id] { + return nil, fail("runtime_unknown") + } + seen[id] = true + out = append(out, id) + } + return out, nil +} + +// Candidates independently queries the union, not complete-label intersections: +// partial and foreign deterministic-name/runtime-label collisions must be visible. +func (p *nativePort) Candidates(ctx context.Context, scope Scope) ([]Container, error) { + if e := p.authorize(ctx); e != nil { + return nil, e + } + if scope != p.scope { + return nil, fail("runtime_conflict") + } + union := map[string]bool{} + for _, filter := range []string{"name=^/" + scope.Name() + "$", "label=workharbor.runtime=" + scope.RuntimeID} { + raw, e := p.read(ctx, []string{"ps", "-aq", "--no-trunc", "--filter", filter}) + if e != nil { + return nil, e + } + ids, e := candidateIDs(raw) + if e != nil { + return nil, e + } + for _, id := range ids { + union[id] = true + } + } + ids := make([]string, 0, len(union)) + for id := range union { + ids = append(ids, id) + } + sort.Strings(ids) + if len(ids) == 0 { + return []Container{}, nil + } + raw, e := p.read(ctx, append([]string{"inspect", "--type", "container"}, ids...)) + if e != nil { + return nil, e + } + all, e := nativeContainers(raw) + if e != nil || len(all) != len(ids) { + return nil, fail("runtime_unknown") + } + seen := map[string]bool{} + for _, c := range all { + if !union[c.ID] || seen[c.ID] { + return nil, fail("runtime_unknown") + } + seen[c.ID] = true + } + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + return all, nil +} +func (p *nativePort) Information(ctx context.Context) (EngineInfo, error) { + raw, e := p.read(ctx, []string{"info", "--format", "{{json .}}"}) + if e != nil { + return EngineInfo{}, e + } + return nativeInformation(raw) +} + +func (p *nativePort) Invoke(ctx context.Context, c Call) ([]byte, error) { + if e := p.authorize(ctx); e != nil { + return nil, e + } + if c.Scope != p.scope { + return nil, fail("provider_refused") + } + switch c.Kind { + case "version", "engine_version", "buildx_version": + args, e := nativeArguments(c, "") + if e != nil { + return nil, e + } + return p.read(ctx, args) + case "image_inspect": + if e := p.classified(c); e != nil { + return nil, e + } + expected, e := p.Image(ctx, c.Reference) + if e != nil { + return nil, e + } + return p.requireLocal(ctx, c.Reference, expected) + case "discovery", "container_inspect": + if e := p.classified(c); e != nil { + return nil, e + } + all, e := p.freshState(ctx) + if e != nil { + return nil, e + } + target, e := p.ObserveAllocation(ctx, all) + if e != nil { + return nil, e + } + if c.Kind == "discovery" { + if target == nil { + if len(all) != 0 { + return nil, fail("runtime_conflict") + } + return []byte{}, nil + } + return []byte(target.ContainerID + "\n"), nil + } + if target == nil || len(c.IDs) != 1 || c.IDs[0] != target.ContainerID { + return nil, fail("runtime_conflict") + } + raw, e := p.read(ctx, []string{"inspect", "--type", "container", target.ContainerID}) + if e != nil { + return nil, e + } + fresh, e := nativeContainers(raw) + if e != nil { + return nil, e + } + actual, e := VerifyTarget(*target, fresh) + if e != nil { + return nil, e + } + p.stateMu.Lock() + policy := copyPolicy(p.policy) + p.stateMu.Unlock() + if e = validateActualRuntime(ctx, p.parser, policy, actual); e != nil { + return nil, e + } + return raw, nil + default: + return nil, fail("provider_unavailable") + } +} diff --git a/apps/host_agent/internal/providers/docker/native_port_test.go b/apps/host_agent/internal/providers/docker/native_port_test.go new file mode 100644 index 00000000..d7aa7c62 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_port_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" +) + +func TestNativeCandidatesQueriesNameAndRuntimeUnionIncludingForeignPartial(t *testing.T) { + scope := policyFixture(Resources).Scope + first, second := strings.Repeat("a", 64), strings.Repeat("c", 64) + calls := [][]string{} + p := &nativePort{scope: scope, check: func(context.Context) error { return nil }, query: func(_ context.Context, args []string) ([]byte, error) { + calls = append(calls, append([]string{}, args...)) + if args[0] == "ps" { + if strings.HasPrefix(args[len(args)-1], "name=") { + return []byte(first + "\n"), nil + } + return []byte(first + "\n" + second + "\n"), nil + } + a, b := nativeContainerFixture(), nativeContainerFixture() + b["Id"] = second + b["Name"] = "/foreign" + b["Config"].(map[string]any)["Labels"] = map[string]any{"workharbor.runtime": scope.RuntimeID} + raw, _ := json.Marshal([]any{a, b}) + return raw, nil + }} + all, e := p.Candidates(context.Background(), scope) + if e != nil || len(all) != 2 || all[1].ID != second || all[1].Name != "/foreign" { + t.Fatal("union incomplete", all, e) + } + if len(calls) != 3 || !strings.HasPrefix(calls[0][len(calls[0])-1], "name=^") || calls[1][len(calls[1])-1] != "label=workharbor.runtime="+scope.RuntimeID || !reflect.DeepEqual(calls[2], []string{"inspect", "--type", "container", first, second}) { + t.Fatal(calls) + } +} +func TestNativeReadAuthorizationAndInspectionExactSet(t *testing.T) { + scope := policyFixture(Resources).Scope + calls := 0 + p := &nativePort{scope: scope, check: func(context.Context) error { return errors.New("denied") }, query: func(context.Context, []string) ([]byte, error) { calls++; return []byte(`[]`), nil }} + if _, e := p.Candidates(context.Background(), scope); e == nil || calls != 0 { + t.Fatal("unauthorized read") + } + if _, e := p.Information(context.Background()); e == nil || calls != 0 { + t.Fatal("unauthorized info") + } + p.check = func(context.Context) error { return nil } + p.query = func(_ context.Context, args []string) ([]byte, error) { + if args[0] == "ps" { + return []byte(strings.Repeat("a", 64) + "\n"), nil + } + return []byte(`[]`), nil + } + if _, e := p.Candidates(context.Background(), scope); e == nil { + t.Fatal("missing inspected candidate treated absent") + } + foreign := scope + foreign.OwnerID = "00000000-0000-4000-8000-000000000099" + if _, e := p.Candidates(context.Background(), foreign); e == nil { + t.Fatal("foreign scope read") + } +} + +func TestNativeInvokeReconstructsOnlyTypedScopeBoundReads(t *testing.T) { + scope := policyFixture(Resources).Scope + calls := [][]string{} + p := &nativePort{scope: scope, check: func(context.Context) error { return nil }, query: func(_ context.Context, args []string) ([]byte, error) { + calls = append(calls, args) + return []byte("Docker version 29.8.2, build fixture\n"), nil + }} + raw, e := p.Invoke(context.Background(), Call{Scope: scope, Kind: "version"}) + if e != nil || len(raw) == 0 || !reflect.DeepEqual(calls, [][]string{{"-v"}}) { + t.Fatal("typed read unavailable", e, calls) + } + for _, call := range []Call{{Scope: scope, Kind: "arbitrary"}, {Kind: "version"}} { + if _, e = p.Invoke(context.Background(), call); e == nil { + t.Fatal("unscoped/unknown call") + } + } + if len(calls) != 1 { + t.Fatal("unscoped call forwarded") + } +} diff --git a/apps/host_agent/internal/providers/docker/native_process.go b/apps/host_agent/internal/providers/docker/native_process.go new file mode 100644 index 00000000..62380000 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_process.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "io" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode/utf8" +) + +// nativeProcess is host-client plumbing used only after typed reconstruction. +// It returns the fully waited owned client outcome, never daemon quiescence, +// official-up completion or a lifecycle receipt. Stderr never exports secrets. +func nativeProcess(ctx context.Context, program string, args []string, dir string, env []string, in io.Reader, out io.Writer) error { + if _, ok := ctx.Deadline(); !ok || ctx.Err() != nil || !filepath.IsAbs(program) || filepath.Clean(program) != program || !ownedDirectory(dir) { + return fail("provider_refused") + } + for _, arg := range args { + if !utf8.ValidString(arg) || strings.ContainsRune(arg, 0) { + return fail("provider_refused") + } + } + command := exec.CommandContext(ctx, program, args...) + command.Dir = dir + command.Env = env + command.Stdin = in + command.Stdout = out + command.Stderr = io.Discard + command.WaitDelay = time.Second + if e := ownedProcessGroup(command); e != nil { + return e + } + if e := command.Run(); e != nil || ctx.Err() != nil { + return fail("provider_unavailable") + } + return nil +} diff --git a/apps/host_agent/internal/providers/docker/native_process_test.go b/apps/host_agent/internal/providers/docker/native_process_test.go new file mode 100644 index 00000000..7330ffb4 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/native_process_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNativeProcessHelper(t *testing.T) { + if os.Getenv("WORKHARBOR_NATIVE_PROCESS_HELPER") != "wait" { + return + } + fmt.Fprint(os.Stdout, "ready\n") + for { + time.Sleep(time.Second) + } +} +func TestNativeOwnedClientStreamsAndCancellationReapsBeforeReturn(t *testing.T) { + program, e := os.Executable() + if e != nil { + t.Fatal(e) + } + program, e = filepath.EvalSymlinks(program) + if e != nil { + t.Fatal(e) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out := ¬ifyWriter{ready: make(chan struct{})} + done := make(chan error, 1) + root := bridgeRoot(t) + go func() { + done <- nativeProcess(ctx, program, []string{"-test.run=^TestNativeProcessHelper$"}, root, []string{"WORKHARBOR_NATIVE_PROCESS_HELPER=wait"}, strings.NewReader(""), out) + }() + select { + case e := <-done: + t.Fatal("owned client did not stream", e) + case <-out.ready: + case <-ctx.Done(): + t.Fatal("stream blocked") + } + cancel() + select { + case e := <-done: + if e == nil { + t.Fatal("cancelled child reported success") + } + case <-time.After(time.Second): + t.Fatal("owned child not killed/reaped") + } + if out.buffer.String() != "ready\n" { + t.Fatal("stdout altered") + } +} +func TestNativeOwnedClientRequiresDeadlineAndAbsoluteProgram(t *testing.T) { + for _, ctx := range []context.Context{context.Background(), func() context.Context { c, cancel := context.WithCancel(context.Background()); cancel(); return c }()} { + if e := nativeProcess(ctx, "relative", nil, "/tmp", nil, nil, os.Stdout); e == nil { + t.Fatal("unbounded/relative execution") + } + } +} diff --git a/apps/host_agent/internal/providers/docker/official_capture_test.go b/apps/host_agent/internal/providers/docker/official_capture_test.go new file mode 100644 index 00000000..8014ae5a --- /dev/null +++ b/apps/host_agent/internal/providers/docker/official_capture_test.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer" +) + +type capturedCLI struct { + Args []string + Dockerfile string +} + +func TestOfficialMutationRefusalCaptureHelper(t *testing.T) { + if os.Getenv("WHR_CAPTURE_DRIVER") != "1" { + return + } + raw, e := base64.StdEncoding.DecodeString(os.Getenv("WHR_CAPTURE_SNAPSHOT")) + if e != nil { + os.Exit(124) + } + var snapshot PreflightDriver + if json.Unmarshal(raw, &snapshot) != nil { + os.Exit(124) + } + split := -1 + for i, a := range os.Args { + if a == "--" { + split = i + break + } + } + if split < 0 { + os.Exit(124) + } + args := os.Args[split+1:] + capture := capturedCLI{Args: args} + if len(args) > 1 && args[0] == "buildx" && args[1] == "build" { + for i, a := range args { + if a == "-f" && i+1 < len(args) { + raw, e := os.ReadFile(args[i+1]) + if e != nil { + os.Exit(124) + } + capture.Dockerfile = string(raw) + } + } + } + raw, _ = json.Marshal(capture) + f, e := os.OpenFile(os.Getenv("WHR_CAPTURE_LOG"), os.O_APPEND|os.O_WRONLY, 0600) + if e != nil { + os.Exit(124) + } + f.Write(append(raw, '\n')) + f.Close() + raw, e = snapshot.Reply(args) + if e != nil { + fmt.Fprintln(os.Stderr, "intentional closed mutation refusal") + os.Exit(125) + } + os.Stdout.Write(raw) + os.Exit(0) +} +func TestPinnedOfficialUpImageAndDockerfileReachClosedMutationGate(t *testing.T) { + for _, kind := range []string{"image", "dockerfile"} { + t.Run(kind, func(t *testing.T) { + root := bridgeRoot(t) + for _, d := range []string{"source/.devcontainer", "source/projects", "operation", "cli", "tmp"} { + if e := os.MkdirAll(filepath.Join(root, d), 0700); e != nil { + t.Fatal(e) + } + } + source := imageSource() + source["containerUser"] = "0:0" + source["remoteUser"] = "1000:1000" + source["overrideCommand"] = true + original := []byte("FROM " + source["image"].(string) + "\nRUN printf fixture > /opt/fixture\n") + if kind == "dockerfile" { + delete(source, "image") + source["build"] = map[string]any{"dockerfile": "Dockerfile", "context": "."} + os.WriteFile(filepath.Join(root, "source/.devcontainer/Dockerfile"), original, 0600) + } + input := Invocation{Phase: Resources, Scope: scopeFixture(), Workspace: filepath.Join(root, "source/projects"), Config: filepath.Join(root, "source/.devcontainer/devcontainer.json"), Override: filepath.Join(root, "operation/override.json"), Driver: filepath.Join(root, "driver"), UserData: filepath.Join(root, "cli"), DataPath: filepath.Join(root, "storage/data")} + raw, _ := json.Marshal(source) + os.WriteFile(input.Config, raw, 0600) + override, e := FullOverride(source, input.Scope, input.DataPath) + if e != nil { + t.Fatal(e) + } + raw, _ = json.Marshal(override) + os.WriteFile(input.Override, raw, 0600) + snapshot, _ := json.Marshal(readonlyFixture()) + log := filepath.Join(root, "capture.jsonl") + os.WriteFile(log, nil, 0600) + script := "#!/bin/sh\nWHR_CAPTURE_DRIVER=1 WHR_CAPTURE_SNAPSHOT=" + shellQuote(base64.StdEncoding.EncodeToString(snapshot)) + " WHR_CAPTURE_LOG=" + shellQuote(log) + " exec " + shellQuote(os.Args[0]) + " -test.run='^TestOfficialMutationRefusalCaptureHelper$' -- \"$@\"\n" + os.WriteFile(input.Driver, []byte(script), 0700) + node, e := exec.LookPath("node") + if e != nil { + t.Fatal(e) + } + toolsRoot, _ := filepath.Abs("../../../tools/devcontainer") + tools := devcontainer.Tools{NodePath: node, Root: toolsRoot} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if e = tools.Verify(ctx, true); e != nil { + t.Fatal(e) + } + args, e := input.Arguments() + if e != nil { + t.Fatal(e) + } + cmd := exec.CommandContext(ctx, node, append([]string{filepath.Join(toolsRoot, "node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js")}, args...)...) + cmd.Dir = input.Workspace + cmd.Env = append(tools.Environment(), "HOME="+root, "TMPDIR="+filepath.Join(root, "tmp"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0") + output, e := cmd.CombinedOutput() + if e == nil { + t.Fatal("mutation refusal unexpectedly succeeded") + } + raw, le := os.ReadFile(log) + if le != nil { + t.Fatal(le) + } + _, metadata, me := ExpectedMetadata(source, nil) + if me != nil { + t.Fatal(me) + } + reached := false + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var call capturedCLI + if json.Unmarshal([]byte(line), &call) != nil { + t.Fatal("bad capture") + } + if kind == "image" && len(call.Args) > 0 && call.Args[0] == "run" { + reached = true + policy := policyFixture(Resources) + policy.Scope = input.Scope + policy.DataPath = input.DataPath + policy.ContainerEnv = map[string]string{} + policy.Build = nil + policy.StartupSHA256 = "" + for i, a := range call.Args { + if a == "-c" && i+1 < len(call.Args) { + sum := sha256.Sum256([]byte(call.Args[i+1])) + policy.StartupSHA256 = hex.EncodeToString(sum[:]) + if policy.StartupSHA256 != "1a63da31389392b4e5ae4276c29a2c9a81b466739351488bef177c13fbcd7813" { + t.Fatal("pinned startup changed", policy.StartupSHA256) + } + } + } + c, ce := Classify(policy, call.Args) + if ce != nil { + t.Fatalf("actual run not admitted: %v argv=%q", ce, call.Args) + } + actual, ae := ValidateImage(ctx, registryParser(t), ImageConfig{Metadata: c.Run.Labels["devcontainer.metadata"]}) + if ae != nil || fmt.Sprint(actual) != fmt.Sprint(metadata) { + t.Fatalf("metadata mismatch actual=%#v expected=%#v error=%v", actual, metadata, ae) + } + t.Logf("measured official startup SHA256=%s", policy.StartupSHA256) + } + if kind == "dockerfile" && call.Dockerfile != "" { + reached = true + policy := policyFixture(Resources) + policy.Scope = input.Scope + policy.Build = &BuildPolicy{Context: filepath.Dir(input.Config), Scratch: filepath.Join(root, "tmp"), Tag: folderImage(input.Workspace), Target: "dev_containers_target_stage", Args: map[string]string{"BUILDKIT_INLINE_CACHE": "1", "_DEV_CONTAINERS_BASE_IMAGE": "dev_container_auto_added_stage_label"}} + if _, ce := Classify(policy, call.Args); ce != nil { + t.Fatalf("actual build argv rejected: %v %q", ce, call.Args) + } + if e = ValidateGeneratedDockerfile(ctx, registryParser(t), original, []byte(call.Dockerfile), "", metadata); e != nil { + t.Fatalf("actual generated file rejected: %v\n%s", e, call.Dockerfile) + } + } + } + if !reached { + t.Fatalf("required mutation gate not reached: captures=%s output=%s", raw, output) + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/official_phase.go b/apps/host_agent/internal/providers/docker/official_phase.go new file mode 100644 index 00000000..5235d7dd --- /dev/null +++ b/apps/host_agent/internal/providers/docker/official_phase.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "path/filepath" + "strings" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" +) + +// PhaseEvidence requires a full normal official CLI process exit and fresh exact +// target inspection. T010 must persist its fenced durable receipt before ack; +// stdout, CID, IID, hook markers and this in-memory value are not durable receipts. +type PhaseEvidence struct { + Phase Phase + Target Target + Descriptor config.Descriptor +} + +func (p *Prepared) Resources(ctx context.Context) (PhaseEvidence, error) { + if e := p.Check(ctx); e != nil { + return PhaseEvidence{}, e + } + p.mu.Lock() + if p.resourcesStarted { + p.mu.Unlock() + return PhaseEvidence{}, fail("phase_unknown") + } + p.resourcesStarted = true + p.mu.Unlock() + // Explicit digest pulls happen only after Prepare completed ALL capability, + // trust, user/daemon and initial-data gates. No inspection silently pulls. + p.port.stateMu.Lock() + refs := []string{} + for ref := range p.port.images { + refs = append(refs, ref) + } + p.port.stateMu.Unlock() + for _, ref := range refs { + if e := p.port.InvokeStream(ctx, Call{Scope: p.admission.Scope(), Kind: "pull", Reference: ref}, nil, discardOutput{}); e != nil { + return PhaseEvidence{}, e + } + } + return p.runPhase(ctx, Resources, "") +} +func (p *Prepared) Hooks(ctx context.Context) (PhaseEvidence, error) { + if e := p.Check(ctx); e != nil { + return PhaseEvidence{}, e + } + p.mu.Lock() + if p.resources == nil || p.hooksStarted { + p.mu.Unlock() + return PhaseEvidence{}, fail("phase_unknown") + } + target := *p.resources + p.hooksStarted = true + p.mu.Unlock() + return p.runPhase(ctx, Hooks, target.ContainerID) +} + +type discardOutput struct{} + +func (discardOutput) Write(p []byte) (int, error) { return len(p), nil } +func (p *Prepared) runPhase(ctx context.Context, phase Phase, id string) (PhaseEvidence, error) { + if p.port == nil || p.port.lifetime == nil { + return PhaseEvidence{}, fail("provider_unavailable") + } + phaseCtx, done, e := p.port.lifetime.enter(ctx) + if e != nil { + return PhaseEvidence{}, e + } + defer done() + ctx = phaseCtx + + if e := p.Check(ctx); e != nil { + return PhaseEvidence{}, e + } + p.port.stateMu.Lock() + policy := copyPolicy(p.port.policy) + policy.Phase = phase + p.port.policy = copyPolicy(policy) + images := copyImages(p.port.images) + var target *Target + if p.port.target != nil { + copy := *p.port.target + target = © + } + p.port.stateMu.Unlock() + if phase == Hooks && (target == nil || target.ContainerID != id) { + return PhaseEvidence{}, fail("runtime_conflict") + } + gateway := &Gateway{Parser: p.port.parser, Policy: policy, Port: p.port, Authorize: p.Check, Target: target, Images: images, CheckBuild: p.port.checkBuild} + driver, e := StartDriver(ctx, gateway, p.root) + if e != nil { + return PhaseEvidence{}, e + } + input := p.invocation + input.Phase = phase + input.ContainerID = id + input.Driver, e = driver.Executable(ctx, p.agent) + if e != nil { + driver.Close() + return PhaseEvidence{}, e + } + args, e := input.Arguments() + if e != nil { + driver.Close() + return PhaseEvidence{}, e + } + if e = p.tools.Verify(ctx, true); e != nil { + driver.Close() + return PhaseEvidence{}, e + } + // Fresh native checks precede even internal CLI exec/start commands. + if phase == Hooks { + current, e := inspectPhaseTarget(ctx, p.port) + if e != nil || current.ContainerID != id { + driver.Close() + if e != nil { + return PhaseEvidence{}, e + } + return PhaseEvidence{}, fail("runtime_conflict") + } + } + if e = p.Check(ctx); e != nil { + driver.Close() + return PhaseEvidence{}, e + } + env := append(p.tools.Environment(), registryEnvironment(p.root, p.tools.NodePath)...) + env = append(env, "TMPDIR="+filepath.Join(p.root, "tmp"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0") + raw, processError := captureMetadata(ctx, p.tools.NodePath, append([]string{filepath.Join(p.tools.Root, "node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js")}, args...), p.root, env, metadataLimit) + closeError := driver.Close() // Cancels/reaps remaining native CLI clients first. + if processError != nil || closeError != nil { + return PhaseEvidence{}, fail("phase_unknown") + } + observed, e := inspectPhaseTarget(ctx, p.port) + if e != nil { + return PhaseEvidence{}, e + } + result, e := phaseResult(raw, phase, observed.ContainerID) + if e != nil { + return PhaseEvidence{}, e + } + if phase == Resources { + if result["remoteUser"] != strings.SplitN(policy.RemoteUser, ":", 2)[0] { + return PhaseEvidence{}, fail("configuration_changed") + } + effective := map[string]any{"configuration": result["configuration"], "mergedConfiguration": result["mergedConfiguration"], "workspace": p.effective["workspace"], "ociAuthDiagnostics": result["ociAuthDiagnostics"]} + if e = ValidateEffective(effective, p.source.Source, p.metadata, input); e != nil { + return PhaseEvidence{}, e + } + } + if phase == Hooks && observed.ContainerID != id { + return PhaseEvidence{}, fail("runtime_conflict") + } + if e = p.Check(ctx); e != nil { + return PhaseEvidence{}, e + } + if phase == Resources { + p.mu.Lock() + copy := observed + p.resources = © + p.mu.Unlock() + } + descriptor, _ := config.DecodeDescriptor(p.source.Descriptor.Map()) + return PhaseEvidence{Phase: phase, Target: observed, Descriptor: descriptor}, nil +} diff --git a/apps/host_agent/internal/providers/docker/owned_other.go b/apps/host_agent/internal/providers/docker/owned_other.go new file mode 100644 index 00000000..1421b002 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/owned_other.go @@ -0,0 +1,14 @@ +//go:build !linux && !darwin + +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import "os" + +func ownedDirectory(string) bool { return false } + +func ownedSocket(string) bool { return false } + +func ownedRegular(os.FileInfo) bool { return false } + +func nativeDockerSocket(string) bool { return false } diff --git a/apps/host_agent/internal/providers/docker/owned_unix.go b/apps/host_agent/internal/providers/docker/owned_unix.go new file mode 100644 index 00000000..5484c2c8 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/owned_unix.go @@ -0,0 +1,53 @@ +//go:build linux || darwin + +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "os" + "path/filepath" + "syscall" +) + +func ownedDirectory(path string) bool { + physical, e := filepath.EvalSymlinks(path) + info, ie := os.Lstat(path) + if e != nil || ie != nil || physical != path || !info.IsDir() || info.Mode().Perm() != 0700 { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Geteuid()) +} + +func ownedSocket(path string) bool { + if !ownedDirectory(filepath.Dir(path)) { + return false + } + info, e := os.Lstat(path) + if e != nil || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0600 { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Geteuid()) && stat.Nlink == 1 +} + +func ownedRegular(info os.FileInfo) bool { + if info == nil || !info.Mode().IsRegular() { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Geteuid()) && stat.Nlink == 1 +} + +func nativeDockerSocket(path string) bool { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return false + } + actual, e := filepath.EvalSymlinks(path) + info, se := os.Lstat(path) + if e != nil || se != nil || actual != path || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm()&0007 != 0 { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && (stat.Uid == 0 || stat.Uid == uint32(os.Geteuid())) && stat.Nlink == 1 +} diff --git a/apps/host_agent/internal/providers/docker/phase_result.go b/apps/host_agent/internal/providers/docker/phase_result.go new file mode 100644 index 00000000..04ef34e5 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/phase_result.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" +) + +// phaseResult validates only a fully waited normal CLI exit's stdout. This +// function alone grants no receipt: final native target inspection is required. +func phaseResult(raw []byte, phase Phase, containerID string) (map[string]any, error) { + value, e := wirejson.Parse(raw) + if e != nil { + return nil, fail("phase_unknown") + } + m, ok := value.(map[string]any) + if !ok || m["outcome"] != "success" { + return nil, fail("phase_unknown") + } + if phase == Hooks { + if !exactKeys(m, []string{"outcome", "result"}) || m["result"] != "done" { + return nil, fail("phase_unknown") + } + return m, nil + } + if phase != Resources || !exactKeys(m, []string{"outcome", "containerId", "remoteUser", "remoteWorkspaceFolder", "configuration", "mergedConfiguration"}, "ociAuthDiagnostics") || m["containerId"] != containerID || !hexID.MatchString(containerID) || m["remoteWorkspaceFolder"] != "/workspace" { + return nil, fail("phase_unknown") + } + if user, ok := m["remoteUser"].(string); !ok || user == "" { + return nil, fail("phase_unknown") + } + for _, key := range []string{"configuration", "mergedConfiguration"} { + if _, ok := m[key].(map[string]any); !ok { + return nil, fail("phase_unknown") + } + } + return m, nil +} +func inspectPhaseTarget(ctx context.Context, p *nativePort) (Target, error) { + all, e := p.freshState(ctx) + if e != nil { + return Target{}, e + } + target, e := p.ObserveAllocation(ctx, all) + if e != nil { + return Target{}, e + } + if target == nil { + return Target{}, fail("phase_unknown") + } + actual, e := VerifyTarget(*target, all) + if e != nil { + return Target{}, e + } + if !actual.Running { + return Target{}, fail("phase_unknown") + } + if e = p.authorize(ctx); e != nil { + return Target{}, e + } + return *target, nil +} diff --git a/apps/host_agent/internal/providers/docker/phase_result_test.go b/apps/host_agent/internal/providers/docker/phase_result_test.go new file mode 100644 index 00000000..342c4773 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/phase_result_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestOfficialPhaseResultIsClosedAndNeverAcceptsMarkersOrPartialSuccess(t *testing.T) { + id := strings.Repeat("a", 64) + valid := map[string]any{"outcome": "success", "containerId": id, "remoteUser": "1000", "remoteWorkspaceFolder": "/workspace", "configuration": map[string]any{}, "mergedConfiguration": map[string]any{}} + raw, _ := json.Marshal(valid) + if _, e := phaseResult(raw, Resources, id); e != nil { + t.Fatal(e) + } + for _, raw := range []string{`{"outcome":"success"}`, `{"outcome":"success","outcome":"error"}`, `{"outcome":"success","result":"skipNonBlocking"}`, `{"outcome":"success","result":"done","unknown":true}`, `{"result":"done"}`, `marker done`, "{\"outcome\":\"success\",\"result\":\"done\"}\n{}"} { + if _, e := phaseResult([]byte(raw), Hooks, id); e == nil { + t.Fatal("incomplete result admitted", raw) + } + } + if _, e := phaseResult([]byte(`{"outcome":"success","result":"done"}`), Hooks, id); e != nil { + t.Fatal(e) + } +} diff --git a/apps/host_agent/internal/providers/docker/preflight_driver.go b/apps/host_agent/internal/providers/docker/preflight_driver.go new file mode 100644 index 00000000..c261439d --- /dev/null +++ b/apps/host_agent/internal/providers/docker/preflight_driver.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "encoding/json" + "sort" + "strings" +) + +// PreflightDriver serves independently validated frozen read snapshots only. Its +// caller must prove candidate absence and installed tools before construction; +// none of these native expected values grants resource mutation authority. +// In particular there is no subprocess or pass-through fallback in this phase. +type PreflightDriver struct { + Scope Scope + EngineVersion, BuildxVersion string + CandidateAbsent bool + Images map[string]map[string]any +} + +func (s Scope) LabelList() []string { + m := s.Labels() + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]string, 0, len(keys)) + for _, key := range keys { + out = append(out, key+"="+m[key]) + } + return out +} +func exactArgs(args []string, want ...string) bool { + if len(args) != len(want) { + return false + } + for i := range args { + if args[i] != want[i] { + return false + } + } + return true +} +func (d PreflightDriver) Reply(args []string) ([]byte, error) { + if !d.Scope.valid() || !d.CandidateAbsent || d.EngineVersion != "29.8.2" || d.BuildxVersion != "v0.38.0" { + return nil, fail("provider_unavailable") + } + switch { + case exactArgs(args, "-v"): + return []byte("Docker version " + d.EngineVersion + "\n"), nil + case exactArgs(args, "version", "--format", "{{.Server.Version}}"): + return []byte(d.EngineVersion + "\n"), nil + case exactArgs(args, "buildx", "version"): + return []byte(d.BuildxVersion + "\n"), nil + case len(args) == 4 && exactArgs(args[:3], "inspect", "--type", "image"): + image, ok := d.Images[args[3]] + if !ok { + return nil, fail("metadata_unavailable") + } + raw, e := json.Marshal([]any{image}) + if e != nil { + return nil, fail("invalid_configuration") + } + return append(raw, '\n'), nil + case len(args) >= 3 && exactArgs(args[:3], "ps", "-q", "-a"): + labels := map[string]bool{} + expected := d.Scope.LabelList() + if len(args) != 3+2*len(expected) { + return nil, fail("provider_refused") + } + for i := 3; i < len(args); i += 2 { + if args[i] != "--filter" || !strings.HasPrefix(args[i+1], "label=") { + return nil, fail("provider_refused") + } + label := strings.TrimPrefix(args[i+1], "label=") + if labels[label] { + return nil, fail("provider_refused") + } + labels[label] = true + } + for _, label := range expected { + if !labels[label] { + return nil, fail("provider_refused") + } + } + return []byte{}, nil + default: + return nil, fail("provider_refused") + } +} diff --git a/apps/host_agent/internal/providers/docker/preflight_driver_test.go b/apps/host_agent/internal/providers/docker/preflight_driver_test.go new file mode 100644 index 00000000..26dd5397 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/preflight_driver_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "encoding/json" + "strings" + "testing" +) + +func readonlyFixture() PreflightDriver { + ref := imageSource()["image"].(string) + return PreflightDriver{Scope: scopeFixture(), EngineVersion: "29.8.2", BuildxVersion: "v0.38.0", CandidateAbsent: true, Images: map[string]map[string]any{ref: {"Id": "sha256:" + strings.Repeat("b", 64), "Os": "linux", "Architecture": "arm64", "Config": map[string]any{"User": "0:0", "Env": []any{}, "Labels": map[string]any{}}}}} +} +func TestReadOnlyDriverKnownImageAndCompleteDiscoveryShapes(t *testing.T) { + d := readonlyFixture() + ref := imageSource()["image"].(string) + raw, e := d.Reply([]string{"inspect", "--type", "image", ref}) + if e != nil { + t.Fatal(e) + } + var images []map[string]any + if json.Unmarshal(raw, &images) != nil || len(images) != 1 || images[0]["Id"] == nil { + t.Fatal("image snapshot lost") + } + args := []string{"ps", "-q", "-a"} + for _, label := range d.Scope.LabelList() { + args = append(args, "--filter", "label="+label) + } + raw, e = d.Reply(args) + if e != nil || len(raw) != 0 { + t.Fatal("fresh candidate discovery must be empty", e) + } + for _, args := range [][]string{{"-v"}, {"version", "--format", "{{.Server.Version}}"}, {"buildx", "version"}} { + raw, e := d.Reply(args) + if e != nil || len(raw) == 0 { + t.Fatal("verified version read missing", e) + } + } +} +func TestReadOnlyDriverRefusesEveryMutationUnknownAndIncompleteShape(t *testing.T) { + d := readonlyFixture() + for _, args := range [][]string{ + {"pull", imageSource()["image"].(string)}, {"run", "--name", d.Scope.Name(), "ubuntu"}, {"start", strings.Repeat("a", 64)}, {"exec", strings.Repeat("a", 64), "true"}, {"rm", "-f", strings.Repeat("a", 64)}, {"volume", "create"}, {"buildx", "build", "--load", "."}, {"--context", "foreign", "version"}, + {"inspect", "--type", "image", "ubuntu:latest"}, {"inspect", "--type", "image", imageSource()["image"].(string), "foreign"}, {"ps", "-q", "-a"}, {"ps", "-q", "-a", "--filter", "label=workharbor.owner=" + d.Scope.OwnerID}, {"version", "--format", "{{json .}}"}, {"buildx", "version", "--format", "json"}, + } { + _, e := d.Reply(args) + if e == nil || e.Error() == "unimplemented" { + t.Fatalf("refusal gate missing for %q: %v", args, e) + } + } +} diff --git a/apps/host_agent/internal/providers/docker/prepared.go b/apps/host_agent/internal/providers/docker/prepared.go new file mode 100644 index 00000000..edb8581b --- /dev/null +++ b/apps/host_agent/internal/providers/docker/prepared.go @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage" +) + +// Provider contains trusted host setup only. It is not a wire command or service. +// Admission carries the separately authenticated operation/approval/fence checker. +type Provider struct { + Resolver config.Resolver + Tools devcontainer.Tools + Registry Registry + Store storage.Store + DockerPath, Socket, BuildxPath, AgentPath, PrivateDirectory string +} +type Prepared struct { + closeOnce sync.Once + closeErr error + closed bool + mu sync.Mutex + source *config.Resolved + admission Admission + storage storage.Handle + port *nativePort + tools devcontainer.Tools + agent, root string + invocation Invocation + metadata []map[string]any + effective map[string]any + resourcesStarted, hooksStarted bool + resources *Target +} + +func (p *Prepared) Check(ctx context.Context) error { + if p == nil { + return fail("forbidden") + } + p.mu.Lock() + defer p.mu.Unlock() + if p.closed || p.source == nil { + return fail("forbidden") + } + if e := p.admission.Check(ctx); e != nil { + return e + } + return p.source.CheckContext(ctx) +} +func (p *Prepared) Storage() storage.Handle { + h := p.storage + h.Binding.Initial, _ = config.DecodeDescriptor(h.Binding.Initial.Map()) + return h +} +func (p *Prepared) Close() error { + if p == nil { + return nil + } + p.closeOnce.Do(func() { + p.mu.Lock() + p.closed = true + p.mu.Unlock() + if p.port != nil { + p.closeErr = p.port.Close() + } + p.mu.Lock() + defer p.mu.Unlock() + if p.source != nil { + if e := p.source.Close(); p.closeErr == nil { + p.closeErr = e + } + } + if p.root != "" { + if !ownedDirectory(p.root) { + if p.closeErr == nil { + p.closeErr = fail("provider_unavailable") + } + return + } + if e := os.RemoveAll(p.root); p.closeErr == nil { + p.closeErr = e + } + p.root = "" + } + }) + return p.closeErr +} +func contextInput(s *config.Resolved, path string, dir bool) (string, error) { + absolute := filepath.Join(filepath.Dir(s.DevcontainerPath), path) + rel, e := filepath.Rel(s.Root, absolute) + if e != nil { + return "", fail("invalid_configuration") + } + return config.ContainedPath(s.Root, filepath.ToSlash(rel), dir) +} +func folderImage(workspace string) string { + sum := sha256.Sum256([]byte(workspace)) + raw := strings.ToLower("vsc-" + filepath.Base(workspace) + "-" + hex.EncodeToString(sum[:])) + filtered := []byte{} + for _, b := range []byte(raw) { + if b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '.' || b == '_' || b == '-' { + filtered = append(filtered, b) + } + } + out := []byte{} + for i := 0; i < len(filtered); { + b := filtered[i] + out = append(out, b) + i++ + if b != '.' && b != '_' && b != '-' { + continue + } + limit := 1 + if b == '_' { + limit = 2 + } + if b == '-' { + limit = len(filtered) + } + kept := 1 + for i < len(filtered) && filtered[i] == b && kept < limit { + out = append(out, b) + i++ + kept++ + } + for i < len(filtered) && (filtered[i] == '.' || filtered[i] == '_' || filtered[i] == '-') { + i++ + } + } + return string(out) +} +func imageSnapshot(i AdmittedImage) map[string]any { + return map[string]any{"Id": i.ID, "Architecture": i.Document["architecture"], "Os": i.Document["os"], "Config": clone(i.Document["config"]), "RootFS": clone(i.Document["rootfs"])} +} +func envValues(v any) map[string]string { + out := map[string]string{} + if m, ok := v.(map[string]any); ok { + for k, v := range m { + out[k] = v.(string) + } + } + return out +} + +// Prepare resolves approved immutable inputs, completes all capability/trust gates, +// then reconciles/publishes only initial project storage. No Docker mutation or +// hook is issued. A valid Inspect observation ALWAYS passes Seed's fsync path. +func (host Provider) Prepare(ctx context.Context, a Admission) (prepared *Prepared, err error) { + if e := a.Check(ctx); e != nil { + return nil, e + } + if _, ok := ctx.Deadline(); !ok || !ownedDirectory(host.PrivateDirectory) || !physicalExecutable(host.AgentPath) { + return nil, fail("provider_unavailable") + } + if e := host.Tools.Verify(ctx, true); e != nil { + return nil, e + } + source, e := host.Resolver.Resolve(ctx, a.Descriptor()) + if e != nil { + return nil, e + } + p := &Prepared{source: source, admission: a, tools: host.Tools, agent: host.AgentPath} + defer func() { + if err != nil { + p.Close() + } + }() + p.root, e = registryPrivate(host.PrivateDirectory) + if e != nil { + return nil, e + } + for _, name := range []string{"tmp", "cli"} { + if os.Mkdir(filepath.Join(p.root, name), 0700) != nil { + return nil, fail("staging_failed") + } + } + if e = p.Check(ctx); e != nil { + return nil, e + } + images := map[string]AdmittedImage{} + registry := host.Registry + registry.Parser = host.Resolver.Parser + for _, ref := range source.Bases { + image, e := registry.Inspect(ctx, ref) + if e != nil { + return nil, e + } + images[ref] = image + } + selected := "" + if ref, ok := source.Source["image"].(string); ok { + selected = ref + } + var original []byte + var plan config.DockerfilePlan + var build *BuildPolicy + target := "" + if selected == "" { + b := source.Source["build"].(map[string]any) + file, e := contextInput(source, b["dockerfile"].(string), false) + if e != nil { + return nil, e + } + original, e = os.ReadFile(file) + if e != nil { + return nil, e + } + target, _ = b["target"].(string) + plan, e = config.AnalyzeDockerfile(original, target) + if e != nil { + return nil, e + } + selected = plan.SelectedBase + contextPath := "." + if value, ok := b["context"].(string); ok { + contextPath = value + } + directory, e := contextInput(source, contextPath, true) + if e != nil { + return nil, e + } + args := envValues(b["args"]) + args["BUILDKIT_INLINE_CACHE"] = "1" + args["_DEV_CONTAINERS_BASE_IMAGE"] = plan.Stage + if target != "" { + args["_DEV_CONTAINERS_BASE_IMAGE"] = target + } + build = &BuildPolicy{Context: directory, Scratch: filepath.Join(p.root, "tmp"), Tag: folderImage(source.ProjectsPath), Target: "dev_containers_target_stage", Args: args} + } + base, ok := images[selected] + if !ok { + return nil, fail("configuration_changed") + } + inherited, e := ValidateImage(ctx, host.Resolver.Parser, base.Config) + if e != nil { + return nil, e + } + runtimeMetadata, metadata, e := ExpectedMetadata(source.Source, inherited) + if e != nil { + return nil, e + } + p.metadata = inherited + binding := a.Binding() + existing, found, e := host.Store.Inspect(ctx, binding, p.Check) + if e != nil { + return nil, e + } + data := filepath.Join(host.Store.Root, binding.StorageID, "data") + policy := DriverPolicy{Phase: Resources, Scope: a.Scope(), DataPath: data, KnownIDs: map[string]bool{}, ImageRefs: map[string]bool{}, RuntimeMetadata: runtimeMetadata, StartupSHA256: "1a63da31389392b4e5ae4276c29a2c9a81b466739351488bef177c13fbcd7813", Build: build} + for ref := range images { + policy.ImageRefs[ref] = true + } + checkBuild := func(ctx context.Context, call BuildCall) error { + if e := p.Check(ctx); e != nil { + return e + } + raw, e := os.ReadFile(call.Dockerfile) + if e != nil { + return fail("configuration_changed") + } + return ValidateGeneratedDockerfile(ctx, host.Resolver.Parser, original, raw, target, metadata) + } + p.port, e = openNativePort(ctx, nativeOptions{Scope: a.Scope(), Policy: policy, Images: images, Registry: ®istry, Parser: host.Resolver.Parser, CheckBuild: checkBuild, Check: p.Check, DockerPath: host.DockerPath, Socket: host.Socket, BuildxPath: host.BuildxPath, PrivateDirectory: p.root}) + if e != nil { + return nil, e + } + info, e := p.port.Information(ctx) + if e != nil { + return nil, e + } + all, e := p.port.Candidates(ctx, a.Scope()) + if e != nil { + return nil, e + } + if len(all) != 0 { + return nil, fail("runtime_conflict") + } + if !supportedEngine(info) { + return nil, fail("unsupported_configuration") + } + // Validate the explicit source pairs before any seed, then compare their + // effective CLI values and immutable INITIAL data owner below. + var initial *User + if found { + initial = &User{UID: existing.Binding.DataUID, GID: existing.Binding.DataGID} + } + user, e := AdmitUsers(source.Source, source.Source, info, initial) + if e != nil { + return nil, e + } + if e = storage.CheckInitialOwner(user.UID, user.GID); e != nil { + return nil, e + } + p.invocation = Invocation{Phase: Preflight, Scope: a.Scope(), Workspace: source.ProjectsPath, Config: source.DevcontainerPath, Override: filepath.Join(p.root, "override.json"), UserData: filepath.Join(p.root, "cli"), DataPath: data} + override, e := FullOverride(source.Source, a.Scope(), data) + if e != nil { + return nil, e + } + raw, _ := json.Marshal(override) + if e = os.WriteFile(p.invocation.Override, raw, 0600); e != nil { + return nil, e + } + snapshot := PreflightDriver{Scope: a.Scope(), EngineVersion: info.ServerVersion, BuildxVersion: "v0.38.0", CandidateAbsent: true, Images: map[string]map[string]any{}} + for ref, image := range images { + snapshot.Images[ref] = imageSnapshot(image) + } + driver, e := startDriver(ctx, p.root, p.Check, func(ctx context.Context, args []string, _ io.Reader, out io.Writer) error { + raw, e := snapshot.Reply(args) + if e != nil { + return e + } + _, e = out.Write(raw) + return e + }) + if e != nil { + return nil, e + } + p.invocation.Driver, e = driver.Executable(ctx, host.AgentPath) + if e != nil { + driver.Close() + return nil, e + } + effective, e := readConfiguration(ctx, host.Tools, p.invocation, p.Check) + closeError := driver.Close() + if e != nil { + return nil, e + } + if closeError != nil { + return nil, closeError + } + if e = ValidateEffective(effective, source.Source, inherited, p.invocation); e != nil { + return nil, e + } + p.effective = effective + merged := effective["mergedConfiguration"].(map[string]any) + user, e = AdmitUsers(source.Source, merged, info, initial) + if e != nil { + return nil, e + } + policy.ContainerUser = user.Container + policy.RemoteUser = user.Remote + policy.ContainerEnv = envValues(merged["containerEnv"]) + policy.RemoteEnv = envValues(merged["remoteEnv"]) + policy.OverrideCommand = source.Source["image"] != nil + if override, ok := merged["overrideCommand"].(bool); ok { + policy.OverrideCommand = override + } + if !policy.OverrideCommand && build == nil { + policy.StartupTail = imageCommand(base) + } + + p.port.policy = copyPolicy(policy) + binding.DataUID = user.UID + binding.DataGID = user.GID + var seedSource *config.Resolved + if !found { + if !reflect.DeepEqual(binding.Initial.Map(), source.Descriptor.Map()) { + return nil, fail("seed_mismatch") + } + seedSource = source + } + p.storage, e = host.Store.Seed(ctx, binding, seedSource, p.Check) + if e != nil { + return nil, e + } + if e = p.Check(ctx); e != nil { + return nil, e + } + return p, nil +} diff --git a/apps/host_agent/internal/providers/docker/process_other.go b/apps/host_agent/internal/providers/docker/process_other.go new file mode 100644 index 00000000..4e45ceee --- /dev/null +++ b/apps/host_agent/internal/providers/docker/process_other.go @@ -0,0 +1,8 @@ +//go:build !linux && !darwin + +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import "os/exec" + +func ownedProcessGroup(*exec.Cmd) error { return fail("provider_unavailable") } diff --git a/apps/host_agent/internal/providers/docker/process_unix.go b/apps/host_agent/internal/providers/docker/process_unix.go new file mode 100644 index 00000000..195fb6ec --- /dev/null +++ b/apps/host_agent/internal/providers/docker/process_unix.go @@ -0,0 +1,26 @@ +//go:build linux || darwin + +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func ownedProcessGroup(command *exec.Cmd) error { + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { + if command.Process == nil { + return os.ErrProcessDone + } + e := syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + if errors.Is(e, syscall.ESRCH) { + return os.ErrProcessDone + } + return e + } + return nil +} diff --git a/apps/host_agent/internal/providers/docker/read_configuration.go b/apps/host_agent/internal/providers/docker/read_configuration.go new file mode 100644 index 00000000..a6d94b53 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/read_configuration.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" +) + +// readConfiguration runs only the pinned official read command, behind the +// snapshot-only executable adapter. Public admission is a separate native port. +func readConfiguration(ctx context.Context, tools devcontainer.Tools, input Invocation, authorize func(context.Context) error) (map[string]any, error) { + if authorize == nil { + return nil, fail("forbidden") + } + if e := authorize(ctx); e != nil { + return nil, e + } + if input.Phase != Preflight { + return nil, fail("invalid_configuration") + } + args, e := input.Arguments() + if e != nil { + return nil, e + } + if tools.Verify(ctx, true) != nil { + return nil, fail("tool_unavailable") + } + parser := config.JSONC{NodePath: tools.NodePath, CompanionDir: tools.Root} + raw, e := os.ReadFile(input.Config) + if e != nil { + return nil, fail("invalid_configuration") + } + value, e := parser.Parse(ctx, raw, false) + if e != nil { + return nil, e + } + source, ok := value.(map[string]any) + if !ok { + return nil, fail("invalid_configuration") + } + expected, e := FullOverride(source, input.Scope, input.DataPath) + if e != nil { + return nil, e + } + container, cok := source["containerUser"].(string) + remote, rok := source["remoteUser"].(string) + _, _, cv := parseUser(container) + _, _, rv := parseUser(remote) + if !cok || !rok || !cv || !rv || (container != remote && container != "0:0") { + return nil, fail("unsupported_configuration") + } + raw, e = os.ReadFile(input.Override) + if e != nil { + return nil, fail("invalid_configuration") + } + override, e := parser.Parse(ctx, raw, true) + if e != nil { + return nil, e + } + if !reflect.DeepEqual(expected, override) { + return nil, fail("unsupported_configuration") + } + info, e := os.Lstat(input.UserData) + physical, pe := filepath.EvalSymlinks(input.UserData) + if e != nil || pe != nil || physical != input.UserData || !info.IsDir() || info.Mode().Perm() != 0700 { + return nil, fail("invalid_configuration") + } + cache, e := os.MkdirTemp(input.UserData, "read-") + if e != nil { + return nil, fail("staging_failed") + } + defer os.RemoveAll(cache) + for _, dir := range []string{"home", "docker", "tmp"} { + if os.Mkdir(filepath.Join(cache, dir), 0700) != nil { + return nil, fail("staging_failed") + } + } + if e := authorize(ctx); e != nil { + return nil, e + } + command := exec.CommandContext(ctx, tools.NodePath, append([]string{filepath.Join(tools.Root, "node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js")}, args...)...) + command.Dir = input.Workspace + command.Env = append(tools.Environment(), "HOME="+filepath.Join(cache, "home"), "DOCKER_CONFIG="+filepath.Join(cache, "docker"), "TMPDIR="+filepath.Join(cache, "tmp"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0") + command.Stderr = io.Discard + raw, e = command.Output() + if e != nil { + return nil, fail("configuration_unavailable") + } + value, e = parser.Parse(ctx, raw, true) + if e != nil { + return nil, e + } + result, ok := value.(map[string]any) + if !ok { + return nil, fail("invalid_configuration") + } + return result, nil +} diff --git a/apps/host_agent/internal/providers/docker/read_configuration_test.go b/apps/host_agent/internal/providers/docker/read_configuration_test.go new file mode 100644 index 00000000..cdf8ac7d --- /dev/null +++ b/apps/host_agent/internal/providers/docker/read_configuration_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestOfficialReadDriverHelper(t *testing.T) { + if os.Getenv("WHR_CLI_READ_DRIVER") != "1" { + return + } + raw, e := base64.StdEncoding.DecodeString(os.Getenv("WHR_CLI_READ_SNAPSHOT")) + if e != nil { + os.Exit(124) + } + var d PreflightDriver + if json.Unmarshal(raw, &d) != nil { + os.Exit(124) + } + split := -1 + for i, arg := range os.Args { + if arg == "--" { + split = i + break + } + } + if split < 0 { + os.Exit(124) + } + out, e := d.Reply(os.Args[split+1:]) + if e != nil { + fmt.Fprintln(os.Stderr, e.Error()) + os.Exit(125) + } + os.Stdout.Write(out) + os.Exit(0) +} +func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } +func TestPinnedOfficialCLIReadConfigurationPreservesSourceAndMetadata(t *testing.T) { + root, e := filepath.EvalSymlinks(t.TempDir()) + if e != nil { + t.Fatal(e) + } + for _, dir := range []string{"source/.devcontainer", "source/projects", "operation", "cli"} { + os.MkdirAll(filepath.Join(root, dir), 0700) + } + source := imageSource() + source["containerUser"] = "0:0" + source["remoteUser"] = "1000:1000" + source["remoteEnv"] = map[string]any{"A": "source"} + source["postStartCommand"] = []any{"true"} + input := Invocation{Phase: Preflight, Scope: scopeFixture(), Workspace: filepath.Join(root, "source/projects"), Config: filepath.Join(root, "source/.devcontainer/devcontainer.json"), Override: filepath.Join(root, "operation/override.json"), Driver: filepath.Join(root, "driver"), UserData: filepath.Join(root, "cli")} + input.DataPath = filepath.Join(root, "future-storage/data") + raw, _ := json.Marshal(source) + os.WriteFile(input.Config, raw, 0600) + override, e := FullOverride(source, input.Scope, input.DataPath) + if e != nil { + t.Fatal(e) + } + raw, _ = json.Marshal(override) + os.WriteFile(input.Override, raw, 0600) + driver := readonlyFixture() + image := driver.Images[source["image"].(string)] + image["Config"].(map[string]any)["Labels"] = map[string]any{"devcontainer.metadata": `[{"remoteEnv":{"A":"base","B":"inherited"},"onCreateCommand":["true"]}]`} + snapshot, _ := json.Marshal(driver) + script := "#!/bin/sh\nWHR_CLI_READ_DRIVER=1 WHR_CLI_READ_SNAPSHOT=" + shellQuote(base64.StdEncoding.EncodeToString(snapshot)) + " exec " + shellQuote(os.Args[0]) + " -test.run='^TestOfficialReadDriverHelper$' -- \"$@\"\n" + os.WriteFile(input.Driver, []byte(script), 0700) + node, e := exec.LookPath("node") + if e != nil { + t.Fatal(e) + } + toolsRoot, e := filepath.Abs("../../../tools/devcontainer") + if e != nil { + t.Fatal(e) + } + tools := devcontainer.Tools{NodePath: node, Root: toolsRoot} + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + result, e := readConfiguration(ctx, tools, input, func(context.Context) error { return nil }) + if e != nil { + t.Fatal(e) + } + metadata := []map[string]any{{"remoteEnv": map[string]any{"A": "base", "B": "inherited"}, "onCreateCommand": []any{"true"}}} + if e := ValidateEffective(result, source, metadata, input); e != nil { + raw, _ := json.Marshal(result) + t.Fatalf("effective gate: %v candidate=%s", e, raw) + } + merged, ok := result["mergedConfiguration"].(map[string]any) + if !ok { + t.Fatal("missing effective config") + } + if merged["remoteUser"] != "1000:1000" || merged["containerUser"] != "0:0" { + t.Fatal("source users lost") + } + env, ok := merged["remoteEnv"].(map[string]any) + if !ok || env["A"] != "source" || env["B"] != "inherited" { + t.Fatalf("env=%#v", env) + } + if len(merged["onCreateCommands"].([]any)) != 1 || len(merged["postStartCommands"].([]any)) != 1 { + t.Fatal("hook source lost") + } + for _, mutate := range []func(map[string]any){ + func(m map[string]any) { delete(m["configuration"].(map[string]any), "image") }, + func(m map[string]any) { + m["mergedConfiguration"].(map[string]any)["remoteEnv"] = map[string]any{"A": "hostsecret"} + }, + func(m map[string]any) { m["mergedConfiguration"].(map[string]any)["postStartCommands"] = []any{} }, + func(m map[string]any) { + m["mergedConfiguration"].(map[string]any)["initializeCommand"] = "touch MUST_NOT_RUN" + }, + func(m map[string]any) { + m["mergedConfiguration"].(map[string]any)["mounts"] = []any{"/var/run/docker.sock"} + }, + func(m map[string]any) { + m["mergedConfiguration"].(map[string]any)["configFilePath"].(map[string]any)["path"] = "/foreign/devcontainer.json" + }, + func(m map[string]any) { m["mergedConfiguration"].(map[string]any)["unknown"] = "CANARY" }, + func(m map[string]any) { + m["mergedConfiguration"].(map[string]any)["runArgs"] = []any{"--name", "foreign"} + }, + func(m map[string]any) { + m["mergedConfiguration"].(map[string]any)["postAttachCommands"] = []any{"true"} + }, + } { + bad := clone(result).(map[string]any) + mutate(bad) + if e := ValidateEffective(bad, source, metadata, input); e == nil { + t.Fatal("effective drift accepted") + } + } + if _, e := os.Stat(input.DataPath); !os.IsNotExist(e) { + t.Fatal("preflight seeded provider storage") + } +} diff --git a/apps/host_agent/internal/providers/docker/registry.go b/apps/host_agent/internal/providers/docker/registry.go new file mode 100644 index 00000000..5d8f02ff --- /dev/null +++ b/apps/host_agent/internal/providers/docker/registry.go @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + dockerspec "github.com/moby/docker-image-spec/specs-go/v1" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" +) + +//go:embed tool-pins.json +var toolPins []byte + +type registrySelection struct { + Reference, ConfigID string + ConfigSize int64 +} + +func registryRef(ref string) (string, string, bool) { + parts := strings.Split(ref, "@sha256:") + if len(parts) != 2 || !hexID.MatchString(parts[1]) { + return "", "", false + } + if config.ValidateSource(map[string]any{"image": ref}) != nil { + return "", "", false + } + return parts[0], "sha256:" + parts[1], true +} +func registryObject(ctx context.Context, p config.JSONC, raw []byte) (map[string]any, error) { + if e := ctx.Err(); e != nil { + return nil, e + } + // The strict companion preserves raw number lexemes into Go UseNumber; + // descriptor integers must never pass through JavaScript numeric conversion. + v, e := p.Parse(ctx, raw, true) + if e != nil { + return nil, fail("metadata_unavailable") + } + m, ok := v.(map[string]any) + if !ok { + return nil, fail("metadata_unavailable") + } + return m, nil +} +func selectRegistryManifest(ctx context.Context, p config.JSONC, ref, arch string, raw []byte) (registrySelection, error) { + if len(raw) > metadataLimit { + return registrySelection{}, fail("metadata_unavailable") + } + name, digest, ok := registryRef(ref) + if !ok || (arch != "arm64" && arch != "amd64") { + return registrySelection{}, fail("metadata_unavailable") + } + sum := sha256.Sum256(raw) + if digest != "sha256:"+hex.EncodeToString(sum[:]) { + return registrySelection{}, fail("metadata_unavailable") + } + m, e := registryObject(ctx, p, raw) + if e != nil { + return registrySelection{}, e + } + if m["schemaVersion"] != json.Number("2") { + return registrySelection{}, fail("metadata_unavailable") + } + switch m["mediaType"] { + case "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json": + cfg, ok := m["config"].(map[string]any) + if !ok { + return registrySelection{}, fail("metadata_unavailable") + } + id, ok := cfg["digest"].(string) + if !ok || !strings.HasPrefix(id, "sha256:") || !hexID.MatchString(strings.TrimPrefix(id, "sha256:")) { + return registrySelection{}, fail("metadata_unavailable") + } + if cfg["mediaType"] != "application/vnd.oci.image.config.v1+json" && cfg["mediaType"] != "application/vnd.docker.container.image.v1+json" { + return registrySelection{}, fail("metadata_unavailable") + } + size, ok := cfg["size"].(json.Number) + if !ok { + return registrySelection{}, fail("metadata_unavailable") + } + count, sizeErr := strconv.ParseInt(string(size), 10, 64) + if sizeErr != nil || count <= 0 || count > metadataLimit { + return registrySelection{}, fail("metadata_unavailable") + } + return registrySelection{Reference: ref, ConfigID: id, ConfigSize: count}, nil + case "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json": + entries, ok := m["manifests"].([]any) + if !ok { + return registrySelection{}, fail("metadata_unavailable") + } + selected := "" + for _, entry := range entries { + item, ok := entry.(map[string]any) + if !ok { + return registrySelection{}, fail("metadata_unavailable") + } + platform, ok := item["platform"].(map[string]any) + if !ok { + continue + } + if platform["os"] != "linux" || platform["architecture"] != arch { + continue + } + if variant, exists := platform["variant"]; exists && variant != "" && (arch != "arm64" || variant != "v8") { + return registrySelection{}, fail("metadata_unavailable") + } + id, ok := item["digest"].(string) + if !ok || !strings.HasPrefix(id, "sha256:") || !hexID.MatchString(strings.TrimPrefix(id, "sha256:")) || selected != "" { + return registrySelection{}, fail("metadata_unavailable") + } + selected = name + "@" + id + } + if selected == "" { + return registrySelection{}, fail("metadata_unavailable") + } + return registrySelection{Reference: selected}, nil + default: + return registrySelection{}, fail("metadata_unavailable") + } +} +func canonicalDockerKeys(m map[string]any, known []string) bool { + seen := map[string]bool{} + for key := range m { + fold := strings.ToLower(key) + if seen[fold] { + return false + } + seen[fold] = true + for _, canonical := range known { + if strings.EqualFold(key, canonical) && key != canonical { + return false + } + } + } + return true +} +func decodeRegistryImage(ctx context.Context, p config.JSONC, selected registrySelection, arch string, raw []byte) (AdmittedImage, error) { + if selected.ConfigID == "" || selected.ConfigSize <= 0 || selected.ConfigSize > metadataLimit || int64(len(raw)) != selected.ConfigSize { + return AdmittedImage{}, fail("metadata_unavailable") + } + sum := sha256.Sum256(raw) + if selected.ConfigID != "sha256:"+hex.EncodeToString(sum[:]) { + return AdmittedImage{}, fail("metadata_unavailable") + } + m, e := registryObject(ctx, p, raw) + if e != nil { + return AdmittedImage{}, e + } + if !canonicalDockerKeys(m, []string{"architecture", "os", "config", "variant", "rootfs", "history", "created", "author"}) { + return AdmittedImage{}, fail("metadata_unavailable") + } + var typedRoot dockerspec.DockerOCIImage + if json.Unmarshal(raw, &typedRoot) != nil { + return AdmittedImage{}, fail("metadata_unavailable") + } + if m["os"] != "linux" || m["architecture"] != arch { + return AdmittedImage{}, fail("metadata_unavailable") + } + cfg, ok := m["config"].(map[string]any) + if !ok { + return AdmittedImage{}, fail("metadata_unavailable") + } + image, e := admitDockerConfig(ctx, p, cfg) + if e != nil { + return AdmittedImage{}, e + } + return AdmittedImage{ID: selected.ConfigID, Config: image, Document: clone(m).(map[string]any)}, nil +} + +// Registry reads public registry metadata through pinned official tools. It never +// calls pull/build/create, installs tools or accepts caller-provided tool flags. +// PrivateDirectory is a native host-owned private root, never repository input. +type Registry struct { + CAFile, CASHA256 string + BuildxPath, CranePath, PrivateDirectory, Architecture string + Parser config.JSONC +} + +const metadataLimit = 1 << 20 + +func registryEnvironment(root, tool string) []string { + return []string{"PATH=" + filepath.Dir(tool), "HOME=" + filepath.Join(root, "home"), "DOCKER_CONFIG=" + filepath.Join(root, "docker"), "XDG_CONFIG_HOME=" + filepath.Join(root, "config"), "XDG_RUNTIME_DIR=" + filepath.Join(root, "runtime"), "LANG=C.UTF-8"} +} +func registryPrivate(root string) (string, error) { + if !filepath.IsAbs(root) || filepath.Clean(root) != root || !ownedDirectory(root) { + return "", fail("tool_unavailable") + } + stage, e := os.MkdirTemp(root, "registry-") + if e != nil { + return "", fail("metadata_unavailable") + } + for _, dir := range []string{"home", "home/.docker", "docker", "config", "runtime"} { + if os.Mkdir(filepath.Join(stage, dir), 0700) != nil { + os.RemoveAll(stage) + return "", fail("metadata_unavailable") + } + } + for _, path := range []string{"home/.docker/config.json", "docker/config.json"} { + if os.WriteFile(filepath.Join(stage, path), []byte("{}"), 0600) != nil { + os.RemoveAll(stage) + return "", fail("metadata_unavailable") + } + } + return stage, nil +} +func verifyRegistryTool(ctx context.Context, path, digest, version, stage string, buildx bool) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fail("tool_unavailable") + } + physical, e := filepath.EvalSymlinks(path) + if e != nil || physical != path { + return fail("tool_unavailable") + } + f, e := os.Open(path) + if e != nil { + return fail("tool_unavailable") + } + defer f.Close() + info, e := f.Stat() + if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 || info.Mode().Perm()&0022 != 0 { + return fail("tool_unavailable") + } + h := sha256.New() + if _, e = io.Copy(h, f); e != nil || hex.EncodeToString(h.Sum(nil)) != digest { + return fail("tool_unavailable") + } + out, e := captureMetadata(ctx, path, []string{"version"}, stage, registryEnvironment(stage, path), 1024) + if e != nil { + return fail("tool_unavailable") + } + if buildx { + if !bytes.Contains(out, []byte(" "+version+" ")) { + return fail("tool_unavailable") + } + } else if strings.TrimPrefix(strings.TrimSpace(string(out)), "v") != strings.TrimPrefix(version, "v") { + return fail("tool_unavailable") + } + return nil +} +func (r Registry) Inspect(ctx context.Context, ref string) (AdmittedImage, error) { + if e := registryTLS(ref); e != nil { + return AdmittedImage{}, e + } + if _, _, ok := registryRef(ref); !ok { + return AdmittedImage{}, fail("metadata_unavailable") + } + if _, ok := ctx.Deadline(); !ok || ctx.Err() != nil { + return AdmittedImage{}, fail("metadata_unavailable") + } + if runtime.GOOS != "linux" || runtime.GOARCH != r.Architecture { + return AdmittedImage{}, fail("tool_unavailable") + } + var pins struct{ Buildx, Crane map[string]string } + if json.Unmarshal(toolPins, &pins) != nil { + return AdmittedImage{}, fail("tool_unavailable") + } + stage, e := registryPrivate(r.PrivateDirectory) + if e != nil { + return AdmittedImage{}, e + } + defer os.RemoveAll(stage) + if _, e = r.readEnvironment(stage, r.BuildxPath); e != nil { + return AdmittedImage{}, e + } + if e = verifyRegistryTool(ctx, r.BuildxPath, pins.Buildx["linux_"+r.Architecture+"_sha256"], pins.Buildx["version"], stage, true); e != nil { + return AdmittedImage{}, e + } + if e = verifyRegistryTool(ctx, r.CranePath, pins.Crane["linux_"+r.Architecture+"_sha256"], pins.Crane["version"], stage, false); e != nil { + return AdmittedImage{}, e + } + manifest := func(ref string) ([]byte, error) { + env, e := r.readEnvironment(stage, r.BuildxPath) + if e != nil { + return nil, e + } + return captureMetadata(ctx, r.BuildxPath, []string{"imagetools", "inspect", "--raw", ref}, stage, env, metadataLimit) + } + raw, e := manifest(ref) + if e != nil { + return AdmittedImage{}, e + } + selected, e := selectRegistryManifest(ctx, r.Parser, ref, r.Architecture, raw) + if e != nil { + return AdmittedImage{}, e + } + if selected.ConfigID == "" { + raw, e = manifest(selected.Reference) + if e != nil { + return AdmittedImage{}, e + } + selected, e = selectRegistryManifest(ctx, r.Parser, selected.Reference, r.Architecture, raw) + if e != nil || selected.ConfigID == "" { + return AdmittedImage{}, fail("metadata_unavailable") + } + } + // The declared size was independently checked before this spawn. Raw stdout + // remains untouched until exact byte-count and SHA256 checks in the decoder. + env, e := r.readEnvironment(stage, r.CranePath) + if e != nil { + return AdmittedImage{}, e + } + raw, e = captureMetadata(ctx, r.CranePath, []string{"config", selected.Reference}, stage, env, selected.ConfigSize) + if e != nil { + return AdmittedImage{}, e + } + return decodeRegistryImage(ctx, r.Parser, selected, r.Architecture, raw) +} + +func admitDockerConfig(ctx context.Context, p config.JSONC, cfg map[string]any) (ImageConfig, error) { + if !canonicalDockerKeys(cfg, []string{"User", "Env", "Entrypoint", "Cmd", "Volumes", "OnBuild", "Labels", "WorkingDir", "ExposedPorts", "StopSignal", "ArgsEscaped", "Healthcheck", "Shell", "StopTimeout"}) { + return ImageConfig{}, fail("metadata_unavailable") + } + var typed dockerspec.DockerOCIImage + typedRaw, marshalErr := json.Marshal(map[string]any{"config": cfg}) + if marshalErr != nil || json.Unmarshal(typedRaw, &typed) != nil { + return ImageConfig{}, fail("metadata_unavailable") + } + image := ImageConfig{} + if value, exists := cfg["Volumes"]; exists && value != nil { + volumes, ok := value.(map[string]any) + if !ok { + return ImageConfig{}, fail("metadata_unavailable") + } + image.Volumes = volumes + } + if value, exists := cfg["OnBuild"]; exists && value != nil { + hooks, ok := value.([]any) + if !ok { + return ImageConfig{}, fail("metadata_unavailable") + } + image.OnBuild = []string{} + for _, hook := range hooks { + s, ok := hook.(string) + if !ok { + return ImageConfig{}, fail("metadata_unavailable") + } + image.OnBuild = append(image.OnBuild, s) + } + } + if value, exists := cfg["Labels"]; exists && value != nil { + labels, ok := value.(map[string]any) + if !ok { + return ImageConfig{}, fail("metadata_unavailable") + } + for name, value := range labels { + s, ok := value.(string) + if !ok { + return ImageConfig{}, fail("metadata_unavailable") + } + if name == "devcontainer.metadata" { + image.Metadata = s + } + } + } + if _, e := ValidateImage(ctx, p, image); e != nil { + return ImageConfig{}, e + } + return image, nil +} diff --git a/apps/host_agent/internal/providers/docker/registry_numbers_test.go b/apps/host_agent/internal/providers/docker/registry_numbers_test.go new file mode 100644 index 00000000..2ca1b850 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/registry_numbers_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC(t *testing.T) { + for _, kind := range []string{"schema-version-fraction", "size-fraction", "size-exponent"} { + t.Run(kind, func(t *testing.T) { + version, size := "2", "100" + switch kind { + case "schema-version-fraction": + version = "2.0000000000000001" + case "size-fraction": + size = "100.0000000000000001" + case "size-exponent": + size = "1e2" + } + raw := []byte(`{"schemaVersion":` + version + `,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:` + strings.Repeat("b", 64) + `","size":` + size + `},"layers":[]}`) + sum := sha256.Sum256(raw) + ref := "example.test/fixture@sha256:" + hex.EncodeToString(sum[:]) + if _, e := selectRegistryManifest(context.Background(), registryParser(t), ref, "arm64", raw); e == nil { + t.Fatal("raw noninteger lexeme normalized into accepted descriptor", kind) + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/registry_test.go b/apps/host_agent/internal/providers/docker/registry_test.go new file mode 100644 index 00000000..ff683e6d --- /dev/null +++ b/apps/host_agent/internal/providers/docker/registry_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func registryDigest(raw string) string { + s := sha256.Sum256([]byte(raw)) + return "sha256:" + hex.EncodeToString(s[:]) +} +func registryParser(t *testing.T) config.JSONC { + t.Helper() + node, e := exec.LookPath("node") + if e != nil { + t.Fatal(e) + } + root, e := filepath.Abs("../../../tools/devcontainer") + if e != nil { + t.Fatal(e) + } + return config.JSONC{NodePath: node, CompanionDir: root} +} +func TestRegistrySingleManifestChecksDigestPlatformAndRawCapabilities(t *testing.T) { + p := registryParser(t) + raw := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:` + strings.Repeat("b", 64) + `","size":128},"layers":[]}` + ref := "example.test/app@" + registryDigest(raw) + selected, e := selectRegistryManifest(context.Background(), p, ref, "arm64", []byte(raw)) + if e != nil || selected.Reference != ref || selected.ConfigID != "sha256:"+strings.Repeat("b", 64) { + t.Fatal(selected, e) + } + configRaw := `{"architecture":"arm64","os":"linux","config":{"User":"image-default","Labels":{"devcontainer.metadata":"[{\"remoteUser\":\"image-user\"}]"}}}` + selected.ConfigID = registryDigest(configRaw) + selected.ConfigSize = int64(len(configRaw)) + im, e := decodeRegistryImage(context.Background(), p, selected, "arm64", []byte(configRaw)) + if e != nil || im.ID != selected.ConfigID || im.Config.Metadata == "" { + t.Fatal(im, e) + } + for _, bad := range []string{`{"architecture":"amd64","os":"linux","config":{}}`, `{"architecture":"arm64","os":"linux","config":{"Volumes":{"/workspace":{}}}}`, `{"architecture":"arm64","os":"linux","config":{"OnBuild":["RUN touch hook"]}}`, `{"architecture":"arm64","os":"linux","config":{"Labels":{"devcontainer.metadata":"{bad"}}}`, `{"architecture":"arm64","os":"linux","config":{"Labels":{"devcontainer.metadata":"{\"initializeCommand\":\"touch hook\"}"}}}`} { + selected.ConfigID = registryDigest(bad) + selected.ConfigSize = int64(len(bad)) + if _, e := decodeRegistryImage(context.Background(), p, selected, "arm64", []byte(bad)); e == nil { + t.Fatal("unsafe raw image admitted", bad) + } + } + _, e = selectRegistryManifest(context.Background(), p, ref, "arm64", []byte(raw+" ")) + errorCode(t, e, "metadata_unavailable") +} +func TestRegistryIndexSelectsUniqueExplicitPlatformWithoutPull(t *testing.T) { + p := registryParser(t) + digest := "sha256:" + strings.Repeat("c", 64) + raw := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"` + digest + `","platform":{"os":"linux","architecture":"arm64","variant":"v8"}},{"digest":"sha256:` + strings.Repeat("d", 64) + `","platform":{"os":"linux","architecture":"amd64"}}]}` + ref := "example.test/app@" + registryDigest(raw) + selected, e := selectRegistryManifest(context.Background(), p, ref, "arm64", []byte(raw)) + if e != nil || selected.Reference != "example.test/app@"+digest || selected.ConfigID != "" { + t.Fatal(selected, e) + } + for _, arch := range []string{"386", "unknown"} { + if _, e := selectRegistryManifest(context.Background(), p, ref, arch, []byte(raw)); e == nil { + t.Fatal("unsupported arch accepted") + } + } + dup := strings.Replace(raw, `"architecture":"amd64"`, `"architecture":"arm64"`, 1) + _, e = selectRegistryManifest(context.Background(), p, "example.test/app@"+registryDigest(dup), "arm64", []byte(dup)) + errorCode(t, e, "metadata_unavailable") +} + +func TestRegistryRawConfigDigestAndDuplicateKeysBeforeNormalization(t *testing.T) { + p := registryParser(t) + raw := `{"architecture":"arm64","os":"linux","config":{}}` + selected := registrySelection{Reference: "example.test/app@sha256:" + strings.Repeat("a", 64), ConfigID: registryDigest(raw), ConfigSize: int64(len(raw))} + if _, e := decodeRegistryImage(context.Background(), p, selected, "arm64", []byte(raw)); e != nil { + t.Fatal(e) + } + if _, e := decodeRegistryImage(context.Background(), p, selected, "arm64", []byte(raw+" ")); e == nil { + t.Fatal("raw digest mismatch admitted") + } + for _, bad := range []string{`{"architecture":"arm64","os":"linux","config":{"OnBuild":["RUN bad"],"OnBuild":[]}}`, `{"architecture":"arm64","os":"linux","config":{"Labels":{"devcontainer.metadata":"{}","devcontainer.metadata":"[]"}}}`} { + selected.ConfigID = registryDigest(bad) + selected.ConfigSize = int64(len(bad)) + selected.ConfigID = registryDigest(bad) + selected.ConfigSize = int64(len(bad)) + if _, e := decodeRegistryImage(context.Background(), p, selected, "arm64", []byte(bad)); e == nil { + t.Fatal("raw duplicate hidden", bad) + } + } +} + +func TestRegistryDeclaredConfigSizeRefusesBeforeToolSpawn(t *testing.T) { + p := registryParser(t) + for _, size := range []string{"0", "-1", "1048577", "1.5", `"123"`} { + raw := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:` + strings.Repeat("a", 64) + `","size":` + size + `},"layers":[]}` + _, e := selectRegistryManifest(context.Background(), p, "example.test/app@"+registryDigest(raw), "arm64", []byte(raw)) + errorCode(t, e, "metadata_unavailable") + } + raw := `{"architecture":"arm64","os":"linux","config":{}}` + _, e := decodeRegistryImage(context.Background(), p, registrySelection{ConfigID: registryDigest(raw), ConfigSize: int64(len(raw) + 1)}, "arm64", []byte(raw)) + errorCode(t, e, "metadata_unavailable") +} + +func TestRegistryDockerCaseAliasesCannotHideInheritedEffects(t *testing.T) { + p := registryParser(t) + for _, raw := range []string{`{"architecture":"arm64","os":"linux","config":{"onbuild":["RUN bad"]}}`, `{"architecture":"arm64","os":"linux","config":{"volumes":{"/workspace":{}}}}`, `{"architecture":"arm64","os":"linux","config":{},"Config":{"OnBuild":["RUN bad"]}}`, `{"architecture":"arm64","os":"linux","config":{"Labels":{},"labels":{"devcontainer.metadata":"{bad"}}}}`} { + _, e := decodeRegistryImage(context.Background(), p, registrySelection{ConfigID: registryDigest(raw), ConfigSize: int64(len(raw))}, "arm64", []byte(raw)) + if e == nil { + t.Fatal("Docker case-insensitive field bypass", raw) + } + } +} + +func TestRegistryMalformedRuntimeFieldsRefuseBeforeOfficialCLI(t *testing.T) { + p := registryParser(t) + for _, field := range []string{`"Env":{"A":"B"}`, `"Entrypoint":42`, `"User":[]`, `"Healthcheck":{"Interval":"forever"}`} { + raw := `{"architecture":"arm64","os":"linux","config":{` + field + `}}` + _, e := decodeRegistryImage(context.Background(), p, registrySelection{ConfigID: registryDigest(raw), ConfigSize: int64(len(raw))}, "arm64", []byte(raw)) + if e == nil { + t.Fatal("malformed runtime field admitted", field) + } + } +} diff --git a/apps/host_agent/internal/providers/docker/registry_trust.go b/apps/host_agent/internal/providers/docker/registry_trust.go new file mode 100644 index 00000000..bfd5e029 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/registry_trust.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Pinned crane chooses plaintext for these literal registry authorities even +// without --insecure. Refuse them before any metadata process or provider effect. +// DNS names use the tools' HTTPS entrypoint; digest validation remains mandatory. +func registryTLS(ref string) error { + name, _, ok := registryRef(ref) + if !ok { + return fail("unsupported_configuration") + } + first := strings.SplitN(name, "/", 2)[0] + if !strings.Contains(name, "/") || !strings.ContainsAny(first, ".:") && first != "localhost" { + return nil + } + host := first + if strings.Contains(first, ":") { + var port string + var e error + host, port, e = net.SplitHostPort(first) + if e != nil { + return fail("unsupported_configuration") + } + number, e := strconv.Atoi(port) + if e != nil || number < 1 || number > 65535 { + return fail("unsupported_configuration") + } + } + host = strings.TrimSuffix(strings.ToLower(host), ".") + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return fail("unsupported_configuration") + } + if ip := net.ParseIP(host); ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) { + return fail("unsupported_configuration") + } + return nil +} +func (r Registry) readEnvironment(root, tool string) ([]string, error) { + env := registryEnvironment(root, tool) + if r.CAFile == "" { + if r.CASHA256 != "" { + return nil, fail("tool_unavailable") + } + return env, nil + } + if !filepath.IsAbs(r.CAFile) || filepath.Clean(r.CAFile) != r.CAFile || !hexID.MatchString(r.CASHA256) { + return nil, fail("tool_unavailable") + } + physical, e := filepath.EvalSymlinks(r.CAFile) + info, se := os.Lstat(r.CAFile) + if e != nil || se != nil || physical != r.CAFile || !ownedRegular(info) || info.Mode().Perm()&0022 != 0 || info.Size() <= 0 || info.Size() > metadataLimit { + return nil, fail("tool_unavailable") + } + f, e := os.Open(r.CAFile) + if e != nil { + return nil, fail("tool_unavailable") + } + actual, fe := f.Stat() + raw, re := io.ReadAll(io.LimitReader(f, metadataLimit+1)) + ce := f.Close() + current, le := os.Lstat(r.CAFile) + if fe != nil || re != nil || ce != nil || le != nil || !os.SameFile(info, actual) || !os.SameFile(info, current) || !ownedRegular(current) || len(raw) > metadataLimit { + return nil, fail("tool_unavailable") + } + sum := sha256.Sum256(raw) + if hex.EncodeToString(sum[:]) != r.CASHA256 { + return nil, fail("tool_unavailable") + } + rest := raw + count := 0 + for len(strings.TrimSpace(string(rest))) > 0 { + block, tail := pem.Decode(rest) + if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) != 0 { + return nil, fail("tool_unavailable") + } + certificate, e := x509.ParseCertificate(block.Bytes) + if e != nil || !certificate.IsCA || time.Now().Before(certificate.NotBefore) || !time.Now().Before(certificate.NotAfter) { + return nil, fail("tool_unavailable") + } + count++ + rest = tail + } + if count == 0 { + return nil, fail("tool_unavailable") + } + return append(env, "SSL_CERT_FILE="+r.CAFile), nil +} diff --git a/apps/host_agent/internal/providers/docker/registry_trust_test.go b/apps/host_agent/internal/providers/docker/registry_trust_test.go new file mode 100644 index 00000000..44f228ff --- /dev/null +++ b/apps/host_agent/internal/providers/docker/registry_trust_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRegistryEndpointRefusesPinnedToolPlaintextHeuristics(t *testing.T) { + for _, host := range []string{"localhost", "localhost:5000", "a.localhost:443", "127.0.0.1:443", "10.1.2.3:443", "172.16.0.1:443", "192.168.1.2:443", "0.0.0.0:443", "http:", "https:"} { + if registryTLS(host+"/fixture@sha256:"+strings.Repeat("a", 64)) == nil { + t.Fatal("plaintext/ambiguous registry admitted", host) + } + } + for _, ref := range []string{"ubuntu@sha256:", "docker.io/library/ubuntu@sha256:", "registry.workharbor.test:443/fixture@sha256:"} { + if e := registryTLS(ref + strings.Repeat("a", 64)); e != nil { + t.Fatal(ref, e) + } + } +} +func TestRegistryExplicitCABundleIntegrityAndPhysicalPath(t *testing.T) { + server := httptest.NewTLSServer(http.NotFoundHandler()) + defer server.Close() + root := bridgeRoot(t) + path := filepath.Join(root, "fixture-ca.pem") + raw := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + sum := sha256.Sum256(raw) + os.WriteFile(path, raw, 0600) + registry := Registry{CAFile: path, CASHA256: hex.EncodeToString(sum[:])} + t.Setenv("WORKHARBOR_T009_REGISTRY_KEY_FILE", "PRIVATE_KEY_CANARY") + environment, e := registry.readEnvironment(root, "/trusted/tool") + if e != nil { + t.Fatal(e) + } + for _, entry := range environment { + if strings.Contains(entry, "PRIVATE_KEY_CANARY") || strings.HasPrefix(entry, "WORKHARBOR_") { + t.Fatal("fixture environment leaked to metadata tool") + } + } + + if _, e := registry.readEnvironment(root, "/trusted/tool"); e != nil { + t.Fatal("valid explicit CA rejected", e) + } + for _, kind := range []string{"missing", "changed", "symlink", "wrong-hash", "non-ca"} { + t.Run(kind, func(t *testing.T) { + r := registry + switch kind { + case "missing": + r.CAFile += ".missing" + case "changed": + r.CASHA256 = strings.Repeat("f", 64) + case "symlink": + link := filepath.Join(root, "ca-link") + os.Symlink(path, link) + r.CAFile = link + case "wrong-hash": + r.CASHA256 = "" + case "non-ca": + other := filepath.Join(root, "bad.pem") + data := []byte("not a PEM certificate") + os.WriteFile(other, data, 0600) + hash := sha256.Sum256(data) + r.CAFile = other + r.CASHA256 = hex.EncodeToString(hash[:]) + } + if _, e := r.readEnvironment(root, "/trusted/tool"); e == nil { + t.Fatal("unverified CA admitted") + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/tool-pins.json b/apps/host_agent/internal/providers/docker/tool-pins.json index 8abaac3e..bf1535ff 100644 --- a/apps/host_agent/internal/providers/docker/tool-pins.json +++ b/apps/host_agent/internal/providers/docker/tool-pins.json @@ -31,5 +31,27 @@ "docker_engine": { "version": "29.8.2", "guest_package_verification": "pending runtime preparation" + }, + "atomic_filesystem": { + "module": "golang.org/x/sys", + "version": "v0.48.0", + "license": "BSD-3-Clause", + "linux": "Renameat2/RENAME_NOREPLACE", + "darwin": "RenameatxNp/RENAME_EXCL", + "fallback": "none" + }, + "crane": { + "version": "v0.22.1", + "license": "Apache-2.0", + "purpose": "raw registry config bytes only; no pull/layers", + "linux_arm64_archive_sha256": "898c0cff975f898a33e8c4580bdafb0e7c02c7faa33374e946762f97c4ab7110", + "linux_arm64_sha256": "1799f559c299f068951822a5efe6689ebab6974f2054d5562e60798fa9db6a55", + "linux_amd64_archive_sha256": "0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0", + "linux_amd64_sha256": "8e75c326adb43b053cfd78b9b987663f8f4fe4557f4d3915905643f25c730992", + "linux_arm64_url": "https://github.com/google/go-containerregistry/releases/download/v0.22.1/go-containerregistry_Linux_arm64.tar.gz", + "linux_amd64_url": "https://github.com/google/go-containerregistry/releases/download/v0.22.1/go-containerregistry_Linux_x86_64.tar.gz", + "checksums_url": "https://github.com/google/go-containerregistry/releases/download/v0.22.1/checksums.txt", + "checksums_sha256": "889e6996ef939c12d613f4cae657cc2ecce822e69ea74a58a453a95d723a857d", + "metadata_input_limit_bytes": 1048576 } } diff --git a/apps/host_agent/internal/storage/inspect.go b/apps/host_agent/internal/storage/inspect.go new file mode 100644 index 00000000..f9ba3f9d --- /dev/null +++ b/apps/host_agent/internal/storage/inspect.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: EUPL-1.2 +package storage + +import ( + "context" + "encoding/json" + "os" + "path/filepath" +) + +// Inspect is read-only ORIGINAL owner admission, never seed completion evidence. +// Even found=true must pass Seed's existing-final fsync/current Check path before +// first provider-handle observation or receipt. No retained data is traversed. +func (s Store) Inspect(ctx context.Context, b Binding, authorize Check) (Handle, bool, error) { + if e := check(ctx, authorize); e != nil { + return Handle{}, false, e + } + if !platformSupported() || !b.valid() || !filepath.IsAbs(s.Root) || filepath.Clean(s.Root) != s.Root { + return Handle{}, false, failure("invalid_storage") + } + physical, e := filepath.EvalSymlinks(s.Root) + if e != nil || physical != s.Root || !privatePath(s.Root, 0700, true) { + return Handle{}, false, failure("invalid_storage") + } + final := filepath.Join(s.Root, b.StorageID) + if b.ExistingHandle != "" && b.ExistingHandle != final { + return Handle{}, false, failure("storage_conflict") + } + if _, e = os.Lstat(final); os.IsNotExist(e) { + if b.ExistingHandle != "" { + return Handle{}, false, failure("storage_unavailable") + } + if e = check(ctx, authorize); e != nil { + return Handle{}, false, e + } + return Handle{}, false, nil + } else if e != nil { + return Handle{}, false, failure("storage_failed") + } + markerPath := filepath.Join(final, "seed.json") + if !privatePath(final, 0700, true) || !privatePath(markerPath, 0600, false) { + return Handle{}, false, failure("storage_conflict") + } + raw, e := os.ReadFile(markerPath) + if e != nil { + return Handle{}, false, failure("storage_conflict") + } + var owner struct { + UID uint32 `json:"data_uid"` + GID uint32 `json:"data_gid"` + } + if json.Unmarshal(raw, &owner) != nil || owner.UID == 4294967295 || owner.GID == 4294967295 { + return Handle{}, false, failure("storage_conflict") + } + b.DataUID = owner.UID + b.DataGID = owner.GID + // Exact canonical marker equality rejects duplicate, extra, missing, aliased or + // malformed fields and binds every ORIGINAL ID/descriptor plus numeric owner. + if !validFinal(final, b) { + return Handle{}, false, failure("storage_conflict") + } + if e = check(ctx, authorize); e != nil { + return Handle{}, false, e + } + return Handle{Parent: final, Data: filepath.Join(final, "data"), Binding: b}, true, nil +} diff --git a/apps/host_agent/internal/storage/inspect_test.go b/apps/host_agent/internal/storage/inspect_test.go new file mode 100644 index 00000000..7612b63e --- /dev/null +++ b/apps/host_agent/internal/storage/inspect_test.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: EUPL-1.2 +package storage + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestStorageInspectReturnsOriginalOwnerReadOnlyAcrossRebuild(t *testing.T) { + s, b, source := fixture(t) + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + t.Fatal(e) + } + edited := []byte{0, 255, 7, 128} + os.WriteFile(filepath.Join(h.Data, "binary"), edited, 0600) + before, e := os.ReadDir(s.Root) + if e != nil { + t.Fatal(e) + } + b.ExistingHandle = h.Parent + b.DataUID++ + b.DataGID++ + actual, found, e := s.Inspect(context.Background(), b, allow) + if e != nil || !found || actual.Binding.DataUID != h.Binding.DataUID || actual.Binding.DataGID != h.Binding.DataGID || actual.Parent != h.Parent { + t.Fatal("initial owner lost", actual, found, e) + } + after, _ := os.ReadDir(s.Root) + if len(before) != len(after) { + t.Fatal("inspection mutated store") + } + raw, e := os.ReadFile(filepath.Join(h.Data, "binary")) + if e != nil || string(raw) != string(edited) { + t.Fatal("retained bytes changed") + } +} +func TestStorageInspectAbsentAndKnownMissingHaveDistinctOutcomes(t *testing.T) { + s, b, _ := fixture(t) + _, found, e := s.Inspect(context.Background(), b, allow) + if e != nil || found { + t.Fatal("new absent storage unavailable", found, e) + } + entries, _ := os.ReadDir(s.Root) + if len(entries) != 0 { + t.Fatal("inspection created lock/temp") + } + b.ExistingHandle = filepath.Join(s.Root, b.StorageID) + _, found, e = s.Inspect(context.Background(), b, allow) + assertCode(t, e, "storage_unavailable") + if found { + t.Fatal("known missing adopted") + } +} +func TestStorageInspectForeignInitialBindingAndMarkerPreserved(t *testing.T) { + s, b, source := fixture(t) + h, e := s.Seed(context.Background(), b, source, allow) + if e != nil { + t.Fatal(e) + } + original, _ := os.ReadFile(filepath.Join(h.Parent, "seed.json")) + b.ReservationOperationID = "00000000-0000-4000-8000-000000000099" + if _, _, e = s.Inspect(context.Background(), b, allow); e == nil { + t.Fatal("foreign initial binding") + } + raw, _ := os.ReadFile(filepath.Join(h.Parent, "seed.json")) + if string(raw) != string(original) { + t.Fatal("foreign final repaired") + } +} diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 0070d719..c029301e 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -520,7 +520,9 @@ "test_go_vectors.GoVectorTests.test_schema_rejects_unknown_fields_types_options_ids_and_operations", "test_go_vectors.GoVectorTests.test_snapshot_manifest_tool_and_schema_tampering_are_rejected", "test_go_vectors.GoVectorTests.test_source_and_product_domains_remain_distinct", - "test_go_vectors.GoVectorTests.test_strict_fixture_loader_rejects_duplicate_keys_constants_utf8_and_size" + "test_go_vectors.GoVectorTests.test_strict_fixture_loader_rejects_duplicate_keys_constants_utf8_and_size", + "test_application_checks.ApplicationChecksTests.test_pinned_companion_setup_precedes_host_tests_and_refuses_scripts", + "test_harness.HarnessTests.test_exact_pure_dockerfile_grammar_imports_do_not_grant_docker_access" ], "retained_assertion_failures": [], "application_tests": { @@ -568,7 +570,396 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestExpiryWatchdogCancelsBlockedIO", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess", + "github.com/wstein/workharbor-nxt/apps/host_agent/cmd/workharbor-agent/TestPrivateDockerProxyModeRejectsMalformedLifetimeAndSocket", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDockerfilePlanBindsSelectedStageToExternalBase", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDockerfilePlanCanonicalizesMixedCaseStagesAndAliases", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestJSONCCommentsTrailingCommaAndScalarStrings", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestJSONCRejectsDeepEscapedDuplicatesAndMalformedScalars", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestStrictMetadataJSONRefusesCommentsAndTrailingComma", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverExactHTTPSCommitPrivateStageAndNoAmbientHooks", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRejectsManifestAmbiguitySymlinksAndHostHooks", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverMissingExactCommitNeverFallsBackToBranch", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverExactCompleteSubmoduleAndNoNestedOrRelativeFallback", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverFrozenInputsDetectContentModeAndSymlinkDrift", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverSnapshotCannotBeRetargetedThroughExportedFields", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverExactSHA256RepositoryNeverUsesDefaultSHA1", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverHTTPSRootUsesExplicitSSHOnlyForApprovedGitlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDescriptorExactNormalizedRevisions", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDescriptorOriginAliasesNeverNormalize", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestApprovalRequiresExactOwnerReferenceAndCompleteSet", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestSourceImageAndDockerfileClosedMatrix", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/initializeCommand", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/postAttachCommand", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/features", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/dockerComposeFile", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/customizations", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/runArgs", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/mounts", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/workspaceMount", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/workspaceFolder", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/privileged", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/capAdd", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/securityOpt", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/hostRequirements", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/appPort", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/forwardPorts", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/unknown", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestReservedFrontendArgumentsAndSubstitutionRefuse", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDockerfileASTRefusesBeforeBuild", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestSourceNULAndUnrepresentableEnvironmentNamesRefuse", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDockerfileMalformedInstructionsRefuseBeforeBuild", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDockerfileMaintainedGrammarCheckCommentsNeverPanic", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDockerfileEveryReservedARGDeclarationRefuses", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.dot-default", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.empty", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.absolute", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.double-leading", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.trailing-slash", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.empty-component", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.dot-component", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.parent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.parent-leading", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.git-root", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.git-component", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.backslash", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.nul", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.cr", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.lf", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.tab", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.star", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.question", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.open-bracket", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.close-bracket", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.colon", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.drive-shaped", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.git-case", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.space", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.padded-space", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.percent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.tilde", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.escape-control", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.other-control", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.line-separator", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.unicode", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.surrogate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.dot-true", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal-dot-true", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.dot-integer", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal-dot-integer", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal-dot-null", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal-dot-text", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.type-null", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.type-integer", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.type-bytes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal-dot-zero", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal-dot-empty-text", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.dot-float", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.surrogate-low", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#0", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#1", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#2", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#3", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#4", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#5", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#6", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#7", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#8", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#9", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#10", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#11", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#12", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#13", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_identity_id", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_identity_id", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_certificate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_certificate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_issuer_certificate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_issuer_certificate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_expires_at", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_expires_at", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_schema_version", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_schema_version", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_owner_id", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_owner_id", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_host_id", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_host_id", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed/valid", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed/wrongkind", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed/closed", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed/error", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestAcknowledgementSequenceFailsClosed/cancelled", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/hello", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/hello_ack", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/heartbeat", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/heartbeat_ack", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/version", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/float", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/foreign", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/unknown", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/missing", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/command", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestSharedHostConnectionFixtures/decoded_duplicate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer/TestExactCompanionIntegrityAndCLIVersion", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer/TestCompanionHashTamperAndSymlinkRefuseBeforeExecution/changed", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer/TestCompanionHashTamperAndSymlinkRefuseBeforeExecution/symlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/devcontainer/TestCompanionHashTamperAndSymlinkRefuseBeforeExecution", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/create", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/write", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/file_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/file_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/parent_open", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/parent_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_enrollment/parent_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/create", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/write", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/file_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/file_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/parent_open", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/parent_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/prepare_renewal/parent_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/create", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/write", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/file_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/file_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/parent_open", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/parent_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/accept_pending/parent_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/create", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/write", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/file_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/file_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/parent_open", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/parent_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestAtomicIdentityPersistenceFailuresAndRestartTransitions/promote/parent_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/create", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/write", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/file_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/file_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/link", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/parent_open", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/parent_sync", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestInitialPossessionDurabilityFailureNeverReportsSuccess/parent_close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateUnsafeFilesSymlinksAndSchemaFailClosed/mode", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateUnsafeFilesSymlinksAndSchemaFailClosed/symlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateUnsafeFilesSymlinksAndSchemaFailClosed/schema", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateUnsafeFilesSymlinksAndSchemaFailClosed/corrupt", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateUnsafeFilesSymlinksAndSchemaFailClosed/parent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateRequiresExactRecursiveNamesAndPresence/case", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateRequiresExactRecursiveNamesAndPresence/missing", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateRequiresExactRecursiveNamesAndPresence/collision", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateRequiresExactRecursiveNamesAndPresence/null", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateRequiresExactRecursiveNamesAndPresence/type", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestPrivateStateRequiresExactRecursiveNamesAndPresence/decoded_duplicate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/issuer_oversized", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/leaf_oversized", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/leaf_unknown_eku", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/issuer_eku", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/critical", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/extra_subject", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/issuer_path", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/issuer_duration", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/identity/TestExactLeafAndIssuerProfilesRejectExtraPurposesAndCriticalExtensions/leaf_duration", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/valid-create", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/schema_version-2", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/schema_version-1", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/kind-shell", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/intent_version-True", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/intent_version-0", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/intent_version-9223372036854775808", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/owner_id-00000000-0000-0000-0000-000000000008", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/host_id-00000000-0000-0000-0000-000000000008", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/operation_id-00000000-0000-0000-0000-000000000008", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/command_id-00000000-0000-0000-0000-000000000008", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/request_sha256-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/unknown-privileged-bag", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/required-null-slot", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/create-config-in-wrong-slot", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/missing-approval-ref", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/float-generation", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/wrong-generation", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/native-binding-alias", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/null-submodules", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/wire-default-not-materialized", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/untrusted-request-owner", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/reserved-delete-data", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/decoded-root-duplicate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/nested-duplicate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/float", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/exponent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/surrogate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/trailing", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/empty", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/nonobject", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/unknown-field-canary", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/bad-path", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-33", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-34", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-35", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-36", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-37", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-38", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-39", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-40", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-41", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/literal-repository-42", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestSharedRawVectors/same-operation-key-changed-action", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestAllLifecycleBranchesAndSameRuntimeNewIntent/start", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestAllLifecycleBranchesAndSameRuntimeNewIntent/stop", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestAllLifecycleBranchesAndSameRuntimeNewIntent/remove_runtime", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol/TestAllLifecycleBranchesAndSameRuntimeNewIntent/rebuild", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeAdmissionEligibleDataNeedsExactApprovalStorageAndCurrentFence", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeAdmissionReplayAndReconciliationNeverAuthorizeNewEffects", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationCIDIsAbsentPrivateAndOnlyLiveExactIdentityBinds", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/missing", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/partial", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/newline", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/symlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/duplicate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/foreign", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/revoked", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind/expired", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationUnknownCIDForeignDuplicatesAndRevocationNeverBind", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationEndWinsBeforeObservationAndDeniedReservationHasNoEffect", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestAllocationObserveAndEndSerializeWithoutLateBinding", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPrivateDriverCancellationRegistrationIsOneUseAndUnknownStaysUnknown", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPrivateDriverUnreadPipeCancellationAndCloseReapExactHandler/cancel", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPrivateDriverUnreadPipeCancellationAndCloseReapExactHandler/close", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPrivateDriverUnreadPipeCancellationAndCloseReapExactHandler", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPrivateDriverBridgeStreamsBeforeExitAndCancelsExactClient", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPrivateDriverBridgeRejectsUnknownArgvBeforeInvoke", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPrivateDriverNeverNormalizesInvalidArgumentBytes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialResourceAndHookArgumentsStaySeparated", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialInvocationRejectsMissingIdentityPathsAndPhase", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestFullOverridePreservesSourceAndAddsOnlyProviderPolicy", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawMetadataStrictGateBeforeCLIFallback", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRuntimeIdentityRejectsDuplicatesAndWrongMountOrName", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestVerifiedTargetRequiresExactRecordedImageAndContainerID", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNumericUsersAndUnmappedEngineRequiredBeforeSeed", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestTypedDriverRunPreservesExactScopeMountUsersAndKnownImage", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestTypedDriverHooksOnlyExecKnownTargetAndApprovedEnvironment", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestTypedDriverBuildClosedArgsContextAndGeneratedFile", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestTypedDriverRefusesGlobalUnknownAndLookupMutations", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeRunUsesVerifiedImageIDAndPrivateCIDOnly", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeEventsAlwaysCarryCompleteReservedScopeFilters", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayRechecksIdentityAndAuthorityBeforeEveryExec", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayRefusesDuplicateNameAndRootlessBeforeRun", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayFrozenImageAndSecondAuthorizationBeforePull", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayRunChecksEffectiveMetadataAndFinalImageID", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayRuntimeUserNamespaceAndMetadataRecheckedBeforeExec/user", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayRuntimeUserNamespaceAndMetadataRecheckedBeforeExec/namespace", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayRuntimeUserNamespaceAndMetadataRecheckedBeforeExec/metadata", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGatewayRuntimeUserNamespaceAndMetadataRecheckedBeforeExec", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestGeneratedDockerfilePreservesSourceAndOverridesHostileAuthoredMetadata", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestMetadataBufferCancelsBeforeCaptureExceedsLimit", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestMetadataOverflowKillsAndReapsOwnedChild", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryPrivateEnvironmentCannotUseAmbientCredentialFallback", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/success", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/missing", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/partial", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/foreign-tag", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/failed-build", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/cancelled", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/hostile-metadata", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/symlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeInspectionPreservesFullCandidateSetAndRuntimeUser", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeInspectionRejectsAmbiguousOrPartialSecurityFields", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeInformationRequiresActualExplicitSecurityOptions", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeRunUsesVerifiedIDNeverPullAndBindsOnlyLiveCID", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeRunMissingImageWrongIdentityAndMountRefuseBeforeForward/missing-image", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeRunMissingImageWrongIdentityAndMountRefuseBeforeForward/wrong-image-id", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeRunMissingImageWrongIdentityAndMountRefuseBeforeForward/wrong-user", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeRunMissingImageWrongIdentityAndMountRefuseBeforeForward/foreign-mount", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeRunMissingImageWrongIdentityAndMountRefuseBeforeForward", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeLocalImageRequiresExactIDAndStrictActualMetadata", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuiltTagCannotRetargetFrozenFinalImage", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeLiveCIDEnablesExactOfficialDiscoveryAndInspection", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestFrozenPolicyDoesNotShareMutableAdmissionMaps", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeCandidatesQueriesNameAndRuntimeUnionIncludingForeignPartial", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeReadAuthorizationAndInspectionExactSet", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeInvokeReconstructsOnlyTypedScopeBoundReads", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeOwnedClientStreamsAndCancellationReapsBeforeReturn", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeOwnedClientRequiresDeadlineAndAbsoluteProgram", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPinnedOfficialUpImageAndDockerfileReachClosedMutationGate/image", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPinnedOfficialUpImageAndDockerfileReachClosedMutationGate/dockerfile", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPinnedOfficialUpImageAndDockerfileReachClosedMutationGate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialPhaseResultIsClosedAndNeverAcceptsMarkersOrPartialSuccess", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestReadOnlyDriverKnownImageAndCompleteDiscoveryShapes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestReadOnlyDriverRefusesEveryMutationUnknownAndIncompleteShape", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPinnedOfficialCLIReadConfigurationPreservesSourceAndMetadata", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistrySingleManifestChecksDigestPlatformAndRawCapabilities", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryIndexSelectsUniqueExplicitPlatformWithoutPull", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryRawConfigDigestAndDuplicateKeysBeforeNormalization", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryDeclaredConfigSizeRefusesBeforeToolSpawn", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryDockerCaseAliasesCannotHideInheritedEffects", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryMalformedRuntimeFieldsRefuseBeforeOfficialCLI", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestStorageInspectReturnsOriginalOwnerReadOnlyAcrossRebuild", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestStorageInspectAbsentAndKnownMissingHaveDistinctOutcomes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestStorageInspectForeignInitialBindingAndMarkerPreserved", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedPublishesWholePrivateParentAndRetainsEditedBinaryUntracked", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedKnownHandleMissingAndWrongOriginalNeverCopies", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedAuthorizationPrecedesAnyFilesystemEffect", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedInvalidFinalPreservedAndSymlinkEscapeRefused", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains/owned_temp", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains/copy_file", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains/copy_complete", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains/manifest_synced", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains/before_rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains/after_rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCrashBoundariesNeverPublishPartialAndRetryRetains", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedCompetingPublishersHaveOnePersistentFinal", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedForeignPublisherCollisionNeverOverwrites", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedInitialModesOwnershipAndChangedOwnerRefusal", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset/owned_temp", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset/copy_file", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset/copy_complete", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset/manifest_synced", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset/before_rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset/after_rename", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedSIGKILLBoundariesRecoverWithoutPartialPublicationOrReset", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedIndependentProcessesSerializeOneFinal", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedActualSymlinkChainsNeverEscapeDataOrRemainIndeterminate/escape-after-symlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedActualSymlinkChainsNeverEscapeDataOrRemainIndeterminate/cycle", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedActualSymlinkChainsNeverEscapeDataOrRemainIndeterminate/dangling", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedActualSymlinkChainsNeverEscapeDataOrRemainIndeterminate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedFullyResolvedContainedLinkChainKeepsOriginalLinkBytes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialCompletionRequiresNormalWaitedExitAfterSuccessStdout/exit0", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialCompletionRequiresNormalWaitedExitAfterSuccessStdout/exit17", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialCompletionRequiresNormalWaitedExitAfterSuccessStdout/wait", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialCompletionRequiresNormalWaitedExitAfterSuccessStdout", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/revoked-after-build", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeBuildFreezesOnlySuccessfulIIDAndMatchingFinalTag/cancel-after-build", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryEndpointRefusesPinnedToolPlaintextHeuristics", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryExplicitCABundleIntegrityAndPhysicalPath/missing", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryExplicitCABundleIntegrityAndPhysicalPath/changed", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryExplicitCABundleIntegrityAndPhysicalPath/symlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryExplicitCABundleIntegrityAndPhysicalPath/wrong-hash", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryExplicitCABundleIntegrityAndPhysicalPath/non-ca", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryExplicitCABundleIntegrityAndPhysicalPath", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestJSONCPreservesExactNumericLexemes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRejectsNoncanonicalSchemaIntegerLexemes/1.0000000000000001", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRejectsNoncanonicalSchemaIntegerLexemes/1e0", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRejectsNoncanonicalSchemaIntegerLexemes/9007199254740993", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRejectsNoncanonicalSchemaIntegerLexemes/18446744073709551616", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRejectsNoncanonicalSchemaIntegerLexemes", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialCompletionProcessHelper", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestMetadataOwnedChildHelper", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestNativeProcessHelper", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialMutationRefusalCaptureHelper", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestOfficialReadDriverHelper", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC/schema-version-fraction", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC/size-fraction", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC/size-exponent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedProcessHelper" ], "api_client": [ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", @@ -749,7 +1140,21 @@ "WorkHarborWeb.AgentLogFilterTest/test owned filter installation is idempotent and refuses a conflicting owner", "WorkHarbor.HostIdentityTest/test serialized owner revocation wins concurrent issuance redemption renewal and activation", "WorkHarbor.HostIdentityTest/test enrollment audit failure rolls back consumption identity and active fingerprint", - "WorkHarbor.HostIdentityTest/test native issuer persistence faults return fixed errors and restart only complete exclusive state" + "WorkHarbor.HostIdentityTest/test native issuer persistence faults return fixed errors and restart only complete exclusive state", + "WorkHarbor.ConfigTrustTest/test exact approval repeats without a second approval or audit", + "WorkHarbor.ConfigTrustTest/test changed config submodule origin and complete set require new approval", + "WorkHarbor.ConfigTrustTest/test unknown null malformed descriptors never approve", + "WorkHarbor.ConfigTrustTest/test dashboard revoked and forged principals cannot approve", + "WorkHarbor.ConfigTrustTest/test dashboard may read exact approval but cannot use a foreign owner", + "WorkHarbor.ConfigTrustTest/test expired credentials cannot approve or read", + "WorkHarbor.ConfigTrustTest/test approval identity is immutable and owner FK is enforced", + "WorkHarbor.ConfigTrustTest/test host approval lookup rechecks genuine current assignment and revocation", + "WorkHarbor.ConfigTrustTest/test concurrent independent writers record one immutable revision approval", + "WorkHarbor.StorageBindingTest/test binding carries original create descriptor and reservation", + "WorkHarbor.StorageBindingTest/test first handle requires executing current scope and repeats immutably", + "WorkHarbor.StorageBindingTest/test foreign operation storage host owner and stale intent refuse", + "WorkHarbor.StorageBindingTest/test later approved revision retains original seed and handle", + "WorkHarbor.StorageBindingTest/test independent competing handles have one winner and never replace" ] } } diff --git a/docs/development/devcontainer-matrix.md b/docs/development/devcontainer-matrix.md new file mode 100644 index 00000000..ffa60133 --- /dev/null +++ b/docs/development/devcontainer-matrix.md @@ -0,0 +1,55 @@ +# Dev Container provider capability matrix + +Feature006 T009 remains unaccepted. This matrix describes the reviewed initial +subset and its source implementation; real Linux/Docker acceptance is still pending. +The [provider contract](../../specs/006-whr-cli/contracts/devcontainer-provider.md) +is authoritative. The official Dev Container CLI 0.89.0 supplies provisioning +semantics, with locked JSONC/parser/tool integrity recorded in the contract and pins. + +| Input or behavior | Initial subset | +| --- | --- | +| Configuration | Dedicated HTTPS/explicit-SSH Git origin; exact 40/64-hex root commit and complete sorted project gitlink set; separate owner approval | +| Submodules | Explicit absolute HTTPS/SSH origins and exact approved commits; no recursive/nested or relative-origin resolution | +| Dev Container input | Strict JSONC, comments/trailing commas; fatal UTF-8/scalar checks and recursive decoded duplicate-key refusal | +| Image | Digest-qualified image; raw selected-platform registry config verified before any pull | +| Dockerfile | Pinned maintained grammar; static digest external FROM or prior stage; literal named/final target; official no-Features wrapper | +| Execution users | Both explicit numeric UID:GID pairs, each ID 0..4294967294; containerUser equals remoteUser or 0:0 | +| Ownership | Rootful unmapped Linux Docker and initial chown capability; preserve ordinary source modes; immutable original data UID/GID | +| Commands | onCreate, updateContent, postCreate, postStart as string/argv/object; resource setup and trusted hook phases separated | +| Workspace data | One persistent project bind at /workspace; stop/start/replacement/failed build must retain edited binary and untracked files | +| Initial symlinks | Relative, existing complete chains contained within original projects subtree; absolute/escaping/cyclic/dangling/indeterminate links refuse | +| Environment | Supported containerEnv/remoteEnv string maps; no Dev Container `${...}` substitutions or ambient host credentials | +| Fixed policies | userEnvProbe none, updateRemoteUserUID false, waitFor postStartCommand, shutdownAction none | +| Unsupported inputs | Host initializeCommand/postAttach, Features, Compose, customizations/extensions/dotfiles, raw runArgs/mounts/ports/security/privilege/device/hostRequirements and unknown fields | +| Unsupported image/build capabilities | Raw image/base Volumes or OnBuild; custom frontends, platform/dynamic FROM, Dockerfile VOLUME/ONBUILD and any reserved frontend/provider ARG declaration | +| Runtime identity | Provider-generated deterministic name and eight complete labels plus exact container/image IDs and sole mount; foreign/partial/duplicate matches refuse | +| Completion | Normal full official CLI completion plus exact inspection; CID/IID/events/markers/client exit alone never establish completion | +| Recovery scope | Atomic ORIGINAL seed publication/reuse only; Feature006 T010 owns lifecycle dispatch/reconciliation and durable phase receipts | + +The historical [workspace example](../../examples/workspace/README.md) remains a +design example with a tag/named user. The independently authored +[admitted image](../../examples/workspace/admitted-image/README.md) and +[admitted Dockerfile](../../examples/workspace/admitted-dockerfile/README.md) +examples use Docker Official buildpack-deps noble-scm at immutable index digest +4163ac2c58660c533f3cf3d178551287ddd0ac18269ef84f3a382d0698c89254. +Official metadata was captured before selection; actual raw manifests/config, +platform, tool integrity and numeric nonroot behavior must be independently checked +by the provider and real tests. No tag fallback is permitted. + +Explicit real acceptance entrypoints TestDockerAcceptanceImageFreshCache and +TestDockerAcceptanceDockerfileFreshCache are implemented behind docker_integration. +They remain unrun and unaccepted; they fail on absent +tools/configuration and never skip. Image and Dockerfile runs use sequential fresh +helper-owned Linux guests; each must start without cached workspace images. Required +results include no hooks/provider mutation on refusal, raw hostile metadata, final +metadata overwrite/reopen, nonroot Git/chmod and exact retained hashes through +stop/start/replacement/failed build/approved configuration changes. Linux seed +SIGKILL/competing-publisher tests are additional evidence, not power-loss proof. +Native unit fixtures or partial slice review never substitute for these results. + +The operator's 2026-10-15 limit is MAX3GB PER TEST GUEST, conservatively at most +3,000,000,000 bytes. The planned guest remains 2,147,483,648 bytes; no automatic +increase is authorized. Host-available memory guard proposals are separate from +that per-guest ceiling and from measured containment. Real proof.json/outcome.json +exports are declared by each test-owned artifact-manifest.json; no project files, +private credentials, raw hook output or ambient host configuration is exported. diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 0c0fae83..7061567b 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1308,3 +1308,107 @@ focused vet exits0 in /private/tmp/workharbor-t009-storage-link-review-vet.log. This is author native Darwin correction evidence; exact independent source re-review and all real Linux/Docker acceptance remain pending. The new read-only Store.Inspect integration file/tests are separate WIP outside this checkpoint. + +### T009 complete provider candidate preparation (not accepted) + +The independent config/parser correction at1a38e97 and atomic-seed correction at +ce1ce97 are scoped SOURCE CLEAR; their original P2 RED logs remain preserved. +New provider/Inspect/native-port integration is separate source awaiting exact +whole-candidate review. Feature006 T009 remains unchecked and NOT accepted; +Feature006 T010 waits. T009 owns SEED crash recovery only, not provisioning or +lifecycle reconciliation. T013 delivery remains excluded. + +The operator explicitly directed on2026-10-15: MAX3GB PER TEST GUEST, conservatively +at most3,000,000,000 bytes. Planned actual guest memory remains2,147,483,648 bytes +(2GiB), with no automatic increase. The separately proposed3GiB TOTAL host-available +guard means2GiB guest plus1GiB reserve and remains proposed until exact independent +runner review. Earlier P3's4GiB available threshold was self-imposed, not a physical +RAM/spec requirement. Small prior source-free observations/pressure refusals are +not full Docker acceptance; no actual T009 Docker guest has run. + +Native build IID behavior RED is /private/tmp/workharbor-t009-native-build-behavior-red.log +(exit1, success assertion); focused superseding build/run checks pass3.012s in +/private/tmp/workharbor-t009-native-build-green.log. The initial wrong-directory +setup/no-tests log is retained separately as native-build-red.log, never called a +behavior RED. Live CID discovery RED is native-live-reads-red.log (exit1); full +focused native/Gateway/private-driver superseding pass is native-live-reads-green.log. +All these paths are under /private/tmp/workharbor-t009-. + +Actual pinned CLI0.89.0 up reached image run AND Dockerfile build behind a test-only +adapter refusing every mutation/no daemon. The generated Dockerfile and nonempty +independently derived metadata passed in official-up-capture-first.log (exit0,2.443s). +Subsequent exact build argv/tag/startup integrity assertions pass in +final-provider-focused-superseding.log (exit0,4.221s); original wrong-directory +final-provider-focused.log setup failure remains untouched. This is compatibility +capture, not successful Docker provisioning/full normal official completion. + +The private HTTP stream cancellation test actually failed at5s in +phase-result-native.log. Register-before-invoke/owned connection/input closure was +independently DESIGN CLEAR. Race batches found additional peer-close-before-headers +stdin hangs; provider-race.log, provider-race-superseding.log and +private-pipe-race-final.log retain those failures. Exact invoke-connection input +cleanup supersedes them: private-peer-close-race.log passes3 repeats1.347s; +provider-race-complete-green.log passes all selected concurrency roots with race +instrumentation6.181s, exit0. No daemon quiescence or lifecycle receipt is inferred. + +Full native host suite passes406 test/subtest outcomes with no failures in +/private/tmp/workharbor-t009-host-agent-full-native.jsonl; subsequent focused +source changes still require the final exact-candidate full application gate. +Full host vet exits0 in host-agent-vet.log. Explicit Linux/Docker tagged entrypoints +compile only in real-final-compile-only.log (exit0,0.264s,no tests run); they remain +unrun. Numeric ownership DESIGN CLEAR does not substitute for actual nonroot Linux +Git/chmod/retention, raw actual-tool registry refusal, image/Dockerfile fresh-cache, +seed SIGKILL/competing-publisher and full phase evidence. + +Raw endpoint/CA gates have genuine assertion RED registry-trust-red.log and full +registry superseding GREEN registry-trust-green.log (exit0,5.677s), with separate +compile setup log retained. Metadata tool environment excludes fixture signing-key +variables; ca-env-native.log passes. Dedicated disposable TLS fixture certificate +assets are outside Git and must be exact-bound by the helper; no OS trust change or +real credential is introduced. Registry DNS/CA/tool compatibility remains real proof. + +Required source checker originally failed on the already reviewed pure grammar +imports; source-check.log retains that failure and exact pinned mise setup output. +The same reviewer DESIGN CLEARed only four exact file/import-line exceptions, with +all Docker clients/socket/other imports/CP scans preserved. New negative harness +assertion RED is parser-boundary-red.log. Full Python3.14 canonical make check on +an exact pristine candidate is now required for this harness change, in addition to +native/app/source/manifest gates. No earlier docs405 canonical gate transfers. +Original extended seed cleanup FAILs, all setup/behavior/race REDs and every earlier +superseding GREEN remain retained; no failed log is deleted or relabeled passing. + +Final precommit native host suite reports420 passed test/subtest outcomes with no +failures in /private/tmp/workharbor-t009-host-agent-final-native.jsonl; full vet +exits0 in host-agent-final-vet.log and tagged Docker tests compile only in +Docker-final-compile-only.log (actual filename docker-final-compile-only.log, +exit0,0.277s,no tests run). The new parser-boundary positive/negative assertions +pass; parser-boundary-green.log nevertheless retains a separate copied-checkout +failure because the new example/matrix files were still untracked at that point. +Do not call that aggregate passing. Stage/commit the complete candidate, then rerun +that full harness and the required exact pristine canonical gate. No Linux/Docker +acceptance, whole source review, task checkbox or Feature006 T010 GO follows. + + +Exact-number input correction has author behavior RED in +/private/tmp/workharbor-t009-jsonc-numeric-red.log (exit1,2.438s): the old companion +rounded9007199254740993, fractional schema_version and exponent spelling. The +wrong-directory no-tests setup output is separate jsonc-numeric-setup.log. The +companion now renders validated AST numeric source slices directly, preserving +all existing UTF-8/scalar/decoded-duplicate/finite and official syntax gates. +Registry descriptor numeric RED is registry-number-red.log (exit1,0.921s). +An attempted integer-only wirejson substitution failed two existing registry +success fixtures in registry-number-green.log; despite its filename that log is +FAIL and remains retained. It was superseded by the corrected strict companion, +without imposing wirejson's integer-only profile on image JSON. Full relevant +precision/strict JSONC/registry checks pass in numeric-superseding-green.log +(exit0:config4.098s,registry5.995s). No real Docker evidence follows from these +native checks. The exact complete candidate still requires independent source +review, pinned pristine canonical/native application gates and real Linux proof. + +Complete provider candidate native suite after precision fix: 430 passing +test/subtest outcomes, no failures, in +/private/tmp/workharbor-t009-candidate-host-native.jsonl (exit0); full host vet +exits0 in candidate-host-vet.log. Required IDs are appended, preserving the prior +ordered inventory. The embedded companion source carries the AST correction; +locked external package bytes/integrities are unchanged. Exact candidate source +review/pristine canonical and actual Linux/Docker proof remain pending. diff --git a/examples/workspace/README.md b/examples/workspace/README.md index 1f8ca858..b9eaa077 100644 --- a/examples/workspace/README.md +++ b/examples/workspace/README.md @@ -14,3 +14,11 @@ Git records the project commit. Authenticate through credential helpers, not URL The devcontainer example selects a language image; before real use replace its tag with a verified digest. No fake digest is supplied. Bundles are optional and empty. The platform manifest is WorkHarbor's proposed schema, not a Dev Container field. + +## Current provider subset + +The historical top-level Dev Container example uses a tag and named user; it is not +admitted by the initial T009 provider subset. Separately identified +[image](admitted-image/README.md) and [Dockerfile](admitted-dockerfile/README.md) +inputs use a pinned digest and explicit numeric users. Their real provider +verification is tracked in the [capability matrix](../../docs/development/devcontainer-matrix.md). diff --git a/examples/workspace/admitted-dockerfile/.devcontainer/Dockerfile b/examples/workspace/admitted-dockerfile/.devcontainer/Dockerfile new file mode 100644 index 00000000..0014d3d3 --- /dev/null +++ b/examples/workspace/admitted-dockerfile/.devcontainer/Dockerfile @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: EUPL-1.2 +FROM docker.io/library/buildpack-deps@sha256:4163ac2c58660c533f3cf3d178551287ddd0ac18269ef84f3a382d0698c89254 AS workspace +RUN printf 'WorkHarbor original fixture\n' > /opt/workharbor-example +LABEL org.workharbor.example="original" diff --git a/examples/workspace/admitted-dockerfile/.devcontainer/devcontainer.json b/examples/workspace/admitted-dockerfile/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f2f691f2 --- /dev/null +++ b/examples/workspace/admitted-dockerfile/.devcontainer/devcontainer.json @@ -0,0 +1,20 @@ +{ + "name": "WorkHarbor admitted dockerfile example", + "containerUser": "0:0", + "remoteUser": "1000:1000", + "overrideCommand": true, + "userEnvProbe": "none", + "updateRemoteUserUID": false, + "waitFor": "postStartCommand", + "shutdownAction": "none", + "onCreateCommand": [ + "sh", + "-c", + "test \"$(id -u)\" = 1000 && touch .workharbor-created" + ], + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "target": "workspace" + } +} diff --git a/examples/workspace/admitted-dockerfile/README.md b/examples/workspace/admitted-dockerfile/README.md new file mode 100644 index 00000000..dc838a01 --- /dev/null +++ b/examples/workspace/admitted-dockerfile/README.md @@ -0,0 +1,13 @@ +# Admitted dockerfile configuration example + +Copy this directory as the root of a dedicated configuration Git repository. Commit +it, pin every actual project submodule and explicitly approve that exact root and +submodule revision set before provisioning. The image is a digest-pinned Docker +Official Image; both execution users are explicit numeric UID:GID pairs. + +The current provider requires rootful, unmapped Linux Docker and initial ownership +capability. UID/GID 1000:1000 must work in the actual image; real Linux nonroot Git, +chmod and retention proof remains an acceptance gate. These fixtures are selected +source inputs, not a claim that Docker acceptance has already passed. + +See [the capability matrix](../../../docs/development/devcontainer-matrix.md). diff --git a/examples/workspace/admitted-dockerfile/projects/app/README.md b/examples/workspace/admitted-dockerfile/projects/app/README.md new file mode 100644 index 00000000..6df0b428 --- /dev/null +++ b/examples/workspace/admitted-dockerfile/projects/app/README.md @@ -0,0 +1,6 @@ +# Original example project + +This synthetic EUPL-1.2 project demonstrates retained workspace files. For a Git +project, make it a separately versioned project submodule pinned by the configuration +repository. Runtime acceptance creates actual independently versioned submodules; +this static directory does not pretend to contain a Git repository. diff --git a/examples/workspace/admitted-dockerfile/projects/app/seed.bin b/examples/workspace/admitted-dockerfile/projects/app/seed.bin new file mode 100644 index 0000000000000000000000000000000000000000..9bd31294981e7c97a9ac96fad7a6f39a4d98d2f2 GIT binary patch literal 4 LcmZQz{NDfo0)+vA literal 0 HcmV?d00001 diff --git a/examples/workspace/admitted-dockerfile/workharbor.json b/examples/workspace/admitted-dockerfile/workharbor.json new file mode 100644 index 00000000..cf6d2b30 --- /dev/null +++ b/examples/workspace/admitted-dockerfile/workharbor.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "devcontainer_path": ".devcontainer/devcontainer.json", + "projects_path": "projects", + "bundles": [] +} diff --git a/examples/workspace/admitted-image/.devcontainer/devcontainer.json b/examples/workspace/admitted-image/.devcontainer/devcontainer.json new file mode 100644 index 00000000..86279700 --- /dev/null +++ b/examples/workspace/admitted-image/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "WorkHarbor admitted image example", + "containerUser": "0:0", + "remoteUser": "1000:1000", + "overrideCommand": true, + "userEnvProbe": "none", + "updateRemoteUserUID": false, + "waitFor": "postStartCommand", + "shutdownAction": "none", + "onCreateCommand": [ + "sh", + "-c", + "test \"$(id -u)\" = 1000 && touch .workharbor-created" + ], + "image": "docker.io/library/buildpack-deps@sha256:4163ac2c58660c533f3cf3d178551287ddd0ac18269ef84f3a382d0698c89254" +} diff --git a/examples/workspace/admitted-image/README.md b/examples/workspace/admitted-image/README.md new file mode 100644 index 00000000..90ddc410 --- /dev/null +++ b/examples/workspace/admitted-image/README.md @@ -0,0 +1,13 @@ +# Admitted image configuration example + +Copy this directory as the root of a dedicated configuration Git repository. Commit +it, pin every actual project submodule and explicitly approve that exact root and +submodule revision set before provisioning. The image is a digest-pinned Docker +Official Image; both execution users are explicit numeric UID:GID pairs. + +The current provider requires rootful, unmapped Linux Docker and initial ownership +capability. UID/GID 1000:1000 must work in the actual image; real Linux nonroot Git, +chmod and retention proof remains an acceptance gate. These fixtures are selected +source inputs, not a claim that Docker acceptance has already passed. + +See [the capability matrix](../../../docs/development/devcontainer-matrix.md). diff --git a/examples/workspace/admitted-image/projects/app/README.md b/examples/workspace/admitted-image/projects/app/README.md new file mode 100644 index 00000000..6df0b428 --- /dev/null +++ b/examples/workspace/admitted-image/projects/app/README.md @@ -0,0 +1,6 @@ +# Original example project + +This synthetic EUPL-1.2 project demonstrates retained workspace files. For a Git +project, make it a separately versioned project submodule pinned by the configuration +repository. Runtime acceptance creates actual independently versioned submodules; +this static directory does not pretend to contain a Git repository. diff --git a/examples/workspace/admitted-image/projects/app/seed.bin b/examples/workspace/admitted-image/projects/app/seed.bin new file mode 100644 index 0000000000000000000000000000000000000000..9bd31294981e7c97a9ac96fad7a6f39a4d98d2f2 GIT binary patch literal 4 LcmZQz{NDfo0)+vA literal 0 HcmV?d00001 diff --git a/examples/workspace/admitted-image/workharbor.json b/examples/workspace/admitted-image/workharbor.json new file mode 100644 index 00000000..cf6d2b30 --- /dev/null +++ b/examples/workspace/admitted-image/workharbor.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "devcontainer_path": ".devcontainer/devcontainer.json", + "projects_path": "projects", + "bundles": [] +} diff --git a/scripts/application_checks.py b/scripts/application_checks.py index fb8ae4f7..6bd5db4d 100644 --- a/scripts/application_checks.py +++ b/scripts/application_checks.py @@ -134,7 +134,7 @@ def check_applications(root, inventory, run): if '.'.join(map(str, sys.version_info[:3])) != pins['python']: raise ValueError('Python does not match .tool-versions') tools = {} - for name in ('go', 'gofmt', 'mix', 'elixir', 'erl', 'node'): + for name in ('go', 'gofmt', 'mix', 'elixir', 'erl', 'node', 'npm'): tools[name] = shutil.which(name) if not tools[name]: raise ValueError('Missing required tool: ' + name + '; install .tool-versions before checking') @@ -174,6 +174,11 @@ def execute(argv, cwd=root): for argv, matches in versions: if not matches(execute(argv).read_text()): raise ValueError('Tool version does not match .tool-versions: ' + argv[0]) + env['npm_config_cache'] = str(cache/'npm') + env['npm_config_userconfig'] = os.devnull + env['npm_config_globalconfig'] = os.devnull + execute([tools['npm'], 'ci', '--ignore-scripts', '--no-audit', '--no-fund', + '--registry=https://registry.npmjs.org'], root/'apps/host_agent/tools/devcontainer') for module in GO_MODULES: cwd = root/'apps'/module if execute([tools['gofmt'], '-l', '.'], cwd).read_bytes(): diff --git a/scripts/check_repo.py b/scripts/check_repo.py index 44b6735b..fe57994c 100644 --- a/scripts/check_repo.py +++ b/scripts/check_repo.py @@ -48,7 +48,18 @@ def boundary_errors(relative, text): if path.startswith('apps/control_plane/') and re.search(r'docker\.sock|github\.com/(docker|moby)/|DockerProvider', text): errors.append(f'{path}: provider implementation leaked into control plane; call a port') if path.startswith('apps/host_agent/') and '/providers/' not in path: - if re.search(r'"github\.com/(docker|moby)/|docker\.sock', text): + # These exact pure input-grammar imports implement the reviewed config + # capability gate; they expose no daemon/provider access. No module-prefix + # exception, alias, comment, mixed statement or other file is admitted. + grammar = 'github.com/moby/buildkit/frontend/dockerfile/' + pure_imports = { + 'apps/host_agent/internal/config/dockerfile_plan.go': {'parser'}, + 'apps/host_agent/internal/config/trusted.go': {'parser', 'instructions', 'linter'}, + } + allowed_lines = {'"' + grammar + name + '"' for name in pure_imports.get(path, set())} + import_scan = ''.join(line for line in text.splitlines(keepends=True) + if line.strip() not in allowed_lines) + if re.search(r'"github\.com/(docker|moby)/', import_scan) or re.search(r'docker\.sock', text): errors.append(f'{path}: Docker access outside provider adapter; move it under providers/') if path.startswith('apps/') and re.search(r'openai/symphony|codex app-server', text): errors.append(f'{path}: development orchestrator entered product code; remove the dependency') diff --git a/scripts/test_application_checks.py b/scripts/test_application_checks.py index a0932a24..2112b8d4 100644 --- a/scripts/test_application_checks.py +++ b/scripts/test_application_checks.py @@ -151,6 +151,24 @@ def test_fixed_gates_locked_setup_external_caches_and_all_modules(self): self.assertNotIn('ERL_AFLAGS', env) self.assertNotIn('PHX_SERVER', env) + def test_pinned_companion_setup_precedes_host_tests_and_refuses_scripts(self): + observed, root = self.exercise() + setup = [(i, argv, cwd, env) for i, (argv, cwd, env) in enumerate(observed) + if Path(argv[0]).name == 'npm' and argv[1:2] == ['ci']] + self.assertEqual(1, len(setup)) + index, argv, cwd, env = setup[0] + self.assertEqual(['ci', '--ignore-scripts', '--no-audit', '--no-fund', + '--registry=https://registry.npmjs.org'], argv[1:]) + self.assertEqual(root.resolve()/'apps/host_agent/tools/devcontainer', cwd) + self.assertFalse(Path(env['npm_config_cache']).is_relative_to(root)) + host_test = next(i for i, (argv, cwd, _) in enumerate(observed) + if Path(cwd).name == 'host_agent' and argv[1:3] == ['test', '-json']) + self.assertLess(index, host_test) + with self.assertRaisesRegex(ValueError, 'Missing required tool'): + self.exercise(missing='npm') + with self.assertRaises(ValueError): + self.exercise(fail=lambda argv: Path(argv[0]).name == 'npm' and 'ci' in argv) + def test_ci_and_local_share_the_application_gate(self): root = Path(__file__).resolve().parents[1] make = (root/'Makefile').read_text() diff --git a/specs/006-whr-cli/contracts/devcontainer-provider.md b/specs/006-whr-cli/contracts/devcontainer-provider.md index 8f80f25b..a335b284 100644 --- a/specs/006-whr-cli/contracts/devcontainer-provider.md +++ b/specs/006-whr-cli/contracts/devcontainer-provider.md @@ -75,7 +75,11 @@ Devcontainer-relative parent references may resolve only inside approved config staging; lexical validation alone cannot establish containment. Devcontainer JSONC uses explicitly pinned official jsonc-parser. Reject syntax and -duplicate decoded keys at every depth before semantic conversion. The accepted +duplicate decoded keys at every depth before semantic conversion. Render validated +AST number nodes from their original source slices, without JavaScript numeric +conversion; native UseNumber retains exact lexemes and integer gates reject +fractional/exponent/overflow spellings rather than coercing them. This also applies +to strict raw registry JSON before descriptor size/schema checks. The accepted integer-only wirejson profile is not a Devcontainer parser. Initial source values containing Devcontainer `${...}` substitutions are unsupported. @@ -171,7 +175,24 @@ script-disabled npm installation; the original JSONC wrapper is embedded product code, so a repository cannot select/replace its parser program. Shared native application gates install this same lock with npm ci --ignore-scripts first. -Buildx v0.38.0 is pinned for raw registry metadata without a bespoke registry client. +Buildx v0.38.0 is pinned for raw registry manifests. Its formatted .Image output +normalizes JSON and drops Docker Config.OnBuild, so it never authorizes raw config +capabilities. The independently reviewed amendment pins maintained crane v0.22.1 +(Apache-2.0), including official Linux arm64/amd64 archive and extracted binary +SHA256 values, for exactly `crane config `; no bespoke registry +client, layers, save or pull. Verify raw root/selected-leaf manifest SHA256, then +raw config SHA256 AND its declared positive byte size before strict duplicate/type/ +metadata parsing. Each raw manifest/config has an explicit 1MiB host input limit; +reject oversized advertised config size before spawning crane, and enforce streamed +stdout size/deadline with owned-child kill/reap and discarded bounded stderr. + +Registry tools receive a fresh host-owned0700 private HOME/config/XDG staging area, +both HOME/.docker/config.json and DOCKER_CONFIG/config.json containing only `{}` +with0600 permissions, and empty private XDG_CONFIG_HOME/XDG_RUNTIME_DIR. Environment +is restricted to tool PATH, LANG and those private paths; ambient registry auth, +cloud/proxy credentials and credential-helper configuration never pass through. +No insecure endpoint/source flags or tags/indexes substitute for the selected leaf. +Unavailable/ambiguous/over-budget raw input refuses before provider effects. Node26.11.1 is the repository runtime pin. These selections are not measured provider compatibility; exact installed version/hash and real Docker evidence must pass before acceptance. Docker Engine29.8.2 is the initial intended provider pin; @@ -189,6 +210,23 @@ metadata is allowed; unavailable/ambiguous metadata refuses without pull. The CLI's registry-inspect failure can fall back to docker pull: the adapter MUST block it in preflight. No cache-only requirement or separate image trust grant. +The reviewed native run identity amendment uses a fresh exclusively reserved +host-private operation directory and generated CIDfile path, left absent for Docker +itself. Raw source CIDfile/runArgs remain forbidden. Docker CLI's CIDfile creation +is not an exclusive/fsync guarantee; only the live same fenced allocation request, +fresh current authorization, full64hex CID, and independent unique full name/all +labels/image/sole mount inspection can bind the first exact target. Never replace +a different target or adopt from names/events/labels after ambiguous loss. CIDfile +is allocation identity only, never completion or a durable receipt. + +The native typed run uses the independently verified FINAL local config ImageID +and fixed provider `--pull=never`; missing images/layers fail unavailable without +implicit pull or tag fallback. Explicit admitted digest pulls occur only after all +approval/capability gates, followed by fresh raw/local metadata+ID checks. Official +up retains workflow authority; no native create/start workflow substitute is added. +Typed run/events/exact-ID exec streams stay within the phase's owned host-client +lifetime; disconnect cancels/reaps that client without inferring daemon quiescence. + ## Runtime identity and closed provider driver Opaque provider handles stay in adapter scope. Runtime name derives only from @@ -331,3 +369,62 @@ head; run appropriate native/pristine app/source/manifest gates and retain raw l Exact pins/install/build, native behavior and actual guest proof remain unverified until recorded. No wire/lifecycle/browser/terminal completion is implied by native trust/provider primitives or by this consolidation review. + +## Native implementation alignment and reviewed transport refinements + +Provider.Prepare accepts only native Admission produced from authenticated T007 +context, exact approval and authoritative ORIGINAL storage binding plus current +fence checker. It resolves/seals source, admits all raw registry inputs, checks +rootful unmapped daemon/candidate union and explicit users, validates full official +read-configuration, then Inspect ALWAYS Seed (including existing-final parent +fsync/current Check) before returning a handle. A newly absent final requires the +resolved descriptor equal ORIGINAL; known handle plus absent final refuses. + +Prepared.Resources and Hooks are one-attempt native primitives, not a dispatcher. +Explicit typed digest pulls happen only after every preparation gate, then official +up performs resource semantics. Hooks require positively completed resources and +exact-ID run-user-commands. Full normal Node exit, private adapter/client reap, +closed result, independent effective-input comparison and fresh exact running +runtime inspection precede PhaseEvidence. T010 must durably record that evidence +before acknowledgment; it owns all interrupted provisioning/lifecycle recovery. +Close cancels and reaps owned official CLI/client groups before private staging +cleanup; cancellation never proves daemon completion or ends container hooks. + +The generated build uses one private absent provider-only IID file, added only to +reconstructed native buildx argv. Full normal build exit plus exact owned regular +IID bytes, inspection of THAT image ID, nonempty expected final metadata and tag +same-ID agreement precede immutable proof registration. Missing, stale, partial, +foreign, mismatched, failed or canceled evidence never registers/adopts/reuses. +The final built image supplies command expectations when overrideCommand is false; +never substitute the original base image's command. Official startup script bytes +are measured against pinned CLI0.89.0 and retained only as SHA256 expectation. + +The private Unix streaming adapter registers a random 32-byte/64-hex token before +one-use invoke. Registration alone starts no work. Duplicate/canceled/unregistered +invokes refuse; cancellation closes only that exact private request connection and +owned input, cancels its native client context and waits boundedly for completion +or reports unknown. A positive completion acknowledgment requires the exact forward +handler/client to finish, never daemon/resource quiescence. Closing registration, +aborting owned input/output and reaping handlers is part of phase cleanup. No +browser/CP wire capability, broad socket executor or source-selected endpoint exists. + +Pinned crane uses plaintext automatically for loopback/.localhost/RFC1918 literal +registry authorities even without an insecure flag. These and link-local/unspecified +literal authorities refuse before metadata subprocesses/provider effects. Named +registry authorities use the pinned tools' HTTPS entrypoint; do not claim a stronger +transport policy than measured pinned-tool behavior. Every raw manifest/config +still requires the exact approved digest/size chain and strict admission. Optional +CAFile/CASHA256 is trusted native host setup only: exact physical privately owned +regular bounded PEM CA bytes/hash/validity verified before each subprocess; only +SSL_CERT_FILE is appended to the existing private registry-read environment. No +source/wire/env passthrough, credential helper, TLS private key, insecure flag or OS +trust mutation. A changed/missing/invalid bundle refuses. Actual pinned-tool TLS +compatibility is a required real test, not established by native fixtures. + +The maintained pure Dockerfile grammar imports remain under config capability +validation. The architecture checker permits only exact whole-line unaliased +parser import in dockerfile_plan.go and parser/instructions/linter in trusted.go. +All Docker clients/sockets, other imports/files and control-plane provider access +remain rejected, with preserved inherited and new independent negative harness IDs. +This checker alignment requires full canonical verification on the exact pristine +candidate; earlier docs-only canonical evidence cannot cover it. diff --git a/tests/test_harness.py b/tests/test_harness.py index cb1e5067..5fa649fd 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -53,6 +53,26 @@ def test_provider_dependency_rejected_outside_adapter(self): self.assertEqual([], checks.boundary_errors(Path('apps/host_agent/internal/providers/docker/provider.go'), text)) self.assertTrue(checks.boundary_errors(Path('apps/control_plane/lib/service.ex'), 'DockerProvider')) + def test_exact_pure_dockerfile_grammar_imports_do_not_grant_docker_access(self): + grammar = 'github.com/moby/buildkit/frontend/dockerfile/' + base = 'apps/host_agent/internal/config/' + allowed = {'dockerfile_plan.go': ['parser'], 'trusted.go': ['parser', 'instructions', 'linter']} + for filename, packages in allowed.items(): + for package in packages: + text = ' "' + grammar + package + '" ' + with self.subTest(filename=filename, package=package): + self.assertEqual([], checks.boundary_errors(Path(base + filename), text)) + self.assertTrue(checks.boundary_errors(Path(base + 'other.go'), text)) + for bad in (text + '\n"github.com/docker/docker/client"', + text + '\ndocker.sock', + 'alias "' + grammar + package + '"', + text + ' // comment', + text + '; "github.com/moby/buildkit/client"'): + self.assertTrue(checks.boundary_errors(Path(base + filename), bad)) + self.assertTrue(checks.boundary_errors(Path('apps/control_plane/lib/test.ex'), text)) + self.assertTrue(checks.boundary_errors(Path(base + 'trusted.go'), '"' + grammar + 'shell"')) + self.assertTrue(checks.boundary_errors(Path(base + 'dockerfile_plan.go'), '"' + grammar + 'instructions"')) + def test_development_orchestrator_rejected_in_product(self): self.assertTrue(checks.boundary_errors(Path('apps/control_plane/lib/service.ex'), 'codex app-server')) From 08de861608478ff3e7049813728005e561e53dfc Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 18:42:57 +0200 Subject: [PATCH 31/51] fix(ci): retain schema-valid provider test identities Keep the required-ID inventory within its existing Test identity schema while preserving full fuzz seed execution and every prior required identity. Retain the original canonical preflight refusal as evidence. Refs: #72 Co-authored-by: Codex --- docs/development/ci-acceptance.json | 15 --------------- docs/exec-plans/active/installable-pilot.md | 9 +++++++++ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index c029301e..05b2f8f9 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -658,21 +658,6 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.normal-dot-empty-text", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.dot-float", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestFrozenPathVectors/path.surrogate-low", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#0", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#1", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#2", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#3", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#4", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#5", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#6", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#7", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#8", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#9", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#10", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#11", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#12", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity/seed#13", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/FuzzLexicalPathIdentity", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_identity_id", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/changed_identity_id", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/connection/TestActivationRejectsIncompleteOrMismatchedIssuedIdentity/missing_certificate", diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 7061567b..de0bb340 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1412,3 +1412,12 @@ exits0 in candidate-host-vet.log. Required IDs are appended, preserving the prio ordered inventory. The embedded companion source carries the AST correction; locked external package bytes/integrities are unchanged. Exact candidate source review/pristine canonical and actual Linux/Docker proof remain pending. + +Exact2042586 pristine canonical preflight refused the newly added15 Fuzz* seed +identities because the existing application required-ID schema accepts Test* +identities only. Original exit2/log/result remain in +/private/tmp/workharbor-t009-canonical-2042586-2uh5doa_/. Remove only those new +non-schema inventory entries; every predecessor and newly required Test* identity +is retained. Full go test still executes all fuzz seed cases (430 outcomes); +415 Test* outcomes remain required. No schema/check relaxation or passing +canonical claim is made; a new pristine exact candidate must run all gates. From 10be1df6173f456c866f0e1a15ba1e773e2ec281 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 18:52:07 +0200 Subject: [PATCH 32/51] fix(provider): preserve official result and config boundaries Use exact-number strict parsing for resource provenance, derive the pinned CLI root display from numeric ownership only, and refuse unknown raw Docker Config fields consistently. Reproduce all three independent review issues and retain original failures. Refs: #72 Co-authored-by: Codex --- .../docker/completion_process_test.go | 2 +- .../providers/docker/official_phase.go | 24 +++++++++++++-- .../internal/providers/docker/phase_result.go | 6 ++-- .../providers/docker/phase_result_test.go | 25 ++++++++++++++-- .../docker/read_configuration_test.go | 30 +++++++++++++++++++ .../internal/providers/docker/registry.go | 3 +- .../providers/docker/registry_test.go | 15 ++++++++++ docs/development/ci-acceptance.json | 12 +++++++- docs/development/devcontainer-matrix.md | 2 +- docs/exec-plans/active/installable-pilot.md | 21 +++++++++++++ .../contracts/devcontainer-provider.md | 16 +++++++++- 11 files changed, 142 insertions(+), 14 deletions(-) diff --git a/apps/host_agent/internal/providers/docker/completion_process_test.go b/apps/host_agent/internal/providers/docker/completion_process_test.go index b44f3746..aba76f2b 100644 --- a/apps/host_agent/internal/providers/docker/completion_process_test.go +++ b/apps/host_agent/internal/providers/docker/completion_process_test.go @@ -51,7 +51,7 @@ func TestOfficialCompletionRequiresNormalWaitedExitAfterSuccessStdout(t *testing if e != nil { t.Fatal(e) } - if _, e := phaseResult(raw, Hooks, ""); e != nil { + if _, e := phaseResult(context.Background(), registryParser(t), raw, Hooks, ""); e != nil { t.Fatal(e) } } else if e == nil || len(raw) != 0 { diff --git a/apps/host_agent/internal/providers/docker/official_phase.go b/apps/host_agent/internal/providers/docker/official_phase.go index 5235d7dd..33df4a87 100644 --- a/apps/host_agent/internal/providers/docker/official_phase.go +++ b/apps/host_agent/internal/providers/docker/official_phase.go @@ -138,13 +138,13 @@ func (p *Prepared) runPhase(ctx context.Context, phase Phase, id string) (PhaseE if e != nil { return PhaseEvidence{}, e } - result, e := phaseResult(raw, phase, observed.ContainerID) + result, e := phaseResult(ctx, config.JSONC{NodePath: p.tools.NodePath, CompanionDir: p.tools.Root}, raw, phase, observed.ContainerID) if e != nil { return PhaseEvidence{}, e } if phase == Resources { - if result["remoteUser"] != strings.SplitN(policy.RemoteUser, ":", 2)[0] { - return PhaseEvidence{}, fail("configuration_changed") + if e := validateResourceUser(result, policy.RemoteUser); e != nil { + return PhaseEvidence{}, e } effective := map[string]any{"configuration": result["configuration"], "mergedConfiguration": result["mergedConfiguration"], "workspace": p.effective["workspace"], "ociAuthDiagnostics": result["ociAuthDiagnostics"]} if e = ValidateEffective(effective, p.source.Source, p.metadata, input); e != nil { @@ -166,3 +166,21 @@ func (p *Prepared) runPhase(ctx context.Context, phase Phase, id string) (PhaseE descriptor, _ := config.DecodeDescriptor(p.source.Descriptor.Map()) return PhaseEvidence{Phase: phase, Target: observed, Descriptor: descriptor}, nil } + +// The CLI display user is checked separately from the authoritative numeric pair. +func validateResourceUser(result map[string]any, remotePair string) error { + uid, _, ok := parseUser(remotePair) + if !ok { + return fail("configuration_changed") + } + expected := strings.SplitN(remotePair, ":", 2)[0] + // Pinned CLI0.89.0 createContainerProperties displays UID0 as root. + // This does not resolve names or alter the enforced UID:GID pair. + if uid == 0 { + expected = "root" + } + if result["remoteUser"] != expected { + return fail("configuration_changed") + } + return nil +} diff --git a/apps/host_agent/internal/providers/docker/phase_result.go b/apps/host_agent/internal/providers/docker/phase_result.go index 04ef34e5..b50a9e32 100644 --- a/apps/host_agent/internal/providers/docker/phase_result.go +++ b/apps/host_agent/internal/providers/docker/phase_result.go @@ -3,13 +3,13 @@ package docker import ( "context" - "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" ) // phaseResult validates only a fully waited normal CLI exit's stdout. This // function alone grants no receipt: final native target inspection is required. -func phaseResult(raw []byte, phase Phase, containerID string) (map[string]any, error) { - value, e := wirejson.Parse(raw) +func phaseResult(ctx context.Context, parser config.JSONC, raw []byte, phase Phase, containerID string) (map[string]any, error) { + value, e := parser.Parse(ctx, raw, true) if e != nil { return nil, fail("phase_unknown") } diff --git a/apps/host_agent/internal/providers/docker/phase_result_test.go b/apps/host_agent/internal/providers/docker/phase_result_test.go index 342c4773..ae51a35d 100644 --- a/apps/host_agent/internal/providers/docker/phase_result_test.go +++ b/apps/host_agent/internal/providers/docker/phase_result_test.go @@ -2,6 +2,7 @@ package docker import ( + "context" "encoding/json" "strings" "testing" @@ -11,15 +12,33 @@ func TestOfficialPhaseResultIsClosedAndNeverAcceptsMarkersOrPartialSuccess(t *te id := strings.Repeat("a", 64) valid := map[string]any{"outcome": "success", "containerId": id, "remoteUser": "1000", "remoteWorkspaceFolder": "/workspace", "configuration": map[string]any{}, "mergedConfiguration": map[string]any{}} raw, _ := json.Marshal(valid) - if _, e := phaseResult(raw, Resources, id); e != nil { + if _, e := phaseResult(context.Background(), registryParser(t), raw, Resources, id); e != nil { t.Fatal(e) } for _, raw := range []string{`{"outcome":"success"}`, `{"outcome":"success","outcome":"error"}`, `{"outcome":"success","result":"skipNonBlocking"}`, `{"outcome":"success","result":"done","unknown":true}`, `{"result":"done"}`, `marker done`, "{\"outcome\":\"success\",\"result\":\"done\"}\n{}"} { - if _, e := phaseResult([]byte(raw), Hooks, id); e == nil { + if _, e := phaseResult(context.Background(), registryParser(t), []byte(raw), Hooks, id); e == nil { t.Fatal("incomplete result admitted", raw) } } - if _, e := phaseResult([]byte(`{"outcome":"success","result":"done"}`), Hooks, id); e != nil { + if _, e := phaseResult(context.Background(), registryParser(t), []byte(`{"outcome":"success","result":"done"}`), Hooks, id); e != nil { t.Fatal(e) } } + +func TestResourceResultNumericUsersMatchPinnedCLIDisplay(t *testing.T) { + for _, row := range []struct { + pair, display string + valid bool + }{ + {"0:0", "root", true}, {"1000:1000", "1000", true}, + {"0:0", "0", false}, {"0:0", "1000", false}, {"1000:1000", "root", false}, + {"1000:1000", "vscode", false}, {"1000", "1000", false}, + } { + t.Run(row.pair+"-"+row.display, func(t *testing.T) { + result := map[string]any{"remoteUser": row.display} + if e := validateResourceUser(result, row.pair); (e == nil) != row.valid { + t.Fatalf("pair=%s display=%s error=%v wantValid=%v", row.pair, row.display, e, row.valid) + } + }) + } +} diff --git a/apps/host_agent/internal/providers/docker/read_configuration_test.go b/apps/host_agent/internal/providers/docker/read_configuration_test.go index cdf8ac7d..7407e98d 100644 --- a/apps/host_agent/internal/providers/docker/read_configuration_test.go +++ b/apps/host_agent/internal/providers/docker/read_configuration_test.go @@ -95,6 +95,36 @@ func TestPinnedOfficialCLIReadConfigurationPreservesSourceAndMetadata(t *testing raw, _ := json.Marshal(result) t.Fatalf("effective gate: %v candidate=%s", e, raw) } + t.Run("resource-result-retains-actual-official-provenance", func(t *testing.T) { + id := strings.Repeat("a", 64) + up := map[string]any{"outcome": "success", "containerId": id, "remoteUser": "1000", "remoteWorkspaceFolder": "/workspace", "configuration": result["configuration"], "mergedConfiguration": result["mergedConfiguration"], "ociAuthDiagnostics": result["ociAuthDiagnostics"]} + raw, e := json.Marshal(up) + if e != nil { + t.Fatal(e) + } + decoded, e := phaseResult(context.Background(), registryParser(t), raw, Resources, id) + if e != nil { + t.Fatal(e) + } + effective := map[string]any{"configuration": decoded["configuration"], "mergedConfiguration": decoded["mergedConfiguration"], "workspace": result["workspace"], "ociAuthDiagnostics": decoded["ociAuthDiagnostics"]} + if e := ValidateEffective(effective, source, metadata, input); e != nil { + t.Fatalf("normal official resource result lost provenance: %v", e) + } + for _, number := range []string{"1.0000000000000001", "1e0", "9007199254740993"} { + bad := strings.ReplaceAll(string(raw), `"$mid":1`, `"$mid":`+number) + if bad == string(raw) { + t.Fatal("official provenance fixture has no numeric mid") + } + decoded, e := phaseResult(context.Background(), registryParser(t), []byte(bad), Resources, id) + if e != nil { + continue + } + effective["configuration"], effective["mergedConfiguration"] = decoded["configuration"], decoded["mergedConfiguration"] + if ValidateEffective(effective, source, metadata, input) == nil { + t.Fatalf("noncanonical provenance integer admitted: %s", number) + } + } + }) merged, ok := result["mergedConfiguration"].(map[string]any) if !ok { t.Fatal("missing effective config") diff --git a/apps/host_agent/internal/providers/docker/registry.go b/apps/host_agent/internal/providers/docker/registry.go index 5d8f02ff..d561769e 100644 --- a/apps/host_agent/internal/providers/docker/registry.go +++ b/apps/host_agent/internal/providers/docker/registry.go @@ -318,7 +318,8 @@ func (r Registry) Inspect(ctx context.Context, ref string) (AdmittedImage, error } func admitDockerConfig(ctx context.Context, p config.JSONC, cfg map[string]any) (ImageConfig, error) { - if !canonicalDockerKeys(cfg, []string{"User", "Env", "Entrypoint", "Cmd", "Volumes", "OnBuild", "Labels", "WorkingDir", "ExposedPorts", "StopSignal", "ArgsEscaped", "Healthcheck", "Shell", "StopTimeout"}) { + known := []string{"User", "Env", "Entrypoint", "Cmd", "Volumes", "OnBuild", "Labels", "WorkingDir", "ExposedPorts", "StopSignal", "ArgsEscaped", "Healthcheck", "Shell", "StopTimeout"} + if !exactKeys(cfg, nil, known...) || !canonicalDockerKeys(cfg, known) { return ImageConfig{}, fail("metadata_unavailable") } var typed dockerspec.DockerOCIImage diff --git a/apps/host_agent/internal/providers/docker/registry_test.go b/apps/host_agent/internal/providers/docker/registry_test.go index ff683e6d..5cb120a5 100644 --- a/apps/host_agent/internal/providers/docker/registry_test.go +++ b/apps/host_agent/internal/providers/docker/registry_test.go @@ -125,3 +125,18 @@ func TestRegistryMalformedRuntimeFieldsRefuseBeforeOfficialCLI(t *testing.T) { } } } + +func TestRegistryUnknownRawDockerConfigFieldRefusesAfterDigestVerification(t *testing.T) { + parser := registryParser(t) + raw := `{"architecture":"arm64","os":"linux","config":{"WorkHarborUnknown":true}}` + selected := registrySelection{Reference: "example.test/fixture@sha256:" + strings.Repeat("a", 64), ConfigID: registryDigest(raw), ConfigSize: int64(len(raw))} + if _, e := decodeRegistryImage(context.Background(), parser, selected, "arm64", []byte(raw)); e == nil { + t.Fatal("unknown raw Config field silently admitted") + } + ancillary := `{"architecture":"arm64","os":"linux","WorkHarborAncillary":{"record":true},"config":{}}` + selected.ConfigID, selected.ConfigSize = registryDigest(ancillary), int64(len(ancillary)) + image, e := decodeRegistryImage(context.Background(), parser, selected, "arm64", []byte(ancillary)) + if e != nil || image.Document["WorkHarborAncillary"].(map[string]any)["record"] != true { + t.Fatalf("ancillary root field not preserved: %#v %v", image, e) + } +} diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 05b2f8f9..df73fbf9 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -944,7 +944,17 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC/size-fraction", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC/size-exponent", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRawRegistryManifestIntegerLexemesAreNotRoundedByJSONC", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedProcessHelper" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage/TestSeedProcessHelper", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/0:0-root", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/1000:1000-1000", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/0:0-0", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/0:0-1000", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/1000:1000-root", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/1000:1000-vscode", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/1000-1000", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPinnedOfficialCLIReadConfigurationPreservesSourceAndMetadata/resource-result-retains-actual-official-provenance", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryUnknownRawDockerConfigFieldRefusesAfterDigestVerification" ], "api_client": [ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", diff --git a/docs/development/devcontainer-matrix.md b/docs/development/devcontainer-matrix.md index ffa60133..af197cbe 100644 --- a/docs/development/devcontainer-matrix.md +++ b/docs/development/devcontainer-matrix.md @@ -11,7 +11,7 @@ semantics, with locked JSONC/parser/tool integrity recorded in the contract and | Configuration | Dedicated HTTPS/explicit-SSH Git origin; exact 40/64-hex root commit and complete sorted project gitlink set; separate owner approval | | Submodules | Explicit absolute HTTPS/SSH origins and exact approved commits; no recursive/nested or relative-origin resolution | | Dev Container input | Strict JSONC, comments/trailing commas; fatal UTF-8/scalar checks and recursive decoded duplicate-key refusal | -| Image | Digest-qualified image; raw selected-platform registry config verified before any pull | +| Image | Digest-qualified image; raw selected-platform registry config verified before any pull; closed raw Config keys as enumerated in the provider contract, unknown Config keys refuse (ancillary root fields preserved) | | Dockerfile | Pinned maintained grammar; static digest external FROM or prior stage; literal named/final target; official no-Features wrapper | | Execution users | Both explicit numeric UID:GID pairs, each ID 0..4294967294; containerUser equals remoteUser or 0:0 | | Ownership | Rootful unmapped Linux Docker and initial chown capability; preserve ordinary source modes; immutable original data UID/GID | diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index de0bb340..307dadeb 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1421,3 +1421,24 @@ non-schema inventory entries; every predecessor and newly required Test* identit is retained. Full go test still executes all fuzz seed cases (430 outcomes); 415 Test* outcomes remain required. No schema/check relaxation or passing canonical claim is made; a new pristine exact candidate must run all gates. + +Whole-candidate static review reported three concrete integration issues, all +reproduced before correction: phase-result provenance used integer-only wirejson +(*big.Int) while effective provenance expects exact json.Number; unknown raw Docker +Config keys were retained rather than refused by the claimed closed fixture; +canonical numeric UID0 displays as root in pinned CLI0.89.0. Genuine assertion REDs +are /private/tmp/workharbor-t009-phase-provenance-red.log (exit1,1.424s), +raw-config-subset-red.log (exit1,0.432s), and resource-user-red.log (exit1,0.247s). +Corrections reuse the strict companion for normal phase stdout, explicitly close +raw Config keys consistently for registry/local images while preserving ancillary +root fields, and derive root display only from an already canonical numeric UID0. +No names grant ownership. Actual CLI preflight output flows through the resource +result/effective validation regression, including fractional/exponent MID refusal. +The full superseding host native batch passes 440 test/subtest outcomes, +with 425 schema-valid required Test* IDs, in +/private/tmp/workharbor-t009-three-fixes-full-native.jsonl (exit0). Original REDs +remain retained. Chosen public-image capture currently contains tag/platform +metadata only; its actual raw Config fit and tool compatibility remain mandatory +real Linux gates, with no subset weakening if unsupported. These author fixes +await exact retained-reviewer closure; canonical restart and real acceptance +remain pending, Feature006 T009 unchecked and T010 waiting. diff --git a/specs/006-whr-cli/contracts/devcontainer-provider.md b/specs/006-whr-cli/contracts/devcontainer-provider.md index a335b284..5a85c795 100644 --- a/specs/006-whr-cli/contracts/devcontainer-provider.md +++ b/specs/006-whr-cli/contracts/devcontainer-provider.md @@ -132,7 +132,13 @@ build. Reject source build.args and ARG declarations in BUILDKIT_ and _DEV_CONTAINERS_ namespaces. Scrub inherited frontend/Buildx/Docker configuration; use fixed provider endpoint/tool paths. Independently validate every external base's raw metadata, with Config.Volumes and Config.OnBuild absent/empty. No extra or -anonymous-volume support. No bespoke shell/Dockerfile instruction interpreter. +anonymous-volume support. Raw Docker Config keys are closed to User, Env, +Entrypoint, Cmd, Volumes, OnBuild, Labels, WorkingDir, ExposedPorts, StopSignal, +ArgsEscaped, Healthcheck, Shell and StopTimeout, with maintained Docker types, +exact case and strict duplicate checks. Any other Config key diagnoses/refuses +before effects; apply the same gate to local post-pull/build image Config. +Ancillary root image-document fields are preserved in the raw Document and do not +extend Config/metadata capabilities. No bespoke shell/Dockerfile interpreter. Raw image devcontainer.metadata is independently strict-decoded before CLI merge; malformed/duplicate/unknown content never becomes an accepted CLI empty fallback. @@ -428,3 +434,11 @@ All Docker clients/sockets, other imports/files and control-plane provider acces remain rejected, with preserved inherited and new independent negative harness IDs. This checker alignment requires full canonical verification on the exact pristine candidate; earlier docs-only canonical evidence cannot cover it. + +Pinned CLI resource stdout displays canonical numeric UID0 as `root` and other +UIDs as their decimal string. Validate exactly that derived display value; it +provides no named-user resolution or ownership authority. Source/effective/run/exec +users and immutable initial ownership remain the exact admitted numeric UID:GID. +Normal resource/hook stdout uses the same strict companion/UseNumber path as +preflight, so exact configFilePath.$mid=1 provenance retains its numeric type and +noncanonical fractions/exponents never become accepted integers. From 05329709d5e37d09426188a4a6e2d26442e281b2 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 19:01:57 +0200 Subject: [PATCH 33/51] test(provider): prove bounded real refusal coverage Complete the same two Linux acceptance roots with exact trust, revision, source and runtime collision refusals. Require zero additional mutating Docker forwards alongside actual inventory and data checks, with separate fixture-owned setup and positive transport controls. Refs: #72 Co-authored-by: Codex --- .../docker/docker_acceptance_test.go | 42 +- .../docker/docker_refusal_acceptance_test.go | 410 ++++++++++++++++++ .../docker/docker_registry_acceptance_test.go | 8 +- docs/development/devcontainer-matrix.md | 12 + docs/exec-plans/active/installable-pilot.md | 28 ++ 5 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go diff --git a/apps/host_agent/internal/providers/docker/docker_acceptance_test.go b/apps/host_agent/internal/providers/docker/docker_acceptance_test.go index fe670d5f..c799cab0 100644 --- a/apps/host_agent/internal/providers/docker/docker_acceptance_test.go +++ b/apps/host_agent/internal/providers/docker/docker_acceptance_test.go @@ -247,6 +247,8 @@ func dockerAcceptance(t *testing.T, example string) { }() resolver, descriptor, mutate := fixtureConfig(t, ctx, root, example) host := realHost(t, root, resolver) + var ledger string + host.DockerPath, ledger = installFixtureDockerLedger(t, root, host.DockerPath) scope := scopeFixture() binding := storage.Binding{OwnerID: scope.OwnerID, HostID: scope.HostID, WorkspaceID: scope.WorkspaceID, StorageID: scope.StorageID, ReservationOperationID: scope.AllocationOperationID, Initial: descriptor} originalBytes, e := os.ReadFile(filepath.Join(root, "fixture-config/.devcontainer/devcontainer.json")) @@ -257,7 +259,14 @@ func dockerAcceptance(t *testing.T, example string) { if json.Unmarshal(originalBytes, &originalSource) != nil { t.Fatal("fixture source malformed") } - rawRegistryRefusals(t, ctx, host, binding, mutate, originalSource, root) + realAdmissionRefusals(t, ctx, host, binding, descriptor, root, ledger) + if t.Failed() { + return + } + rawRegistryRefusals(t, ctx, host, binding, mutate, originalSource, root, ledger) + if t.Failed() { + return + } p, e := host.Prepare(ctx, realAdmission(descriptor, binding, 1)) if e != nil { t.Fatal("real preparation", e) @@ -280,6 +289,10 @@ func dockerAcceptance(t *testing.T, example string) { if e != nil || reopened.Target != resource.Target { t.Fatal("official exact-target reopen", e) } + realRuntimeRefusals(t, ctx, host, binding, descriptor, p, root, ledger) + if t.Failed() { + return + } realExec(t, ctx, p, "sh", "-c", "cd /workspace/app && git status --porcelain && chmod +x seed.bin && printf '\\000\\377\\200\\001' > seed.bin && printf '\\000untracked\\377' > untracked.bin && git status --porcelain") data := p.Storage().Data binary := filepath.Join(data, "app/seed.bin") @@ -360,7 +373,7 @@ func dockerAcceptance(t *testing.T, example string) { return out.String() } before := inventory() - for _, kind := range []string{"initializeCommand", "features", "mounts", "named-user", "VOLUME", "ONBUILD", "custom-frontend", "reserved-build-arg", "reserved-later-ARG"} { + for _, kind := range []string{"initializeCommand", "features", "mounts", "named-user", "VOLUME", "ONBUILD", "custom-frontend", "reserved-build-arg", "reserved-later-ARG", "compose", "source-runArgs"} { t.Run("unsupported-"+kind, func(t *testing.T) { bad := clone(source).(map[string]any) switch kind { @@ -372,6 +385,11 @@ func dockerAcceptance(t *testing.T, example string) { bad["mounts"] = []any{"/var/run/docker.sock"} case "named-user": bad["remoteUser"] = "vscode" + case "compose": + bad["dockerComposeFile"] = "compose.yml" + bad["service"] = "workspace" + case "source-runArgs": + bad["runArgs"] = []any{"--privileged"} default: delete(bad, "image") b := map[string]any{"dockerfile": "Dockerfile", "context": "."} @@ -392,10 +410,13 @@ func dockerAcceptance(t *testing.T, example string) { realWrite(t, filepath.Join(root, "fixture-config/.devcontainer/Dockerfile"), []byte(dockerfile)) } unsupported := mutate(bad) - if candidate, e := host.Prepare(ctx, realAdmission(unsupported, binding, 4)); e == nil { - candidate.Close() - t.Fatal("unsupported input admitted", kind) - } + assertRealRefusal(t, ctx, p.port, ledger, "unsupported_configuration", func() error { + candidate, e := host.Prepare(ctx, realAdmission(unsupported, binding, 4)) + if candidate != nil { + candidate.Close() + } + return e + }) if inventory() != before { t.Fatal("refusal changed Docker inventory", kind) } @@ -406,7 +427,14 @@ func dockerAcceptance(t *testing.T, example string) { }) } - proof := map[string]any{"example": example, "initial_config": descriptor.Map(), "replacement_config": changed.Map(), "target": resource.Target, "replacement_target": replacementResource.Target, "hashes": hashes, "owner_uid": p.Storage().Binding.DataUID, "owner_gid": p.Storage().Binding.DataGID, "real_normal_resource_hooks": true, "explicit_fixture_official_reopen": true, "automatic_resource_replay_refused": true, "retention_stop_start_replacement": true, "failed_build_retained": true, "authored_label_host_hook_absent": true, "unsupported_host_hook_refused": true, "guest_containment": "separate helper evidence required", "feature006_t010": "not executed"} + if t.Failed() { + return + } + forwarded := string(fixtureLedger(t, ledger)) + if !strings.Contains(forwarded, "run\n") || !strings.Contains(forwarded, "pull\n") || (example == "admitted-dockerfile" && !strings.Contains(forwarded, "build\n")) { + t.Fatal("actual mutating-forward ledger positive control missing") + } + proof := map[string]any{"refusals_zero_mutating_forwards": true, "forwarded_categories": strings.Split(strings.TrimSpace(forwarded), "\n"), "example": example, "initial_config": descriptor.Map(), "replacement_config": changed.Map(), "target": resource.Target, "replacement_target": replacementResource.Target, "hashes": hashes, "owner_uid": p.Storage().Binding.DataUID, "owner_gid": p.Storage().Binding.DataGID, "real_normal_resource_hooks": true, "explicit_fixture_official_reopen": true, "automatic_resource_replay_refused": true, "retention_stop_start_replacement": true, "failed_build_retained": true, "authored_label_host_hook_absent": true, "unsupported_host_hook_refused": true, "guest_containment": "separate helper evidence required", "feature006_t010": "not executed"} raw, _ := json.MarshalIndent(proof, "", " ") realWrite(t, filepath.Join(root, "proof.json"), append(raw, '\n')) finished = true diff --git a/apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go b/apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go new file mode 100644 index 00000000..21c3e5e6 --- /dev/null +++ b/apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go @@ -0,0 +1,410 @@ +//go:build docker_integration + +// SPDX-License-Identifier: EUPL-1.2 +package docker + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/protocol" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage" +) + +// Only the test selects this physical host executable. It preserves actual +// Docker stdio/exit/argv and records fixed categories, never repository content. +func installFixtureDockerLedger(t *testing.T, root, original string) (string, string) { + t.Helper() + if !physicalExecutable(original) { + t.Fatal("fixture Docker executable is not physical") + } + ledger := filepath.Join(root, "docker-forwarded-categories") + f, e := os.OpenFile(ledger, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if e != nil { + t.Fatal(e) + } + if e = f.Close(); e != nil { + t.Fatal(e) + } + wrapper := filepath.Join(root, "docker-with-ledger") + script := `#!/bin/sh +set -eu +if [ "$#" = 1 ] && [ "$1" = -v ]; then exec ` + shellQuote(original) + ` "$@"; fi +[ "$#" -ge 5 ] && [ "$1" = --config ] && [ "$3" = --host ] || exit 124 +category=read +case "$5" in + run|pull|start|stop|rm|exec|create) category="$5" ;; + buildx) case "${6-}" in build) category=build ;; version) [ "$#" = 6 ] || exit 124 ;; *) exit 124 ;; esac ;; + image|volume) [ "${6-}" = ls ] || exit 124 ;; + inspect|ps|info|events|version) ;; + *) exit 124 ;; +esac +if [ "$category" != read ]; then printf '%s\n' "$category" >> ` + shellQuote(ledger) + `; fi +exec ` + shellQuote(original) + ` "$@" +` + realWrite(t, wrapper, []byte(script)) + if e = os.Chmod(wrapper, 0700); e != nil { + t.Fatal(e) + } + return wrapper, ledger +} +func fixtureLedger(t *testing.T, path string) []byte { + t.Helper() + info, e := os.Lstat(path) + if e != nil || !ownedRegular(info) || info.Mode().Perm() != 0600 || info.Size() > 65536 { + t.Fatal("unsafe or oversized fixture ledger", e) + } + raw, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + return raw +} +func fixtureInventory(t *testing.T, ctx context.Context, p *nativePort) string { + t.Helper() + var b strings.Builder + for _, args := range [][]string{{"ps", "-aq", "--no-trunc"}, {"image", "ls", "-q", "--no-trunc"}, {"volume", "ls", "-q"}} { + raw, e := p.read(ctx, args) + if e != nil { + t.Fatal(e) + } + b.Write(raw) + b.WriteByte('\n') + } + return b.String() +} +func assertRealRefusal(t *testing.T, ctx context.Context, p *nativePort, ledger, diagnostic string, action func() error) { + t.Helper() + before := fixtureInventory(t, ctx, p) + forwarded := fixtureLedger(t, ledger) + e := action() + if e == nil || e.Error() != diagnostic { + t.Fatalf("intended refusal %s not reached: %v", diagnostic, e) + } + if !bytes.Equal(forwarded, fixtureLedger(t, ledger)) { + t.Fatal("refusal forwarded mutating Docker command") + } + if fixtureInventory(t, ctx, p) != before { + t.Fatal("refusal changed actual Docker inventory") + } +} +func realTrustInputs(t *testing.T, d config.Descriptor, b storage.Binding) (map[string]any, protocol.Context, config.Approval) { + t.Helper() + s := scopeFixture() + request := map[string]any{"schema_version": int64(1), "workspace_id": s.WorkspaceID, "action": "create", "parameters": map[string]any{"host_id": s.HostID, "config": d.Map()}} + identity := map[string]any{"schema_version": int64(1), "owner_id": s.OwnerID, "workspace_id": s.WorkspaceID, "action": "create", "parameters": request["parameters"]} + raw, e := wirejson.CanonicalBytes(identity) + if e != nil { + t.Fatal(e) + } + sum := sha256.Sum256(raw) + digest := hex.EncodeToString(sum[:]) + approvalID := "00000000-0000-4000-8000-000000000009" + command := map[string]any{"schema_version": int64(1), "kind": "command", "command_id": "00000000-0000-4000-8000-000000000010", "owner_id": s.OwnerID, "host_id": s.HostID, "workspace_id": s.WorkspaceID, "operation_id": s.AllocationOperationID, "idempotency_key": "fixture:create:1", "request_sha256": digest, "intent_version": int64(1), "request": request, "bindings": map[string]any{"target": nil, "candidate": map[string]any{"runtime_id": s.RuntimeID, "generation": int64(1)}}, "execution_config": nil, "approval_ref": approvalID} + trusted := protocol.Context{Peer: &protocol.Peer{Kind: "control_plane", OwnerID: s.OwnerID, HostID: s.HostID}, Snapshot: &protocol.Snapshot{Command: command, RequestBytes: raw, RequestHash: digest, NativeBindings: map[string]any{"target": nil, "candidate": map[string]any{"id": s.RuntimeID, "generation": int64(1)}}, CurrentIntent: 1, Status: "queued"}} + return command, trusted, config.Approval{ID: approvalID, OwnerID: s.OwnerID, Descriptor: d} +} +func realAdmissionRefusals(t *testing.T, ctx context.Context, host Provider, b storage.Binding, d config.Descriptor, root, ledger string) { + t.Helper() + port, e := openNativePort(ctx, nativeOptions{Policy: DriverPolicy{Phase: Preflight, Scope: scopeFixture()}, Scope: scopeFixture(), Check: func(ctx context.Context) error { return ctx.Err() }, DockerPath: host.DockerPath, Socket: host.Socket, BuildxPath: host.BuildxPath, PrivateDirectory: host.PrivateDirectory}) + if e != nil { + t.Fatal(e) + } + defer port.Close() + // The independently authored command matches this actual HTTPS/gitlink fixture. + command, trusted, approval := realTrustInputs(t, d, b) + if _, e := Admit(ctx, command, trusted, b, approval, func(ctx context.Context) error { return ctx.Err() }); e != nil { + t.Fatal("positive exact trust control", e) + } + for _, kind := range []string{"approval-owner", "approval-revision", "approval-gitlink", "peer-host", "workspace-scope", "current-revoked"} { + t.Run("admission-"+kind, func(t *testing.T) { + command, trusted, approval := realTrustInputs(t, d, b) + check := func(ctx context.Context) error { return ctx.Err() } + want := "trust_required" + switch kind { + case "approval-owner": + approval.OwnerID = b.StorageID + case "approval-revision": + approval.Descriptor.Commit = strings.Repeat("b", 40) + case "approval-gitlink": + approval.Descriptor.Submodules = append([]config.Submodule{}, d.Submodules...) + approval.Descriptor.Submodules[0].Commit = strings.Repeat("b", 40) + case "peer-host": + trusted.Peer.HostID = b.StorageID + want = "unauthorized_scope" + case "workspace-scope": + command = clone(command).(map[string]any) + command["workspace_id"] = b.StorageID + want = "unauthorized_scope" + case "current-revoked": + check = func(context.Context) error { return fail("forbidden") } + want = "forbidden" + } + assertRealRefusal(t, ctx, port, ledger, want, func() error { + a, e := Admit(ctx, command, trusted, b, approval, check) + if e != nil { + return e + } + p, e := host.Prepare(ctx, a) + if p != nil { + p.Close() + } + return e + }) + }) + } + for _, kind := range []string{"missing-revision", "missing-gitlink", "changed-gitlink"} { + t.Run("revision-"+kind, func(t *testing.T) { + bad, _ := config.DecodeDescriptor(d.Map()) + want := "invalid_configuration" + switch kind { + case "missing-revision": + bad.Commit = strings.Repeat("f", 40) + want = "revision_unavailable" + case "missing-gitlink": + bad.Submodules = []config.Submodule{} + case "changed-gitlink": + bad.Submodules[0].Commit = strings.Repeat("b", 40) + } + assertRealRefusal(t, ctx, port, ledger, want, func() error { + p, e := host.Prepare(ctx, realAdmission(bad, b, 1)) + if p != nil { + p.Close() + } + return e + }) + }) + } + repository := filepath.Join(root, "fixture-config") + manifestPath, sourcePath, modulesPath := filepath.Join(repository, "workharbor.json"), filepath.Join(repository, ".devcontainer/devcontainer.json"), filepath.Join(repository, ".gitmodules") + manifest, me := os.ReadFile(manifestPath) + source, se := os.ReadFile(sourcePath) + modules, ge := os.ReadFile(modulesPath) + if me != nil || se != nil || ge != nil { + t.Fatal("fixture source snapshot unavailable") + } + commit := func() config.Descriptor { + realGit(t, ctx, repository, "add", "--all") + realGit(t, ctx, repository, "commit", "--quiet", "--allow-empty", "-m", "refusal fixture") + bad, _ := config.DecodeDescriptor(d.Map()) + bad.Commit = realGit(t, ctx, repository, "rev-parse", "HEAD") + realGit(t, ctx, repository, "update-server-info") + return bad + } + for _, kind := range []string{"manifest-path", "source-symlink", "jsonc-duplicate", "jsonc-surrogate", "ssh-submodule-no-credentials"} { + t.Run("source-"+kind, func(t *testing.T) { + if e := os.Remove(sourcePath); e != nil { + t.Fatal(e) + } + realWrite(t, sourcePath, source) + realWrite(t, manifestPath, manifest) + realWrite(t, modulesPath, modules) + want := "invalid_configuration" + switch kind { + case "manifest-path": + var m map[string]any + if json.Unmarshal(manifest, &m) != nil { + t.Fatal("manifest snapshot") + } + m["devcontainer_path"] = "../outside.json" + raw, _ := json.Marshal(m) + realWrite(t, manifestPath, raw) + case "source-symlink": + if e := os.Remove(sourcePath); e != nil { + t.Fatal(e) + } + if e := os.Symlink("../../outside.json", sourcePath); e != nil { + t.Fatal(e) + } + want = "context_schema" + case "jsonc-duplicate": + realWrite(t, sourcePath, []byte(`{"image":"one","\u0069mage":"two"}`)) + case "jsonc-surrogate": + realWrite(t, sourcePath, []byte(`{"image":"\ud800"}`)) + case "ssh-submodule-no-credentials": + realWrite(t, modulesPath, []byte("[submodule \"app\"]\npath=projects/app\nurl=ssh://git@example.invalid/project.git\n")) + want = "credential_unavailable" + } + bad := commit() + assertRealRefusal(t, ctx, port, ledger, want, func() error { + p, e := host.Prepare(ctx, realAdmission(bad, b, 1)) + if p != nil { + p.Close() + } + return e + }) + }) + } + if e := os.Remove(sourcePath); e != nil { + t.Fatal(e) + } + realWrite(t, sourcePath, source) + realWrite(t, manifestPath, manifest) + realWrite(t, modulesPath, modules) + commit() + if _, e := os.Lstat(filepath.Join(host.Store.Root, b.StorageID)); !os.IsNotExist(e) { + t.Fatal("admission refusal seeded data", e) + } +} + +func realRuntimeRefusals(t *testing.T, ctx context.Context, host Provider, b storage.Binding, d config.Descriptor, p *Prepared, root, ledger string) { + t.Helper() + p.port.stateMu.Lock() + policy := copyPolicy(p.port.policy) + policy.Phase = Hooks + target := *p.port.target + images := copyImages(p.port.images) + p.port.stateMu.Unlock() + for _, kind := range []string{"duplicate-runtime", "partial-runtime-labels", "reserved-name-collision", "internal-wrong-target"} { + t.Run("runtime-"+kind, func(t *testing.T) { + if kind == "internal-wrong-target" { + wrong := strings.Repeat("f", 64) + policy.KnownIDs[wrong] = true + g := &Gateway{Parser: p.port.parser, Policy: copyPolicy(policy), Port: p.port, Authorize: p.Check, Target: &target, Images: images} + assertRealRefusal(t, ctx, p.port, ledger, "runtime_conflict", func() error { + _, e := g.Forward(ctx, []string{"exec", "-i", "-u", policy.RemoteUser, "-w", "/workspace", wrong, "true"}) + return e + }) + return + } + name := "workharbor-fixture-" + filepath.Base(root) + "-" + kind + marker := name + labels := map[string]string{"workharbor.test.fixture": marker} + if kind == "duplicate-runtime" { + for k, v := range target.Scope.Labels() { + labels[k] = v + } + } + if kind == "partial-runtime-labels" { + labels["workharbor.runtime"] = target.Scope.RuntimeID + } + collision := realAdmission(d, b, 5) + if kind == "reserved-name-collision" { + name = collision.Scope().Name() + } + args := []string{"create", "--pull=never", "--name", name} + keys := []string{} + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + args = append(args, "--label", k+"="+labels[k]) + } + args = append(args, target.ImageID, "/bin/true") + raw, e := p.port.read(ctx, args) + if e != nil { + t.Fatal("owned collision setup", e) + } + id := strings.TrimSpace(string(raw)) + if !hexID.MatchString(id) { + t.Fatal("collision setup returned no exact ID") + } + verify := func(checkCtx context.Context) { + t.Helper() + raw, e := p.port.read(checkCtx, []string{"inspect", "--type", "container", id}) + if e != nil { + t.Fatal("exact fixture inspect", e) + } + c, e := nativeContainers(raw) + if e != nil || len(c) != 1 || c[0].ID != id || c[0].Name != "/"+name || c[0].Image != target.ImageID || c[0].Running || c[0].Labels["workharbor.test.fixture"] != marker { + t.Fatal("foreign collision fixture", e) + } + } + verify(ctx) + defer func() { + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + verify(cleanup) + if _, e := p.port.read(cleanup, []string{"rm", id}); e != nil { + t.Error("owned collision teardown", e) + return + } + raw, e := p.port.read(cleanup, []string{"ps", "-aq", "--no-trunc"}) + if e != nil || strings.Contains(string(raw), id) { + t.Error("collision removal not verified", e) + } + }() + assertRealRefusal(t, ctx, p.port, ledger, "runtime_conflict", func() error { + if kind == "reserved-name-collision" { + candidate, e := host.Prepare(ctx, collision) + if candidate != nil { + candidate.Close() + } + return e + } + g := &Gateway{Parser: p.port.parser, Policy: copyPolicy(policy), Port: p.port, Authorize: p.Check, Target: &target, Images: images} + _, e := g.Forward(ctx, []string{"exec", "-i", "-u", policy.RemoteUser, "-w", "/workspace", target.ContainerID, "true"}) + return e + }) + }) + } +} + +// These transport controls use an owned shell fixture, not Docker acceptance. +func TestDockerFixtureFixedLedgerTransport(t *testing.T) { + root, e := filepath.EvalSymlinks(t.TempDir()) + if e != nil { + t.Fatal(e) + } + original := filepath.Join(root, "owned-transport-fixture") + realWrite(t, original, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 17\n")) + if e = os.Chmod(original, 0700); e != nil { + t.Fatal(e) + } + wrapper, ledger := installFixtureDockerLedger(t, root, original) + for _, row := range []struct { + args []string + code int + category string + }{ + {[]string{"-v"}, 17, ""}, + {[]string{"--config", "private", "--host", "unix://owned", "exec", "literal with space"}, 17, "exec\n"}, + {[]string{"--config", "private", "--host", "unix://owned", "buildx", "build", "literal"}, 17, "build\n"}, + {[]string{"--config", "private", "--host", "unix://owned", "buildx", "version"}, 17, ""}, + {[]string{"--config", "private", "--host", "unix://owned", "image", "rm", "foreign"}, 124, ""}, + {[]string{"--host", "unix://foreign", "run"}, 124, ""}, + } { + before := fixtureLedger(t, ledger) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + c := exec.CommandContext(ctx, wrapper, row.args...) + c.Env = []string{"PATH=/usr/bin:/bin"} + raw, e := c.Output() + cancel() + exit, ok := e.(*exec.ExitError) + if !ok || exit.ExitCode() != row.code { + t.Fatalf("transport exit: %v", e) + } + if row.code == 17 && string(raw) != strings.Join(row.args, "\n")+"\n" { + t.Fatalf("argv changed: %q", raw) + } + if row.code == 124 && len(raw) != 0 { + t.Fatal("unknown shape reached original executable") + } + if !bytes.Equal(append(append([]byte{}, before...), []byte(row.category)...), fixtureLedger(t, ledger)) { + t.Fatal("ledger category mismatch") + } + } +} +func TestDockerFixtureIndependentTrustCommandMatchesExactInputs(t *testing.T) { + d := config.Descriptor{RepositoryIdentity: "https://fixture.example/config.git", Commit: strings.Repeat("a", 40), Submodules: []config.Submodule{{Path: "projects/app", Commit: strings.Repeat("b", 40)}}} + s := scopeFixture() + b := storage.Binding{OwnerID: s.OwnerID, HostID: s.HostID, WorkspaceID: s.WorkspaceID, StorageID: s.StorageID, ReservationOperationID: s.AllocationOperationID, Initial: d} + command, trusted, approval := realTrustInputs(t, d, b) + admitted, e := Admit(context.Background(), command, trusted, b, approval, func(context.Context) error { return nil }) + if e != nil || admitted.Descriptor().Commit != d.Commit || admitted.Scope() != s { + t.Fatalf("independent trusted fixture mismatch: %v %#v", e, admitted.Scope()) + } +} diff --git a/apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go b/apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go index fc4c4c4b..689d5c1d 100644 --- a/apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go +++ b/apps/host_agent/internal/providers/docker/docker_registry_acceptance_test.go @@ -27,7 +27,7 @@ import ( "github.com/wstein/workharbor-nxt/apps/host_agent/internal/storage" ) -func rawRegistryRefusals(t *testing.T, ctx context.Context, host Provider, binding storage.Binding, mutate func(map[string]any) config.Descriptor, original map[string]any, root string) { +func rawRegistryRefusals(t *testing.T, ctx context.Context, host Provider, binding storage.Binding, mutate func(map[string]any) config.Descriptor, original map[string]any, root, ledger string) { t.Helper() caPath, keyPath := realEnv(t, "WORKHARBOR_T009_REGISTRY_CA_FILE"), realEnv(t, "WORKHARBOR_T009_REGISTRY_KEY_FILE") caHash, keyHash := os.Getenv("WORKHARBOR_T009_REGISTRY_CA_SHA256"), os.Getenv("WORKHARBOR_T009_REGISTRY_KEY_SHA256") @@ -87,6 +87,7 @@ func rawRegistryRefusals(t *testing.T, ctx context.Context, host Provider, bindi proof := map[string]any{} for _, kind := range []string{"duplicate-raw-key", "onbuild", "volumes", "host-metadata", "malformed-metadata", "unknown-raw-field"} { t.Run("raw-"+kind, func(t *testing.T) { + forwarded := fixtureLedger(t, ledger) cfg := map[string]any{"architecture": runtimeArchitecture(), "os": "linux", "rootfs": map[string]any{"type": "layers", "diff_ids": []any{}}, "config": map[string]any{"User": "0:0", "Labels": map[string]any{}}} properties := cfg["config"].(map[string]any) switch kind { @@ -191,6 +192,9 @@ func rawRegistryRefusals(t *testing.T, ctx context.Context, host Provider, bindi if len(bad) != 0 { t.Fatal("non-metadata/plaintext/layer request", bad) } + if !bytes.Equal(forwarded, fixtureLedger(t, ledger)) { + t.Fatal("raw refusal forwarded mutating Docker command") + } if snapshot() != baseline { t.Fatal("raw refusal changed daemon inventory") } @@ -200,7 +204,7 @@ func rawRegistryRefusals(t *testing.T, ctx context.Context, host Provider, bindi if _, e := os.Lstat(filepath.Join(root, "RAW_HOST_MUST_NOT_RUN")); !os.IsNotExist(e) { t.Fatal("raw metadata host hook executed", e) } - proof[kind] = map[string]any{"raw_config_sha256": hex.EncodeToString(configSum[:]), "manifest_digest": manifestDigest, "config_size": len(configBytes), "metadata_requests": record, "daemon_unchanged": true, "seed_absent": true, "hooks_absent": true} + proof[kind] = map[string]any{"raw_config_sha256": hex.EncodeToString(configSum[:]), "manifest_digest": manifestDigest, "config_size": len(configBytes), "metadata_requests": record, "daemon_unchanged": true, "zero_mutating_forwards": true, "seed_absent": true, "hooks_absent": true} }) } raw, _ := json.MarshalIndent(map[string]any{"ca_sha256": caHash, "server_name": "registry.workharbor.test", "cases": proof}, "", " ") diff --git a/docs/development/devcontainer-matrix.md b/docs/development/devcontainer-matrix.md index af197cbe..815b531a 100644 --- a/docs/development/devcontainer-matrix.md +++ b/docs/development/devcontainer-matrix.md @@ -53,3 +53,15 @@ increase is authorized. Host-available memory guard proposals are separate from that per-guest ceiling and from measured containment. Real proof.json/outcome.json exports are declared by each test-owned artifact-manifest.json; no project files, private credentials, raw hook output or ambient host configuration is exported. + +The two actual Docker roots each require36 Go root/subtest outcomes: six raw +registry refusals; six exact trust/current-scope admissions; three revision/gitlink +refusals; five actual Git/path/JSONC/SSH-credential source refusals; eleven unsupported +source cases (including Compose and raw runArgs); four actual scoped-runtime +collision/wrong-target refusals; and the root itself. This is an expected matrix, +not an executed count. Every refusal compares exact private fixed-category Docker +forward ledger bytes and actual daemon inventory. Ledger positive controls require +real pull/run and Dockerfile build; collision setup/cleanup is test-owned and +excluded from each product-refusal baseline. No argv, environment, credential or +project file bytes are exported. Two separate owned-shell/pure admission fixture +controls can run natively; their success is not real Docker acceptance. diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 307dadeb..65f4665c 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1442,3 +1442,31 @@ metadata only; its actual raw Config fit and tool compatibility remain mandatory real Linux gates, with no subset weakening if unsupported. These author fixes await exact retained-reviewer closure; canonical restart and real acceptance remain pending, Feature006 T009 unchecked and T010 waiting. + +Independent exact0d435d6 THREE-FIX SOURCE CLEAR is retained at +/private/tmp/workharbor-t009-candidate-fix-review-ywwwqavy/: full isolated +provider/config/devcontainer/storage and reviewer regressions pass, vet exits0; +tagged Docker compile-only passes0.294s, no runtime claim. Original independent +2042586 reviewer probe RED remains in candidate-review-i_gw7zu_. + +Reviewer identified a finite real-acceptance source coverage gap. The SAME two +fixed Linux roots now each declare36 required root/subtest outcomes, adding exact +trust/scope/current-fence, revisions/gitlinks, actual Git/path/JSONC/SSH-credential, +Compose/runArgs and actual duplicate/partial/name/wrong-target refusal cases. +Each negative requires its intended diagnostic, unchanged actual Docker inventory +and identical mutating-forward ledger. A host-trusted physical test-only wrapper +forwards exact original Docker argv/stdio/exit, recognizes fixed global arguments +and records only command categories in private0600 storage; unexpected shapes +refuse. Pinned registry tools remain unchanged/unwrapped. Owned collision objects +are separately allocated/inspected and removed by exact ID; setup/removal effects +are outside refusal baselines. Successful resource/build paths provide positive +ledger controls. The expected actual36+36 outcomes remain UNRUN, with no guest GO. +Owned-shell ledger and independently authored pure trust-command controls pass +/private/tmp/workharbor-t009-refusal-fixture-controls.log (exit0,0.522s); tagged +compile-only passes refusal-coverage-compile.log (exit0,0.212s,no tests run). +These fixture controls do not substitute for actual Docker proof. Exact added +fixture source review and pristine pinned canonical remain mandatory. + +Tagged fixture vet has an original wrong-directory setup failure retained in +/private/tmp/workharbor-t009-refusal-coverage-vet.log; the correct module invocation +supersedes it in refusal-coverage-vet-superseding.log (exit0). From e3d58cf6d2cd8421b822b82f0013010905950f14 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 19:05:04 +0200 Subject: [PATCH 34/51] fix(test): require exact source symlink refusal diagnostic The immutable resolver deliberately reports filesystem source containment errors as invalid_configuration. Reproduce the relative symlink chain and correct the acceptance expectation without weakening exact diagnostics or changing provider behavior. Refs: #72 Co-authored-by: Codex --- .../host_agent/internal/config/resolver_test.go | 17 +++++++++++++++++ .../docker/docker_refusal_acceptance_test.go | 1 - docs/development/ci-acceptance.json | 3 ++- docs/exec-plans/active/installable-pilot.md | 10 ++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/host_agent/internal/config/resolver_test.go b/apps/host_agent/internal/config/resolver_test.go index 06d6d5a7..c04f2e17 100644 --- a/apps/host_agent/internal/config/resolver_test.go +++ b/apps/host_agent/internal/config/resolver_test.go @@ -225,3 +225,20 @@ func TestResolverHTTPSRootUsesExplicitSSHOnlyForApprovedGitlink(t *testing.T) { t.Fatal("explicit SSH transport never used", e) } } + +func TestResolverRelativeSourceSymlinkUsesConfigurationDiagnostic(t *testing.T) { + r, d := resolverFixture(t, func(root string) { + path := filepath.Join(root, ".devcontainer/devcontainer.json") + if e := os.Remove(path); e != nil { + t.Fatal(e) + } + if e := os.Symlink("../../outside.json", path); e != nil { + t.Fatal(e) + } + }) + resolved, e := r.Resolve(context.Background(), d) + if resolved != nil { + resolved.Close() + } + code(t, e, "invalid_configuration") +} diff --git a/apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go b/apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go index 21c3e5e6..863985d2 100644 --- a/apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go +++ b/apps/host_agent/internal/providers/docker/docker_refusal_acceptance_test.go @@ -228,7 +228,6 @@ func realAdmissionRefusals(t *testing.T, ctx context.Context, host Provider, b s if e := os.Symlink("../../outside.json", sourcePath); e != nil { t.Fatal(e) } - want = "context_schema" case "jsonc-duplicate": realWrite(t, sourcePath, []byte(`{"image":"one","\u0069mage":"two"}`)) case "jsonc-surrogate": diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index df73fbf9..fffbd09a 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -954,7 +954,8 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay/1000-1000", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPinnedOfficialCLIReadConfigurationPreservesSourceAndMetadata/resource-result-retains-actual-official-provenance", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryUnknownRawDockerConfigFieldRefusesAfterDigestVerification" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryUnknownRawDockerConfigFieldRefusesAfterDigestVerification", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRelativeSourceSymlinkUsesConfigurationDiagnostic" ], "api_client": [ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 65f4665c..61f4eccc 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1470,3 +1470,13 @@ fixture source review and pristine pinned canonical remain mandatory. Tagged fixture vet has an original wrong-directory setup failure retained in /private/tmp/workharbor-t009-refusal-coverage-vet.log; the correct module invocation supersedes it in refusal-coverage-vet-superseding.log (exit0). + +Exact1d4bfa3 fixture review found one incorrect expected diagnostic for a relative +source symlink: the actual resolver's filesystem-containment refusal is +invalid_configuration, not lexical context_schema. The actual immutable HTTPS Git +chain with source symlink ../../outside.json reproduced that expected-diagnostic +mismatch in /private/tmp/workharbor-t009-symlink-fixture-diagnostic-red.log +(exit1,1.085s); the deliberate corrected diagnostic passes the same native chain +in symlink-fixture-diagnostic-green.log (exit0,1.147s). Product resolver behavior +is unchanged and strict exact diagnostic checking remains. New native required +ID is appended; same-reviewer exact correction/fixture closure remains pending. From dc89e4dae94a5958c9af3cb63846de78caf1d12e Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 19:35:34 +0200 Subject: [PATCH 35/51] fix(ci): isolate npm configs in distinct private files Pinned npm rejects loading /dev/null twice for user and global configuration. Preserve hermetic locked companion installation with distinct exclusive empty0600 files and verify their state during invocation; retain the original canonical failure. Refs: #72 Co-authored-by: Codex --- docs/development/ci-acceptance.json | 3 ++- docs/exec-plans/active/installable-pilot.md | 21 +++++++++++++++++++++ scripts/application_checks.py | 9 +++++++-- scripts/test_application_checks.py | 20 +++++++++++++++++++- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index fffbd09a..bd3ce052 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -522,7 +522,8 @@ "test_go_vectors.GoVectorTests.test_source_and_product_domains_remain_distinct", "test_go_vectors.GoVectorTests.test_strict_fixture_loader_rejects_duplicate_keys_constants_utf8_and_size", "test_application_checks.ApplicationChecksTests.test_pinned_companion_setup_precedes_host_tests_and_refuses_scripts", - "test_harness.HarnessTests.test_exact_pure_dockerfile_grammar_imports_do_not_grant_docker_access" + "test_harness.HarnessTests.test_exact_pure_dockerfile_grammar_imports_do_not_grant_docker_access", + "test_application_checks.ApplicationChecksTests.test_npm_configs_are_distinct_private_empty_files_at_invocation" ], "retained_assertion_failures": [], "application_tests": { diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 61f4eccc..5529f5e5 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1480,3 +1480,24 @@ mismatch in /private/tmp/workharbor-t009-symlink-fixture-diagnostic-red.log in symlink-fixture-diagnostic-green.log (exit0,1.147s). Product resolver behavior is unchanged and strict exact diagnostic checking remains. New native required ID is appended; same-reviewer exact correction/fixture closure remains pending. + +Exact06b5c7e WHOLE T009 SOURCE REVIEW CLEAR is independent source evidence only. +Its pristine pinned canonical run is retained in +/private/tmp/workharbor-t009-canonical-06b5c7e-dcg4uy14/{binding.json,canonical.log,result.json}: +raw427 PASS522.385s and qualified848 PASS917.922s, but OVERALL exit2/1444.462s. +The first actual application npm ci failed because pinned npm refuses the same +/dev/null file as both user and global config. NO Go/Phoenix application suites +ran in that canonical, and raw/qualified passes do not qualify it GREEN. + +Same-reviewer DESIGN CLEAR permits only distinct exclusive empty0600 user/global +config files under the existing private cache, keeping hermetic environment, +fixed npm ci/ignore-scripts/public registry and locked package bytes unchanged. +Invocation-time regression RED is +/private/tmp/workharbor-t009-npm-private-config-red.log (exit1); full focused +application-checker superseding GREEN is npm-private-config-green.log (exit0). +Actual pinned npm config-get registry proof with those private files passes in +/private/tmp/workharbor-t009-npm-config-native-t20h4nxp/{evidence.json,stdout.log,stderr.log}; +no package fetch/install or project script was executed by that proof. Required +regression ID is appended. Exact correction review, fresh application gates and +new pristine canonical remain mandatory; Feature006 T009 is still NOT accepted +and T010 waits. All original logs/failures remain retained. diff --git a/scripts/application_checks.py b/scripts/application_checks.py index 6bd5db4d..898952c2 100644 --- a/scripts/application_checks.py +++ b/scripts/application_checks.py @@ -175,8 +175,13 @@ def execute(argv, cwd=root): if not matches(execute(argv).read_text()): raise ValueError('Tool version does not match .tool-versions: ' + argv[0]) env['npm_config_cache'] = str(cache/'npm') - env['npm_config_userconfig'] = os.devnull - env['npm_config_globalconfig'] = os.devnull + # npm refuses loading the same physical file for user and global config. + # Distinct private empty files preserve isolation without inherited defaults. + for kind in ('user', 'global'): + config = cache/('npm-' + kind + '.conf') + descriptor = os.open(config, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(descriptor) + env['npm_config_' + kind + 'config'] = str(config) execute([tools['npm'], 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org'], root/'apps/host_agent/tools/devcontainer') for module in GO_MODULES: diff --git a/scripts/test_application_checks.py b/scripts/test_application_checks.py index 2112b8d4..e573aa5a 100644 --- a/scripts/test_application_checks.py +++ b/scripts/test_application_checks.py @@ -86,13 +86,14 @@ def test_malformed_event_bytes_never_qualify(self): path.write_bytes(content) with self.subTest(content=content), self.assertRaises(ValueError): self.apps.read_events(path) - def exercise(self, *, missing=None, fail=None, formatted=False, wrong_version=False): + def exercise(self, *, missing=None, fail=None, formatted=False, wrong_version=False, observe=None): with tempfile.TemporaryDirectory() as directory: root = Path(directory)/'source'; root.mkdir() (root/'.tool-versions').write_text('erlang 29.1.1\nelixir 1.20.4-otp-29\ngolang 1.27.2\nnodejs 26.11.1\npython 3.14.8\n') observed = [] def execute(argv, cwd, *, env, output_path): observed.append((argv, cwd, env)) + if observe: observe(argv, env) command = tuple(argv) if fail and fail(command): return 1 name = Path(argv[0]).name @@ -179,3 +180,20 @@ def test_ci_and_local_share_the_application_gate(self): for version in ('29.1.1', '1.20.4', '1.27.2', '26.11.1', '3.14.8'): self.assertIn(version, workflow) self.assertNotIn('continue-on-error', workflow) + + def test_npm_configs_are_distinct_private_empty_files_at_invocation(self): + calls = [] + def observe(argv, env): + if Path(argv[0]).name != 'npm' or argv[1:2] != ['ci']: return + user, global_ = Path(env['npm_config_userconfig']), Path(env['npm_config_globalconfig']) + self.assertNotEqual(user, global_) + for path in (user, global_): + self.assertTrue(path.is_absolute()) + self.assertFalse(path.is_symlink()) + self.assertTrue(path.is_file()) + self.assertEqual(0o600, path.stat().st_mode & 0o777) + self.assertEqual(b'', path.read_bytes()) + self.assertTrue(path.is_relative_to(Path(env['HOME']).parent)) + calls.append(True) + self.exercise(observe=observe) + self.assertEqual([True], calls) From cd926eb5329e8056d4e9a919d0d001895a862340 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 19:40:03 +0200 Subject: [PATCH 36/51] test(provider): require failed build to reach Docker Prevent an unrelated early resource refusal from satisfying retention evidence. The same real acceptance roots must observe a new forwarded build during the intentionally failing build attempt before checking preserved hashes. Refs: #72 Co-authored-by: Codex --- .../internal/providers/docker/docker_acceptance_test.go | 6 ++++++ docs/exec-plans/active/installable-pilot.md | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/apps/host_agent/internal/providers/docker/docker_acceptance_test.go b/apps/host_agent/internal/providers/docker/docker_acceptance_test.go index c799cab0..1b729a7a 100644 --- a/apps/host_agent/internal/providers/docker/docker_acceptance_test.go +++ b/apps/host_agent/internal/providers/docker/docker_acceptance_test.go @@ -349,10 +349,16 @@ func dockerAcceptance(t *testing.T, example string) { if e != nil { t.Fatal("failed build preparation", e) } + beforeFailedBuild := fixtureLedger(t, ledger) if _, e = failed.Resources(ctx); e == nil { failed.Close() t.Fatal("failed build reported success") } + afterFailedBuild := fixtureLedger(t, ledger) + if !bytes.HasPrefix(afterFailedBuild, beforeFailedBuild) || !bytes.Contains(afterFailedBuild[len(beforeFailedBuild):], []byte("build\n")) { + failed.Close() + t.Fatal("failed-build retention did not reach an actual forwarded build") + } failed.Close() assertRetained() if _, e := os.Lstat(filepath.Join(root, "AUTHORED_LABEL_MUST_NOT_RUN")); !os.IsNotExist(e) { diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 5529f5e5..86848ec2 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1501,3 +1501,10 @@ no package fetch/install or project script was executed by that proof. Required regression ID is appended. Exact correction review, fresh application gates and new pristine canonical remain mandatory; Feature006 T009 is still NOT accepted and T010 waits. All original logs/failures remain retained. + +The existing actual failed-build retention fixture is tightened to require a new +forwarded build category during that failed Resources attempt, in addition to +error and unchanged data hashes. An unrelated early phase refusal cannot satisfy +the intended failed-build proof. This changes no product code, root/subtest IDs or +capabilities; the same36 expected outcomes per real root remain UNRUN. Exact +finite fixture-delta review precedes the final canonical source pin. From a6cbf47f0fab264e07847363855f0c9274e2e5c9 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Thu, 15 Oct 2026 22:13:05 +0200 Subject: [PATCH 37/51] docs(pilot): record T009 canonical and runtime block Preserve the exact canonical pass and every pre-create refusal so source readiness cannot be mistaken for actual Docker acceptance. Record the scoped diagnostic evidence and missing authoritative backing mapping without weakening resource or ownership gates. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 141 ++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 86848ec2..b151383b 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1508,3 +1508,144 @@ error and unchanged data hashes. An unrelated early phase refusal cannot satisfy the intended failed-build proof. This changes no product code, root/subtest IDs or capabilities; the same36 expected outcomes per real root remain UNRUN. Exact finite fixture-delta review precedes the final canonical source pin. + +### Feature006 T009 evidence checkpoint — 2026-10-15 + +**Feature006 T009 is NOT accepted; its checkbox remains unchecked and Feature006 +T010 waits.** The frozen product/source candidate is +`90ea176d7e11e49c3d544e4a884e02c66071ea51`. Independent whole-source review and the +exact pristine canonical check are CLEAR. Actual Linux/Docker acceptance is still +UNRUN. This documentation-only checkpoint changes no behavior, contract, +capability, required test identity, task status or fixture implementation. + +The final pristine command was +`make check PYTHON=/Users/werner/.local/share/mise/installs/python/3.14.8/bin/python` +against exact90, using the bound pinned Go1.27.2, Node26.11.1/npm11.20.0, +Elixir1.20.4-otp-29 and OTP29.1.1. External development-only serial wrappers apply +GOMAXPROCS2/Go-p1 and ERL_FLAGS='+S 2:2'; they do not change check semantics. +It completed exit0 in1581.0415000915527s and all owned children were reaped. +Raw427 passed in490.951s; qualified849 passed in883.802s. Actual final application +counts were whr1, host_agent441, api_client162 and control_plane187, with every +required identity present (host426 Test* requirements plus15 executed fuzz seed +outcomes). Locked dependency setup, format, vet, build, asset and application +checks ran. The source checkout's456 tracked regular files matched bytes and modes. +The retained evidence root is +`/private/tmp/workharbor-t009-canonical-90ea176-oiso_kzz/`: + +| File | SHA256 | +| --- | --- | +| binding.json | dadd58dfedcf3250d167b7747d0463685139843b801d7b866f0404930e337608 | +| canonical.log | b54e2aeeda2c81379a0c51068a610bd4a499e5d9322033430592d58862e13847 | +| result.json | b66a510f626d16f031344e33ba5245dee645a45d47c383a38f2792a60e7edd31 | + +Independent canonical binding review is +`/private/tmp/workharbor-t009-canonical90-review-onez8nwj/assessment.json`, +SHA256 `2527a428bfd88c7faebe318a33b9e7c1c23765a3c0863ffc60ce3d3da102c87a`. +These gates belong to exact90; the documentation successor must separately prove +unchanged source blobs and appropriate pristine/source/manifest checks. + +All original failures remain retained. In particular, exact2042586 preflight exit2 +rejected15 newly appended Fuzz* required identities under the existing Test* schema; +only those new invalid inventory entries were removed, without dropping executed +seed cases or predecessor requirements. Exact06b5c7e canonical overall exit2 +(1444.462s) passed raw427/qualified848 but failed the first npm setup because the +same /dev/null was used for both npm configuration files: **no Go/Phoenix +application suites ran in that failed canonical**. The independently reviewed +befe43af correction uses distinct exclusive empty0600 private files. Its actual +application-only preflight passed217.334727s with1/441/162/187 outcomes; it did not +substitute for the later full exact90 canonical. Earlier assertion REDs, setup +failures, cleanup failures, reviewer probes and superseding GREENs described above +remain unchanged; no failed aggregate is relabeled passing. + +The sole fixture owner consumed four separately authorized exact90 acceptance +attempts. Every attempt ended exit125 BEFORE creation, with no owned guest ID, +no Linux/source/Docker tests and final machine inventory empty. No acceptance +guest was ever created by this sequence; no deletion was needed. Earlier small +1GiB discovery guests described above were deleted and are not Docker acceptance. + +| Attempt | Measured refusal and scope | Retained finite outcome SHA256 | +| --- | --- | --- | +| 017ioqlx | Targeted lsof command15 exited1;16226 stdout bytes and0 stderr bytes did not satisfy the strict exit0 reader. Backing parsing was not reached. | 7ac49fe5302f2c3f7e3a36f64a688fabe84c56d6bc79c43ad14783dcdb6abd6a | +| hlz6pdz1 | Fresh pressure2 WARNING refused the then-NORMAL-only entry guard, despite3806101504 available bytes exceeding the3221225472-byte planning threshold. No lsof/backing query ran. | b9319ca29ee29401658c16998cd6b133f5c540a0ba3b9f519ce90e2917886e69 | +| kf52n2hr | Corrected fixed selector lsof exited0 but158 stderr bytes triggered BACKING_WARNING before parsing. Pressure1 NORMAL and3887857664 available bytes passed that attempt's entry gate. | f609f9e7d11b0d0218937f775d832de368a26c5e97773a925bc7439caeac2e98 | +| 5sjea_61 | Native reader command16 exited125 in0.212821s with fixed LIBPROC_BACKING_REFUSED; underlying subcondition initially remained opaque. Pressure1 NORMAL and4157227008 available bytes passed. | 7672805219b931a8d746f3b7f4917543db69e3938c5239f3da7c7bcbb869c33b | + +Each table hash identifies `finite-outcome.json` under, respectively: +`/private/tmp/workharbor-t009-acceptance-90ea176-017ioqlx/`, +`/private/tmp/workharbor-t009-acceptance-90-retry-hlz6pdz1/`, +`/private/tmp/workharbor-t009-acceptance-90-pressure-kf52n2hr/` and +`/private/tmp/workharbor-t009-acceptance-90-libproc-5sjea_61/`. +The original commands/results and independent audits remain private alongside +these summaries. The first selector-only correction retained all unique-descriptor +and exit0 requirements. The independently reviewed pressure amendment admits +known1 NORMAL or2 WARNING distinctly, retains the3GiB entry planning index and +adds a1GiB live host reserve; the fixed2GiB guest and remaining proof obligations +are unchanged. No resource guard was silently waived. The host-available index +is not physical RAM, a product specification or a between-sample guarantee. + +Bounded before/after outer Engine/context/BuildKit observations matched within +017, kf52 and5sjea observation windows. hlz6 stopped before outer probes, so it has +no fresh outer continuity proof. No attempt issued an outer runtime mutation; +these statements make no broader historical continuity claim. None of these +refusals was an execution auto-review rejection or a demonstrated product-source +failure, and none authorizes an automatic retry. + +Subsequent separately reviewed, read-only diagnostics preserved the refusal: + +- The warning classifier identified MOUNT_STAT_WARNING (nfs), OUTPUT_INCOMPLETE + and MOUNT_TABLE_DEVICE_ASSUMPTION, with no unknown lines. Classification did not + establish completeness, harmlessness or irrelevance. Observation: + `/private/tmp/workharbor-t009-lsof-diagnostic-20261015-1909-4bdfa76e/observation.json`, + SHA256 `4310714ce84505d9ffa316d7ed34684e30de61fc672e7ea3e5e754b6534cdd8f`. +- The native reason diagnostic reported IMAGE_ABSENT_OR_AMBIGUOUS with last API + proc_pidinfo returning64 bytes/errno0; the count-only diagnostic measured + selected2/image0/unique-identity0 within the frozen selected-process scope. + Neither distinguishes global absence, permissions or platform support. + Count observation: + `/private/tmp/workharbor-t009-libproc-diagnostic-count-20261015-1940-2eb049cc/observation.json`, + SHA256 `90c9e3d090713e036a73166bb30d3ea4b7fee05e9075a0f2b852a4de8b62c5b1`. +- The fixed installed-lsof channel diagnostic found0 literal data.img candidate + records,0 candidate owner processes and0 cells across numeric-FD, fileport, + mapped-text and unknown channels. Incomplete-output warnings remain material; + those counts are scoped observations, not a global absence or identity proof. + Observation: + `/private/tmp/workharbor-t009-lsof-channel-diagnostic-20261015-1952-d26a853c/observation.json`, + SHA256 `33c2b0df74d71a0bcdbf04290ff5dd744e6f669cf617dcb9bb39a205f3fbb259`. + +The separate vendor UI observation invoked only `cua.getApp('OrbStack')`. It +returned **Computer Use permissions are not granted**, with no UI state, storage +path or pending relocation/restart state. The tool blocked367.0225s before control +returned, exceeding the requested60s scope; the helper stopped immediately with +no navigation, settings writes or further native/filesystem probes. This is a +Computer Use capability failure, not an execution auto-review rejection. Record: +`/private/tmp/workharbor-t009-vendor-storage-ui-kpmuyeb4/observation.json`, +SHA256 `718d53d2100dc98dd1ef806a6da6c7ca3dd8991ef592b365ae362a1c236a9293`. +No permission bypass, global setting change or inferred UI/storage state followed. + +The current external input block is an actual authoritative backing-location +mapping required by the reviewed P3 capacity/identity proof. The observations +above have not supplied it. Backing identity and quota-domain enforcement remain +UNMEASURED; they do not establish unsupported OrbStack, missing backing storage, +a product defect or insufficient physical RAM. An equivalent mapping/anchor must +be concrete and independently reviewed before another separately authorized run; +this checkpoint neither replaces the criterion nor adds a human approval gate for +routine already-authorized reversible preparation. + +The operator's **2026-10-15 maximum is3,000,000,000 bytes per test guest**. The planned +single guest remains2GiB (2147483648 bytes),2vCPU/16GiB disk, isolated with isolated +network and verified native architecture. These are planned caps, not measured +T009 acceptance caps. Whole-guest CPU/memory/disk coverage, daemon/tool ancestry, +OOM/swap and outer preservation must be measured before workload, with bounded +exact owned-ID teardown on every outcome. No silent increase, host-global change +or unreviewed backing mechanism is authorized by this evidence. + +Still required are two sequential fresh-cache real roots (36 outcomes each), +actual numeric nonroot Git/chmod and exact binary/untracked retention through +stop/start/replacement/failed build, real raw TLS metadata/no-layer/zero-forward +refusals, full official image AND Dockerfile phases and generated-metadata/reopen +proof. The separate Linux config/storage command still requires141 outcomes, +including six subprocess SIGKILL SEED boundaries and competing publishers. +Those tests have not run in this acceptance sequence. T009 owns SEED recovery; +provisioning/lifecycle reconciliation belongs to Feature006 T010. Same-author fixes, +independent whole-WI review and committed actual evidence remain prerequisites to +accepting Feature006 T009 or starting Feature006 T010. From a2545193344bc9281cebbf3f9e8e78e0e8dbfa41 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 00:36:49 +0200 Subject: [PATCH 38/51] docs(pilot): record bounded T009 fixture evidence Preserve the reviewed monitored-disk strategy, corrected fixture evidence and two actual incomplete attempts so canonical source readiness cannot be mistaken for Linux/Docker acceptance. Record verified owned cleanup, the unknown baseline cause and the current bounded outer-readiness failure without changing runtime gates or task completion. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 151 ++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index b151383b..2002fb6c 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1649,3 +1649,154 @@ Those tests have not run in this acceptance sequence. T009 owns SEED recovery; provisioning/lifecycle reconciliation belongs to Feature006 T010. Same-author fixes, independent whole-WI review and committed actual evidence remain prerequisites to accepting Feature006 T009 or starting Feature006 T010. + +### Feature006 T009 bounded-disk fixture checkpoint — 2026-10-16 + +**Feature006 T009 is NOT accepted; its checkbox remains unchecked and Feature006 +T010 waits.** The product/source candidate remains +`90ea176d7e11e49c3d544e4a884e02c66071ea51`; the preceding documentation-only head is +`832dd63000257bad01656154b639251d643f2a2d`. The exact90 independent source review +and pristine canonical GREEN above remain valid for their unchanged source. +No full canonical rerun or product-source change is claimed by this checkpoint. +Actual acceptance still requires the unchanged36 IMAGE,36 Dockerfile and141 +ordinary Linux config/storage outcomes, including numeric nonroot Git/chmod, +retention, official phases, raw TLS refusals and SEED crash boundaries. + +The separately reviewed bounded-disk strategy replaces the unavailable privileged +quota/active-backing-image prerequisites with an explicit **measured planning +watchdog**. Each guest remains2CPU/2GiB (2147483648 bytes), below the operator's +3000000000-byte ceiling, with supported16G disk configuration. The12GiB whole-root +planning budget includes base OS, signed package closure, pinned tools, source and +helpers, Go modules/build outputs, Docker/containerd/BuildKit/workspaces, temporary +files, logs and evidence. A nonoverlapping longest-prefix walker counts every +owned-root backing entry, including the root inode, using the greater of apparent +bytes and allocated blocks; it deduplicates hardlinks globally and refuses category +conflicts, unknown writable domains, unreadable entries and observed races. +Before each phase it conservatively reserves ALL remaining declared total growth +plus4GiB logical filesystem free space. Live and terminal category/total checks +remain bounded by the original deadlines. These are monitored test-plan ceilings, +not kernel-enforced disk quotas or a between-sample guarantee. + +Vendor16G is **CONFIGURED only**. Kernel disk-quota enforcement, OS-inclusive16GiB +accounting and Mac physical OrbStack backing capacity remain **UNMEASURED**. +The host root filesystem20GiB planning observation is explicitly not an OrbStack +backing mapping. The reviewed entry planning index is2.75GiB (2952790016 bytes), +with1GiB live host availability and a measured128MiB runner-RSS refusal. Whole-guest +memory/CPU, swap/OOM, all actor ancestry, closed daemon configuration, immutable +owned-ID teardown and outer preservation gates remain mandatory. + +Pinned crane's OCI mode writes a directory layout. The fixture bounds its aggregate +files to128MiB, verifies the exact selected leaf/config/layer hashes and declared +sizes, and counts aggregate decompressed gzip tar bytes AND regular-file payloads +within1GiB. Config diff IDs must match. Sparse, unsupported and size-override +encodings refuse. No layer is extracted, executed or imported; the identity-owned +layout is removed before real Docker work, leaving its cache fresh. Signed package +Size512MiB, Installed-Size1GiB and indices256MiB are frozen before installation; +unknown or excessive metadata refuses. All original source/archive/tool/TLS/test +identity bindings remain unchanged. + +The strategy and final prepared code were independently reviewed. Code2's two +P2 findings (aggregate layer consumption before remaining-budget enforcement and +signed resolution before its fresh phase reserve) were reproduced RED, corrected +in code3 and independently closed. Code3 passed32 native-denied tests. Its actual +failure subsequently exposed an observability defect: known mount-domain guard +refusals and unrelated TypeError exceptions collapsed to the same generic receipt. +Code4 changes only the disk helper and native adapter to emit fixed safe stage, +exception-family and whitelisted reason/errno fields, and retain a validated +at-most1KiB nonzero receipt before raising. It never records raw exception text or +paths. Its five new error-entrypoint/retention tests and all37 native-denied tests +pass. Same-reviewer code4 PREPARATION and final identity reviews are CLEAR; these +are not actual Docker acceptance. + +| Retained evidence | SHA256 | +| --- | --- | +| original strategy.json | 80037ab1a6d38f27fcbbd5747c7564cbaa91db2d778b2e3597d0e464cc97a7bf | +| reviewed strategy.revision2.json | 0b076903843253d46b1e50b99f50edef77cfba175a1499a8599f669ec46c713f | +| bounded-disk design assessment | ee3fd84123d92c31bd39d67bcc63b6727b8e9c06618293f9a98183feb41ec3b3 | +| code3 review-packet.json | d9bbdd62a3e4f9f35513cc3472959b0253d8943919264ce735e1f634959433fe | +| code3 independent assessment | a741f311739225cec3962e5a58f49eaf014c35db910e8d8c5f4bac6d8b41c0ac | +| code4 review-packet.json | 1ac1874be370b381d729e2f7ad15246e04e1eb243abe1fa4b195786cde22a3fe | +| code4 independent assessment | 9459676537da87cc56549419d488a8650405a69b4706e3fc9e7bf4431c96afd6 | +| exact90 source.tar.gz | ce71b26983b30960fc812fd554882bd626cbc383b028ccbae8e8714f132d7bff | +| exact90 runtime test-manifest.json | c35f15c2252a1545cc71c183d5dffe8ce9661bbf62bda9d5f6b20be6dc6a4d50 | +| code3 activation-manifest.json | 603cfe0b9f075589821578ab66568bbc8eefa68e91855d714f68d9fed252b0c9 | +| code4 activation-manifest.json | 804752a69bc4598fc1e2f3096dc8382080afbb9fb1822667ee1b7f62ca105abe | + +Strategy and packets are under +`/private/tmp/workharbor-t009-bounded-disk-strategy-wlsha3bp/` (packets in +`code3/` and `code4/`). The retained reviewed revision2 clarifies category accounting, OCI counting and +the entry amendment. +Independent assessment directories are, respectively, +`/private/tmp/workharbor-t009-bounded-disk-design-review-m8uem5eh/`, +`/private/tmp/workharbor-t009-code3-review-6wougg2b/` and +`/private/tmp/workharbor-t009-code4-review-15m30z7m/`, each `assessment.json`. +All original code2/code3 failures, independent reproductions, RED/GREEN logs, +activation manifests, separate coordinator GO artifacts and private raw evidence +remain retained. No consumed GO was reused and no automatic retry occurred. + +The first bounded-disk invocation was exactly: + +```text +/opt/homebrew/opt/python@3.15/bin/python3.15 -B /private/tmp/workharbor-t009-bounded-disk-strategy-wlsha3bp/code3/runner.py /private/tmp/workharbor-t009-bounded-code3-activation-td60n65f/activation-manifest.json --execute --evidence-directory /private/tmp/workharbor-t009-acceptance-90-bounded-f1e569a6 +``` + +It exited125 after14.267166s of recorded command time (44 commands). One isolated +Ubuntu24.04/arm64 guest was created; actual parent `cpu.max=200000 100000` and +`memory.max=2147483648` were verified. Memory current214560768/peak345313280 bytes, +all max/OOM counters0, swap.max/current0 and visible process ancestry passed. +Host entry NORMAL1 availability5813239808 and pre-staging4829184000 bytes passed. +Command30's disk-baseline helper refused before source upload or package/tool/ +daemon/OCI/test work. Its129-byte filtered stdout SHA256 +`7498fa2ebd7329e38435d2991f1befe79d946a6478e77043e54b8174eea55780` +exactly matches the retained fixed-schema reconstruction +`MEASUREMENT_UNAVAILABLE / OTHER`. The underlying operation/cause is **UNKNOWN**; +the later offline collapse reproduction does not establish an actual mount, +platform, quota, capacity or product-source failure. + +Owned ID `01M50REBDAKANRDHPVE0QJQE3R` was stopped (command34 exit0), independently +deleted (command37 exit0), and final inventory was empty. The outer before/after +observations matched within that window. Session1326 and every owned direct +command were reaped; no second guest ran. The finite summary is +`/private/tmp/workharbor-t009-acceptance-90-bounded-f1e569a6/finite-actual.json`, +SHA256 `11dccddc80ec614754a0a63f7357d1c9f95c8c7cd4a2d6fa418377cc592770eb`. +Its independent actual assessment is +`/private/tmp/workharbor-t009-actual-code3-review-t6cjy7kt/assessment.json`, SHA256 +`0e719765d345a081a08ad78637d21e26fbc10cb66543982dd22d52a2e165ce01`. +That directory also retains `diagnostic-collapse-repro.json`. + +After the independently reviewed observability correction and a NEW separately +bound GO, the second bounded-disk invocation was exactly: + +```text +/opt/homebrew/opt/python@3.15/bin/python3.15 -B /private/tmp/workharbor-t009-bounded-disk-strategy-wlsha3bp/code4/runner.py /private/tmp/workharbor-t009-bounded-code4-activation-ulus6z81/activation-manifest.json --execute --evidence-directory /private/tmp/workharbor-t009-acceptance-90-bounded-code4-369fb543 +``` + +It exited125 BEFORE creation, in5.329881s of recorded command time (nine commands). +Host entry NORMAL1 availability5523292160 bytes passed. The existing outer Engine +`GET /_ping` request used the explicitly whitelisted current local Unix endpoint +and `curl --max-time 5`; command10 exited28 after5.01606s. Its exact diagnostic was +`Operation timed out after 5004 milliseconds with 0 bytes received`. This is one +bounded readiness failure, not proof of a persistent outage or product/platform +fault. No owned ID/create/guest/source/package/tool/daemon/OCI/test work occurred. +The corrected disk baseline was **UNRUN**, and the prior actual baseline cause +remains UNKNOWN. Final inventory was empty; stop/delete were inapplicable because +no guest was created. Session63797 and all direct commands were reaped. + +The initial outer baseline did not complete, so this second window's outer +continuity is **UNMEASURED**, not "unchanged". No outer mutation, repair, restart, +settings change, extra health probe, timeout increase or automatic retry followed. +The finite summary is +`/private/tmp/workharbor-t009-acceptance-90-bounded-code4-369fb543/finite-actual.json`, +SHA256 `2a1a52ecbc237302f3489d993265f75719b7a13ce08117c8fff345bde4bce9a4`. +The independent actual assessment is +`/private/tmp/workharbor-t009-code4-actual-review-8yhdg777/assessment.json`, SHA256 +`ccd6ab20a06db9cb84f9bb8d6c8ec312c0434676c0c4b97e738611a97bb43de7`. + +The current fixture input block is a successful bounded read of the required +existing outer Engine, needed to establish the outer-preservation baseline. +Earlier successful pings (0.088/0.015s) provide no measured basis for an arbitrary +new timeout. Source90 canonical GREEN remains separate from this missing runtime +evidence. No active guest or owned native check remains; resource windows were +released. T009 still requires actual whole-root budget/domain, daemon/tool actor, +36+36 Docker and141 Linux evidence and independent whole-WI acceptance before +T010 may start. This checkpoint changes no acceptance criterion or runtime guard. From 50b98db373bb1e62db7bcd347e05a31877f32754 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 00:41:21 +0200 Subject: [PATCH 39/51] docs(pilot): correct T009 entry-policy provenance Attribute the accepted 2.75GiB entry amendment to the later byte-bound fixture pressure policy and mechanism clarification artifacts. Preserve the earlier strategy's historical 3GiB text and all actual evidence without changing numeric policy, source behavior or task status. Refs: #72 Co-authored-by: Codex --- docs/exec-plans/active/installable-pilot.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 2002fb6c..5c9ed25e 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1712,6 +1712,8 @@ are not actual Docker acceptance. | --- | --- | | original strategy.json | 80037ab1a6d38f27fcbbd5747c7564cbaa91db2d778b2e3597d0e464cc97a7bf | | reviewed strategy.revision2.json | 0b076903843253d46b1e50b99f50edef77cfba175a1499a8599f669ec46c713f | +| later code3/code4 pressure-policy.json | 87a044dcfeb51a2a559663f33fe9968ed75be3d1458b033b75b4a221f626d33d | +| later code3/code4 mechanism-clarifications.json | cbf1c8dbb0250791717de6f2564c93141beab7fa2ad0d7d8d91dd37ad9f0702e | | bounded-disk design assessment | ee3fd84123d92c31bd39d67bcc63b6727b8e9c06618293f9a98183feb41ec3b3 | | code3 review-packet.json | d9bbdd62a3e4f9f35513cc3472959b0253d8943919264ce735e1f634959433fe | | code3 independent assessment | a741f311739225cec3962e5a58f49eaf014c35db910e8d8c5f4bac6d8b41c0ac | @@ -1724,8 +1726,12 @@ are not actual Docker acceptance. Strategy and packets are under `/private/tmp/workharbor-t009-bounded-disk-strategy-wlsha3bp/` (packets in -`code3/` and `code4/`). The retained reviewed revision2 clarifies category accounting, OCI counting and -the entry amendment. +`code3/` and `code4/`). The retained reviewed revision2 clarifies category and OCI accounting; its +`phase_safety` retains historical3GiB entry text. The later accepted2.75GiB entry +amendment is bound by `code3/pressure-policy.json` and `code4/pressure-policy.json`, +plus their `mechanism-clarifications.json`, and verified by the code3/code4 +independent assessments above. Each later artifact pair has identical bytes in +code3 and code4, with exact hashes in the table. Independent assessment directories are, respectively, `/private/tmp/workharbor-t009-bounded-disk-design-review-m8uem5eh/`, `/private/tmp/workharbor-t009-code3-review-6wougg2b/` and From 15f93c67172deccd9b7c5daf55758e7b4e21e8d9 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:27:21 +0200 Subject: [PATCH 40/51] fix(storage): refuse link chains that leave and re-enter the source The lexical and final-target checks accepted a chain that climbs above ProjectsPath through an intermediate link and comes back in by name. It resolves inside the source but to a sibling of data once copied. Resolve each link component by component inside the source root. Co-Authored-By: Claude Opus 5.5 --- apps/host_agent/internal/storage/seed.go | 58 ++++++++++++++++++- apps/host_agent/internal/storage/seed_test.go | 15 +++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/host_agent/internal/storage/seed.go b/apps/host_agent/internal/storage/seed.go index aab401c1..0f2e58d1 100644 --- a/apps/host_agent/internal/storage/seed.go +++ b/apps/host_agent/internal/storage/seed.go @@ -265,6 +265,61 @@ func syncDir(path string) error { } return e } + +// resolvesWithin follows link from dir component by component, relative to +// root, as the copied tree will resolve it. Every step, including steps +// through intermediate links, must stay inside root: a chain that climbs out +// and re-enters by name resolves elsewhere once copied. Dangling targets and +// cycles are refused. +func resolvesWithin(root, dir, link string) bool { + hops := 0 + var walk func(stack []string, target string) ([]string, bool) + walk = func(stack []string, target string) ([]string, bool) { + if filepath.IsAbs(target) { + return nil, false + } + for _, part := range strings.Split(filepath.ToSlash(target), "/") { + switch part { + case "", ".": + continue + case "..": + if len(stack) == 0 { + return nil, false + } + stack = stack[:len(stack)-1] + continue + } + next := append(append([]string(nil), stack...), part) + path := filepath.Join(append([]string{root}, next...)...) + info, e := os.Lstat(path) + if e != nil { + return nil, false + } + if info.Mode()&os.ModeSymlink == 0 { + stack = next + continue + } + if hops++; hops > 40 { + return nil, false + } + inner, e := os.Readlink(path) + if e != nil { + return nil, false + } + var ok bool + if stack, ok = walk(stack, inner); !ok { + return nil, false + } + } + return stack, true + } + start := []string{} + if dir != "." { + start = strings.Split(filepath.ToSlash(dir), "/") + } + _, ok := walk(start, link) + return ok +} func copyData(ctx context.Context, source, dest string, b Binding, point func(string) error) error { physicalSource, evalErr := filepath.EvalSymlinks(source) if evalErr != nil { @@ -343,7 +398,8 @@ func copyData(ctx context.Context, source, dest string, b Binding, point func(st // project subtree. Dangling and cyclic inputs are deliberately refused. actual, resolveErr := filepath.EvalSymlinks(path) actualRelative, relativeErr := filepath.Rel(physicalSource, actual) - if resolveErr != nil || relativeErr != nil || !filepath.IsLocal(actualRelative) { + if resolveErr != nil || relativeErr != nil || !filepath.IsLocal(actualRelative) || + !resolvesWithin(physicalSource, filepath.Dir(rel), link) { return failure("invalid_seed") } if os.Symlink(link, target) != nil || os.Lchown(target, int(b.DataUID), int(b.DataGID)) != nil { diff --git a/apps/host_agent/internal/storage/seed_test.go b/apps/host_agent/internal/storage/seed_test.go index ab486b59..3351fe59 100644 --- a/apps/host_agent/internal/storage/seed_test.go +++ b/apps/host_agent/internal/storage/seed_test.go @@ -388,6 +388,21 @@ func TestSeedActualSymlinkChainsNeverEscapeDataOrRemainIndeterminate(t *testing. } } +func TestSeedLinkChainLeavingAndReenteringSourceRefused(t *testing.T) { + s, b, source := fixture(t) + os.Mkdir(filepath.Join(source.ProjectsPath, "s"), 0755) + os.Symlink("..", filepath.Join(source.ProjectsPath, "s/up")) + // Resolves back to binary in the source, but to a sibling of data once copied. + parent := filepath.Base(filepath.Dir(source.ProjectsPath)) + reentry := "s/up/../../" + parent + "/" + filepath.Base(source.ProjectsPath) + "/binary" + os.Symlink(reentry, filepath.Join(source.ProjectsPath, "reentry")) + _, e := s.Seed(context.Background(), b, source, allow) + assertCode(t, e, "invalid_seed") + if _, e = os.Lstat(filepath.Join(s.Root, b.StorageID)); !os.IsNotExist(e) { + t.Fatal("re-entering link published") + } +} + func TestSeedFullyResolvedContainedLinkChainKeepsOriginalLinkBytes(t *testing.T) { s, b, source := fixture(t) os.Symlink("dir/link", filepath.Join(source.ProjectsPath, "chain")) From 5fbd50b9704ec857368835622406f13741eea637 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:28:46 +0200 Subject: [PATCH 41/51] fix(provider): decode embedded tool pins with non-string fields Registry admission decoded tool-pins.json into string-only maps, but the crane pin carries the numeric metadata_input_limit_bytes. The decode always failed, so every registry image refused as tool_unavailable before any tool check. Read string fields per pin and test the embedded pins directly. Co-Authored-By: Claude Opus 5.5 --- .../internal/providers/docker/native_open.go | 9 +++--- .../internal/providers/docker/registry.go | 31 ++++++++++++++++--- .../providers/docker/registry_trust_test.go | 17 ++++++++++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/host_agent/internal/providers/docker/native_open.go b/apps/host_agent/internal/providers/docker/native_open.go index 141815f3..3c725bfb 100644 --- a/apps/host_agent/internal/providers/docker/native_open.go +++ b/apps/host_agent/internal/providers/docker/native_open.go @@ -5,7 +5,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config" "io" "os" @@ -107,11 +106,11 @@ func openNativePort(ctx context.Context, o nativeOptions) (*nativePort, error) { if e != nil || !strings.HasPrefix(strings.TrimSpace(string(version)), "Docker version 29.8.2,") { return nil, fail("tool_unavailable") } - var pins struct{ Buildx map[string]string } - if json.Unmarshal(toolPins, &pins) != nil { + buildx, ok := toolPin("buildx") + if !ok { return nil, fail("tool_unavailable") } - if e = verifyRegistryTool(ctx, o.BuildxPath, pins.Buildx["linux_"+runtime.GOARCH+"_sha256"], pins.Buildx["version"], root, true); e != nil { + if e = verifyRegistryTool(ctx, o.BuildxPath, buildx["linux_"+runtime.GOARCH+"_sha256"], buildx["version"], root, true); e != nil { return nil, e } // Docker's plugin lookup is confined to the private config, with pinned bytes. @@ -121,7 +120,7 @@ func openNativePort(ctx context.Context, o nativeOptions) (*nativePort, error) { } raw, e := os.ReadFile(o.BuildxPath) sum := sha256.Sum256(raw) - if e != nil || hex.EncodeToString(sum[:]) != pins.Buildx["linux_"+runtime.GOARCH+"_sha256"] { + if e != nil || hex.EncodeToString(sum[:]) != buildx["linux_"+runtime.GOARCH+"_sha256"] { return nil, fail("tool_unavailable") } plugin := filepath.Join(plugins, "docker-buildx") diff --git a/apps/host_agent/internal/providers/docker/registry.go b/apps/host_agent/internal/providers/docker/registry.go index d561769e..1063390d 100644 --- a/apps/host_agent/internal/providers/docker/registry.go +++ b/apps/host_agent/internal/providers/docker/registry.go @@ -22,6 +22,28 @@ import ( //go:embed tool-pins.json var toolPins []byte +// toolPin returns the string fields of one embedded tool pin. Pins also carry +// non-string fields, such as crane's metadata_input_limit_bytes, which a +// map[string]string decode rejects. +func toolPin(name string) (map[string]string, bool) { + var all map[string]json.RawMessage + if json.Unmarshal(toolPins, &all) != nil { + return nil, false + } + var fields map[string]json.RawMessage + if json.Unmarshal(all[name], &fields) != nil || len(fields) == 0 { + return nil, false + } + pin := map[string]string{} + for key, raw := range fields { + var value string + if json.Unmarshal(raw, &value) == nil { + pin[key] = value + } + } + return pin, true +} + type registrySelection struct { Reference, ConfigID string ConfigSize int64 @@ -261,8 +283,9 @@ func (r Registry) Inspect(ctx context.Context, ref string) (AdmittedImage, error if runtime.GOOS != "linux" || runtime.GOARCH != r.Architecture { return AdmittedImage{}, fail("tool_unavailable") } - var pins struct{ Buildx, Crane map[string]string } - if json.Unmarshal(toolPins, &pins) != nil { + buildx, okBuildx := toolPin("buildx") + crane, okCrane := toolPin("crane") + if !okBuildx || !okCrane { return AdmittedImage{}, fail("tool_unavailable") } stage, e := registryPrivate(r.PrivateDirectory) @@ -273,10 +296,10 @@ func (r Registry) Inspect(ctx context.Context, ref string) (AdmittedImage, error if _, e = r.readEnvironment(stage, r.BuildxPath); e != nil { return AdmittedImage{}, e } - if e = verifyRegistryTool(ctx, r.BuildxPath, pins.Buildx["linux_"+r.Architecture+"_sha256"], pins.Buildx["version"], stage, true); e != nil { + if e = verifyRegistryTool(ctx, r.BuildxPath, buildx["linux_"+r.Architecture+"_sha256"], buildx["version"], stage, true); e != nil { return AdmittedImage{}, e } - if e = verifyRegistryTool(ctx, r.CranePath, pins.Crane["linux_"+r.Architecture+"_sha256"], pins.Crane["version"], stage, false); e != nil { + if e = verifyRegistryTool(ctx, r.CranePath, crane["linux_"+r.Architecture+"_sha256"], crane["version"], stage, false); e != nil { return AdmittedImage{}, e } manifest := func(ref string) ([]byte, error) { diff --git a/apps/host_agent/internal/providers/docker/registry_trust_test.go b/apps/host_agent/internal/providers/docker/registry_trust_test.go index 44f228ff..a7b21dc5 100644 --- a/apps/host_agent/internal/providers/docker/registry_trust_test.go +++ b/apps/host_agent/internal/providers/docker/registry_trust_test.go @@ -76,3 +76,20 @@ func TestRegistryExplicitCABundleIntegrityAndPhysicalPath(t *testing.T) { }) } } + +func TestEmbeddedToolPinsDecodeRegistryTools(t *testing.T) { + for _, name := range []string{"buildx", "crane"} { + pin, ok := toolPin(name) + if !ok || pin["version"] == "" { + t.Fatalf("%s pin unavailable", name) + } + for _, arch := range []string{"amd64", "arm64"} { + if len(pin["linux_"+arch+"_sha256"]) != 64 { + t.Fatalf("%s %s digest missing", name, arch) + } + } + } + if _, ok := toolPin("missing"); ok { + t.Fatal("unknown pin admitted") + } +} From f6874459733ffe1556d9be893036d5e80c34ae5f Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:29:41 +0200 Subject: [PATCH 42/51] fix(hosts): keep a valid identity connected when renewal fails A failed renewal skipped the session even while the active certificate was valid, so an unreachable or conflicting renewal disconnected a healthy host. A failed reload after a successful renewal dereferenced a nil state and crashed the agent. Keep the active credential, which the session still checks, and return ErrTransport on a failed reload. Co-Authored-By: Claude Opus 5.5 --- apps/host_agent/internal/connection/client.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/host_agent/internal/connection/client.go b/apps/host_agent/internal/connection/client.go index 107fb6a0..35cd5e61 100644 --- a/apps/host_agent/internal/connection/client.go +++ b/apps/host_agent/internal/connection/client.go @@ -346,10 +346,14 @@ func Run(ctx context.Context, dir string, onEvent func(Event)) error { } expires, e := identity.Expires(state) if (state.Pending != nil && state.Pending.Certificate != "") || (e == nil && time.Until(expires) <= 6*time.Hour) { - e = Renew(ctx, dir) - if e == nil { - state, e = identity.Load(dir) + // A failed renewal keeps the active credential in use while session + // still accepts it; the next attempt retries renewal. + if Renew(ctx, dir) == nil { + if state, e = identity.Load(dir); e != nil || state.Active == nil { + return ErrTransport + } } + e = nil } if e == nil { e = session(ctx, state, func(event Event) { attempt = 0; emit(onEvent, event.Kind, event.HostID) }) From 515ba34b12c4a3f5f8443f89a526e95adad9a24d Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:30:52 +0200 Subject: [PATCH 43/51] fix(config): admit stage sources only from earlier stages The Dockerfile gate pinned FROM bases but ignored COPY/ADD --from and RUN --mount from=, so an approved Dockerfile could pull a mutable external image past the digest and metadata gate. Admit only earlier stage aliases or indexes there, and refuse remote ADD sources and dynamic flag values. External tools enter through a pinned FROM stage. Co-Authored-By: Claude Opus 5.5 --- apps/host_agent/internal/config/trusted.go | 46 +++++++++++++++++++ .../internal/config/trusted_test.go | 31 +++++++++++++ 2 files changed, 77 insertions(+) diff --git a/apps/host_agent/internal/config/trusted.go b/apps/host_agent/internal/config/trusted.go index 23affed9..d7e21d7c 100644 --- a/apps/host_agent/internal/config/trusted.go +++ b/apps/host_agent/internal/config/trusted.go @@ -346,6 +346,46 @@ func ValidateSource(m map[string]any) error { } // ValidateDockerfile uses the upstream AST only to reject unsupported capabilities. +// priorStageSources admits COPY/ADD --from and RUN --mount from= only for an +// earlier stage, so every external image enters through a digest-pinned FROM +// and its metadata gate. Remote ADD sources and dynamic flags refuse. +func priorStageSources(node *parser.Node, aliases map[string]bool, stages int) bool { + prior := func(ref string) bool { + if index, e := strconv.Atoi(ref); e == nil { + return index >= 0 && index < stages-1 && strconv.Itoa(index) == ref + } + return aliases[strings.ToLower(ref)] + } + for _, flag := range node.Flags { + if strings.Contains(flag, "$") { + return false + } + name, value, _ := strings.Cut(strings.TrimPrefix(flag, "--"), "=") + switch strings.ToLower(name) { + case "from": + if !prior(value) { + return false + } + case "mount": + for _, field := range strings.Split(value, ",") { + key, ref, _ := strings.Cut(field, "=") + if strings.EqualFold(strings.TrimSpace(key), "from") && !prior(ref) { + return false + } + } + } + } + if strings.EqualFold(node.Value, "add") { + for argument := node.Next; argument != nil && argument.Next != nil; argument = argument.Next { + source := strings.ToLower(argument.Value) + if strings.Contains(source, "://") || strings.HasPrefix(source, "git@") { + return false + } + } + } + return true +} + func ValidateDockerfile(raw []byte) ([]string, error) { if !utf8.Valid(raw) || bytes.IndexByte(raw, 0) >= 0 { return nil, unsupportedConfiguration() @@ -364,10 +404,15 @@ func ValidateDockerfile(raw []byte) ([]string, error) { seen := map[string]bool{} refs := []string{} from := false + stages := 0 for _, node := range result.AST.Children { switch strings.ToLower(node.Value) { case "volume", "onbuild": return nil, unsupportedConfiguration() + case "copy", "add", "run": + if !priorStageSources(node, aliases, stages) { + return nil, unsupportedConfiguration() + } case "arg": if node.Next == nil { return nil, unsupportedConfiguration() @@ -379,6 +424,7 @@ func ValidateDockerfile(raw []byte) ([]string, error) { } case "from": from = true + stages++ if len(node.Flags) > 0 || node.Next == nil { return nil, unsupportedConfiguration() } diff --git a/apps/host_agent/internal/config/trusted_test.go b/apps/host_agent/internal/config/trusted_test.go index 08479f4a..8d4c369e 100644 --- a/apps/host_agent/internal/config/trusted_test.go +++ b/apps/host_agent/internal/config/trusted_test.go @@ -112,6 +112,37 @@ func TestDockerfileASTRefusesBeforeBuild(t *testing.T) { } } +func TestDockerfileExternalStageSourcesRefuseBeforeBuild(t *testing.T) { + base := "ubuntu@sha256:" + strings.Repeat("a", 64) + head := "FROM " + base + " AS tool\nFROM " + base + "\n" + for _, data := range []string{ + head + "COPY --from=tool /bin/x /x\n", + head + "COPY --from=0 /bin/x /x\n", + head + "RUN --mount=type=bind,from=tool,target=/t true\n", + head + "RUN --mount=type=cache,target=/c true\n", + head + "ADD local.tar /dst\n", + } { + if _, err := ValidateDockerfile([]byte(data)); err != nil { + t.Fatalf("prior-stage source refused: %q %v", data, err) + } + } + for _, data := range []string{ + head + "COPY --from=evil/tool:latest / /\n", + head + "COPY --from=" + base + " / /\n", + head + "COPY --from=1 /x /x\n", + head + "COPY --from=$TOOL /x /x\n", + head + "ADD --from=evil/tool:latest / /\n", + head + "RUN --mount=type=bind,from=evil/tool:latest,target=/t true\n", + head + "RUN --mount=from=evil/tool:latest,target=/t true\n", + head + "ADD https://example.test/x.tar /dst\n", + head + "ADD git@example.test:repo.git /dst\n", + "FROM " + base + "\nCOPY --from=later /x /x\nFROM " + base + " AS later\n", + } { + _, err := ValidateDockerfile([]byte(data)) + code(t, err, "unsupported_configuration") + } +} + func TestSourceNULAndUnrepresentableEnvironmentNamesRefuse(t *testing.T) { for _, field := range []string{"name", "postCreateCommand"} { m := map[string]any{"image": "ubuntu@sha256:" + strings.Repeat("a", 64), field: "bad\x00value"} From ca70f9c507a544b248682a899fbced595daaeef8 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:31:43 +0200 Subject: [PATCH 44/51] docs(ci): describe application acceptance without stale counts The harness guide and ADR 0007 still described the T002 scaffold: no required API client tests and five ExUnit tests. The manifest now requires wirejson and many more ExUnit identities. Point to the manifest inventory instead of fixed counts. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-explicit-ci-acceptance.md | 3 ++- docs/development/harness.md | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/adr/0007-explicit-ci-acceptance.md b/docs/adr/0007-explicit-ci-acceptance.md index 3c6d0f36..0c665048 100644 --- a/docs/adr/0007-explicit-ci-acceptance.md +++ b/docs/adr/0007-explicit-ci-acceptance.md @@ -98,7 +98,8 @@ acceptance. Schema 2 retains all existing Python required IDs and retained-outco semantics and adds typed application required test identities, with fixed argv in `scripts/application_checks.py`. Go JSON events and a test-only ExUnit formatter prove each required case actually passed; all other discovered test failures/skips -remain blockers. The doc-only API client has no fabricated required test. +remain blockers. At T002 the doc-only API client had no fabricated required test; +later tasks add required identities only for tests that exist. The application gate performs pinned bootstrap/locked dependency acquisition from a fresh checkout, then Go format/vet/build/tests and Phoenix format/compile/assets/tests. diff --git a/docs/development/harness.md b/docs/development/harness.md index 0c3e67be..6192d99a 100644 --- a/docs/development/harness.md +++ b/docs/development/harness.md @@ -63,12 +63,12 @@ budgets remain 600 and 1200 seconds, respectively; utility commands retain 300 s Schema 2 adds an explicit `application_tests` inventory. Fixed repository code runs `gofmt -l`, `go mod download`, `go vet`, `go build` and uncached `go test -json` for -all three Go modules. The two command refusal tests are required. The API client -currently has documentation only and no required tests; any new test outcome must +all three Go modules. The `application_tests` inventory in the manifest is the +authoritative list of required identities per module; any other test outcome must still pass. Package skips qualify only for an explicit `[no test files]` event, never for a skipped test. Phoenix runs locked dependency acquisition, format, warnings-as-errors compile, asset tool installation/build and warnings-as-errors -tests. All five existing ExUnit tests are required. The test-only formatter records +tests, with required ExUnit identities listed in the same inventory. The test-only formatter records actual test outcomes alongside the ordinary CLI formatter; missing IDs, skipped or excluded tests, duplicate outcomes and incomplete streams fail. New green tests are collected without requiring new manifest entries. Thirteen required Python controls From 0152355b7dfb42901f56627ae742cb5b923188d9 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:32:02 +0200 Subject: [PATCH 45/51] ci(provider): compile real-Docker acceptance entrypoints in every run The docker_integration acceptance files were never vetted or compiled by make check or CI, so a provider refactor could break them unseen until a scarce guest run. Vet the tagged package without running it. Co-Authored-By: Claude Opus 5.5 --- scripts/application_checks.py | 3 +++ scripts/test_application_checks.py | 1 + 2 files changed, 4 insertions(+) diff --git a/scripts/application_checks.py b/scripts/application_checks.py index 898952c2..26c4f995 100644 --- a/scripts/application_checks.py +++ b/scripts/application_checks.py @@ -190,6 +190,9 @@ def execute(argv, cwd=root): raise ValueError('Unformatted Go source: ' + module) execute([tools['go'], 'mod', 'download'], cwd) execute([tools['go'], 'vet', './...'], cwd) + if module == 'host_agent': + # Compile the real-Docker acceptance entrypoints without running them. + execute([tools['go'], 'vet', '-tags', 'docker_integration', './internal/providers/docker/'], cwd) output = cache/('build-' + module) output.mkdir() argv = ([tools['go'], 'build', './...'] if module == 'api_client' else diff --git a/scripts/test_application_checks.py b/scripts/test_application_checks.py index e573aa5a..c652c34b 100644 --- a/scripts/test_application_checks.py +++ b/scripts/test_application_checks.py @@ -142,6 +142,7 @@ def test_fixed_gates_locked_setup_external_caches_and_all_modules(self): self.assertIn(['assets.build'], commands) self.assertEqual(3, commands.count(['test', '-json', '-count=1', './...'])) self.assertEqual(3, commands.count(['vet', './...'])) + self.assertIn(['vet', '-tags', 'docker_integration', './internal/providers/docker/'], commands) self.assertIn(['build', './...'], commands) self.assertEqual(2, sum(argv[:2] == ['build', '-o'] for argv in commands)) for _, _, env in observed: From 4f4f5478237d79310fed9b10a0d10aa2594d5498 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:32:35 +0200 Subject: [PATCH 46/51] docs(pilot): map rewritten evidence IDs and record review outcome The split from PR #83 rebased every pilot commit, so evidence IDs cited in the plan no longer resolve on the branch. Map them by subject, mark them historical, and record the independent review's fixed and deferred findings. Co-Authored-By: Claude Opus 5.5 --- docs/exec-plans/active/installable-pilot.md | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 5c9ed25e..62470246 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1806,3 +1806,93 @@ evidence. No active guest or owned native check remains; resource windows were released. T009 still requires actual whole-root budget/domain, daemon/tool actor, 36+36 Docker and141 Linux evidence and independent whole-WI acceptance before T010 may start. This checkpoint changes no acceptance criterion or runtime guard. + +## Pilot PR split and independent review — 2026-10-16 + +The pilot left PR #83 for its own PR. Its commits were rebased from `f02250f` +onto `origin/main` `641177e`, without merge commits. Commits that `main` +already carried were dropped, and the out-of-scope orchestrator adapter +proposal moved to its own branch. Every commit ID cited as evidence above +predates this rewrite: it is historical and does not cover the current head. +An owner rebase merge rewrites the IDs again, so match commits by subject. + +| Cited before | After rebase | Subject | +| --- | --- | --- | +| `4d06962` | `0dd3b60` | docs(spec006): distinguish lifecycle retries from agent input | +| `c597fa6` | `c340f05` | docs(pilot): plan installable workspace and CLI work items | +| `602ae81` | `1275096` | docs(pilot): require terminal safety before attachment acceptance | +| `3dd0b78` | `1370c53` | feat(pilot): scaffold pinned Phoenix and Go applications | +| `0c2c135` | `e566c99` | docs(pilot): separate internal acceptance from signed release | +| `d3d84c8` | `e85bb1d` | fix(whr): return documented usage exit for unsupported commands | +| `bc18034` | `3e34360` | ci(pilot): enforce explicit Go and Phoenix acceptance | +| `191c62b` | `5c16378` | test(migration): freeze selected Python wire and path contracts | +| `6abb741` | `3c249ef` | feat(migration): port strict JSON and lexical path primitives | +| `f51d6fe` | `d9bddce` | docs(pilot): record accepted owner persistence contract | +| `1189c81` | `586e83f` | feat(owner): persist scoped durable operation intent | +| `5b28644` | `3149bc7` | docs(auth): consolidate accepted browser security contract | +| `65af90c` | `79c9b24` | feat(auth): enforce scoped owner and browser authentication | +| `ef399f5` | `70e090e` | fix(test): reuse loaded authentication migration module | +| `5d81ae5` | `7b77c9b` | docs(protocol): consolidate accepted host validation contract | +| `e10c699` | `e582db1` | feat(protocol): validate scoped immutable host messages | +| `ac89bf7` | `a59d396` | test(security): pin HTTP1-only authenticated transport contract | +| `c88f339` | `a215669` | fix(security): enforce HTTP1-only authenticated listener profile | +| `e99c170` | `b4c7757` | docs(hosts): freeze reviewed enrollment and liveness contract | +| `e42fe27` | `b2b4bae` | feat(hosts): enroll and connect authenticated host identities | +| `64541c1` | `627a9db` | fix(hosts): prove atomic identity persistence recovery | +| `7b041e4` | `c2a496f` | docs(hosts): record accepted enrollment transport evidence | +| `5de4a8e` | `3e2fb65` | docs(provider): consolidate T009 trust and Docker contract | +| `21c5a92` | `46dd171` | docs(provider): record reviewed seed ownership and native evidence | +| `ad3cb18` | `a483ae9` | feat(trust): persist exact revision and initial storage binding | +| `405f936` | moved to `docs/orchestrator-adapters` | docs: propose external orchestrator adapter boundary | +| `9a5be15` | `d0c53fa` | feat(config): resolve and freeze approved container inputs | +| `c606a79` | `80126d3` | feat(storage): publish initial project data atomically | +| `1a38e97` | `7340c84` | fix(config): check every ARG and canonical stage name | +| `ce1ce97` | `2c1605a` | fix(storage): resolve complete initial symlink chains | +| `2042586` | `1793bd1` | feat(provider): implement trusted Docker workspace primitives | +| `ac70adb` | `ed77c22` | fix(ci): retain schema-valid provider test identities | +| `0d435d6` | `a465d3c` | fix(provider): preserve official result and config boundaries | +| `1d4bfa3` | `5241769` | test(provider): prove bounded real refusal coverage | +| `06b5c7e` | `e759847` | fix(test): require exact source symlink refusal diagnostic | +| `befe43a` | `d4c42ed` | fix(ci): isolate npm configs in distinct private files | +| `90ea176` | `f6c71a6` | test(provider): require failed build to reach Docker | +| `832dd63` | `283c1fd` | docs(pilot): record T009 canonical and runtime block | +| `5d919ec` | `d2bee51` | docs(pilot): record bounded T009 fixture evidence | +| `20102b7` | `340d043` | docs(pilot): correct T009 entry-policy provenance | + +Independent static review of the rebased pilot (five reviewers, one per area) +found one high and six medium defects. These are fixed on this branch, each +with a test that fails without the fix where code changed: + +- Registry admission decoded the embedded tool pins into string maps, so + every registry image refused as `tool_unavailable`. +- A link chain could leave ProjectsPath and re-enter by name, resolving to a + sibling of data once copied. +- COPY/ADD `--from`, RUN `--mount from=` and remote ADD bypassed the + digest-pinned FROM gate. +- A failed renewal disconnected a host whose active certificate was still + valid; a failed reload after renewal crashed the agent. +- The harness guide and ADR 0007 stated T002-era test counts. +- No gate compiled the `docker_integration` acceptance files. + +Deferred for owner decision or a follow-up work item, not fixed here: + +- Docker CLI and Node binaries are trusted on their version string. The Node + pins hash release archives, not the binary, and the Docker CLI has no pin; + binary pins need owner-verified values. +- Renewal paths in `connection.Run` have no Go unit test, and + `TestExpiryWatchdogCancelsBlockedIO` exercises only a stub callback. +- An expired active credential without a pending one retries forever instead + of reporting owner newcode recovery. +- `identity.Prepare` does not fsync the parent after `Mkdir`. The server CA + file has no owner/mode check. The exclusive-link temp name removal is not synced. +- The JSONC companion check does not refuse unlisted files or writable roots. +- `read_configuration` reads config and CLI output without size or deadline + bounds. The driver accepts `exec -u root` in every phase. Gateway + state-changing calls are not serialized. seccomp `unconfined` passes the + engine check. +- Control plane: PubSub subscriptions are never removed; an old connection + closes before the replacement commits; heartbeats have no rate floor; + in-transaction denial audits roll back; a new enrollment code leaves earlier + codes valid; one test name overstates its coverage. +- Fix-up commits remain separate review history rather than squashed, and some + evidence prose lost spaces between numbers and words. From 0b15e782256af89eac5b5ac0fe3afbc1af6c49b5 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:45:53 +0200 Subject: [PATCH 47/51] fix(hosts): retry failed renewal while connected After a failed renewal Run opened a session on the old credential whose deadline was the real expiry once under 6h remained, so renewal was not retried until the certificate expired and the host was lost. Bound the session to the next retry time (renewRetry) when renewal is due and failed. Co-Authored-By: Claude Sonnet 5.5 --- apps/host_agent/internal/connection/client.go | 23 +++++++-- .../internal/connection/client_test.go | 48 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/host_agent/internal/connection/client.go b/apps/host_agent/internal/connection/client.go index 35cd5e61..a22c82b4 100644 --- a/apps/host_agent/internal/connection/client.go +++ b/apps/host_agent/internal/connection/client.go @@ -256,7 +256,16 @@ func ack(ctx context.Context, in <-chan incomingMessage, kind string) error { return nil } } -func session(ctx context.Context, state *identity.State, onEvent func(Event)) error { + +// Seams for tests; production values are Renew and session. +var ( + renewFn = Renew + sessionFn = session + // renewRetry bounds a session on a credential whose renewal is due but failed. + renewRetry = 30 * time.Second +) + +func session(ctx context.Context, state *identity.State, limit time.Time, onEvent func(Event)) error { expires, e := identity.Expires(state) if e != nil { return ErrTransport @@ -266,6 +275,9 @@ func session(ctx context.Context, state *identity.State, onEvent func(Event)) er if !deadline.After(time.Now()) { deadline = expires } + if !limit.IsZero() && limit.Before(deadline) { + deadline = limit + } ctx, cancel := context.WithDeadline(ctx, deadline) defer cancel() c, e := client(state, false) @@ -344,19 +356,24 @@ func Run(ctx context.Context, dir string, onEvent func(Event)) error { if e != nil || state.Active == nil { return ErrTransport } + var limit time.Time expires, e := identity.Expires(state) if (state.Pending != nil && state.Pending.Certificate != "") || (e == nil && time.Until(expires) <= 6*time.Hour) { // A failed renewal keeps the active credential in use while session // still accepts it; the next attempt retries renewal. - if Renew(ctx, dir) == nil { + if renewFn(ctx, dir) == nil { if state, e = identity.Load(dir); e != nil || state.Active == nil { return ErrTransport } + } else { + // Stay connected on the still-valid credential, but end the + // session in time to retry renewal long before expiry. + limit = time.Now().Add(renewRetry) } e = nil } if e == nil { - e = session(ctx, state, func(event Event) { attempt = 0; emit(onEvent, event.Kind, event.HostID) }) + e = sessionFn(ctx, state, limit, func(event Event) { attempt = 0; emit(onEvent, event.Kind, event.HostID) }) } if ctx.Err() != nil { return nil diff --git a/apps/host_agent/internal/connection/client_test.go b/apps/host_agent/internal/connection/client_test.go index afe6cebe..9041af3f 100644 --- a/apps/host_agent/internal/connection/client_test.go +++ b/apps/host_agent/internal/connection/client_test.go @@ -88,6 +88,12 @@ func TestActivationRejectsIncompleteOrMismatchedIssuedIdentity(t *testing.T) { } func savedPending(t *testing.T) (*identity.State, identity.Response) { + t.Helper() + _, state, response := savedPendingDir(t) + return state, response +} + +func savedPendingDir(t *testing.T) (string, *identity.State, identity.Response) { t.Helper() temp, e := filepath.EvalSymlinks(t.TempDir()) if e != nil { @@ -142,7 +148,7 @@ func savedPending(t *testing.T) (*identity.State, identity.Response) { if e != nil { t.Fatal(e) } - return state, response + return dir, state, response } func TestExpiryWatchdogCancelsBlockedIO(t *testing.T) { @@ -189,3 +195,43 @@ func TestAcknowledgementSequenceFailsClosed(t *testing.T) { }) } } + +func TestRunRetriesFailedRenewalWhileConnected(t *testing.T) { + dir, state, _ := savedPendingDir(t) + expires, e := identity.Expires(state) + if e != nil { + t.Fatal(e) + } + oldRenew, oldSession := renewFn, sessionFn + defer func() { renewFn, sessionFn = oldRenew, oldSession }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var renews atomic.Int32 + renewFn = func(context.Context, string) error { + if renews.Add(1) == 1 { + return ErrTransport + } + cancel() + return nil + } + var limit time.Time + sessionFn = func(_ context.Context, _ *identity.State, l time.Time, _ func(Event)) error { + if limit.IsZero() { + limit = l + } + return ErrTransport + } + done := make(chan error, 1) + go func() { done <- Run(ctx, dir, nil) }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("renewal was not retried") + } + if renews.Load() != 2 { + t.Fatalf("renewals = %d, want retry after failure", renews.Load()) + } + if limit.IsZero() || !limit.Before(expires) || time.Until(limit) > renewRetry { + t.Fatalf("session after failed renewal not bounded to the retry time: %v", limit) + } +} From ef2d881fcd1097bb9ccca7d89ac06b66ffac6e30 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:46:44 +0200 Subject: [PATCH 48/51] fix(config): parse Dockerfile sources as BuildKit does Mount options are CSV in BuildKit, so quoted from= fields bypassed the comma split; any user@host:path is git, not only git@. Reuse csvvalue and gitutil.IsGitTransport from the pinned module and trim/lowercase keys. Apply the "$" refusal only to --from and mount from=, so --chown=$U:$U and cache targets using ARG are accepted again. Co-Authored-By: Claude Sonnet 5.5 --- apps/host_agent/go.mod | 2 +- apps/host_agent/internal/config/trusted.go | 22 +++++++++++-------- .../internal/config/trusted_test.go | 7 ++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/host_agent/go.mod b/apps/host_agent/go.mod index 66ccf56d..2cb4c75b 100644 --- a/apps/host_agent/go.mod +++ b/apps/host_agent/go.mod @@ -17,6 +17,6 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect + github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/apps/host_agent/internal/config/trusted.go b/apps/host_agent/internal/config/trusted.go index d7e21d7c..3c7fbe30 100644 --- a/apps/host_agent/internal/config/trusted.go +++ b/apps/host_agent/internal/config/trusted.go @@ -3,6 +3,8 @@ package config import ( "bytes" + "github.com/moby/buildkit/util/gitutil" + "github.com/tonistiigi/go-csvvalue" "net" "reflect" "regexp" @@ -357,19 +359,21 @@ func priorStageSources(node *parser.Node, aliases map[string]bool, stages int) b return aliases[strings.ToLower(ref)] } for _, flag := range node.Flags { - if strings.Contains(flag, "$") { - return false - } name, value, _ := strings.Cut(strings.TrimPrefix(flag, "--"), "=") - switch strings.ToLower(name) { + switch strings.ToLower(strings.TrimSpace(name)) { case "from": - if !prior(value) { + if strings.Contains(value, "$") || !prior(value) { return false } case "mount": - for _, field := range strings.Split(value, ",") { + // BuildKit parses mount options as CSV, so quoted fields count. + fields, e := csvvalue.Fields(value, nil) + if e != nil { + return false + } + for _, field := range fields { key, ref, _ := strings.Cut(field, "=") - if strings.EqualFold(strings.TrimSpace(key), "from") && !prior(ref) { + if strings.EqualFold(strings.TrimSpace(key), "from") && (strings.Contains(ref, "$") || !prior(ref)) { return false } } @@ -377,8 +381,8 @@ func priorStageSources(node *parser.Node, aliases map[string]bool, stages int) b } if strings.EqualFold(node.Value, "add") { for argument := node.Next; argument != nil && argument.Next != nil; argument = argument.Next { - source := strings.ToLower(argument.Value) - if strings.Contains(source, "://") || strings.HasPrefix(source, "git@") { + source := argument.Value + if strings.Contains(source, "://") || gitutil.IsGitTransport(source) { return false } } diff --git a/apps/host_agent/internal/config/trusted_test.go b/apps/host_agent/internal/config/trusted_test.go index 8d4c369e..c07fbb57 100644 --- a/apps/host_agent/internal/config/trusted_test.go +++ b/apps/host_agent/internal/config/trusted_test.go @@ -121,6 +121,8 @@ func TestDockerfileExternalStageSourcesRefuseBeforeBuild(t *testing.T) { head + "RUN --mount=type=bind,from=tool,target=/t true\n", head + "RUN --mount=type=cache,target=/c true\n", head + "ADD local.tar /dst\n", + head + "ARG U=vscode\nCOPY --chown=$U:$U a /a\n", + head + "ARG X=y\nRUN --mount=type=cache,target=/root/${X} true\n", } { if _, err := ValidateDockerfile([]byte(data)); err != nil { t.Fatalf("prior-stage source refused: %q %v", data, err) @@ -136,6 +138,11 @@ func TestDockerfileExternalStageSourcesRefuseBeforeBuild(t *testing.T) { head + "RUN --mount=from=evil/tool:latest,target=/t true\n", head + "ADD https://example.test/x.tar /dst\n", head + "ADD git@example.test:repo.git /dst\n", + head + "ADD user@example.test:repo.git /dst\n", + head + "ADD ssh://example.test/repo.git /dst\n", + head + "RUN --mount='type=bind,\"from=evil/tool:latest\",target=/t' true\n", + head + "RUN --mount=type=bind,\" From =evil/tool:latest\",target=/t true\n", + head + "RUN --mount=type=bind,from=$TOOL,target=/t true\n", "FROM " + base + "\nCOPY --from=later /x /x\nFROM " + base + " AS later\n", } { _, err := ValidateDockerfile([]byte(data)) From bb3841f0219079df5fb730cdda9457568f5b478f Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 11:47:02 +0200 Subject: [PATCH 49/51] docs(pilot): correct review-fix record and harness vet step State which review fixes have failing-without-fix tests, record the renewal-retry defect and the second review round, and document the docker_integration go vet step. Co-Authored-By: Claude Sonnet 5.5 --- docs/development/harness.md | 3 ++- docs/exec-plans/active/installable-pilot.md | 22 ++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/development/harness.md b/docs/development/harness.md index 6192d99a..3f16ab4a 100644 --- a/docs/development/harness.md +++ b/docs/development/harness.md @@ -63,7 +63,8 @@ budgets remain 600 and 1200 seconds, respectively; utility commands retain 300 s Schema 2 adds an explicit `application_tests` inventory. Fixed repository code runs `gofmt -l`, `go mod download`, `go vet`, `go build` and uncached `go test -json` for -all three Go modules. The `application_tests` inventory in the manifest is the +all three Go modules, plus `go vet -tags docker_integration ./internal/providers/docker/` +in the host agent so the build-tagged acceptance files compile. The `application_tests` inventory in the manifest is the authoritative list of required identities per module; any other test outcome must still pass. Package skips qualify only for an explicit `[no test files]` event, never for a skipped test. Phoenix runs locked dependency acquisition, format, diff --git a/docs/exec-plans/active/installable-pilot.md b/docs/exec-plans/active/installable-pilot.md index 62470246..64fad8d4 100644 --- a/docs/exec-plans/active/installable-pilot.md +++ b/docs/exec-plans/active/installable-pilot.md @@ -1860,8 +1860,9 @@ An owner rebase merge rewrites the IDs again, so match commits by subject. | `20102b7` | `340d043` | docs(pilot): correct T009 entry-policy provenance | Independent static review of the rebased pilot (five reviewers, one per area) -found one high and six medium defects. These are fixed on this branch, each -with a test that fails without the fix where code changed: +found one high and six medium defects. These were fixed on this branch. +Tests were added where code changed. Only some were shown to fail without the +fix, and the renewal fix lacked a test until the second review round: - Registry admission decoded the embedded tool pins into string maps, so every registry image refused as `tool_unavailable`. @@ -1870,17 +1871,28 @@ with a test that fails without the fix where code changed: - COPY/ADD `--from`, RUN `--mount from=` and remote ADD bypassed the digest-pinned FROM gate. - A failed renewal disconnected a host whose active certificate was still - valid; a failed reload after renewal crashed the agent. + valid; a failed reload after renewal crashed the agent. The first fix kept + the session open until actual expiry, so renewal was never retried in time; + the second round bounds that session to the next retry (see below). - The harness guide and ADR 0007 stated T002-era test counts. - No gate compiled the `docker_integration` acceptance files. +A second independent review of `340d043..02683fe` was NOT CLEAR. Follow-up +commits on `pilot/review-fixes` fix: failed renewal retried while connected +(`TestRunRetriesFailedRenewalWhileConnected`); Dockerfile mount options parsed +as CSV and git sources detected as BuildKit does; the `$` refusal limited to +`--from` and mount `from=` so `--chown=$U:$U` and ARG cache targets work again +(extended `TestDockerfileExternalStageSourcesRefuseBeforeBuild`); and this +record. These need a fresh independent review of their exact head. + Deferred for owner decision or a follow-up work item, not fixed here: - Docker CLI and Node binaries are trusted on their version string. The Node pins hash release archives, not the binary, and the Docker CLI has no pin; binary pins need owner-verified values. -- Renewal paths in `connection.Run` have no Go unit test, and - `TestExpiryWatchdogCancelsBlockedIO` exercises only a stub callback. +- `TestExpiryWatchdogCancelsBlockedIO` exercises only a stub callback. + `connection.Run` renewal retry is covered through test seams, not a loopback + renewal endpoint. - An expired active credential without a pending one retries forever instead of reporting owner newcode recovery. - `identity.Prepare` does not fsync the parent after `Mkdir`. The server CA From 27386d654677b34d05de24a8f5397a4d2448ecb8 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 12:25:52 +0200 Subject: [PATCH 50/51] fix(config): detect git sources without a moby util import check_repo.py admits only the BuildKit parser, instructions and linter imports in trusted.go, so importing util/gitutil failed the boundary check. Mirror BuildKit v0.34.0 gitutil.IsGitTransport and sshutil IsImplicitSSHTransport locally and extend the refusal test with each accepted transport form. Co-Authored-By: Claude Sonnet 5.5 --- apps/host_agent/internal/config/trusted.go | 19 +++++++++++++++++-- .../internal/config/trusted_test.go | 5 +++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/host_agent/internal/config/trusted.go b/apps/host_agent/internal/config/trusted.go index 3c7fbe30..d4cc8a1e 100644 --- a/apps/host_agent/internal/config/trusted.go +++ b/apps/host_agent/internal/config/trusted.go @@ -3,7 +3,6 @@ package config import ( "bytes" - "github.com/moby/buildkit/util/gitutil" "github.com/tonistiigi/go-csvvalue" "net" "reflect" @@ -347,6 +346,22 @@ func ValidateSource(m map[string]any) error { return nil } +var ( + gitProtoRegexp = regexp.MustCompile(`^[a-zA-Z0-9]+://`) + gitSSHRegexp = regexp.MustCompile(`^([a-zA-Z0-9-_]+)@([a-zA-Z0-9-.]+):(.*?)(?:\?(.*?))?(?:#(.*))?$`) + gitProtocolsSet = map[string]bool{"http": true, "https": true, "ssh": true, "git": true} +) + +// isGitTransport mirrors github.com/moby/buildkit v0.34.0 util/gitutil +// IsGitTransport and util/sshutil IsImplicitSSHTransport (copied, not imported, +// to keep moby imports at the parser boundary). +func isGitTransport(remote string) bool { + if proto := gitProtoRegexp.FindString(remote); proto != "" { + return gitProtocolsSet[strings.ToLower(strings.TrimSuffix(proto, "://"))] + } + return gitSSHRegexp.MatchString(remote) +} + // ValidateDockerfile uses the upstream AST only to reject unsupported capabilities. // priorStageSources admits COPY/ADD --from and RUN --mount from= only for an // earlier stage, so every external image enters through a digest-pinned FROM @@ -382,7 +397,7 @@ func priorStageSources(node *parser.Node, aliases map[string]bool, stages int) b if strings.EqualFold(node.Value, "add") { for argument := node.Next; argument != nil && argument.Next != nil; argument = argument.Next { source := argument.Value - if strings.Contains(source, "://") || gitutil.IsGitTransport(source) { + if strings.Contains(source, "://") || isGitTransport(source) { return false } } diff --git a/apps/host_agent/internal/config/trusted_test.go b/apps/host_agent/internal/config/trusted_test.go index c07fbb57..33d979e7 100644 --- a/apps/host_agent/internal/config/trusted_test.go +++ b/apps/host_agent/internal/config/trusted_test.go @@ -140,6 +140,11 @@ func TestDockerfileExternalStageSourcesRefuseBeforeBuild(t *testing.T) { head + "ADD git@example.test:repo.git /dst\n", head + "ADD user@example.test:repo.git /dst\n", head + "ADD ssh://example.test/repo.git /dst\n", + head + "ADD git://example.test/repo.git /dst\n", + head + "ADD HTTP://example.test/repo.git /dst\n", + head + "ADD http://example.test/repo.git /dst\n", + head + "ADD https://example.test/repo.git#main /dst\n", + head + "ADD a-b_c@host-1.example:path/x.git?ref=1#frag /dst\n", head + "RUN --mount='type=bind,\"from=evil/tool:latest\",target=/t' true\n", head + "RUN --mount=type=bind,\" From =evil/tool:latest\",target=/t true\n", head + "RUN --mount=type=bind,from=$TOOL,target=/t true\n", From 2235c207fd8a04f5a64e2aff2e05ae5d38368025 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 16 Oct 2026 13:09:44 +0200 Subject: [PATCH 51/51] fix(config): make csvvalue direct and test isGitTransport go-csvvalue is imported directly by trusted.go, so list it in the direct require block. Add a unit test of isGitTransport covering every upstream transport form and register it in the CI acceptance manifest. Co-Authored-By: Claude Sonnet 5.5 --- apps/host_agent/go.mod | 2 +- .../internal/config/trusted_test.go | 19 +++++++++++++++++++ docs/development/ci-acceptance.json | 1 + 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/host_agent/go.mod b/apps/host_agent/go.mod index 2cb4c75b..3c651144 100644 --- a/apps/host_agent/go.mod +++ b/apps/host_agent/go.mod @@ -6,6 +6,7 @@ require ( github.com/coder/websocket v1.8.15 github.com/moby/buildkit v0.34.0 github.com/moby/docker-image-spec v1.3.1 + github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 golang.org/x/sys v0.48.0 ) @@ -17,6 +18,5 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/apps/host_agent/internal/config/trusted_test.go b/apps/host_agent/internal/config/trusted_test.go index 33d979e7..7c7972ae 100644 --- a/apps/host_agent/internal/config/trusted_test.go +++ b/apps/host_agent/internal/config/trusted_test.go @@ -195,3 +195,22 @@ func TestDockerfileEveryReservedARGDeclarationRefuses(t *testing.T) { } } } + +func TestIsGitTransportMirrorsBuildKit(t *testing.T) { + for source, want := range map[string]bool{ + "foo://x": false, + "HTTPS://x": true, + "http://x": true, + "ssh://x": true, + "git://x": true, + "user@h:p": true, + "a-b_c@host-1.example:p?q=1#f": true, + "user@h": false, + "local.tar": false, + "dir/user@h:p": false, + } { + if got := isGitTransport(source); got != want { + t.Errorf("isGitTransport(%q) = %v, want %v", source, got, want) + } + } +} diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index bd3ce052..7ea0d607 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -590,6 +590,7 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestDescriptorOriginAliasesNeverNormalize", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestApprovalRequiresExactOwnerReferenceAndCompleteSet", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestSourceImageAndDockerfileClosedMatrix", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestIsGitTransportMirrorsBuildKit", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/initializeCommand", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/postAttachCommand", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestUnsupportedSourceRefusesEveryEffectClass/features",