diff --git a/.well-known/agents-shipgate.json b/.well-known/agents-shipgate.json index 417c9047..84053ce2 100644 --- a/.well-known/agents-shipgate.json +++ b/.well-known/agents-shipgate.json @@ -106,6 +106,8 @@ "verification_worker": "agents-shipgate verification worker --plan agents-shipgate-reports/verification-plan.json --workspace . --out agents-shipgate-reports/verification-unit-result.json", "verification_assemble": "agents-shipgate verification assemble --plan agents-shipgate-reports/verification-plan.json --unit-result agents-shipgate-reports/verification-unit-result.json --verifier agents-shipgate-reports/verifier.json --artifacts-root agents-shipgate-reports --out agents-shipgate-reports/verification-receipt.json", "verification_reproduce": "agents-shipgate verification reproduce --receipt agents-shipgate-reports/verification-receipt.json --artifacts-root agents-shipgate-reports", + "authorization_request": "agents-shipgate authorization request --receipt agents-shipgate-reports/verification-receipt.json --artifacts-root agents-shipgate-reports --remote origin --destination-ref refs/heads/ --expected-lease-oid --out agents-shipgate-reports/human-authorization-request.json", + "authorization_execute": "agents-shipgate authorization execute --workspace . --receipt agents-shipgate-reports/verification-receipt.json --artifacts-root agents-shipgate-reports", "agent_handoff": "agents-shipgate agent handoff --from agents-shipgate-reports/verifier.json --json", "trigger": "agents-shipgate trigger --base origin/main --head HEAD --json", "capability_export": "agents-shipgate capability export -c shipgate.yaml", @@ -178,18 +180,24 @@ "agent_boundary_result_schema_path": "docs/agent-boundary-result-schema.v1.json", "report_schema_version": "0.34", "packet_schema_version": "0.12", - "verifier_schema_version": "0.5", + "verifier_schema_version": "0.6", "verify_run_schema_version": "shipgate.verify_run/v3", "verification_plan_schema_version": "shipgate.verification_plan/v1", "verification_unit_result_schema_version": "shipgate.verification_unit_result/v1", "verification_artifact_manifest_schema_version": "shipgate.verification_artifact_manifest/v1", "verification_receipt_schema_version": "shipgate.verification_receipt/v1", - "agent_handoff_schema_version": "shipgate.agent_handoff/v5", - "agent_handoff_schema_path": "docs/agent-handoff-schema.v5.json", + "human_authorization_request_schema_version": "shipgate.human_authorization_request/v1", + "human_authorization_schema_version": "shipgate.human_authorization/v1", + "human_authorization_evaluation_schema_version": "shipgate.human_authorization_evaluation/v1", + "human_authorization_trust_policy_schema_version": "shipgate.human_authorization_trust_policy/v1", + "human_authorization_trust_policy_default_path": "~/.config/agents-shipgate/human-authorization-trust-policy.json", + "human_authorization_schema_path": "docs/human-authorization-schema.v1.json", + "agent_handoff_schema_version": "shipgate.agent_handoff/v6", + "agent_handoff_schema_path": "docs/agent-handoff-schema.v6.json", "agent_handoff_artifact": "agents-shipgate-reports/agent-handoff.json", - "contract_version": "17", + "contract_version": "18", "minimum_control_contract_version": "14", - "local_agent_contract_schema_version": "6", + "local_agent_contract_schema_version": "7", "inputs": [ "mcp", "openapi", @@ -218,6 +226,8 @@ "verification_unit_result_json", "verification_artifact_manifest_json", "verification_receipt_json", + "human_authorization_request_json", + "human_authorization_json", "pr_comment_md", "check_annotations_json", "capability_lock_json", @@ -240,6 +250,8 @@ "verification_unit_result": "agents-shipgate-reports/verification-unit-result.json", "verification_artifact_manifest": "agents-shipgate-reports/verification-artifacts.json", "verification_receipt": "agents-shipgate-reports/verification-receipt.json", + "human_authorization_request": "agents-shipgate-reports/human-authorization-request.json", + "human_authorization": "agents-shipgate-reports/human-authorization.json", "report": "agents-shipgate-reports/report.json", "pr_comment": "agents-shipgate-reports/pr-comment.md", "check_annotations": "agents-shipgate-reports/check-annotations.json", @@ -296,6 +308,7 @@ "verification_unit_result", "verification_artifact_manifest", "verification_receipt", + "human_authorization", "host_grants_inventory", "host_grants_baseline", "host_grants_drift", @@ -378,6 +391,7 @@ }, "verifier_read_order": [ "control.state", + "authorization", "execution", "merge_verdict", "applicability", @@ -395,6 +409,7 @@ "verification-receipt.json.receipt_id", "agent-handoff.json", "agent-handoff.json.control.state", + "agent-handoff.json.authorization", "verifier.json.control.state", "verify-run.json", "report.json.release_decision.decision" @@ -410,6 +425,7 @@ "prohibited-action", "runtime-trace", "human-ack", + "human-authorization", "suppression", "waiver", "baseline", @@ -425,13 +441,14 @@ "agent_result": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-result-schema.v2.json", "agent_boundary_result": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-boundary-result-schema.v1.json", "codex_boundary_result": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/codex-boundary-result-schema.v2.json", - "verifier": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verifier-schema.v0.5.json", + "verifier": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verifier-schema.v0.6.json", "verify_run": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verify-run-schema.v3.json", "verification_plan": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verification-plan-schema.v1.json", "verification_unit_result": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verification-unit-result-schema.v1.json", "verification_artifact_manifest": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verification-artifact-manifest-schema.v1.json", "verification_receipt": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verification-receipt-schema.v1.json", - "agent_handoff": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-handoff-schema.v5.json", + "human_authorization": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/human-authorization-schema.v1.json", + "agent_handoff": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-handoff-schema.v6.json", "packet": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/packet-schema.v0.12.json", "preflight": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/preflight-schema.v0.3.json", "capability_lock": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/capability-lock-schema.v0.6.json", diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f22d5c..445ae732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,16 +18,37 @@ prepare|worker|assemble|reproduce` exposes the portable v1 protocol without claiming distributed policy evaluation, arbitrary sharding, or parallel speedup. -- **Identity contract versions.** Runtime contract advances to v17; report to - v0.34; packet to v0.12; verifier to v0.5; verify-run to v3; handoff to v5; - attestation to v0.5; registry to v0.4; organization evidence bundle to v2; - downstream local contract to v6; and safety qualification formats to v4. +- **Externally rooted exact-operation authorization (contract v18).** A trusted + host can derive an unsigned authorization request from a host-attested + `review_required` receipt, authenticate a human, and return a short-lived + Ed25519 grant for one exact force-with-lease Git push. Agents Shipgate ships + no signing or approval command. A second verification recomputes the complete + request, decision, review set, repository, and tree identities before an + accepted grant exposes only the guarded `authorization execute` consumer; + the release decision, merge verdict, and completion authority remain + unchanged. The executor revalidates current evidence and expiry immediately + before using an isolated Git object store. Authorization requires an exact + plugins-disabled engine, rejects third-party plugin loading in the broker, + and parent-streams the Git pack with bounded stdout, stderr, and time. + Authorization remains disabled + without a host-protected trust policy, launcher, interpreter, entire virtual + environment and `site-packages` tree, dependencies, credentials, and + separately installed distribution; same-UID modes and editable workspace + installs are not a trust boundary. +- **Identity and authorization contract versions.** Runtime contract advances + to v18; report to v0.34; packet to v0.12; verifier to v0.6; verify-run to v3; + handoff to v6; attestation to v0.5; registry to v0.4; organization evidence + bundle to v2; + downstream local contract to v7; and safety qualification formats to v4. Verification plan, unit-result, artifact-manifest, and receipt schemas begin - at v1. Prior schemas remain frozen readers. + at v1. The authorization request, signed grant, verifier evaluation, and + external trust-policy schemas also begin at v1. Prior schemas remain frozen + readers. - **Immutable CI subject.** The GitHub Action evaluates `github.sha` by default - and records a pull request's source head separately. It exports receipt, - request, decision, and artifact-set identities only after validating every - terminal artifact hash. + and treats the default pull-request synthetic merge as authorization- + ineligible. Push authorization requires a separate verification of the exact + PR head commit. The Action exports receipt, request, decision, and + artifact-set identities only after validating every terminal artifact hash. - **Non-forgeable trust decay.** The content-bound commit evaluation date remains reproducibility provenance, but cannot extend reviewer-owned trust. Baseline, acknowledgement, and severity-override expiry use the later of that diff --git a/README.md b/README.md index 60249be0..cf596e80 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,109 @@ Common review signals include missing confirmation, missing idempotency evidence, broad-scope permissions, prohibited-action policy gaps, and trust-root changes such as weakened CI or manifest policy. +### Authorize one exact coding-agent action + +A `human_review_required` result normally stops the coding agent. Runtime +contract v18 adds one narrow continuation path for a trusted host: after a +person reviews a host-attested receipt and its complete review set, the host can +sign an exact authorization request with Ed25519. The private key, human +authentication, and signing operation stay outside Agents Shipgate and outside +the evaluated workspace; Agents Shipgate ships no signing or approval command. +On POSIX, the verifier reads the trust policy only from the OS account home's +fixed path +`~/.config/agents-shipgate/human-authorization-trust-policy.json`. Changing +`HOME` or `XDG_CONFIG_HOME` does not redirect that lookup. The fixed lookup +prevents environment-based path substitution; the coding host must still make +the file and its parent path unwritable by the agent. The guarded executor is +POSIX-only in v1; this authorization route remains disabled on Windows. +Authorization-eligible verification must also bind an effective +`plugins_enabled=false` engine. Use `--no-plugins` for both verification +passes when the environment enables third-party plugins; the protected broker +never imports third-party check or adapter code. + +First build the unsigned challenge from the validated receipt. It derives the +reviewed source commit and complete review set; you supply the exact destination +and the remote OID that the reviewer observed: + +```bash +agents-shipgate authorization request \ + --receipt agents-shipgate-reports/verification-receipt.json \ + --artifacts-root agents-shipgate-reports \ + --remote origin \ + --destination-ref refs/heads/ \ + --expected-lease-oid \ + --out agents-shipgate-reports/human-authorization-request.json +``` + +That command does not approve or sign anything. The external host must present +the complete request to the authenticated human and produce the signed grant. +The request binds the source `receipt_id`, `artifact_set_id`, engine, and +executor so the signer can require a trusted-CI attestation or rerun +verification itself. Content addressing detects changed bytes; it does **not** +authenticate an agent-supplied receipt. A signer must never treat a +self-consistent receipt from the coding agent as trusted provenance. +The request also exposes the evaluated base commit and merge base. Because a +Git commit transitively binds every parent and reachable object, the signer +must inspect the complete source ancestry—not only the final tree diff—before +authorizing its push. The guarded executor snapshots that full graph with a +512 MiB pack ceiling and a 120-second process timeout; the host broker should +apply tighter disk, memory, and CPU quotas for its deployment. The compressed +pack ceiling does not bound expanded-object indexing memory or CPU, so a +production broker should use a cgroup, container, or equivalent host quota. + +Rerun verification with the externally produced grant: + +```bash +agents-shipgate verify --workspace . --config shipgate.yaml \ + --base origin/main --head HEAD --ci-mode advisory --format json \ + --no-plugins \ + --authorization /path/outside/workspace/human-authorization.json +``` + +The verifier recomputes the request, subject, decision, trees, and full review +set, then checks the signature, trusted principal, repository scope, and TTL. +The v1 grant can authorize only one exact Git push. The source must be the +exact commit whose tree was verified; a synthetic PR merge receipt cannot +authorize pushing a different parent commit. The operation binds a canonical, +credential-free HTTPS destination, one full destination ref, and an explicit +expected remote OID in `--force-with-lease`. If accepted, `control.state` +becomes `agent_action_required` and exposes only a guarded +`agents-shipgate authorization execute` command. That consumer validates the +receipt from one immutable byte snapshot, grant, current trust policy, +wall-clock expiry, engine, repository, and commit again immediately before the +push. It copies the reachable Git graph into an isolated object store, checks +its object IDs with `git fsck`, disables hooks, configuration, replacement +objects, and HTTP redirects, and then pushes only that snapshot. The raw +`git push` is never an +allowed command. This does **not** turn +the PR into a pass: `release_decision.decision` stays `review_required`, +`merge_verdict` stays `human_review_required`, and both +`can_merge_without_human` and `completion_allowed` stay false. Invalid, +expired, stale, or mismatched grants fail closed with zero commands. If the remote ref +moves after approval, Git rejects the push because the signed command carries +the previously reviewed lease OID. + +GitHub's default `pull_request` checkout evaluates a synthetic merge commit; +request push authorization only after a separate committed verification whose +`--head` is the actual PR head SHA. This release defines the signed protocol, +verifier consumer, and guarded executor. A Codex, +Claude Code, or other coding-host signing adapter must still authenticate the +human, protect the key from the agent, attest or rerun the source verification, +and produce the grant; no current UI integration is implied. + +Authorization is enabled only when the coding host enforces a real write +boundary around the trust policy, signing key, clean launcher environment, +Python interpreter, entire virtual environment and `site-packages` tree +(including startup `.pth` files), dependencies, credentials, and separately +installed Agents Shipgate distribution. +Filesystem ownership/mode checks alone cannot distinguish an unrestricted +same-UID coding agent from its human user. Editable installs whose package code +resolves inside the authorized repository are therefore ineligible. If the +host cannot enforce these boundaries, it must leave the result at +`human_review_required` and expose no authorization command. Authorization v1 +is intentionally push-only; reviewed patch application is a later operation +type, not an implied capability of this grant. + ## Not sure if Shipgate applies? Run the zero-install detector from the repo you are reviewing. It is a @@ -311,8 +414,9 @@ make the base ref available first because `verify` never fetches. Validate `fix_task`), then supporting/provisional `capability_review.top_changes` and `agents-shipgate-reports/report.json` for `release_decision.decision`. Do not claim completion unless `control.state` is `complete`. Conversation-level -acknowledgement never clears a human-review route; only a new verifier artifact -can change control state. Do not +acknowledgement never clears a human-review route. A trusted host may provide a +signed external authorization for one exact command; only a new verifier +artifact that validates that grant can change control state. Do not auto-assert action effect, action authority, approval, confirmation, idempotency, broad-scope safety, prohibited-action enforcement, runtime-trace proof, suppressions, waivers, baselines, or policy weakening. Never remove @@ -534,8 +638,8 @@ When a PR changes what your agent can do, the verify loop writes these artifacts — in read order: - **`agents-shipgate-reports/verification-receipt.json`** — the **first artifact a coding agent validates**: a terminal content-addressed closure over the exact request (including `verification-input.diff`), worker result, decision, and artifact set. It is written last; use `agents-shipgate verification reproduce` to validate every referenced hash. -- **`agents-shipgate-reports/agent-handoff.json`** — the compact `shipgate.agent_handoff/v5` object. Lead with `control.state`, then `gate.merge_verdict`; it projects the same request and decision IDs and does not introduce a second verdict. -- **`agents-shipgate-reports/verifier.json`** — the **authoritative PR/control evidence substrate** (`verifier_schema_version: "0.5"`). A coding agent switches on `control.state`, then reads `merge_verdict` (`mergeable | human_review_required | insufficient_evidence | blocked | unknown`), `can_merge_without_human`, `control.next_action`, and `fix_task` when producing reviewer evidence for an agent-capability PR. Local control comes from `shipgate check --format agent-boundary-json` and `shipgate.agent_boundary_result/v1`. See [`docs/agent-contract-current.md`](docs/agent-contract-current.md) for the field contract. +- **`agents-shipgate-reports/agent-handoff.json`** — the compact `shipgate.agent_handoff/v6` object. Lead with `control.state`, then `gate.merge_verdict`; it projects the same request, decision, and authorization evaluation and does not introduce a second verdict. +- **`agents-shipgate-reports/verifier.json`** — the **authoritative PR/control evidence substrate** (`verifier_schema_version: "0.6"`). A coding agent switches on `control.state`, then reads `authorization`, `merge_verdict` (`mergeable | human_review_required | insufficient_evidence | blocked | unknown`), `can_merge_without_human`, `control.next_action`, and `fix_task` when producing reviewer evidence for an agent-capability PR. Only an accepted signed authorization evaluation may expose an exact reviewed command; the release verdict remains unchanged. Local control comes from `shipgate check --format agent-boundary-json` and `shipgate.agent_boundary_result/v1`. See [`docs/agent-contract-current.md`](docs/agent-contract-current.md) for the field contract. - **`agents-shipgate-reports/verify-run.json`** — the `shipgate.verify_run/v3` projection embedding the exact verification plan, executor, unit-result IDs, decision ID, outcome, and artifact paths. Its deprecated `run_id` is an exact alias of `request_id`. - **`agents-shipgate-reports/attestation.json`** + **`agents-shipgate-reports/org-evidence-bundle.json`** — optional organization-governance projections over the same verifier/report artifacts. They are ledger inputs for platform teams, not release gates; `report.json.release_decision.decision` remains the decision engine. - **`agents-shipgate-reports/host-grants.json`** + **`agents-shipgate-reports/org-status.json`** — optional fleet-governance artifacts from `audit --host --out` and `org status --json`, useful for host-grant drift, policy-pack pin state, and exception hygiene. @@ -584,7 +688,7 @@ Agents Shipgate is designed to be agent-friendly. If you're a coding agent (Clau - **`agents-shipgate install-hooks --target claude-code --write`** — deterministic Claude Code hooks: a PreToolUse trust-root guard, a cheap trigger check after `Edit|Write|MultiEdit`, and a full `verify` at `Stop`, so the gate runs even when instruction files lose attention on long sessions. See [`docs/agents/use-with-claude-code.md`](docs/agents/use-with-claude-code.md#hooks-the-deterministic-path-recommended). - **`agents-shipgate mcp-serve`** (`[mcp]` extra) — read-only stdio MCP server exposing `shipgate.check`, `shipgate.preflight`, `shipgate.explain`, `shipgate.capabilities`, and `shipgate.handoff` for agents without comfortable shell access. It is static-only and not a general MCP permission broker. See [`docs/mcp-server.md`](docs/mcp-server.md). - **[`docs/ai-search-summary.md`](docs/ai-search-summary.md)** — human-readable summary for AI search, answer engines, and coding agents -- **[`docs/manifest-v0.1.json`](docs/manifest-v0.1.json)** + **[`docs/report-schema.v0.34.json`](docs/report-schema.v0.34.json)** + **[`docs/agent-handoff-schema.v5.json`](docs/agent-handoff-schema.v5.json)** + **[`docs/verification-receipt-schema.v1.json`](docs/verification-receipt-schema.v1.json)** — JSON Schemas for live editor validation, agent routing, and reproducible verification identity. Reports carry `report_schema_version: "0.34"`; every policy finding records typed predicate support, while heuristic-only, mixed, unknown, or conflicting applicability becomes a non-waivable evidence gap. `passed` still requires a complete root-reachable binding graph and complete identity, effect, authority, and policy evidence for each reachable action. A successful verifier run writes a terminal content-addressed receipt last; validate it before trusting the handoff or report. Every release decision remains static-only. See [`docs/verification-reproducibility.md`](docs/verification-reproducibility.md) and [`docs/passed-verdict-contract.md`](docs/passed-verdict-contract.md). v0.33 is frozen. +- **[`docs/manifest-v0.1.json`](docs/manifest-v0.1.json)** + **[`docs/report-schema.v0.34.json`](docs/report-schema.v0.34.json)** + **[`docs/agent-handoff-schema.v6.json`](docs/agent-handoff-schema.v6.json)** + **[`docs/verification-receipt-schema.v1.json`](docs/verification-receipt-schema.v1.json)** + **[`docs/human-authorization-schema.v1.json`](docs/human-authorization-schema.v1.json)** — JSON Schemas for live editor validation, agent routing, reproducible verification identity, and the external signed-authorization family. Reports carry `report_schema_version: "0.34"`; every policy finding records typed predicate support, while heuristic-only, mixed, unknown, or conflicting applicability becomes a non-waivable evidence gap. `passed` still requires a complete root-reachable binding graph and complete identity, effect, authority, and policy evidence for each reachable action. A successful verifier run writes a terminal content-addressed receipt last; validate it before trusting the handoff or report. Every release decision remains static-only. See [`docs/verification-reproducibility.md`](docs/verification-reproducibility.md) and [`docs/passed-verdict-contract.md`](docs/passed-verdict-contract.md). v0.33 is frozen. - **[`docs/capability-lock-schema.v0.5.json`](docs/capability-lock-schema.v0.5.json)** + **[`docs/capability-lock-diff-schema.v0.6.json`](docs/capability-lock-diff-schema.v0.6.json)** — current capability standard v0.4 schemas, including binding hashes, for the static capability envelope and semantic diff; non-gating and separate from `report.json`. - **[`docs/attestation-schema.v0.5.json`](docs/attestation-schema.v0.5.json)** + **[`docs/org-governance-schema.v0.1.json`](docs/org-governance-schema.v0.1.json)** + **[`docs/org-evidence-bundle-schema.v2.json`](docs/org-evidence-bundle-schema.v2.json)** + **[`docs/registry-schema.v0.4.json`](docs/registry-schema.v0.4.json)** + **[`docs/host-grants-inventory-schema.v0.2.json`](docs/host-grants-inventory-schema.v0.2.json)** — deterministic local attestation, organization governance, org evidence bundle, append-only registry, and scope-aware host-grant inventory schemas for multi-repo governance. - **[`docs/governance-benchmark-catalog-schema.v0.2.json`](docs/governance-benchmark-catalog-schema.v0.2.json)** + **[`docs/governance-benchmark-result-schema.v0.2.json`](docs/governance-benchmark-result-schema.v0.2.json)** — stable schemas for the research benchmark catalog and deterministic result artifact. diff --git a/STABILITY.md b/STABILITY.md index f690707f..969643cf 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -17,8 +17,9 @@ for reproducible CI. ## Migration Note: 0.16.0b6 -Runtime contract `16 → 17` makes verification identity and artifact closure -content-addressed. A successful `verify` now emits a verification plan, +Runtime contract `16 → 18` lands in two additive steps. Contract v17 makes +verification identity and artifact closure content-addressed. A successful +`verify` now emits a verification plan, decision-free unit result, artifact manifest, and terminal receipt. The receipt is written last and binds the resolved Git subject, hashed inputs, engine requirement, executor, assembled decision, and every referenced @@ -26,19 +27,82 @@ artifact. Git snapshots use exact blobs rather than `git archive`; cache reuse cannot change public artifacts; and expiry-sensitive policy evaluation uses a declared evaluation date rather than a worker wall clock. -Report advances `0.33 → 0.34`, packet `0.11 → 0.12`, verifier `0.4 → 0.5`, -verify-run v2 → v3, handoff v4 → v5, attestation `0.4 → 0.5`, registry +Contract v18 adds a fail-closed human-authorization overlay for one exact +coding-agent operation. A trusted host derives an unsigned +`shipgate.human_authorization_request/v1` from a validated prior receipt, the +current request/subject/decision/tree identities, and the complete ordered +review set. The host or authenticator — not Agents Shipgate and not the coding +agent — signs the request with Ed25519. Agents Shipgate ships no private key and +no signing/approval CLI. The corresponding trust policy must live outside the +evaluated workspace and be protected from writes by the coding agent. On +POSIX, its only lookup location is the OS account home's fixed path +`~/.config/agents-shipgate/human-authorization-trust-policy.json`; `HOME` and +`XDG_CONFIG_HOME` are ignored for this lookup. The +request also binds the source receipt, artifact set, engine, and executor so a +host signer can require trusted-CI provenance or rerun verification. A +content-addressed closure detects mutation but is not an authenticity claim. +It exposes the evaluated base commit and merge base; the signer must review the +source commit's complete ancestry and reachable history, not only its final +tree. Guarded execution caps the serialized source graph at 512 MiB and 120 +seconds, while the mandatory host broker remains responsible for tighter +resource quotas. The serialized-pack limit does not bound expanded-object +indexing memory or CPU; production brokers need a cgroup, container, or +equivalent host quota. Authorization-eligible plans require an exact effective +`plugins_enabled=false` mode, and the protected executor rejects enabled +third-party plugins before engine validation so plugin entry points never enter +the broker TCB. + +A second `agents-shipgate verify --no-plugins --authorization ` recomputes +the verification identities and accepts the grant only when its signature, +trusted principal, repository scope, validity window, request, subject, trees, +decision, complete review set, and typed operation still match. The v1 +operation binds the exact evaluated commit, a canonical credential-free HTTPS +repository endpoint, one full destination ref, and explicit +`--force-with-lease=:`. Synthetic PR merge receipts are not +eligible to authorize pushing a different parent commit. Only a +successfully evaluated `review_required` result can then project +`control.state: "agent_action_required"` with that one exact command. The +release decision remains `review_required`; `merge_verdict` remains +`human_review_required`; `can_merge_without_human` and `completion_allowed` +remain false. The one published command is the guarded +`agents-shipgate authorization execute` consumer, which revalidates the +receipt from an immutable snapshot, trust policy, clock, engine, repository, +and commit immediately before copying and fsck-validating the reachable Git +graph in an isolated store. It disables replacement objects, hooks, +configuration, and HTTP redirects before the signed push. Invalid, expired, +stale, or mismatched grants fail closed and publish zero allowed commands. If +the remote ref moves after approval, Git rejects the push because the signed +operation carries the previously reviewed lease OID. + +This release defines and consumes the authorization protocol; it does not ship +a Codex, Claude Code, or other UI signing adapter. A host integration must +authenticate the human, keep the Ed25519 private key outside agent reach, +attest or rerun the source verification, present the complete request for +review, and return the signed grant. It must also isolate the trust policy, +launcher environment, interpreter, entire virtual environment and +`site-packages` tree (including startup `.pth` files), dependencies, +credentials, and installed distribution from coding-agent writes. Same-UID +file modes alone are insufficient; without a host-enforced +write boundary authorization stays disabled. Editable installs rooted in the +authorized repository are ineligible. V1 is push-only. + +Report advances `0.33 → 0.34`, packet `0.11 → 0.12`, verifier `0.4 → 0.6`, +verify-run v2 → v3, handoff v4 → v6, attestation `0.4 → 0.5`, registry `0.3 → 0.4`, organization evidence bundle v1 → v2, generated downstream -contract `5 → 6`, and safety qualification envelopes v3 → v4. New +contract `5 → 7`, and safety qualification envelopes v3 → v4. New verification-plan, unit-result, artifact-manifest, and receipt schemas begin -at v1. Previous schemas remain frozen readers; they are not emitted by -default. +at v1. The authorization request, signed grant, verifier evaluation, and trust +policy also begin at v1 and share +[`docs/human-authorization-schema.v1.json`](docs/human-authorization-schema.v1.json). +Previous schemas remain frozen readers; they are not emitted by default. `verify-run.run_id` remains for one compatibility cycle as an exact alias of `request_id`; it is no longer independently derived. GitHub Actions evaluate -the immutable `${{ github.sha }}` by default and separately record a PR's -source head. Current Action outputs include the validated receipt path and -request, receipt, decision, and artifact-set IDs. +the immutable `${{ github.sha }}` by default. A default `pull_request` +synthetic-merge receipt carries no executable source authority; authorization +requires a separate verification of the actual PR head commit. Current Action +outputs include the validated receipt path and request, receipt, decision, and +artifact-set IDs. The v1 portable protocol has one deterministic evaluate task. Workers validate their installed engine and immutable transported inputs and emit normalized IR, @@ -351,7 +415,9 @@ changes only by bumping `contract_version` and updating this file. | Command | Stable flags | |---|---| | `agents-shipgate scan` | `-c`, `--config`, `--out`, `--format`, `--ci-mode`, `--fail-on`, `--baseline`, `--diff-from`, `--changed-files`, `--no-plugins`, `--strict-plugins`, `--no-heuristics`, `--verbose`, `--workspace`, `--packet`/`--no-packet`, `--packet-format` | -| `agents-shipgate verify` | `--workspace`, `--config`, `--base`, `--no-base`, `--head`, `--ci-mode`, `--fail-on`, `--baseline`, `--baseline-mode`, `--diff-from`, `--out`, `--format` (`text`, `json`), `--policy-pack`, `--no-plugins`, `--strict-plugins`, `--no-heuristics`, `--suggest-patches`, `--verbose` | +| `agents-shipgate verify` | `--workspace`, `--config`, `--base`, `--no-base`, `--head`, `--ci-mode`, `--fail-on`, `--baseline`, `--baseline-mode`, `--diff-from`, `--authorization`, `--out`, `--format` (`text`, `json`), `--policy-pack`, `--no-plugins`, `--strict-plugins`, `--no-heuristics`, `--suggest-patches`, `--verbose` | +| `agents-shipgate authorization request` | `--receipt`, `--artifacts-root`, `--remote`, `--destination-ref`, `--expected-lease-oid`, `--out`, `--json` — builds an unsigned challenge only; never signs or approves it | +| `agents-shipgate authorization execute` | `--workspace`, `--receipt`, `--artifacts-root`, `--json` — guarded consumer; must be launched by a host-protected broker/runtime | | `agents-shipgate evidence-packet` | `--from`, `--out`, `--format`, `--json` | | `agents-shipgate scenario suggest` | `--from`, `--out` | | `shipgate check` | `--agent`, `--workspace`, `--format` (`codex-boundary-json`), `--diff`, `--base`, `--head`, `--config`, `--policy` | @@ -480,6 +546,17 @@ Stable JSON fields: `agents-shipgate-reports/verifier.json`. - `verify_run_schema_version` — schema version for `agents-shipgate-reports/verify-run.json`. +- `human_authorization_request_schema_version`, + `human_authorization_schema_version`, + `human_authorization_evaluation_schema_version`, and + `human_authorization_trust_policy_schema_version` — v1 versions for the + unsigned challenge, externally signed grant, fail-closed verifier result, + and external trust policy. +- `human_authorization_trust_policy_default_path` — the fixed POSIX + OS-account-home trust-policy path; `HOME` and `XDG_CONFIG_HOME` do not + redirect it. +- `human_authorization_schema_path` — checked-in schema family path for those + four authorization objects. - `agent_handoff_schema_version` — schema version for `agents-shipgate-reports/agent-handoff.json`. - `agent_handoff_schema_path` — checked-in JSON Schema path for the handoff @@ -491,8 +568,9 @@ Stable JSON fields: `agent-handoff.json.control.state`, then `verifier.json.control.state`, `verify-run.json`, then `report.json.release_decision.decision`. -- `agent_interface_operations[]` — stable operation vocabulary for the - handoff artifact. +- `agent_interface_operations[]` — stable verification-operation vocabulary + for the handoff artifact only. Authorization `request` and `execute` are + entries in `commands{}`; they are not handoff operation enum values. - `exit_code_policy{}` — stable machine-readable exit-code meanings for agent-facing commands. - `mcp_tools[]` — read-only MCP tool names exposed by `agents-shipgate @@ -913,11 +991,18 @@ tests on every CI run, not by convention: --exclude-standard`) for trigger evaluation. Reads diff content only. **Plus** one `importlib.resources.files('agents_shipgate')` call to resolve the bundled trigger catalog. - - **`cli/verify/git.py`** — one shared `subprocess.run` helper invokes - local `git rev-parse`, `git diff`, `git ls-files`, `git ls-tree`, and - `git cat-file` for exact base/head and working-tree orchestration. It - never fetches, uses fixed argv, captures output, and never executes user - code. + - **`cli/verify/git.py`** — one shared `subprocess.run` boundary invokes + local Git plumbing for exact base/head and working-tree orchestration, + plus `pack-objects`, `index-pack`, and `fsck` to materialize an isolated, + object-ID-validated snapshot. It never fetches, uses list argv without a + shell, and never executes user code. + - **`core/authorization_execution.py`** — one shared `subprocess.run` + boundary consumes the fixed protected Git argv, and one audited + `subprocess.Popen` boundary parent-streams `pack-objects` with hard stdout, + stderr, and wall-clock limits. Both use a host-protected `/usr/bin/git`, + sanitized environment, isolated/fsck-validated object graph, fixed list + argv, exact force-with-lease, and no shell. They are explicit operational + executor surfaces, not part of static tool extraction. - **`cli/fixture.py`** — one `subprocess.run` helper invokes local `git init`, `git config`, `git add`, `git commit`, and `git update-ref` against a temporary bundled fixture copy so @@ -1146,11 +1231,12 @@ release decision. That action may be `detect`/`initialize` for relevant unconfigured repos, or `verify` for configured repos. Use it as the first touch on a repo or PR before committing to a full scan. -`verifier.json` is governed by [`docs/verifier-schema.v0.5.json`](docs/verifier-schema.v0.5.json). -Verifier v0.1 through v0.4 remain frozen references. It remains an orchestration artifact: `release_decision.decision` in -`report.json` is still the only release gate, and every verifier field is either -a mirror or a deterministic projection of report data. Stable additive fields a -consumer may read: +`verifier.json` is governed by [`docs/verifier-schema.v0.6.json`](docs/verifier-schema.v0.6.json). +Verifier v0.1 through v0.5 remain frozen references. It remains an orchestration artifact: `release_decision.decision` in +`report.json` is still the only release gate. Release and merge fields remain +mirrors or deterministic projections of report data; the v0.6 authorization +evaluation is an operational overlay that cannot change them. Stable additive +fields a consumer may read: - `control` — the schema-enforced `complete | agent_action_required | human_review_required` operational projection. The same serialized object is @@ -1177,6 +1263,13 @@ consumer may read: - `decision` — mirror of `release_decision.decision` (or `null` when no scan ran). - `headline` — single-sentence, PR-comment-friendly summary (or `null`). +- `authorization` — the + `shipgate.human_authorization_evaluation/v1` result. `accepted` is possible + only for a successful `review_required` evaluation and must carry the same + one exact command as `control.next_action` and + `control.allowed_next_commands`. `not_requested`, `not_applicable`, and + `rejected` carry no command authority; rejection reason codes are evidence, + never instructions. - `human_review` and `first_next_action` — compatibility mirrors of `control.human_review` and `control.next_action` for one cycle. - `trust_root_touched` — `bool`; `true` when the PR changed a release-gate trust @@ -1303,14 +1396,15 @@ refund-capability PR. `agents-shipgate-reports/agent-handoff.json` is the preferred compact machine-readable handoff object for coding agents and CI agents. The current schema is -[`docs/agent-handoff-schema.v5.json`](docs/agent-handoff-schema.v5.json) with -`schema_version: "shipgate.agent_handoff/v5"`. v1 through v4 remain frozen +[`docs/agent-handoff-schema.v6.json`](docs/agent-handoff-schema.v6.json) with +`schema_version: "shipgate.agent_handoff/v6"`. v1 through v5 remain frozen references. The handoff artifact is derived only from `verifier.json`, `verify-run.json`, and `report.json`. It mirrors `release_decision.decision`, `verifier.json.merge_verdict`, and -the byte-identical `verifier.json.control` object. Its +the byte-identical `verifier.json.control` object. Handoff v6 also mirrors the +verifier's `authorization` evaluation; it cannot upgrade or reinterpret it. Its `gate.{static_analysis_only,runtime_behavior_verified,static_verdict_disclaimer}` also mirrors the verifier/report boundary; construction fails if any mirror disagrees. It never computes a separate release verdict, does not contain diff --git a/docs/INDEX.md b/docs/INDEX.md index 9f82a255..81ca8944 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -46,7 +46,8 @@ A single entry point for human readers and AI agents walking the `docs/` tree. - [`report-schema.v0.27.json`](report-schema.v0.27.json) — frozen v0.27 reference schema; pre-v0.28 reports validate against this - [`report-schema.v0.26.json`](report-schema.v0.26.json) — frozen v0.26 reference schema; pre-v0.27 reports validate against this - [`report-schema.v0.25.json`](report-schema.v0.25.json) — frozen v0.25 reference schema; pre-v0.26 reports validate against this -- [`verifier-schema.v0.5.json`](verifier-schema.v0.5.json) — JSON Schema for `verifier.json`, including typed policy-evidence gaps and finding support +- [`verifier-schema.v0.6.json`](verifier-schema.v0.6.json) — current JSON Schema for `verifier.json`, including the fail-closed signed authorization evaluation +- [`verifier-schema.v0.5.json`](verifier-schema.v0.5.json) — frozen v0.5 verifier reference - [`verifier-schema.v0.4.json`](verifier-schema.v0.4.json) — frozen v0.4 verifier reference - [`verifier-schema.v0.3.json`](verifier-schema.v0.3.json) — frozen v0.3 verifier reference - [`verifier-schema.v0.2.json`](verifier-schema.v0.2.json) — frozen v0.2 verifier reference @@ -54,7 +55,10 @@ A single entry point for human readers and AI agents walking the `docs/` tree. - [`verify-run-schema.v3.json`](verify-run-schema.v3.json) — JSON Schema for `verify-run.json`, the deterministic verify-run reproducibility artifact - [`verify-run-schema.v2.json`](verify-run-schema.v2.json) — frozen verify-run v2 reference - [`verify-run-schema.v1.json`](verify-run-schema.v1.json) — frozen verify-run v1 reference -- [`agent-handoff-schema.v5.json`](agent-handoff-schema.v5.json) — current compact verifier handoff schema with support hashes on blocking evidence +- [`human-authorization-schema.v1.json`](human-authorization-schema.v1.json) — current schema family for unsigned requests, externally signed grants, evaluations, and external trust policies +- [`human-authorization-signature-v1.json`](human-authorization-signature-v1.json) — canonical Ed25519 signature interoperability vector +- [`agent-handoff-schema.v6.json`](agent-handoff-schema.v6.json) — current compact verifier handoff schema with authorization provenance +- [`agent-handoff-schema.v5.json`](agent-handoff-schema.v5.json) — frozen handoff v5 reference - [`verification-plan-schema.v1.json`](verification-plan-schema.v1.json) — content-addressed verification subject, inputs, engine requirement, and task plan - [`verification-unit-result-schema.v1.json`](verification-unit-result-schema.v1.json) — decision-free worker result contract - [`verification-artifact-manifest-schema.v1.json`](verification-artifact-manifest-schema.v1.json) — content-addressed terminal artifact set diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md index 960e9bbc..06f81e5a 100644 --- a/docs/agent-contract-current.md +++ b/docs/agent-contract-current.md @@ -10,16 +10,16 @@ Verify the installed CLI contract locally before relying on hard-coded docs: agents-shipgate contract --json ``` -Runtime contract v17 retains the v16 typed policy-evidence, v15 host-neutral +Runtime contract v18 retains the v17 content-addressed verification identity, +v16 typed policy-evidence, v15 host-neutral boundary, v14 unambiguous `AgentControl`, and v13 root-reachable binding -contracts. It adds a content-addressed verification request, decision-free -worker result, artifact manifest, and terminal receipt shared by local and -distributed verification. Agents +contracts. It adds a signed, externally rooted human-authorization overlay for +one exact post-review coding-agent action. Agents switch on `control.state`; `decision` remains diagnostic and `release_decision.decision` remains the release gate. Contract v14 requires `completion_allowed == (state == "complete")` and `must_stop == (state == "human_review_required")`. Report v0.34, packet v0.12, -verifier v0.5, verify-run v3, and handoff v5 bind their projections to the +verifier v0.6, verify-run v3, and handoff v6 bind their projections to the same request and decision IDs. The terminal receipt hashes the complete artifact set; see [Verification Identity and Reproduction](verification-reproducibility.md). The runtime contract also exposes the local agent command spec: @@ -29,7 +29,13 @@ The runtime contract also exposes the local agent command spec: `verify_run_schema_version`, `verification_plan_schema_version`, `verification_unit_result_schema_version`, `verification_artifact_manifest_schema_version`, -`verification_receipt_schema_version`, `agent_handoff_schema_version`, +`verification_receipt_schema_version`, +`human_authorization_request_schema_version`, +`human_authorization_schema_version`, +`human_authorization_evaluation_schema_version`, +`human_authorization_trust_policy_schema_version`, +`human_authorization_trust_policy_default_path`, +`human_authorization_schema_path`, `agent_handoff_schema_version`, `agent_handoff_schema_path`, `agent_handoff_artifact`, `agent_boundary_result_schema_version`, the deprecated `codex_boundary_result_schema_version`, `attestation_schema_version`, @@ -56,18 +62,19 @@ Downstream repos generated with - Latest release: `v0.15.0` - In-tree runtime: `0.16.0b6` — see [pyproject.toml](../pyproject.toml) -- Runtime contract: `17` (minimum control contract: `14`) +- Runtime contract: `18` (minimum control contract: `14`) - Current report schema: `0.34` — [`docs/report-schema.v0.34.json`](report-schema.v0.34.json) - Current packet schema: `0.12` — [`docs/packet-schema.v0.12.json`](packet-schema.v0.12.json) - Current shared agent result schema: `agent_result_v2` — [`docs/agent-result-schema.v2.json`](agent-result-schema.v2.json) -- Current verifier schema: `0.5` — [`docs/verifier-schema.v0.5.json`](verifier-schema.v0.5.json) +- Current verifier schema: `0.6` — [`docs/verifier-schema.v0.6.json`](verifier-schema.v0.6.json) - Current verify-run schema: `shipgate.verify_run/v3` — [`docs/verify-run-schema.v3.json`](verify-run-schema.v3.json) - Current verification identity schemas: [`plan v1`](verification-plan-schema.v1.json), [`unit result v1`](verification-unit-result-schema.v1.json), [`artifact manifest v1`](verification-artifact-manifest-schema.v1.json), and [`terminal receipt v1`](verification-receipt-schema.v1.json) -- Current agent handoff schema: `shipgate.agent_handoff/v5` — [`docs/agent-handoff-schema.v5.json`](agent-handoff-schema.v5.json) +- Current human-authorization schemas: request, signed grant, verifier evaluation, and external trust policy v1 — [`docs/human-authorization-schema.v1.json`](human-authorization-schema.v1.json) +- Current agent handoff schema: `shipgate.agent_handoff/v6` — [`docs/agent-handoff-schema.v6.json`](agent-handoff-schema.v6.json) - Current agent boundary result schema: `shipgate.agent_boundary_result/v1` — [`docs/agent-boundary-result-schema.v1.json`](agent-boundary-result-schema.v1.json) - Frozen deprecated Codex projection: `shipgate.codex_boundary_result/v2` — [`docs/codex-boundary-result-schema.v2.json`](codex-boundary-result-schema.v2.json) - Current preflight schema: `0.3` — [`docs/preflight-schema.v0.3.json`](preflight-schema.v0.3.json) -- Current downstream local agent contract schema: `6` +- Current downstream local agent contract schema: `7` - Current capability standard: `0.5` — [`docs/capability-standard.md`](capability-standard.md) - Current capability lock schema: `0.6` — [`docs/capability-lock-schema.v0.6.json`](capability-lock-schema.v0.6.json) - Current capability lock diff schema: `0.7` — [`docs/capability-lock-diff-schema.v0.7.json`](capability-lock-diff-schema.v0.7.json) @@ -80,7 +87,7 @@ Downstream repos generated with - Current governance benchmark result schema: `0.2` — [`docs/governance-benchmark-result-schema.v0.2.json`](governance-benchmark-result-schema.v0.2.json) - Frozen-reference report schemas: frozen [`v0.33`](report-schema.v0.33.json), frozen [`v0.32`](report-schema.v0.32.json), frozen [`v0.31`](report-schema.v0.31.json), frozen [`v0.30`](report-schema.v0.30.json), and older versions listed in [`docs/INDEX.md`](INDEX.md#reference) - Frozen-reference packet schemas live in [`docs/INDEX.md`](INDEX.md#reference). -- Boundary v1, verifier v0.1–v0.4, verify-run v1/v2, handoff v1–v4, and preflight +- Boundary v1, verifier v0.1–v0.5, verify-run v1/v2, handoff v1–v5, and preflight v0.1/v0.2 remain frozen references for legacy readers. - Frozen experimental capability lock and governance benchmark result schemas live in [`docs/INDEX.md`](INDEX.md#reference). @@ -94,7 +101,7 @@ one decision engine. repair, or stop*. Prefer validate `agents-shipgate-reports/verification-receipt.json`, then read `agents-shipgate-reports/agent-handoff.json` for the compact - `shipgate.agent_handoff/v5` view: lead with `control.state`, then read + `shipgate.agent_handoff/v6` view: lead with `control.state`, then read `control.next_action`, `gate.merge_verdict`, and `reproducibility.run_id` for the content-addressed verify identity. `verifier.json` remains the authoritative controller substrate and `verify-run.json` remains the detailed run @@ -280,17 +287,18 @@ first touch before a full scan. To evaluate just the run/skip trigger, run `agents-shipgate verify` and `verify --preview` also write `agents-shipgate-reports/verify-run.json` whenever the output directory can be -created. It carries `schema_version: "shipgate.verify_run/v2"`, the same -derived `control` object as the verifier and handoff, and a deterministic -`run_id` over `{tool, subject, inputs}` (outcome and artifact hashes are carried -separately), local input hashes (`config_sha256`, `baseline_sha256`, -`policy_packs[].sha256`), the outcome projection, and artifact hashes for -emitted files. It has no wall-clock timestamp and is not a second gate. +created. It carries `schema_version: "shipgate.verify_run/v3"`, the exact +verification plan, executor, unit-result IDs, decision ID, outcome projection, +and artifact references. `request_id` is the content-addressed run identity; +the deprecated `run_id` remains for one compatibility cycle as its exact alias, +never as a separately derived identity. It has no wall-clock timestamp and is +not a second gate. `agents-shipgate-reports/agent-handoff.json` carries -`schema_version: "shipgate.agent_handoff/v5"` and top-level sections +`schema_version: "shipgate.agent_handoff/v6"` and top-level sections `gate`, `control`, `fix_task`, `blocked_by[]`, -`remediation_plan[]`, `capability_review`, `reproducibility`, and `artifacts`. +`remediation_plan[]`, `capability_review`, `authorization`, `reproducibility`, +and `artifacts`. `gate.decision` mirrors `release_decision.decision`; `gate.merge_verdict` mirrors `verifier.json.merge_verdict`; and `gate.{static_analysis_only,runtime_behavior_verified,static_verdict_disclaimer}` @@ -298,17 +306,20 @@ mirrors the report/verifier static-only boundary. The values are locked to `true`, `false`, and the canonical disclaimer. `control` is byte-identical to the verifier/verify-run control object, and `can_merge_without_human` is true only for a verified `passed` result or a completed deterministic -`not_applicable` skip. Re-render it +`not_applicable` skip. `authorization` is the byte-equivalent verifier +evaluation; the handoff cannot grant a command independently. Re-render it from existing artifacts with: ```bash agents-shipgate agent handoff --from agents-shipgate-reports/verifier.json --json ``` -In `agents-shipgate-reports/verifier.json`, read the v0.5 fields below (full -schema [`docs/verifier-schema.v0.5.json`](verifier-schema.v0.5.json)). **Lead -with `control.state`.** Every field below is a mirror or deterministic projection of -`report.json`; `release_decision.decision` remains the gate. +In `agents-shipgate-reports/verifier.json`, read the v0.6 fields below (full +schema [`docs/verifier-schema.v0.6.json`](verifier-schema.v0.6.json)). **Lead +with `control.state`.** Every release and merge field below is a mirror or +deterministic projection of `report.json`; the authorization evaluation is an +operational overlay and cannot change those fields. +`release_decision.decision` remains the gate. - `control` — the discriminated `complete | agent_action_required | human_review_required` operational projection. Its variant fixes @@ -337,10 +348,17 @@ with `control.state`.** Every field below is a mirror or deterministic projectio - `can_merge_without_human` — `bool`. - `decision` — mirror of `release_decision.decision` (or `null` when no scan ran). - `headline` — single-sentence, PR-comment-friendly summary (or `null`). -- `control.human_review` and `control.next_action` are the only serialized - human-review and next-action authority in verifier v0.5. +- `authorization` — the + `shipgate.human_authorization_evaluation/v1` result. Only `accepted` can + expose a command, and that command must exactly match both + `control.next_action.command` and the sole entry in + `control.allowed_next_commands`. `rejected`, `not_requested`, and + `not_applicable` carry no command authority. +- `control.human_review` and `control.next_action` are the serialized route for + the current verifier state; when authorization is accepted, the signed + evaluation is the provenance for the exact coding-agent next action. - `AgentController`, `VerifierNextAction`, and `VerifierHumanReview` remain - importable only as deprecated v0.1/v0.2 reader models. Verifier v0.4 does not + importable only as deprecated v0.1/v0.2 reader models. Verifier v0.6 does not emit or invoke the retired `build_agent_controller` projector. - `fix_task` — `{actor, safe_to_attempt, instructions[], allowed_repairs[], forbidden_repairs[], forbidden_shortcuts[], verification_command, patches[]}` or `null`. @@ -372,6 +390,87 @@ with `control.state`.** Every field below is a mirror or deterministic projectio informational values) and never introduces a finding-independent blocker. - `mode` — `"advisory"` / `"strict"` / `"skipped"` / `"preview"`. +### Trusted human authorization for one exact command + +Authorization changes operational routing, never the static release verdict. +The flow is deliberately two-pass: + +1. Run `agents-shipgate verify --no-plugins` and validate the resulting + terminal receipt. Authorization requires the plan's exact effective plugin + mode to be false; the protected executor never loads third-party plugin or + adapter entry points. + `agents-shipgate authorization request --receipt + --artifacts-root --destination-ref + --expected-lease-oid --out ` constructs the unsigned + `shipgate.human_authorization_request/v1` from that receipt's current + request, subject, decision, source receipt/artifact-set/engine/executor and + tree identities, the complete ordered + review set, and one typed Git-push operation. This command creates a + challenge, not authority. +2. The host authenticates the human and signs the canonical request with an + Ed25519 key kept outside coding-agent reach. Agents Shipgate supplies no + private key and no command that signs or approves a request. The v1 trust + policy must be stored outside the evaluated workspace and protected from + writes by the agent. On POSIX, Agents Shipgate reads it only from the OS + account home's fixed path + `~/.config/agents-shipgate/human-authorization-trust-policy.json`; `HOME` + and `XDG_CONFIG_HOME` do not redirect that lookup. +3. Rerun `agents-shipgate verify --no-plugins --authorization + `. The + verifier recomputes the current identities and validates the signature, + principal, repository scope, TTL, request, subject, trees, decision, full + review set, and operation before publishing any command authority. + +The only v1 operation is an exact force-with-lease Git push. It binds the exact +evaluated commit, a canonical credential-free HTTPS destination whose +repository identity equals the verified repository, a full destination +`refs/heads/...` ref, and the expected remote OID. A synthetic PR merge receipt +cannot authorize pushing a different parent commit. Authorization is eligible +only when execution +succeeded and the release decision is `review_required`. An accepted grant +changes `control.state` from `human_review_required` to +`agent_action_required` for that exact command, while all release facts remain +unchanged: `release_decision.decision="review_required"`, +`merge_verdict="human_review_required"`, `can_merge_without_human=false`, and +`completion_allowed=false`. The coding agent may perform only the serialized +guarded `agents-shipgate authorization execute` command. That consumer +revalidates the current receipt, trust root, clock, repository, and commit and +isolates Git configuration and hooks before issuing the internal typed push; +the raw Git command is never operational authority. The agent must rerun +verification afterward. + +The signer must authenticate the source closure: content addressing is +integrity, not provenance. It must rerun verification in a trusted worker or +verify trusted-CI attestation over the bound source receipt/artifact-set IDs. +The request exposes the evaluated base commit and merge base, and the source +commit transitively binds its full parent graph. The signer must review that +complete ancestry and reachable history rather than relying only on the final +tree diff. Execution enforces a 512 MiB graph-pack ceiling and a 120-second +process timeout; the host broker should impose tighter deployment quotas. The +compressed pack ceiling does not bound expanded-object indexing memory or CPU, +so production brokers need cgroup, container, or equivalent host resource +limits. +Execution also requires a host-protected broker with a sanitized environment, +external trust policy, interpreter, entire virtual environment and +`site-packages` tree (including startup `.pth` files), dependencies, +credentials, and separately installed Agents Shipgate distribution. Same-UID +file permissions alone are insufficient, and an +editable install rooted in the authorized workspace is ineligible. If the host +cannot enforce those boundaries, authorization remains disabled. The guarded +executor is POSIX-only in v1 and authorization remains disabled on Windows. V1 is +push-only and does not authorize the coding agent to apply reviewed protected +patches. + +Malformed, untrusted, expired, not-yet-valid, incomplete-review-set, +wrong-tree, wrong-request, wrong-ref, or wrong-lease grants fail closed with +zero allowed commands. Plain JSON in the repository, a PR comment, or +conversation-level approval is not equivalent to a signed grant. This release +defines the protocol and verifier consumer; it does not claim a current Codex, +Claude Code, or other coding-host UI signing integration. Such a host adapter +must be implemented separately. A grant replayed after the remote ref advances +cannot overwrite that ref: Git enforces the signed command's explicit expected +lease OID. + `verifier.json` also carries `trigger`, `base_status`, `head_status`, `base_ref`, `head_ref`, `changed_files`, `base_notes`, the embedded `release_decision`, and an `artifacts` map. When present, `artifacts.capability_lock_json`, diff --git a/docs/agent-handoff-schema.v6.json b/docs/agent-handoff-schema.v6.json new file mode 100644 index 00000000..942b5718 --- /dev/null +++ b/docs/agent-handoff-schema.v6.json @@ -0,0 +1,1510 @@ +{ + "$defs": { + "AgentActionRequiredControl": { + "additionalProperties": false, + "description": "Non-terminal state with one exact coding-agent-owned next step.", + "properties": { + "allowed_next_commands": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Allowed Next Commands", + "type": "array" + }, + "completion_allowed": { + "const": false, + "default": false, + "title": "Completion Allowed", + "type": "boolean" + }, + "human_review": { + "$ref": "#/$defs/NoHumanReview" + }, + "must_stop": { + "const": false, + "default": false, + "title": "Must Stop", + "type": "boolean" + }, + "next_action": { + "$ref": "#/$defs/CodingAgentAction" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "agent_action_required", + "title": "State", + "type": "string" + }, + "stop_reason": { + "default": null, + "title": "Stop Reason", + "type": "null" + }, + "verify_required": { + "default": false, + "title": "Verify Required", + "type": "boolean" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop", + "verify_required", + "next_action", + "allowed_next_commands", + "human_review", + "stop_reason" + ], + "title": "AgentActionRequiredControl", + "type": "object" + }, + "AgentControl": { + "discriminator": { + "mapping": { + "agent_action_required": "#/$defs/AgentActionRequiredControl", + "complete": "#/$defs/CompleteAgentControl", + "human_review_required": "#/$defs/HumanReviewRequiredControl" + }, + "propertyName": "state" + }, + "oneOf": [ + { + "$ref": "#/$defs/CompleteAgentControl" + }, + { + "$ref": "#/$defs/AgentActionRequiredControl" + }, + { + "$ref": "#/$defs/HumanReviewRequiredControl" + } + ] + }, + "AgentHandoffBlockedBy": { + "additionalProperties": false, + "properties": { + "baseline_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline Status" + }, + "blocks_release": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocks Release" + }, + "bucket": { + "enum": [ + "blocker", + "review_item" + ], + "title": "Bucket", + "type": "string" + }, + "capability_refs": { + "items": { + "type": "string" + }, + "title": "Capability Refs", + "type": "array" + }, + "capability_trace_refs": { + "items": { + "type": "string" + }, + "title": "Capability Trace Refs", + "type": "array" + }, + "check_id": { + "title": "Check Id", + "type": "string" + }, + "fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fingerprint" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "support_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Support Hash" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "bucket", + "check_id", + "severity", + "title" + ], + "title": "AgentHandoffBlockedBy", + "type": "object" + }, + "AgentHandoffGateV3": { + "additionalProperties": false, + "description": "Current gate projection with explicit execution applicability.", + "properties": { + "applicability": { + "enum": [ + "not_evaluated", + "verified", + "not_applicable", + "failed" + ], + "title": "Applicability", + "type": "string" + }, + "can_merge_without_human": { + "default": false, + "title": "Can Merge Without Human", + "type": "boolean" + }, + "ci_would_fail": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ci Would Fail" + }, + "decision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decision" + }, + "gating_signal": { + "const": "release_decision.decision", + "default": "release_decision.decision", + "title": "Gating Signal", + "type": "string" + }, + "merge_verdict": { + "enum": [ + "mergeable", + "human_review_required", + "insufficient_evidence", + "blocked", + "unknown" + ], + "title": "Merge Verdict", + "type": "string" + }, + "runtime_behavior_verified": { + "const": false, + "default": false, + "title": "Runtime Behavior Verified", + "type": "boolean" + }, + "static_analysis_only": { + "const": true, + "default": true, + "title": "Static Analysis Only", + "type": "boolean" + }, + "static_verdict_disclaimer": { + "default": "This verdict covers deterministic static evidence only. Agents Shipgate did not execute the agent or prove runtime behavior, tool routing, credential enforcement, or safety.", + "title": "Static Verdict Disclaimer", + "type": "string" + } + }, + "required": [ + "merge_verdict", + "applicability" + ], + "title": "AgentHandoffGateV3", + "type": "object" + }, + "AgentHandoffRemediationStep": { + "additionalProperties": false, + "properties": { + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "check_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Check Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "finding_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finding Id" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "patch": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Patch" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "safety": { + "enum": [ + "allowed", + "forbidden", + "patch" + ], + "title": "Safety", + "type": "string" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + } + }, + "required": [ + "safety", + "kind", + "reason" + ], + "title": "AgentHandoffRemediationStep", + "type": "object" + }, + "AgentHandoffReproducibility": { + "additionalProperties": false, + "properties": { + "artifact_sha256": { + "additionalProperties": { + "type": "string" + }, + "title": "Artifact Sha256", + "type": "object" + }, + "baseline_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline Sha256" + }, + "config_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config Sha256" + }, + "decision_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decision Id" + }, + "engine_requirement_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Engine Requirement Id" + }, + "executor_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Executor Id" + }, + "input_set_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input Set Id" + }, + "policy_packs": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Policy Packs", + "type": "array" + }, + "request_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Id" + }, + "run_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Run Id" + }, + "subject_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subject Id" + } + }, + "title": "AgentHandoffReproducibility", + "type": "object" + }, + "AgentHandoffSubject": { + "additionalProperties": false, + "properties": { + "base_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Ref" + }, + "changed_files": { + "items": { + "type": "string" + }, + "title": "Changed Files", + "type": "array" + }, + "config": { + "title": "Config", + "type": "string" + }, + "head_ref": { + "default": "HEAD", + "title": "Head Ref", + "type": "string" + }, + "workspace": { + "title": "Workspace", + "type": "string" + } + }, + "required": [ + "workspace", + "config" + ], + "title": "AgentHandoffSubject", + "type": "object" + }, + "AgentHandoffTool": { + "additionalProperties": false, + "properties": { + "name": { + "default": "agents-shipgate", + "title": "Name", + "type": "string" + }, + "version": { + "default": "0.16.0b6", + "title": "Version", + "type": "string" + } + }, + "title": "AgentHandoffTool", + "type": "object" + }, + "AuthorizationEvaluationV1": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "accepted" + } + } + }, + "then": { + "properties": { + "authorization_id": { + "not": { + "type": "null" + } + }, + "authorization_request_id": { + "not": { + "type": "null" + } + }, + "command": { + "not": { + "type": "null" + } + }, + "expires_at": { + "not": { + "type": "null" + } + }, + "issued_at": { + "not": { + "type": "null" + } + }, + "key_id": { + "not": { + "type": "null" + } + }, + "operation_id": { + "not": { + "type": "null" + } + }, + "principal": { + "not": { + "type": "null" + } + }, + "provider": { + "not": { + "type": "null" + } + }, + "reason_codes": { + "maxItems": 0 + }, + "trust_policy_id": { + "not": { + "type": "null" + } + } + }, + "required": [ + "authorization_id", + "authorization_request_id", + "trust_policy_id", + "key_id", + "provider", + "principal", + "operation_id", + "command", + "issued_at", + "expires_at" + ] + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "rejected", + "not_requested", + "not_applicable" + ] + } + } + }, + "then": { + "properties": { + "command": { + "type": "null" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "rejected" + } + } + }, + "then": { + "properties": { + "reason_codes": { + "minItems": 1 + } + }, + "required": [ + "reason_codes" + ] + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "not_requested", + "not_applicable" + ] + } + } + }, + "then": { + "properties": { + "authorization_id": { + "type": "null" + }, + "authorization_request_id": { + "type": "null" + }, + "command": { + "type": "null" + }, + "expires_at": { + "type": "null" + }, + "issued_at": { + "type": "null" + }, + "key_id": { + "type": "null" + }, + "operation_id": { + "type": "null" + }, + "principal": { + "type": "null" + }, + "provider": { + "type": "null" + }, + "trust_policy_id": { + "type": "null" + } + } + } + } + ], + "description": "Fail-closed authorization evaluation consumed by verifier projections.", + "properties": { + "authorization_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authorization Id" + }, + "authorization_request_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authorization Request Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "issued_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Issued At" + }, + "key_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Id" + }, + "operation_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation Id" + }, + "principal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "reason_codes": { + "items": { + "type": "string" + }, + "title": "Reason Codes", + "type": "array", + "uniqueItems": true + }, + "schema_version": { + "const": "shipgate.human_authorization_evaluation/v1", + "default": "shipgate.human_authorization_evaluation/v1", + "title": "Schema Version", + "type": "string" + }, + "status": { + "enum": [ + "not_requested", + "accepted", + "rejected", + "not_applicable" + ], + "title": "Status", + "type": "string" + }, + "trust_policy_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trust Policy Id" + } + }, + "required": [ + "status" + ], + "title": "AuthorizationEvaluationV1", + "type": "object" + }, + "CodingAgentAction": { + "discriminator": { + "mapping": { + "configure": "#/$defs/CodingAgentCommandAction", + "discover": "#/$defs/CodingAgentCommandAction", + "fetch_base": "#/$defs/CodingAgentFetchBaseAction", + "initialize": "#/$defs/CodingAgentCommandAction", + "install": "#/$defs/CodingAgentCommandAction", + "repair": "#/$defs/CodingAgentCommandAction", + "rerun": "#/$defs/CodingAgentCommandAction", + "verify": "#/$defs/CodingAgentCommandAction" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CodingAgentCommandAction" + }, + { + "$ref": "#/$defs/CodingAgentFetchBaseAction" + } + ] + }, + "CodingAgentCommandAction": { + "additionalProperties": false, + "description": "An executable, exact next step owned by the coding agent.", + "properties": { + "actor": { + "const": "coding_agent", + "default": "coding_agent", + "title": "Actor", + "type": "string" + }, + "command": { + "minLength": 1, + "title": "Command", + "type": "string" + }, + "expects": { + "default": null, + "title": "Expects", + "type": "null" + }, + "kind": { + "enum": [ + "verify", + "discover", + "configure", + "initialize", + "repair", + "install", + "rerun" + ], + "title": "Kind", + "type": "string" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "actor", + "kind", + "command", + "expects", + "why" + ], + "title": "CodingAgentCommandAction", + "type": "object" + }, + "CodingAgentFetchBaseAction": { + "additionalProperties": false, + "description": "A structured input request when an exact fetch command is unavailable.\n\nShipgate never fetches refs itself. ``expects`` therefore names the exact\nref or artifact a caller must make available before rerunning verification.", + "properties": { + "actor": { + "const": "coding_agent", + "default": "coding_agent", + "title": "Actor", + "type": "string" + }, + "command": { + "default": null, + "title": "Command", + "type": "null" + }, + "expects": { + "minLength": 1, + "title": "Expects", + "type": "string" + }, + "kind": { + "const": "fetch_base", + "title": "Kind", + "type": "string" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "actor", + "kind", + "command", + "expects", + "why" + ], + "title": "CodingAgentFetchBaseAction", + "type": "object" + }, + "CompleteAgentControl": { + "additionalProperties": false, + "description": "Terminal state: the coding agent may report the task complete.", + "properties": { + "allowed_next_commands": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 0, + "title": "Allowed Next Commands", + "type": "array" + }, + "completion_allowed": { + "const": true, + "default": true, + "title": "Completion Allowed", + "type": "boolean" + }, + "human_review": { + "$ref": "#/$defs/NoHumanReview" + }, + "must_stop": { + "const": false, + "default": false, + "title": "Must Stop", + "type": "boolean" + }, + "next_action": { + "default": null, + "title": "Next Action", + "type": "null" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "complete", + "title": "State", + "type": "string" + }, + "stop_reason": { + "default": null, + "title": "Stop Reason", + "type": "null" + }, + "verify_required": { + "const": false, + "default": false, + "title": "Verify Required", + "type": "boolean" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop", + "verify_required", + "next_action", + "allowed_next_commands", + "human_review", + "stop_reason" + ], + "title": "CompleteAgentControl", + "type": "object" + }, + "HumanControlAction": { + "additionalProperties": false, + "description": "A human-owned route. Human actions never expose executable commands.", + "properties": { + "actor": { + "const": "human", + "default": "human", + "title": "Actor", + "type": "string" + }, + "command": { + "default": null, + "title": "Command", + "type": "null" + }, + "expects": { + "default": null, + "title": "Expects", + "type": "null" + }, + "kind": { + "enum": [ + "review", + "stop" + ], + "title": "Kind", + "type": "string" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "actor", + "kind", + "command", + "expects", + "why" + ], + "title": "HumanControlAction", + "type": "object" + }, + "HumanReviewRequiredControl": { + "additionalProperties": false, + "description": "Stopping state: no further coding-agent action is authorized.", + "properties": { + "allowed_next_commands": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 0, + "title": "Allowed Next Commands", + "type": "array" + }, + "completion_allowed": { + "const": false, + "default": false, + "title": "Completion Allowed", + "type": "boolean" + }, + "human_review": { + "$ref": "#/$defs/RequiredHumanReview" + }, + "must_stop": { + "const": true, + "default": true, + "title": "Must Stop", + "type": "boolean" + }, + "next_action": { + "$ref": "#/$defs/HumanControlAction" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "human_review_required", + "title": "State", + "type": "string" + }, + "stop_reason": { + "minLength": 1, + "title": "Stop Reason", + "type": "string" + }, + "verify_required": { + "default": false, + "title": "Verify Required", + "type": "boolean" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop", + "verify_required", + "next_action", + "allowed_next_commands", + "human_review", + "stop_reason" + ], + "title": "HumanReviewRequiredControl", + "type": "object" + }, + "NoHumanReview": { + "additionalProperties": false, + "description": "Exact negative human-review projection for non-stopping states.", + "properties": { + "required": { + "const": false, + "default": false, + "title": "Required", + "type": "boolean" + }, + "required_reviewers": { + "items": { + "type": "string" + }, + "maxItems": 0, + "title": "Required Reviewers", + "type": "array" + }, + "why": { + "default": null, + "title": "Why", + "type": "null" + } + }, + "required": [ + "required", + "why", + "required_reviewers" + ], + "title": "NoHumanReview", + "type": "object" + }, + "RequiredHumanReview": { + "additionalProperties": false, + "description": "Human-review evidence carried by the stopping state.", + "properties": { + "required": { + "const": true, + "default": true, + "title": "Required", + "type": "boolean" + }, + "required_reviewers": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Required Reviewers", + "type": "array" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "required", + "why", + "required_reviewers" + ], + "title": "RequiredHumanReview", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-handoff-schema.v6.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "else": { + "properties": { + "control": { + "properties": { + "state": { + "enum": [ + "agent_action_required", + "human_review_required" + ] + } + }, + "required": [ + "state" + ] + } + } + }, + "if": { + "properties": { + "gate": { + "properties": { + "can_merge_without_human": { + "const": true + } + }, + "required": [ + "can_merge_without_human" + ] + } + }, + "required": [ + "gate" + ] + }, + "then": { + "properties": { + "control": { + "properties": { + "state": { + "const": "complete" + } + }, + "required": [ + "state" + ] + }, + "fix_task": { + "type": "null" + } + } + } + }, + { + "if": { + "properties": { + "authorization": { + "properties": { + "status": { + "const": "accepted" + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "authorization" + ] + }, + "then": { + "properties": { + "control": { + "properties": { + "allowed_next_commands": { + "maxItems": 1, + "minItems": 1 + }, + "completion_allowed": { + "const": false + }, + "next_action": { + "properties": { + "kind": { + "const": "repair" + } + }, + "required": [ + "kind" + ] + }, + "state": { + "const": "agent_action_required" + } + }, + "required": [ + "state", + "completion_allowed", + "next_action", + "allowed_next_commands" + ] + }, + "fix_task": { + "type": "null" + }, + "gate": { + "properties": { + "can_merge_without_human": { + "const": false + }, + "decision": { + "const": "review_required" + }, + "merge_verdict": { + "const": "human_review_required" + } + }, + "required": [ + "decision", + "merge_verdict", + "can_merge_without_human" + ] + } + } + } + } + ], + "description": "JSON Schema for agents-shipgate-reports/agent-handoff.json. Generated from agents_shipgate.schemas.agent_handoff.AgentHandoffArtifact. It is a compact projection for coding agents and does not gate releases; release_decision.decision remains the only gate.", + "properties": { + "artifacts": { + "additionalProperties": { + "type": "string" + }, + "title": "Artifacts", + "type": "object" + }, + "authorization": { + "$ref": "#/$defs/AuthorizationEvaluationV1" + }, + "blocked_by": { + "items": { + "$ref": "#/$defs/AgentHandoffBlockedBy" + }, + "title": "Blocked By", + "type": "array" + }, + "capability_review": { + "additionalProperties": true, + "title": "Capability Review", + "type": "object" + }, + "contract_version": { + "title": "Contract Version", + "type": "string" + }, + "control": { + "$ref": "#/$defs/AgentControl" + }, + "fix_task": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fix Task" + }, + "forbidden_actions": { + "items": { + "type": "string" + }, + "title": "Forbidden Actions", + "type": "array" + }, + "forbidden_file_edits": { + "items": { + "type": "string" + }, + "title": "Forbidden File Edits", + "type": "array" + }, + "gate": { + "$ref": "#/$defs/AgentHandoffGateV3" + }, + "operation": { + "enum": [ + "verify_pr", + "verify_local", + "verify_preview" + ], + "title": "Operation", + "type": "string" + }, + "remediation_plan": { + "items": { + "$ref": "#/$defs/AgentHandoffRemediationStep" + }, + "title": "Remediation Plan", + "type": "array" + }, + "reproducibility": { + "$ref": "#/$defs/AgentHandoffReproducibility" + }, + "schema_version": { + "const": "shipgate.agent_handoff/v6", + "default": "shipgate.agent_handoff/v6", + "title": "Schema Version", + "type": "string" + }, + "subject": { + "$ref": "#/$defs/AgentHandoffSubject" + }, + "tool": { + "$ref": "#/$defs/AgentHandoffTool" + } + }, + "required": [ + "contract_version", + "operation", + "subject", + "gate", + "control", + "authorization" + ], + "title": "Agents Shipgate Agent Handoff v6", + "type": "object" +} diff --git a/docs/agents/protocol.md b/docs/agents/protocol.md index 7f6d8875..c124f72f 100644 --- a/docs/agents/protocol.md +++ b/docs/agents/protocol.md @@ -309,7 +309,7 @@ capability evidence requests, and host/MCP permission review. `shipgate.explain` deterministic check/finding explanation JSON. `shipgate.capabilities` returns capability lock or capability lock diff JSON. `shipgate.handoff` reads existing `verifier.json` / `report.json` / `verify-run.json` artifacts and returns exact -`shipgate.agent_handoff/v5`. These are projections only; the +`shipgate.agent_handoff/v6`. These are projections only; the release gate remains `report.json.release_decision.decision`. The MCP server is a static adapter only. It exposes no scan, verify, diff --git a/docs/ai-search-summary.md b/docs/ai-search-summary.md index 31f222bc..519cbadf 100644 --- a/docs/ai-search-summary.md +++ b/docs/ai-search-summary.md @@ -90,8 +90,8 @@ Per-agent guides cover [Codex](agents/use-with-codex.md), [Claude Code](agents/use-with-claude-code.md), and [Cursor](agents/use-with-cursor.md). -The current source tree is `0.16.0b6` (runtime contract v17); the latest -published release remains `v0.15.0` until that beta is cut. In report v0.32, +The current source tree is `0.16.0b6` (runtime contract v18); the latest +published release remains `v0.15.0` until that beta is cut. In report v0.34, `passed` is an evidence-backed static verdict: the configured root has a complete reachable binding graph, every reachable action has complete, conflict-free identity, binding, effect, and authority evidence, all applicable diff --git a/docs/architecture.md b/docs/architecture.md index 3db36725..6c76cbdd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ A single-page summary of the `agents-shipgate` codebase for new contributors and AI coding agents extending the project. Current as of 2026-07-13; auto-checked against `agents-shipgate contract --json`: -runtime contract `17`, report schema `v0.34`, packet schema `v0.12`. +runtime contract `18`, report schema `v0.34`, packet schema `v0.12`. For the per-field stability contract, see [`../STABILITY.md`](../STABILITY.md). For the agent-facing field index, diff --git a/docs/human-authorization-schema.v1.json b/docs/human-authorization-schema.v1.json new file mode 100644 index 00000000..aaae3bfe --- /dev/null +++ b/docs/human-authorization-schema.v1.json @@ -0,0 +1,850 @@ +{ + "$defs": { + "AuthorizationEvaluationV1": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "accepted" + } + } + }, + "then": { + "properties": { + "authorization_id": { + "not": { + "type": "null" + } + }, + "authorization_request_id": { + "not": { + "type": "null" + } + }, + "command": { + "not": { + "type": "null" + } + }, + "expires_at": { + "not": { + "type": "null" + } + }, + "issued_at": { + "not": { + "type": "null" + } + }, + "key_id": { + "not": { + "type": "null" + } + }, + "operation_id": { + "not": { + "type": "null" + } + }, + "principal": { + "not": { + "type": "null" + } + }, + "provider": { + "not": { + "type": "null" + } + }, + "reason_codes": { + "maxItems": 0 + }, + "trust_policy_id": { + "not": { + "type": "null" + } + } + }, + "required": [ + "authorization_id", + "authorization_request_id", + "trust_policy_id", + "key_id", + "provider", + "principal", + "operation_id", + "command", + "issued_at", + "expires_at" + ] + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "rejected", + "not_requested", + "not_applicable" + ] + } + } + }, + "then": { + "properties": { + "command": { + "type": "null" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "rejected" + } + } + }, + "then": { + "properties": { + "reason_codes": { + "minItems": 1 + } + }, + "required": [ + "reason_codes" + ] + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "not_requested", + "not_applicable" + ] + } + } + }, + "then": { + "properties": { + "authorization_id": { + "type": "null" + }, + "authorization_request_id": { + "type": "null" + }, + "command": { + "type": "null" + }, + "expires_at": { + "type": "null" + }, + "issued_at": { + "type": "null" + }, + "key_id": { + "type": "null" + }, + "operation_id": { + "type": "null" + }, + "principal": { + "type": "null" + }, + "provider": { + "type": "null" + }, + "trust_policy_id": { + "type": "null" + } + } + } + } + ], + "description": "Fail-closed authorization evaluation consumed by verifier projections.", + "properties": { + "authorization_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authorization Id" + }, + "authorization_request_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authorization Request Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "issued_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Issued At" + }, + "key_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Id" + }, + "operation_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation Id" + }, + "principal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "reason_codes": { + "items": { + "type": "string" + }, + "title": "Reason Codes", + "type": "array", + "uniqueItems": true + }, + "schema_version": { + "const": "shipgate.human_authorization_evaluation/v1", + "default": "shipgate.human_authorization_evaluation/v1", + "title": "Schema Version", + "type": "string" + }, + "status": { + "enum": [ + "not_requested", + "accepted", + "rejected", + "not_applicable" + ], + "title": "Status", + "type": "string" + }, + "trust_policy_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trust Policy Id" + } + }, + "required": [ + "status" + ], + "title": "AuthorizationEvaluationV1", + "type": "object" + }, + "GitPushOperationV1": { + "additionalProperties": false, + "description": "One exact force-with-lease push; no shell or wildcard semantics.", + "properties": { + "destination_ref": { + "pattern": "^refs/heads/(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9._/-]*[A-Za-z0-9])$", + "title": "Destination Ref", + "type": "string" + }, + "destination_repository_id": { + "minLength": 1, + "title": "Destination Repository Id", + "type": "string" + }, + "expected_lease_oid": { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "title": "Expected Lease Oid", + "type": "string" + }, + "force_with_lease": { + "const": true, + "default": true, + "title": "Force With Lease", + "type": "boolean" + }, + "kind": { + "const": "git_push", + "default": "git_push", + "title": "Kind", + "type": "string" + }, + "operation_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Operation Id", + "type": "string" + }, + "push_url": { + "minLength": 1, + "pattern": "^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?/(?:[A-Za-z0-9._~+-]+/)*[A-Za-z0-9._~+-]+$", + "title": "Push Url", + "type": "string" + }, + "source_commit_sha": { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "title": "Source Commit Sha", + "type": "string" + } + }, + "required": [ + "operation_id", + "destination_repository_id", + "push_url", + "source_commit_sha", + "destination_ref", + "expected_lease_oid" + ], + "title": "GitPushOperationV1", + "type": "object" + }, + "HumanAuthorizationPrincipalV1": { + "additionalProperties": false, + "properties": { + "provider": { + "enum": [ + "codex", + "claude-code", + "github", + "local-hardware" + ], + "title": "Provider", + "type": "string" + }, + "subject": { + "minLength": 1, + "title": "Subject", + "type": "string" + } + }, + "required": [ + "provider", + "subject" + ], + "title": "HumanAuthorizationPrincipalV1", + "type": "object" + }, + "HumanAuthorizationProofV1": { + "additionalProperties": false, + "properties": { + "algorithm": { + "const": "ed25519", + "default": "ed25519", + "title": "Algorithm", + "type": "string" + }, + "key_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Key Id", + "type": "string" + }, + "signature": { + "maxLength": 86, + "minLength": 86, + "pattern": "^[A-Za-z0-9_-]{86}$", + "title": "Signature", + "type": "string" + } + }, + "required": [ + "key_id", + "signature" + ], + "title": "HumanAuthorizationProofV1", + "type": "object" + }, + "HumanAuthorizationRequestV1": { + "additionalProperties": false, + "description": "Unsigned, content-addressed challenge shown by a trusted host to a human.", + "properties": { + "authorization_request_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Authorization Request Id", + "type": "string" + }, + "base_commit_sha": { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "title": "Base Commit Sha", + "type": "string" + }, + "base_tree_sha": { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "title": "Base Tree Sha", + "type": "string" + }, + "decision": { + "const": "review_required", + "default": "review_required", + "title": "Decision", + "type": "string" + }, + "decision_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Decision Id", + "type": "string" + }, + "head_tree_sha": { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "title": "Head Tree Sha", + "type": "string" + }, + "merge_base_sha": { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "title": "Merge Base Sha", + "type": "string" + }, + "operation": { + "$ref": "#/$defs/GitPushOperationV1" + }, + "repository_id": { + "minLength": 1, + "title": "Repository Id", + "type": "string" + }, + "review_items": { + "items": { + "$ref": "#/$defs/HumanAuthorizationReviewItemV1" + }, + "minItems": 1, + "title": "Review Items", + "type": "array" + }, + "review_set_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Review Set Id", + "type": "string" + }, + "schema_version": { + "const": "shipgate.human_authorization_request/v1", + "default": "shipgate.human_authorization_request/v1", + "title": "Schema Version", + "type": "string" + }, + "source_artifact_set_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Source Artifact Set Id", + "type": "string" + }, + "source_engine_requirement_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Source Engine Requirement Id", + "type": "string" + }, + "source_executor_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Source Executor Id", + "type": "string" + }, + "source_head_commit_sha": { + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", + "title": "Source Head Commit Sha", + "type": "string" + }, + "source_receipt_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Source Receipt Id", + "type": "string" + }, + "subject_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Subject Id", + "type": "string" + }, + "verification_request_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Verification Request Id", + "type": "string" + }, + "worktree_overlay_sha256": { + "default": null, + "title": "Worktree Overlay Sha256", + "type": "null" + } + }, + "required": [ + "authorization_request_id", + "repository_id", + "source_receipt_id", + "source_artifact_set_id", + "source_engine_requirement_id", + "source_executor_id", + "verification_request_id", + "subject_id", + "decision_id", + "base_commit_sha", + "merge_base_sha", + "base_tree_sha", + "head_tree_sha", + "source_head_commit_sha", + "review_set_id", + "review_items", + "operation" + ], + "title": "HumanAuthorizationRequestV1", + "type": "object" + }, + "HumanAuthorizationReviewItemV1": { + "additionalProperties": false, + "description": "One complete reviewer obligation included in the signed review set.", + "properties": { + "check_id": { + "minLength": 1, + "title": "Check Id", + "type": "string" + }, + "fingerprint": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fingerprint" + }, + "paths": { + "items": { + "type": "string" + }, + "title": "Paths", + "type": "array" + }, + "review_item_id": { + "minLength": 1, + "title": "Review Item Id", + "type": "string" + }, + "support_hash": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Support Hash" + } + }, + "required": [ + "review_item_id", + "check_id" + ], + "title": "HumanAuthorizationReviewItemV1", + "type": "object" + }, + "HumanAuthorizationStatementV1": { + "additionalProperties": false, + "description": "Canonical payload covered by the detached signature.", + "properties": { + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "issued_at": { + "format": "date-time", + "title": "Issued At", + "type": "string" + }, + "nonce": { + "maxLength": 128, + "minLength": 22, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Nonce", + "type": "string" + }, + "not_before": { + "format": "date-time", + "title": "Not Before", + "type": "string" + }, + "principal": { + "$ref": "#/$defs/HumanAuthorizationPrincipalV1" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "request": { + "$ref": "#/$defs/HumanAuthorizationRequestV1" + } + }, + "required": [ + "request", + "principal", + "reason", + "issued_at", + "not_before", + "expires_at", + "nonce" + ], + "title": "HumanAuthorizationStatementV1", + "type": "object" + }, + "HumanAuthorizationTrustPolicyV1": { + "additionalProperties": false, + "description": "Fixed external trust policy; never valid merely because it is in the repo.", + "properties": { + "clock_skew_seconds": { + "default": 30, + "maximum": 300, + "minimum": 0, + "title": "Clock Skew Seconds", + "type": "integer" + }, + "keys": { + "items": { + "$ref": "#/$defs/TrustedEd25519KeyV1" + }, + "minItems": 1, + "title": "Keys", + "type": "array" + }, + "max_ttl_seconds": { + "default": 900, + "maximum": 86400, + "minimum": 1, + "title": "Max Ttl Seconds", + "type": "integer" + }, + "repository_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Repository Ids", + "type": "array" + }, + "schema_version": { + "const": "shipgate.human_authorization_trust_policy/v1", + "default": "shipgate.human_authorization_trust_policy/v1", + "title": "Schema Version", + "type": "string" + }, + "trust_policy_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Trust Policy Id", + "type": "string" + } + }, + "required": [ + "trust_policy_id", + "repository_ids", + "keys" + ], + "title": "HumanAuthorizationTrustPolicyV1", + "type": "object" + }, + "HumanAuthorizationV1": { + "additionalProperties": false, + "description": "Signed authorization for exactly one request and operation.", + "properties": { + "authorization_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Authorization Id", + "type": "string" + }, + "proof": { + "$ref": "#/$defs/HumanAuthorizationProofV1" + }, + "schema_version": { + "const": "shipgate.human_authorization/v1", + "default": "shipgate.human_authorization/v1", + "title": "Schema Version", + "type": "string" + }, + "statement": { + "$ref": "#/$defs/HumanAuthorizationStatementV1" + } + }, + "required": [ + "authorization_id", + "statement", + "proof" + ], + "title": "HumanAuthorizationV1", + "type": "object" + }, + "TrustedEd25519KeyV1": { + "additionalProperties": false, + "description": "One host/admin-controlled key and the identity it is allowed to assert.", + "properties": { + "key_id": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Key Id", + "type": "string" + }, + "principal": { + "minLength": 1, + "title": "Principal", + "type": "string" + }, + "provider": { + "enum": [ + "codex", + "claude-code", + "github", + "local-hardware" + ], + "title": "Provider", + "type": "string" + }, + "public_key": { + "maxLength": 43, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]{43}$", + "title": "Public Key", + "type": "string" + }, + "valid_from": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Valid From" + }, + "valid_until": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Valid Until" + } + }, + "required": [ + "key_id", + "public_key", + "provider", + "principal" + ], + "title": "TrustedEd25519KeyV1", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/human-authorization-schema.v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "$ref": "#/$defs/HumanAuthorizationRequestV1" + }, + { + "$ref": "#/$defs/HumanAuthorizationV1" + }, + { + "$ref": "#/$defs/AuthorizationEvaluationV1" + }, + { + "$ref": "#/$defs/HumanAuthorizationTrustPolicyV1" + } + ], + "description": "JSON Schema family for the unsigned request, externally signed grant, fail-closed evaluation, and external trust policy. Agents Shipgate does not provide signing or approval authority. Content-address, cross-object identity, canonical ordering, and time-window relations also require application-level model validation.", + "title": "Agents Shipgate Human Authorization Protocol v1" +} diff --git a/docs/human-authorization-signature-v1.json b/docs/human-authorization-signature-v1.json new file mode 100644 index 00000000..61fdaf48 --- /dev/null +++ b/docs/human-authorization-signature-v1.json @@ -0,0 +1,61 @@ +{ + "canonical_statement_json": "{\"expires_at\":\"2026-07-18T12:10:00Z\",\"issued_at\":\"2026-07-18T12:00:00Z\",\"nonce\":\"AAECAwQFBgcICQoLDA0ODw\",\"not_before\":\"2026-07-18T12:00:00Z\",\"principal\":{\"provider\":\"github\",\"subject\":\"github:user:reviewer\"},\"reason\":\"Reviewed exact operation.\",\"request\":{\"authorization_request_id\":\"sha256:aada615ac1feead3e0196a1ad623eec8f4900f6bdebba4951c6c56e5ab5c22a6\",\"base_commit_sha\":\"9999999999999999999999999999999999999999\",\"base_tree_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"decision\":\"review_required\",\"decision_id\":\"sha256:3333333333333333333333333333333333333333333333333333333333333333\",\"head_tree_sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"merge_base_sha\":\"8888888888888888888888888888888888888888\",\"operation\":{\"destination_ref\":\"refs/heads/codex/reviewed\",\"destination_repository_id\":\"example.test/acme/agent\",\"expected_lease_oid\":\"dddddddddddddddddddddddddddddddddddddddd\",\"force_with_lease\":true,\"kind\":\"git_push\",\"operation_id\":\"sha256:61deb55770225738ed36777e301dc7adde3dabed9b34d56b9cc6b361ba448e2f\",\"push_url\":\"https://example.test/acme/agent.git\",\"source_commit_sha\":\"cccccccccccccccccccccccccccccccccccccccc\"},\"repository_id\":\"example.test/acme/agent\",\"review_items\":[{\"check_id\":\"SHIP-VERIFY-TRUST-ROOT-TOUCHED\",\"fingerprint\":null,\"paths\":[\"action.yml\"],\"review_item_id\":\"review-1\",\"support_hash\":null}],\"review_set_id\":\"sha256:49806f6f5d712fb4f7712cee6a57cf6946f3607eeb88b629c17ad0c93d27df73\",\"schema_version\":\"shipgate.human_authorization_request/v1\",\"source_artifact_set_id\":\"sha256:5555555555555555555555555555555555555555555555555555555555555555\",\"source_engine_requirement_id\":\"sha256:6666666666666666666666666666666666666666666666666666666666666666\",\"source_executor_id\":\"sha256:7777777777777777777777777777777777777777777777777777777777777777\",\"source_head_commit_sha\":\"cccccccccccccccccccccccccccccccccccccccc\",\"source_receipt_id\":\"sha256:4444444444444444444444444444444444444444444444444444444444444444\",\"subject_id\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"verification_request_id\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\"worktree_overlay_sha256\":null}}", + "key_id": "sha256:56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c", + "public_key": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg", + "schema_version": "shipgate.human_authorization_signature_test_vector/v1", + "signature": "fI79H0ignGoV8mI-bJeHGaohQhaL9BnMzcqUP1zOVmQrv0RRCuDdfg_scTPvpGEuBQnhgpML8Do0BZ27dqxzBQ", + "signature_domain_hex": "73686970676174652e68756d616e5f617574686f72697a6174696f6e2f763100", + "signature_payload_sha256": "sha256:3f8a6089d32ff320f096b6a3cec74ff4c5eb30c47dd3ae457f8982197910e90b", + "statement": { + "expires_at": "2026-07-18T12:10:00Z", + "issued_at": "2026-07-18T12:00:00Z", + "nonce": "AAECAwQFBgcICQoLDA0ODw", + "not_before": "2026-07-18T12:00:00Z", + "principal": { + "provider": "github", + "subject": "github:user:reviewer" + }, + "reason": "Reviewed exact operation.", + "request": { + "authorization_request_id": "sha256:aada615ac1feead3e0196a1ad623eec8f4900f6bdebba4951c6c56e5ab5c22a6", + "base_commit_sha": "9999999999999999999999999999999999999999", + "base_tree_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "decision": "review_required", + "decision_id": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "head_tree_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "merge_base_sha": "8888888888888888888888888888888888888888", + "operation": { + "destination_ref": "refs/heads/codex/reviewed", + "destination_repository_id": "example.test/acme/agent", + "expected_lease_oid": "dddddddddddddddddddddddddddddddddddddddd", + "force_with_lease": true, + "kind": "git_push", + "operation_id": "sha256:61deb55770225738ed36777e301dc7adde3dabed9b34d56b9cc6b361ba448e2f", + "push_url": "https://example.test/acme/agent.git", + "source_commit_sha": "cccccccccccccccccccccccccccccccccccccccc" + }, + "repository_id": "example.test/acme/agent", + "review_items": [ + { + "check_id": "SHIP-VERIFY-TRUST-ROOT-TOUCHED", + "fingerprint": null, + "paths": [ + "action.yml" + ], + "review_item_id": "review-1", + "support_hash": null + } + ], + "review_set_id": "sha256:49806f6f5d712fb4f7712cee6a57cf6946f3607eeb88b629c17ad0c93d27df73", + "schema_version": "shipgate.human_authorization_request/v1", + "source_artifact_set_id": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "source_engine_requirement_id": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "source_executor_id": "sha256:7777777777777777777777777777777777777777777777777777777777777777", + "source_head_commit_sha": "cccccccccccccccccccccccccccccccccccccccc", + "source_receipt_id": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "subject_id": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "verification_request_id": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "worktree_overlay_sha256": null + } + } +} diff --git a/docs/mcp-server.md b/docs/mcp-server.md index c2dfa561..f5014bee 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -31,7 +31,7 @@ Claude Code registration (`.mcp.json`): | `shipgate.preflight` | `{workspace?, config?, plan?, changed_files?, diff_text?, capability_request?, base_preflight?}` | exact `PreflightResultV3` | | `shipgate.explain` | `{check_id}` or `{fingerprint, report_path}` | deterministic check/finding explanation JSON | | `shipgate.capabilities` | `{config}` or `{base_lock, head_lock}` | capability lock or capability lock diff JSON | -| `shipgate.handoff` | `{verifier_path, report_path?, verify_run_path?}` | exact `shipgate.agent_handoff/v5` | +| `shipgate.handoff` | `{verifier_path, report_path?, verify_run_path?}` | exact `shipgate.agent_handoff/v6` | `shipgate.check` is the same protocol surface documented in [`agents/protocol.md`](agents/protocol.md). `shipgate.preflight` is proactive @@ -43,7 +43,7 @@ but it is not a second release verdict. The release gate remains `shipgate.handoff` is a read-only projection over existing verifier artifacts. It never runs `verify`, shells out to git, or writes `agent-handoff.json`; it -returns the same `shipgate.agent_handoff/v5` shape that `verify` writes for +returns the same `shipgate.agent_handoff/v6` shape that `verify` writes for agents that need a compact control/release-readiness object. ## Trust model diff --git a/docs/passed-verdict-contract.md b/docs/passed-verdict-contract.md index 5cc24d8a..28296323 100644 --- a/docs/passed-verdict-contract.md +++ b/docs/passed-verdict-contract.md @@ -1,6 +1,6 @@ # Evidence-backed `passed` verdict -In the Agents Shipgate `0.16.0b6` runtime (contract v17, report schema v0.34), +In the Agents Shipgate `0.16.0b6` runtime (contract v18, report schema v0.34), `release_decision.decision: passed` means the configured root agent and its complete reachable tool/handoff graph were statically proven, and every reachable capability has complete, conflict-free static identity, diff --git a/docs/report-reading-for-agents.md b/docs/report-reading-for-agents.md index a955ac06..1bd32c16 100644 --- a/docs/report-reading-for-agents.md +++ b/docs/report-reading-for-agents.md @@ -18,7 +18,7 @@ report = json.loads(open("agents-shipgate-reports/report.json").read()) gate = report["release_decision"]["decision"] # blocked | review_required | insufficient_evidence | passed ``` -The CLI's stable contract names this signal explicitly: run `agents-shipgate contract --json` and inspect `gating_signal` — it is always `release_decision.decision` in the current contract (see [`STABILITY.md`](../STABILITY.md) §"Runtime contract JSON"). +The CLI's stable contract names this signal explicitly: run `agents-shipgate contract --json` and inspect `gating_signal` — it is always `release_decision.decision` in runtime contract v18 (see [`STABILITY.md`](../STABILITY.md) §"Runtime contract JSON"). --- @@ -39,6 +39,12 @@ Precedence (highest first): `blocked` → `review_required` (active high/critica The decision is **baseline-aware**: a baseline-matched critical surfaces in `release_decision.review_items` (accepted debt), not in `release_decision.blockers`. Compare with the legacy `summary.status` field, which is *baseline-blind* — see Anti-patterns below. +Runtime contract v18 can attach an externally signed authorization to a new +verifier artifact and route one exact guarded operation, but it never rewrites +this report verdict. The report remains `review_required`; autonomous agents +must read `agent-handoff.json.control.state` to distinguish a human stop from +that narrowly authorized next action. + Before summarizing any verdict, preserve the machine boundary: `release_decision.static_analysis_only` is always `true`, `runtime_behavior_verified` is always `false`, and @@ -90,8 +96,9 @@ removing Shipgate CI; those are the bypass patterns the verifier checks are designed to make visible. `agents-shipgate verify` also writes -`agents-shipgate-reports/verifier.json`. Lead with `merge_verdict`, -`can_merge_without_human`, `control.next_action`, `fix_task`, and +`agents-shipgate-reports/verifier.json`. Lead with `control.state`, then read +`authorization`, `merge_verdict`, `can_merge_without_human`, +`control.next_action`, `fix_task`, and `capability_review.top_changes`; then confirm `report.json.release_decision.decision`, which remains the release gate. `merge_verdict` is a deterministic projection for controller flow, not a second @@ -146,7 +153,7 @@ Alongside `report.json`, scan emits a reviewer-shaped Release Evidence Packet at - §1 verdict — derives from `release_decision.decision` only. Never derive a verdict from `summary.status`. - §10 ("What this packet did NOT prove") — always lists prompt robustness, runtime behavior, model correctness, adversarial resistance. -The packet schema is `0.10`; full schema at [`docs/packet-schema.v0.10.json`](packet-schema.v0.10.json). It projects current report binding and semantic coverage plus gap remediation; v0.9 is a frozen reference. +The packet schema is `0.12`; full schema at [`docs/packet-schema.v0.12.json`](packet-schema.v0.12.json). It projects current report binding and semantic coverage plus gap remediation; v0.11 and older versions are frozen references. --- @@ -230,7 +237,7 @@ Surface the `next_action` to the user rather than scraping prose. The full diagn | Report | `0.34` | `0.33`, `0.32`, `0.31`, `0.30`, `0.29`, `0.28`, `0.27`, `0.26`, `0.25`, `0.24`, `0.23`, `0.22`, `0.21`, `0.20`, `0.19`, `0.18`, `0.17`, `0.16`, `0.15`, `0.14`, `0.13`, `0.12`, `0.11`, `0.10`, `0.9`, `0.8`, `0.7`, `0.6`, `0.5`, `0.4`, `0.3`, `0.2`, `0.1` | [`report-schema.v0.34.json`](report-schema.v0.34.json) | | Packet | `0.12` | `0.11`, `0.10`, `0.9`, `0.8`, `0.7`, `0.6`, `0.5`, `0.4`, `0.3`, `0.2`, `0.1` | [`packet-schema.v0.12.json`](packet-schema.v0.12.json) | | Manifest | `0.1` | — | [`manifest-v0.1.json`](manifest-v0.1.json) | -| CLI contract | `17` | — | `agents-shipgate contract --json` | +| CLI contract | `18` | — | `agents-shipgate contract --json` | To detect the version programmatically: diff --git a/docs/verification-plan-schema.v1.json b/docs/verification-plan-schema.v1.json index a8cc5669..df53a054 100644 --- a/docs/verification-plan-schema.v1.json +++ b/docs/verification-plan-schema.v1.json @@ -126,7 +126,7 @@ "base_commit_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -151,7 +151,7 @@ "base_tree_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -164,7 +164,7 @@ "head_commit_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -181,7 +181,7 @@ "head_tree_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -194,7 +194,7 @@ "merge_base_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -219,7 +219,7 @@ "source_head_commit_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { diff --git a/docs/verification-reproducibility.md b/docs/verification-reproducibility.md index 3ac70f24..01e4e285 100644 --- a/docs/verification-reproducibility.md +++ b/docs/verification-reproducibility.md @@ -115,6 +115,124 @@ boundary, not distributed evaluation, parallel speedup, or arbitrary sharding. Those claims require deterministic extraction/evaluation IR, partitioning, and cross-executor equivalence qualification that are not present in v1. +## Reviewed authorization overlay + +Runtime contract v18 can project one externally authorized coding-agent action +without changing the release decision. This is an overlay on a completed +verification graph, not an input that lets a worker or repository assert human +approval. + +The flow starts with a normal successful verification whose decision is +`review_required` and whose exact effective plugin mode is false. Run both +verification passes with `--no-plugins` when third-party plugins could be +enabled. The protected authorization executor rejects plugin-enabled plans +before engine validation and never loads third-party plugin or adapter entry +points. The consumer validates that run's terminal receipt and all referenced +hashes. A trusted host then builds an unsigned +`shipgate.human_authorization_request/v1` from the current `request_id`, +`subject_id`, `decision_id`, source receipt/artifact-set/engine/executor +identities, repository and tree identities, the complete +ordered review set and its `review_set_id`, and one typed operation. The host +must show that complete request to an authenticated human before signing it. +Agents Shipgate can build this deterministic unsigned challenge without +creating authority: + +```bash +agents-shipgate authorization request \ + --receipt agents-shipgate-reports/verification-receipt.json \ + --artifacts-root agents-shipgate-reports \ + --remote origin \ + --destination-ref refs/heads/ \ + --expected-lease-oid \ + --out agents-shipgate-reports/human-authorization-request.json +``` + +The signed `shipgate.human_authorization/v1` grant uses a domain-separated +Ed25519 detached signature. The private key and signing operation belong to the +trusted host or authenticator; Agents Shipgate ships neither a private key nor +a signing/approval CLI. The +`shipgate.human_authorization_trust_policy/v1` file must live outside the +evaluated workspace and be protected from coding-agent writes. On POSIX, the +loader uses the OS account home's fixed path +`~/.config/agents-shipgate/human-authorization-trust-policy.json`; it ignores +`HOME` and `XDG_CONFIG_HOME` for trust-policy selection. A repository file, PR +comment, environment assertion, or conversation-level acknowledgement cannot +become a trust anchor merely by matching the schema. + +Content addressing proves internal byte closure, not who produced the +receipt. Before signing, the host must either rerun verification in its own +trusted worker or verify a trusted-CI attestation over the request's +`source_receipt_id` and `source_artifact_set_id` (and the bound engine and +executor). An agent-generated self-consistent receipt is not provenance. +The request also exposes the evaluated base commit and merge base. The exact +source commit transitively binds all parents and reachable objects, so the +signer must review the complete ancestry—not just the final tree diff. The +executor serializes that full graph with a 512 MiB ceiling and 120-second +timeout; a production broker should enforce tighter disk, memory, and CPU +quotas. The compressed pack ceiling does not bound expanded-object indexing +memory or CPU, so the broker should use a cgroup, container, or equivalent host +quota. + +The signed bytes are exactly the ASCII domain +`shipgate.human_authorization/v1`, one NUL byte, then the UTF-8 encoding of the +statement JSON serialized with sorted keys, no insignificant whitespace, and +non-ASCII characters preserved. Public keys are 32 raw Ed25519 bytes and +signatures are 64 raw bytes, both encoded as canonical unpadded base64url; +`key_id` is `sha256:` over the raw public key. The checked-in +[`human-authorization-signature-v1.json`](human-authorization-signature-v1.json) +is the cross-implementation conformance vector. + +The grant is consumed by a second run: + +```bash +agents-shipgate verify --workspace . --config shipgate.yaml \ + --base origin/main --head HEAD --ci-mode advisory --format json \ + --no-plugins \ + --authorization /path/outside/workspace/human-authorization.json +``` + +That run recomputes the verification plan, unit, release decision, trees, and +complete review set before validating the external signature and its scope and +TTL. This ordering avoids a circular request identity: the grant is not added +to the verification input set whose `request_id` it signs. When accepted, the +grant and `shipgate.human_authorization_evaluation/v1` projection are added to +the final artifact closure, so the newly written terminal receipt binds the +authorization result and exact command. + +Authorization v1 permits only one typed Git-push operation. It binds the exact +commit whose tree was evaluated, a canonical credential-free HTTPS destination +whose repository identity matches the verified repository, one full +destination ref, and one expected remote OID. Synthetic PR merge verification +is not eligible to authorize a different source parent; verify the actual PR +head commit separately. Acceptance requires a +successful `review_required` verification. It changes only operational +control: `control.state` becomes `agent_action_required` for that sole command. +The static decision remains `review_required`, `merge_verdict` remains +`human_review_required`, `can_merge_without_human` remains false, and +`completion_allowed` remains false. After the operation, the agent must rerun +verification; the authorization does not itself establish completion or +mergeability. The exposed command is the guarded +`agents-shipgate authorization execute` consumer, not the underlying Git +command. It revalidates the receipt, signature, current external trust policy, +clock, engine, repository, and commit at execution time. Receipt artifacts are +opened without following symlinks and parsed from one immutable validated byte +snapshot. The executor copies the reachable commit graph into a temporary bare +store, validates object IDs and connectivity, and disables Git replacement +objects, hooks, local/global/system configuration, and HTTP redirects before +issuing the signed force-with-lease operation. + +An invalid signature, unknown key, inaccessible or workspace-local trust +policy, expired/not-yet-valid window (including expiry after verification but +before execution), repository mismatch, changed request, +subject, tree, decision, review item, destination ref, source commit, or lease +OID fails closed. A non-accepted evaluation exposes no command and produces +zero allowed commands. The verifier does not query the remote; replay resistance +for a ref that moved after approval comes from Git enforcing the exact signed +`--force-with-lease=:` argument. The protocol does not itself +authenticate a Codex or Claude Code UI user: a separately implemented host +signing adapter must perform that authentication and keep the signing key +outside the agent's authority. + ## Time and GitHub Actions The plan's `evaluation_date` is content-bound provenance, derived from the @@ -133,9 +251,10 @@ assembled at its evaluation; it does not renew expired human consent. Workers do not evaluate these expiries and their local clocks cannot weaken the main verifier's result. -The GitHub Action evaluates `${{ github.sha }}` by default and separately -records `${{ github.event.pull_request.head.sha }}` when present. This prevents -the source PR head from being confused with GitHub's synthetic merge commit. +The GitHub Action evaluates `${{ github.sha }}` by default. A default +`pull_request` synthetic-merge receipt deliberately carries no executable +source authority; verify `${{ github.event.pull_request.head.sha }}` as the +explicit head in a separate run before requesting push authorization. The Action exports `request_id`, `receipt_id`, `decision_id`, `artifact_set_id`, and `verification_receipt_json` only after validating the receipt and all referenced hashes. @@ -147,7 +266,19 @@ graph around a deterministic static evaluation. It does not independently prove that an executor performed the bound evaluation, runtime agent behavior, remote service state, credential enforcement, build provenance, or that an executor is trustworthy merely because it self-reports a matching descriptor. -Signatures, trusted execution, and CI identity remain separate distribution -controls. Legacy verify-run, handoff, attestation, registry, and organization -artifacts remain readable, but only current artifacts carrying the receipt -graph qualify as reproducible verification evidence. +The v1 human-authorization signature proves only the exact operational grant +described above. It records source receipt, artifact-set, engine, and executor +IDs, but the signer must authenticate those IDs through trusted CI or its own +rerun. Trusted execution and CI identity remain separate distribution +controls. The host must protect the trust policy, key, sanitized launcher +environment, interpreter, entire virtual environment and `site-packages` +(including startup `.pth` files), dependencies, credentials, and separately +installed distribution from the coding agent. Same-UID modes are not isolation, +and editable installs rooted in +the authorized workspace are ineligible. Without that broker boundary no +authorization command is safe to expose. The guarded executor is POSIX-only in +v1 and authorization remains disabled on Windows. V1 authorizes only the exact push; +reviewed patch/apply operations require a future typed operation. Legacy +verify-run, handoff, attestation, registry, and +organization artifacts remain readable, but only current artifacts carrying +the receipt graph qualify as reproducible verification evidence. diff --git a/docs/verifier-schema.v0.6.json b/docs/verifier-schema.v0.6.json new file mode 100644 index 00000000..d506442f --- /dev/null +++ b/docs/verifier-schema.v0.6.json @@ -0,0 +1,2739 @@ +{ + "$defs": { + "AgentActionRequiredControl": { + "additionalProperties": false, + "description": "Non-terminal state with one exact coding-agent-owned next step.", + "properties": { + "allowed_next_commands": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Allowed Next Commands", + "type": "array" + }, + "completion_allowed": { + "const": false, + "default": false, + "title": "Completion Allowed", + "type": "boolean" + }, + "human_review": { + "$ref": "#/$defs/NoHumanReview" + }, + "must_stop": { + "const": false, + "default": false, + "title": "Must Stop", + "type": "boolean" + }, + "next_action": { + "$ref": "#/$defs/CodingAgentAction" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "agent_action_required", + "title": "State", + "type": "string" + }, + "stop_reason": { + "default": null, + "title": "Stop Reason", + "type": "null" + }, + "verify_required": { + "default": false, + "title": "Verify Required", + "type": "boolean" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop", + "verify_required", + "next_action", + "allowed_next_commands", + "human_review", + "stop_reason" + ], + "title": "AgentActionRequiredControl", + "type": "object" + }, + "AgentControl": { + "discriminator": { + "mapping": { + "agent_action_required": "#/$defs/AgentActionRequiredControl", + "complete": "#/$defs/CompleteAgentControl", + "human_review_required": "#/$defs/HumanReviewRequiredControl" + }, + "propertyName": "state" + }, + "oneOf": [ + { + "$ref": "#/$defs/CompleteAgentControl" + }, + { + "$ref": "#/$defs/AgentActionRequiredControl" + }, + { + "$ref": "#/$defs/HumanReviewRequiredControl" + } + ] + }, + "AuthorizationEvaluationV1": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "accepted" + } + } + }, + "then": { + "properties": { + "authorization_id": { + "not": { + "type": "null" + } + }, + "authorization_request_id": { + "not": { + "type": "null" + } + }, + "command": { + "not": { + "type": "null" + } + }, + "expires_at": { + "not": { + "type": "null" + } + }, + "issued_at": { + "not": { + "type": "null" + } + }, + "key_id": { + "not": { + "type": "null" + } + }, + "operation_id": { + "not": { + "type": "null" + } + }, + "principal": { + "not": { + "type": "null" + } + }, + "provider": { + "not": { + "type": "null" + } + }, + "reason_codes": { + "maxItems": 0 + }, + "trust_policy_id": { + "not": { + "type": "null" + } + } + }, + "required": [ + "authorization_id", + "authorization_request_id", + "trust_policy_id", + "key_id", + "provider", + "principal", + "operation_id", + "command", + "issued_at", + "expires_at" + ] + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "rejected", + "not_requested", + "not_applicable" + ] + } + } + }, + "then": { + "properties": { + "command": { + "type": "null" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "rejected" + } + } + }, + "then": { + "properties": { + "reason_codes": { + "minItems": 1 + } + }, + "required": [ + "reason_codes" + ] + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "not_requested", + "not_applicable" + ] + } + } + }, + "then": { + "properties": { + "authorization_id": { + "type": "null" + }, + "authorization_request_id": { + "type": "null" + }, + "command": { + "type": "null" + }, + "expires_at": { + "type": "null" + }, + "issued_at": { + "type": "null" + }, + "key_id": { + "type": "null" + }, + "operation_id": { + "type": "null" + }, + "principal": { + "type": "null" + }, + "provider": { + "type": "null" + }, + "trust_policy_id": { + "type": "null" + } + } + } + } + ], + "description": "Fail-closed authorization evaluation consumed by verifier projections.", + "properties": { + "authorization_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authorization Id" + }, + "authorization_request_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authorization Request Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "issued_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Issued At" + }, + "key_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Id" + }, + "operation_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation Id" + }, + "principal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "reason_codes": { + "items": { + "type": "string" + }, + "title": "Reason Codes", + "type": "array", + "uniqueItems": true + }, + "schema_version": { + "const": "shipgate.human_authorization_evaluation/v1", + "default": "shipgate.human_authorization_evaluation/v1", + "title": "Schema Version", + "type": "string" + }, + "status": { + "enum": [ + "not_requested", + "accepted", + "rejected", + "not_applicable" + ], + "title": "Status", + "type": "string" + }, + "trust_policy_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trust Policy Id" + } + }, + "required": [ + "status" + ], + "title": "AuthorizationEvaluationV1", + "type": "object" + }, + "BaselineDelta": { + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "matched_count": { + "default": 0, + "title": "Matched Count", + "type": "integer" + }, + "new_count": { + "default": 0, + "title": "New Count", + "type": "integer" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "resolved_count": { + "default": 0, + "title": "Resolved Count", + "type": "integer" + } + }, + "required": [ + "enabled" + ], + "title": "BaselineDelta", + "type": "object" + }, + "BindingCoverageDecision": { + "properties": { + "gap_count": { + "default": 0, + "title": "Gap Count", + "type": "integer" + }, + "pass_eligible": { + "default": false, + "title": "Pass Eligible", + "type": "boolean" + }, + "possible_tools": { + "default": 0, + "title": "Possible Tools", + "type": "integer" + }, + "reachable_tools": { + "default": 0, + "title": "Reachable Tools", + "type": "integer" + }, + "reason_counts": { + "additionalProperties": { + "type": "integer" + }, + "title": "Reason Counts", + "type": "object" + }, + "total_catalog_tools": { + "default": 0, + "title": "Total Catalog Tools", + "type": "integer" + }, + "unbound_tools": { + "default": 0, + "title": "Unbound Tools", + "type": "integer" + } + }, + "title": "BindingCoverageDecision", + "type": "object" + }, + "CodingAgentAction": { + "discriminator": { + "mapping": { + "configure": "#/$defs/CodingAgentCommandAction", + "discover": "#/$defs/CodingAgentCommandAction", + "fetch_base": "#/$defs/CodingAgentFetchBaseAction", + "initialize": "#/$defs/CodingAgentCommandAction", + "install": "#/$defs/CodingAgentCommandAction", + "repair": "#/$defs/CodingAgentCommandAction", + "rerun": "#/$defs/CodingAgentCommandAction", + "verify": "#/$defs/CodingAgentCommandAction" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/CodingAgentCommandAction" + }, + { + "$ref": "#/$defs/CodingAgentFetchBaseAction" + } + ] + }, + "CodingAgentCommandAction": { + "additionalProperties": false, + "description": "An executable, exact next step owned by the coding agent.", + "properties": { + "actor": { + "const": "coding_agent", + "default": "coding_agent", + "title": "Actor", + "type": "string" + }, + "command": { + "minLength": 1, + "title": "Command", + "type": "string" + }, + "expects": { + "default": null, + "title": "Expects", + "type": "null" + }, + "kind": { + "enum": [ + "verify", + "discover", + "configure", + "initialize", + "repair", + "install", + "rerun" + ], + "title": "Kind", + "type": "string" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "actor", + "kind", + "command", + "expects", + "why" + ], + "title": "CodingAgentCommandAction", + "type": "object" + }, + "CodingAgentFetchBaseAction": { + "additionalProperties": false, + "description": "A structured input request when an exact fetch command is unavailable.\n\nShipgate never fetches refs itself. ``expects`` therefore names the exact\nref or artifact a caller must make available before rerunning verification.", + "properties": { + "actor": { + "const": "coding_agent", + "default": "coding_agent", + "title": "Actor", + "type": "string" + }, + "command": { + "default": null, + "title": "Command", + "type": "null" + }, + "expects": { + "minLength": 1, + "title": "Expects", + "type": "string" + }, + "kind": { + "const": "fetch_base", + "title": "Kind", + "type": "string" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "actor", + "kind", + "command", + "expects", + "why" + ], + "title": "CodingAgentFetchBaseAction", + "type": "object" + }, + "CompleteAgentControl": { + "additionalProperties": false, + "description": "Terminal state: the coding agent may report the task complete.", + "properties": { + "allowed_next_commands": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 0, + "title": "Allowed Next Commands", + "type": "array" + }, + "completion_allowed": { + "const": true, + "default": true, + "title": "Completion Allowed", + "type": "boolean" + }, + "human_review": { + "$ref": "#/$defs/NoHumanReview" + }, + "must_stop": { + "const": false, + "default": false, + "title": "Must Stop", + "type": "boolean" + }, + "next_action": { + "default": null, + "title": "Next Action", + "type": "null" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "complete", + "title": "State", + "type": "string" + }, + "stop_reason": { + "default": null, + "title": "Stop Reason", + "type": "null" + }, + "verify_required": { + "const": false, + "default": false, + "title": "Verify Required", + "type": "boolean" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop", + "verify_required", + "next_action", + "allowed_next_commands", + "human_review", + "stop_reason" + ], + "title": "CompleteAgentControl", + "type": "object" + }, + "ContributionRule": { + "additionalProperties": false, + "description": "Per-finding audit row explaining how a finding contributed to the\nrelease decision.\n\nAdditive in v0.17. Every finding in `report.findings` produces\nexactly one ContributionRule. Reading the contribution rule is\nsufficient to predict the gate outcome for that finding without\nre-deriving the decision logic; the set of valid `(rule, category)`\npairs is the contract documented in STABILITY.md \"Release decision\ntruth table\".", + "properties": { + "category": { + "enum": [ + "blocker", + "review_item", + "excluded" + ], + "title": "Category", + "type": "string" + }, + "check_id": { + "title": "Check Id", + "type": "string" + }, + "finding_id": { + "title": "Finding Id", + "type": "string" + }, + "fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fingerprint" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "rule": { + "enum": [ + "policy_block_new", + "severity_block_new", + "policy_baseline_accepted", + "severity_baseline_accepted", + "review_required", + "sub_threshold", + "unsupported_evidence", + "suppressed" + ], + "title": "Rule", + "type": "string" + } + }, + "required": [ + "finding_id", + "check_id", + "category", + "rule", + "rationale" + ], + "title": "ContributionRule", + "type": "object" + }, + "EvidenceCoverageDecision": { + "properties": { + "binding_coverage": { + "$ref": "#/$defs/BindingCoverageDecision" + }, + "evidence_gaps": { + "items": { + "$ref": "#/$defs/EvidenceGap" + }, + "title": "Evidence Gaps", + "type": "array" + }, + "human_review_recommended": { + "title": "Human Review Recommended", + "type": "boolean" + }, + "identity_coverage": { + "$ref": "#/$defs/IdentityCoverageDecision" + }, + "level": { + "title": "Level", + "type": "string" + }, + "low_confidence_tool_count": { + "title": "Low Confidence Tool Count", + "type": "integer" + }, + "policy_gap_count": { + "default": 0, + "title": "Policy Gap Count", + "type": "integer" + }, + "semantic_coverage": { + "$ref": "#/$defs/SemanticCoverageDecision" + }, + "source_warning_count": { + "title": "Source Warning Count", + "type": "integer" + } + }, + "required": [ + "level", + "human_review_recommended", + "source_warning_count", + "low_confidence_tool_count" + ], + "title": "EvidenceCoverageDecision", + "type": "object" + }, + "EvidenceGap": { + "description": "v0.26: one structured row per measurable evidence gap.\n\n``insufficient_evidence`` previously diagnosed without prescribing;\neach gap names the degraded subject and the specific next action\nthat raises extraction confidence. Purely explanatory \u2014 gating\nstill uses only the counts (the gap list is a projection of them).", + "properties": { + "kind": { + "enum": [ + "low_confidence_tool", + "source_warning", + "incomplete_surface", + "missing_effect_evidence", + "inferred_effect_only", + "conflicting_effect_evidence", + "missing_authority_evidence", + "partial_authority_evidence", + "conflicting_authority_evidence", + "invalid_semantic_annotation", + "incomplete_tool_identity", + "conflicting_tool_identity", + "unresolved_tool_selector", + "ambiguous_tool_selector", + "ambiguous_legacy_tool_identity", + "invalid_tool_binding", + "missing_binding_evidence", + "partial_binding_evidence", + "conflicting_binding_evidence", + "ambiguous_root_agent", + "unresolved_agent_binding", + "unresolved_bound_tool", + "incomplete_handoff_graph", + "invalid_binding_annotation", + "invalid_evidence_provenance", + "inferred_policy_applicability", + "mixed_policy_evidence", + "unknown_policy_evidence", + "conflicting_policy_evidence" + ], + "title": "Kind", + "type": "string" + }, + "next_action": { + "$ref": "#/$defs/EvidenceGapAction" + }, + "source_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Ref" + }, + "source_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Type" + }, + "subject": { + "title": "Subject", + "type": "string" + }, + "why": { + "title": "Why", + "type": "string" + } + }, + "required": [ + "kind", + "subject", + "why", + "next_action" + ], + "title": "EvidenceGap", + "type": "object" + }, + "EvidenceGapAction": { + "description": "One concrete, mechanically-executable step that closes a gap.\n\nMirrors the agent-mode ``next_actions[]`` error shape\n(``kind``/``command``/``path``/``why``/``expects``) so agents reuse\none routing vocabulary across error recovery and evidence repair.", + "properties": { + "accepted_values": { + "items": { + "type": "string" + }, + "title": "Accepted Values", + "type": "array" + }, + "auto_apply": { + "const": false, + "default": false, + "title": "Auto Apply", + "type": "boolean" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "declaration_template": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Declaration Template" + }, + "expects": { + "title": "Expects", + "type": "string" + }, + "kind": { + "enum": [ + "declare_tool_inventory", + "provide_source", + "review_warning", + "declare_action_effect", + "declare_action_authority", + "provide_complete_inventory", + "resolve_semantic_conflict", + "declare_source_identity", + "qualify_tool_selector", + "provide_tool_binding", + "resolve_tool_identity_conflict", + "regenerate_identity_artifact", + "declare_agent_root", + "declare_agent_bindings", + "provide_static_binding_source", + "provide_complete_binding_graph", + "resolve_binding_conflict", + "regenerate_binding_artifact", + "provide_policy_evidence", + "review_policy_evidence", + "resolve_policy_evidence_conflict" + ], + "title": "Kind", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "requires_human_review": { + "const": true, + "default": true, + "title": "Requires Human Review", + "type": "boolean" + }, + "suggested_patch_kind": { + "const": "manual", + "default": "manual", + "title": "Suggested Patch Kind", + "type": "string" + }, + "why": { + "title": "Why", + "type": "string" + } + }, + "required": [ + "kind", + "why", + "expects" + ], + "title": "EvidenceGapAction", + "type": "object" + }, + "FailPolicy": { + "properties": { + "ci_mode": { + "title": "Ci Mode", + "type": "string" + }, + "exit_code": { + "title": "Exit Code", + "type": "integer" + }, + "fail_on": { + "items": { + "enum": [ + "info", + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "title": "Fail On", + "type": "array" + }, + "new_findings_only": { + "default": false, + "title": "New Findings Only", + "type": "boolean" + }, + "would_fail_ci": { + "title": "Would Fail Ci", + "type": "boolean" + } + }, + "required": [ + "ci_mode", + "would_fail_ci", + "exit_code" + ], + "title": "FailPolicy", + "type": "object" + }, + "FindingSupport": { + "additionalProperties": false, + "description": "Authoritative support for finding confidence and release contribution.\n\nRule metadata may request a severity or block, but it cannot upgrade the\nunderlying evidence. ``support_hash`` binds baselines and audit surfaces\nto the predicate evidence that actually made the finding eligible.", + "properties": { + "blocking_eligible": { + "default": false, + "title": "Blocking Eligible", + "type": "boolean" + }, + "claim_ids": { + "items": { + "type": "string" + }, + "title": "Claim Ids", + "type": "array" + }, + "confidence": { + "default": "low", + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "evidence_bases": { + "items": { + "enum": [ + "reviewed_declaration", + "protocol_structure", + "typed_provider_fact", + "structural_scope", + "inferred_keyword", + "inferred_regex", + "protocol_default", + "unknown" + ], + "type": "string" + }, + "title": "Evidence Bases", + "type": "array" + }, + "policy_eligible": { + "default": false, + "title": "Policy Eligible", + "type": "boolean" + }, + "predicates": { + "items": { + "$ref": "#/$defs/PolicyPredicateEvidence" + }, + "title": "Predicates", + "type": "array" + }, + "status": { + "default": "matched", + "enum": [ + "matched", + "not_matched", + "indeterminate", + "conflicting" + ], + "title": "Status", + "type": "string" + }, + "support_hash": { + "title": "Support Hash", + "type": "string" + } + }, + "required": [ + "support_hash" + ], + "title": "FindingSupport", + "type": "object" + }, + "HumanControlAction": { + "additionalProperties": false, + "description": "A human-owned route. Human actions never expose executable commands.", + "properties": { + "actor": { + "const": "human", + "default": "human", + "title": "Actor", + "type": "string" + }, + "command": { + "default": null, + "title": "Command", + "type": "null" + }, + "expects": { + "default": null, + "title": "Expects", + "type": "null" + }, + "kind": { + "enum": [ + "review", + "stop" + ], + "title": "Kind", + "type": "string" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "actor", + "kind", + "command", + "expects", + "why" + ], + "title": "HumanControlAction", + "type": "object" + }, + "HumanReviewRequiredControl": { + "additionalProperties": false, + "description": "Stopping state: no further coding-agent action is authorized.", + "properties": { + "allowed_next_commands": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 0, + "title": "Allowed Next Commands", + "type": "array" + }, + "completion_allowed": { + "const": false, + "default": false, + "title": "Completion Allowed", + "type": "boolean" + }, + "human_review": { + "$ref": "#/$defs/RequiredHumanReview" + }, + "must_stop": { + "const": true, + "default": true, + "title": "Must Stop", + "type": "boolean" + }, + "next_action": { + "$ref": "#/$defs/HumanControlAction" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "state": { + "const": "human_review_required", + "title": "State", + "type": "string" + }, + "stop_reason": { + "minLength": 1, + "title": "Stop Reason", + "type": "string" + }, + "verify_required": { + "default": false, + "title": "Verify Required", + "type": "boolean" + } + }, + "required": [ + "state", + "reason", + "completion_allowed", + "must_stop", + "verify_required", + "next_action", + "allowed_next_commands", + "human_review", + "stop_reason" + ], + "title": "HumanReviewRequiredControl", + "type": "object" + }, + "IdentityCoverageDecision": { + "properties": { + "ambiguous_name_count": { + "default": 0, + "title": "Ambiguous Name Count", + "type": "integer" + }, + "bound_tools": { + "default": 0, + "title": "Bound Tools", + "type": "integer" + }, + "canonical_tools": { + "default": 0, + "title": "Canonical Tools", + "type": "integer" + }, + "gap_count": { + "default": 0, + "title": "Gap Count", + "type": "integer" + }, + "pass_eligible_tools": { + "default": 0, + "title": "Pass Eligible Tools", + "type": "integer" + }, + "reason_counts": { + "additionalProperties": { + "type": "integer" + }, + "title": "Reason Counts", + "type": "object" + }, + "total_observations": { + "default": 0, + "title": "Total Observations", + "type": "integer" + } + }, + "title": "IdentityCoverageDecision", + "type": "object" + }, + "NoHumanReview": { + "additionalProperties": false, + "description": "Exact negative human-review projection for non-stopping states.", + "properties": { + "required": { + "const": false, + "default": false, + "title": "Required", + "type": "boolean" + }, + "required_reviewers": { + "items": { + "type": "string" + }, + "maxItems": 0, + "title": "Required Reviewers", + "type": "array" + }, + "why": { + "default": null, + "title": "Why", + "type": "null" + } + }, + "required": [ + "required", + "why", + "required_reviewers" + ], + "title": "NoHumanReview", + "type": "object" + }, + "PolicyPredicateEvidence": { + "additionalProperties": false, + "description": "One tri-state policy predicate and the evidence that supports it.", + "properties": { + "claim_ids": { + "items": { + "type": "string" + }, + "title": "Claim Ids", + "type": "array" + }, + "confidence": { + "default": "low", + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "evidence_bases": { + "items": { + "enum": [ + "reviewed_declaration", + "protocol_structure", + "typed_provider_fact", + "structural_scope", + "inferred_keyword", + "inferred_regex", + "protocol_default", + "unknown" + ], + "type": "string" + }, + "title": "Evidence Bases", + "type": "array" + }, + "expected": { + "default": null, + "title": "Expected" + }, + "observed": { + "default": null, + "title": "Observed" + }, + "policy_eligible": { + "default": false, + "title": "Policy Eligible", + "type": "boolean" + }, + "predicate": { + "title": "Predicate", + "type": "string" + }, + "status": { + "enum": [ + "matched", + "not_matched", + "indeterminate", + "conflicting" + ], + "title": "Status", + "type": "string" + }, + "why": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Why" + } + }, + "required": [ + "predicate", + "status" + ], + "title": "PolicyPredicateEvidence", + "type": "object" + }, + "ReleaseDecision": { + "properties": { + "baseline_delta": { + "$ref": "#/$defs/BaselineDelta" + }, + "blockers": { + "items": { + "$ref": "#/$defs/ReleaseDecisionItem" + }, + "title": "Blockers", + "type": "array" + }, + "contribution_rules": { + "items": { + "$ref": "#/$defs/ContributionRule" + }, + "title": "Contribution Rules", + "type": "array" + }, + "decision": { + "enum": [ + "blocked", + "review_required", + "insufficient_evidence", + "passed" + ], + "title": "Decision", + "type": "string" + }, + "evidence_coverage": { + "$ref": "#/$defs/EvidenceCoverageDecision" + }, + "fail_policy": { + "$ref": "#/$defs/FailPolicy" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "review_items": { + "items": { + "$ref": "#/$defs/ReleaseDecisionItem" + }, + "title": "Review Items", + "type": "array" + }, + "runtime_behavior_verified": { + "const": false, + "default": false, + "title": "Runtime Behavior Verified", + "type": "boolean" + }, + "static_analysis_only": { + "const": true, + "default": true, + "title": "Static Analysis Only", + "type": "boolean" + }, + "static_verdict_disclaimer": { + "default": "This verdict covers deterministic static evidence only. Agents Shipgate did not execute the agent or prove runtime behavior, tool routing, credential enforcement, or safety.", + "title": "Static Verdict Disclaimer", + "type": "string" + } + }, + "required": [ + "decision", + "reason", + "evidence_coverage", + "baseline_delta", + "fail_policy" + ], + "title": "ReleaseDecision", + "type": "object" + }, + "ReleaseDecisionItem": { + "properties": { + "baseline_status": { + "anyOf": [ + { + "enum": [ + "new", + "matched", + "resolved" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline Status" + }, + "blocks_release": { + "default": false, + "title": "Blocks Release", + "type": "boolean" + }, + "capability_refs": { + "items": { + "type": "string" + }, + "title": "Capability Refs", + "type": "array" + }, + "capability_trace_refs": { + "items": { + "type": "string" + }, + "title": "Capability Trace Refs", + "type": "array" + }, + "check_id": { + "title": "Check Id", + "type": "string" + }, + "fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fingerprint" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "policy_evidence_source": { + "anyOf": [ + { + "$ref": "#/$defs/SourceReference" + }, + { + "type": "null" + } + ], + "default": null + }, + "severity": { + "enum": [ + "info", + "low", + "medium", + "high", + "critical" + ], + "title": "Severity", + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/SourceReference" + }, + { + "type": "null" + } + ], + "default": null + }, + "support": { + "anyOf": [ + { + "$ref": "#/$defs/FindingSupport" + }, + { + "type": "null" + } + ], + "default": null + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "check_id", + "severity", + "title" + ], + "title": "ReleaseDecisionItem", + "type": "object" + }, + "RequiredHumanReview": { + "additionalProperties": false, + "description": "Human-review evidence carried by the stopping state.", + "properties": { + "required": { + "const": true, + "default": true, + "title": "Required", + "type": "boolean" + }, + "required_reviewers": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Required Reviewers", + "type": "array" + }, + "why": { + "minLength": 1, + "title": "Why", + "type": "string" + } + }, + "required": [ + "required", + "why", + "required_reviewers" + ], + "title": "RequiredHumanReview", + "type": "object" + }, + "SemanticCoverageDecision": { + "description": "v0.29 pass eligibility across the normalized action surface.\n\nUnlike extraction-confidence thresholds, semantic gaps are\nzero-tolerance: any non-pass-eligible unknown/partial/conflicting\ndimension prevents ``passed``. Known authority review concerns (for\nexample ambient or unscoped credentials) are counted separately so\nthey deterministically route to ``review_required`` rather than\n``insufficient_evidence``.", + "properties": { + "gap_count": { + "default": 0, + "title": "Gap Count", + "type": "integer" + }, + "pass_eligible_actions": { + "default": 0, + "title": "Pass Eligible Actions", + "type": "integer" + }, + "reason_counts": { + "additionalProperties": { + "type": "integer" + }, + "title": "Reason Counts", + "type": "object" + }, + "review_concern_count": { + "default": 0, + "title": "Review Concern Count", + "type": "integer" + }, + "total_actions": { + "default": 0, + "title": "Total Actions", + "type": "integer" + } + }, + "title": "SemanticCoverageDecision", + "type": "object" + }, + "SourceReference": { + "additionalProperties": true, + "properties": { + "end_line": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "End Line" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Location" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "pointer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pointer" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref" + }, + "start_column": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Start Column" + }, + "start_line": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Start Line" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SourceReference", + "type": "object" + }, + "VerifierCapabilityChange": { + "additionalProperties": false, + "description": "One reviewer-facing capability change projected for verifier output.", + "properties": { + "change_bucket": { + "enum": [ + "added", + "modified", + "removed" + ], + "title": "Change Bucket", + "type": "string" + }, + "change_type": { + "title": "Change Type", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "impact": { + "default": "informational", + "enum": [ + "blocks_release", + "review_required", + "insufficient_evidence", + "informational", + "none" + ], + "title": "Impact", + "type": "string" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "related_finding_ids": { + "items": { + "type": "string" + }, + "title": "Related Finding Ids", + "type": "array" + }, + "source_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Path" + }, + "source_start_line": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Start Line" + }, + "subject": { + "title": "Subject", + "type": "string" + }, + "subject_kind": { + "title": "Subject Kind", + "type": "string" + } + }, + "required": [ + "id", + "change_type", + "change_bucket", + "subject_kind", + "subject", + "rationale" + ], + "title": "VerifierCapabilityChange", + "type": "object" + }, + "VerifierCapabilityReview": { + "additionalProperties": false, + "description": "Derived capability-review rollup for PR comments and Action outputs.\n\nThis is a projection only. It never gates independently of\n``report.json.release_decision.decision``.", + "properties": { + "added": { + "default": 0, + "title": "Added", + "type": "integer" + }, + "modified": { + "default": 0, + "title": "Modified", + "type": "integer" + }, + "notes": { + "items": { + "type": "string" + }, + "title": "Notes", + "type": "array" + }, + "policy_weakened": { + "default": false, + "title": "Policy Weakened", + "type": "boolean" + }, + "removed": { + "default": 0, + "title": "Removed", + "type": "integer" + }, + "top_changes": { + "items": { + "$ref": "#/$defs/VerifierCapabilityChange" + }, + "title": "Top Changes", + "type": "array" + }, + "trust_root_touched": { + "default": false, + "title": "Trust Root Touched", + "type": "boolean" + } + }, + "title": "VerifierCapabilityReview", + "type": "object" + }, + "VerifierFixTask": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "actor": { + "const": "human" + } + }, + "required": [ + "actor" + ] + }, + "then": { + "properties": { + "safe_to_attempt": { + "const": false + } + } + } + }, + { + "if": { + "properties": { + "actor": { + "const": "coding_agent" + }, + "safe_to_attempt": { + "const": true + } + }, + "required": [ + "actor", + "safe_to_attempt" + ] + }, + "then": { + "properties": { + "verification_command": { + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "verification_command" + ] + } + } + ], + "description": "The single repair task a verify run hands to whoever acts next.\n\nRouting is deterministic and projected from the head scan \u2014 never an LLM\njudgment. ``coding_agent`` + ``safe_to_attempt=True`` means the gating\ngaps are mechanical (every gating finding is ``autofix_safe``): the agent\nmay fix them and re-run ``verification_command``. ``human`` +\n``safe_to_attempt=False`` means an authority gap a coding agent must not\ninvent its way past \u2014 missing approval/idempotency evidence, a weakened\npolicy, or a touched trust root. ``forbidden_shortcuts`` are the\nreward-hacking moves that are never acceptable for either actor.\n``patches`` (v0.12+) carries the machine-applicable suggested patches for\nthe gating findings when verify ran with ``--suggest-patches`` and the\ntask routes to the coding agent.", + "properties": { + "actor": { + "enum": [ + "coding_agent", + "human" + ], + "title": "Actor", + "type": "string" + }, + "allowed_repairs": { + "items": { + "$ref": "#/$defs/VerifierRepair" + }, + "title": "Allowed Repairs", + "type": "array" + }, + "forbidden_repairs": { + "items": { + "$ref": "#/$defs/VerifierRepair" + }, + "title": "Forbidden Repairs", + "type": "array" + }, + "forbidden_shortcuts": { + "items": { + "type": "string" + }, + "title": "Forbidden Shortcuts", + "type": "array" + }, + "instructions": { + "items": { + "type": "string" + }, + "title": "Instructions", + "type": "array" + }, + "patches": { + "items": { + "$ref": "#/$defs/VerifierFixTaskPatch" + }, + "title": "Patches", + "type": "array" + }, + "safe_to_attempt": { + "title": "Safe To Attempt", + "type": "boolean" + }, + "verification_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Verification Command" + } + }, + "required": [ + "actor", + "safe_to_attempt" + ], + "title": "VerifierFixTask", + "type": "object" + }, + "VerifierFixTaskPatch": { + "additionalProperties": false, + "description": "A machine-applicable patch projected into the fix task.\n\nRepair aid only \u2014 never a gate input. ``patch`` carries the\ndiscriminated Patch payload (``set_pointer`` / ``append_pointer`` /\n``remove_pointer``) exactly as the head scan emitted it; ``manual``\npatches are intentionally excluded because their guidance already\nappears in ``instructions``.", + "properties": { + "check_id": { + "default": "", + "title": "Check Id", + "type": "string" + }, + "finding_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finding Id" + }, + "patch": { + "additionalProperties": true, + "title": "Patch", + "type": "object" + } + }, + "title": "VerifierFixTaskPatch", + "type": "object" + }, + "VerifierRepair": { + "additionalProperties": false, + "description": "One deterministic repair affordance or prohibition.\n\nThe verifier owns the actor and safety boundary. These rows are not model\nsuggestions: they are a structured projection of remediation metadata and\ntrust-root rules so coding agents can distinguish mechanical fixes from\nhuman-only authority decisions.", + "properties": { + "actor": { + "enum": [ + "coding_agent", + "human" + ], + "title": "Actor", + "type": "string" + }, + "check_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Check Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "finding_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finding Id" + }, + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + } + }, + "required": [ + "id", + "actor", + "kind", + "reason" + ], + "title": "VerifierRepair", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verifier-schema.v0.6.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "decision": { + "const": "passed" + } + }, + "required": [ + "decision" + ] + }, + "then": { + "properties": { + "applicability": { + "const": "verified" + }, + "can_merge_without_human": { + "const": true + }, + "capability_review": { + "properties": { + "policy_weakened": { + "const": false + }, + "trust_root_touched": { + "const": false + } + } + }, + "control": { + "properties": { + "state": { + "const": "complete" + } + }, + "required": [ + "state" + ] + }, + "execution": { + "const": "succeeded" + }, + "fix_task": { + "type": "null" + }, + "head_status": { + "const": "succeeded" + }, + "merge_verdict": { + "const": "mergeable" + }, + "release_decision": { + "properties": { + "blockers": { + "maxItems": 0 + }, + "decision": { + "const": "passed" + }, + "evidence_coverage": { + "properties": { + "evidence_gaps": { + "maxItems": 0 + }, + "human_review_recommended": { + "const": false + } + } + }, + "review_items": { + "maxItems": 0 + } + }, + "type": "object" + } + } + } + }, + { + "else": { + "properties": { + "control": { + "properties": { + "state": { + "enum": [ + "agent_action_required", + "human_review_required" + ] + } + }, + "required": [ + "state" + ] + } + } + }, + "if": { + "properties": { + "can_merge_without_human": { + "const": true + } + }, + "required": [ + "can_merge_without_human" + ] + }, + "then": { + "oneOf": [ + { + "properties": { + "applicability": { + "const": "verified" + }, + "decision": { + "const": "passed" + }, + "execution": { + "const": "succeeded" + } + } + }, + { + "properties": { + "applicability": { + "const": "not_applicable" + }, + "decision": { + "type": "null" + }, + "execution": { + "const": "skipped" + } + } + } + ], + "properties": { + "control": { + "properties": { + "state": { + "const": "complete" + } + }, + "required": [ + "state" + ] + } + } + } + }, + { + "if": { + "properties": { + "authorization": { + "properties": { + "status": { + "const": "accepted" + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "authorization" + ] + }, + "then": { + "properties": { + "applicability": { + "const": "verified" + }, + "can_merge_without_human": { + "const": false + }, + "control": { + "properties": { + "allowed_next_commands": { + "maxItems": 1, + "minItems": 1 + }, + "completion_allowed": { + "const": false + }, + "next_action": { + "properties": { + "kind": { + "const": "repair" + } + }, + "required": [ + "kind" + ] + }, + "state": { + "const": "agent_action_required" + } + }, + "required": [ + "state", + "completion_allowed", + "next_action", + "allowed_next_commands" + ] + }, + "decision": { + "const": "review_required" + }, + "execution": { + "const": "succeeded" + }, + "fix_task": { + "type": "null" + }, + "head_status": { + "const": "succeeded" + }, + "merge_verdict": { + "const": "human_review_required" + }, + "release_decision": { + "properties": { + "decision": { + "const": "review_required" + } + }, + "required": [ + "decision" + ], + "type": "object" + } + }, + "required": [ + "execution", + "head_status", + "release_decision", + "decision", + "merge_verdict", + "applicability", + "can_merge_without_human", + "control", + "fix_task" + ] + } + } + ], + "description": "JSON Schema for verifier.json. Generated from agents_shipgate.schemas.verifier.VerifierArtifact. Do not edit by hand.", + "properties": { + "agent_summary": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Agent Summary" + }, + "applicability": { + "default": "not_evaluated", + "enum": [ + "not_evaluated", + "verified", + "not_applicable", + "failed" + ], + "title": "Applicability", + "type": "string" + }, + "artifacts": { + "additionalProperties": { + "type": "string" + }, + "title": "Artifacts", + "type": "object" + }, + "authorization": { + "$ref": "#/$defs/AuthorizationEvaluationV1" + }, + "base_notes": { + "items": { + "type": "string" + }, + "title": "Base Notes", + "type": "array" + }, + "base_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Ref" + }, + "base_report_json": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Report Json" + }, + "base_status": { + "default": "not_requested", + "enum": [ + "not_requested", + "skipped", + "diff_from_provided", + "ref_missing", + "archive_failed", + "missing_manifest", + "scan_failed", + "cache_hit", + "succeeded" + ], + "title": "Base Status", + "type": "string" + }, + "base_tree_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Tree Sha" + }, + "can_merge_without_human": { + "default": false, + "title": "Can Merge Without Human", + "type": "boolean" + }, + "capability_review": { + "$ref": "#/$defs/VerifierCapabilityReview" + }, + "changed_files": { + "items": { + "type": "string" + }, + "title": "Changed Files", + "type": "array" + }, + "config": { + "title": "Config", + "type": "string" + }, + "control": { + "$ref": "#/$defs/AgentControl" + }, + "decision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decision" + }, + "decision_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decision Id" + }, + "diff_text_available": { + "default": false, + "title": "Diff Text Available", + "type": "boolean" + }, + "engine_requirement_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Engine Requirement Id" + }, + "execution": { + "default": "not_run", + "enum": [ + "not_run", + "succeeded", + "skipped", + "failed" + ], + "title": "Execution", + "type": "string" + }, + "executor_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Executor Id" + }, + "fix_task": { + "anyOf": [ + { + "$ref": "#/$defs/VerifierFixTask" + }, + { + "type": "null" + } + ], + "default": null + }, + "forbidden_actions": { + "items": { + "type": "string" + }, + "title": "Forbidden Actions", + "type": "array" + }, + "forbidden_file_edits": { + "items": { + "type": "string" + }, + "title": "Forbidden File Edits", + "type": "array" + }, + "head_exit_code": { + "default": 0, + "title": "Head Exit Code", + "type": "integer" + }, + "head_ref": { + "default": "HEAD", + "title": "Head Ref", + "type": "string" + }, + "head_report_json": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Head Report Json" + }, + "head_status": { + "default": "not_run", + "enum": [ + "not_run", + "succeeded", + "skipped", + "failed" + ], + "title": "Head Status", + "type": "string" + }, + "head_tree_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Head Tree Sha" + }, + "headline": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headline" + }, + "input_set_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input Set Id" + }, + "merge_verdict": { + "default": "unknown", + "enum": [ + "mergeable", + "human_review_required", + "insufficient_evidence", + "blocked", + "unknown" + ], + "title": "Merge Verdict", + "type": "string" + }, + "mode": { + "default": "advisory", + "title": "Mode", + "type": "string" + }, + "release_decision": { + "anyOf": [ + { + "$ref": "#/$defs/ReleaseDecision" + }, + { + "type": "null" + } + ], + "default": null + }, + "request_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Id" + }, + "reviewer_summary": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reviewer Summary" + }, + "runtime_behavior_verified": { + "const": false, + "default": false, + "title": "Runtime Behavior Verified", + "type": "boolean" + }, + "static_analysis_only": { + "const": true, + "default": true, + "title": "Static Analysis Only", + "type": "boolean" + }, + "static_verdict_disclaimer": { + "default": "This verdict covers deterministic static evidence only. Agents Shipgate did not execute the agent or prove runtime behavior, tool routing, credential enforcement, or safety.", + "title": "Static Verdict Disclaimer", + "type": "string" + }, + "subject_id": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subject Id" + }, + "trigger": { + "additionalProperties": true, + "title": "Trigger", + "type": "object" + }, + "verifier_schema_version": { + "const": "0.6", + "default": "0.6", + "title": "Verifier Schema Version", + "type": "string" + }, + "workspace": { + "title": "Workspace", + "type": "string" + } + }, + "required": [ + "workspace", + "config", + "control", + "authorization" + ], + "title": "Agents Shipgate Verifier Artifact v0.6", + "type": "object" +} diff --git a/docs/verify-run-schema.v3.json b/docs/verify-run-schema.v3.json index 1560be1c..333e1fe3 100644 --- a/docs/verify-run-schema.v3.json +++ b/docs/verify-run-schema.v3.json @@ -595,7 +595,7 @@ "base_commit_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -620,7 +620,7 @@ "base_tree_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -633,7 +633,7 @@ "head_commit_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -650,7 +650,7 @@ "head_tree_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -663,7 +663,7 @@ "merge_base_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { @@ -688,7 +688,7 @@ "source_head_commit_sha": { "anyOf": [ { - "pattern": "^[0-9a-f]{40,64}$", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$", "type": "string" }, { diff --git a/llms-full.txt b/llms-full.txt index 8ff064af..1ea53e66 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1034,16 +1034,16 @@ Verify the installed CLI contract locally before relying on hard-coded docs: agents-shipgate contract --json ``` -Runtime contract v17 retains the v16 typed policy-evidence, v15 host-neutral +Runtime contract v18 retains the v17 content-addressed verification identity, +v16 typed policy-evidence, v15 host-neutral boundary, v14 unambiguous `AgentControl`, and v13 root-reachable binding -contracts. It adds a content-addressed verification request, decision-free -worker result, artifact manifest, and terminal receipt shared by local and -distributed verification. Agents +contracts. It adds a signed, externally rooted human-authorization overlay for +one exact post-review coding-agent action. Agents switch on `control.state`; `decision` remains diagnostic and `release_decision.decision` remains the release gate. Contract v14 requires `completion_allowed == (state == "complete")` and `must_stop == (state == "human_review_required")`. Report v0.34, packet v0.12, -verifier v0.5, verify-run v3, and handoff v5 bind their projections to the +verifier v0.6, verify-run v3, and handoff v6 bind their projections to the same request and decision IDs. The terminal receipt hashes the complete artifact set; see [Verification Identity and Reproduction](verification-reproducibility.md). The runtime contract also exposes the local agent command spec: @@ -1053,7 +1053,13 @@ The runtime contract also exposes the local agent command spec: `verify_run_schema_version`, `verification_plan_schema_version`, `verification_unit_result_schema_version`, `verification_artifact_manifest_schema_version`, -`verification_receipt_schema_version`, `agent_handoff_schema_version`, +`verification_receipt_schema_version`, +`human_authorization_request_schema_version`, +`human_authorization_schema_version`, +`human_authorization_evaluation_schema_version`, +`human_authorization_trust_policy_schema_version`, +`human_authorization_trust_policy_default_path`, +`human_authorization_schema_path`, `agent_handoff_schema_version`, `agent_handoff_schema_path`, `agent_handoff_artifact`, `agent_boundary_result_schema_version`, the deprecated `codex_boundary_result_schema_version`, `attestation_schema_version`, @@ -1080,18 +1086,19 @@ Downstream repos generated with - Latest release: `v0.15.0` - In-tree runtime: `0.16.0b6` — see [pyproject.toml](../pyproject.toml) -- Runtime contract: `17` (minimum control contract: `14`) +- Runtime contract: `18` (minimum control contract: `14`) - Current report schema: `0.34` — [`docs/report-schema.v0.34.json`](report-schema.v0.34.json) - Current packet schema: `0.12` — [`docs/packet-schema.v0.12.json`](packet-schema.v0.12.json) - Current shared agent result schema: `agent_result_v2` — [`docs/agent-result-schema.v2.json`](agent-result-schema.v2.json) -- Current verifier schema: `0.5` — [`docs/verifier-schema.v0.5.json`](verifier-schema.v0.5.json) +- Current verifier schema: `0.6` — [`docs/verifier-schema.v0.6.json`](verifier-schema.v0.6.json) - Current verify-run schema: `shipgate.verify_run/v3` — [`docs/verify-run-schema.v3.json`](verify-run-schema.v3.json) - Current verification identity schemas: [`plan v1`](verification-plan-schema.v1.json), [`unit result v1`](verification-unit-result-schema.v1.json), [`artifact manifest v1`](verification-artifact-manifest-schema.v1.json), and [`terminal receipt v1`](verification-receipt-schema.v1.json) -- Current agent handoff schema: `shipgate.agent_handoff/v5` — [`docs/agent-handoff-schema.v5.json`](agent-handoff-schema.v5.json) +- Current human-authorization schemas: request, signed grant, verifier evaluation, and external trust policy v1 — [`docs/human-authorization-schema.v1.json`](human-authorization-schema.v1.json) +- Current agent handoff schema: `shipgate.agent_handoff/v6` — [`docs/agent-handoff-schema.v6.json`](agent-handoff-schema.v6.json) - Current agent boundary result schema: `shipgate.agent_boundary_result/v1` — [`docs/agent-boundary-result-schema.v1.json`](agent-boundary-result-schema.v1.json) - Frozen deprecated Codex projection: `shipgate.codex_boundary_result/v2` — [`docs/codex-boundary-result-schema.v2.json`](codex-boundary-result-schema.v2.json) - Current preflight schema: `0.3` — [`docs/preflight-schema.v0.3.json`](preflight-schema.v0.3.json) -- Current downstream local agent contract schema: `6` +- Current downstream local agent contract schema: `7` - Current capability standard: `0.5` — [`docs/capability-standard.md`](capability-standard.md) - Current capability lock schema: `0.6` — [`docs/capability-lock-schema.v0.6.json`](capability-lock-schema.v0.6.json) - Current capability lock diff schema: `0.7` — [`docs/capability-lock-diff-schema.v0.7.json`](capability-lock-diff-schema.v0.7.json) @@ -1104,7 +1111,7 @@ Downstream repos generated with - Current governance benchmark result schema: `0.2` — [`docs/governance-benchmark-result-schema.v0.2.json`](governance-benchmark-result-schema.v0.2.json) - Frozen-reference report schemas: frozen [`v0.33`](report-schema.v0.33.json), frozen [`v0.32`](report-schema.v0.32.json), frozen [`v0.31`](report-schema.v0.31.json), frozen [`v0.30`](report-schema.v0.30.json), and older versions listed in [`docs/INDEX.md`](INDEX.md#reference) - Frozen-reference packet schemas live in [`docs/INDEX.md`](INDEX.md#reference). -- Boundary v1, verifier v0.1–v0.4, verify-run v1/v2, handoff v1–v4, and preflight +- Boundary v1, verifier v0.1–v0.5, verify-run v1/v2, handoff v1–v5, and preflight v0.1/v0.2 remain frozen references for legacy readers. - Frozen experimental capability lock and governance benchmark result schemas live in [`docs/INDEX.md`](INDEX.md#reference). @@ -1118,7 +1125,7 @@ one decision engine. repair, or stop*. Prefer validate `agents-shipgate-reports/verification-receipt.json`, then read `agents-shipgate-reports/agent-handoff.json` for the compact - `shipgate.agent_handoff/v5` view: lead with `control.state`, then read + `shipgate.agent_handoff/v6` view: lead with `control.state`, then read `control.next_action`, `gate.merge_verdict`, and `reproducibility.run_id` for the content-addressed verify identity. `verifier.json` remains the authoritative controller substrate and `verify-run.json` remains the detailed run @@ -1304,17 +1311,18 @@ first touch before a full scan. To evaluate just the run/skip trigger, run `agents-shipgate verify` and `verify --preview` also write `agents-shipgate-reports/verify-run.json` whenever the output directory can be -created. It carries `schema_version: "shipgate.verify_run/v2"`, the same -derived `control` object as the verifier and handoff, and a deterministic -`run_id` over `{tool, subject, inputs}` (outcome and artifact hashes are carried -separately), local input hashes (`config_sha256`, `baseline_sha256`, -`policy_packs[].sha256`), the outcome projection, and artifact hashes for -emitted files. It has no wall-clock timestamp and is not a second gate. +created. It carries `schema_version: "shipgate.verify_run/v3"`, the exact +verification plan, executor, unit-result IDs, decision ID, outcome projection, +and artifact references. `request_id` is the content-addressed run identity; +the deprecated `run_id` remains for one compatibility cycle as its exact alias, +never as a separately derived identity. It has no wall-clock timestamp and is +not a second gate. `agents-shipgate-reports/agent-handoff.json` carries -`schema_version: "shipgate.agent_handoff/v5"` and top-level sections +`schema_version: "shipgate.agent_handoff/v6"` and top-level sections `gate`, `control`, `fix_task`, `blocked_by[]`, -`remediation_plan[]`, `capability_review`, `reproducibility`, and `artifacts`. +`remediation_plan[]`, `capability_review`, `authorization`, `reproducibility`, +and `artifacts`. `gate.decision` mirrors `release_decision.decision`; `gate.merge_verdict` mirrors `verifier.json.merge_verdict`; and `gate.{static_analysis_only,runtime_behavior_verified,static_verdict_disclaimer}` @@ -1322,17 +1330,20 @@ mirrors the report/verifier static-only boundary. The values are locked to `true`, `false`, and the canonical disclaimer. `control` is byte-identical to the verifier/verify-run control object, and `can_merge_without_human` is true only for a verified `passed` result or a completed deterministic -`not_applicable` skip. Re-render it +`not_applicable` skip. `authorization` is the byte-equivalent verifier +evaluation; the handoff cannot grant a command independently. Re-render it from existing artifacts with: ```bash agents-shipgate agent handoff --from agents-shipgate-reports/verifier.json --json ``` -In `agents-shipgate-reports/verifier.json`, read the v0.5 fields below (full -schema [`docs/verifier-schema.v0.5.json`](verifier-schema.v0.5.json)). **Lead -with `control.state`.** Every field below is a mirror or deterministic projection of -`report.json`; `release_decision.decision` remains the gate. +In `agents-shipgate-reports/verifier.json`, read the v0.6 fields below (full +schema [`docs/verifier-schema.v0.6.json`](verifier-schema.v0.6.json)). **Lead +with `control.state`.** Every release and merge field below is a mirror or +deterministic projection of `report.json`; the authorization evaluation is an +operational overlay and cannot change those fields. +`release_decision.decision` remains the gate. - `control` — the discriminated `complete | agent_action_required | human_review_required` operational projection. Its variant fixes @@ -1361,10 +1372,17 @@ with `control.state`.** Every field below is a mirror or deterministic projectio - `can_merge_without_human` — `bool`. - `decision` — mirror of `release_decision.decision` (or `null` when no scan ran). - `headline` — single-sentence, PR-comment-friendly summary (or `null`). -- `control.human_review` and `control.next_action` are the only serialized - human-review and next-action authority in verifier v0.5. +- `authorization` — the + `shipgate.human_authorization_evaluation/v1` result. Only `accepted` can + expose a command, and that command must exactly match both + `control.next_action.command` and the sole entry in + `control.allowed_next_commands`. `rejected`, `not_requested`, and + `not_applicable` carry no command authority. +- `control.human_review` and `control.next_action` are the serialized route for + the current verifier state; when authorization is accepted, the signed + evaluation is the provenance for the exact coding-agent next action. - `AgentController`, `VerifierNextAction`, and `VerifierHumanReview` remain - importable only as deprecated v0.1/v0.2 reader models. Verifier v0.4 does not + importable only as deprecated v0.1/v0.2 reader models. Verifier v0.6 does not emit or invoke the retired `build_agent_controller` projector. - `fix_task` — `{actor, safe_to_attempt, instructions[], allowed_repairs[], forbidden_repairs[], forbidden_shortcuts[], verification_command, patches[]}` or `null`. @@ -1396,6 +1414,87 @@ with `control.state`.** Every field below is a mirror or deterministic projectio informational values) and never introduces a finding-independent blocker. - `mode` — `"advisory"` / `"strict"` / `"skipped"` / `"preview"`. +### Trusted human authorization for one exact command + +Authorization changes operational routing, never the static release verdict. +The flow is deliberately two-pass: + +1. Run `agents-shipgate verify --no-plugins` and validate the resulting + terminal receipt. Authorization requires the plan's exact effective plugin + mode to be false; the protected executor never loads third-party plugin or + adapter entry points. + `agents-shipgate authorization request --receipt + --artifacts-root --destination-ref + --expected-lease-oid --out ` constructs the unsigned + `shipgate.human_authorization_request/v1` from that receipt's current + request, subject, decision, source receipt/artifact-set/engine/executor and + tree identities, the complete ordered + review set, and one typed Git-push operation. This command creates a + challenge, not authority. +2. The host authenticates the human and signs the canonical request with an + Ed25519 key kept outside coding-agent reach. Agents Shipgate supplies no + private key and no command that signs or approves a request. The v1 trust + policy must be stored outside the evaluated workspace and protected from + writes by the agent. On POSIX, Agents Shipgate reads it only from the OS + account home's fixed path + `~/.config/agents-shipgate/human-authorization-trust-policy.json`; `HOME` + and `XDG_CONFIG_HOME` do not redirect that lookup. +3. Rerun `agents-shipgate verify --no-plugins --authorization + `. The + verifier recomputes the current identities and validates the signature, + principal, repository scope, TTL, request, subject, trees, decision, full + review set, and operation before publishing any command authority. + +The only v1 operation is an exact force-with-lease Git push. It binds the exact +evaluated commit, a canonical credential-free HTTPS destination whose +repository identity equals the verified repository, a full destination +`refs/heads/...` ref, and the expected remote OID. A synthetic PR merge receipt +cannot authorize pushing a different parent commit. Authorization is eligible +only when execution +succeeded and the release decision is `review_required`. An accepted grant +changes `control.state` from `human_review_required` to +`agent_action_required` for that exact command, while all release facts remain +unchanged: `release_decision.decision="review_required"`, +`merge_verdict="human_review_required"`, `can_merge_without_human=false`, and +`completion_allowed=false`. The coding agent may perform only the serialized +guarded `agents-shipgate authorization execute` command. That consumer +revalidates the current receipt, trust root, clock, repository, and commit and +isolates Git configuration and hooks before issuing the internal typed push; +the raw Git command is never operational authority. The agent must rerun +verification afterward. + +The signer must authenticate the source closure: content addressing is +integrity, not provenance. It must rerun verification in a trusted worker or +verify trusted-CI attestation over the bound source receipt/artifact-set IDs. +The request exposes the evaluated base commit and merge base, and the source +commit transitively binds its full parent graph. The signer must review that +complete ancestry and reachable history rather than relying only on the final +tree diff. Execution enforces a 512 MiB graph-pack ceiling and a 120-second +process timeout; the host broker should impose tighter deployment quotas. The +compressed pack ceiling does not bound expanded-object indexing memory or CPU, +so production brokers need cgroup, container, or equivalent host resource +limits. +Execution also requires a host-protected broker with a sanitized environment, +external trust policy, interpreter, entire virtual environment and +`site-packages` tree (including startup `.pth` files), dependencies, +credentials, and separately installed Agents Shipgate distribution. Same-UID +file permissions alone are insufficient, and an +editable install rooted in the authorized workspace is ineligible. If the host +cannot enforce those boundaries, authorization remains disabled. The guarded +executor is POSIX-only in v1 and authorization remains disabled on Windows. V1 is +push-only and does not authorize the coding agent to apply reviewed protected +patches. + +Malformed, untrusted, expired, not-yet-valid, incomplete-review-set, +wrong-tree, wrong-request, wrong-ref, or wrong-lease grants fail closed with +zero allowed commands. Plain JSON in the repository, a PR comment, or +conversation-level approval is not equivalent to a signed grant. This release +defines the protocol and verifier consumer; it does not claim a current Codex, +Claude Code, or other coding-host UI signing integration. Such a host adapter +must be implemented separately. A grant replayed after the remote ref advances +cannot overwrite that ref: Git enforces the signed command's explicit expected +lease OID. + `verifier.json` also carries `trigger`, `base_status`, `head_status`, `base_ref`, `head_ref`, `changed_files`, `base_notes`, the embedded `release_decision`, and an `artifacts` map. When present, `artifacts.capability_lock_json`, diff --git a/llms.txt b/llms.txt index 14aea41d..913592d5 100644 --- a/llms.txt +++ b/llms.txt @@ -13,7 +13,7 @@ - Publisher URL: https://threemoonslab.com/ - License: Apache-2.0 - Latest public release: v0.15.0 -- Current source-tree runtime: 0.16.0b6 (contract 17; pre-release) +- Current source-tree runtime: 0.16.0b6 (contract 18; pre-release) - Canonical repository: https://github.com/ThreeMoonsLab/agents-shipgate - Do not use: Agent Shipcheck, Agent Shipgate, agents shipgate, Agents-Shipgate @@ -71,7 +71,7 @@ - Agent handoff artifact (preferred compact agent projection for verify): `agents-shipgate-reports/agent-handoff.json`. - Terminal verification receipt (read and validate first): `agents-shipgate-reports/verification-receipt.json`. - Verification receipt schema: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verification-receipt-schema.v1.json -- Agent handoff schema: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-handoff-schema.v5.json +- Agent handoff schema: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-handoff-schema.v6.json - PR comment (ongoing-PR verify): `agents-shipgate-reports/pr-comment.md`. - Proactive preflight routing JSON: `agents-shipgate preflight --workspace . --plan - --json` emits `preflight_schema_version: "0.3"`; switch on `control.state`. It routes protected-surface edits, host permission requests, and high-risk capability evidence gaps but is not a release verdict. - Preflight schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/preflight-schema.v0.3.json @@ -167,7 +167,7 @@ - Governance benchmark: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/governance-benchmark.md - Current agent contract: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-contract-current.md - Verification identity contract: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/verification-reproducibility.md -- Agent handoff schema: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-handoff-schema.v5.json +- Agent handoff schema: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/agent-handoff-schema.v6.json ## Category vocabulary diff --git a/pyproject.toml b/pyproject.toml index 7eaf221e..ecb5310b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "cryptography>=46,<47", "packaging>=24,<27", "pydantic>=2.13.4,<3", "pyyaml>=6.0.3,<7", diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py index 67f3772b..3c00aa9d 100644 --- a/scripts/generate_schemas.py +++ b/scripts/generate_schemas.py @@ -26,7 +26,10 @@ - docs/verification-artifact-manifest-schema.v1.json - docs/verification-receipt-schema.v1.json (from agents_shipgate.schemas.verification_identity) -- docs/agent-handoff-schema.v5.json +- docs/human-authorization-schema.v1.json + (authorization request, signed grant, + evaluation, and trust policy union) +- docs/agent-handoff-schema.v6.json (from agents_shipgate.schemas.agent_handoff. AgentHandoffArtifact) - docs/agent-result-schema.v2.json @@ -83,7 +86,7 @@ import sys from collections.abc import Callable from pathlib import Path -from typing import get_args +from typing import Any, get_args REPO_ROOT = Path(__file__).resolve().parent.parent DOCS = REPO_ROOT / "docs" @@ -1214,12 +1217,77 @@ def write_packet_schema(*, check_only: bool = False, drift: list[str] | None = N return _emit(target, content, check_only=check_only, drift=drift if drift is not None else []) +def _postprocess_authorization_evaluation(schema: dict[str, Any]) -> None: + """Publish model-validator invariants in every schema embedding authorization.""" + + evaluation = schema.get("$defs", {}).get("AuthorizationEvaluationV1") + if not isinstance(evaluation, dict): + return + authority_fields = [ + "authorization_id", + "authorization_request_id", + "trust_policy_id", + "key_id", + "provider", + "principal", + "operation_id", + "command", + "issued_at", + "expires_at", + ] + evaluation["allOf"] = [ + { + "if": {"properties": {"status": {"const": "accepted"}}}, + "then": { + "required": authority_fields, + "properties": { + **{field: {"not": {"type": "null"}} for field in authority_fields}, + "reason_codes": {"maxItems": 0}, + }, + }, + }, + { + "if": { + "properties": { + "status": { + "enum": ["rejected", "not_requested", "not_applicable"] + } + } + }, + "then": {"properties": {"command": {"type": "null"}}}, + }, + { + "if": {"properties": {"status": {"const": "rejected"}}}, + "then": { + "required": ["reason_codes"], + "properties": {"reason_codes": {"minItems": 1}}, + }, + }, + { + "if": { + "properties": { + "status": {"enum": ["not_requested", "not_applicable"]} + } + }, + "then": { + "properties": { + **{field: {"type": "null"} for field in authority_fields}, + } + }, + }, + ] + properties = evaluation.get("properties", {}) + if isinstance(properties, dict) and isinstance(properties.get("reason_codes"), dict): + properties["reason_codes"]["uniqueItems"] = True + + def build_verifier_schema() -> tuple[Path, str]: """Generate docs/verifier-schema.v0.1.json from VerifierArtifact.""" from agents_shipgate.schemas.verifier import VerifierArtifact schema = VerifierArtifact.model_json_schema() + _postprocess_authorization_evaluation(schema) minor = str(VerifierArtifact.model_fields["verifier_schema_version"].default) schema["$id"] = ( "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" @@ -1381,6 +1449,41 @@ def build_verification_receipt_schema() -> tuple[Path, str]: ) +def build_human_authorization_schema() -> tuple[Path, str]: + """Generate the signed authorization protocol schema family.""" + + from pydantic import TypeAdapter + + from agents_shipgate.schemas.human_authorization import ( + AuthorizationEvaluationV1, + HumanAuthorizationRequestV1, + HumanAuthorizationTrustPolicyV1, + HumanAuthorizationV1, + ) + + schema = TypeAdapter( + HumanAuthorizationRequestV1 + | HumanAuthorizationV1 + | AuthorizationEvaluationV1 + | HumanAuthorizationTrustPolicyV1 + ).json_schema() + schema["$id"] = ( + "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" + "main/docs/human-authorization-schema.v1.json" + ) + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["title"] = "Agents Shipgate Human Authorization Protocol v1" + schema["description"] = ( + "JSON Schema family for the unsigned request, externally signed grant, " + "fail-closed evaluation, and external trust policy. Agents Shipgate " + "does not provide signing or approval authority. Content-address, " + "cross-object identity, canonical ordering, and time-window relations " + "also require application-level model validation." + ) + _postprocess_authorization_evaluation(schema) + return DOCS / "human-authorization-schema.v1.json", _canonical_json(schema) + + def build_agent_handoff_schema() -> tuple[Path, str]: """Generate the current agent-handoff schema.""" @@ -1390,6 +1493,7 @@ def build_agent_handoff_schema() -> tuple[Path, str]: ) schema = AgentHandoffArtifact.model_json_schema() + _postprocess_authorization_evaluation(schema) major = AGENT_HANDOFF_SCHEMA_VERSION.rsplit("/v", 1)[-1] schema["$id"] = ( "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" @@ -1743,6 +1847,7 @@ def build_host_grants_drift_schema() -> tuple[Path, str]: ("verification_unit_result", build_verification_unit_result_schema), ("verification_artifact_manifest", build_verification_artifact_manifest_schema), ("verification_receipt", build_verification_receipt_schema), + ("human_authorization", build_human_authorization_schema), ("agent_handoff", build_agent_handoff_schema), ("agent_result", build_agent_result_schema), # codex_boundary_result v2 is a frozen compatibility schema and is not diff --git a/src/agents_shipgate/cli/agent_interface.py b/src/agents_shipgate/cli/agent_interface.py index a16fbebc..1a5e7f85 100644 --- a/src/agents_shipgate/cli/agent_interface.py +++ b/src/agents_shipgate/cli/agent_interface.py @@ -45,7 +45,7 @@ def handoff( help="Print the handoff JSON to stdout.", ), ) -> None: - """Render the shipgate.agent_handoff/v5 artifact from verifier outputs.""" + """Render the shipgate.agent_handoff/v6 artifact from verifier outputs.""" try: verifier_payload = _load_required_json(source, "verifier.json") diff --git a/src/agents_shipgate/cli/attest.py b/src/agents_shipgate/cli/attest.py index d849b5b9..258ad4f8 100644 --- a/src/agents_shipgate/cli/attest.py +++ b/src/agents_shipgate/cli/attest.py @@ -132,7 +132,7 @@ def _attest_command( receipt_path = source.with_name("verification-receipt.json") receipt_payload = _load_optional_json_object(receipt_path) receipt_sha256 = None - if verifier.get("verifier_schema_version") == "0.5": + if verifier.get("verifier_schema_version") in {"0.5", "0.6"}: if not receipt_payload: raise typer.Exit(3) receipt = VerificationReceipt.model_validate(receipt_payload) diff --git a/src/agents_shipgate/cli/authorization.py b/src/agents_shipgate/cli/authorization.py new file mode 100644 index 00000000..a57a2063 --- /dev/null +++ b/src/agents_shipgate/cli/authorization.py @@ -0,0 +1,414 @@ +"""Human-authorization request and guarded execution tooling. + +These commands never sign or approve anything. ``request`` projects one +content-addressed challenge for an external host; ``execute`` revalidates a +receipt-bound grant at the current wall clock before invoking its typed action. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import typer +from pydantic import ValidationError + +from agents_shipgate.cli.verify.git import ( + active_replace_refs, + ensure_git_workspace, + resolve_git_push_endpoint, +) +from agents_shipgate.core.authorization_execution import ( + authorization_execute_command, + authorization_workspace_root, + ensure_authorization_runtime_is_external, + execute_pinned_git_push, +) +from agents_shipgate.core.errors import ConfigError +from agents_shipgate.core.human_authorization import ( + default_human_authorization_trust_policy_path, + evaluate_human_authorization, +) +from agents_shipgate.core.verification_identity import ( + load_validated_receipt_artifacts, + validate_engine_requirement, +) +from agents_shipgate.schemas.diagnostics import NextAction +from agents_shipgate.schemas.human_authorization import ( + AuthorizationEvaluationV1, + HumanAuthorizationV1, + authorization_review_items, + build_git_push_operation, + build_human_authorization_request, +) +from agents_shipgate.schemas.verification_identity import VerificationPlan +from agents_shipgate.schemas.verifier import VerifierArtifact + +authorization_app = typer.Typer(no_args_is_help=True) + +_AUTHORIZATION_RECEIPT_ARTIFACTS = frozenset( + { + "agent_handoff_json", + "base_capability_lock_json", + "capability_lock_diff_json", + "capability_lock_diff_markdown", + "capability_lock_json", + "human_authorization_json", + "packet_json", + "pr_comment", + "report_json", + "report_markdown", + "report_sarif", + "verification_input_diff", + "verification_base_report_json", + "verification_plan_json", + "verification_unit_result_json", + "verifier_json", + "verify_run_json", + } +) + + +class _AuthorizedOperationFailed(RuntimeError): + """The validated operation reached its executor but did not succeed.""" + + +@authorization_app.command("request") +def request_authorization( + receipt_path: Path = typer.Option( + Path("agents-shipgate-reports/verification-receipt.json"), + "--receipt", + help="Validated terminal receipt for the review-required verification.", + ), + artifacts_root: Path = typer.Option( + Path("agents-shipgate-reports"), + "--artifacts-root", + help="Root used to resolve receipt-bound artifacts.", + ), + remote: str = typer.Option("origin", "--remote"), + destination_ref: str = typer.Option(..., "--destination-ref"), + expected_lease_oid: str = typer.Option(..., "--expected-lease-oid"), + out: Path = typer.Option( + Path("agents-shipgate-reports/human-authorization-request.json"), + "--out", + ), + json_output: bool = typer.Option( + False, + "--json", + help="Emit the JSON result (authorization commands are JSON-native).", + ), +) -> None: + """Build an unsigned exact-operation challenge from trusted verifier output.""" + + try: + root = artifacts_root.resolve() + del json_output + receipt, artifacts = load_validated_receipt_artifacts( + receipt_path=receipt_path, + root=root, + allowed_artifact_names=_AUTHORIZATION_RECEIPT_ARTIFACTS, + max_artifact_count=20, + max_total_size=128 * 1024 * 1024, + ) + if receipt.decision != "review_required": + raise ValueError("human authorization requests require decision='review_required'") + plan = VerificationPlan.model_validate( + _load_json_bytes(_required_artifact(artifacts, "verification_plan_json")) + ) + if plan.inputs.options.get("plugins_enabled") is not False: + raise ValueError( + "human authorization requests require an exact plugins-disabled engine mode" + ) + report = _load_json_bytes(_required_artifact(artifacts, "report_json")) + release_decision = report.get("release_decision") + if not isinstance(release_decision, dict): + raise ValueError("bound report lacks a release_decision object") + review_items = authorization_review_items(release_decision) + git = plan.subject.git + if git.snapshot_kind != "committed_tree" or git.worktree_overlay_sha256 is not None: + raise ValueError("human authorization rejects uncommitted worktree overlays") + required_git = { + "base_commit_sha": git.base_commit_sha, + "base_tree_sha": git.base_tree_sha, + "head_tree_sha": git.head_tree_sha, + "merge_base_sha": git.merge_base_sha, + "source_head_commit_sha": git.source_head_commit_sha, + } + missing = sorted(name for name, value in required_git.items() if value is None) + if missing: + raise ValueError( + "human authorization requires committed PR identity fields: " + ", ".join(missing) + ) + git_root = ensure_git_workspace(root) + if active_replace_refs(git_root): + raise ValueError("human authorization rejects repositories with Git replace refs") + endpoint = resolve_git_push_endpoint(git_root, remote) + if endpoint.repository_id != git.repository_id: + raise ValueError( + "selected push endpoint repository identity does not match the " + f"reviewed repository: {endpoint.repository_id!r} != {git.repository_id!r}" + ) + operation = build_git_push_operation( + destination_repository_id=endpoint.repository_id, + push_url=endpoint.push_url, + source_commit_sha=git.source_head_commit_sha, + destination_ref=destination_ref, + expected_lease_oid=expected_lease_oid, + ) + request = build_human_authorization_request( + repository_id=git.repository_id, + source_receipt_id=receipt.receipt_id, + source_artifact_set_id=receipt.artifact_set_id, + source_engine_requirement_id=receipt.engine_requirement_id, + source_executor_id=receipt.executor_id, + verification_request_id=receipt.request_id, + subject_id=receipt.subject_id, + decision_id=receipt.decision_id, + base_commit_sha=git.base_commit_sha, + merge_base_sha=git.merge_base_sha, + base_tree_sha=git.base_tree_sha, + head_tree_sha=git.head_tree_sha, + source_head_commit_sha=git.source_head_commit_sha, + review_items=review_items, + operation=operation, + ) + payload = request.model_dump(mode="json") + try: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + except OSError as exc: + guidance = f"Make the authorization request output directory writable: {out.parent}" + _emit_authorization_error("other_error", exc, guidance) + raise typer.Exit(4) from exc + typer.echo(json.dumps({**payload, "output_path": str(out)}, sort_keys=True)) + except (ConfigError, OSError, ValueError, ValidationError, json.JSONDecodeError) as exc: + guidance = ( + "Validate a committed review_required verification receipt, then rerun this " + "command with a safe HTTPS remote, one exact destination ref, and lease OID." + ) + _emit_authorization_error("input_parse_error", exc, guidance) + raise typer.Exit(3) from exc + + +@authorization_app.command("execute") +def execute_authorization( + workspace: Path = typer.Option(Path("."), "--workspace"), + receipt_path: Path = typer.Option( + Path("agents-shipgate-reports/verification-receipt.json"), + "--receipt", + ), + artifacts_root: Path = typer.Option( + Path("agents-shipgate-reports"), + "--artifacts-root", + ), + json_output: bool = typer.Option( + False, + "--json", + help="Emit the JSON result (authorization commands are JSON-native).", + ), +) -> None: + """Revalidate and execute one receipt-bound signed operation.""" + + try: + root = artifacts_root.resolve() + del json_output + receipt, artifacts = load_validated_receipt_artifacts( + receipt_path=receipt_path, + root=root, + allowed_artifact_names=_AUTHORIZATION_RECEIPT_ARTIFACTS, + max_artifact_count=20, + max_total_size=128 * 1024 * 1024, + ) + if receipt.decision != "review_required": + raise ValueError("authorized execution requires decision='review_required'") + + plan = VerificationPlan.model_validate( + _load_json_bytes(_required_artifact(artifacts, "verification_plan_json")) + ) + report = _load_json_bytes(_required_artifact(artifacts, "report_json")) + verifier = VerifierArtifact.model_validate( + _load_json_bytes(_required_artifact(artifacts, "verifier_json")) + ) + grant = HumanAuthorizationV1.model_validate( + _load_json_bytes(_required_artifact(artifacts, "human_authorization_json")) + ) + release_decision = report.get("release_decision") + if not isinstance(release_decision, dict): + raise ValueError("bound report lacks a release_decision object") + review_items = authorization_review_items(release_decision) + + git = plan.subject.git + if ( + git.snapshot_kind != "committed_tree" + or git.worktree_overlay_sha256 is not None + or git.base_commit_sha is None + or git.base_tree_sha is None + or git.head_tree_sha is None + or git.head_commit_sha is None + or git.merge_base_sha is None + or git.source_head_commit_sha is None + or git.source_head_commit_sha != git.head_commit_sha + ): + raise ValueError( + "authorized execution requires the exact evaluated commit as its source" + ) + plugins_enabled = plan.inputs.options.get("plugins_enabled") + if plugins_enabled is not False: + raise ValueError( + "authorized execution requires an exact plugins-disabled engine mode" + ) + git_root = authorization_workspace_root(workspace) + ensure_authorization_runtime_is_external(git_root) + validate_engine_requirement(plan.engine, plugins_enabled=plugins_enabled) + expected_receipt_path = (root / "verification-receipt.json").resolve() + if receipt_path.resolve() != expected_receipt_path: + raise ValueError("receipt path must name the terminal receipt in artifacts-root") + try: + root.relative_to(git_root) + expected_receipt_path.relative_to(git_root) + except ValueError as exc: + raise ValueError("authorization artifacts must remain inside the workspace") from exc + if git.repository_id != grant.statement.request.operation.destination_repository_id: + raise ValueError("signed destination differs from the verified repository") + signed_source = grant.statement.request + if signed_source.source_engine_requirement_id != plan.engine.engine_requirement_id: + raise ValueError("signed source engine differs from the current verified engine") + if signed_source.source_executor_id != verifier.executor_id: + raise ValueError("signed source executor differs from the current verified executor") + + expected_request = build_human_authorization_request( + repository_id=git.repository_id, + source_receipt_id=signed_source.source_receipt_id, + source_artifact_set_id=signed_source.source_artifact_set_id, + source_engine_requirement_id=signed_source.source_engine_requirement_id, + source_executor_id=signed_source.source_executor_id, + verification_request_id=receipt.request_id, + subject_id=receipt.subject_id, + decision_id=receipt.decision_id, + base_commit_sha=git.base_commit_sha, + merge_base_sha=git.merge_base_sha, + base_tree_sha=git.base_tree_sha, + head_tree_sha=git.head_tree_sha, + source_head_commit_sha=git.source_head_commit_sha, + review_items=review_items, + operation=grant.statement.request.operation, + ) + expected_command = authorization_execute_command( + workspace=git_root, + receipt=expected_receipt_path, + artifacts_root=root, + ) + + def revalidate_authority() -> None: + """Reload every revocable boundary immediately before push dispatch.""" + + current_receipt, current_artifacts = load_validated_receipt_artifacts( + receipt_path=expected_receipt_path, + root=root, + allowed_artifact_names=_AUTHORIZATION_RECEIPT_ARTIFACTS, + max_artifact_count=20, + max_total_size=128 * 1024 * 1024, + ) + if current_receipt != receipt or current_artifacts != artifacts: + raise ValueError("authorization receipt closure changed before execution") + validate_engine_requirement(plan.engine, plugins_enabled=plugins_enabled) + current = evaluate_human_authorization( + grant, + trust_policy_path=default_human_authorization_trust_policy_path(), + workspace=git_root, + expected_request=expected_request, + expected_review_items=review_items, + ) + if current.status != "accepted": + reasons = ", ".join(current.reason_codes) or "authorization_rejected" + raise ValueError(f"authorization is not currently executable: {reasons}") + payload = current.model_dump(mode="json") + payload["command"] = expected_command + if AuthorizationEvaluationV1.model_validate(payload) != verifier.authorization: + raise ValueError("bound verifier authorization disagrees with current validation") + + revalidate_authority() + if verifier.control.allowed_next_commands != [expected_command]: + raise ValueError("bound verifier control does not expose exactly one guarded command") + + # ``plan``, ``report``, ``verifier``, and ``grant`` all came from the + # same immutable, hash-validated byte snapshot loaded above. + result = execute_pinned_git_push( + grant.statement.request.operation, + workspace=git_root, + expected_source_tree_sha=git.head_tree_sha, + revalidate_authority=revalidate_authority, + ) + if result.returncode != 0: + detail = result.stderr.strip() or f"exit {result.returncode}" + raise _AuthorizedOperationFailed( + f"authorized Git push failed without changing authority: {detail}" + ) + typer.echo( + json.dumps( + { + "executed": True, + "authorization_id": grant.authorization_id, + "operation_id": grant.statement.request.operation.operation_id, + }, + sort_keys=True, + ) + ) + except _AuthorizedOperationFailed as exc: + guidance = ( + "Inspect the protected executor failure, repair the host or destination, and " + "rerun verification before retrying the guarded command." + ) + _emit_authorization_error("other_error", exc, guidance) + raise typer.Exit(4) from exc + except ( + ConfigError, + OSError, + ValueError, + ValidationError, + json.JSONDecodeError, + ) as exc: + guidance = ( + "Rerun verification with a current signed grant; do not run the underlying " + "Git operation directly." + ) + _emit_authorization_error("input_parse_error", exc, guidance) + raise typer.Exit(3) from exc + + +def _emit_authorization_error(error_kind: str, exc: Exception, guidance: str) -> None: + action = NextAction( + kind="review", + why=guidance, + expects="A fresh valid receipt and externally signed authorization closure.", + ).model_dump(mode="json") + typer.echo( + json.dumps( + { + "error": error_kind, + "message": str(exc), + "next_action": guidance, + "next_actions": [action], + }, + sort_keys=True, + ), + err=True, + ) + + +def _load_json_bytes(data: bytes) -> dict[str, Any]: + payload = json.loads(data.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("expected one JSON object") + return payload + + +def _required_artifact(artifacts: dict[str, bytes], name: str) -> bytes: + data = artifacts.get(name) + if data is None: + raise ValueError(f"receipt does not bind {name}") + return data + + +__all__ = ["authorization_app"] diff --git a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py index 96261381..ecceec2e 100644 --- a/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py +++ b/src/agents_shipgate/cli/discovery/agent_instructions/renderers/local_contract.py @@ -11,7 +11,12 @@ def render_file() -> str: return render_local_agent_contract() -PRIOR_RENDER_SHA256: tuple[str, ...] = () +# Exact render shipped by local contract schema v6. Keeping this hash lets +# first-adoption reruns upgrade an untouched managed file to v7 without +# overwriting user-authored JSON. +PRIOR_RENDER_SHA256: tuple[str, ...] = ( + "85d33d005d35f933b72e32c2d370efc2680e09d2ebe0c9997931c8ab4f352738", +) __all__ = ["PRIOR_RENDER_SHA256", "render_file"] diff --git a/src/agents_shipgate/cli/discovery/local_contract.py b/src/agents_shipgate/cli/discovery/local_contract.py index c77074c7..de586019 100644 --- a/src/agents_shipgate/cli/discovery/local_contract.py +++ b/src/agents_shipgate/cli/discovery/local_contract.py @@ -31,6 +31,12 @@ HOST_GRANTS_BASELINE_SCHEMA_VERSION, HOST_GRANTS_DRIFT_SCHEMA_VERSION, HOST_GRANTS_INVENTORY_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_SCHEMA_PATH, + HUMAN_AUTHORIZATION_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH, + HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION, MCP_TOOLS, MERGE_VERDICTS, MINIMUM_CONTROL_CONTRACT_VERSION, @@ -48,7 +54,7 @@ ) from agents_shipgate.schemas.verifier import VerifierArtifact -LOCAL_CONTRACT_SCHEMA_VERSION = "6" +LOCAL_CONTRACT_SCHEMA_VERSION = "7" LOCAL_CONTRACT_RELATIVE_PATH = ".shipgate/agent-contract.json" @@ -74,6 +80,12 @@ class LocalAgentContract(BaseModel): verification_unit_result_schema_version: str verification_artifact_manifest_schema_version: str verification_receipt_schema_version: str + human_authorization_request_schema_version: str + human_authorization_schema_version: str + human_authorization_evaluation_schema_version: str + human_authorization_trust_policy_schema_version: str + human_authorization_trust_policy_default_path: str + human_authorization_schema_path: str agent_handoff_schema_version: str agent_handoff_schema_path: str agent_handoff_artifact: str @@ -125,6 +137,20 @@ def build_local_agent_contract() -> LocalAgentContract: VERIFICATION_ARTIFACT_MANIFEST_SCHEMA_VERSION ), verification_receipt_schema_version=VERIFICATION_RECEIPT_SCHEMA_VERSION, + human_authorization_request_schema_version=( + HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION + ), + human_authorization_schema_version=HUMAN_AUTHORIZATION_SCHEMA_VERSION, + human_authorization_evaluation_schema_version=( + HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION + ), + human_authorization_trust_policy_schema_version=( + HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION + ), + human_authorization_trust_policy_default_path=( + HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH + ), + human_authorization_schema_path=HUMAN_AUTHORIZATION_SCHEMA_PATH, agent_handoff_schema_version=AGENT_HANDOFF_SCHEMA_VERSION, agent_handoff_schema_path=AGENT_HANDOFF_SCHEMA_PATH, agent_handoff_artifact=ARTIFACTS["agent_handoff"], diff --git a/src/agents_shipgate/cli/main.py b/src/agents_shipgate/cli/main.py index abe5c07c..c4fbf498 100644 --- a/src/agents_shipgate/cli/main.py +++ b/src/agents_shipgate/cli/main.py @@ -18,6 +18,7 @@ from agents_shipgate.cli.agent_interface import agent_app from agents_shipgate.cli.apply_patches import apply_patches as _apply_patches_command from agents_shipgate.cli.attest import _attest_command +from agents_shipgate.cli.authorization import authorization_app from agents_shipgate.cli.bootstrap import bootstrap as _bootstrap_command from agents_shipgate.cli.capability import capability_app from agents_shipgate.cli.check import check as _check_command @@ -196,6 +197,7 @@ def _mcp_serve_command() -> None: app.add_typer(org_app, name="org", hidden=True) app.add_typer(registry_app, name="registry", hidden=True) app.add_typer(verification_app, name="verification", hidden=True) +app.add_typer(authorization_app, name="authorization", hidden=True) logger = logging.getLogger(__name__) diff --git a/src/agents_shipgate/cli/org.py b/src/agents_shipgate/cli/org.py index 208e172e..e31e7889 100644 --- a/src/agents_shipgate/cli/org.py +++ b/src/agents_shipgate/cli/org.py @@ -228,7 +228,7 @@ def org_bundle( ) receipt = _load_optional_json_object(receipt_path) verified_receipt: VerificationReceipt | None = None - if verifier.get("verifier_schema_version") == "0.5": + if verifier.get("verifier_schema_version") in {"0.5", "0.6"}: try: if receipt is None: raise ValueError("current verifier evidence is missing verification-receipt.json") diff --git a/src/agents_shipgate/cli/verification.py b/src/agents_shipgate/cli/verification.py index cdda9826..3d723389 100644 --- a/src/agents_shipgate/cli/verification.py +++ b/src/agents_shipgate/cli/verification.py @@ -18,7 +18,9 @@ merge_base_sha, ref_exists, repository_identity, + resolve_source_head_identity, tree_sha, + validate_source_head_identity, working_tree_context, ) from agents_shipgate.core.agent_handoff import build_agent_handoff @@ -81,6 +83,7 @@ def prepare( policy_paths = [_under(root, path) for path in policy_packs or []] baseline_path = _under(root, baseline) if baseline is not None else None diff_from_path = _under(root, diff_from) if diff_from is not None else None + git_identity = _git_identity(root, base, head_ref) if head is None: plan = build_verification_plan( @@ -91,7 +94,8 @@ def prepare( base_ref=base, head_ref=head_ref, archived_head=False, - **_git_identity(root, base, head_ref), + source_head_commit_sha=None, + **git_identity, changed_files=changed, diff_text=diff_text, baseline_path=baseline_path, @@ -106,6 +110,10 @@ def prepare( plugins_enabled=False if no_plugins else None, ) else: + source_identity = resolve_source_head_identity( + root, + head_ref=head_ref, + ) with tempfile.TemporaryDirectory(prefix="agents-shipgate-plan-") as tmp: snapshot = Path(tmp) / "snapshot" archive_tree(root, head_ref, snapshot) @@ -117,7 +125,8 @@ def prepare( base_ref=base, head_ref=head_ref, archived_head=True, - **_git_identity(root, base, head_ref), + source_head_commit_sha=source_identity.source_head_commit_sha, + **git_identity, changed_files=changed, diff_text=diff_text, baseline_path=_map(snapshot, root, baseline_path), @@ -486,7 +495,17 @@ def _validate_git_subject(plan: VerificationPlan, workspace: Path) -> None: ) if actual_merge_base != subject.merge_base_sha: raise ValueError("worker merge base does not match the plan") + if subject.snapshot_kind == "committed_tree": + if subject.head_commit_sha is None: + raise ValueError("committed plan lacks its exact evaluated head commit") + validate_source_head_identity( + root, + evaluated_head_commit_sha=subject.head_commit_sha, + source_head_commit_sha=subject.source_head_commit_sha, + ) if subject.snapshot_kind == "worktree_overlay": + if subject.source_head_commit_sha is not None: + raise ValueError("worktree-overlay plan cannot carry source-head authority") if commit_sha(root, "HEAD") != subject.head_commit_sha: raise ValueError("worker HEAD does not match the worktree-overlay plan") rows: list[dict[str, Any]] = [] diff --git a/src/agents_shipgate/cli/verify/command.py b/src/agents_shipgate/cli/verify/command.py index c66bd51e..7a8e98f7 100644 --- a/src/agents_shipgate/cli/verify/command.py +++ b/src/agents_shipgate/cli/verify/command.py @@ -121,6 +121,14 @@ def verify( "--diff-from", help="Explicit prior report.json or baseline JSON for head diff.", ), + authorization: Path | None = typer.Option( + None, + "--authorization", + help=( + "Signed shipgate.human_authorization/v1 grant emitted by a trusted " + "coding host. The grant must live outside the evaluated workspace." + ), + ), policy_packs: list[Path] | None = typer.Option( None, "--policy-pack", @@ -169,6 +177,8 @@ def verify( raise ConfigError("--ci-mode must be advisory or strict") parsed_fail_on = _parse_fail_on(fail_on) parsed_pr_comment_style = _parse_pr_comment_style(pr_comment_style) + if preview and authorization is not None: + raise ConfigError("--authorization cannot be combined with --preview") except ConfigError as exc: typer.echo(f"Config error: {exc}", err=True) guidance = "Fix the invalid CLI flag value referenced in the error and re-run verify." @@ -211,6 +221,7 @@ def verify( baseline=baseline, baseline_mode=baseline_mode, diff_from=diff_from, + authorization=authorization, policy_packs=policy_packs, plugins_enabled=False if no_plugins else None, strict_plugins=strict_plugins, diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py index 66a7e7f6..3ddab58d 100644 --- a/src/agents_shipgate/cli/verify/git.py +++ b/src/agents_shipgate/cli/verify/git.py @@ -1,13 +1,20 @@ from __future__ import annotations +import hashlib import os import re import subprocess +import tempfile +import unicodedata from dataclasses import dataclass from pathlib import Path +from typing import Any, Literal from urllib.parse import urlsplit from agents_shipgate.core.errors import ConfigError +from agents_shipgate.schemas.human_authorization import canonical_https_git_endpoint + +_GIT_OBJECT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") def ensure_git_workspace(workspace: Path) -> Path: @@ -37,6 +44,15 @@ class DefaultBaseDetection: notes: list[str] +@dataclass(frozen=True) +class GitPushEndpoint: + """One remote selector resolved to an immutable authorization endpoint.""" + + selector: str + push_url: str + repository_id: str + + def commit_sha(workspace: Path, ref: str) -> str | None: result = _run_git( workspace, @@ -48,6 +64,111 @@ def commit_sha(workspace: Path, ref: str) -> str | None: return result.stdout.strip() or None +def active_replace_refs(workspace: Path) -> list[str]: + """Return active Git object-replacement refs. + + Verification always disables replacement-object resolution, but an + authorization route also rejects a checkout that contains any replace + refs. That keeps the reviewed identity and the later executable source + unambiguous even for callers that inspect the repository independently. + """ + + result = _run_git( + workspace, + ["for-each-ref", "--format=%(refname)", "refs/replace/"], + check=False, + ) + if result.returncode != 0: + raise ConfigError("Could not inspect Git replacement refs") + return sorted(line.strip() for line in result.stdout.splitlines() if line.strip()) + + +SourceHeadRelation = Literal["evaluated_head", "authorization_ineligible"] + + +@dataclass(frozen=True) +class SourceHeadIdentity: + """Resolved evaluated/source commits and their validated relationship.""" + + evaluated_head_commit_sha: str + source_head_commit_sha: str | None + relation: SourceHeadRelation + + +def validate_source_head_identity( + workspace: Path, + *, + evaluated_head_commit_sha: str, + source_head_commit_sha: str | None, +) -> SourceHeadRelation: + """Require executable source authority to equal the evaluated commit. + + A missing source is a valid receipt state but is authorization-ineligible. + No parent, ancestry, or host assertion can authorize a distinct commit: + Git permits a merge commit to use an arbitrary tree unrelated to a parent. + """ + + if not _GIT_OBJECT_RE.fullmatch(evaluated_head_commit_sha): + raise ValueError("evaluated head must be one full lowercase Git object ID") + if commit_sha(workspace, evaluated_head_commit_sha) != evaluated_head_commit_sha: + raise ValueError("the exact evaluated head commit is unavailable locally") + if source_head_commit_sha is None: + return "authorization_ineligible" + if not _GIT_OBJECT_RE.fullmatch(source_head_commit_sha): + raise ValueError("source head must be one full lowercase Git object ID") + if commit_sha(workspace, source_head_commit_sha) != source_head_commit_sha: + raise ValueError("the exact source head commit is unavailable locally") + if source_head_commit_sha != evaluated_head_commit_sha: + raise ValueError("authorization source head must equal the evaluated head commit") + return "evaluated_head" + + +def resolve_source_head_identity( + workspace: Path, + *, + head_ref: str, + github_actions: bool = False, + event_name: str | None = None, + evaluated_head_sha: str | None = None, +) -> SourceHeadIdentity: + """Resolve direct authority or mark a synthetic PR merge ineligible. + + Local and explicitly overridden heads authorize only themselves. The + default GitHub ``pull_request`` merge commit is useful verification + evidence, but cannot authorize pushing its distinct PR source; callers + must explicitly verify that source commit in a separate run. + """ + + evaluated = commit_sha(workspace, head_ref) + if evaluated is None: + raise ValueError(f"evaluated head ref is unavailable locally: {head_ref}") + evaluated_hint = _normalized_sha_hint( + evaluated_head_sha, + label="action evaluated head", + ) + is_default_pr_merge = ( + github_actions + and (event_name or "").strip() == "pull_request" + and evaluated_hint == evaluated + ) + source = None if is_default_pr_merge else evaluated + relation = validate_source_head_identity( + workspace, + evaluated_head_commit_sha=evaluated, + source_head_commit_sha=source, + ) + return SourceHeadIdentity(evaluated, source, relation) + + +def _normalized_sha_hint(value: str | None, *, label: str) -> str | None: + if value is None or not value.strip(): + return None + normalized = value.strip() + if not _GIT_OBJECT_RE.fullmatch(normalized): + raise ValueError(f"{label} must be one full lowercase Git object ID") + return normalized + + def detect_default_base(workspace: Path, head: str = "HEAD") -> str | None: """Best-effort default base ref for PR-style diff enrichment. @@ -165,6 +286,35 @@ def repository_identity(workspace: Path) -> str: return normalized or f"local:{workspace.name}" +def resolve_git_push_endpoint(workspace: Path, selector: str) -> GitPushEndpoint: + """Resolve a local remote name once to a canonical HTTPS push endpoint. + + ``selector`` is deliberately not returned as an executable destination. + Authorization signs the concrete URL and repository identity so later + mutation of ``remote.`` cannot retarget the operation. + """ + + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", selector): + raise ConfigError("Git remote selector contains an unsafe token") + result = _run_git( + workspace, + ["remote", "get-url", "--push", selector], + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + raise ConfigError(f"Git remote {selector!r} has no resolvable push URL") + raw_url = result.stdout.strip() + try: + push_url, repository_id = canonical_https_git_endpoint(raw_url) + except ValueError as exc: + raise ConfigError(f"Git remote {selector!r} has an unsafe push URL: {exc}") from exc + return GitPushEndpoint( + selector=selector, + push_url=push_url, + repository_id=repository_id, + ) + + def _normalize_repository_url(value: str) -> str | None: """Normalize common HTTPS/SSH Git locators without credentials.""" @@ -241,28 +391,185 @@ def archive_tree(workspace: Path, ref: str, destination: Path) -> None: """Materialize exact Git blobs without export-ignore or substitutions.""" destination.mkdir(parents=True, exist_ok=True) - listing = _run_git(workspace, ["ls-tree", "-r", "-z", ref], text=False).stdout + if any(destination.iterdir()): + raise ConfigError("Git archive destination must be empty") + commit = commit_sha(workspace, ref) + if commit is None: + raise ConfigError(f"Git archive ref is unavailable: {ref}") + with tempfile.TemporaryDirectory(prefix="agents-shipgate-git-snapshot-") as raw: + git_dir = Path(raw) / "git" + _copy_verified_commit_graph(workspace, commit=commit, git_dir=git_dir) + _materialize_isolated_tree(git_dir, commit=commit, destination=destination) + + +def _copy_verified_commit_graph(workspace: Path, *, commit: str, git_dir: Path) -> None: + """Copy one reachable object graph and verify it independently.""" + + object_format = _run_git(workspace, ["rev-parse", "--show-object-format"]).stdout.strip() + if object_format not in {"sha1", "sha256"}: + raise ConfigError(f"Unsupported Git object format: {object_format!r}") + init_args = ["git", "--no-replace-objects", "init", "--quiet", "--bare"] + if object_format == "sha256": + init_args.append("--object-format=sha256") + init_args.append(str(git_dir)) + env = _git_object_environment() + initialized = _run_process( + init_args, + capture_output=True, + check=False, + env=env, + text=True, + timeout=60, + ) + if initialized.returncode != 0: + raise ConfigError(f"Could not initialize isolated Git store: {initialized.stderr.strip()}") + + pack_path = git_dir.parent / "reachable.pack" + with pack_path.open("wb") as output: + packed = _run_process( + [ + "git", + "--no-replace-objects", + "-C", + str(workspace), + "pack-objects", + "--stdout", + "--revs", + ], + input=f"{commit}\n".encode("ascii"), + stdout=output, + stderr=subprocess.PIPE, + check=False, + env=env, + timeout=120, + ) + if packed.returncode != 0: + detail = packed.stderr.decode("utf-8", errors="replace").strip() + raise ConfigError(f"Could not copy verified Git objects: {detail}") + with pack_path.open("rb") as source: + indexed = _run_process( + [ + "git", + "--no-replace-objects", + f"--git-dir={git_dir}", + "index-pack", + "--stdin", + ], + stdin=source, + capture_output=True, + check=False, + env=env, + timeout=120, + ) + if indexed.returncode != 0: + detail = indexed.stderr.decode("utf-8", errors="replace").strip() + raise ConfigError(f"Copied Git objects failed index validation: {detail}") + checked = _run_git_dir( + git_dir, + ["fsck", "--strict", "--no-reflogs", "--no-dangling", commit], + check=False, + ) + if checked.returncode != 0: + detail = checked.stderr.strip() or checked.stdout.strip() + raise ConfigError(f"Copied Git object graph failed integrity validation: {detail}") + + +def _materialize_isolated_tree(git_dir: Path, *, commit: str, destination: Path) -> None: + listing = _run_git_dir(git_dir, ["ls-tree", "-r", "-z", commit], text=False).stdout root = destination.resolve() + entries: list[tuple[str, str, str]] = [] + portable_paths: dict[str, str] = {} for raw in listing.split(b"\0"): if not raw: continue metadata, raw_path = raw.split(b"\t", 1) mode, object_type, oid = metadata.decode("ascii").split(" ", 2) path_text = raw_path.decode("utf-8", errors="strict") - target = (root / path_text).resolve() - if target != root and root not in target.parents: - raise ConfigError(f"Git tree path escapes destination: {path_text}") + if "\\" in path_text: + raise ConfigError(f"Git tree path is not portable: {path_text}") + portable_parts = [ + unicodedata.normalize("NFKC", part).casefold().rstrip(" .") + for part in path_text.split("/") + ] + if any(not part for part in portable_parts): + raise ConfigError(f"Git tree path is not portable: {path_text}") + portable_key = "/".join(portable_parts) + prior = portable_paths.setdefault(portable_key, path_text) + if prior != path_text: + raise ConfigError( + "Git tree contains filesystem-colliding paths: " + f"{prior!r} and {path_text!r}" + ) if object_type != "blob" or mode in {"120000", "160000"}: raise ConfigError( f"Git tree contains unsupported external binding at {path_text} " f"(mode {mode}, type {object_type})." ) + entries.append((mode, oid, path_text)) + + object_format = _run_git_dir(git_dir, ["rev-parse", "--show-object-format"]).stdout.strip() + expected_digests: dict[str, str] = {} + for mode, oid, path_text in entries: + target = (root / path_text).resolve() + if target == root or root not in target.parents: + raise ConfigError(f"Git tree path escapes destination: {path_text}") target.parent.mkdir(parents=True, exist_ok=True) - blob = _run_git(workspace, ["cat-file", "blob", oid], text=False).stdout + blob = _run_git_dir(git_dir, ["cat-file", "blob", oid], text=False).stdout + if _git_object_id("blob", blob, algorithm=object_format) != oid: + raise ConfigError(f"Git blob failed object-ID validation: {path_text}") target.write_bytes(blob) + expected_digests[path_text] = hashlib.sha256(blob).hexdigest() if mode == "100755": os.chmod(target, 0o755) + materialized = { + path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*") + if path.is_file() and not path.is_symlink() + } + if materialized != expected_digests: + raise ConfigError("Materialized Git tree differs from the verified object graph") + + +def _git_object_id(kind: str, data: bytes, *, algorithm: str) -> str: + digest = hashlib.new(algorithm) + digest.update(f"{kind} {len(data)}\0".encode("ascii")) + digest.update(data) + return digest.hexdigest() + + +def _git_object_environment() -> dict[str, str]: + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_ALLOW_PROTOCOL": "", + "GIT_NO_LAZY_FETCH": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_PROTOCOL_FROM_USER": "0", + } + ) + return env + + +def _run_git_dir( + git_dir: Path, + args: list[str], + *, + check: bool = True, + text: bool = True, +) -> subprocess.CompletedProcess: + return _run_process( + ["git", "--no-replace-objects", f"--git-dir={git_dir}", *args], + capture_output=True, + check=check, + env=_git_object_environment(), + text=text, + timeout=120, + ) + def _run_git( workspace: Path, @@ -271,16 +578,55 @@ def _run_git( check: bool = True, text: bool = True, ) -> subprocess.CompletedProcess: - cmd = ["git", "-C", str(workspace), *args] - return subprocess.run( + cmd = ["git", "--no-replace-objects", "-C", str(workspace), *args] + env = os.environ.copy() + env.update( + { + "GIT_ALLOW_PROTOCOL": "", + "GIT_NO_LAZY_FETCH": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_PROTOCOL_FROM_USER": "0", + } + ) + return _run_process( cmd, capture_output=True, check=check, + env=env, text=text, timeout=60, ) +def _run_process( + cmd: list[str], + *, + capture_output: bool = False, + check: bool = False, + env: dict[str, str], + text: bool = False, + timeout: int, + input: bytes | None = None, + stdout: Any = None, + stderr: Any = None, + stdin: Any = None, +) -> subprocess.CompletedProcess: + """Single audited no-shell process boundary for local Git plumbing.""" + + return subprocess.run( + cmd, + capture_output=capture_output, + check=check, + env=env, + input=input, + stderr=stderr, + stdin=stdin, + stdout=stdout, + text=text, + timeout=timeout, + ) + + def staged_paths_under(workspace: Path, subdir: str) -> list[str]: """Return staged (index) paths under ``subdir``, relative to ``workspace``. @@ -302,6 +648,7 @@ def staged_paths_under(workspace: Path, subdir: str) -> list[str]: __all__ = [ + "active_replace_refs", "archive_tree", "commit_date", "commit_sha", @@ -311,11 +658,16 @@ def staged_paths_under(workspace: Path, subdir: str) -> list[str]: "diff_context", "ensure_git_workspace", "git_path", + "GitPushEndpoint", "merge_base_sha", "read_file_at_ref", "repository_identity", + "resolve_git_push_endpoint", + "resolve_source_head_identity", "ref_exists", + "SourceHeadIdentity", "staged_paths_under", "tree_sha", + "validate_source_head_identity", "working_tree_context", ] diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index 3c704797..f552342a 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -17,6 +17,10 @@ from agents_shipgate.cli.scan.orchestrator import run_scan from agents_shipgate.core.agent_control import derive_agent_control from agents_shipgate.core.agent_handoff import build_agent_handoff +from agents_shipgate.core.authorization_execution import ( + authorization_execute_command, + ensure_authorization_runtime_is_external, +) from agents_shipgate.core.capability_lock import ( DEFAULT_CAPABILITY_LOCK_PATH, diff_capability_locks, @@ -26,6 +30,10 @@ ) from agents_shipgate.core.errors import AgentsShipgateError, ConfigError, InputParseError from agents_shipgate.core.evaluation_clock import use_evaluation_date +from agents_shipgate.core.human_authorization import ( + default_human_authorization_trust_policy_path, + evaluate_human_authorization, +) from agents_shipgate.core.verification_identity import ( build_executor, build_terminal_receipt, @@ -46,6 +54,12 @@ HumanControlAction, ) from agents_shipgate.schemas.capabilities import CapabilityLockDiffV1, CapabilityLockFileV1 +from agents_shipgate.schemas.human_authorization import ( + AuthorizationEvaluationV1, + HumanAuthorizationV1, + authorization_review_items, + build_human_authorization_request, +) from agents_shipgate.schemas.report import ReadinessReport, ReleaseDecision from agents_shipgate.schemas.verification import VerificationContext from agents_shipgate.schemas.verification_identity import VerificationPlan, content_id @@ -68,6 +82,7 @@ from .capability_review import build_capability_review from .fix_task import FORBIDDEN_SHORTCUTS, build_fix_task from .git import ( + active_replace_refs, archive_tree, commit_date, commit_sha, @@ -79,6 +94,7 @@ read_file_at_ref, ref_exists, repository_identity, + resolve_source_head_identity, tree_sha, working_tree_context, ) @@ -89,6 +105,7 @@ HEAD_PACKET_FORMATS = ["json"] DEFAULT_OUT_DIR = Path("agents-shipgate-reports") BASE_CACHE_KEEP_ENTRIES = 16 +MAX_HUMAN_AUTHORIZATION_BYTES = 1024 * 1024 def run_verify( @@ -112,6 +129,7 @@ def run_verify( verbose: bool, pr_comment_style: str = "capability-review", auto_base: bool = False, + authorization: Path | None = None, ) -> tuple[VerifierArtifact, ReadinessReport | None, int]: git_root = ensure_git_workspace(workspace.resolve()) config_path = _resolve_under_workspace(git_root, config) @@ -547,13 +565,17 @@ def capture_capability_lock(lock: CapabilityLockFileV1) -> None: input_root=head_input_root, diff_text=diff_text, diff_from_path=base_report, + authorization_path=authorization, verification_options={ "archive_head": archive_head, "baseline_mode": baseline_mode, "strict_plugins": strict_plugins, "suggest_patches": suggest_patches, - "source_head_commit_sha": os.getenv("SOURCE_HEAD_SHA") or None, - "evaluated_head_commit_sha": os.getenv("GITHUB_SHA") or None, + "evaluated_head_commit_sha": (os.getenv("EVALUATED_HEAD_SHA") or None), + "github_actions": os.getenv("GITHUB_ACTIONS") == "true", + "github_event_name": ( + os.getenv("EVENT_NAME") or os.getenv("GITHUB_EVENT_NAME") or None + ), }, evaluation_date=verification_date, ) @@ -1177,6 +1199,7 @@ def _build_verifier( applicability=applicability, can_merge_without_human=can_merge, control=control, + authorization=AuthorizationEvaluationV1.not_requested(), headline=headline, fix_task=fix_task, forbidden_file_edits=list(PROTECTED_FILE_EDITS), @@ -1263,6 +1286,7 @@ def _clear_trusted_handoff(out_dir: Path) -> None: "verification-unit-result.json", "verification-artifacts.json", "verification-receipt.json", + "human-authorization.json", ): path = out_dir / name if path.is_file() or path.is_symlink(): @@ -1270,6 +1294,179 @@ def _clear_trusted_handoff(out_dir: Path) -> None: path.unlink() +def _apply_authorization_overlay( + verifier: VerifierArtifact, + evaluation: AuthorizationEvaluationV1, +) -> None: + """Project a trusted, exact operation onto control without changing the gate. + + The signed authorization is operational evidence only. It can replace a + human stop with one exact coding-agent command, but it cannot make the PR + mergeable, alter the release decision, or authorize completion. Build and + validate the complete replacement state before mutating ``verifier`` so an + incompatible evaluation fails atomically. + """ + + update: dict[str, Any] = {"authorization": evaluation.model_dump(mode="json")} + if evaluation.status == "accepted": + if ( + verifier.execution != "succeeded" + or verifier.decision != "review_required" + or verifier.merge_verdict != "human_review_required" + or verifier.can_merge_without_human + ): + raise ValueError( + "accepted human authorization can only overlay a succeeded " + "review_required verifier result" + ) + command = evaluation.command + if not command: + raise ValueError("accepted human authorization must carry an exact command") + reason = ( + "A trusted human authorization permits exactly one bound operation; " + "the static release decision and merge authority remain unchanged." + ) + update.update( + { + "control": derive_agent_control( + reason=reason, + next_action=CodingAgentCommandAction( + kind="repair", + command=command, + why=reason, + ), + verify_required=True, + allowed_next_commands=[command], + ).model_dump(mode="json"), + "fix_task": None, + } + ) + + payload = verifier.model_dump(mode="json") + payload.update(update) + validated = VerifierArtifact.model_validate(payload) + verifier.authorization = validated.authorization + if evaluation.status == "accepted": + verifier.control = validated.control + verifier.fix_task = validated.fix_task + + +def _evaluate_authorization_overlay( + *, + authorization_path: Path | None, + verifier: VerifierArtifact, + report: ReadinessReport | None, + plan: VerificationPlan, + workspace: Path, + authorization_command: str, +) -> tuple[AuthorizationEvaluationV1, HumanAuthorizationV1 | None]: + """Evaluate an external grant against the just-recomputed verifier graph.""" + + if authorization_path is None: + return AuthorizationEvaluationV1.not_requested(), None + if verifier.execution != "succeeded" or verifier.decision != "review_required": + return ( + AuthorizationEvaluationV1.not_applicable("release_decision_not_review_required"), + None, + ) + if plan.inputs.options.get("plugins_enabled") is not False: + return ( + AuthorizationEvaluationV1.not_applicable( + "authorization_requires_plugins_disabled" + ), + None, + ) + try: + if report is None: + raise ValueError("authorization requires a release report") + ensure_authorization_runtime_is_external(workspace) + if active_replace_refs(workspace): + raise ValueError("authorization rejects repositories with Git replace refs") + grant = _load_external_human_authorization( + authorization_path, + workspace=workspace, + ) + review_items = authorization_review_items(report.release_decision.model_dump(mode="json")) + git = plan.subject.git + if git.snapshot_kind != "committed_tree" or git.worktree_overlay_sha256 is not None: + raise ValueError("authorization requires a committed Git subject") + if not ( + git.base_commit_sha + and git.merge_base_sha + and git.base_tree_sha + and git.head_tree_sha + and git.source_head_commit_sha + ): + raise ValueError("authorization requires complete committed PR tree identity") + signed_source = grant.statement.request + if signed_source.source_engine_requirement_id != plan.engine.engine_requirement_id: + raise ValueError("authorization source engine differs from the current engine") + if signed_source.source_executor_id != verifier.executor_id: + raise ValueError("authorization source executor differs from the current executor") + expected_request = build_human_authorization_request( + repository_id=git.repository_id, + source_receipt_id=signed_source.source_receipt_id, + source_artifact_set_id=signed_source.source_artifact_set_id, + source_engine_requirement_id=signed_source.source_engine_requirement_id, + source_executor_id=signed_source.source_executor_id, + verification_request_id=plan.request_id, + subject_id=plan.subject.subject_id, + decision_id=verifier.decision_id or "", + base_commit_sha=git.base_commit_sha, + merge_base_sha=git.merge_base_sha, + base_tree_sha=git.base_tree_sha, + head_tree_sha=git.head_tree_sha, + source_head_commit_sha=git.source_head_commit_sha, + review_items=review_items, + operation=grant.statement.request.operation, + ) + except (OSError, ValueError, json.JSONDecodeError): + return AuthorizationEvaluationV1.rejected("authorization_context_invalid"), None + + evaluation = evaluate_human_authorization( + grant, + trust_policy_path=default_human_authorization_trust_policy_path(), + workspace=workspace, + expected_request=expected_request, + expected_review_items=review_items, + ) + if evaluation.status == "accepted": + payload = evaluation.model_dump(mode="json") + payload["command"] = authorization_command + evaluation = AuthorizationEvaluationV1.model_validate(payload) + return evaluation, grant + + +def _load_external_human_authorization( + path: Path, + *, + workspace: Path, +) -> HumanAuthorizationV1: + """Load a signed grant only from outside the evaluated workspace.""" + + lexical = Path(os.path.abspath(path)) + resolved_workspace = workspace.resolve(strict=True) + resolved = lexical.resolve(strict=True) + if _path_is_within(lexical, Path(os.path.abspath(workspace))) or _path_is_within( + resolved, resolved_workspace + ): + raise ValueError("human authorization grant must be stored outside the workspace") + if not resolved.is_file() or resolved.stat().st_size > MAX_HUMAN_AUTHORIZATION_BYTES: + raise ValueError("human authorization grant is unavailable or exceeds the size limit") + payload = json.loads(resolved.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("human authorization grant must contain one JSON object") + return HumanAuthorizationV1.model_validate(payload) + + +def _path_is_within(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + def _write_artifacts( verifier: VerifierArtifact, verifier_path: Path, @@ -1289,6 +1486,7 @@ def _write_artifacts( input_root: Path | None = None, diff_text: str = "", diff_from_path: Path | None = None, + authorization_path: Path | None = None, verification_options: dict[str, Any] | None = None, evaluation_date: str | None = None, ) -> None: @@ -1337,6 +1535,35 @@ def _write_artifacts( resolved_date = evaluation_date or commit_date(git_root, verifier.head_ref) base_commit = commit_sha(git_root, verifier.base_ref) if verifier.base_ref else None head_commit = commit_sha(git_root, verifier.head_ref) + archived_head = resolved_input_root != git_root.resolve() + resolved_options = dict(verification_options or {}) + evaluated_hint = resolved_options.pop("evaluated_head_commit_sha", None) + github_actions = resolved_options.pop("github_actions", False) + github_event_name = resolved_options.pop("github_event_name", None) + source_head_commit: str | None = None + if archived_head: + try: + source_identity = resolve_source_head_identity( + git_root, + head_ref=verifier.head_ref, + github_actions=github_actions is True, + event_name=(github_event_name if isinstance(github_event_name, str) else None), + evaluated_head_sha=(evaluated_hint if isinstance(evaluated_hint, str) else None), + ) + except ValueError as exc: + raise InputParseError(f"Invalid committed source-head identity: {exc}") from exc + if head_commit != source_identity.evaluated_head_commit_sha: + raise InputParseError( + "Resolved verification head changed while building source-head identity" + ) + source_head_commit = source_identity.source_head_commit_sha + resolved_options.update( + { + "evaluated_head_commit_sha": source_identity.evaluated_head_commit_sha, + "source_head_commit_sha": source_identity.source_head_commit_sha, + "source_head_relation": source_identity.relation, + } + ) portable_diff_from_path: Path | None = None if diff_from_path is not None and diff_from_path.is_file(): portable_diff_from_path = verifier_path.with_name("verification-base-report.json") @@ -1351,12 +1578,13 @@ def _write_artifacts( config_logical_path=logical_config, base_ref=verifier.base_ref, head_ref=verifier.head_ref, - archived_head=resolved_input_root != git_root.resolve(), + archived_head=archived_head, repository_id=repository_identity(git_root), base_commit_sha=base_commit, base_tree_sha=( tree_sha(git_root, verifier.base_ref) if verifier.base_ref and base_commit else None ), + source_head_commit_sha=source_head_commit, head_commit_sha=head_commit, head_tree_sha=(tree_sha(git_root, verifier.head_ref) if head_commit else None), merge_base_sha=( @@ -1375,7 +1603,7 @@ def _write_artifacts( "fail_on": sorted(fail_on or []), "no_heuristics": no_heuristics, "plugins_enabled": plugins_enabled is not False, - **(verification_options or {}), + **resolved_options, }, plugins_enabled=plugins_enabled, ) @@ -1416,6 +1644,39 @@ def _write_artifacts( verifier.engine_requirement_id = plan.engine.engine_requirement_id verifier.executor_id = executor.executor_id verifier.decision_id = decision_id + evaluation, accepted_grant = _evaluate_authorization_overlay( + authorization_path=authorization_path, + verifier=verifier, + report=report, + plan=plan, + workspace=git_root, + authorization_command=authorization_execute_command( + workspace=git_root, + receipt=verifier_path.with_name("verification-receipt.json").resolve(), + artifacts_root=verifier_path.parent.resolve(), + ), + ) + try: + _apply_authorization_overlay(verifier, evaluation) + except ValueError: + # A contradictory accepted projection is an internal invariant failure, + # never a reason to leak command authority or lose verifier artifacts. + evaluation = AuthorizationEvaluationV1.rejected("authorization_overlay_invalid") + accepted_grant = None + _apply_authorization_overlay(verifier, evaluation) + if evaluation.status == "accepted" and accepted_grant is not None: + authorization_artifact = verifier_path.with_name("human-authorization.json") + authorization_artifact.write_text( + json.dumps( + accepted_grant.model_dump(mode="json"), + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + verifier.artifacts["human_authorization_json"] = _display_path( + authorization_artifact.resolve(), git_root + ) if report is not None: report.request_id = plan.request_id report.subject_id = plan.subject.subject_id @@ -1558,6 +1819,10 @@ def _verify_run_artifact_refs( ) -> dict[str, VerifyRunArtifactRef]: refs: dict[str, VerifyRunArtifactRef] = {} terminal_identity_artifacts = { + # agent-handoff.json embeds verify-run reproducibility, so including its + # pre-projection hash here would create a cycle and become stale when + # the final handoff is written immediately after verify-run.json. + "agent_handoff_json", "verify_run_json", "verification_plan_json", "verification_unit_result_json", @@ -1876,6 +2141,7 @@ def run_preview( applicability="not_evaluated", can_merge_without_human=False, control=control, + authorization=AuthorizationEvaluationV1.not_requested(), headline=headline, forbidden_file_edits=list(PROTECTED_FILE_EDITS), forbidden_actions=list(FORBIDDEN_SHORTCUTS), diff --git a/src/agents_shipgate/core/agent_handoff.py b/src/agents_shipgate/core/agent_handoff.py index d7967029..334cd5df 100644 --- a/src/agents_shipgate/core/agent_handoff.py +++ b/src/agents_shipgate/core/agent_handoff.py @@ -94,6 +94,7 @@ def build_agent_handoff( ), gate=gate, control=verifier_model.control, + authorization=verifier_model.authorization, fix_task=_dict_or_none(verifier_payload.get("fix_task")), blocked_by=_blocked_by(release_decision), remediation_plan=_remediation_plan(verifier_payload.get("fix_task")), diff --git a/src/agents_shipgate/core/authorization_execution.py b/src/agents_shipgate/core/authorization_execution.py new file mode 100644 index 00000000..2c8f1f51 --- /dev/null +++ b/src/agents_shipgate/core/authorization_execution.py @@ -0,0 +1,694 @@ +"""Guarded execution for one previously signed human authorization. + +The signed grant remains the authority. This module is only its execution +consumer: it revalidates the current repository identity, isolates Git from +workspace/global rewrite configuration, disables hooks, and invokes the typed +force-with-lease operation. It never creates or signs authority. +""" + +from __future__ import annotations + +import os +import re +import selectors +import shlex +import stat +import subprocess +import sys +import tempfile +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import agents_shipgate +from agents_shipgate.schemas.human_authorization import GitPushOperationV1 + +_AUTHORIZATION_HEADER_RE = re.compile( + r"^authorization:[ \t]+(?:basic|bearer)[ \t]+[A-Za-z0-9+/=_-]+$", + re.IGNORECASE, +) +_MAX_AUTHORIZED_GRAPH_PACK_BYTES = 512 * 1024 * 1024 +_MAX_AUTHORIZED_PROCESS_STDERR_BYTES = 1024 * 1024 + + +def _authorization_python_launcher() -> Path: + """Return the absolute launcher path without dereferencing a venv symlink.""" + + return Path(os.path.abspath(sys.executable)) + + +def authorization_execute_command( + *, + workspace: str | Path, + receipt: str | Path, + artifacts_root: str | Path, +) -> str: + """Render the sole durable command exposed to the coding agent. + + Absolute paths plus isolated Python mode remove cwd, ``PATH``, user-site, + and ``PYTHON*`` ambiguity. A production host must still launch this argv + from a sanitized, host-protected broker environment; the CLI cannot prove + that boundary from inside the child process. + """ + + workspace_path = Path(workspace).resolve() + receipt_path = Path(receipt) + if not receipt_path.is_absolute(): + receipt_path = workspace_path / receipt_path + artifacts_path = Path(artifacts_root) + if not artifacts_path.is_absolute(): + artifacts_path = workspace_path / artifacts_path + + return shlex.join( + [ + "/usr/bin/env", + "-i", + str(_authorization_python_launcher()), + "-I", + "-m", + "agents_shipgate", + "authorization", + "execute", + "--workspace", + str(workspace_path), + "--receipt", + str(receipt_path.resolve()), + "--artifacts-root", + str(artifacts_path.resolve()), + ] + ) + + +def ensure_authorization_runtime_is_external(workspace: Path) -> None: + """Reject editable/workspace-owned authorization runtimes. + + This is defense in depth for the host-owned broker precondition. A + verifier imported from the repository it is authorizing is not an + independent execution boundary and therefore cannot expose command + authority. + """ + + root = workspace.resolve(strict=True) + interpreter_launcher = _authorization_python_launcher() + interpreter = interpreter_launcher.resolve(strict=True) + package_file = Path(agents_shipgate.__file__ or "").resolve(strict=True) + for label, candidate in ( + ("Python launcher", interpreter_launcher), + ("Python interpreter", interpreter), + ("Agents Shipgate distribution", package_file), + ): + if _path_is_within(candidate, root): + raise ValueError(f"{label} must be installed outside the authorized workspace") + metadata = interpreter.stat() + if not stat.S_ISREG(metadata.st_mode) or not os.access(interpreter, os.X_OK): + raise ValueError("authorization Python interpreter is not an executable regular file") + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise ValueError("authorization Python interpreter is group/world writable") + environment_launcher = Path("/usr/bin/env").resolve(strict=True) + launcher_metadata = environment_launcher.stat() + if ( + not stat.S_ISREG(launcher_metadata.st_mode) + or not os.access(environment_launcher, os.X_OK) + or launcher_metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH) + or (hasattr(launcher_metadata, "st_uid") and launcher_metadata.st_uid != 0) + ): + raise ValueError("authorization environment launcher is not host protected") + + +def authorization_workspace_root(workspace: Path) -> Path: + """Resolve the checkout root with the protected Git/read-only environment.""" + + requested = workspace.resolve(strict=True) + git = _trusted_git_executable(requested) + return _git_root(git, requested) + + +def execute_pinned_git_push( + operation: GitPushOperationV1, + *, + workspace: Path, + expected_source_tree_sha: str, + revalidate_authority: Callable[[], None] | None = None, +) -> subprocess.CompletedProcess[str]: + """Execute a typed push in a config-isolated temporary bare Git view.""" + + requested_workspace = workspace.resolve() + git = _trusted_git_executable(requested_workspace) + root = _git_root(git, requested_workspace) + if _active_replace_refs(git, root): + raise ValueError("Git replacement refs make authorized execution ineligible") + if _repository_identity(git, root) != operation.destination_repository_id: + raise ValueError("current repository identity differs from the signed destination") + if _commit_sha(git, root, operation.source_commit_sha) != operation.source_commit_sha: + raise ValueError("current repository lacks the exact signed source commit") + if _tree_sha(git, root, operation.source_commit_sha) != expected_source_tree_sha: + raise ValueError("raw signed source tree differs from the verified tree") + + with tempfile.TemporaryDirectory(prefix="agents-shipgate-authorized-push-") as raw: + execution_root = Path(raw) + git_dir = execution_root / "git" + for relative in ("objects/info", "objects/pack", "refs/heads", "refs/tags"): + (git_dir / relative).mkdir(parents=True) + uses_sha256 = len(operation.source_commit_sha) == 64 + config_lines = [ + "[core]", + f"\trepositoryformatversion = {1 if uses_sha256 else 0}", + "\tbare = true", + "\thooksPath = /dev/null", + ] + if uses_sha256: + config_lines.extend(["[extensions]", "\tobjectFormat = sha256"]) + (git_dir / "config").write_text("\n".join(config_lines) + "\n", encoding="utf-8") + (git_dir / "HEAD").write_text("ref: refs/heads/shipgate-empty\n", encoding="ascii") + (execution_root / "home").mkdir() + (execution_root / "xdg").mkdir() + + snapshot_env = _sanitized_git_environment( + git=git, + execution_root=execution_root, + ) + _copy_verified_source_graph( + git=git, + workspace=root, + git_dir=git_dir, + source_commit_sha=operation.source_commit_sha, + env=snapshot_env, + ) + argv = [ + str(git), + "--no-replace-objects", + f"--git-dir={git_dir}", + "-c", + "core.hooksPath=/dev/null", + "-c", + "http.sslVerify=true", + "-c", + "http.followRedirects=false", + ] + isolated_tree = _run_isolated_git( + argv[:3], + ["rev-parse", f"{operation.source_commit_sha}^{{tree}}"], + cwd=root, + env=snapshot_env, + ).stdout.strip() + if isolated_tree != expected_source_tree_sha: + raise ValueError("isolated push source tree differs from the verified tree") + + push_env = dict(snapshot_env) + push_env["GIT_ALLOW_PROTOCOL"] = "https" + header = _authorization_header( + git=git, + workspace=root, + push_url=operation.push_url, + ) + if header is not None: + push_env["SHIPGATE_HTTP_AUTHORIZATION"] = header + auth_scope = _https_auth_scope(operation.push_url) + argv.extend( + [ + "--config-env", + f"http.{auth_scope}.extraHeader=SHIPGATE_HTTP_AUTHORIZATION", + ] + ) + + # Graph preparation and credential discovery can take long enough for + # a grant to expire or a key to be revoked. Reload and re-evaluate + # authority only after both are ready, immediately before dispatching + # the sole network-capable subprocess. + if revalidate_authority is not None: + revalidate_authority() + argv.extend(operation.argv()[1:]) + return _run_process( + argv, + cwd=root, + env=push_env, + text=True, + capture_output=True, + check=False, + timeout=120, + ) + + +def _sanitized_git_environment( + *, + git: Path, + execution_root: Path, +) -> dict[str, str]: + # Start from a small display-only allowlist. In particular, do not inherit + # loader injection, shell startup, proxy, CA override, SSL key logging, or + # Git config/transport variables from the coding-agent process. + env = { + key: os.environ[key] + for key in ("LANG", "LC_ALL", "LC_CTYPE") + if key in os.environ + } + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_EXEC_PATH": str(_trusted_git_exec_path(git)), + "GIT_TERMINAL_PROMPT": "0", + "GIT_NO_LAZY_FETCH": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_ALLOW_PROTOCOL": "", + "GIT_PROTOCOL_FROM_USER": "0", + "HOME": str(execution_root / "home"), + "XDG_CONFIG_HOME": str(execution_root / "xdg"), + "NO_PROXY": "*", + "no_proxy": "*", + "PATH": os.pathsep.join( + dict.fromkeys( + [ + str(git.parent), + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + ) + ), + } + ) + return env + + +def _authorization_header(*, git: Path, workspace: Path, push_url: str) -> str | None: + result = _run_process( + [ + str(git), + "--no-replace-objects", + "-C", + str(workspace), + "config", + "--get-urlmatch", + "http.extraheader", + push_url, + ], + capture_output=True, + env=_read_only_git_environment(git), + text=True, + check=False, + timeout=10, + ) + if result.returncode not in {0, 1}: + raise ValueError("could not read the repository's scoped HTTPS authorization header") + headers = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if not headers: + return None + if len(headers) != 1 or not _AUTHORIZATION_HEADER_RE.fullmatch(headers[0]): + raise ValueError("repository HTTPS authorization header is ambiguous or unsafe") + return headers[0] + + +def _https_auth_scope(push_url: str) -> str: + # Scope copied credentials to the exact canonical repository endpoint. + return push_url + + +def _trusted_git_executable(workspace: Path) -> Path: + candidate = Path("/usr/bin/git").resolve(strict=True) + if _path_is_within(candidate, workspace): + raise ValueError("host Git executable resolves inside the workspace") + _require_root_protected_executable(candidate, label="Git executable") + transport = _trusted_git_exec_path(candidate) / "git-remote-https" + _require_root_protected_executable(transport.resolve(strict=True), label="Git HTTPS helper") + return candidate + + +def _trusted_git_exec_path(git: Path) -> Path: + exec_path_result = _run_process( + [str(git), "--exec-path"], + capture_output=True, + env=_read_only_git_environment(git, include_exec_path=False), + text=True, + check=False, + timeout=10, + ) + if exec_path_result.returncode != 0: + raise ValueError("could not resolve the protected Git transport directory") + exec_path = Path(exec_path_result.stdout.strip()).resolve(strict=True) + if not exec_path.is_dir(): + raise ValueError("protected Git transport path is not a directory") + return exec_path + + +def _git_root(git: Path, workspace: Path) -> Path: + result = _run_git(git, workspace, ["rev-parse", "--show-toplevel"]) + root = Path(result.stdout.strip()).resolve(strict=True) + if not root.is_dir(): + raise ValueError("authorization workspace is not inside a Git checkout") + return root + + +def _commit_sha(git: Path, workspace: Path, ref: str) -> str | None: + result = _run_git( + git, + workspace, + ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + check=False, + ) + return result.stdout.strip() or None if result.returncode == 0 else None + + +def _tree_sha(git: Path, workspace: Path, ref: str) -> str | None: + result = _run_git( + git, + workspace, + ["rev-parse", "--verify", "--quiet", f"{ref}^{{tree}}"], + check=False, + ) + return result.stdout.strip() or None if result.returncode == 0 else None + + +def _active_replace_refs(git: Path, workspace: Path) -> list[str]: + result = _run_git( + git, + workspace, + ["for-each-ref", "--format=%(refname)", "refs/replace/"], + check=False, + ) + if result.returncode != 0: + raise ValueError("could not inspect Git replacement refs") + return sorted(line.strip() for line in result.stdout.splitlines() if line.strip()) + + +def _copy_verified_source_graph( + *, + git: Path, + workspace: Path, + git_dir: Path, + source_commit_sha: str, + env: dict[str, str], +) -> None: + """Pack one source graph into isolated storage and fsck it before push.""" + + pack_path = git_dir.parent / "reachable.pack" + with pack_path.open("wb") as output: + packed = _run_bounded_stdout_process( + [ + str(git), + "--no-replace-objects", + "-C", + str(workspace), + "pack-objects", + "--stdout", + "--revs", + "--missing=error", + ], + input=f"{source_commit_sha}\n".encode("ascii"), + output=output, + env=env, + max_bytes=_MAX_AUTHORIZED_GRAPH_PACK_BYTES, + timeout=120, + ) + if packed.returncode != 0: + detail = packed.stderr.decode("utf-8", errors="replace").strip() + raise ValueError(f"could not snapshot the signed Git object graph: {detail}") + if pack_path.stat().st_size > _MAX_AUTHORIZED_GRAPH_PACK_BYTES: + raise ValueError("signed Git object graph exceeds the 512 MiB execution limit") + with pack_path.open("rb") as source: + indexed = _run_process( + [ + str(git), + "--no-replace-objects", + f"--git-dir={git_dir}", + "index-pack", + "--stdin", + ], + stdin=source, + capture_output=True, + check=False, + env=env, + timeout=120, + ) + if indexed.returncode != 0: + detail = indexed.stderr.decode("utf-8", errors="replace").strip() + raise ValueError(f"signed Git object snapshot failed index validation: {detail}") + checked = _run_isolated_git( + [str(git), "--no-replace-objects", f"--git-dir={git_dir}"], + ["fsck", "--strict", "--no-reflogs", "--no-dangling", source_commit_sha], + cwd=workspace, + env=env, + ) + if checked.returncode != 0: # pragma: no cover - helper raises first + raise ValueError("signed Git object snapshot failed integrity validation") + + +def _repository_identity(git: Path, workspace: Path) -> str: + result = _run_git(git, workspace, ["remote", "get-url", "origin"], check=False) + if result.returncode != 0: + raise ValueError("current repository has no origin identity") + value = result.stdout.strip() + parsed = urlsplit(value) if "://" in value else None + if parsed is not None: + host = (parsed.hostname or "").lower() + path = parsed.path + else: + match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", value) + if match is None: + raise ValueError("current origin is not a stable network repository URL") + host, path = match.group(1).lower(), match.group(2) + normalized = path.strip("/") + if normalized.endswith(".git"): + normalized = normalized[:-4] + if not host or not normalized: + raise ValueError("current origin has no credential-free repository identity") + return f"{host}/{normalized}" + + +def _run_git( + git: Path, + workspace: Path, + args: list[str], + *, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + return _run_process( + [str(git), "--no-replace-objects", "-C", str(workspace), *args], + capture_output=True, + env=_read_only_git_environment(git), + text=True, + check=check, + timeout=10, + ) + + +def _run_isolated_git( + prefix: list[str], + args: list[str], + *, + cwd: Path, + env: dict[str, str], +) -> subprocess.CompletedProcess[str]: + result = _run_process( + [*prefix, *args], + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + if result.returncode != 0: + detail = result.stderr.strip() or f"exit {result.returncode}" + raise ValueError(f"could not resolve the isolated push source: {detail}") + return result + + +def _run_process( + cmd: list[str], + *, + capture_output: bool = False, + check: bool = False, + env: dict[str, str], + text: bool = False, + timeout: int, + cwd: Path | None = None, + input: bytes | None = None, + stdout: Any = None, + stderr: Any = None, + stdin: Any = None, +) -> subprocess.CompletedProcess: + """Single audited no-shell boundary for protected Git execution.""" + + try: + return subprocess.run( + cmd, + capture_output=capture_output, + check=check, + cwd=cwd, + env=env, + input=input, + stderr=stderr, + stdin=stdin, + stdout=stdout, + text=text, + timeout=timeout, + ) + except subprocess.SubprocessError as exc: + raise ValueError(f"protected Git subprocess failed: {exc}") from exc + + +def _run_bounded_stdout_process( + cmd: list[str], + *, + input: bytes, + output: Any, + env: dict[str, str], + max_bytes: int, + timeout: int, +) -> subprocess.CompletedProcess[bytes]: + """Stream one process stdout to disk without trusting its output size. + + Stderr goes to a separate temporary file, so neither pipe can deadlock. + The parent enforces both the byte ceiling and wall-clock deadline; this + avoids ``preexec_fn``, which is unsafe when a protected broker has threads. + """ + + deadline = time.monotonic() + timeout + try: + process = subprocess.Popen( + cmd, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as exc: + raise ValueError(f"protected Git subprocess failed to start: {exc}") from exc + + captured_stderr = bytearray() + stderr_truncated = False + try: + if ( + process.stdin is None + or process.stdout is None + or process.stderr is None + ): # pragma: no cover - fixed argv. + raise ValueError("protected Git subprocess pipes were unavailable") + try: + process.stdin.write(input) + except BrokenPipeError: + pass + finally: + try: + process.stdin.close() + except BrokenPipeError: + pass + + written = 0 + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(cmd, timeout) + events = selector.select(remaining) + if not events: + raise subprocess.TimeoutExpired(cmd, timeout) + for key, _mask in events: + chunk = os.read(key.fd, 1024 * 1024) + if not chunk: + selector.unregister(key.fileobj) + continue + if key.data == "stdout": + capacity = max_bytes - written + if len(chunk) > capacity: + raise ValueError( + "signed Git object graph exceeds the 512 MiB execution limit" + ) + output.write(chunk) + written += len(chunk) + continue + remaining_stderr = ( + _MAX_AUTHORIZED_PROCESS_STDERR_BYTES - len(captured_stderr) + ) + if remaining_stderr > 0: + captured_stderr.extend(chunk[:remaining_stderr]) + stderr_truncated = stderr_truncated or len(chunk) > remaining_stderr + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(cmd, timeout) + returncode = process.wait(timeout=remaining) + except subprocess.TimeoutExpired as exc: + raise ValueError("signed Git object graph exceeded the 120-second timeout") from exc + finally: + if process.poll() is None: + process.kill() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + for stream in (process.stdout, process.stderr): + if stream is not None: + stream.close() + if process.stdin is not None and not process.stdin.closed: + process.stdin.close() + + if stderr_truncated: + captured_stderr.extend(b"\n[stderr truncated by Agents Shipgate]\n") + return subprocess.CompletedProcess( + cmd, + returncode, + stdout=None, + stderr=bytes(captured_stderr), + ) + + +def _read_only_git_environment( + git: Path, + *, + include_exec_path: bool = True, +) -> dict[str, str]: + env = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_ALLOW_PROTOCOL": "", + "GIT_NO_LAZY_FETCH": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_PROTOCOL_FROM_USER": "0", + "GIT_TERMINAL_PROMPT": "0", + "HOME": os.devnull, + "PATH": os.pathsep.join((str(git.parent), "/usr/bin", "/bin")), + } + if include_exec_path: + env["GIT_EXEC_PATH"] = str(_trusted_git_exec_path(git)) + return env + + +def _require_root_protected_executable(path: Path, *, label: str) -> None: + metadata = path.stat() + if not stat.S_ISREG(metadata.st_mode) or not os.access(path, os.X_OK): + raise ValueError(f"{label} is not an executable regular file") + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise ValueError(f"{label} is group/world writable") + if hasattr(metadata, "st_uid") and metadata.st_uid != 0: + raise ValueError(f"{label} is not owned by the host root account") + for parent in path.parents: + parent_metadata = parent.stat() + if parent_metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise ValueError(f"{label} has an unprotected ancestor directory") + if hasattr(parent_metadata, "st_uid") and parent_metadata.st_uid != 0: + raise ValueError(f"{label} has a non-host-owned ancestor directory") + + +def _path_is_within(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +__all__ = [ + "authorization_execute_command", + "authorization_workspace_root", + "ensure_authorization_runtime_is_external", + "execute_pinned_git_push", +] diff --git a/src/agents_shipgate/core/human_authorization.py b/src/agents_shipgate/core/human_authorization.py new file mode 100644 index 00000000..1f6be226 --- /dev/null +++ b/src/agents_shipgate/core/human_authorization.py @@ -0,0 +1,419 @@ +"""Fail-closed verification for externally signed human authorizations. + +This module verifies authority; it never creates it. In particular, it has no +private-key loading or signing API. The trust policy is fixed outside the +reviewed workspace, and an accepted evaluation exposes only the exact typed +operation covered by the signature. +""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from pydantic import ValidationError + +from agents_shipgate.schemas.human_authorization import ( + HUMAN_AUTHORIZATION_SIGNATURE_DOMAIN, + HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH, + AuthorizationEvaluationV1, + HumanAuthorizationRequestV1, + HumanAuthorizationReviewItemV1, + HumanAuthorizationStatementV1, + HumanAuthorizationTrustPolicyV1, + HumanAuthorizationV1, + canonical_review_items, + decode_ed25519_public_key, + decode_ed25519_signature, + review_set_id, +) +from agents_shipgate.schemas.verification_identity import canonical_json + +_MAX_TRUST_POLICY_BYTES = 1024 * 1024 + + +class HumanAuthorizationTrustPolicyError(ValueError): + """A stable fail-closed error raised while loading the external trust root.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def default_human_authorization_trust_policy_path() -> Path: + """Return the non-workspace, non-environment-overridable default trust path. + + On POSIX, the account database is authoritative rather than ``HOME`` or + ``XDG_CONFIG_HOME`` because a child coding-agent process can override those + environment variables. The fallback exists for non-POSIX platforms only. + """ + + try: + import pwd + + home = Path(pwd.getpwuid(os.getuid()).pw_dir) + except (ImportError, KeyError, OSError): # pragma: no cover - non-POSIX fallback + home = Path.home() + relative = HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH.removeprefix("~/") + return home.joinpath(*relative.split("/")) + + +def human_authorization_signature_payload( + statement: HumanAuthorizationStatementV1, +) -> bytes: + """Return the domain-separated canonical bytes an external signer signs.""" + + return HUMAN_AUTHORIZATION_SIGNATURE_DOMAIN + canonical_json(statement) + + +def _is_within(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +def load_external_trust_policy( + path: Path, + *, + workspace: Path, +) -> HumanAuthorizationTrustPolicyV1: + """Load one fixed trust policy, rejecting workspace-controlled locations.""" + + candidate = Path(path) + if not candidate.is_absolute(): + raise HumanAuthorizationTrustPolicyError( + "trust_policy_path_not_absolute", + "human authorization trust policy path must be absolute", + ) + + workspace_lexical = Path(os.path.abspath(workspace)) + candidate_lexical = Path(os.path.abspath(candidate)) + if _is_within(candidate_lexical, workspace_lexical): + raise HumanAuthorizationTrustPolicyError( + "trust_policy_inside_workspace", + "human authorization trust policy must not be stored in the workspace", + ) + try: + workspace_resolved = workspace.resolve(strict=True) + except OSError as exc: + raise HumanAuthorizationTrustPolicyError( + "workspace_unavailable", + "authorization workspace could not be resolved", + ) from exc + try: + raw, metadata = _read_trust_policy_no_follow(candidate) + except HumanAuthorizationTrustPolicyError: + raise + except OSError as exc: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_unavailable", + "human authorization trust policy could not be read", + ) from exc + if _is_within(candidate_lexical, workspace_resolved): + raise HumanAuthorizationTrustPolicyError( + "trust_policy_inside_workspace", + "human authorization trust policy must not resolve into the workspace", + ) + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise HumanAuthorizationTrustPolicyError( + "trust_policy_insecure_permissions", + "human authorization trust policy must not be group/world writable", + ) + try: + document = json.loads(raw) + return HumanAuthorizationTrustPolicyV1.model_validate(document) + except (UnicodeDecodeError, json.JSONDecodeError, ValidationError, TypeError) as exc: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_invalid", + "human authorization trust policy is malformed or fails schema validation", + ) from exc + + +def _read_trust_policy_no_follow(path: Path) -> tuple[bytes, os.stat_result]: + """Read the policy from one no-follow descriptor chain and stable FD.""" + + parts = path.parts + if not parts or parts[0] != os.path.sep: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_path_not_absolute", + "human authorization trust policy path must be absolute", + ) + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptors: list[int] = [] + try: + current = os.open(os.path.sep, directory_flags) + descriptors.append(current) + _validate_trust_path_node(os.fstat(current), label="trust policy root", directory=True) + for part in parts[1:-1]: + current = os.open(part, directory_flags, dir_fd=current) + descriptors.append(current) + _validate_trust_path_node( + os.fstat(current), + label="trust policy ancestor", + directory=True, + ) + file_descriptor = os.open(parts[-1], file_flags, dir_fd=current) + descriptors.append(file_descriptor) + before = os.fstat(file_descriptor) + if not stat.S_ISREG(before.st_mode): + raise HumanAuthorizationTrustPolicyError( + "trust_policy_not_regular_file", + "human authorization trust policy must be a regular file", + ) + _validate_trust_path_node(before, label="trust policy file", directory=False) + if before.st_nlink != 1: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_hard_linked", + "human authorization trust policy must have exactly one hard link", + ) + if before.st_size > _MAX_TRUST_POLICY_BYTES: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_too_large", + "human authorization trust policy exceeds the size limit", + ) + chunks: list[bytes] = [] + remaining = _MAX_TRUST_POLICY_BYTES + 1 + while remaining: + chunk = os.read(file_descriptor, min(1024 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + after = os.fstat(file_descriptor) + if ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + ) != ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + ): + raise HumanAuthorizationTrustPolicyError( + "trust_policy_changed_during_read", + "human authorization trust policy changed while it was read", + ) + if len(raw) > _MAX_TRUST_POLICY_BYTES: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_too_large", + "human authorization trust policy exceeds the size limit", + ) + return raw, before + except OSError as exc: + code = "trust_policy_symlink" if exc.errno in {40, 62} else "trust_policy_unavailable" + raise HumanAuthorizationTrustPolicyError( + code, + "human authorization trust policy path is unavailable or contains a symlink", + ) from exc + finally: + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + + +def _validate_trust_path_node( + metadata: os.stat_result, + *, + label: str, + directory: bool, +) -> None: + expected_type = stat.S_ISDIR(metadata.st_mode) if directory else stat.S_ISREG(metadata.st_mode) + if not expected_type: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_path_type_invalid", + f"{label} has an invalid filesystem type", + ) + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise HumanAuthorizationTrustPolicyError( + "trust_policy_insecure_ancestor" if directory else "trust_policy_insecure_permissions", + f"{label} must not be group/world writable", + ) + effective_uid = os.geteuid() if hasattr(os, "geteuid") else None + if effective_uid is not None and metadata.st_uid not in {0, effective_uid}: + raise HumanAuthorizationTrustPolicyError( + "trust_policy_owner_mismatch", + f"{label} must be owned by root or the broker account", + ) + + +def _utc_now(now: datetime | None) -> datetime: + value = now if now is not None else datetime.now(UTC) + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("authorization evaluation clock must be timezone-aware") + return value.astimezone(UTC) + + +def _rejected( + reasons: Sequence[str], + *, + authorization: HumanAuthorizationV1 | None = None, + trust_policy: HumanAuthorizationTrustPolicyV1 | None = None, +) -> AuthorizationEvaluationV1: + statement = authorization.statement if authorization is not None else None + request = statement.request if statement is not None else None + proof = authorization.proof if authorization is not None else None + principal = statement.principal if statement is not None else None + return AuthorizationEvaluationV1.rejected( + *sorted(set(reasons)), + authorization_id=(authorization.authorization_id if authorization else None), + authorization_request_id=(request.authorization_request_id if request else None), + trust_policy_id=(trust_policy.trust_policy_id if trust_policy else None), + key_id=(proof.key_id if proof else None), + provider=(principal.provider if principal else None), + principal=(principal.subject if principal else None), + operation_id=(request.operation.operation_id if request else None), + issued_at=(statement.issued_at if statement else None), + expires_at=(statement.expires_at if statement else None), + ) + + +def evaluate_human_authorization( + authorization: HumanAuthorizationV1 | Mapping[str, Any], + *, + trust_policy_path: Path, + workspace: Path, + expected_request: HumanAuthorizationRequestV1 | Mapping[str, Any], + expected_review_items: Sequence[HumanAuthorizationReviewItemV1 | Mapping[str, Any]], + now: datetime | None = None, +) -> AuthorizationEvaluationV1: + """Verify an exact signed request against one external fixed trust policy. + + All input or trust failures return ``status='rejected'`` with no command. + Only a fully valid, current, exact-match authorization returns the one + rendered command covered by its typed ``git_push`` operation. + """ + + try: + grant = ( + authorization + if isinstance(authorization, HumanAuthorizationV1) + else HumanAuthorizationV1.model_validate(authorization) + ) + except (ValidationError, TypeError, ValueError): + return _rejected(["authorization_malformed"]) + + try: + expected = ( + expected_request + if isinstance(expected_request, HumanAuthorizationRequestV1) + else HumanAuthorizationRequestV1.model_validate(expected_request) + ) + expected_items = canonical_review_items(expected_review_items) + except (ValidationError, TypeError, ValueError): + return _rejected(["expected_authorization_context_invalid"], authorization=grant) + + try: + policy = load_external_trust_policy(trust_policy_path, workspace=workspace) + except HumanAuthorizationTrustPolicyError as exc: + return _rejected([exc.code], authorization=grant) + + request = grant.statement.request + reasons: list[str] = [] + + expected_scope_id = review_set_id(expected_items) + if expected.review_set_id != expected_scope_id: + reasons.append("expected_review_scope_invalid") + if request.review_set_id != expected_scope_id: + reasons.append("review_scope_mismatch") + if request.authorization_request_id != expected.authorization_request_id: + reasons.append("authorization_request_id_mismatch") + if request.model_dump(mode="json") != expected.model_dump(mode="json"): + reasons.append("authorization_request_mismatch") + if request.repository_id not in policy.repository_ids: + reasons.append("repository_not_trusted") + + trusted_key = next( + (key for key in policy.keys if key.key_id == grant.proof.key_id), + None, + ) + if trusted_key is None: + reasons.append("signing_key_not_trusted") + else: + if trusted_key.provider != grant.statement.principal.provider: + reasons.append("principal_provider_mismatch") + if trusted_key.principal != grant.statement.principal.subject: + reasons.append("principal_subject_mismatch") + try: + public_key = Ed25519PublicKey.from_public_bytes( + decode_ed25519_public_key(trusted_key.public_key) + ) + public_key.verify( + decode_ed25519_signature(grant.proof.signature), + human_authorization_signature_payload(grant.statement), + ) + except (InvalidSignature, ValueError): + reasons.append("signature_invalid") + + try: + evaluated_at = _utc_now(now) + except ValueError: + reasons.append("evaluation_clock_invalid") + evaluated_at = None + + if evaluated_at is not None: + skew = timedelta(seconds=policy.clock_skew_seconds) + statement = grant.statement + if statement.issued_at > evaluated_at + skew: + reasons.append("authorization_issued_in_future") + if statement.not_before > evaluated_at + skew: + reasons.append("authorization_not_yet_valid") + # Expiry is deliberately strict: clock skew never extends authority. + if evaluated_at >= statement.expires_at: + reasons.append("authorization_expired") + ttl = (statement.expires_at - statement.issued_at).total_seconds() + if ttl > policy.max_ttl_seconds: + reasons.append("authorization_ttl_exceeded") + + if trusted_key is not None: + if trusted_key.valid_from is not None: + if statement.issued_at < trusted_key.valid_from: + reasons.append("signing_key_not_yet_valid") + if evaluated_at + skew < trusted_key.valid_from: + reasons.append("signing_key_not_yet_valid") + if trusted_key.valid_until is not None: + if statement.expires_at > trusted_key.valid_until: + reasons.append("signing_key_validity_exceeded") + if evaluated_at >= trusted_key.valid_until: + reasons.append("signing_key_expired") + + if reasons: + return _rejected(reasons, authorization=grant, trust_policy=policy) + + return AuthorizationEvaluationV1( + status="accepted", + authorization_id=grant.authorization_id, + authorization_request_id=request.authorization_request_id, + trust_policy_id=policy.trust_policy_id, + key_id=grant.proof.key_id, + provider=grant.statement.principal.provider, + principal=grant.statement.principal.subject, + operation_id=request.operation.operation_id, + command=request.operation.command, + issued_at=grant.statement.issued_at, + expires_at=grant.statement.expires_at, + reason_codes=[], + ) + + +__all__ = [ + "HumanAuthorizationTrustPolicyError", + "default_human_authorization_trust_policy_path", + "evaluate_human_authorization", + "human_authorization_signature_payload", + "load_external_trust_policy", +] diff --git a/src/agents_shipgate/core/verification_identity.py b/src/agents_shipgate/core/verification_identity.py index 976af3b2..2c66e34b 100644 --- a/src/agents_shipgate/core/verification_identity.py +++ b/src/agents_shipgate/core/verification_identity.py @@ -2,11 +2,16 @@ from __future__ import annotations +import contextlib import hashlib import importlib.metadata import json +import os import platform +import stat +import tempfile import tomllib +from collections.abc import Iterator from pathlib import Path from typing import Any @@ -14,7 +19,7 @@ from packaging.requirements import Requirement from agents_shipgate import __version__ -from agents_shipgate.checks.registry import check_catalog +from agents_shipgate.checks.registry import _plugins_enabled, check_catalog from agents_shipgate.inputs.protocol import REGISTRY from agents_shipgate.schemas.verification_identity import ( VerificationArtifactManifest, @@ -56,6 +61,7 @@ def build_blob(*, path: Path, logical_path: str, source: str) -> VerificationBlo def build_engine_requirement(*, plugins_enabled: bool | None) -> VerificationEngineRequirement: + effective_plugins_enabled = _plugins_enabled(plugins_enabled) engine_distribution_sha256 = _engine_distribution_sha256() source_project = _source_project_metadata() if source_project is None: @@ -63,7 +69,7 @@ def build_engine_requirement(*, plugins_enabled: bool | None) -> VerificationEng declared_requirements = metadata.get_all("Requires-Dist") or [] else: declared_requirements = list(source_project.get("dependencies") or []) - if plugins_enabled is not False: + if effective_plugins_enabled: declared_requirements.extend(_plugin_dependency_requirements()) requirements = _installed_dependency_closure(declared_requirements) adapters = sorted(REGISTRY.clone()._adapters) # noqa: SLF001 - public contract digest. @@ -71,13 +77,16 @@ def build_engine_requirement(*, plugins_enabled: bool | None) -> VerificationEng source_project=source_project, engine_distribution_sha256=engine_distribution_sha256, ) - if plugins_enabled is not False and any( + if effective_plugins_enabled and any( entry.endswith(":unresolved-distribution") for entry in plugin_entries ): raise ValueError( "an enabled Agents Shipgate plugin has no resolvable distribution identity" ) - checks = [row.model_dump(mode="json") for row in check_catalog(plugins_enabled=plugins_enabled)] + checks = [ + row.model_dump(mode="json") + for row in check_catalog(plugins_enabled=effective_plugins_enabled) + ] payload = { "package": "agents-shipgate", "version": __version__, @@ -88,7 +97,7 @@ def build_engine_requirement(*, plugins_enabled: bool | None) -> VerificationEng "dependency_set_sha256": content_id(requirements), "adapter_set_sha256": content_id(adapters), "plugin_set_sha256": content_id( - {"enabled": plugins_enabled is not False, "entries": plugin_entries} + {"enabled": effective_plugins_enabled, "entries": plugin_entries} ), "policy_catalog_sha256": content_id(checks), } @@ -142,6 +151,7 @@ def build_verification_plan( repository_id: str, base_commit_sha: str | None, base_tree_sha: str | None, + source_head_commit_sha: str | None = None, head_commit_sha: str | None, head_tree_sha: str | None, merge_base_sha: str | None, @@ -155,19 +165,26 @@ def build_verification_plan( plugins_enabled: bool | None, diff_logical_path: str = "verification-input.diff", ) -> VerificationPlan: + effective_plugins_enabled = _plugins_enabled(plugins_enabled) + normalized_options = dict(options) + normalized_options["plugins_enabled"] = effective_plugins_enabled overlay = _worktree_overlay(git_root, changed_files) if not archived_head else [] overlay_hash = content_id(overlay) if overlay else None + if not archived_head and source_head_commit_sha is not None: + raise ValueError("worktree-overlay plans cannot declare a source head commit") + if ( + archived_head + and source_head_commit_sha is not None + and source_head_commit_sha != head_commit_sha + ): + raise ValueError("authorization source head must equal the evaluated committed head") git_subject = VerificationGitSubject( repository_id=repository_id, base_ref=base_ref, base_commit_sha=base_commit_sha, base_tree_sha=base_tree_sha, head_ref=head_ref, - source_head_commit_sha=( - str(options.get("source_head_commit_sha")) - if options.get("source_head_commit_sha") - else None - ), + source_head_commit_sha=source_head_commit_sha, head_commit_sha=head_commit_sha, head_tree_sha=head_tree_sha, merge_base_sha=merge_base_sha, @@ -215,7 +232,7 @@ def build_verification_plan( "tool_sources": tool_sources, "changed_paths": sorted(set(changed_files)), "changed_files": changed_blobs, - "options": options, + "options": normalized_options, } inputs = VerificationInputSet( input_set_id=content_id( @@ -232,7 +249,7 @@ def build_verification_plan( ), **inputs_payload, ) - engine = build_engine_requirement(plugins_enabled=plugins_enabled) + engine = build_engine_requirement(plugins_enabled=effective_plugins_enabled) task_payload = { "kind": "evaluate", "shard": 0, @@ -486,9 +503,224 @@ def validate_receipt_artifacts(receipt: VerificationReceipt, *, root: Path) -> N ): raise ValueError("verify-run outcome disagrees with the receipt") + _validate_verify_run_artifact_refs( + verify_run=verify_run, + receipt=receipt, + resolved=resolved, + ) + _validate_projection_ids(receipt, resolved) +@contextlib.contextmanager +def validated_receipt_snapshot( + *, + receipt_path: Path, + root: Path, + allowed_artifact_names: frozenset[str] | None = None, + max_artifact_count: int = 64, + max_artifact_size: int = 64 * 1024 * 1024, + max_total_size: int = 256 * 1024 * 1024, +) -> Iterator[tuple[VerificationReceipt, Path]]: + """Yield one immutable, validated snapshot of a terminal artifact closure. + + Every source file is opened beneath ``root`` with no-follow directory/file + descriptors, read once, checked against the embedded receipt, and copied + into a private temporary directory. Consumers must parse only the yielded + snapshot so validation and use operate on the exact same bytes. + """ + + source_root = root.resolve(strict=True) + expected_receipt = source_root / "verification-receipt.json" + if receipt_path.resolve(strict=True) != expected_receipt: + raise ValueError("receipt path must name the terminal receipt in artifacts-root") + receipt_bytes = _read_regular_file_beneath( + source_root, + "verification-receipt.json", + max_size=4 * 1024 * 1024, + ) + receipt = VerificationReceipt.model_validate(_json_object_bytes(receipt_bytes, "receipt")) + artifact_refs = receipt.artifact_manifest.artifacts + if len(artifact_refs) > max_artifact_count: + raise ValueError("receipt binds too many artifacts") + if allowed_artifact_names is not None: + unexpected = sorted(set(artifact_refs) - allowed_artifact_names) + if unexpected: + raise ValueError( + "receipt binds artifacts outside the allowed execution closure: " + + ", ".join(unexpected) + ) + paths = [ref.path for ref in artifact_refs.values()] + if len(paths) != len(set(paths)): + raise ValueError("receipt binds more than one artifact name to the same path") + declared_total = 0 + for name, ref in artifact_refs.items(): + if ref.size_bytes > max_artifact_size: + raise ValueError(f"receipt artifact {name!r} exceeds the trusted size limit") + declared_total += ref.size_bytes + if declared_total > max_total_size: + raise ValueError("receipt artifact closure exceeds the trusted total size limit") + + with tempfile.TemporaryDirectory(prefix="agents-shipgate-receipt-snapshot-") as raw: + snapshot_root = Path(raw) + (snapshot_root / "verification-receipt.json").write_bytes(receipt_bytes) + for name, ref in receipt.artifact_manifest.artifacts.items(): + data = _read_regular_file_beneath( + source_root, + ref.path, + max_size=ref.size_bytes, + ) + if len(data) != ref.size_bytes: + raise ValueError(f"receipt artifact {name!r} size does not match") + if sha256_bytes(data) != ref.sha256: + raise ValueError(f"receipt artifact {name!r} hash does not match") + target = snapshot_root / ref.path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + + manifest_bytes = _read_regular_file_beneath( + source_root, + "verification-artifacts.json", + max_size=4 * 1024 * 1024, + ) + external_manifest = VerificationArtifactManifest.model_validate( + _json_object_bytes(manifest_bytes, "terminal artifact manifest") + ) + if external_manifest != receipt.artifact_manifest: + raise ValueError("terminal artifact manifest disagrees with the receipt") + (snapshot_root / "verification-artifacts.json").write_bytes(manifest_bytes) + + validate_receipt_artifacts(receipt, root=snapshot_root) + yield receipt, snapshot_root + + +def load_validated_receipt_artifacts( + *, + receipt_path: Path, + root: Path, + allowed_artifact_names: frozenset[str] | None = None, + max_artifact_count: int = 64, + max_artifact_size: int = 64 * 1024 * 1024, + max_total_size: int = 256 * 1024 * 1024, +) -> tuple[VerificationReceipt, dict[str, bytes]]: + """Load receipt-bound artifact bytes from one validated snapshot.""" + + with validated_receipt_snapshot( + receipt_path=receipt_path, + root=root, + allowed_artifact_names=allowed_artifact_names, + max_artifact_count=max_artifact_count, + max_artifact_size=max_artifact_size, + max_total_size=max_total_size, + ) as ( + receipt, + snapshot_root, + ): + artifacts = { + name: (snapshot_root / ref.path).read_bytes() + for name, ref in receipt.artifact_manifest.artifacts.items() + } + return receipt, artifacts + + +def _read_regular_file_beneath(root: Path, logical_path: str, *, max_size: int) -> bytes: + parts = Path(logical_path).parts + if not parts or Path(logical_path).is_absolute() or any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"receipt artifact path is not portable: {logical_path!r}") + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptors: list[int] = [] + try: + current = os.open(root, directory_flags) + descriptors.append(current) + for part in parts[:-1]: + current = os.open(part, directory_flags, dir_fd=current) + descriptors.append(current) + file_descriptor = os.open(parts[-1], file_flags, dir_fd=current) + descriptors.append(file_descriptor) + before = os.fstat(file_descriptor) + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"receipt artifact is not a regular file: {logical_path}") + if before.st_size > max_size: + raise ValueError(f"receipt artifact exceeds its size limit: {logical_path}") + chunks: list[bytes] = [] + remaining = max_size + 1 + while remaining: + chunk = os.read(file_descriptor, min(1024 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + data = b"".join(chunks) + after = os.fstat(file_descriptor) + if ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + ) != ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + ): + raise ValueError(f"receipt artifact changed while it was read: {logical_path}") + if len(data) > max_size: + raise ValueError(f"receipt artifact exceeds its size limit: {logical_path}") + return data + except OSError as exc: + raise ValueError(f"could not safely read receipt artifact {logical_path!r}: {exc}") from exc + finally: + for descriptor in reversed(descriptors): + with contextlib.suppress(OSError): + os.close(descriptor) + + +def _json_object_bytes(data: bytes, label: str) -> dict[str, Any]: + try: + payload = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"{label} is not one UTF-8 JSON object") from exc + if not isinstance(payload, dict): + raise ValueError(f"{label} must contain one JSON object") + return payload + + +def _validate_verify_run_artifact_refs( + *, + verify_run: Any, + receipt: VerificationReceipt, + resolved: dict[str, Path], +) -> None: + """Close every non-cyclic verify-run artifact ref over receipt-bound bytes.""" + + for name, nested_ref in verify_run.artifacts.items(): + # Older v3 runs could include the initial handoff even though the final + # handoff necessarily embeds verify-run reproducibility. Treat that + # edge as non-authoritative for compatibility; current writers omit it. + if name == "agent_handoff_json": + continue + manifest_ref = receipt.artifact_manifest.artifacts.get(name) + current_path = resolved.get(name) + if manifest_ref is None or current_path is None: + raise ValueError(f"verify-run artifact {name!r} is not bound by the terminal receipt") + if not nested_ref.sha256: + raise ValueError(f"verify-run artifact {name!r} lacks a content hash") + nested_sha256 = ( + nested_ref.sha256 + if nested_ref.sha256.startswith("sha256:") + else f"sha256:{nested_ref.sha256}" + ) + if nested_sha256 != manifest_ref.sha256: + raise ValueError( + f"verify-run artifact {name!r} hash disagrees with the terminal receipt" + ) + if sha256_file(current_path) != nested_sha256: + raise ValueError( + f"verify-run artifact {name!r} hash disagrees with current artifact bytes" + ) + + def _load_json_object(path: Path) -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) diff --git a/src/agents_shipgate/schemas/agent_handoff.py b/src/agents_shipgate/schemas/agent_handoff.py index 2a27527b..a3cf6260 100644 --- a/src/agents_shipgate/schemas/agent_handoff.py +++ b/src/agents_shipgate/schemas/agent_handoff.py @@ -7,11 +7,12 @@ from agents_shipgate import __version__ from agents_shipgate.schemas.agent_control import AgentControl from agents_shipgate.schemas.disclaimers import STATIC_VERDICT_DISCLAIMER +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 from agents_shipgate.schemas.verification_identity import CONTENT_ID_PATTERN from agents_shipgate.schemas.verifier import Applicability, MergeVerdict, map_merge_verdict -AGENT_HANDOFF_SCHEMA_VERSION = "shipgate.agent_handoff/v5" -AGENT_HANDOFF_SCHEMA_PATH = "docs/agent-handoff-schema.v5.json" +AGENT_HANDOFF_SCHEMA_VERSION = "shipgate.agent_handoff/v6" +AGENT_HANDOFF_SCHEMA_PATH = "docs/agent-handoff-schema.v6.json" AgentHandoffOperation = Literal["verify_pr", "verify_local", "verify_preview"] RemediationPlanSafety = Literal["allowed", "forbidden", "patch"] @@ -176,49 +177,106 @@ class AgentHandoffArtifact(BaseModel): model_config = ConfigDict( extra="forbid", json_schema_extra={ - "if": { - "properties": { - "gate": { - "properties": {"can_merge_without_human": {"const": True}}, - "required": ["can_merge_without_human"], - } - }, - "required": ["gate"], - }, - "then": { - "properties": { - "control": { - "properties": {"state": {"const": "complete"}}, - "required": ["state"], + "allOf": [ + { + "if": { + "properties": { + "gate": { + "properties": { + "can_merge_without_human": {"const": True} + }, + "required": ["can_merge_without_human"], + } + }, + "required": ["gate"], + }, + "then": { + "properties": { + "control": { + "properties": {"state": {"const": "complete"}}, + "required": ["state"], + }, + "fix_task": {"type": "null"}, + } + }, + "else": { + "properties": { + "control": { + "properties": { + "state": { + "enum": [ + "agent_action_required", + "human_review_required", + ] + } + }, + "required": ["state"], + } + } }, - "fix_task": {"type": "null"}, - } - }, - "else": { - "properties": { - "control": { + }, + { + "if": { "properties": { - "state": { - "enum": [ - "agent_action_required", - "human_review_required", - ] + "authorization": { + "properties": {"status": {"const": "accepted"}}, + "required": ["status"], } }, - "required": ["state"], - } - } - }, + "required": ["authorization"], + }, + "then": { + "properties": { + "gate": { + "properties": { + "decision": {"const": "review_required"}, + "merge_verdict": { + "const": "human_review_required" + }, + "can_merge_without_human": {"const": False}, + }, + "required": [ + "decision", + "merge_verdict", + "can_merge_without_human", + ], + }, + "control": { + "properties": { + "state": {"const": "agent_action_required"}, + "completion_allowed": {"const": False}, + "next_action": { + "properties": {"kind": {"const": "repair"}}, + "required": ["kind"], + }, + "allowed_next_commands": { + "minItems": 1, + "maxItems": 1, + }, + }, + "required": [ + "state", + "completion_allowed", + "next_action", + "allowed_next_commands", + ], + }, + "fix_task": {"type": "null"}, + } + }, + }, + ] }, ) - schema_version: Literal["shipgate.agent_handoff/v5"] = AGENT_HANDOFF_SCHEMA_VERSION + schema_version: Literal["shipgate.agent_handoff/v6"] = AGENT_HANDOFF_SCHEMA_VERSION contract_version: str tool: AgentHandoffTool = Field(default_factory=AgentHandoffTool) operation: AgentHandoffOperation subject: AgentHandoffSubject gate: AgentHandoffGateV3 control: AgentControl + authorization: AuthorizationEvaluationV1 fix_task: dict[str, Any] | None = None blocked_by: list[AgentHandoffBlockedBy] = Field(default_factory=list) remediation_plan: list[AgentHandoffRemediationStep] = Field(default_factory=list) @@ -251,6 +309,30 @@ def _single_gate_and_control(self) -> AgentHandoffArtifact: raise ValueError("handoff control must exactly project gate merge authority") if self.control.state == "complete" and self.fix_task is not None: raise ValueError("complete handoff control cannot carry a pending fix task") + if self.authorization.status == "accepted": + if self.gate.decision != "review_required": + raise ValueError( + "accepted authorization requires a review_required release decision" + ) + if self.control.state != "agent_action_required": + raise ValueError( + "accepted authorization requires agent_action_required control" + ) + action = self.control.next_action + if action.kind != "repair" or getattr(action, "command", None) != ( + self.authorization.command + ): + raise ValueError( + "accepted authorization must exactly bind the repair command" + ) + if self.control.allowed_next_commands != [self.authorization.command]: + raise ValueError( + "accepted authorization must expose only its exact allowed command" + ) + if self.fix_task is not None: + raise ValueError( + "accepted authorization is an operational overlay, not a fix task" + ) return self diff --git a/src/agents_shipgate/schemas/contract.py b/src/agents_shipgate/schemas/contract.py index 7619961c..3b995b2b 100644 --- a/src/agents_shipgate/schemas/contract.py +++ b/src/agents_shipgate/schemas/contract.py @@ -32,6 +32,14 @@ HOST_GRANTS_DRIFT_SCHEMA_VERSION, HOST_GRANTS_INVENTORY_SCHEMA_VERSION, ) +from agents_shipgate.schemas.human_authorization import ( + HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_SCHEMA_PATH, + HUMAN_AUTHORIZATION_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH, + HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION, +) from agents_shipgate.schemas.org_evidence_bundle import ORG_EVIDENCE_BUNDLE_SCHEMA_VERSION from agents_shipgate.schemas.packet import EvidencePacket from agents_shipgate.schemas.preflight import PREFLIGHT_SCHEMA_VERSION @@ -46,7 +54,7 @@ from agents_shipgate.schemas.verifier import VerifierArtifact from agents_shipgate.schemas.verify_run import VERIFY_RUN_SCHEMA_VERSION -CONTRACT_VERSION: Literal["17"] = "17" +CONTRACT_VERSION: Literal["18"] = "18" MINIMUM_CONTROL_CONTRACT_VERSION: Literal["14"] = "14" GATING_SIGNAL: Literal["release_decision.decision"] = "release_decision.decision" AGENT_RESULT_SCHEMA_VERSION: Literal["agent_result_v2"] = "agent_result_v2" @@ -93,6 +101,7 @@ "verification_unit_result", "verification_artifact_manifest", "verification_receipt", + "human_authorization", "host_grants_inventory", "host_grants_baseline", "host_grants_drift", @@ -219,6 +228,18 @@ "agents-shipgate-reports/verification-receipt.json --artifacts-root " "agents-shipgate-reports" ), + "authorization_request": ( + "agents-shipgate authorization request --receipt " + "agents-shipgate-reports/verification-receipt.json --artifacts-root " + "agents-shipgate-reports --remote origin --destination-ref " + "refs/heads/ --expected-lease-oid --out " + "agents-shipgate-reports/human-authorization-request.json" + ), + "authorization_execute": ( + "agents-shipgate authorization execute --workspace . --receipt " + "agents-shipgate-reports/verification-receipt.json --artifacts-root " + "agents-shipgate-reports" + ), "agent_handoff": ( "agents-shipgate agent handoff --from agents-shipgate-reports/verifier.json --json" ), @@ -257,6 +278,10 @@ "verification_unit_result": "agents-shipgate-reports/verification-unit-result.json", "verification_artifact_manifest": "agents-shipgate-reports/verification-artifacts.json", "verification_receipt": "agents-shipgate-reports/verification-receipt.json", + "human_authorization_request": ( + "agents-shipgate-reports/human-authorization-request.json" + ), + "human_authorization": "agents-shipgate-reports/human-authorization.json", "report": "agents-shipgate-reports/report.json", "pr_comment": "agents-shipgate-reports/pr-comment.md", "packet": "agents-shipgate-reports/packet.json", @@ -272,12 +297,14 @@ "verification-receipt.json.receipt_id", "agent-handoff.json", "agent-handoff.json.control.state", + "agent-handoff.json.authorization", "verifier.json.control.state", "verify-run.json", "report.json.release_decision.decision", ) VERIFIER_READ_ORDER: tuple[str, ...] = ( "control.state", + "authorization", "execution", "merge_verdict", "applicability", @@ -316,6 +343,7 @@ "prohibited-action", "runtime-trace", "human-ack", + "human-authorization", "suppression", "waiver", "baseline", @@ -340,6 +368,12 @@ class ContractPayload(BaseModel): verification_unit_result_schema_version: str verification_artifact_manifest_schema_version: str verification_receipt_schema_version: str + human_authorization_request_schema_version: str + human_authorization_schema_version: str + human_authorization_evaluation_schema_version: str + human_authorization_trust_policy_schema_version: str + human_authorization_trust_policy_default_path: str + human_authorization_schema_path: str agent_handoff_schema_version: str agent_handoff_schema_path: str agent_handoff_artifact: str @@ -402,6 +436,20 @@ def build_contract_payload() -> ContractPayload: VERIFICATION_ARTIFACT_MANIFEST_SCHEMA_VERSION ), verification_receipt_schema_version=VERIFICATION_RECEIPT_SCHEMA_VERSION, + human_authorization_request_schema_version=( + HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION + ), + human_authorization_schema_version=HUMAN_AUTHORIZATION_SCHEMA_VERSION, + human_authorization_evaluation_schema_version=( + HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION + ), + human_authorization_trust_policy_schema_version=( + HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION + ), + human_authorization_trust_policy_default_path=( + HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH + ), + human_authorization_schema_path=HUMAN_AUTHORIZATION_SCHEMA_PATH, agent_handoff_schema_version=AGENT_HANDOFF_SCHEMA_VERSION, agent_handoff_schema_path=AGENT_HANDOFF_SCHEMA_PATH, agent_handoff_artifact=ARTIFACTS["agent_handoff"], @@ -475,6 +523,12 @@ def build_contract_payload() -> ContractPayload: "HOST_GRANTS_INVENTORY_SCHEMA_VERSION", "HOST_GRANTS_BASELINE_SCHEMA_VERSION", "HOST_GRANTS_DRIFT_SCHEMA_VERSION", + "HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION", + "HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION", + "HUMAN_AUTHORIZATION_SCHEMA_PATH", + "HUMAN_AUTHORIZATION_SCHEMA_VERSION", + "HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH", + "HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION", "MANUAL_REVIEW_SIGNALS", "MERGE_VERDICTS", "MCP_TOOLS", diff --git a/src/agents_shipgate/schemas/human_authorization.py b/src/agents_shipgate/schemas/human_authorization.py new file mode 100644 index 00000000..a4dcc378 --- /dev/null +++ b/src/agents_shipgate/schemas/human_authorization.py @@ -0,0 +1,953 @@ +"""Signed human authorization artifacts for exact coding-agent operations. + +These models deliberately keep human authority outside the repository being +reviewed. A request is content-addressed but unsigned; an authorization binds +that exact request to one authenticated principal and one short validity +window. The detached Ed25519 proof is verified by +``agents_shipgate.core.human_authorization`` against an external trust policy. +""" + +from __future__ import annotations + +import base64 +import hashlib +import re +import shlex +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from pathlib import PurePosixPath +from typing import Annotated, Any, Literal +from urllib.parse import urlsplit + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StringConstraints, + field_validator, + model_validator, +) + +from agents_shipgate.schemas.verification_identity import ( + CONTENT_ID_PATTERN, + GIT_OBJECT_PATTERN, + content_id, +) + +HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION = "shipgate.human_authorization_request/v1" +HUMAN_AUTHORIZATION_SCHEMA_VERSION = "shipgate.human_authorization/v1" +HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION = "shipgate.human_authorization_evaluation/v1" +HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION = "shipgate.human_authorization_trust_policy/v1" +HUMAN_AUTHORIZATION_SCHEMA_PATH = "docs/human-authorization-schema.v1.json" +HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH = ( + "~/.config/agents-shipgate/human-authorization-trust-policy.json" +) +HUMAN_AUTHORIZATION_SIGNATURE_DOMAIN = b"shipgate.human_authorization/v1\x00" + +_STRICT = ConfigDict(extra="forbid") +_BASE64URL_PATTERN = r"^[A-Za-z0-9_-]+$" +_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") +_SHELL_UNSAFE_RE = re.compile(r"[\x00\r\n;&|`$<>(){}!]") +_HTTPS_ENDPOINT_RE = re.compile(r"^[A-Za-z0-9._~+:/-]+$") +_HOST_LABEL_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +_REPOSITORY_COMPONENT_RE = re.compile(r"^[A-Za-z0-9._~+-]+$") + +NonEmptyText = Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1), +] +Ed25519PublicKeyText = Annotated[ + str, + StringConstraints( + min_length=43, + max_length=43, + pattern=r"^[A-Za-z0-9_-]{43}$", + ), +] +Ed25519SignatureText = Annotated[ + str, + StringConstraints( + min_length=86, + max_length=86, + pattern=r"^[A-Za-z0-9_-]{86}$", + ), +] +CanonicalHttpsGitEndpointText = Annotated[ + str, + StringConstraints( + min_length=1, + pattern=( + r"^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?/" + r"(?:[A-Za-z0-9._~+-]+/)*[A-Za-z0-9._~+-]+$" + ), + ), +] +FullHeadsRefText = Annotated[ + str, + StringConstraints( + pattern=( + r"^refs/heads/(?:[A-Za-z0-9]|" + r"[A-Za-z0-9][A-Za-z0-9._/-]*[A-Za-z0-9])$" + ) + ), +] + + +def _identity_payload(model: BaseModel, identity_field: str) -> dict[str, Any]: + return model.model_dump( + mode="json", + exclude={identity_field, "schema_version"}, + exclude_none=False, + ) + + +def _decode_base64url(value: str, *, expected_size: int, label: str) -> bytes: + if not re.fullmatch(_BASE64URL_PATTERN, value): + raise ValueError(f"{label} must be unpadded base64url") + try: + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + except (ValueError, TypeError) as exc: + raise ValueError(f"{label} must be valid base64url") from exc + if len(decoded) != expected_size: + raise ValueError(f"{label} must decode to exactly {expected_size} bytes") + canonical = base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") + if canonical != value: + raise ValueError(f"{label} must use canonical unpadded base64url") + return decoded + + +def decode_ed25519_public_key(value: str) -> bytes: + """Decode one canonical raw Ed25519 public key.""" + + return _decode_base64url(value, expected_size=32, label="public_key") + + +def decode_ed25519_signature(value: str) -> bytes: + """Decode one canonical detached Ed25519 signature.""" + + return _decode_base64url(value, expected_size=64, label="signature") + + +def ed25519_key_id(public_key: bytes) -> str: + """Return the stable key identifier used by trust policies and proofs.""" + + if len(public_key) != 32: + raise ValueError("Ed25519 public keys must contain exactly 32 raw bytes") + return f"sha256:{hashlib.sha256(public_key).hexdigest()}" + + +def _utc(value: datetime, *, label: str) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{label} must include a UTC offset") + return value.astimezone(UTC) + + +def _inert_text(value: str, *, label: str) -> str: + """Validate display-only metadata without imposing shell token rules.""" + + if _CONTROL_RE.search(value): + raise ValueError(f"{label} contains a control character") + return value + + +def _safe_shell_token(value: str, *, label: str) -> str: + """Reject shell syntax in values that are rendered into an operation.""" + + if _SHELL_UNSAFE_RE.search(value): + raise ValueError(f"{label} contains a control character or shell metacharacter") + return value + + +def _portable_path(value: str) -> str: + _inert_text(value, label="path") + path = PurePosixPath(value) + if ( + not value + or "\\" in value + or path.is_absolute() + or ".." in path.parts + or path.as_posix() != value + ): + raise ValueError("paths must be normalized portable repository-relative paths") + return value + + +def _git_destination_ref(value: str) -> str: + _safe_shell_token(value, label="destination_ref") + if not value.startswith("refs/heads/"): + raise ValueError("destination_ref must be a full refs/heads/* ref") + if any(character.isspace() or ord(character) < 32 for character in value): + raise ValueError("destination_ref must not contain whitespace or controls") + if any(token in value for token in ("..", "@{", "//")): + raise ValueError("destination_ref is not a valid normalized Git ref") + if any(character in value for character in "~^:?*[\\"): + raise ValueError("destination_ref contains a Git-ref metacharacter") + parts = value.split("/") + if any( + not part + or part in {".", ".."} + or part.startswith(".") + or part.endswith(".") + or part.endswith(".lock") + for part in parts + ): + raise ValueError("destination_ref contains an invalid Git-ref component") + return value + + +def canonical_https_git_endpoint(value: str) -> tuple[str, str]: + """Return one canonical HTTPS push URL and its credential-free repo ID. + + Human authorization v1 intentionally accepts only conventional HTTPS + repository endpoints. Local paths, ``file:`` locators, SSH/scp syntax, + embedded credentials, and query/fragment state are not stable enough to + carry execution authority. The repository identity excludes transport + syntax and the conventional ``.git`` suffix, while the URL remains the + concrete destination used by the exact operation. + """ + + if not isinstance(value, str) or not value: + raise ValueError("push_url must be a non-empty HTTPS URL") + if not value.isascii() or _CONTROL_RE.search(value) or not _HTTPS_ENDPOINT_RE.fullmatch(value): + raise ValueError("push_url contains an unsafe or non-ASCII token") + parsed = urlsplit(value) + if parsed.scheme.lower() != "https": + raise ValueError("push_url must use the https scheme") + if parsed.username is not None or parsed.password is not None: + raise ValueError("push_url must not embed credentials") + if parsed.query or parsed.fragment: + raise ValueError("push_url must not contain a query or fragment") + host = (parsed.hostname or "").lower() + if not host or len(host) > 253 or any( + not _HOST_LABEL_RE.fullmatch(label) for label in host.split(".") + ): + raise ValueError("push_url must contain a valid DNS host") + try: + port = parsed.port + except ValueError as exc: + raise ValueError("push_url contains an invalid port") from exc + if port not in {None, 443}: + raise ValueError("push_url must use the default HTTPS port") + if parsed.path.endswith("/"): + raise ValueError("push_url repository path must not end with a slash") + components = parsed.path.removeprefix("/").split("/") + if ( + not parsed.path.startswith("/") + or not components + or any( + not component + or component in {".", ".."} + or not _REPOSITORY_COMPONENT_RE.fullmatch(component) + for component in components + ) + ): + raise ValueError("push_url must contain a normalized repository path") + canonical_url = f"https://{host}/{'/'.join(components)}" + repository_components = list(components) + if repository_components[-1].endswith(".git"): + repository_components[-1] = repository_components[-1][:-4] + if not repository_components[-1]: + raise ValueError("push_url must name a repository") + repository_id = f"{host}/{'/'.join(repository_components)}" + return canonical_url, repository_id + + +class HumanAuthorizationReviewItemV1(BaseModel): + """One complete reviewer obligation included in the signed review set.""" + + model_config = _STRICT + + review_item_id: NonEmptyText + check_id: NonEmptyText + fingerprint: NonEmptyText | None = None + support_hash: NonEmptyText | None = None + paths: list[str] = Field(default_factory=list) + + @field_validator("review_item_id", "check_id", "fingerprint", "support_hash") + @classmethod + def _text_is_inert(cls, value: str | None, info) -> str | None: + if value is None: + return None + return _inert_text(value, label=info.field_name) + + @field_validator("paths") + @classmethod + def _paths_are_portable(cls, value: list[str]) -> list[str]: + normalized = [_portable_path(path) for path in value] + if normalized != sorted(set(normalized)): + raise ValueError("review item paths must be sorted and unique") + return normalized + + +def canonical_review_items( + items: Sequence[HumanAuthorizationReviewItemV1 | Mapping[str, Any]], +) -> list[HumanAuthorizationReviewItemV1]: + """Validate and deterministically order a complete review-item set.""" + + validated = [ + item + if isinstance(item, HumanAuthorizationReviewItemV1) + else HumanAuthorizationReviewItemV1.model_validate(item) + for item in items + ] + ordered = sorted( + validated, + key=lambda item: ( + item.review_item_id, + item.check_id, + item.fingerprint or "", + item.support_hash or "", + tuple(item.paths), + ), + ) + ids = [item.review_item_id for item in ordered] + if len(ids) != len(set(ids)): + raise ValueError("review_item_id values must be unique") + return ordered + + +def review_set_id( + items: Sequence[HumanAuthorizationReviewItemV1 | Mapping[str, Any]], +) -> str: + """Hash the full, ordered review scope an authorizer inspected.""" + + ordered = canonical_review_items(items) + return content_id( + { + "review_items": [item.model_dump(mode="json") for item in ordered], + } + ) + + +def authorization_review_items( + release_decision: Mapping[str, Any], +) -> list[HumanAuthorizationReviewItemV1]: + """Project the one closed review set used by request and verifier paths. + + The projection intentionally binds stable item/check/fingerprint/support + identity plus every source and policy-source path. It rejects incomplete + structures instead of silently dropping fields, so request construction + and later authorization evaluation cannot disagree about review scope. + """ + + if not isinstance(release_decision, Mapping): + raise ValueError("release_decision must be one object") + if release_decision.get("decision") != "review_required": + raise ValueError("human authorization requires decision='review_required'") + raw_items = release_decision.get("review_items") + if not isinstance(raw_items, list) or not raw_items: + raise ValueError("review_required decision must carry a non-empty review_items set") + + projected: list[HumanAuthorizationReviewItemV1] = [] + for index, raw in enumerate(raw_items): + if not isinstance(raw, Mapping): + raise ValueError(f"review_items[{index}] must be one object") + review_item_id = raw.get("id") + check_id = raw.get("check_id") + fingerprint = raw.get("fingerprint") + if not isinstance(review_item_id, str) or not review_item_id.strip(): + raise ValueError(f"review_items[{index}].id must be a non-empty string") + if not isinstance(check_id, str) or not check_id.strip(): + raise ValueError(f"review_items[{index}].check_id must be a non-empty string") + if fingerprint is not None and not isinstance(fingerprint, str): + raise ValueError(f"review_items[{index}].fingerprint must be a string or null") + + support = raw.get("support") + if support is not None and not isinstance(support, Mapping): + raise ValueError(f"review_items[{index}].support must be an object or null") + support_hash = support.get("support_hash") if support is not None else None + if support_hash is not None and not isinstance(support_hash, str): + raise ValueError(f"review_items[{index}].support.support_hash must be a string or null") + + paths: list[str] = [] + for source_name in ("source", "policy_evidence_source"): + source = raw.get(source_name) + if source is None: + continue + if not isinstance(source, Mapping): + raise ValueError(f"review_items[{index}].{source_name} must be an object or null") + if "path" not in source: + raise ValueError( + f"review_items[{index}].{source_name}.path must be present" + ) + path = source.get("path") + if path is None: + continue + if not isinstance(path, str) or not path: + raise ValueError( + f"review_items[{index}].{source_name}.path must be a non-empty string or null" + ) + paths.append(path) + + projected.append( + HumanAuthorizationReviewItemV1( + review_item_id=review_item_id, + check_id=check_id, + fingerprint=fingerprint, + support_hash=support_hash, + paths=sorted(set(paths)), + ) + ) + return canonical_review_items(projected) + + +class GitPushOperationV1(BaseModel): + """One exact force-with-lease push; no shell or wildcard semantics.""" + + model_config = _STRICT + + operation_id: str = Field(pattern=CONTENT_ID_PATTERN) + kind: Literal["git_push"] = "git_push" + destination_repository_id: str = Field(min_length=1) + push_url: CanonicalHttpsGitEndpointText + source_commit_sha: str = Field(pattern=GIT_OBJECT_PATTERN) + destination_ref: FullHeadsRefText + expected_lease_oid: str = Field(pattern=GIT_OBJECT_PATTERN) + force_with_lease: Literal[True] = True + + _destination_is_valid = field_validator("destination_ref")(_git_destination_ref) + + @model_validator(mode="after") + def _operation_id_is_content_addressed(self) -> GitPushOperationV1: + canonical_url, repository_id = canonical_https_git_endpoint(self.push_url) + if self.push_url != canonical_url: + raise ValueError("push_url must use its canonical HTTPS representation") + if self.destination_repository_id != repository_id: + raise ValueError( + "destination_repository_id must match the credential-free push_url identity" + ) + expected = content_id(_identity_payload(self, "operation_id")) + if self.operation_id != expected: + raise ValueError("operation_id must hash the exact typed operation") + return self + + def argv(self) -> list[str]: + """Return the only command vector authorized by this operation.""" + + return [ + "git", + "push", + f"--force-with-lease={self.destination_ref}:{self.expected_lease_oid}", + self.push_url, + f"{self.source_commit_sha}:{self.destination_ref}", + ] + + @property + def command(self) -> str: + return shlex.join(self.argv()) + + +def build_git_push_operation( + *, + destination_repository_id: str, + push_url: str, + source_commit_sha: str, + destination_ref: str, + expected_lease_oid: str, +) -> GitPushOperationV1: + """Build a content-addressed exact force-with-lease operation.""" + + canonical_url, derived_repository_id = canonical_https_git_endpoint(push_url) + if destination_repository_id != derived_repository_id: + raise ValueError( + "destination_repository_id must equal the repository identity derived from push_url" + ) + payload = { + "kind": "git_push", + "destination_repository_id": destination_repository_id, + "push_url": canonical_url, + "source_commit_sha": source_commit_sha, + "destination_ref": destination_ref, + "expected_lease_oid": expected_lease_oid, + "force_with_lease": True, + } + return GitPushOperationV1(operation_id=content_id(payload), **payload) + + +class HumanAuthorizationRequestV1(BaseModel): + """Unsigned, content-addressed challenge shown by a trusted host to a human.""" + + model_config = _STRICT + + schema_version: Literal["shipgate.human_authorization_request/v1"] = ( + HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION + ) + authorization_request_id: str = Field(pattern=CONTENT_ID_PATTERN) + repository_id: str = Field(min_length=1) + source_receipt_id: str = Field(pattern=CONTENT_ID_PATTERN) + source_artifact_set_id: str = Field(pattern=CONTENT_ID_PATTERN) + source_engine_requirement_id: str = Field(pattern=CONTENT_ID_PATTERN) + source_executor_id: str = Field(pattern=CONTENT_ID_PATTERN) + verification_request_id: str = Field(pattern=CONTENT_ID_PATTERN) + subject_id: str = Field(pattern=CONTENT_ID_PATTERN) + decision_id: str = Field(pattern=CONTENT_ID_PATTERN) + decision: Literal["review_required"] = "review_required" + base_commit_sha: str = Field(pattern=GIT_OBJECT_PATTERN) + merge_base_sha: str = Field(pattern=GIT_OBJECT_PATTERN) + base_tree_sha: str = Field(pattern=GIT_OBJECT_PATTERN) + head_tree_sha: str = Field(pattern=GIT_OBJECT_PATTERN) + source_head_commit_sha: str = Field(pattern=GIT_OBJECT_PATTERN) + worktree_overlay_sha256: None = None + review_set_id: str = Field(pattern=CONTENT_ID_PATTERN) + review_items: list[HumanAuthorizationReviewItemV1] = Field(min_length=1) + operation: GitPushOperationV1 + + @field_validator("repository_id") + @classmethod + def _repository_id_is_inert(cls, value: str) -> str: + _inert_text(value, label="repository_id") + if value != value.strip(): + raise ValueError("repository_id must not contain surrounding whitespace") + return value + + @model_validator(mode="after") + def _request_is_one_complete_identity(self) -> HumanAuthorizationRequestV1: + self.review_items = canonical_review_items(self.review_items) + if self.review_set_id != review_set_id(self.review_items): + raise ValueError("review_set_id must hash the complete review_items list") + if self.operation.source_commit_sha != self.source_head_commit_sha: + raise ValueError("git_push source must equal the reviewed source head commit") + if self.operation.destination_repository_id != self.repository_id: + raise ValueError( + "git_push destination repository must equal the reviewed repository identity" + ) + expected = content_id(_identity_payload(self, "authorization_request_id")) + if self.authorization_request_id != expected: + raise ValueError("authorization_request_id must hash the complete request") + return self + + +def build_human_authorization_request( + *, + repository_id: str, + source_receipt_id: str, + source_artifact_set_id: str, + source_engine_requirement_id: str, + source_executor_id: str, + verification_request_id: str, + subject_id: str, + decision_id: str, + base_commit_sha: str, + merge_base_sha: str, + base_tree_sha: str, + head_tree_sha: str, + source_head_commit_sha: str, + review_items: Sequence[HumanAuthorizationReviewItemV1 | Mapping[str, Any]], + operation: GitPushOperationV1, +) -> HumanAuthorizationRequestV1: + """Build the exact unsigned challenge a host may present for approval.""" + + ordered = canonical_review_items(review_items) + payload = { + "repository_id": repository_id, + "source_receipt_id": source_receipt_id, + "source_artifact_set_id": source_artifact_set_id, + "source_engine_requirement_id": source_engine_requirement_id, + "source_executor_id": source_executor_id, + "verification_request_id": verification_request_id, + "subject_id": subject_id, + "decision_id": decision_id, + "decision": "review_required", + "base_commit_sha": base_commit_sha, + "merge_base_sha": merge_base_sha, + "base_tree_sha": base_tree_sha, + "head_tree_sha": head_tree_sha, + "source_head_commit_sha": source_head_commit_sha, + "worktree_overlay_sha256": None, + "review_set_id": review_set_id(ordered), + "review_items": ordered, + "operation": operation, + } + identity_payload = { + key: ( + value.model_dump(mode="json") + if isinstance(value, BaseModel) + else [item.model_dump(mode="json") for item in value] + if isinstance(value, list) + else value + ) + for key, value in payload.items() + } + return HumanAuthorizationRequestV1( + authorization_request_id=content_id(identity_payload), + **payload, + ) + + +class HumanAuthorizationPrincipalV1(BaseModel): + model_config = _STRICT + + provider: Literal["codex", "claude-code", "github", "local-hardware"] + subject: NonEmptyText + + @field_validator("subject") + @classmethod + def _subject_is_inert(cls, value: str) -> str: + return _inert_text(value, label="principal subject") + + +class HumanAuthorizationStatementV1(BaseModel): + """Canonical payload covered by the detached signature.""" + + model_config = _STRICT + + request: HumanAuthorizationRequestV1 + principal: HumanAuthorizationPrincipalV1 + reason: NonEmptyText + issued_at: datetime + not_before: datetime + expires_at: datetime + nonce: str = Field(min_length=22, max_length=128, pattern=_BASE64URL_PATTERN) + + @field_validator("reason") + @classmethod + def _reason_is_inert(cls, value: str) -> str: + return _inert_text(value, label="reason") + + @field_validator("nonce") + @classmethod + def _nonce_has_entropy(cls, value: str) -> str: + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + if len(decoded) < 16: + raise ValueError("nonce must contain at least 128 bits") + return value + + @field_validator("issued_at", "not_before", "expires_at") + @classmethod + def _times_are_utc(cls, value: datetime, info) -> datetime: + return _utc(value, label=info.field_name) + + @model_validator(mode="after") + def _time_window_is_ordered(self) -> HumanAuthorizationStatementV1: + if self.issued_at > self.not_before: + raise ValueError("issued_at must not be after not_before") + if self.not_before >= self.expires_at: + raise ValueError("not_before must be before expires_at") + return self + + +class HumanAuthorizationProofV1(BaseModel): + model_config = _STRICT + + algorithm: Literal["ed25519"] = "ed25519" + key_id: str = Field(pattern=CONTENT_ID_PATTERN) + signature: Ed25519SignatureText + + @field_validator("signature") + @classmethod + def _signature_is_canonical(cls, value: str) -> str: + decode_ed25519_signature(value) + return value + + +class HumanAuthorizationV1(BaseModel): + """Signed authorization for exactly one request and operation.""" + + model_config = _STRICT + + schema_version: Literal["shipgate.human_authorization/v1"] = HUMAN_AUTHORIZATION_SCHEMA_VERSION + authorization_id: str = Field(pattern=CONTENT_ID_PATTERN) + statement: HumanAuthorizationStatementV1 + proof: HumanAuthorizationProofV1 + + @model_validator(mode="after") + def _authorization_id_is_content_addressed(self) -> HumanAuthorizationV1: + expected = content_id( + { + "statement": self.statement.model_dump(mode="json"), + "proof": self.proof.model_dump(mode="json"), + } + ) + if self.authorization_id != expected: + raise ValueError("authorization_id must hash the complete signed grant") + return self + + +def human_authorization_id( + statement: HumanAuthorizationStatementV1, + proof: HumanAuthorizationProofV1, +) -> str: + """Hash the complete signed grant, including proof and signing-key identity.""" + + return content_id( + { + "statement": statement.model_dump(mode="json"), + "proof": proof.model_dump(mode="json"), + } + ) + + +def build_human_authorization( + *, + statement: HumanAuthorizationStatementV1, + proof: HumanAuthorizationProofV1, +) -> HumanAuthorizationV1: + """Assemble a grant from an externally produced detached signature. + + This helper never accepts a private key and never signs. The signature is + produced by the trusted host or human authenticator outside Shipgate. + """ + + return HumanAuthorizationV1( + authorization_id=human_authorization_id(statement, proof), + statement=statement, + proof=proof, + ) + + +class TrustedEd25519KeyV1(BaseModel): + """One host/admin-controlled key and the identity it is allowed to assert.""" + + model_config = _STRICT + + key_id: str = Field(pattern=CONTENT_ID_PATTERN) + public_key: Ed25519PublicKeyText + provider: Literal["codex", "claude-code", "github", "local-hardware"] + principal: NonEmptyText + valid_from: datetime | None = None + valid_until: datetime | None = None + + @field_validator("public_key") + @classmethod + def _key_is_canonical(cls, value: str) -> str: + decode_ed25519_public_key(value) + return value + + @field_validator("principal") + @classmethod + def _principal_is_inert(cls, value: str) -> str: + return _inert_text(value, label="trusted principal") + + @field_validator("valid_from", "valid_until") + @classmethod + def _key_times_are_utc(cls, value: datetime | None, info) -> datetime | None: + return _utc(value, label=info.field_name) if value is not None else None + + @model_validator(mode="after") + def _key_identity_matches_bytes(self) -> TrustedEd25519KeyV1: + raw = decode_ed25519_public_key(self.public_key) + if self.key_id != ed25519_key_id(raw): + raise ValueError("key_id must be the SHA-256 digest of the raw public key") + if ( + self.valid_from is not None + and self.valid_until is not None + and self.valid_from >= self.valid_until + ): + raise ValueError("trusted key valid_from must be before valid_until") + return self + + +class HumanAuthorizationTrustPolicyV1(BaseModel): + """Fixed external trust policy; never valid merely because it is in the repo.""" + + model_config = _STRICT + + schema_version: Literal["shipgate.human_authorization_trust_policy/v1"] = ( + HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION + ) + trust_policy_id: str = Field(pattern=CONTENT_ID_PATTERN) + repository_ids: list[NonEmptyText] = Field(min_length=1) + max_ttl_seconds: int = Field(default=900, ge=1, le=86400) + clock_skew_seconds: int = Field(default=30, ge=0, le=300) + keys: list[TrustedEd25519KeyV1] = Field(min_length=1) + + @model_validator(mode="after") + def _policy_is_fixed_and_content_addressed(self) -> HumanAuthorizationTrustPolicyV1: + for repository_id in self.repository_ids: + _inert_text(repository_id, label="repository_id") + if self.repository_ids != sorted(set(self.repository_ids)): + raise ValueError("repository_ids must be sorted and unique") + self.keys = sorted(self.keys, key=lambda key: key.key_id) + key_ids = [key.key_id for key in self.keys] + if len(key_ids) != len(set(key_ids)): + raise ValueError("trusted key_id values must be unique") + expected = content_id(_identity_payload(self, "trust_policy_id")) + if self.trust_policy_id != expected: + raise ValueError("trust_policy_id must hash the complete fixed policy") + return self + + +def build_human_authorization_trust_policy( + *, + repository_ids: Sequence[str], + keys: Sequence[TrustedEd25519KeyV1 | Mapping[str, Any]], + max_ttl_seconds: int = 900, + clock_skew_seconds: int = 30, +) -> HumanAuthorizationTrustPolicyV1: + """Build a content-addressed policy for storage outside the workspace.""" + + normalized_repositories = sorted(set(repository_ids)) + normalized_keys = sorted( + [ + key if isinstance(key, TrustedEd25519KeyV1) else TrustedEd25519KeyV1.model_validate(key) + for key in keys + ], + key=lambda key: key.key_id, + ) + payload = { + "repository_ids": normalized_repositories, + "max_ttl_seconds": max_ttl_seconds, + "clock_skew_seconds": clock_skew_seconds, + "keys": [key.model_dump(mode="json") for key in normalized_keys], + } + return HumanAuthorizationTrustPolicyV1( + trust_policy_id=content_id(payload), + repository_ids=normalized_repositories, + max_ttl_seconds=max_ttl_seconds, + clock_skew_seconds=clock_skew_seconds, + keys=normalized_keys, + ) + + +AuthorizationEvaluationStatus = Literal[ + "not_requested", + "accepted", + "rejected", + "not_applicable", +] + + +class AuthorizationEvaluationV1(BaseModel): + """Fail-closed authorization evaluation consumed by verifier projections.""" + + model_config = _STRICT + + schema_version: Literal["shipgate.human_authorization_evaluation/v1"] = ( + HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION + ) + status: AuthorizationEvaluationStatus + authorization_id: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + authorization_request_id: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + trust_policy_id: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + key_id: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + provider: str | None = None + principal: str | None = None + operation_id: str | None = Field(default=None, pattern=CONTENT_ID_PATTERN) + command: str | None = None + issued_at: datetime | None = None + expires_at: datetime | None = None + reason_codes: list[str] = Field(default_factory=list) + + @field_validator("issued_at", "expires_at") + @classmethod + def _evaluation_times_are_utc(cls, value: datetime | None, info) -> datetime | None: + return _utc(value, label=info.field_name) if value is not None else None + + @field_validator("provider", "principal", "command") + @classmethod + def _evaluation_text_is_inert(cls, value: str | None, info) -> str | None: + if value is None: + return None + if not value.strip(): + raise ValueError(f"{info.field_name} must not be blank") + if "\x00" in value or "\r" in value or "\n" in value: + raise ValueError(f"{info.field_name} contains a control character") + return value + + @field_validator("reason_codes") + @classmethod + def _reason_codes_are_normalized(cls, value: list[str]) -> list[str]: + if any(not item or not item.strip() for item in value): + raise ValueError("reason_codes must be non-empty strings") + if value != sorted(set(value)): + raise ValueError("reason_codes must be sorted and unique") + return value + + @model_validator(mode="after") + def _status_controls_authority(self) -> AuthorizationEvaluationV1: + authority = ( + self.authorization_id, + self.authorization_request_id, + self.trust_policy_id, + self.key_id, + self.provider, + self.principal, + self.operation_id, + self.command, + self.issued_at, + self.expires_at, + ) + if self.status == "accepted": + if not all(value is not None for value in authority): + raise ValueError("accepted authorization requires complete authority fields") + if self.reason_codes: + raise ValueError("accepted authorization cannot carry rejection reasons") + return self + if self.command is not None: + raise ValueError("non-accepted authorization cannot expose a command") + if self.status == "rejected" and not self.reason_codes: + raise ValueError("rejected authorization requires at least one reason code") + if self.status in {"not_requested", "not_applicable"}: + if any(value is not None for value in authority): + raise ValueError(f"{self.status} authorization cannot carry authority fields") + return self + + @classmethod + def not_requested(cls) -> AuthorizationEvaluationV1: + return cls(status="not_requested") + + @classmethod + def not_applicable(cls, reason_code: str | None = None) -> AuthorizationEvaluationV1: + reasons = [reason_code] if reason_code else [] + return cls(status="not_applicable", reason_codes=reasons) + + @classmethod + def rejected( + cls, + *reason_codes: str, + authorization_id: str | None = None, + authorization_request_id: str | None = None, + trust_policy_id: str | None = None, + key_id: str | None = None, + provider: str | None = None, + principal: str | None = None, + operation_id: str | None = None, + issued_at: datetime | None = None, + expires_at: datetime | None = None, + ) -> AuthorizationEvaluationV1: + return cls( + status="rejected", + authorization_id=authorization_id, + authorization_request_id=authorization_request_id, + trust_policy_id=trust_policy_id, + key_id=key_id, + provider=provider, + principal=principal, + operation_id=operation_id, + issued_at=issued_at, + expires_at=expires_at, + reason_codes=sorted(set(reason_codes or ("authorization_rejected",))), + ) + + +__all__ = [ + "AuthorizationEvaluationStatus", + "AuthorizationEvaluationV1", + "GitPushOperationV1", + "HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION", + "HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION", + "HUMAN_AUTHORIZATION_SCHEMA_PATH", + "HUMAN_AUTHORIZATION_SCHEMA_VERSION", + "HUMAN_AUTHORIZATION_SIGNATURE_DOMAIN", + "HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION", + "HumanAuthorizationPrincipalV1", + "HumanAuthorizationProofV1", + "HumanAuthorizationRequestV1", + "HumanAuthorizationReviewItemV1", + "HumanAuthorizationStatementV1", + "HumanAuthorizationTrustPolicyV1", + "HumanAuthorizationV1", + "TrustedEd25519KeyV1", + "build_human_authorization", + "build_git_push_operation", + "build_human_authorization_request", + "build_human_authorization_trust_policy", + "authorization_review_items", + "canonical_https_git_endpoint", + "canonical_review_items", + "decode_ed25519_public_key", + "decode_ed25519_signature", + "ed25519_key_id", + "human_authorization_id", + "review_set_id", +] diff --git a/src/agents_shipgate/schemas/verification_identity.py b/src/agents_shipgate/schemas/verification_identity.py index a114158d..1a322854 100644 --- a/src/agents_shipgate/schemas/verification_identity.py +++ b/src/agents_shipgate/schemas/verification_identity.py @@ -21,7 +21,7 @@ VERIFICATION_ARTIFACT_MANIFEST_SCHEMA_VERSION = "shipgate.verification_artifact_manifest/v1" VERIFICATION_RECEIPT_SCHEMA_VERSION = "shipgate.verification_receipt/v1" CONTENT_ID_PATTERN = r"^sha256:[0-9a-f]{64}$" -GIT_OBJECT_PATTERN = r"^[0-9a-f]{40,64}$" +GIT_OBJECT_PATTERN = r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$" def canonical_json(value: Any) -> bytes: diff --git a/src/agents_shipgate/schemas/verifier.py b/src/agents_shipgate/schemas/verifier.py index d2a479e0..85aaeb45 100644 --- a/src/agents_shipgate/schemas/verifier.py +++ b/src/agents_shipgate/schemas/verifier.py @@ -7,6 +7,7 @@ from agents_shipgate.schemas.agent_control import AgentControl, normalize_legacy_agent_control from agents_shipgate.schemas.common import ReleaseDecisionStatus from agents_shipgate.schemas.disclaimers import STATIC_VERDICT_DISCLAIMER +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 from agents_shipgate.schemas.report import ReleaseDecision from agents_shipgate.schemas.verification_identity import CONTENT_ID_PATTERN @@ -361,6 +362,7 @@ class VerifierArtifact(BaseModel): } }, "release_decision": { + "type": "object", "properties": { "decision": {"const": "passed"}, "blockers": {"maxItems": 0}, @@ -421,11 +423,71 @@ class VerifierArtifact(BaseModel): } }, }, + { + "if": { + "properties": { + "authorization": { + "properties": {"status": {"const": "accepted"}}, + "required": ["status"], + } + }, + "required": ["authorization"], + }, + "then": { + "required": [ + "execution", + "head_status", + "release_decision", + "decision", + "merge_verdict", + "applicability", + "can_merge_without_human", + "control", + "fix_task", + ], + "properties": { + "execution": {"const": "succeeded"}, + "head_status": {"const": "succeeded"}, + "decision": {"const": "review_required"}, + "merge_verdict": {"const": "human_review_required"}, + "applicability": {"const": "verified"}, + "can_merge_without_human": {"const": False}, + "control": { + "properties": { + "state": {"const": "agent_action_required"}, + "completion_allowed": {"const": False}, + "next_action": { + "properties": {"kind": {"const": "repair"}}, + "required": ["kind"], + }, + "allowed_next_commands": { + "minItems": 1, + "maxItems": 1, + }, + }, + "required": [ + "state", + "completion_allowed", + "next_action", + "allowed_next_commands", + ], + }, + "fix_task": {"type": "null"}, + "release_decision": { + "type": "object", + "properties": { + "decision": {"const": "review_required"} + }, + "required": ["decision"], + }, + } + }, + }, ] }, ) - verifier_schema_version: Literal["0.5"] = "0.5" + verifier_schema_version: Literal["0.6"] = "0.6" static_analysis_only: Literal[True] = True runtime_behavior_verified: Literal[False] = False static_verdict_disclaimer: str = STATIC_VERDICT_DISCLAIMER @@ -463,6 +525,7 @@ class VerifierArtifact(BaseModel): applicability: Applicability = "not_evaluated" can_merge_without_human: bool = False control: AgentControl + authorization: AuthorizationEvaluationV1 headline: str | None = None fix_task: VerifierFixTask | None = None forbidden_file_edits: list[str] = Field(default_factory=list) @@ -478,14 +541,18 @@ def _normalize_legacy_control(cls, data: Any) -> Any: return data normalized = dict(data) legacy_version = normalized.get("verifier_schema_version") - legacy = legacy_version in {"0.1", "0.2", "0.3", "0.4"} + legacy = legacy_version in {"0.1", "0.2", "0.3", "0.4", "0.5"} if not legacy: - # Current v0.5 artifacts must already carry the authoritative + # Current v0.6 artifacts must already carry the authoritative # control union. Silently synthesizing a missing or malformed # current control would turn an internal consistency failure into # a trusted handoff. Only the frozen v0.2 reader is normalized. return normalized - normalized["verifier_schema_version"] = "0.5" + normalized["verifier_schema_version"] = "0.6" + normalized.setdefault( + "authorization", + AuthorizationEvaluationV1.not_requested().model_dump(mode="json"), + ) execution = normalized.get("execution") or normalized.get("head_status") execution = execution or "not_run" @@ -672,14 +739,47 @@ def _control_projects_gate(self) -> VerifierArtifact: raise ValueError("non-mergeable artifacts cannot authorize completion") if self.control.state == "complete" and self.fix_task is not None: raise ValueError("complete control cannot carry a pending fix task") + authorization_accepted = self.authorization.status == "accepted" + if authorization_accepted: + if self.execution != "succeeded" or self.decision != "review_required": + raise ValueError( + "accepted human authorization requires a succeeded review_required result" + ) + if self.merge_verdict != "human_review_required" or self.can_merge_without_human: + raise ValueError( + "human authorization is operational evidence and cannot change merge authority" + ) + if self.control.state != "agent_action_required": + raise ValueError( + "accepted human authorization must route one exact coding-agent action" + ) + action = self.control.next_action + if action.kind != "repair" or getattr(action, "command", None) != self.authorization.command: + raise ValueError( + "authorized control must expose the exact signed operation command" + ) + if self.control.allowed_next_commands != [self.authorization.command]: + raise ValueError( + "authorized control may expose only the exact signed operation command" + ) + if self.fix_task is not None: + raise ValueError( + "authorized operational control must not relabel a human review task as agent-safe" + ) if self.control.state == "agent_action_required": action = self.control.next_action if action.kind == "repair": - if self.fix_task is None: + if self.fix_task is None and not authorization_accepted: raise ValueError("agent repair control requires a verifier fix task") - if self.fix_task.actor != "coding_agent" or not self.fix_task.safe_to_attempt: + if ( + self.fix_task is not None + and (self.fix_task.actor != "coding_agent" or not self.fix_task.safe_to_attempt) + ): raise ValueError("agent repair control requires an agent-safe fix task") - if getattr(action, "command", None) != self.fix_task.verification_command: + if ( + self.fix_task is not None + and getattr(action, "command", None) != self.fix_task.verification_command + ): raise ValueError( "agent repair control command must equal the exact fix-task rerun command" ) diff --git a/tests/integration/github_action/test_agent_result.py b/tests/integration/github_action/test_agent_result.py index 2e2c7334..bcd815cb 100644 --- a/tests/integration/github_action/test_agent_result.py +++ b/tests/integration/github_action/test_agent_result.py @@ -18,6 +18,7 @@ from agents_shipgate.schemas.agent_result import AgentResultV2 from agents_shipgate.schemas.capability_change import EffectivePolicy from agents_shipgate.schemas.common import SourceReference +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 from agents_shipgate.schemas.report import ( BaselineDelta, ContributionRule, @@ -473,6 +474,7 @@ def _verifier( applicability="verified", can_merge_without_human=can_merge, control=control, + authorization=AuthorizationEvaluationV1.not_requested(), fix_task=fix_task, capability_review=capability_review or VerifierCapabilityReview(), artifacts={ diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py index 94785563..c092d16a 100644 --- a/tests/test_adapter_static_only.py +++ b/tests/test_adapter_static_only.py @@ -291,24 +291,74 @@ class AllowedException: AllowedException( relative_path="cli/verify/git.py", surface="import:subprocess", - line=5, + line=6, snippet="import subprocess", rationale=( "Verify uses local git commands to resolve refs, collect diffs, " - "resolve git metadata paths, and archive base/head trees. Fixed " - "argv, capture-only, no shell, and no network fetch." + "resolve git metadata paths, and copy/fsck exact base/head object " + "graphs into isolated stores. Fixed argv, no shell, and no " + "network fetch." ), ), AllowedException( relative_path="cli/verify/git.py", surface="attr_call:subprocess.run", - line=275, - snippet="subprocess.run(cmd, capture_output=True, check=check, text=text, timeout=60)", + line=616, + snippet=( + "subprocess.run(cmd, capture_output=capture_output, check=check, " + "env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, " + "text=text, timeout=timeout)" + ), + rationale=( + "Single _run_process boundary for verify: executes Git argv " + "assembled inside Shipgate (local ref/diff reads plus " + "pack-objects, index-pack, and fsck for immutable snapshots). " + "No shell, user-code execution, or fetch." + ), + ), + # core/authorization_execution.py — guarded consumer for an externally + # signed, typed Git push. It invokes only a host-protected Git binary and + # exact internally assembled argv after revalidation. + AllowedException( + relative_path="core/authorization_execution.py", + surface="import:subprocess", + line=16, + snippet="import subprocess", + rationale=( + "The guarded authorization executor snapshots/fscks one signed " + "commit graph and invokes its exact force-with-lease Git push. " + "The host Git binary and HTTPS helper are protected; no shell or " + "user-code execution is used." + ), + ), + AllowedException( + relative_path="core/authorization_execution.py", + surface="attr_call:subprocess.run", + line=518, + snippet=( + "subprocess.run(cmd, capture_output=capture_output, check=check, " + "cwd=cwd, env=env, input=input, stderr=stderr, stdin=stdin, " + "stdout=stdout, text=text, timeout=timeout)" + ), + rationale=( + "Single _run_process boundary for guarded authorization: all " + "callers use list argv, shell=False by default, a sanitized " + "environment, protected Git/transport paths, and bounded timeouts." + ), + ), + AllowedException( + relative_path="core/authorization_execution.py", + surface="attr_call:subprocess.Popen", + line=553, + snippet=( + "subprocess.Popen(cmd, env=env, stdin=subprocess.PIPE, " + "stdout=subprocess.PIPE, stderr=subprocess.PIPE)" + ), rationale=( - "_run_git helper for verify: executes fixed git argv assembled " - "inside Shipgate (rev-parse, diff, ls-tree, cat-file, and the " - "default-base detection, which only reads local refs). " - "Capture-only, no shell, no user-code execution, and no fetch." + "The isolated graph snapshot parent-streams Git pack-objects " + "through fixed pipes so stdout, stderr, and wall-clock use are " + "bounded without a thread-unsafe preexec hook. Fixed Git argv, " + "sanitized environment, no shell, no user-code execution." ), ), # cli/fixture.py — the ai_generated_refund_pr demo fixture creates a @@ -834,7 +884,7 @@ def test_scanner_source_contains_no_forbidden_calls_or_imports( scanner_source: Path, ) -> None: """Each scanner source is statically free of code-execution surfaces, - EXCEPT for the four legitimate first-party meta-CLI surfaces captured + EXCEPT for the audited first-party meta-CLI surfaces captured in ``ALLOWED_EXCEPTIONS`` (each with prose rationale). A regression here means a contributor added a way for the scanner to @@ -1284,7 +1334,7 @@ def test_allowed_exceptions_pin_subprocess_run_per_call_site() -> None: ) assert len(verify_subprocess_run) == 1, ( f"Expected 1 AllowedException entry for cli/verify/git.py " - f"subprocess.run (the shared fixed-argv _run_git helper), got " + f"subprocess.run (the shared fixed-argv process boundary), got " f"{len(verify_subprocess_run)}." ) diff --git a/tests/test_agent_handoff.py b/tests/test_agent_handoff.py index e10053d6..5280d353 100644 --- a/tests/test_agent_handoff.py +++ b/tests/test_agent_handoff.py @@ -20,9 +20,36 @@ AgentHandoffGate, AgentHandoffSubject, ) +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 ROOT = Path(__file__).resolve().parent.parent runner = CliRunner() +AUTHORIZED_COMMAND = ( + "git push --force-with-lease=refs/heads/codex/human-authorization-state:" + + "a" * 40 + + " origin HEAD:refs/heads/codex/human-authorization-state" +) + + +def _content_id(character: str) -> str: + return f"sha256:{character * 64}" + + +def _accepted_authorization() -> AuthorizationEvaluationV1: + return AuthorizationEvaluationV1( + status="accepted", + authorization_id=_content_id("1"), + authorization_request_id=_content_id("2"), + trust_policy_id=_content_id("3"), + key_id=_content_id("5"), + provider="github", + principal="github:user:reviewer", + operation_id=_content_id("4"), + command=AUTHORIZED_COMMAND, + issued_at="2026-07-18T12:00:00Z", + expires_at="2026-07-18T12:15:00Z", + reason_codes=[], + ) def _verifier_payload() -> dict: @@ -118,6 +145,30 @@ def _verifier_payload() -> dict: } +def _authorized_verifier_payload() -> dict: + payload = _verifier_payload() + release = payload["release_decision"] + release["decision"] = "review_required" + release["reason"] = "A protected workflow change requires human review." + release["review_items"] = release.pop("blockers") + payload["verifier_schema_version"] = "0.6" + payload["decision"] = "review_required" + payload["merge_verdict"] = "human_review_required" + payload["control"] = derive_agent_control( + reason="A human authorized one exact operation.", + next_action=CodingAgentCommandAction( + kind="repair", + command=AUTHORIZED_COMMAND, + why="Execute only the exact signed operation.", + ), + verify_required=True, + allowed_next_commands=[AUTHORIZED_COMMAND], + ).model_dump(mode="json") + payload["authorization"] = _accepted_authorization().model_dump(mode="json") + payload["fix_task"] = None + return payload + + def test_agent_handoff_projects_verifier_report_and_verify_run() -> None: handoff = build_agent_handoff( verifier=_verifier_payload(), @@ -138,12 +189,13 @@ def test_agent_handoff_projects_verifier_report_and_verify_run() -> None: }, ) - assert handoff.schema_version == "shipgate.agent_handoff/v5" + assert handoff.schema_version == "shipgate.agent_handoff/v6" assert handoff.gate.decision == "blocked" assert handoff.gate.merge_verdict == "blocked" assert handoff.gate.ci_would_fail is True assert handoff.control.state == "human_review_required" assert handoff.control.must_stop is True + assert handoff.authorization.status == "not_requested" assert handoff.blocked_by[0].id == "F1" assert {step.safety for step in handoff.remediation_plan} == { "allowed", @@ -154,7 +206,7 @@ def test_agent_handoff_projects_verifier_report_and_verify_run() -> None: def test_agent_handoff_schema_validates_sample_projection() -> None: - schema = json.loads((ROOT / "docs" / "agent-handoff-schema.v5.json").read_text()) + schema = json.loads((ROOT / "docs" / "agent-handoff-schema.v6.json").read_text()) payload = build_agent_handoff(verifier=_verifier_payload()).model_dump(mode="json") Draft202012Validator(schema).validate(payload) @@ -171,6 +223,7 @@ def test_agent_handoff_has_exact_top_level_sections() -> None: "subject", "gate", "control", + "authorization", "fix_task", "blocked_by", "remediation_plan", @@ -182,6 +235,34 @@ def test_agent_handoff_has_exact_top_level_sections() -> None: ] +def test_agent_handoff_projects_accepted_authorization_without_changing_gate() -> None: + handoff = build_agent_handoff(verifier=_authorized_verifier_payload()) + + assert handoff.authorization.model_dump(mode="json") == ( + _accepted_authorization().model_dump(mode="json") + ) + assert handoff.gate.decision == "review_required" + assert handoff.gate.merge_verdict == "human_review_required" + assert handoff.gate.can_merge_without_human is False + assert handoff.control.state == "agent_action_required" + assert handoff.control.completion_allowed is False + assert handoff.control.must_stop is False + assert handoff.control.next_action.kind == "repair" + assert handoff.control.next_action.command == AUTHORIZED_COMMAND + assert handoff.control.allowed_next_commands == [AUTHORIZED_COMMAND] + assert handoff.fix_task is None + + +def test_agent_handoff_rejects_authorization_command_drift() -> None: + handoff = build_agent_handoff(verifier=_authorized_verifier_payload()) + payload = handoff.model_dump(mode="json") + payload["control"]["next_action"]["command"] = "git push origin wrong-branch" + payload["control"]["allowed_next_commands"] = ["git push origin wrong-branch"] + + with pytest.raises(ValidationError, match="exactly bind the repair command"): + AgentHandoffArtifact.model_validate(payload) + + def test_agent_handoff_cli_rerenders_existing_artifacts(tmp_path: Path) -> None: report_dir = tmp_path / "agents-shipgate-reports" report_dir.mkdir() @@ -220,7 +301,7 @@ def test_agent_handoff_cli_rerenders_existing_artifacts(tmp_path: Path) -> None: emitted = json.loads(result.output) written = json.loads(out_path.read_text(encoding="utf-8")) assert emitted == written - assert emitted["schema_version"] == "shipgate.agent_handoff/v5" + assert emitted["schema_version"] == "shipgate.agent_handoff/v6" assert emitted["gate"]["decision"] == "blocked" @@ -255,6 +336,7 @@ def test_agent_handoff_rejects_mismatched_decision_and_merge_verdict() -> None: next_action=HumanControlAction(kind="review", why="Human review is required."), human_review_required=True, ), + authorization=AuthorizationEvaluationV1.not_requested(), ) @@ -274,6 +356,7 @@ def test_agent_handoff_rejects_controller_completion_mismatch() -> None: next_action=HumanControlAction(kind="review", why="Human review is required."), human_review_required=True, ), + authorization=AuthorizationEvaluationV1.not_requested(), ) @@ -301,6 +384,7 @@ def test_preview_handoff_carries_standing_forbidden_lists() -> None: "merge_verdict": "unknown", "applicability": "not_evaluated", "can_merge_without_human": False, + "authorization": AuthorizationEvaluationV1.not_requested().model_dump(mode="json"), "control": derive_agent_control( reason="Configure Agents Shipgate before verification.", next_action=CodingAgentCommandAction( diff --git a/tests/test_agent_instructions_apply.py b/tests/test_agent_instructions_apply.py index ed786c8d..ff2daa59 100644 --- a/tests/test_agent_instructions_apply.py +++ b/tests/test_agent_instructions_apply.py @@ -189,13 +189,13 @@ def test_claude_command_current_file_matches_renderer() -> None: def test_local_contract_renderer_has_required_fields() -> None: payload = json.loads(render_local_contract_file()) - assert payload["schema_version"] == "6" - assert payload["contract_version"] == "17" + assert payload["schema_version"] == "7" + assert payload["contract_version"] == "18" assert "verify_local" not in payload["primary_commands"] assert payload["primary_commands"]["verify_pr"].startswith("agents-shipgate verify") assert payload["commands"]["verify_local"].startswith("agents-shipgate verify") assert payload["primary_commands"]["host_audit"].startswith("shipgate audit --host") - assert payload["agent_handoff_schema_version"] == "shipgate.agent_handoff/v5" + assert payload["agent_handoff_schema_version"] == "shipgate.agent_handoff/v6" assert payload["agent_handoff_artifact"] == "agents-shipgate-reports/agent-handoff.json" assert payload["attestation_schema_version"] == "0.5" assert payload["registry_schema_version"] == "0.4" @@ -209,6 +209,7 @@ def test_local_contract_renderer_has_required_fields() -> None: assert payload["default_paths"]["local_contract"] == ".shipgate/agent-contract.json" assert payload["verifier_read_order"] == [ "control.state", + "authorization", "execution", "merge_verdict", "applicability", diff --git a/tests/test_agent_instructions_renderers.py b/tests/test_agent_instructions_renderers.py index e994c3c4..16e7ae79 100644 --- a/tests/test_agent_instructions_renderers.py +++ b/tests/test_agent_instructions_renderers.py @@ -160,18 +160,18 @@ def test_committed_claude_command_matches_renderer() -> None: def test_local_contract_renderer_exposes_agent_operational_fields() -> None: payload = json.loads(render_local_contract_file()) - assert payload["schema_version"] == "6" + assert payload["schema_version"] == "7" assert payload["agents_shipgate_version"] - assert payload["contract_version"] == "17" + assert payload["contract_version"] == "18" assert payload["minimum_control_contract_version"] == "14" assert payload["primary_commands"]["verify_pr"].startswith("agents-shipgate verify") assert payload["primary_commands"]["host_audit"].startswith("shipgate audit --host") assert "verify_local" not in payload["primary_commands"] assert payload["commands"]["verify_local"].startswith("agents-shipgate verify") - assert payload["verifier_schema_version"] == "0.5" + assert payload["verifier_schema_version"] == "0.6" assert payload["verify_run_schema_version"] == "shipgate.verify_run/v3" - assert payload["agent_handoff_schema_version"] == "shipgate.agent_handoff/v5" - assert payload["agent_handoff_schema_path"] == "docs/agent-handoff-schema.v5.json" + assert payload["agent_handoff_schema_version"] == "shipgate.agent_handoff/v6" + assert payload["agent_handoff_schema_path"] == "docs/agent-handoff-schema.v6.json" assert payload["agent_handoff_artifact"] == "agents-shipgate-reports/agent-handoff.json" assert payload["codex_boundary_result_schema_version"] == "shipgate.codex_boundary_result/v2" assert payload["agent_boundary_result_schema_version"] == ("shipgate.agent_boundary_result/v1") @@ -214,6 +214,7 @@ def test_local_contract_renderer_exposes_agent_operational_fields() -> None: "verification-receipt.json.receipt_id", "agent-handoff.json", "agent-handoff.json.control.state", + "agent-handoff.json.authorization", "verifier.json.control.state", "verify-run.json", "report.json.release_decision.decision", @@ -221,6 +222,16 @@ def test_local_contract_renderer_exposes_agent_operational_fields() -> None: assert payload["gating_signal"] == "release_decision.decision" +def test_local_contract_keeps_v6_managed_render_migration_hash() -> None: + from agents_shipgate.cli.discovery.agent_instructions.renderers.local_contract import ( + PRIOR_RENDER_SHA256, + ) + + assert "85d33d005d35f933b72e32c2d370efc2680e09d2ebe0c9997931c8ab4f352738" in ( + PRIOR_RENDER_SHA256 + ) + + def test_target_repo_cursor_snippet_matches_renderer() -> None: """The copyable docs snippet must match the generated Cursor file.""" text = (REPO_ROOT / "docs/target-repo-agent-snippets.md").read_text(encoding="utf-8") diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index 431bdca4..918df7c8 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -220,7 +220,7 @@ def test_verify_json_shortcut_prints_verifier_artifact(tmp_path: Path) -> None: assert result.exit_code == 0, result.output payload = json.loads(result.output) - assert payload["verifier_schema_version"] == "0.5" + assert payload["verifier_schema_version"] == "0.6" assert payload["merge_verdict"] == "insufficient_evidence" assert payload["can_merge_without_human"] is False assert payload["control"]["state"] == "human_review_required" @@ -230,7 +230,7 @@ def test_verify_json_shortcut_prints_verifier_artifact(tmp_path: Path) -> None: handoff_path = repo / "agents-shipgate-reports" / "agent-handoff.json" assert handoff_path.is_file() handoff = json.loads(handoff_path.read_text(encoding="utf-8")) - assert handoff["schema_version"] == "shipgate.agent_handoff/v5" + assert handoff["schema_version"] == "shipgate.agent_handoff/v6" assert handoff["operation"] == "verify_pr" assert not (repo / "agents-shipgate-reports" / "agent-result.json").exists() @@ -253,7 +253,7 @@ def test_verify_preview_writes_agent_handoff(tmp_path: Path) -> None: handoff = json.loads( (repo / "agents-shipgate-reports" / "agent-handoff.json").read_text(encoding="utf-8") ) - assert handoff["schema_version"] == "shipgate.agent_handoff/v5" + assert handoff["schema_version"] == "shipgate.agent_handoff/v6" assert handoff["operation"] == "verify_preview" assert handoff["gate"]["decision"] is None assert handoff["control"]["state"] == "agent_action_required" @@ -269,7 +269,7 @@ def test_verify_format_json_still_prints_full_verifier_artifact( assert result.exit_code == 0, result.output payload = json.loads(result.output) - assert payload["verifier_schema_version"] == "0.5" + assert payload["verifier_schema_version"] == "0.6" assert payload["execution"] == "succeeded" assert payload["head_status"] == "succeeded" assert payload["trigger"]["run_shipgate"] is True @@ -286,7 +286,7 @@ def test_verify_agent_environment_defaults_to_verifier_json( assert result.exit_code == 0, result.output payload = json.loads(result.output) - assert payload["verifier_schema_version"] == "0.5" + assert payload["verifier_schema_version"] == "0.6" assert payload["merge_verdict"] == "insufficient_evidence" diff --git a/tests/test_authorization_cli.py b/tests/test_authorization_cli.py new file mode 100644 index 00000000..0e57abe4 --- /dev/null +++ b/tests/test_authorization_cli.py @@ -0,0 +1,543 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError +from typer.testing import CliRunner + +from agents_shipgate.cli.main import app +from agents_shipgate.core.agent_control import derive_agent_control +from agents_shipgate.core.verification_identity import ( + build_terminal_receipt, + build_unit_result, + load_validated_receipt_artifacts, + sha256_bytes, +) +from agents_shipgate.schemas.agent_control import HumanControlAction +from agents_shipgate.schemas.human_authorization import ( + HumanAuthorizationRequestV1, + authorization_review_items, +) +from agents_shipgate.schemas.verification_identity import ( + VerificationBlob, + VerificationEngineRequirement, + VerificationGitSubject, + VerificationInputSet, + VerificationPlan, + VerificationSubject, + VerificationTask, + content_id, +) +from agents_shipgate.schemas.verify_run import VerifyRunOutcome, build_verify_run_artifact + +runner = CliRunner() +REPOSITORY_ID = "example.test/ThreeMoonsLab/agents-shipgate" +PUSH_URL = "https://example.test/ThreeMoonsLab/agents-shipgate.git" + + +def _review_required_report(items: list[Any]) -> dict[str, Any]: + return { + "release_decision": { + "decision": "review_required", + "review_items": items, + } + } + + +def _plan(*, committed: bool, plugins_enabled: bool = False) -> VerificationPlan: + git = VerificationGitSubject( + repository_id=REPOSITORY_ID, + base_ref="origin/main", + base_commit_sha="a" * 40, + base_tree_sha="b" * 40, + head_ref="HEAD", + source_head_commit_sha="c" * 40, + head_commit_sha="c" * 40, + head_tree_sha="d" * 40, + merge_base_sha="a" * 40, + snapshot_kind="committed_tree" if committed else "worktree_overlay", + worktree_overlay_sha256=None if committed else content_id({"overlay": True}), + ) + subject = VerificationSubject(subject_id=content_id(git), git=git) + config = VerificationBlob( + path="shipgate.yaml", + sha256=sha256_bytes(b'version: "0.1"\n'), + size_bytes=len(b'version: "0.1"\n'), + source="git_blob" if committed else "worktree", + ) + diff = VerificationBlob( + path="verification-input.diff", + sha256=sha256_bytes(b""), + size_bytes=0, + source="generated", + ) + input_payload = { + "evaluation_date": "2026-07-18", + "config": config, + "diff": diff, + "baseline": None, + "diff_from": None, + "policy_packs": [], + "tool_sources": [], + "changed_paths": [], + "changed_files": [], + "options": { + "ci_mode": "advisory", + "plugins_enabled": plugins_enabled, + }, + } + inputs = VerificationInputSet( + input_set_id=content_id( + { + key: value.model_dump(mode="json") if hasattr(value, "model_dump") else value + for key, value in input_payload.items() + } + ), + **input_payload, + ) + engine_payload = { + "version": "test-version", + "python_implementation": "CPython", + "python_version": "3.12.0", + "platform": "linux", + "engine_distribution_sha256": "sha256:" + "1" * 64, + "dependency_set_sha256": "sha256:" + "2" * 64, + "adapter_set_sha256": "sha256:" + "3" * 64, + "plugin_set_sha256": "sha256:" + "4" * 64, + "policy_catalog_sha256": "sha256:" + "5" * 64, + } + engine = VerificationEngineRequirement( + engine_requirement_id=content_id({"package": "agents-shipgate", **engine_payload}), + **engine_payload, + ) + task_payload = { + "kind": "evaluate", + "shard": 0, + "shard_count": 1, + "input_paths": ["shipgate.yaml"], + } + task = VerificationTask(task_id=content_id(task_payload), **task_payload) + request_payload = { + "subject_id": subject.subject_id, + "input_set_id": inputs.input_set_id, + "engine_requirement_id": engine.engine_requirement_id, + "task_ids": [task.task_id], + } + return VerificationPlan( + request_id=content_id(request_payload), + subject=subject, + inputs=inputs, + engine=engine, + tasks=[task], + ) + + +def _write_receipt_bundle( + root: Path, + *, + committed: bool, + plugins_enabled: bool = False, + review_items: list[dict[str, Any]] | None = None, +) -> tuple[Path, VerificationPlan]: + root.mkdir() + plan = _plan(committed=committed, plugins_enabled=plugins_enabled) + items = review_items or [ + { + "id": "fp_binding_review", + "fingerprint": "fp_binding_review", + "check_id": "SHIP-VERIFY-TRUST-ROOT-TOUCHED", + "source": {"path": "shipgate.yaml"}, + "support": {"support_hash": "sha256:" + "9" * 64}, + } + ] + paths = { + "verification_plan_json": root / "verification-plan.json", + "verification_input_diff": root / "verification-input.diff", + "verification_unit_result_json": root / "verification-unit-result.json", + "verify_run_json": root / "verify-run.json", + "report_json": root / "report.json", + } + unit = build_unit_result(plan=plan, normalized_ir={"test": "authorization-request"}) + outcome = VerifyRunOutcome( + exit_code=0, + base_status="not_requested", + execution="succeeded", + applicability="verified", + decision="review_required", + merge_verdict="human_review_required", + can_merge_without_human=False, + control=derive_agent_control( + reason="Human review is required.", + next_action=HumanControlAction( + kind="review", + why="Review the closed release-decision set.", + ), + human_review_required=True, + ), + ) + verify_run = build_verify_run_artifact( + plan=plan, + executor=unit.executor, + unit_result_ids=[unit.unit_result_id], + outcome=outcome, + artifacts={}, + ) + paths["verification_plan_json"].write_text( + json.dumps(plan.model_dump(mode="json"), sort_keys=True), + encoding="utf-8", + ) + paths["verification_input_diff"].write_bytes(b"") + paths["verification_unit_result_json"].write_text( + json.dumps(unit.model_dump(mode="json"), sort_keys=True), + encoding="utf-8", + ) + paths["verify_run_json"].write_text( + json.dumps(verify_run.model_dump(mode="json"), sort_keys=True), + encoding="utf-8", + ) + report = _review_required_report(items) + report.update( + { + "request_id": plan.request_id, + "subject_id": plan.subject.subject_id, + "input_set_id": plan.inputs.input_set_id, + "engine_requirement_id": plan.engine.engine_requirement_id, + "decision_id": verify_run.decision_id, + } + ) + paths["report_json"].write_text( + json.dumps(report, sort_keys=True), + encoding="utf-8", + ) + manifest, receipt = build_terminal_receipt( + plan=plan, + unit_results=[unit], + decision="review_required", + merge_verdict="human_review_required", + can_merge_without_human=False, + artifact_paths=paths, + artifact_root=root, + ) + (root / "verification-artifacts.json").write_text( + json.dumps(manifest.model_dump(mode="json"), sort_keys=True), + encoding="utf-8", + ) + receipt_path = root / "verification-receipt.json" + receipt_path.write_text( + json.dumps(receipt.model_dump(mode="json"), sort_keys=True), + encoding="utf-8", + ) + return receipt_path, plan + + +def _request_args(root: Path, receipt_path: Path, out: Path) -> list[str]: + return [ + "authorization", + "request", + "--receipt", + str(receipt_path), + "--artifacts-root", + str(root), + "--remote", + "origin", + "--destination-ref", + "refs/heads/codex/human-authorization-state", + "--expected-lease-oid", + "e" * 40, + "--out", + str(out), + "--json", + ] + + +def _git_repository(path: Path, *, remote_url: str = PUSH_URL) -> Path: + path.mkdir() + subprocess.run( + ["git", "init", "-q", "-b", "main"], + cwd=path, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", remote_url], + cwd=path, + check=True, + capture_output=True, + ) + return path + + +def test_review_items_projection_is_canonical_and_complete() -> None: + first = { + "id": "review-z", + "fingerprint": "fp-z", + "check_id": "SHIP-Z", + "source": {"path": "z/source.json"}, + "policy_evidence_source": {"path": "a/policy.yaml"}, + "support": {"support_hash": "sha256:" + "f" * 64}, + } + second = { + "id": "review-a", + "fingerprint": "review-a", + "check_id": "SHIP-A", + "source": {"path": "b/source.json"}, + } + + forward = authorization_review_items( + _review_required_report([first, second])["release_decision"] + ) + reverse = authorization_review_items( + _review_required_report([second, first])["release_decision"] + ) + + assert forward == reverse + assert [item.review_item_id for item in forward] == ["review-a", "review-z"] + assert forward[0].fingerprint == "review-a" + assert forward[1].support_hash == "sha256:" + "f" * 64 + assert forward[1].paths == ["a/policy.yaml", "z/source.json"] + + +@pytest.mark.parametrize( + "release_decision", + [ + {}, + {"decision": "passed", "review_items": [{}]}, + _review_required_report([])["release_decision"], + _review_required_report(["not-an-object"])["release_decision"], + _review_required_report([{"id": "review-1"}])["release_decision"], + _review_required_report([{"check_id": "SHIP-TEST"}])["release_decision"], + _review_required_report( + [ + { + "id": "review-1", + "check_id": "SHIP-TEST", + "source": {"path": "../escape"}, + } + ] + )["release_decision"], + _review_required_report( + [ + {"id": "review-1", "check_id": "SHIP-TEST"}, + {"id": "review-1", "check_id": "SHIP-OTHER"}, + ] + )["release_decision"], + ], + ids=[ + "missing-release-decision", + "not-review-required", + "empty-set", + "non-object-item", + "missing-check-id", + "missing-item-identity", + "non-portable-source-path", + "duplicate-item-identity", + ], +) +def test_review_items_reject_empty_or_malformed_closed_sets( + release_decision: dict[str, Any], +) -> None: + with pytest.raises((ValueError, ValidationError)): + authorization_review_items(release_decision) + + +def test_authorization_request_binds_exact_committed_git_push( + tmp_path: Path, +) -> None: + repo = _git_repository(tmp_path / "repo") + root = repo / "artifacts" + receipt_path, plan = _write_receipt_bundle(root, committed=True) + out = tmp_path / "authorization-request.json" + result = runner.invoke(app, _request_args(root, receipt_path, out)) + + assert result.exit_code == 0, result.output + request = HumanAuthorizationRequestV1.model_validate( + json.loads(out.read_text(encoding="utf-8")) + ) + receipt_payload = json.loads(receipt_path.read_text(encoding="utf-8")) + git = plan.subject.git + assert request.source_receipt_id == receipt_payload["receipt_id"] + assert request.source_artifact_set_id == receipt_payload["artifact_set_id"] + assert request.source_engine_requirement_id == receipt_payload["engine_requirement_id"] + assert request.source_executor_id == receipt_payload["executor_id"] + assert request.verification_request_id == plan.request_id + assert request.subject_id == plan.subject.subject_id + assert request.base_commit_sha == git.base_commit_sha + assert request.merge_base_sha == git.merge_base_sha + assert request.base_tree_sha == git.base_tree_sha + assert request.head_tree_sha == git.head_tree_sha + assert request.source_head_commit_sha == "c" * 40 + assert request.operation.destination_repository_id == REPOSITORY_ID + assert request.operation.push_url == PUSH_URL + assert "remote" not in request.operation.model_dump(mode="json") + assert request.operation.destination_ref == ("refs/heads/codex/human-authorization-state") + assert request.operation.expected_lease_oid == "e" * 40 + assert request.operation.command == ( + "git push " + "--force-with-lease=refs/heads/codex/human-authorization-state:" + + "e" * 40 + + " " + + PUSH_URL + + " " + + "c" * 40 + + ":refs/heads/codex/human-authorization-state" + ) + assert json.loads(result.output)["output_path"] == str(out) + + for field, replacement in ( + ("destination_ref", "refs/heads/attacker"), + ("expected_lease_oid", "f" * 40), + ("push_url", "https://attacker.test/ThreeMoonsLab/agents-shipgate.git"), + ( + "destination_repository_id", + "attacker.test/ThreeMoonsLab/agents-shipgate", + ), + ): + tampered = request.model_dump(mode="json") + tampered["operation"][field] = replacement + with pytest.raises(ValidationError): + HumanAuthorizationRequestV1.model_validate(tampered) + + +def test_receipt_loader_rejects_symlinked_artifacts_and_returns_immutable_bytes( + tmp_path: Path, +) -> None: + repo = _git_repository(tmp_path / "repo") + root = repo / "artifacts" + receipt_path, _plan_value = _write_receipt_bundle(root, committed=True) + + _receipt, artifacts = load_validated_receipt_artifacts( + receipt_path=receipt_path, + root=root, + ) + original_report = artifacts["report_json"] + (root / "report.json").write_text("{}", encoding="utf-8") + assert artifacts["report_json"] == original_report + + # Restore valid bytes behind a symlink. Hashes still match, but the loader + # rejects path substitution instead of following it. + target = root / "report-target.json" + target.write_bytes(original_report) + (root / "report.json").unlink() + (root / "report.json").symlink_to(target.name) + result = runner.invoke( + app, + _request_args(root, receipt_path, tmp_path / "request.json"), + ) + assert result.exit_code == 3 + assert "safely read receipt artifact" in result.output + error = json.loads(result.output) + assert error["error"] == "input_parse_error" + assert error["next_action"] + assert len(error["next_actions"]) == 1 + + +def test_receipt_loader_enforces_trusted_name_and_byte_budgets( + tmp_path: Path, +) -> None: + repo = _git_repository(tmp_path / "repo") + root = repo / "artifacts" + receipt_path, _plan_value = _write_receipt_bundle(root, committed=True) + + with pytest.raises(ValueError, match="outside the allowed execution closure"): + load_validated_receipt_artifacts( + receipt_path=receipt_path, + root=root, + allowed_artifact_names=frozenset(), + ) + with pytest.raises(ValueError, match="trusted total size limit"): + load_validated_receipt_artifacts( + receipt_path=receipt_path, + root=root, + max_total_size=1, + ) + + +def test_authorization_request_rejects_worktree_overlay( + tmp_path: Path, +) -> None: + repo = _git_repository(tmp_path / "repo") + root = repo / "artifacts" + receipt_path, _plan_value = _write_receipt_bundle(root, committed=False) + result = runner.invoke( + app, + _request_args(root, receipt_path, tmp_path / "request.json"), + ) + + assert result.exit_code == 3 + assert "human authorization rejects uncommitted worktree overlays" in result.output + assert not (tmp_path / "request.json").exists() + + +def test_authorization_request_rejects_plugin_enabled_engine( + tmp_path: Path, +) -> None: + repo = _git_repository(tmp_path / "repo") + root = repo / "artifacts" + receipt_path, _plan_value = _write_receipt_bundle( + root, + committed=True, + plugins_enabled=True, + ) + + result = runner.invoke( + app, + _request_args(root, receipt_path, tmp_path / "request.json"), + ) + + assert result.exit_code == 3 + assert "plugins-disabled engine mode" in result.output + assert not (tmp_path / "request.json").exists() + + +def test_authorization_request_rejects_remote_for_another_repository( + tmp_path: Path, +) -> None: + repo = _git_repository( + tmp_path / "repo", + remote_url="https://attacker.test/ThreeMoonsLab/agents-shipgate.git", + ) + root = repo / "artifacts" + receipt_path, _plan_value = _write_receipt_bundle(root, committed=True) + + result = runner.invoke( + app, + _request_args(root, receipt_path, tmp_path / "request.json"), + ) + + assert result.exit_code == 3 + assert "does not match the reviewed repository" in result.output + assert not (tmp_path / "request.json").exists() + + +@pytest.mark.parametrize( + "remote_url", + [ + "http://example.test/ThreeMoonsLab/agents-shipgate.git", + "ssh://git@example.test/ThreeMoonsLab/agents-shipgate.git", + "file:///tmp/agents-shipgate.git", + "../agents-shipgate.git", + "https://token@example.test/ThreeMoonsLab/agents-shipgate.git", + "https://example.test/ThreeMoonsLab/agents-shipgate.git?token=secret", + "https://example.test/ThreeMoonsLab/agents-shipgate.git#alternate", + ], +) +def test_authorization_request_rejects_unsafe_remote_endpoint( + tmp_path: Path, + remote_url: str, +) -> None: + repo = _git_repository(tmp_path / "repo", remote_url=remote_url) + root = repo / "artifacts" + receipt_path, _plan_value = _write_receipt_bundle(root, committed=True) + + result = runner.invoke( + app, + _request_args(root, receipt_path, tmp_path / "request.json"), + ) + + assert result.exit_code == 3 + assert "unsafe push URL" in result.output + assert not (tmp_path / "request.json").exists() diff --git a/tests/test_authorization_execution.py b/tests/test_authorization_execution.py new file mode 100644 index 00000000..cb21278b --- /dev/null +++ b/tests/test_authorization_execution.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +import os +import shlex +import subprocess +import zlib +from pathlib import Path + +import pytest + +from agents_shipgate.cli.verify import git as verify_git +from agents_shipgate.core import authorization_execution +from agents_shipgate.schemas.human_authorization import build_git_push_operation + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + check=True, + text=True, + ) + return result.stdout.strip() + + +def _committed_repo(tmp_path: Path) -> tuple[Path, str, str]: + repo = tmp_path / "source" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "tests@example.test") + _git(repo, "config", "user.name", "Shipgate Tests") + (repo / "policy.yaml").write_text("safe\n", encoding="utf-8") + _git(repo, "add", "policy.yaml") + _git(repo, "commit", "-q", "-m", "fixture") + return repo, _git(repo, "rev-parse", "HEAD"), _git(repo, "rev-parse", "HEAD:policy.yaml") + + +def _committed_sha256_repo(tmp_path: Path) -> tuple[Path, str, str]: + repo = tmp_path / "source-sha256" + repo.mkdir() + initialized = subprocess.run( + ["git", "init", "--object-format=sha256", "-q", str(repo)], + capture_output=True, + check=False, + text=True, + ) + if initialized.returncode != 0: + pytest.skip("local Git does not support SHA-256 repositories") + _git(repo, "config", "user.email", "tests@example.test") + _git(repo, "config", "user.name", "Shipgate Tests") + _git(repo, "remote", "add", "origin", "https://example.test/acme/sha256-agent.git") + (repo / "policy.yaml").write_text("safe\n", encoding="utf-8") + _git(repo, "add", "policy.yaml") + _git(repo, "commit", "-q", "-m", "fixture") + return repo, _git(repo, "rev-parse", "HEAD"), _git(repo, "rev-parse", "HEAD^{tree}") + + +def test_guarded_push_disables_http_redirect_retargeting( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The signed HTTPS endpoint must remain the endpoint Git contacts.""" + + repo = tmp_path / "repo" + (repo / ".git" / "objects").mkdir(parents=True) + operation = build_git_push_operation( + destination_repository_id="example.test/acme/review-agent", + push_url="https://example.test/acme/review-agent.git", + source_commit_sha="c" * 40, + destination_ref="refs/heads/codex/authorized", + expected_lease_oid="e" * 40, + ) + + monkeypatch.setattr( + authorization_execution, + "_trusted_git_executable", + lambda _workspace: Path("/usr/bin/git"), + ) + monkeypatch.setattr(authorization_execution, "_git_root", lambda _git, _workspace: repo) + monkeypatch.setattr( + authorization_execution, + "_repository_identity", + lambda _git, _workspace: operation.destination_repository_id, + ) + monkeypatch.setattr( + authorization_execution, + "_commit_sha", + lambda _git, _workspace, _ref: operation.source_commit_sha, + ) + monkeypatch.setattr( + authorization_execution, + "_tree_sha", + lambda _git, _workspace, _ref: "d" * 40, + ) + monkeypatch.setattr( + authorization_execution, + "_active_replace_refs", + lambda _git, _workspace: [], + ) + events: list[str] = [] + monkeypatch.setattr( + authorization_execution, + "_copy_verified_source_graph", + lambda **_kwargs: events.append("snapshot"), + ) + monkeypatch.setattr( + authorization_execution, + "_authorization_header", + lambda **_kwargs: events.append("credentials") or None, + ) + monkeypatch.setattr( + authorization_execution, + "_trusted_git_exec_path", + lambda _git: Path("/usr/libexec/git-core"), + ) + + invocations: list[tuple[list[str], dict[str, object]]] = [] + + def _run(argv, **kwargs): + invocations.append((argv, kwargs)) + if "rev-parse" in argv: + events.append("isolated-tree") + stdout = "d" * 40 + "\n" + else: + events.append("push") + stdout = "" + return subprocess.CompletedProcess(argv, 0, stdout=stdout, stderr="") + + monkeypatch.setattr(authorization_execution.subprocess, "run", _run) + + result = authorization_execution.execute_pinned_git_push( + operation, + workspace=repo, + expected_source_tree_sha="d" * 40, + revalidate_authority=lambda: events.append("revalidate"), + ) + + assert result.returncode == 0 + argv = invocations[-1][0] + assert isinstance(argv, list) + config_values = [argv[index + 1] for index, value in enumerate(argv[:-1]) if value == "-c"] + assert "http.followRedirects=false" in config_values + assert any(part.startswith("--git-dir=") for part in invocations[-2][0]) + assert events == ["snapshot", "isolated-tree", "credentials", "revalidate", "push"] + + +def test_execution_snapshot_rejects_blob_bytes_that_do_not_match_oid(tmp_path: Path) -> None: + repo, commit, blob = _committed_repo(tmp_path) + loose_object = repo / ".git" / "objects" / blob[:2] / blob[2:] + if not loose_object.is_file(): + pytest.skip("fixture blob is not stored as a loose object") + loose_object.chmod(0o644) + loose_object.write_bytes(zlib.compress(b"blob 5\0evil\n")) + + git_dir = tmp_path / "isolated.git" + (git_dir / "objects").mkdir(parents=True) + (git_dir / "config").write_text( + "[core]\n\trepositoryformatversion = 0\n\tbare = true\n", + encoding="utf-8", + ) + (git_dir / "HEAD").write_text("ref: refs/heads/empty\n", encoding="ascii") + git = Path("/usr/bin/git") + + with pytest.raises(ValueError, match="snapshot"): + authorization_execution._copy_verified_source_graph( + git=git, + workspace=repo, + git_dir=git_dir, + source_commit_sha=commit, + env=authorization_execution._read_only_git_environment(git), + ) + + +@pytest.mark.skipif(os.name != "posix", reason="authorization execution v1 is POSIX-only") +def test_source_snapshot_enforces_parent_side_pack_limit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo, commit, _blob = _committed_repo(tmp_path) + git_dir = tmp_path / "isolated.git" + (git_dir / "objects").mkdir(parents=True) + (git_dir / "config").write_text( + "[core]\n\trepositoryformatversion = 0\n\tbare = true\n", + encoding="utf-8", + ) + (git_dir / "HEAD").write_text("ref: refs/heads/empty\n", encoding="ascii") + git = Path("/usr/bin/git") + monkeypatch.setattr( + authorization_execution, + "_MAX_AUTHORIZED_GRAPH_PACK_BYTES", + 32, + ) + + with pytest.raises(ValueError, match="object graph exceeds"): + authorization_execution._copy_verified_source_graph( + git=git, + workspace=repo, + git_dir=git_dir, + source_commit_sha=commit, + env=authorization_execution._read_only_git_environment(git), + ) + + assert (tmp_path / "reachable.pack").stat().st_size <= 32 + + +def test_guarded_push_uses_repository_format_v1_for_sha256_objects( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo, commit, tree = _committed_sha256_repo(tmp_path) + operation = build_git_push_operation( + destination_repository_id="example.test/acme/sha256-agent", + push_url="https://example.test/acme/sha256-agent.git", + source_commit_sha=commit, + destination_ref="refs/heads/codex/authorized-sha256", + expected_lease_oid="e" * 64, + ) + monkeypatch.setattr( + authorization_execution, + "_authorization_header", + lambda **_kwargs: None, + ) + real_run_process = authorization_execution._run_process + observed_config: list[str] = [] + + def _intercept_push(cmd, **kwargs): + if "push" not in cmd: + return real_run_process(cmd, **kwargs) + git_dir_arg = next(part for part in cmd if part.startswith("--git-dir=")) + config = Path(git_dir_arg.removeprefix("--git-dir=")) / "config" + observed_config.append(config.read_text(encoding="utf-8")) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(authorization_execution, "_run_process", _intercept_push) + + result = authorization_execution.execute_pinned_git_push( + operation, + workspace=repo, + expected_source_tree_sha=tree, + ) + + assert result.returncode == 0 + assert observed_config == [ + "[core]\n" + "\trepositoryformatversion = 1\n" + "\tbare = true\n" + "\thooksPath = /dev/null\n" + "[extensions]\n" + "\tobjectFormat = sha256\n" + ] + + +def test_source_snapshot_disables_promisor_lazy_fetch_and_remote_helpers( + tmp_path: Path, +) -> None: + repo, commit, _blob = _committed_repo(tmp_path) + tree = _git(repo, "rev-parse", "HEAD^{tree}") + loose_object = repo / ".git" / "objects" / tree[:2] / tree[2:] + if not loose_object.is_file(): + pytest.skip("fixture blob is not stored as a loose object") + loose_object.unlink() + marker = tmp_path / "remote-helper-ran" + _git(repo, "config", "core.repositoryformatversion", "1") + _git(repo, "config", "extensions.partialClone", "evil") + _git(repo, "config", "remote.evil.promisor", "true") + _git(repo, "config", "remote.evil.partialclonefilter", "blob:none") + _git(repo, "config", "protocol.ext.allow", "always") + _git(repo, "config", "remote.evil.url", f"ext::touch {marker}") + + git_dir = tmp_path / "isolated.git" + (git_dir / "objects").mkdir(parents=True) + (git_dir / "config").write_text( + "[core]\n\trepositoryformatversion = 0\n\tbare = true\n", + encoding="utf-8", + ) + (git_dir / "HEAD").write_text("ref: refs/heads/empty\n", encoding="ascii") + execution_root = tmp_path / "execution" + (execution_root / "home").mkdir(parents=True) + (execution_root / "xdg").mkdir() + git = Path("/usr/bin/git") + env = authorization_execution._sanitized_git_environment( + git=git, + execution_root=execution_root, + ) + + with pytest.raises(subprocess.CalledProcessError): + verify_git.tree_sha(repo, commit) + assert not marker.exists() + with pytest.raises(ValueError, match="snapshot"): + authorization_execution._copy_verified_source_graph( + git=git, + workspace=repo, + git_dir=git_dir, + source_commit_sha=commit, + env=env, + ) + + assert env["GIT_NO_LAZY_FETCH"] == "1" + assert env["GIT_ALLOW_PROTOCOL"] == "" + assert "SHIPGATE_HTTP_AUTHORIZATION" not in env + assert not marker.exists() + + +def test_execute_command_preserves_virtualenv_launcher_symlink( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + launcher = tmp_path / "venv" / "bin" / "python" + launcher.parent.mkdir(parents=True) + launcher.symlink_to(Path(authorization_execution.sys.executable).resolve()) + monkeypatch.setattr(authorization_execution.sys, "executable", str(launcher)) + + command = authorization_execution.authorization_execute_command( + workspace=tmp_path / "workspace", + receipt="reports/verification-receipt.json", + artifacts_root="reports", + ) + + argv = shlex.split(command) + assert argv[2] == str(launcher) + assert argv[2] != str(launcher.resolve()) diff --git a/tests/test_authorization_verifier_scenarios.py b/tests/test_authorization_verifier_scenarios.py new file mode 100644 index 00000000..e9860993 --- /dev/null +++ b/tests/test_authorization_verifier_scenarios.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from typing import Literal + +import pytest + +from agents_shipgate.cli.verify.orchestrator import _apply_authorization_overlay +from agents_shipgate.core.agent_control import derive_agent_control +from agents_shipgate.schemas.agent_control import HumanControlAction +from agents_shipgate.schemas.disclaimers import STATIC_VERDICT_DISCLAIMER +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 +from agents_shipgate.schemas.verifier import VerifierArtifact, VerifierFixTask, map_merge_verdict + +AUTHORIZED_COMMAND = ( + "git push --force-with-lease=refs/heads/codex/human-authorization-state:" + + "a" * 40 + + " origin HEAD:refs/heads/codex/human-authorization-state" +) + + +def _content_id(character: str) -> str: + return f"sha256:{character * 64}" + + +def _accepted_authorization(*, command: str = AUTHORIZED_COMMAND) -> AuthorizationEvaluationV1: + return AuthorizationEvaluationV1( + status="accepted", + authorization_id=_content_id("1"), + authorization_request_id=_content_id("2"), + trust_policy_id=_content_id("3"), + key_id=_content_id("5"), + provider="github", + principal="github:user:reviewer", + operation_id=_content_id("4"), + command=command, + issued_at="2026-07-18T12:00:00Z", + expires_at="2026-07-18T12:15:00Z", + reason_codes=[], + ) + + +def _rejected_authorization(*, reason: str) -> AuthorizationEvaluationV1: + return AuthorizationEvaluationV1( + status="rejected", + authorization_id=_content_id("1"), + authorization_request_id=_content_id("2"), + trust_policy_id=_content_id("3"), + key_id=_content_id("5"), + provider="github", + principal="github:user:reviewer", + operation_id=_content_id("4"), + command=None, + issued_at="2026-07-18T12:00:00Z", + expires_at="2026-07-18T12:15:00Z", + reason_codes=[reason], + ) + + +def _release_decision(decision: str) -> dict[str, object]: + return { + "decision": decision, + "reason": f"Release decision is {decision}.", + "blockers": [], + "review_items": [], + "evidence_coverage": { + "level": "complete", + "human_review_recommended": False, + "source_warning_count": 0, + "low_confidence_tool_count": 0, + "evidence_gaps": [], + }, + "baseline_delta": {"enabled": False}, + "fail_policy": { + "ci_mode": "advisory", + "fail_on": ["critical", "high"], + "new_findings_only": False, + "would_fail_ci": False, + "exit_code": 0, + }, + "static_analysis_only": True, + "runtime_behavior_verified": False, + "static_verdict_disclaimer": STATIC_VERDICT_DISCLAIMER, + } + + +def _human_control(*, reason: str, blocked: bool = False): + return derive_agent_control( + reason=reason, + next_action=HumanControlAction( + kind="stop" if blocked else "review", + why=reason, + ), + verify_required=True, + human_review_required=True, + unsafe_block=blocked, + human_review_why=reason, + stop_reason=reason, + ) + + +def _verifier( + decision: Literal["blocked", "review_required", "insufficient_evidence"] | None, + *, + include_human_fix_task: bool = False, +) -> VerifierArtifact: + if decision is None: + reason = "Shipgate could not complete verification." + return VerifierArtifact( + workspace="/tmp/repo", + config="shipgate.yaml", + execution="failed", + head_status="failed", + release_decision=None, + decision=None, + merge_verdict="unknown", + applicability="failed", + can_merge_without_human=False, + control=_human_control(reason=reason), + authorization=AuthorizationEvaluationV1.not_requested(), + ) + + reason = f"Release decision is {decision}." + return VerifierArtifact( + workspace="/tmp/repo", + config="shipgate.yaml", + execution="succeeded", + head_status="succeeded", + release_decision=_release_decision(decision), + decision=decision, + merge_verdict=map_merge_verdict(decision), + applicability="verified", + can_merge_without_human=False, + control=_human_control(reason=reason, blocked=decision == "blocked"), + authorization=AuthorizationEvaluationV1.not_requested(), + fix_task=( + VerifierFixTask( + actor="human", + safe_to_attempt=False, + instructions=["Review and authorize the exact requested operation."], + ) + if include_human_fix_task + else None + ), + ) + + +def test_no_authorization_preserves_human_stop() -> None: + verifier = _verifier("review_required", include_human_fix_task=True) + before_control = verifier.control.model_dump_json() + + _apply_authorization_overlay(verifier, AuthorizationEvaluationV1.not_requested()) + + assert verifier.authorization.status == "not_requested" + assert verifier.control.model_dump_json() == before_control + assert verifier.control.state == "human_review_required" + assert verifier.control.must_stop is True + assert verifier.control.completion_allowed is False + assert verifier.control.allowed_next_commands == [] + assert verifier.fix_task is not None + + +def test_not_applicable_authorization_preserves_human_stop() -> None: + verifier = _verifier("review_required", include_human_fix_task=True) + before_control = verifier.control.model_dump_json() + + _apply_authorization_overlay( + verifier, + AuthorizationEvaluationV1.not_applicable("authorization_input_not_supplied"), + ) + + assert verifier.authorization.status == "not_applicable" + assert verifier.control.model_dump_json() == before_control + assert verifier.control.state == "human_review_required" + assert verifier.control.allowed_next_commands == [] + assert verifier.fix_task is not None + + +def test_accepted_exact_git_push_authorizes_only_that_operation() -> None: + verifier = _verifier("review_required", include_human_fix_task=True) + authorization = _accepted_authorization() + release_before = verifier.release_decision.model_dump_json() + + _apply_authorization_overlay(verifier, authorization) + + assert verifier.authorization.model_dump_json() == authorization.model_dump_json() + assert verifier.release_decision.model_dump_json() == release_before + assert verifier.decision == "review_required" + assert verifier.merge_verdict == "human_review_required" + assert verifier.can_merge_without_human is False + assert verifier.control.state == "agent_action_required" + assert verifier.control.completion_allowed is False + assert verifier.control.must_stop is False + assert verifier.control.next_action.actor == "coding_agent" + assert verifier.control.next_action.kind == "repair" + assert verifier.control.next_action.command == AUTHORIZED_COMMAND + assert verifier.control.allowed_next_commands == [AUTHORIZED_COMMAND] + assert verifier.fix_task is None + VerifierArtifact.model_validate(verifier.model_dump(mode="json")) + + +@pytest.mark.parametrize( + "decision", + ["blocked", "insufficient_evidence", None], + ids=["blocked", "insufficient-evidence", "unknown"], +) +def test_incompatible_accepted_authorization_fails_atomically( + decision: Literal["blocked", "insufficient_evidence"] | None, +) -> None: + verifier = _verifier(decision) + before = verifier.model_dump(mode="json") + + with pytest.raises(ValueError): + _apply_authorization_overlay(verifier, _accepted_authorization()) + + assert verifier.model_dump(mode="json") == before + assert verifier.control.state == "human_review_required" + assert verifier.control.allowed_next_commands == [] + + +def test_rejected_review_scope_mismatch_never_authorizes() -> None: + verifier = _verifier("review_required", include_human_fix_task=True) + before_control = verifier.control.model_dump_json() + before_fix_task = verifier.fix_task.model_dump_json() + evaluation = _rejected_authorization(reason="authorization_request_id_mismatch") + + _apply_authorization_overlay(verifier, evaluation) + + assert verifier.authorization.model_dump_json() == evaluation.model_dump_json() + assert verifier.control.model_dump_json() == before_control + assert verifier.control.state == "human_review_required" + assert verifier.control.allowed_next_commands == [] + assert verifier.fix_task is not None + assert verifier.fix_task.model_dump_json() == before_fix_task diff --git a/tests/test_authorization_verify_integration.py b/tests/test_authorization_verify_integration.py new file mode 100644 index 00000000..bd10ca2e --- /dev/null +++ b/tests/test_authorization_verify_integration.py @@ -0,0 +1,775 @@ +from __future__ import annotations + +import base64 +import json +import os +import subprocess +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from typer.testing import CliRunner + +from agents_shipgate.cli import authorization as authorization_cli +from agents_shipgate.cli.main import app +from agents_shipgate.cli.verify import orchestrator +from agents_shipgate.cli.verify.orchestrator import run_verify +from agents_shipgate.core.authorization_execution import authorization_execute_command +from agents_shipgate.core.human_authorization import ( + evaluate_human_authorization, + human_authorization_signature_payload, +) +from agents_shipgate.core.verification_identity import ( + build_terminal_receipt, + sha256_file, + validate_receipt_artifacts, +) +from agents_shipgate.schemas.human_authorization import ( + HumanAuthorizationPrincipalV1, + HumanAuthorizationProofV1, + HumanAuthorizationRequestV1, + HumanAuthorizationStatementV1, + HumanAuthorizationV1, + TrustedEd25519KeyV1, + authorization_review_items, + build_git_push_operation, + build_human_authorization, + build_human_authorization_request, + build_human_authorization_trust_policy, + ed25519_key_id, +) +from agents_shipgate.schemas.verification_identity import ( + VerificationInputSet, + VerificationPlan, + VerificationReceipt, + VerificationUnitResult, + content_id, +) + +DESTINATION_REF = "refs/heads/codex/human-authorization-state" +LEASE_OID = "e" * 40 +REPOSITORY_ID = "example.test/acme/review-agent" +PUSH_URL = "https://example.test/acme/review-agent.git" +runner = CliRunner() + + +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "Authorization Test", + "GIT_AUTHOR_EMAIL": "authorization@example.test", + "GIT_COMMITTER_NAME": "Authorization Test", + "GIT_COMMITTER_EMAIL": "authorization@example.test", + }, + ) + + +def _committed_review_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "shipgate.yaml").write_text( + """ +version: "0.1" +project: + name: authorization-integration +agent: + name: empty-test-agent + declared_purpose: + - exercise verifier authorization integration +environment: + target: local +tool_sources: + - id: docs_tools + type: mcp + path: tools.json +agent_bindings: + declarations: + - agent: root + complete: true + tools: + - {tool: docs.lookup, source_id: docs_tools} + handoffs: [] + reason: reviewed local integration-test binding +permissions: + scopes: + - docs:read +action_surface: + actions: + - tool: docs.lookup + effect: read + scopes: [docs:read] + authority: + mode: scoped + auth_type: oauth2 + credential_mode: delegated +""".lstrip(), + encoding="utf-8", + ) + (repo / "tools.json").write_text( + """ +{ + "tools": [ + { + "name": "docs.lookup", + "description": "Look up metadata for one existing documentation article.", + "inputSchema": { + "type": "object", + "properties": {"article_id": {"type": "string"}}, + "required": ["article_id"], + "additionalProperties": false + }, + "annotations": {"readOnlyHint": true}, + "auth": {"type": "oauth2", "scopes": ["docs:read"]}, + "owner": "docs-platform" + } + ] +} +""".lstrip(), + encoding="utf-8", + ) + _git(repo, "init", "-q", "-b", "main") + _git(repo, "remote", "add", "origin", "https://example.test/acme/review-agent.git") + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", "base") + + tools_path = repo / "tools.json" + tools_path.write_text( + tools_path.read_text(encoding="utf-8").replace( + "Look up metadata for one existing documentation article.", + "Too short.", + ), + encoding="utf-8", + ) + _git(repo, "add", "tools.json") + _git(repo, "commit", "-q", "-m", "introduce review item") + return repo + + +def _verify( + repo: Path, + *, + authorization: Path | None = None, + plugins_enabled: bool = False, +): + return run_verify( + workspace=repo, + config=Path("shipgate.yaml"), + base="HEAD~1", + head="HEAD", + archive_head=True, + out=repo / "agents-shipgate-reports", + ci_mode="advisory", + fail_on=None, + baseline=None, + baseline_mode="new-findings", + diff_from=None, + policy_packs=None, + plugins_enabled=plugins_enabled, + strict_plugins=False, + suggest_patches=False, + no_heuristics=False, + verbose=False, + authorization=authorization, + ) + + +def _load_json(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + assert isinstance(payload, dict) + return payload + + +def _b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _request_from_first_verification(repo: Path) -> HumanAuthorizationRequestV1: + out = repo / "agents-shipgate-reports" + receipt = _validated_receipt(out) + plan = VerificationPlan.model_validate(_load_json(out / "verification-plan.json")) + report = _load_json(out / "report.json") + release_decision = report["release_decision"] + assert isinstance(release_decision, dict) + review_items = authorization_review_items(release_decision) + git = plan.subject.git + assert git.base_commit_sha is not None + assert git.merge_base_sha is not None + assert git.base_tree_sha is not None + assert git.head_tree_sha is not None + assert git.source_head_commit_sha is not None + assert git.repository_id == REPOSITORY_ID + operation = build_git_push_operation( + destination_repository_id=git.repository_id, + push_url=PUSH_URL, + source_commit_sha=git.source_head_commit_sha, + destination_ref=DESTINATION_REF, + expected_lease_oid=LEASE_OID, + ) + verifier = _load_json(out / "verifier.json") + decision_id = verifier["decision_id"] + assert isinstance(decision_id, str) + return build_human_authorization_request( + repository_id=git.repository_id, + source_receipt_id=receipt.receipt_id, + source_artifact_set_id=receipt.artifact_set_id, + source_engine_requirement_id=receipt.engine_requirement_id, + source_executor_id=receipt.executor_id, + verification_request_id=plan.request_id, + subject_id=plan.subject.subject_id, + decision_id=decision_id, + base_commit_sha=git.base_commit_sha, + merge_base_sha=git.merge_base_sha, + base_tree_sha=git.base_tree_sha, + head_tree_sha=git.head_tree_sha, + source_head_commit_sha=git.source_head_commit_sha, + review_items=review_items, + operation=operation, + ) + + +def _trusted_key( + private_key: Ed25519PrivateKey, + *, + now: datetime, +) -> TrustedEd25519KeyV1: + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return TrustedEd25519KeyV1( + key_id=ed25519_key_id(public_key), + public_key=_b64url(public_key), + provider="github", + principal="github:user:release-reviewer", + valid_from=now - timedelta(days=1), + valid_until=now + timedelta(days=1), + ) + + +def _signed_grant( + private_key: Ed25519PrivateKey, + request: HumanAuthorizationRequestV1, + *, + issued_at: datetime, + not_before: datetime, + expires_at: datetime, +) -> HumanAuthorizationV1: + statement = HumanAuthorizationStatementV1( + request=request, + principal=HumanAuthorizationPrincipalV1( + provider="github", + subject="github:user:release-reviewer", + ), + reason="Reviewed the exact force-with-lease operation.", + issued_at=issued_at, + not_before=not_before, + expires_at=expires_at, + nonce=_b64url(b"authorization-e2e-nonce"), + ) + proof = HumanAuthorizationProofV1( + key_id=ed25519_key_id( + private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ), + signature=_b64url(private_key.sign(human_authorization_signature_payload(statement))), + ) + return build_human_authorization(statement=statement, proof=proof) + + +def _write_host_policy( + path: Path, + *, + request: HumanAuthorizationRequestV1, + key: TrustedEd25519KeyV1, +) -> None: + policy = build_human_authorization_trust_policy( + repository_ids=[request.repository_id], + keys=[key], + max_ttl_seconds=900, + clock_skew_seconds=30, + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(policy.model_dump_json(indent=2), encoding="utf-8") + path.chmod(0o600) + + +def _write_grant(path: Path, grant: HumanAuthorizationV1) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(grant.model_dump(mode="json"), indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def _validated_receipt(out: Path) -> VerificationReceipt: + receipt = VerificationReceipt.model_validate(_load_json(out / "verification-receipt.json")) + validate_receipt_artifacts(receipt, root=out) + return receipt + + +def _static_gate(verifier) -> tuple[str | None, str, bool]: + return verifier.decision, verifier.merge_verdict, verifier.can_merge_without_human + + +def test_signed_authorization_overlays_one_exact_command_and_binds_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _committed_review_repo(tmp_path) + initial, report, exit_code = _verify(repo) + assert exit_code == 0 + assert report is not None + assert _static_gate(initial) == ( + "review_required", + "human_review_required", + False, + ) + _validated_receipt(repo / "agents-shipgate-reports") + + request = _request_from_first_verification(repo) + now = datetime.now(UTC) + private_key = Ed25519PrivateKey.generate() + trust_path = tmp_path / "host" / "trust-policy.json" + _write_host_policy( + trust_path, + request=request, + key=_trusted_key(private_key, now=now), + ) + grant = _signed_grant( + private_key, + request, + issued_at=now - timedelta(seconds=1), + not_before=now - timedelta(seconds=1), + expires_at=now + timedelta(minutes=5), + ) + grant_path = tmp_path / "host" / "authorization.json" + _write_grant(grant_path, grant) + monkeypatch.setattr( + orchestrator, + "default_human_authorization_trust_policy_path", + lambda: trust_path, + ) + + authorized, authorized_report, authorized_exit = _verify( + repo, + authorization=grant_path, + ) + + assert authorized_exit == exit_code + assert authorized_report is not None + assert _static_gate(authorized) == _static_gate(initial) + assert authorized_report.release_decision.decision == report.release_decision.decision + assert authorized.authorization.status == "accepted" + assert authorized.authorization.authorization_id == grant.authorization_id + out = repo / "agents-shipgate-reports" + execute_command = authorization_execute_command( + workspace=repo, + receipt=out / "verification-receipt.json", + artifacts_root=out, + ) + assert authorized.authorization.command == execute_command + assert authorized.control.state == "agent_action_required" + assert authorized.control.must_stop is False + assert authorized.control.completion_allowed is False + assert authorized.control.allowed_next_commands == [execute_command] + assert authorized.control.next_action.kind == "repair" + assert authorized.control.next_action.command == execute_command + assert authorized.fix_task is None + + handoff = _load_json(out / "agent-handoff.json") + assert handoff["authorization"]["status"] == "accepted" # type: ignore[index] + assert handoff["control"] == authorized.control.model_dump(mode="json") + assert handoff["gate"]["decision"] == "review_required" # type: ignore[index] + assert handoff["gate"]["merge_verdict"] == "human_review_required" # type: ignore[index] + assert handoff["gate"]["can_merge_without_human"] is False # type: ignore[index] + + canonical_path = out / "human-authorization.json" + assert _load_json(canonical_path) == grant.model_dump(mode="json") + assert authorized.artifacts["human_authorization_json"] == ( + "agents-shipgate-reports/human-authorization.json" + ) + receipt = _validated_receipt(out) + assert receipt.decision == "review_required" + assert receipt.merge_verdict == "human_review_required" + assert receipt.can_merge_without_human is False + assert "human_authorization_json" in receipt.artifact_manifest.artifacts + + # A broker must not touch promised/missing source objects through the + # workspace's agent-controlled partial-clone configuration before entering + # the hardened executor. Such a read could otherwise launch remote-ext. + tree = subprocess.run( + ["git", "rev-parse", "HEAD^{tree}"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + loose_tree = repo / ".git" / "objects" / tree[:2] / tree[2:] + assert loose_tree.is_file() + loose_tree.unlink() + helper_marker = tmp_path / "pre-executor-remote-helper-ran" + _git(repo, "config", "core.repositoryformatversion", "1") + _git(repo, "config", "extensions.partialClone", "evil") + _git(repo, "config", "remote.evil.promisor", "true") + _git(repo, "config", "remote.evil.partialclonefilter", "tree:0") + _git(repo, "config", "protocol.ext.allow", "always") + _git(repo, "config", "remote.evil.url", f"ext::touch {helper_marker}") + + executed: dict[str, object] = {} + + def _execute( + operation, + *, + workspace, + expected_source_tree_sha, + revalidate_authority, + ): + revalidate_authority() + executed["operation"] = operation + executed["workspace"] = workspace + executed["expected_source_tree_sha"] = expected_source_tree_sha + return subprocess.CompletedProcess([], 0, stdout="ok", stderr="") + + monkeypatch.setattr( + authorization_cli, + "default_human_authorization_trust_policy_path", + lambda: trust_path, + ) + monkeypatch.setattr(authorization_cli, "execute_pinned_git_push", _execute) + + # A receipt that claims an enabled plugin engine must fail before the + # protected broker inspects the workspace or validates a plugin catalog. + plan = VerificationPlan.model_validate(_load_json(out / "verification-plan.json")) + plugin_plan_payload = plan.model_dump(mode="json") + plugin_inputs_payload = plan.inputs.model_dump(mode="json") + plugin_inputs_payload["options"]["plugins_enabled"] = True + plugin_inputs_payload["input_set_id"] = content_id( + { + key: value + for key, value in plugin_inputs_payload.items() + if key != "input_set_id" + } + ) + plugin_inputs = VerificationInputSet.model_validate(plugin_inputs_payload) + plugin_plan_payload["inputs"] = plugin_inputs.model_dump(mode="json") + plugin_plan_payload["request_id"] = content_id( + { + "subject_id": plan.subject.subject_id, + "input_set_id": plugin_inputs.input_set_id, + "engine_requirement_id": plan.engine.engine_requirement_id, + "task_ids": [task.task_id for task in plan.tasks], + } + ) + plugin_plan = VerificationPlan.model_validate(plugin_plan_payload) + plugin_artifacts = { + name: (out / ref.path).read_bytes() + for name, ref in receipt.artifact_manifest.artifacts.items() + } + plugin_artifacts["verification_plan_json"] = plugin_plan.model_dump_json().encode() + + with monkeypatch.context() as plugin_guard: + plugin_guard.setattr( + authorization_cli, + "load_validated_receipt_artifacts", + lambda **_kwargs: (receipt, plugin_artifacts), + ) + plugin_guard.setattr( + authorization_cli, + "authorization_workspace_root", + lambda _workspace: pytest.fail("plugin guard ran after protected runtime entry"), + ) + plugin_result = runner.invoke( + app, + [ + "authorization", + "execute", + "--workspace", + str(repo), + "--receipt", + str(out / "verification-receipt.json"), + "--artifacts-root", + str(out), + ], + ) + assert plugin_result.exit_code == 3 + assert "plugins-disabled engine mode" in plugin_result.output + assert executed == {} + + execute_result = runner.invoke( + app, + [ + "authorization", + "execute", + "--workspace", + str(repo), + "--receipt", + str(out / "verification-receipt.json"), + "--artifacts-root", + str(out), + ], + ) + assert execute_result.exit_code == 0, execute_result.output + assert not helper_marker.exists() + assert json.loads(execute_result.output)["executed"] is True + assert executed == { + "operation": request.operation, + "workspace": repo.resolve(), + "expected_source_tree_sha": request.head_tree_sha, + } + + executed.clear() + + # Terminal receipts are content-addressed closure, not an authority + # signature. Even when an agent rebuilds that closure around a malicious + # shell suffix, the executor must derive the wrapper independently and + # reject the artifact text before dispatching the signed operation. + original_artifacts = { + path: path.read_bytes() + for path in ( + out / "verifier.json", + out / "verify-run.json", + out / "verification-artifacts.json", + out / "verification-receipt.json", + ) + } + forged_command = f"{execute_command} ; touch agent-controlled" + forged_verifier = _load_json(out / "verifier.json") + forged_verifier["authorization"]["command"] = forged_command # type: ignore[index] + forged_verifier["control"]["allowed_next_commands"] = [forged_command] # type: ignore[index] + forged_verifier["control"]["next_action"]["command"] = forged_command # type: ignore[index] + (out / "verifier.json").write_text( + json.dumps(forged_verifier, indent=2, sort_keys=True), + encoding="utf-8", + ) + forged_verify_run = _load_json(out / "verify-run.json") + forged_verify_run["artifacts"]["verifier_json"]["sha256"] = sha256_file( # type: ignore[index] + out / "verifier.json" + ).removeprefix("sha256:") + (out / "verify-run.json").write_text( + json.dumps(forged_verify_run, indent=2, sort_keys=True), + encoding="utf-8", + ) + plan = VerificationPlan.model_validate(_load_json(out / "verification-plan.json")) + units = [ + VerificationUnitResult.model_validate(_load_json(out / ref.path)) + for name, ref in receipt.artifact_manifest.artifacts.items() + if name.startswith("verification_unit_result") + ] + forged_manifest, forged_receipt = build_terminal_receipt( + plan=plan, + unit_results=units, + decision=receipt.decision, + merge_verdict=receipt.merge_verdict, + can_merge_without_human=receipt.can_merge_without_human, + artifact_paths={ + name: out / ref.path + for name, ref in receipt.artifact_manifest.artifacts.items() + }, + artifact_root=out, + ) + (out / "verification-artifacts.json").write_text( + forged_manifest.model_dump_json(indent=2), + encoding="utf-8", + ) + (out / "verification-receipt.json").write_text( + forged_receipt.model_dump_json(indent=2), + encoding="utf-8", + ) + validate_receipt_artifacts(forged_receipt, root=out) + + forged_result = runner.invoke( + app, + [ + "authorization", + "execute", + "--workspace", + str(repo), + "--receipt", + str(out / "verification-receipt.json"), + "--artifacts-root", + str(out), + ], + ) + assert forged_result.exit_code == 3 + assert "bound verifier authorization disagrees" in forged_result.output + assert executed == {} + for path, content in original_artifacts.items(): + path.write_bytes(content) + validate_receipt_artifacts(receipt, root=out) + + def _evaluate_after_expiry(*args, **kwargs): + return evaluate_human_authorization( + *args, + **kwargs, + now=grant.statement.expires_at, + ) + + monkeypatch.setattr( + authorization_cli, + "evaluate_human_authorization", + _evaluate_after_expiry, + ) + expired_result = runner.invoke( + app, + [ + "authorization", + "execute", + "--workspace", + str(repo), + "--receipt", + str(out / "verification-receipt.json"), + "--artifacts-root", + str(out), + ], + ) + assert expired_result.exit_code == 3 + assert "authorization_expired" in expired_result.output + assert executed == {} + + +def test_plugin_enabled_verification_never_exposes_authorized_command( + tmp_path: Path, +) -> None: + repo = _committed_review_repo(tmp_path) + grant_path = tmp_path / "host" / "authorization.json" + + verifier, report, exit_code = _verify( + repo, + authorization=grant_path, + plugins_enabled=True, + ) + + assert exit_code == 0 + assert report is not None + assert verifier.authorization.status == "not_applicable" + assert verifier.authorization.reason_codes == [ + "authorization_requires_plugins_disabled" + ] + assert verifier.authorization.command is None + assert verifier.control.state == "human_review_required" + assert verifier.control.allowed_next_commands == [] + + +@pytest.mark.parametrize( + "failure", + ["tampered", "expired", "wrong_tree", "missing_trust"], +) +def test_invalid_authorization_keeps_human_stop_and_receipt_valid( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + repo = _committed_review_repo(tmp_path) + initial, report, exit_code = _verify(repo) + assert exit_code == 0 + assert report is not None + assert initial.control.state == "human_review_required" + _validated_receipt(repo / "agents-shipgate-reports") + + request = _request_from_first_verification(repo) + now = datetime.now(UTC) + private_key = Ed25519PrivateKey.generate() + trust_path = tmp_path / "host" / "trust-policy.json" + if failure != "missing_trust": + _write_host_policy( + trust_path, + request=request, + key=_trusted_key(private_key, now=now), + ) + + grant_request = request + if failure == "wrong_tree": + grant_request = build_human_authorization_request( + repository_id=request.repository_id, + source_receipt_id=request.source_receipt_id, + source_artifact_set_id=request.source_artifact_set_id, + source_engine_requirement_id=request.source_engine_requirement_id, + source_executor_id=request.source_executor_id, + verification_request_id=request.verification_request_id, + subject_id=request.subject_id, + decision_id=request.decision_id, + base_commit_sha=request.base_commit_sha, + merge_base_sha=request.merge_base_sha, + base_tree_sha="f" * 40, + head_tree_sha=request.head_tree_sha, + source_head_commit_sha=request.source_head_commit_sha, + review_items=request.review_items, + operation=request.operation, + ) + issued_at = now - timedelta(seconds=1) + not_before = now - timedelta(seconds=1) + expires_at = now + timedelta(minutes=5) + if failure == "expired": + issued_at = now - timedelta(minutes=10) + not_before = issued_at + expires_at = now - timedelta(seconds=1) + grant = _signed_grant( + private_key, + grant_request, + issued_at=issued_at, + not_before=not_before, + expires_at=expires_at, + ) + if failure == "tampered": + raw = base64.urlsafe_b64decode( + grant.proof.signature + "=" * (-len(grant.proof.signature) % 4) + ) + grant = build_human_authorization( + statement=grant.statement, + proof=HumanAuthorizationProofV1( + key_id=grant.proof.key_id, + signature=_b64url(bytes([raw[0] ^ 1]) + raw[1:]), + ), + ) + grant_path = tmp_path / "host" / "authorization.json" + _write_grant(grant_path, grant) + monkeypatch.setattr( + orchestrator, + "default_human_authorization_trust_policy_path", + lambda: trust_path, + ) + + rejected, rejected_report, rejected_exit = _verify( + repo, + authorization=grant_path, + ) + + assert rejected_exit == exit_code + assert rejected_report is not None + assert _static_gate(rejected) == _static_gate(initial) + assert rejected_report.release_decision.decision == report.release_decision.decision + assert rejected.authorization.status == "rejected" + assert rejected.authorization.command is None + expected_reasons = { + "tampered": {"signature_invalid"}, + "expired": {"authorization_expired"}, + "wrong_tree": { + "authorization_request_id_mismatch", + "authorization_request_mismatch", + }, + "missing_trust": {"trust_policy_unavailable"}, + } + assert expected_reasons[failure] <= set(rejected.authorization.reason_codes) + assert rejected.control.state == "human_review_required" + assert rejected.control.must_stop is True + assert rejected.control.completion_allowed is False + assert rejected.control.allowed_next_commands == [] + assert getattr(rejected.control.next_action, "command", None) is None + + out = repo / "agents-shipgate-reports" + handoff = _load_json(out / "agent-handoff.json") + assert handoff["authorization"]["status"] == "rejected" # type: ignore[index] + assert handoff["control"] == rejected.control.model_dump(mode="json") + assert "human_authorization_json" not in rejected.artifacts + assert not (out / "human-authorization.json").exists() + receipt = _validated_receipt(out) + assert "human_authorization_json" not in receipt.artifact_manifest.artifacts diff --git a/tests/test_cli.py b/tests/test_cli.py index ec8ebdce..bdca763e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,6 +44,12 @@ HOST_GRANTS_BASELINE_SCHEMA_VERSION, HOST_GRANTS_DRIFT_SCHEMA_VERSION, HOST_GRANTS_INVENTORY_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_SCHEMA_PATH, + HUMAN_AUTHORIZATION_SCHEMA_VERSION, + HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH, + HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION, MANUAL_REVIEW_SIGNALS, MCP_TOOLS, MERGE_VERDICTS, @@ -267,6 +273,12 @@ def test_cli_contract_json_outputs_runtime_contract(): "verification_unit_result_schema_version", "verification_artifact_manifest_schema_version", "verification_receipt_schema_version", + "human_authorization_request_schema_version", + "human_authorization_schema_version", + "human_authorization_evaluation_schema_version", + "human_authorization_trust_policy_schema_version", + "human_authorization_trust_policy_default_path", + "human_authorization_schema_path", "agent_handoff_schema_version", "agent_handoff_schema_path", "agent_handoff_artifact", @@ -324,6 +336,20 @@ def test_cli_contract_json_outputs_runtime_contract(): VERIFICATION_ARTIFACT_MANIFEST_SCHEMA_VERSION ), "verification_receipt_schema_version": VERIFICATION_RECEIPT_SCHEMA_VERSION, + "human_authorization_request_schema_version": ( + HUMAN_AUTHORIZATION_REQUEST_SCHEMA_VERSION + ), + "human_authorization_schema_version": HUMAN_AUTHORIZATION_SCHEMA_VERSION, + "human_authorization_evaluation_schema_version": ( + HUMAN_AUTHORIZATION_EVALUATION_SCHEMA_VERSION + ), + "human_authorization_trust_policy_schema_version": ( + HUMAN_AUTHORIZATION_TRUST_POLICY_SCHEMA_VERSION + ), + "human_authorization_trust_policy_default_path": ( + HUMAN_AUTHORIZATION_TRUST_POLICY_ACCOUNT_PATH + ), + "human_authorization_schema_path": HUMAN_AUTHORIZATION_SCHEMA_PATH, "agent_handoff_schema_version": AGENT_HANDOFF_SCHEMA_VERSION, "agent_handoff_schema_path": AGENT_HANDOFF_SCHEMA_PATH, "agent_handoff_artifact": ARTIFACTS["agent_handoff"], diff --git a/tests/test_human_authorization.py b/tests/test_human_authorization.py new file mode 100644 index 00000000..9124cee1 --- /dev/null +++ b/tests/test_human_authorization.py @@ -0,0 +1,763 @@ +from __future__ import annotations + +import base64 +import json +import os +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from agents_shipgate.core.human_authorization import ( + default_human_authorization_trust_policy_path, + evaluate_human_authorization, + human_authorization_signature_payload, + load_external_trust_policy, +) +from agents_shipgate.schemas.human_authorization import ( + AuthorizationEvaluationV1, + HumanAuthorizationPrincipalV1, + HumanAuthorizationProofV1, + HumanAuthorizationRequestV1, + HumanAuthorizationReviewItemV1, + HumanAuthorizationStatementV1, + HumanAuthorizationTrustPolicyV1, + HumanAuthorizationV1, + TrustedEd25519KeyV1, + authorization_review_items, + build_git_push_operation, + build_human_authorization, + build_human_authorization_request, + build_human_authorization_trust_policy, + ed25519_key_id, +) +from agents_shipgate.schemas.verification_identity import content_id + +NOW = datetime(2026, 7, 18, 12, 0, tzinfo=UTC) +BASE_COMMIT = "9" * 40 +MERGE_BASE = "8" * 40 +BASE_TREE = "a" * 40 +HEAD_TREE = "b" * 40 +HEAD_COMMIT = "c" * 40 +LEASE_OID = "d" * 40 +DESTINATION_REF = "refs/heads/codex/human-authorization-state" +REPOSITORY_ID = "example.test/ThreeMoonsLab/agents-shipgate" +PUSH_URL = "https://example.test/ThreeMoonsLab/agents-shipgate.git" + + +def _b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _content(character: str) -> str: + return f"sha256:{character * 64}" + + +def _trusted_key( + private_key: Ed25519PrivateKey, + *, + provider: str = "github", + principal: str = "github:user:reviewer", +) -> TrustedEd25519KeyV1: + raw = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return TrustedEd25519KeyV1( + key_id=ed25519_key_id(raw), + public_key=_b64url(raw), + provider=provider, + principal=principal, + valid_from=NOW - timedelta(days=1), + valid_until=NOW + timedelta(days=1), + ) + + +def _review_items() -> list[HumanAuthorizationReviewItemV1]: + return [ + HumanAuthorizationReviewItemV1( + review_item_id="binding-graph", + check_id="SHIP-VERIFY-BINDING-GRAPH", + support_hash=_content("1"), + paths=["docs/review (final).md", "shipgate.yaml"], + ), + HumanAuthorizationReviewItemV1( + review_item_id="protected-workflow", + check_id="SHIP-VERIFY-PROTECTED-WORKFLOW", + fingerprint="fp_workflow!reviewed", + paths=[".github/workflows/agents-shipgate.yml"], + ), + ] + + +def _request( + review_items: list[HumanAuthorizationReviewItemV1] | None = None, + *, + source_commit_sha: str = HEAD_COMMIT, + destination_ref: str = DESTINATION_REF, + expected_lease_oid: str = LEASE_OID, +) -> HumanAuthorizationRequestV1: + operation = build_git_push_operation( + destination_repository_id=REPOSITORY_ID, + push_url=PUSH_URL, + source_commit_sha=source_commit_sha, + destination_ref=destination_ref, + expected_lease_oid=expected_lease_oid, + ) + return build_human_authorization_request( + repository_id=REPOSITORY_ID, + source_receipt_id=_content("5"), + source_artifact_set_id=_content("6"), + source_engine_requirement_id=_content("7"), + source_executor_id=_content("8"), + verification_request_id=_content("2"), + subject_id=_content("3"), + decision_id=_content("4"), + base_commit_sha=BASE_COMMIT, + merge_base_sha=MERGE_BASE, + base_tree_sha=BASE_TREE, + head_tree_sha=HEAD_TREE, + source_head_commit_sha=source_commit_sha, + review_items=review_items or _review_items(), + operation=operation, + ) + + +def _grant( + private_key: Ed25519PrivateKey, + request: HumanAuthorizationRequestV1, + *, + issued_at: datetime = NOW - timedelta(seconds=5), + not_before: datetime = NOW - timedelta(seconds=5), + expires_at: datetime = NOW + timedelta(minutes=10), + reason: str = "LGTM! (reviewed in Codex)", + provider: str = "github", + principal: str = "github:user:reviewer", +) -> HumanAuthorizationV1: + statement = HumanAuthorizationStatementV1( + request=request, + principal=HumanAuthorizationPrincipalV1( + provider=provider, + subject=principal, + ), + reason=reason, + issued_at=issued_at, + not_before=not_before, + expires_at=expires_at, + nonce=_b64url(b"0123456789abcdef"), + ) + signature = private_key.sign(human_authorization_signature_payload(statement)) + trusted = _trusted_key( + private_key, + provider=provider, + principal=principal, + ) + proof = HumanAuthorizationProofV1( + key_id=trusted.key_id, + signature=_b64url(signature), + ) + return build_human_authorization(statement=statement, proof=proof) + + +def _write_policy( + path: Path, + key: TrustedEd25519KeyV1, + *, + repository_ids: list[str] | None = None, + max_ttl_seconds: int = 900, +) -> HumanAuthorizationTrustPolicyV1: + policy = build_human_authorization_trust_policy( + repository_ids=repository_ids or [REPOSITORY_ID], + keys=[key], + max_ttl_seconds=max_ttl_seconds, + clock_skew_seconds=30, + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(policy.model_dump_json(indent=2), encoding="utf-8") + path.chmod(0o600) + return policy + + +@dataclass +class AuthorizationWorld: + workspace: Path + trust_policy_path: Path + private_key: Ed25519PrivateKey + trusted_key: TrustedEd25519KeyV1 + review_items: list[HumanAuthorizationReviewItemV1] + request: HumanAuthorizationRequestV1 + grant: HumanAuthorizationV1 + policy: HumanAuthorizationTrustPolicyV1 + + +@pytest.fixture +def world(tmp_path: Path) -> AuthorizationWorld: + workspace = tmp_path / "workspace" + workspace.mkdir() + private_key = Ed25519PrivateKey.generate() + trusted_key = _trusted_key(private_key) + review_items = _review_items() + request = _request(review_items) + grant = _grant(private_key, request) + trust_policy_path = tmp_path / "host-trust" / "policy.json" + policy = _write_policy(trust_policy_path, trusted_key) + return AuthorizationWorld( + workspace=workspace, + trust_policy_path=trust_policy_path, + private_key=private_key, + trusted_key=trusted_key, + review_items=review_items, + request=request, + grant=grant, + policy=policy, + ) + + +def _evaluate( + world: AuthorizationWorld, + grant: HumanAuthorizationV1 | dict[str, object] | None = None, + *, + request: HumanAuthorizationRequestV1 | None = None, + review_items: list[HumanAuthorizationReviewItemV1] | None = None, + trust_policy_path: Path | None = None, + now: datetime = NOW, +) -> AuthorizationEvaluationV1: + return evaluate_human_authorization( + grant or world.grant, + trust_policy_path=trust_policy_path or world.trust_policy_path, + workspace=world.workspace, + expected_request=request or world.request, + expected_review_items=review_items or world.review_items, + now=now, + ) + + +def test_exact_signed_authorization_accepts_only_bound_push( + world: AuthorizationWorld, +) -> None: + evaluation = _evaluate(world) + + expected_command = ( + f"git push --force-with-lease={DESTINATION_REF}:{LEASE_OID} " + f"{PUSH_URL} {HEAD_COMMIT}:{DESTINATION_REF}" + ) + assert evaluation.status == "accepted" + assert evaluation.authorization_id == world.grant.authorization_id + assert evaluation.trust_policy_id == world.policy.trust_policy_id + assert evaluation.operation_id == world.request.operation.operation_id + assert evaluation.command == expected_command + assert evaluation.reason_codes == [] + + +def test_changed_operation_and_request_are_rejected_without_command( + world: AuthorizationWorld, +) -> None: + changed_request = _request( + world.review_items, + expected_lease_oid="e" * 40, + ) + changed_grant = _grant(world.private_key, changed_request) + + evaluation = _evaluate(world, changed_grant) + + assert evaluation.status == "rejected" + assert "authorization_request_id_mismatch" in evaluation.reason_codes + assert "authorization_request_mismatch" in evaluation.reason_codes + assert evaluation.command is None + + +def test_signature_tampering_changes_grant_id_and_fails_closed( + world: AuthorizationWorld, +) -> None: + original_signature = base64.urlsafe_b64decode( + world.grant.proof.signature + "=" * (-len(world.grant.proof.signature) % 4) + ) + tampered_signature = bytes([original_signature[0] ^ 1]) + original_signature[1:] + proof = HumanAuthorizationProofV1( + key_id=world.grant.proof.key_id, + signature=_b64url(tampered_signature), + ) + tampered = build_human_authorization( + statement=world.grant.statement, + proof=proof, + ) + + evaluation = _evaluate(world, tampered) + + assert tampered.authorization_id != world.grant.authorization_id + assert evaluation.status == "rejected" + assert evaluation.reason_codes == ["signature_invalid"] + assert evaluation.command is None + + +def test_expired_and_overlong_authorizations_are_rejected( + world: AuthorizationWorld, +) -> None: + expired = _grant( + world.private_key, + world.request, + issued_at=NOW - timedelta(minutes=10), + not_before=NOW - timedelta(minutes=10), + expires_at=NOW, + ) + overlong = _grant( + world.private_key, + world.request, + issued_at=NOW - timedelta(seconds=5), + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=16), + ) + + expired_result = _evaluate(world, expired) + overlong_result = _evaluate(world, overlong) + + assert "authorization_expired" in expired_result.reason_codes + assert expired_result.command is None + assert "authorization_ttl_exceeded" in overlong_result.reason_codes + assert overlong_result.command is None + + +def test_partial_review_scope_is_rejected(world: AuthorizationWorld) -> None: + partial_items = [world.review_items[0]] + partial_request = _request(partial_items) + partial_grant = _grant(world.private_key, partial_request) + + evaluation = _evaluate(world, partial_grant) + + assert evaluation.status == "rejected" + assert "review_scope_mismatch" in evaluation.reason_codes + assert evaluation.command is None + + +def test_untrusted_key_and_principal_mismatch_are_rejected( + world: AuthorizationWorld, +) -> None: + other_private_key = Ed25519PrivateKey.generate() + other_key = _trusted_key(other_private_key) + other_policy_path = world.trust_policy_path.parent / "other-policy.json" + _write_policy(other_policy_path, other_key) + + untrusted = _evaluate(world, trust_policy_path=other_policy_path) + assert "signing_key_not_trusted" in untrusted.reason_codes + assert untrusted.command is None + + wrong_principal_key = _trusted_key( + world.private_key, + principal="github:user:different-reviewer", + ) + wrong_principal_policy = world.trust_policy_path.parent / "wrong-principal.json" + _write_policy(wrong_principal_policy, wrong_principal_key) + mismatch = _evaluate(world, trust_policy_path=wrong_principal_policy) + assert "principal_subject_mismatch" in mismatch.reason_codes + assert mismatch.command is None + + +def test_workspace_or_relative_trust_policy_is_never_authoritative( + world: AuthorizationWorld, +) -> None: + in_workspace = world.workspace / "trust.json" + _write_policy(in_workspace, world.trusted_key) + + inside_result = _evaluate(world, trust_policy_path=in_workspace) + relative_result = _evaluate(world, trust_policy_path=Path("trust.json")) + + assert inside_result.reason_codes == ["trust_policy_inside_workspace"] + assert relative_result.reason_codes == ["trust_policy_path_not_absolute"] + assert inside_result.command is None + assert relative_result.command is None + + +def test_symlinked_or_world_writable_trust_policy_is_rejected( + world: AuthorizationWorld, +) -> None: + symlink = world.trust_policy_path.parent / "policy-link.json" + symlink.symlink_to(world.trust_policy_path) + symlink_result = _evaluate(world, trust_policy_path=symlink) + assert symlink_result.reason_codes == ["trust_policy_symlink"] + + world.trust_policy_path.chmod(0o666) + writable_result = _evaluate(world) + assert writable_result.reason_codes == ["trust_policy_insecure_permissions"] + + +def test_hard_linked_or_replaceable_trust_policy_path_is_rejected( + world: AuthorizationWorld, +) -> None: + hard_link = world.trust_policy_path.parent / "policy-hard-link.json" + os.link(world.trust_policy_path, hard_link) + hard_linked = _evaluate(world) + assert hard_linked.reason_codes == ["trust_policy_hard_linked"] + hard_link.unlink() + + insecure_parent = world.trust_policy_path.parent.parent / "replaceable" + insecure_parent.mkdir() + insecure_policy = insecure_parent / "policy.json" + _write_policy(insecure_policy, world.trusted_key) + insecure_parent.chmod(0o777) + try: + replaceable = _evaluate(world, trust_policy_path=insecure_policy) + finally: + insecure_parent.chmod(0o700) + assert replaceable.reason_codes == ["trust_policy_insecure_ancestor"] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("push_url", "https://example.test/repo.git;touch-pwned"), + ("push_url", "https://example.test/repo.git\nother"), + ("destination_ref", "refs/heads/main;touch-pwned"), + ("destination_ref", "refs/heads/main\x00other"), + ], +) +def test_rendered_git_operation_rejects_shell_syntax( + field: str, + value: str, +) -> None: + arguments = { + "destination_repository_id": REPOSITORY_ID, + "push_url": PUSH_URL, + "source_commit_sha": HEAD_COMMIT, + "destination_ref": DESTINATION_REF, + "expected_lease_oid": LEASE_OID, + } + arguments[field] = value + + with pytest.raises((ValueError, ValidationError)): + build_git_push_operation(**arguments) + + +@pytest.mark.parametrize( + "push_url", + [ + "http://example.test/ThreeMoonsLab/agents-shipgate.git", + "ssh://git@example.test/ThreeMoonsLab/agents-shipgate.git", + "git@example.test:ThreeMoonsLab/agents-shipgate.git", + "file:///tmp/agents-shipgate.git", + "/tmp/agents-shipgate.git", + "../agents-shipgate.git", + "https://token@example.test/ThreeMoonsLab/agents-shipgate.git", + "https://example.test/ThreeMoonsLab/agents-shipgate.git?token=secret", + "https://example.test/ThreeMoonsLab/agents-shipgate.git#alternate", + "https://example.test/ThreeMoonsLab/../agents-shipgate.git", + ], +) +def test_git_push_operation_rejects_noncanonical_or_mutable_endpoints( + push_url: str, +) -> None: + with pytest.raises((ValueError, ValidationError)): + build_git_push_operation( + destination_repository_id=REPOSITORY_ID, + push_url=push_url, + source_commit_sha=HEAD_COMMIT, + destination_ref=DESTINATION_REF, + expected_lease_oid=LEASE_OID, + ) + + +def test_git_push_operation_binds_repository_identity_and_pinned_url() -> None: + operation = build_git_push_operation( + destination_repository_id=REPOSITORY_ID, + push_url=PUSH_URL, + source_commit_sha=HEAD_COMMIT, + destination_ref=DESTINATION_REF, + expected_lease_oid=LEASE_OID, + ) + + assert operation.destination_repository_id == REPOSITORY_ID + assert operation.push_url == PUSH_URL + assert operation.argv() == [ + "git", + "push", + f"--force-with-lease={DESTINATION_REF}:{LEASE_OID}", + PUSH_URL, + f"{HEAD_COMMIT}:{DESTINATION_REF}", + ] + + with pytest.raises((ValueError, ValidationError), match="repository identity"): + build_git_push_operation( + destination_repository_id="attacker.test/ThreeMoonsLab/agents-shipgate", + push_url=PUSH_URL, + source_commit_sha=HEAD_COMMIT, + destination_ref=DESTINATION_REF, + expected_lease_oid=LEASE_OID, + ) + + +@pytest.mark.parametrize("oid_length", [39, 41, 63, 65]) +def test_git_push_operation_rejects_non_git_object_id_lengths(oid_length: int) -> None: + with pytest.raises((ValueError, ValidationError)): + build_git_push_operation( + destination_repository_id=REPOSITORY_ID, + push_url=PUSH_URL, + source_commit_sha="c" * oid_length, + destination_ref=DESTINATION_REF, + expected_lease_oid=LEASE_OID, + ) + + +def test_authorization_request_rejects_destination_repository_drift() -> None: + operation = build_git_push_operation( + destination_repository_id=REPOSITORY_ID, + push_url=PUSH_URL, + source_commit_sha=HEAD_COMMIT, + destination_ref=DESTINATION_REF, + expected_lease_oid=LEASE_OID, + ) + + with pytest.raises(ValidationError, match="reviewed repository identity"): + build_human_authorization_request( + repository_id="attacker.test/ThreeMoonsLab/agents-shipgate", + source_receipt_id=_content("5"), + source_artifact_set_id=_content("6"), + source_engine_requirement_id=_content("7"), + source_executor_id=_content("8"), + verification_request_id=_content("2"), + subject_id=_content("3"), + decision_id=_content("4"), + base_commit_sha=BASE_COMMIT, + merge_base_sha=MERGE_BASE, + base_tree_sha=BASE_TREE, + head_tree_sha=HEAD_TREE, + source_head_commit_sha=HEAD_COMMIT, + review_items=_review_items(), + operation=operation, + ) + + +def test_display_metadata_allows_normal_punctuation_but_not_controls() -> None: + request = _request() + statement = HumanAuthorizationStatementV1( + request=request, + principal=HumanAuthorizationPrincipalV1( + provider="github", + subject="Reviewer! (release owner)", + ), + reason="LGTM! (reviewed end-to-end); ship it.", + issued_at=NOW, + not_before=NOW, + expires_at=NOW + timedelta(minutes=1), + nonce=_b64url(b"0123456789abcdef"), + ) + assert statement.reason.endswith("ship it.") + + with pytest.raises(ValidationError): + HumanAuthorizationPrincipalV1( + provider="github", + subject="Reviewer\nforged", + ) + + +def test_nonaccepted_evaluation_cannot_expose_a_command() -> None: + with pytest.raises(ValidationError): + AuthorizationEvaluationV1( + status="rejected", + command="git push origin HEAD:refs/heads/main", + reason_codes=["signature_invalid"], + ) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX account-home guarantee") +def test_default_trust_path_ignores_agent_controlled_home_and_xdg( + monkeypatch: pytest.MonkeyPatch, +) -> None: + before = default_human_authorization_trust_policy_path() + monkeypatch.setenv("HOME", "/tmp/attacker-home") + monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/attacker-xdg") + + after = default_human_authorization_trust_policy_path() + + assert after == before + assert not str(after).startswith("/tmp/attacker-") + + +def test_trust_policy_round_trip_is_content_addressed( + world: AuthorizationWorld, +) -> None: + loaded = load_external_trust_policy( + world.trust_policy_path, + workspace=world.workspace, + ) + assert loaded == world.policy + + document = json.loads(world.trust_policy_path.read_text(encoding="utf-8")) + document["max_ttl_seconds"] += 1 + world.trust_policy_path.write_text(json.dumps(document), encoding="utf-8") + world.trust_policy_path.chmod(0o600) + result = _evaluate(world) + assert result.reason_codes == ["trust_policy_invalid"] + assert result.command is None + + +def test_complete_grant_identity_includes_proof(world: AuthorizationWorld) -> None: + expected = content_id( + { + "statement": world.grant.statement.model_dump(mode="json"), + "proof": world.grant.proof.model_dump(mode="json"), + } + ) + assert world.grant.authorization_id == expected + + +def test_release_decision_projection_binds_complete_review_scope() -> None: + projected = authorization_review_items( + { + "decision": "review_required", + "review_items": [ + { + "id": "review-1", + "fingerprint": "fp_review_1", + "check_id": "SHIP-VERIFY-TRUST-ROOT-TOUCHED", + "support": {"support_hash": _content("8")}, + "source": {"path": "src/agent.py"}, + "policy_evidence_source": {"path": "shipgate.yaml"}, + } + ], + } + ) + + assert projected == [ + HumanAuthorizationReviewItemV1( + review_item_id="review-1", + fingerprint="fp_review_1", + check_id="SHIP-VERIFY-TRUST-ROOT-TOUCHED", + support_hash=_content("8"), + paths=["shipgate.yaml", "src/agent.py"], + ) + ] + + +def test_release_decision_projection_accepts_explicit_null_source_path() -> None: + projected = authorization_review_items( + { + "decision": "review_required", + "review_items": [ + { + "id": "review-1", + "check_id": "SHIP-VERIFY-TRUST-ROOT-TOUCHED", + "source": { + "type": "verify", + "ref": "protected-surface", + "path": None, + }, + } + ], + } + ) + + assert projected == [ + HumanAuthorizationReviewItemV1( + review_item_id="review-1", + check_id="SHIP-VERIFY-TRUST-ROOT-TOUCHED", + paths=[], + ) + ] + + +@pytest.mark.parametrize( + "release_decision", + [ + {"decision": "blocked", "review_items": [{"id": "x", "check_id": "C"}]}, + {"decision": "review_required", "review_items": []}, + { + "decision": "review_required", + "review_items": [{"id": "x", "check_id": "C", "source": {}}], + }, + { + "decision": "review_required", + "review_items": [{"id": "x", "check_id": "C", "support": "forged"}], + }, + ], +) +def test_release_decision_projection_rejects_incomplete_sets( + release_decision: dict[str, object], +) -> None: + with pytest.raises(ValueError): + authorization_review_items(release_decision) + + +def _authorization_wire_schema(name: str) -> dict[str, object]: + schema_path = Path(__file__).resolve().parent.parent / "docs/human-authorization-schema.v1.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + return schema["$defs"][name] + + +def test_wire_schema_rejects_impossible_authorization_evaluations( + world: AuthorizationWorld, +) -> None: + validator = Draft202012Validator(_authorization_wire_schema("AuthorizationEvaluationV1")) + accepted = _evaluate(world).model_dump(mode="json") + assert list(validator.iter_errors(accepted)) == [] + + missing_command = dict(accepted) + missing_command.pop("command") + assert list(validator.iter_errors(missing_command)) + + rejected_without_reason = { + "schema_version": "shipgate.human_authorization_evaluation/v1", + "status": "rejected", + "reason_codes": [], + } + assert list(validator.iter_errors(rejected_without_reason)) + + not_requested_with_authority = { + "schema_version": "shipgate.human_authorization_evaluation/v1", + "status": "not_requested", + "authorization_id": world.grant.authorization_id, + "reason_codes": [], + } + assert list(validator.iter_errors(not_requested_with_authority)) + + rejected_with_command = { + "schema_version": "shipgate.human_authorization_evaluation/v1", + "status": "rejected", + "command": world.request.operation.command, + "reason_codes": ["signature_invalid"], + } + assert list(validator.iter_errors(rejected_with_command)) + + +def test_wire_schema_constrains_keys_signatures_and_push_syntax( + world: AuthorizationWorld, +) -> None: + proof_validator = Draft202012Validator(_authorization_wire_schema("HumanAuthorizationProofV1")) + proof = world.grant.proof.model_dump(mode="json") + assert list(proof_validator.iter_errors(proof)) == [] + assert list(proof_validator.iter_errors({**proof, "signature": "not-base64url"})) + + key_validator = Draft202012Validator(_authorization_wire_schema("TrustedEd25519KeyV1")) + key = world.trusted_key.model_dump(mode="json") + assert list(key_validator.iter_errors(key)) == [] + assert list(key_validator.iter_errors({**key, "public_key": "short"})) + + operation_validator = Draft202012Validator(_authorization_wire_schema("GitPushOperationV1")) + operation = world.request.operation.model_dump(mode="json") + assert list(operation_validator.iter_errors(operation)) == [] + assert list( + operation_validator.iter_errors({**operation, "push_url": "http://example.test/repo"}) + ) + assert list(operation_validator.iter_errors({**operation, "destination_ref": "main"})) + + +@pytest.mark.parametrize( + "schema_name", + ["verifier-schema.v0.6.json", "agent-handoff-schema.v6.json"], +) +def test_embedded_authorization_evaluation_schemas_are_fail_closed( + schema_name: str, +) -> None: + schema_path = Path(__file__).resolve().parent.parent / "docs" / schema_name + schema = json.loads(schema_path.read_text(encoding="utf-8")) + evaluation = schema["$defs"]["AuthorizationEvaluationV1"] + validator = Draft202012Validator(evaluation) + impossible = { + "schema_version": "shipgate.human_authorization_evaluation/v1", + "status": "accepted", + "reason_codes": [], + } + assert list(validator.iter_errors(impossible)) diff --git a/tests/test_human_authorization_signature_vector.py b/tests/test_human_authorization_signature_vector.py new file mode 100644 index 00000000..34b59ae6 --- /dev/null +++ b/tests/test_human_authorization_signature_vector.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from agents_shipgate.schemas.human_authorization import ( + HUMAN_AUTHORIZATION_SIGNATURE_DOMAIN, + HumanAuthorizationStatementV1, + ed25519_key_id, +) +from agents_shipgate.schemas.verification_identity import canonical_json + +ROOT = Path(__file__).resolve().parent.parent +VECTOR = ROOT / "docs" / "human-authorization-signature-v1.json" + + +def _decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def test_public_human_authorization_signature_vector_is_canonical() -> None: + vector = json.loads(VECTOR.read_text(encoding="utf-8")) + statement = HumanAuthorizationStatementV1.model_validate(vector["statement"]) + canonical = canonical_json(statement.model_dump(mode="json")) + payload = HUMAN_AUTHORIZATION_SIGNATURE_DOMAIN + canonical + public_key = _decode(vector["public_key"]) + + assert vector["signature_domain_hex"] == HUMAN_AUTHORIZATION_SIGNATURE_DOMAIN.hex() + assert vector["canonical_statement_json"].encode("utf-8") == canonical + assert vector["signature_payload_sha256"] == ( + "sha256:" + hashlib.sha256(payload).hexdigest() + ) + assert vector["key_id"] == ed25519_key_id(public_key) + Ed25519PublicKey.from_public_bytes(public_key).verify( + _decode(vector["signature"]), + payload, + ) diff --git a/tests/test_local_contract.py b/tests/test_local_contract.py index 5d0507c7..70b2bcde 100644 --- a/tests/test_local_contract.py +++ b/tests/test_local_contract.py @@ -33,6 +33,12 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: "verification_unit_result_schema_version", "verification_artifact_manifest_schema_version", "verification_receipt_schema_version", + "human_authorization_request_schema_version", + "human_authorization_schema_version", + "human_authorization_evaluation_schema_version", + "human_authorization_trust_policy_schema_version", + "human_authorization_trust_policy_default_path", + "human_authorization_schema_path", "agent_handoff_schema_version", "agent_handoff_schema_path", "agent_handoff_artifact", @@ -58,9 +64,9 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: "release_decisions", "do_not_auto_assert", ] - assert payload["schema_version"] == LOCAL_CONTRACT_SCHEMA_VERSION + assert payload["schema_version"] == LOCAL_CONTRACT_SCHEMA_VERSION == "7" assert payload["agents_shipgate_version"] == __version__ - assert payload["contract_version"] == CONTRACT_VERSION + assert payload["contract_version"] == CONTRACT_VERSION == "18" assert payload["minimum_control_contract_version"] == "14" assert payload["default_paths"]["local_contract"] == LOCAL_CONTRACT_RELATIVE_PATH assert payload["primary_commands"] == dict(PRIMARY_COMMANDS) @@ -94,21 +100,40 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: "verification-receipt.json.receipt_id", "agent-handoff.json", "agent-handoff.json.control.state", + "agent-handoff.json.authorization", "verifier.json.control.state", "verify-run.json", "report.json.release_decision.decision", ] assert payload["verifier_read_order"][:3] == [ "control.state", + "authorization", "execution", - "merge_verdict", ] assert payload["verifier_read_order"][-2:] == ["request_id", "decision_id"] assert payload["gating_signal"] == GATING_SIGNAL - assert payload["verifier_schema_version"] == "0.5" + assert payload["verifier_schema_version"] == "0.6" assert payload["verify_run_schema_version"] == "shipgate.verify_run/v3" - assert payload["agent_handoff_schema_version"] == "shipgate.agent_handoff/v5" - assert payload["agent_handoff_schema_path"] == "docs/agent-handoff-schema.v5.json" + assert payload["human_authorization_request_schema_version"] == ( + "shipgate.human_authorization_request/v1" + ) + assert payload["human_authorization_schema_version"] == ( + "shipgate.human_authorization/v1" + ) + assert payload["human_authorization_evaluation_schema_version"] == ( + "shipgate.human_authorization_evaluation/v1" + ) + assert payload["human_authorization_trust_policy_schema_version"] == ( + "shipgate.human_authorization_trust_policy/v1" + ) + assert payload["human_authorization_trust_policy_default_path"] == ( + "~/.config/agents-shipgate/human-authorization-trust-policy.json" + ) + assert payload["human_authorization_schema_path"] == ( + "docs/human-authorization-schema.v1.json" + ) + assert payload["agent_handoff_schema_version"] == "shipgate.agent_handoff/v6" + assert payload["agent_handoff_schema_path"] == "docs/agent-handoff-schema.v6.json" assert payload["agent_handoff_artifact"] == "agents-shipgate-reports/agent-handoff.json" assert payload["codex_boundary_result_schema_version"] == "shipgate.codex_boundary_result/v2" assert payload["agent_boundary_result_schema_version"] == ("shipgate.agent_boundary_result/v1") @@ -146,6 +171,7 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: assert "blocked" in payload["merge_verdicts"] assert "passed" in payload["release_decisions"] assert "approval" in payload["do_not_auto_assert"] + assert "human-authorization" in payload["do_not_auto_assert"] assert "action_effect" in payload["do_not_auto_assert"] assert "action_authority" in payload["do_not_auto_assert"] diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e7e53b8f..18e008e8 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -177,7 +177,7 @@ def test_mcp_handoff_handler_is_read_only(tmp_path: Path) -> None: "merge_verdict": "mergeable", "applicability": "verified", "can_merge_without_human": True, - "control": { + "control": { "state": "complete", "reason": "All required static verification passed.", "completion_allowed": True, @@ -190,8 +190,23 @@ def test_mcp_handoff_handler_is_read_only(tmp_path: Path) -> None: "required_reviewers": [], }, "stop_reason": None, - "allowed_next_commands": [], - }, + "allowed_next_commands": [], + }, + "authorization": { + "schema_version": "shipgate.human_authorization_evaluation/v1", + "status": "not_requested", + "authorization_id": None, + "authorization_request_id": None, + "trust_policy_id": None, + "key_id": None, + "provider": None, + "principal": None, + "operation_id": None, + "command": None, + "issued_at": None, + "expires_at": None, + "reason_codes": [], + }, "forbidden_file_edits": [], "forbidden_actions": [], "artifacts": { @@ -205,7 +220,7 @@ def test_mcp_handoff_handler_is_read_only(tmp_path: Path) -> None: payload = shipgate_handoff(verifier_path=str(output_dir / "verifier.json")) - assert payload["schema_version"] == "shipgate.agent_handoff/v5" + assert payload["schema_version"] == "shipgate.agent_handoff/v6" assert payload["gate"]["merge_verdict"] == "mergeable" assert payload["control"]["state"] == "complete" assert _snapshot(tmp_path) == before diff --git a/tests/test_public_surface_contract.py b/tests/test_public_surface_contract.py index 1bf3810f..5b539fb6 100644 --- a/tests/test_public_surface_contract.py +++ b/tests/test_public_surface_contract.py @@ -389,6 +389,29 @@ def test_well_known_metadata_lists_packet_outputs(): data.get("verification_receipt_schema_version") == contract["verification_receipt_schema_version"] ) + assert ( + data.get("human_authorization_request_schema_version") + == contract["human_authorization_request_schema_version"] + ) + assert ( + data.get("human_authorization_schema_version") + == contract["human_authorization_schema_version"] + ) + assert ( + data.get("human_authorization_evaluation_schema_version") + == contract["human_authorization_evaluation_schema_version"] + ) + assert ( + data.get("human_authorization_trust_policy_schema_version") + == contract["human_authorization_trust_policy_schema_version"] + ) + assert data.get("human_authorization_trust_policy_default_path") == ( + contract["human_authorization_trust_policy_default_path"] + ) + assert ( + data.get("human_authorization_schema_path") + == contract["human_authorization_schema_path"] + ) assert data.get("agent_handoff_schema_version") == contract["agent_handoff_schema_version"] assert data.get("agent_handoff_schema_path") == contract["agent_handoff_schema_path"] assert data.get("agent_handoff_artifact") == contract["agent_handoff_artifact"] @@ -407,6 +430,7 @@ def test_well_known_metadata_lists_packet_outputs(): assert data.get("agent_read_order") == contract["agent_read_order"] assert data.get("verifier_read_order") == contract["verifier_read_order"] assert data.get("do_not_auto_assert") == contract["do_not_auto_assert"] + assert "human_authorization" in contract["external_integration_surfaces"] assert "action_effect" in contract["do_not_auto_assert"] assert "action_authority" in contract["do_not_auto_assert"] assert data.get("agent_interface_operations") == contract["agent_interface_operations"] @@ -509,7 +533,9 @@ def test_well_known_metadata_lists_packet_outputs(): ) assert "verification_receipt" in schemas assert "verification-receipt-schema.v1.json" in schemas["verification_receipt"] - assert "agent_handoff" in schemas and "agent-handoff-schema.v5.json" in schemas["agent_handoff"] + assert "human_authorization" in schemas + assert "human-authorization-schema.v1.json" in schemas["human_authorization"] + assert "agent_handoff" in schemas and "agent-handoff-schema.v6.json" in schemas["agent_handoff"] assert ( "codex_boundary_result" in schemas and "codex-boundary-result-schema.v2.json" in schemas["codex_boundary_result"] @@ -1598,8 +1624,9 @@ def test_well_known_seo_geo_positioning_fields_are_pinned(): assert data.get("static_scan_fixture_run") == ( "agents-shipgate fixture run support_refund_agent" ) - assert data.get("verifier_read_order", [])[:9] == [ + assert data.get("verifier_read_order", [])[:10] == [ "control.state", + "authorization", "execution", "merge_verdict", "applicability", diff --git a/tests/test_safety_qualification.py b/tests/test_safety_qualification.py index 3ff07ac9..0d1680ce 100644 --- a/tests/test_safety_qualification.py +++ b/tests/test_safety_qualification.py @@ -16,6 +16,7 @@ ) from agents_shipgate.schemas.agent_control import HumanControlAction from agents_shipgate.schemas.disclaimers import STATIC_VERDICT_DISCLAIMER +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 from agents_shipgate.schemas.report import ReadinessReport from agents_shipgate.schemas.safety_qualification import ( FrozenSafetyCorpusV1, @@ -378,6 +379,7 @@ def _fixture( applicability="verified", can_merge_without_human=actual == "passed", control=control, + authorization=AuthorizationEvaluationV1.not_requested(), mode="advisory", artifacts={"report_json": "agents-shipgate-reports/report.json"}, ) diff --git a/tests/test_schema_boundaries.py b/tests/test_schema_boundaries.py index 9c9d07e2..d97b5b66 100644 --- a/tests/test_schema_boundaries.py +++ b/tests/test_schema_boundaries.py @@ -398,6 +398,20 @@ def test_representative_schema_payloads_keep_wire_fields() -> None: "shipgate.verification_artifact_manifest/v1" ), verification_receipt_schema_version="shipgate.verification_receipt/v1", + human_authorization_request_schema_version=( + "shipgate.human_authorization_request/v1" + ), + human_authorization_schema_version="shipgate.human_authorization/v1", + human_authorization_evaluation_schema_version=( + "shipgate.human_authorization_evaluation/v1" + ), + human_authorization_trust_policy_schema_version=( + "shipgate.human_authorization_trust_policy/v1" + ), + human_authorization_trust_policy_default_path=( + "~/.config/agents-shipgate/human-authorization-trust-policy.json" + ), + human_authorization_schema_path="docs/human-authorization-schema.v1.json", agent_handoff_schema_version="shipgate.agent_handoff/v2", agent_handoff_schema_path="docs/agent-handoff-schema.v2.json", agent_handoff_artifact="agents-shipgate-reports/agent-handoff.json", @@ -462,6 +476,20 @@ def test_representative_schema_payloads_keep_wire_fields() -> None: "shipgate.verification_artifact_manifest/v1" ), "verification_receipt_schema_version": "shipgate.verification_receipt/v1", + "human_authorization_request_schema_version": ( + "shipgate.human_authorization_request/v1" + ), + "human_authorization_schema_version": "shipgate.human_authorization/v1", + "human_authorization_evaluation_schema_version": ( + "shipgate.human_authorization_evaluation/v1" + ), + "human_authorization_trust_policy_schema_version": ( + "shipgate.human_authorization_trust_policy/v1" + ), + "human_authorization_trust_policy_default_path": ( + "~/.config/agents-shipgate/human-authorization-trust-policy.json" + ), + "human_authorization_schema_path": "docs/human-authorization-schema.v1.json", "agent_handoff_schema_version": "shipgate.agent_handoff/v2", "agent_handoff_schema_path": "docs/agent-handoff-schema.v2.json", "agent_handoff_artifact": "agents-shipgate-reports/agent-handoff.json", diff --git a/tests/test_source_head_identity.py b/tests/test_source_head_identity.py new file mode 100644 index 00000000..4b5adc76 --- /dev/null +++ b/tests/test_source_head_identity.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from agents_shipgate.cli.verify.git import ( + archive_tree, + resolve_source_head_identity, + tree_sha, + validate_source_head_identity, +) +from agents_shipgate.core.verification_identity import build_verification_plan + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +@dataclass(frozen=True) +class GitGraph: + repo: Path + base: str + source: str + evaluated_merge: str + unrelated: str + + +@pytest.fixture +def graph(tmp_path: Path) -> GitGraph: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "tests@example.com") + _git(repo, "config", "user.name", "Shipgate Tests") + + (repo / "base.txt").write_text("base\n", encoding="utf-8") + _git(repo, "add", "base.txt") + _git(repo, "commit", "-q", "-m", "base") + base = _git(repo, "rev-parse", "HEAD") + _git(repo, "branch", "base", base) + + _git(repo, "switch", "-q", "-c", "source") + (repo / "source.txt").write_text("source\n", encoding="utf-8") + _git(repo, "add", "source.txt") + _git(repo, "commit", "-q", "-m", "source") + source = _git(repo, "rev-parse", "HEAD") + + _git(repo, "switch", "-q", "--detach", base) + _git(repo, "merge", "-q", "--no-ff", source, "-m", "synthetic merge") + evaluated_merge = _git(repo, "rev-parse", "HEAD") + _git(repo, "branch", "evaluated-merge", evaluated_merge) + + _git(repo, "switch", "-q", "-c", "unrelated", base) + (repo / "unrelated.txt").write_text("unrelated\n", encoding="utf-8") + _git(repo, "add", "unrelated.txt") + _git(repo, "commit", "-q", "-m", "unrelated") + unrelated = _git(repo, "rev-parse", "HEAD") + + return GitGraph( + repo=repo, + base=base, + source=source, + evaluated_merge=evaluated_merge, + unrelated=unrelated, + ) + + +def test_local_explicit_head_is_its_own_source(graph: GitGraph) -> None: + identity = resolve_source_head_identity(graph.repo, head_ref="source") + + assert identity.evaluated_head_commit_sha == graph.source + assert identity.source_head_commit_sha == graph.source + assert identity.relation == "evaluated_head" + + +def test_default_github_pr_merge_is_authorization_ineligible( + graph: GitGraph, +) -> None: + identity = resolve_source_head_identity( + graph.repo, + head_ref=graph.evaluated_merge, + github_actions=True, + event_name="pull_request", + evaluated_head_sha=graph.evaluated_merge, + ) + + assert identity.evaluated_head_commit_sha == graph.evaluated_merge + assert identity.source_head_commit_sha is None + assert identity.relation == "authorization_ineligible" + + +def test_explicit_action_head_override_authorizes_only_the_evaluated_override( + graph: GitGraph, +) -> None: + identity = resolve_source_head_identity( + graph.repo, + head_ref=graph.source, + github_actions=True, + event_name="pull_request", + # The host default still names the synthetic merge, proving that the + # effective --head was explicitly overridden to the source commit. + evaluated_head_sha=graph.evaluated_merge, + ) + + assert identity.evaluated_head_commit_sha == graph.source + assert identity.source_head_commit_sha == graph.source + assert identity.relation == "evaluated_head" + + +def test_ambient_source_head_sha_is_never_read_as_authority( + graph: GitGraph, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SOURCE_HEAD_SHA", graph.unrelated) + + identity = resolve_source_head_identity(graph.repo, head_ref=graph.source) + + assert identity.source_head_commit_sha == graph.source + assert identity.source_head_commit_sha != graph.unrelated + + +def test_replace_ref_cannot_change_the_tree_bound_to_a_source_commit( + graph: GitGraph, +) -> None: + raw_source_tree = _git( + graph.repo, + "--no-replace-objects", + "rev-parse", + f"{graph.source}^{{tree}}", + ) + replacement_tree = _git( + graph.repo, + "--no-replace-objects", + "rev-parse", + f"{graph.unrelated}^{{tree}}", + ) + assert raw_source_tree != replacement_tree + + _git(graph.repo, "replace", graph.source, graph.unrelated) + + # Authorization pushes the raw object named by ``graph.source``. The + # verifier therefore must bind that object's real tree, never Git's local + # replacement view of the same object ID. + assert tree_sha(graph.repo, graph.source) == raw_source_tree + + +def test_replace_ref_cannot_change_the_tree_archived_for_verification( + graph: GitGraph, + tmp_path: Path, +) -> None: + _git(graph.repo, "replace", graph.source, graph.unrelated) + archive = tmp_path / "archive" + + archive_tree(graph.repo, graph.source, archive) + + assert (archive / "source.txt").read_text(encoding="utf-8") == "source\n" + assert not (archive / "unrelated.txt").exists() + + +def test_even_exact_second_parent_cannot_be_a_distinct_authorized_source( + graph: GitGraph, +) -> None: + with pytest.raises(ValueError, match="must equal the evaluated head"): + validate_source_head_identity( + graph.repo, + evaluated_head_commit_sha=graph.evaluated_merge, + source_head_commit_sha=graph.source, + ) + + +def test_missing_source_is_a_valid_but_ineligible_receipt_state( + graph: GitGraph, +) -> None: + relation = validate_source_head_identity( + graph.repo, + evaluated_head_commit_sha=graph.evaluated_merge, + source_head_commit_sha=None, + ) + assert relation == "authorization_ineligible" + + +def test_plan_builder_rejects_distinct_source_even_with_rehashed_identity( + graph: GitGraph, +) -> None: + with pytest.raises(ValueError, match="must equal the evaluated committed head"): + build_verification_plan( + git_root=graph.repo, + input_root=graph.repo, + config_path=graph.repo / "not-read-before-invariant.yaml", + config_logical_path="shipgate.yaml", + base_ref=graph.base, + head_ref=graph.evaluated_merge, + archived_head=True, + repository_id="local:test", + base_commit_sha=graph.base, + base_tree_sha="a" * 40, + source_head_commit_sha=graph.source, + head_commit_sha=graph.evaluated_merge, + head_tree_sha="b" * 40, + merge_base_sha=graph.base, + changed_files=[], + diff_text="", + baseline_path=None, + diff_from_path=None, + policy_pack_paths=[], + evaluation_date="2026-07-18", + options={}, + plugins_enabled=False, + ) + + +def test_worktree_plan_cannot_carry_source_authority(graph: GitGraph) -> None: + with pytest.raises(ValueError, match="worktree-overlay plans cannot declare"): + build_verification_plan( + git_root=graph.repo, + input_root=graph.repo, + config_path=graph.repo / "not-read-before-invariant.yaml", + config_logical_path="shipgate.yaml", + base_ref=None, + head_ref="HEAD", + archived_head=False, + repository_id="local:test", + base_commit_sha=None, + base_tree_sha=None, + source_head_commit_sha=graph.unrelated, + head_commit_sha=graph.unrelated, + head_tree_sha="b" * 40, + merge_base_sha=None, + changed_files=[], + diff_text="", + baseline_path=None, + diff_from_path=None, + policy_pack_paths=[], + evaluation_date="2026-07-18", + options={}, + plugins_enabled=False, + ) diff --git a/tests/test_verdict_contract.py b/tests/test_verdict_contract.py index 6a311c90..164f1b25 100644 --- a/tests/test_verdict_contract.py +++ b/tests/test_verdict_contract.py @@ -20,6 +20,7 @@ from agents_shipgate.core.agent_control import derive_agent_control from agents_shipgate.schemas.capability_change import VerifierSummary, VerifierVerdict from agents_shipgate.schemas.common import ReleaseDecisionStatus +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 from agents_shipgate.schemas.report import ( AgentSummary, ReleaseConsequence, @@ -150,6 +151,7 @@ def _artifact(**overrides) -> VerifierArtifact: "workspace": "/tmp/w", "config": "shipgate.yaml", "head_status": "succeeded", + "authorization": AuthorizationEvaluationV1.not_requested(), } base.update(overrides) return VerifierArtifact(**base) @@ -222,6 +224,7 @@ def test_artifact_rejects_top_level_decision_mismatch() -> None: applicability="verified", can_merge_without_human=True, control=derive_agent_control(reason="Static verification passed."), + authorization=AuthorizationEvaluationV1.not_requested(), ) diff --git a/tests/test_verification_git_snapshot.py b/tests/test_verification_git_snapshot.py index a748ada5..d41bb8cc 100644 --- a/tests/test_verification_git_snapshot.py +++ b/tests/test_verification_git_snapshot.py @@ -1,6 +1,7 @@ from __future__ import annotations import subprocess +import zlib from pathlib import Path import pytest @@ -13,6 +14,17 @@ def _git(root: Path, *args: str) -> None: subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True) +def _git_output(root: Path, *args: str, input_text: str | None = None) -> str: + result = subprocess.run( + ["git", "-C", str(root), *args], + input=input_text, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + def _repo(tmp_path: Path) -> Path: root = tmp_path / "repo" root.mkdir() @@ -43,6 +55,39 @@ def test_exact_snapshot_rejects_git_symlink_bindings(tmp_path: Path) -> None: archive_tree(root, "HEAD", tmp_path / "snapshot") +def test_exact_snapshot_rejects_portable_filesystem_path_collisions(tmp_path: Path) -> None: + root = _repo(tmp_path) + blob = _git_output(root, "hash-object", "-w", "--stdin", input_text="bound\n") + tree = _git_output( + root, + "mktree", + input_text=( + f"100644 blob {blob}\tPolicy.yaml\n" + f"100644 blob {blob}\tpolicy.yaml\n" + ), + ) + commit = _git_output(root, "commit-tree", tree, "-m", "colliding tree") + + with pytest.raises(ConfigError, match="filesystem-colliding paths"): + archive_tree(root, commit, tmp_path / "snapshot") + + +def test_exact_snapshot_rejects_blob_bytes_that_do_not_match_their_oid(tmp_path: Path) -> None: + root = _repo(tmp_path) + (root / "policy.yaml").write_text("safe\n", encoding="utf-8") + _git(root, "add", "policy.yaml") + _git(root, "commit", "-m", "fixture") + oid = _git_output(root, "rev-parse", "HEAD:policy.yaml") + loose_object = root / ".git" / "objects" / oid[:2] / oid[2:] + if not loose_object.is_file(): + pytest.skip("fixture blob is not stored as a loose object") + loose_object.chmod(0o644) + loose_object.write_bytes(zlib.compress(b"blob 5\0evil\n")) + + with pytest.raises(ConfigError, match="Git object"): + archive_tree(root, "HEAD", tmp_path / "snapshot") + + def test_repository_identity_normalizes_ssh_and_https_remotes(tmp_path: Path) -> None: root = _repo(tmp_path) _git(root, "remote", "add", "origin", "git@GitHub.com:ThreeMoonsLab/shipgate.git") diff --git a/tests/test_verifier_control_contract.py b/tests/test_verifier_control_contract.py index 57ae67da..b415c1f5 100644 --- a/tests/test_verifier_control_contract.py +++ b/tests/test_verifier_control_contract.py @@ -15,12 +15,31 @@ build_unit_result, build_verification_plan, ) -from agents_shipgate.schemas.agent_control import HumanControlAction +from agents_shipgate.schemas.agent_control import CodingAgentCommandAction, HumanControlAction from agents_shipgate.schemas.disclaimers import STATIC_VERDICT_DISCLAIMER +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 from agents_shipgate.schemas.verifier import VerifierArtifact, map_merge_verdict from agents_shipgate.schemas.verify_run import VerifyRunOutcome, build_verify_run_artifact ROOT = Path(__file__).resolve().parent.parent +AUTHORIZED_COMMAND = "git push origin HEAD:refs/heads/codex/human-authorization-state" + + +def _accepted_authorization() -> AuthorizationEvaluationV1: + return AuthorizationEvaluationV1( + status="accepted", + authorization_id=f"sha256:{'1' * 64}", + authorization_request_id=f"sha256:{'2' * 64}", + trust_policy_id=f"sha256:{'3' * 64}", + key_id=f"sha256:{'5' * 64}", + provider="github", + principal="github:user:reviewer", + operation_id=f"sha256:{'4' * 64}", + command=AUTHORIZED_COMMAND, + issued_at="2026-07-18T12:00:00Z", + expires_at="2026-07-18T12:15:00Z", + reason_codes=[], + ) def _release_decision(decision: str) -> dict[str, object]: @@ -62,6 +81,34 @@ def _passed_verifier() -> VerifierArtifact: applicability="verified", can_merge_without_human=True, control=derive_agent_control(reason="Static verification passed."), + authorization=AuthorizationEvaluationV1.not_requested(), + ) + + +def _authorized_verifier() -> VerifierArtifact: + control = derive_agent_control( + reason="Run only the externally authorized operation.", + next_action=CodingAgentCommandAction( + kind="repair", + command=AUTHORIZED_COMMAND, + why="Run only the externally authorized operation.", + ), + verify_required=True, + allowed_next_commands=[AUTHORIZED_COMMAND], + ) + return VerifierArtifact( + workspace="/tmp/repo", + config="shipgate.yaml", + execution="succeeded", + head_status="succeeded", + release_decision=_release_decision("review_required"), + decision="review_required", + merge_verdict="human_review_required", + applicability="verified", + can_merge_without_human=False, + control=control, + authorization=_accepted_authorization(), + fix_task=None, ) @@ -130,8 +177,8 @@ def test_handoff_rejects_tampered_current_verify_run_outcome() -> None: @pytest.mark.parametrize( ("schema_path", "control_path"), [ - ("docs/verifier-schema.v0.5.json", ("control",)), - ("docs/agent-handoff-schema.v5.json", ("control",)), + ("docs/verifier-schema.v0.6.json", ("control",)), + ("docs/agent-handoff-schema.v6.json", ("control",)), ("docs/verify-run-schema.v3.json", ("outcome", "control")), ], ) @@ -143,8 +190,8 @@ def test_generated_public_schemas_reject_contradictory_control( run = _passed_run(verifier) handoff = build_agent_handoff(verifier=verifier, verify_run=run) payload_by_schema = { - "docs/verifier-schema.v0.5.json": verifier.model_dump(mode="json"), - "docs/agent-handoff-schema.v5.json": handoff.model_dump(mode="json"), + "docs/verifier-schema.v0.6.json": verifier.model_dump(mode="json"), + "docs/agent-handoff-schema.v6.json": handoff.model_dump(mode="json"), "docs/verify-run-schema.v3.json": run.model_dump(mode="json"), } payload = deepcopy(payload_by_schema[schema_path]) @@ -157,6 +204,49 @@ def test_generated_public_schemas_reject_contradictory_control( assert list(Draft202012Validator(schema).iter_errors(payload)) +@pytest.mark.parametrize( + "schema_path", + ["docs/verifier-schema.v0.6.json", "docs/agent-handoff-schema.v6.json"], +) +def test_generated_schemas_reject_accepted_authorization_on_passed_gate( + schema_path: str, +) -> None: + verifier = _passed_verifier() + run = _passed_run(verifier) + handoff = build_agent_handoff(verifier=verifier, verify_run=run) + payload_by_schema = { + "docs/verifier-schema.v0.6.json": verifier.model_dump(mode="json"), + "docs/agent-handoff-schema.v6.json": handoff.model_dump(mode="json"), + } + payload = deepcopy(payload_by_schema[schema_path]) + payload["authorization"] = _accepted_authorization().model_dump(mode="json") + + schema = json.loads((ROOT / schema_path).read_text(encoding="utf-8")) + assert list(Draft202012Validator(schema).iter_errors(payload)) + + +@pytest.mark.parametrize( + "field", + [ + "execution", + "head_status", + "release_decision", + "decision", + "merge_verdict", + "applicability", + "can_merge_without_human", + "control", + "fix_task", + ], +) +def test_verifier_schema_requires_complete_authorized_projection(field: str) -> None: + payload = _authorized_verifier().model_dump(mode="json") + payload.pop(field) + + schema = json.loads((ROOT / "docs/verifier-schema.v0.6.json").read_text(encoding="utf-8")) + assert list(Draft202012Validator(schema).iter_errors(payload)) + + @pytest.mark.parametrize( "decision", ["blocked", "review_required", "insufficient_evidence"], @@ -180,6 +270,48 @@ def test_nonpassing_release_decision_cannot_claim_merge_authority(decision: str) applicability="verified", can_merge_without_human=True, control=human, + authorization=AuthorizationEvaluationV1.not_requested(), + ) + + +@pytest.mark.parametrize("mismatch", ["human_control", "different_command"]) +def test_accepted_authorization_rejects_control_mismatch(mismatch: str) -> None: + why = "A human must review this release decision." + if mismatch == "human_control": + control = derive_agent_control( + reason=why, + next_action=HumanControlAction(kind="review", why=why), + human_review_required=True, + human_review_why=why, + stop_reason=why, + ) + else: + different_command = "git push origin HEAD:refs/heads/a-different-branch" + control = derive_agent_control( + reason=why, + next_action=CodingAgentCommandAction( + kind="repair", + command=different_command, + why="Run only the separately authorized operation.", + ), + verify_required=True, + allowed_next_commands=[different_command], + ) + + with pytest.raises(ValidationError): + VerifierArtifact( + workspace="/tmp/repo", + config="shipgate.yaml", + execution="succeeded", + head_status="succeeded", + release_decision=_release_decision("review_required"), + decision="review_required", + merge_verdict="human_review_required", + applicability="verified", + can_merge_without_human=False, + control=control, + authorization=_accepted_authorization(), + fix_task=None, ) @@ -227,5 +359,5 @@ def test_passed_wrapper_contradictions_fail_pydantic_and_generated_schema( mutate(payload) with pytest.raises(ValidationError): VerifierArtifact.model_validate(payload) - schema = json.loads((ROOT / "docs/verifier-schema.v0.3.json").read_text()) + schema = json.loads((ROOT / "docs/verifier-schema.v0.6.json").read_text()) assert list(Draft202012Validator(schema).iter_errors(payload)) diff --git a/tests/test_verify.py b/tests/test_verify.py index b1a4aca1..9f7d6cd4 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -32,6 +32,7 @@ VerifierCapabilityDeltaSummary, VerifierSummary, ) +from agents_shipgate.schemas.human_authorization import AuthorizationEvaluationV1 from agents_shipgate.schemas.patches import RemovePointerPatch from agents_shipgate.schemas.report import ( BaselineDelta, @@ -480,6 +481,7 @@ def test_pr_comment_keeps_code_span_values_unescaped() -> None: verifier = VerifierArtifact( workspace="/tmp/work", config="shipgate.yaml", + authorization=AuthorizationEvaluationV1.not_requested(), base_ref="origin/main", head_ref="HEAD", trigger={"rationale": "docs-only"}, @@ -577,6 +579,7 @@ def test_capability_review_pr_comment_leads_with_top_changes_and_trust_root() -> verifier = VerifierArtifact( workspace="/tmp/work", config="shipgate.yaml", + authorization=AuthorizationEvaluationV1.not_requested(), trigger={"rationale": "1 run_shipgate rule(s) matched."}, execution="succeeded", head_status="succeeded", @@ -645,6 +648,7 @@ def test_capability_review_pr_comment_preserves_valid_agent_json_when_compacted( verifier = VerifierArtifact( workspace="/tmp/work", config="shipgate.yaml", + authorization=AuthorizationEvaluationV1.not_requested(), trigger={"rationale": "1 run_shipgate rule(s) matched."}, execution="succeeded", head_status="succeeded", @@ -687,6 +691,7 @@ def test_capability_review_pr_comment_uses_merge_verdict_vocabulary() -> None: verifier = VerifierArtifact( workspace="/tmp/work", config="shipgate.yaml", + authorization=AuthorizationEvaluationV1.not_requested(), trigger={"rationale": "1 run_shipgate rule(s) matched."}, execution="succeeded", head_status="succeeded", @@ -712,6 +717,7 @@ def test_capability_review_pr_comment_does_not_double_blank_without_headline() - verifier = VerifierArtifact( workspace="/tmp/work", config="shipgate.yaml", + authorization=AuthorizationEvaluationV1.not_requested(), trigger={"rationale": "1 run_shipgate rule(s) matched."}, execution="succeeded", head_status="succeeded", @@ -734,6 +740,7 @@ def test_capability_review_pr_comment_unknown_when_head_scan_failed() -> None: verifier = VerifierArtifact( workspace="/tmp/work", config="shipgate.yaml", + authorization=AuthorizationEvaluationV1.not_requested(), trigger={"rationale": "1 run_shipgate rule(s) matched."}, execution="failed", head_status="failed", diff --git a/tests/test_verify_orchestrator.py b/tests/test_verify_orchestrator.py index fc56079f..1515353a 100644 --- a/tests/test_verify_orchestrator.py +++ b/tests/test_verify_orchestrator.py @@ -13,8 +13,15 @@ from agents_shipgate.cli.verify.git import commit_date from agents_shipgate.cli.verify.orchestrator import run_verify from agents_shipgate.core.errors import ConfigError -from agents_shipgate.core.verification_identity import validate_receipt_artifacts -from agents_shipgate.schemas.verification_identity import VerificationReceipt +from agents_shipgate.core.verification_identity import ( + build_terminal_receipt, + validate_receipt_artifacts, +) +from agents_shipgate.schemas.verification_identity import ( + VerificationPlan, + VerificationReceipt, + VerificationUnitResult, +) REPO_ROOT = Path(__file__).resolve().parent.parent @@ -153,6 +160,17 @@ def test_verify_threads_changed_files_into_head_scan(tmp_path): assert (out_dir / "capabilities.lock.json").is_file() assert (out_dir / "base.capabilities.lock.json").is_file() assert (out_dir / "capability-lock-diff.json").is_file() + initial_receipt = VerificationReceipt.model_validate( + json.loads((out_dir / "verification-receipt.json").read_text(encoding="utf-8")) + ) + initial_verify_run = json.loads( + (out_dir / "verify-run.json").read_text(encoding="utf-8") + ) + assert "agent_handoff_json" not in initial_verify_run["artifacts"] + for name, nested_ref in initial_verify_run["artifacts"].items(): + receipt_ref = initial_receipt.artifact_manifest.artifacts[name] + assert f"sha256:{nested_ref['sha256']}" == receipt_ref.sha256 + validate_receipt_artifacts(initial_receipt, root=out_dir) diff_input = out_dir / "verification-input.diff" assert diff_input.is_file() distributed_unit = out_dir / "distributed-unit.json" @@ -184,6 +202,40 @@ def test_verify_threads_changed_files_into_head_scan(tmp_path): ) assert receipt_path.read_bytes() == first_bytes + verify_run_path = out_dir / "verify-run.json" + verify_run_payload = json.loads(verify_run_path.read_text(encoding="utf-8")) + assert "report_json" in verify_run_payload["artifacts"] + verify_run_payload["artifacts"]["report_json"]["sha256"] = "0" * 64 + verify_run_path.write_text( + json.dumps(verify_run_payload, indent=2, sort_keys=True), + encoding="utf-8", + ) + plan = VerificationPlan.model_validate( + json.loads((out_dir / "verification-plan.json").read_text(encoding="utf-8")) + ) + unit = VerificationUnitResult.model_validate( + json.loads(distributed_unit.read_text(encoding="utf-8")) + ) + rebound_paths = { + name: out_dir / ref.path + for name, ref in receipt.artifact_manifest.artifacts.items() + } + tampered_manifest, tampered_receipt = build_terminal_receipt( + plan=plan, + unit_results=[unit], + decision=receipt.decision, + merge_verdict=receipt.merge_verdict, + can_merge_without_human=receipt.can_merge_without_human, + artifact_paths=rebound_paths, + artifact_root=out_dir, + ) + (out_dir / "verification-artifacts.json").write_text( + tampered_manifest.model_dump_json(indent=2), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="verify-run artifact 'report_json' hash disagrees"): + validate_receipt_artifacts(tampered_receipt, root=out_dir) + def test_verify_threads_uncommitted_worktree_files_into_head_scan(tmp_path): repo = tmp_path / "repo"