diff --git a/CHANGELOG.md b/CHANGELOG.md index 86fb7a6f..8676789e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Added +- **Immutable gate scope manifest (CC-518).** `pmctl gate run` now creates a + content-addressed `gate_scope_manifest_v1` before reviewer dispatch, bound to + the immutable gate subject and linked from `gate_assurance_v3`. It declares + changed/renamed/untracked paths, hunk ranges, paired tests, sensitive + signals, surface flags, and bounded peer/call-site/shared-helper hints. + Sequential and parallel reviewers receive the same manifest digest. + Budget omissions stop as `INCOMPLETE` unless explicitly accepted with + `--accept-scope-truncation`; accepted truncation remains recorded with exact + omitted counts and reasons. Named v3 consumers now require verified linked + scope evidence; historical v3 envelopes with unavailable scope remain + readable only through non-authorizing artifact inspection. + - **Immutable gate subject and shared three-axis verification (CC-515).** Current gate producers emit `gate_assurance_v3` with a stable Git common-directory repository key, provenance-only observed root, base/head diff --git a/cli/commands.tsv b/cli/commands.tsv index af0c7e83..0033d3c4 100644 --- a/cli/commands.tsv +++ b/cli/commands.tsv @@ -38,7 +38,7 @@ task review Record a task review result. pmctl task review [options] experi safe bash Run a shell command through guard policy. pmctl safe bash --role --runtime [--] experimental false true --role, --runtime, -- pmctl safe bash --role reviewer --runtime codex -- "git diff --check" validate brief Validate a dispatch brief. pmctl validate brief [options] experimental false false --schema pmctl validate brief /tmp/brief.md decision add Append a structured decision. pmctl decision add --date --title --path <path> [options] experimental true true --date, --title, --path, --closes, --tags, --evidence, --json pmctl decision add --date 2026-01-01 --title "Example" --path DECISIONS.md -gate run Start the pull-request gate. pmctl gate run [options] experimental false true --executor, --test-cmd, --cd pmctl gate run --executor codex +gate run Start the pull-request gate. pmctl gate run [options] experimental false true --executor, --test-cmd, --accept-scope-truncation, --cd pmctl gate run --executor codex gate verify Assess artifact validity, subject freshness, and consumer applicability. pmctl gate verify <result-file> [--cd <repo>] [--consumer embedded|generic|maintainer|publish] [--json] experimental true false --cd,--consumer,--json pmctl gate verify .gate-results/result.md --cd . --json gate wait Wait for a detached gate run. pmctl gate wait <gate-id> [options] experimental false true --cd, --timeout pmctl gate wait gate-123 --cd . gate cancel Stop the verified gate producer process tree, then cancel only its recorded child runs; partial termination stays indeterminate. pmctl gate cancel <operation-id> --cd <dir> [--grace <seconds>] experimental false true --cd, --grace pmctl gate cancel op-20260724T000000Z-abcdef --cd . diff --git a/commands/pr-gate.md b/commands/pr-gate.md index c8b1d514..451990a2 100644 --- a/commands/pr-gate.md +++ b/commands/pr-gate.md @@ -1,6 +1,6 @@ --- description: Run the tiered pre-PR review pipeline on the current branch. -argument-hint: "[express|standard|full] [--targeted r1,r2 --initial-result path] [--scope context] [--mode sequential|parallel]" +argument-hint: "[express|standard|full] [--targeted r1,r2 --initial-result path] [--scope context] [--mode sequential|parallel] [--accept-scope-truncation]" --- Run the PR gate via `pmctl gate run`. The `runtime/bin/pr-gate.sh` script is the @@ -30,6 +30,7 @@ but does not treat it as a downgrade. | Conserve reviewer-session token usage | `--mode sequential` | | Request independent reviewer sessions | `--mode parallel` | | Request a specific tier | `express` / `standard` / `full` (cannot lower the policy floor) | +| Continue after declared-scope budgets omit entries | `--accept-scope-truncation` (only after inspecting the manifest) | A tier or reviewer-coverage policy rejection happens before reviewer dispatch and is an execution/policy failure, not a `Final: NO-GO` reviewer verdict. @@ -39,6 +40,15 @@ plus in-scope untracked content, so an approval cannot be replayed after a same-shape content change. `.gate-overrides.md` only supplies reviewer finding context and cannot lower policy. +Before dispatch, the gate writes one immutable-subject-bound +`gate_scope_manifest_v1` containing changed/renamed/untracked paths, hunk +ranges, paired tests, sensitive signals, explicit surface flags, and bounded +adjacent review hints. Every selected reviewer receives the same manifest +digest. If a budget omits entries, the default outcome is `INCOMPLETE` before +dispatch. `--accept-scope-truncation` permits the run to continue while +recording `accepted_truncation`, omitted counts, and reasons; it does not claim +complete semantic or call-graph coverage. + ## Step 1 - Invoke pmctl directly Call the bare `pmctl` command with no resolution preamble — an installed @@ -102,6 +112,7 @@ SCOPE_TOKENS=() GATE_MODE="" GATE_EXECUTOR="<gate_executor>" GATE_MODEL="" +ACCEPT_SCOPE_TRUNCATION=false if [[ -n "$RAW_ARGS" ]]; then read -r -a TOKENS <<< "$RAW_ARGS" @@ -166,6 +177,9 @@ while [[ "$idx" -lt "${#TOKENS[@]}" ]]; do GATE_MODEL="${TOKENS[$idx]:-}" [[ -n "$GATE_MODEL" ]] || { echo "error: --model requires a value" >&2; exit 2; } ;; + --accept-scope-truncation) + ACCEPT_SCOPE_TRUNCATION=true + ;; *) SCOPE_TOKENS+=("$tok") ;; @@ -204,6 +218,7 @@ GATE_ARGS=(--cd "<work_dir>" --executor "$GATE_EXECUTOR" --policy generic) [[ -n "$TARGETED_REVIEWERS" ]] && GATE_ARGS+=(--targeted "$TARGETED_REVIEWERS" --initial-result "$INITIAL_RESULT") [[ -n "$SCOPE" ]] && GATE_ARGS+=(--scope "$SCOPE") [[ -n "$GATE_MODE" ]] && GATE_ARGS+=(--mode "$GATE_MODE") +[[ "$ACCEPT_SCOPE_TRUNCATION" == true ]] && GATE_ARGS+=(--accept-scope-truncation) # Launch detached: this call is inline (NOT run_in_background) and returns in # well under a second once the supervisor is forked -- stdout prints exactly diff --git a/core/README.md b/core/README.md index 17d5b13f..755416f7 100644 --- a/core/README.md +++ b/core/README.md @@ -22,6 +22,10 @@ Gate assurance definitions are split deliberately: - `schema/gate-assurance.schema.json` defines the portable envelope that records resolved coordinates, policy resolution, immutable subject, and linked evidence. +- `schema/gate-scope-manifest.schema.json` defines the content-addressed, + immutable-subject-bound scope declaration shared by every selected reviewer. + It records exact changed inputs, bounded review hints, and explicit + truncation rather than claiming a complete call graph. - `schema/gate-verification.schema.json` defines the shared three-axis assessment returned to gate consumers. diff --git a/core/schema/gate-scope-manifest.schema.json b/core/schema/gate-scope-manifest.schema.json new file mode 100644 index 00000000..1619be9a --- /dev/null +++ b/core/schema/gate-scope-manifest.schema.json @@ -0,0 +1,887 @@ +{ + "title": "Gate scope manifest", + "description": "Immutable, bounded declaration of the repository scope supplied to selected gate reviewers.", + "type": "object", + "required": [ + "kind", + "schema_version", + "status", + "subject", + "selection", + "changes", + "diff", + "paired_tests", + "sensitive_signals", + "flags", + "expansion", + "truncation", + "content" + ], + "properties": { + "kind": { + "const": "gate_scope_manifest_v1" + }, + "schema_version": { + "const": 1 + }, + "status": { + "enum": [ + "complete", + "accepted_truncation", + "incomplete" + ] + }, + "subject": { + "type": "object", + "required": [ + "repository_key", + "base_commit", + "head_commit", + "tree_fingerprint", + "subject_kind" + ], + "properties": { + "repository_key": { + "$ref": "#/definitions/sha256" + }, + "base_commit": { + "$ref": "#/definitions/sha1" + }, + "head_commit": { + "$ref": "#/definitions/sha1" + }, + "tree_fingerprint": { + "$ref": "#/definitions/sha256" + }, + "subject_kind": { + "enum": [ + "committed_head", + "working_tree", + "fixed_ref" + ] + } + }, + "additionalProperties": false + }, + "selection": { + "type": "object", + "required": [ + "diff_kind", + "base_ref", + "head_ref", + "include_untracked" + ], + "properties": { + "diff_kind": { + "enum": [ + "committed", + "working-tree", + "allow-dirty", + "fixed-head" + ] + }, + "base_ref": { + "type": "string", + "minLength": 1 + }, + "head_ref": { + "type": "string", + "minLength": 1 + }, + "include_untracked": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "changes": { + "type": "object", + "required": [ + "entries", + "changed_paths", + "renamed_paths", + "untracked_paths" + ], + "properties": { + "entries": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/changeEntry" + } + }, + "changed_paths": { + "$ref": "#/definitions/nonEmptyPathSet" + }, + "renamed_paths": { + "type": "array", + "items": { + "type": "object", + "required": [ + "from", + "to", + "similarity" + ], + "properties": { + "from": { + "$ref": "#/definitions/path" + }, + "to": { + "$ref": "#/definitions/path" + }, + "similarity": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + } + }, + "untracked_paths": { + "$ref": "#/definitions/pathSet" + } + }, + "additionalProperties": false + }, + "diff": { + "type": "object", + "required": [ + "hunks", + "binary_or_special_paths" + ], + "properties": { + "hunks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "path", + "source", + "old_start", + "old_lines", + "new_start", + "new_lines", + "header" + ], + "properties": { + "path": { + "$ref": "#/definitions/path" + }, + "source": { + "enum": [ + "tracked", + "untracked" + ] + }, + "old_start": { + "type": "integer", + "minimum": 0 + }, + "old_lines": { + "type": "integer", + "minimum": 0 + }, + "new_start": { + "type": "integer", + "minimum": 0 + }, + "new_lines": { + "type": "integer", + "minimum": 0 + }, + "header": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "binary_or_special_paths": { + "$ref": "#/definitions/pathSet" + } + }, + "additionalProperties": false + }, + "paired_tests": { + "type": "array", + "items": { + "type": "object", + "required": [ + "source_path", + "test_path", + "reason" + ], + "properties": { + "source_path": { + "$ref": "#/definitions/path" + }, + "test_path": { + "$ref": "#/definitions/path" + }, + "reason": { + "const": "language-convention" + } + }, + "additionalProperties": false + } + }, + "sensitive_signals": { + "type": "array", + "items": { + "$ref": "#/definitions/sensitiveSignal" + } + }, + "flags": { + "type": "object", + "required": [ + "public_interface", + "schema", + "config", + "install", + "ci", + "release", + "migration" + ], + "properties": { + "public_interface": { + "$ref": "#/definitions/scopeFlag" + }, + "schema": { + "$ref": "#/definitions/scopeFlag" + }, + "config": { + "$ref": "#/definitions/scopeFlag" + }, + "install": { + "$ref": "#/definitions/scopeFlag" + }, + "ci": { + "$ref": "#/definitions/scopeFlag" + }, + "release": { + "$ref": "#/definitions/scopeFlag" + }, + "migration": { + "$ref": "#/definitions/scopeFlag" + } + }, + "additionalProperties": false + }, + "expansion": { + "type": "object", + "required": [ + "claim", + "entries", + "included_paths" + ], + "properties": { + "claim": { + "const": "bounded-hints-not-complete-call-graph" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/expansionEntry" + } + }, + "included_paths": { + "$ref": "#/definitions/pathSet" + } + }, + "additionalProperties": false + }, + "truncation": { + "type": "object", + "required": [ + "occurred", + "budgets", + "omitted", + "reasons", + "acceptance" + ], + "properties": { + "occurred": { + "type": "boolean" + }, + "budgets": { + "$ref": "#/definitions/scopeCounts" + }, + "omitted": { + "$ref": "#/definitions/scopeCounts" + }, + "reasons": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "diff-hunk-budget", + "expansion-source-budget", + "symbol-budget", + "search-match-budget", + "expansion-entry-budget" + ] + } + }, + "acceptance": { + "type": "object", + "required": [ + "required", + "accepted", + "source" + ], + "properties": { + "required": { + "type": "boolean" + }, + "accepted": { + "type": "boolean" + }, + "source": { + "type": [ + "string", + "null" + ], + "enum": [ + null, + "--accept-scope-truncation" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "content": { + "type": "object", + "required": [ + "digest_algorithm", + "digest" + ], + "properties": { + "digest_algorithm": { + "const": "sha256-canonical-json-without-content-digest" + }, + "digest": { + "$ref": "#/definitions/sha256" + } + }, + "additionalProperties": false + } + }, + "allOf": [ + { + "if": { + "properties": { + "subject": { + "properties": { + "subject_kind": { + "const": "committed_head" + } + } + } + } + }, + "then": { + "properties": { + "selection": { + "properties": { + "diff_kind": { + "const": "committed" + }, + "include_untracked": { + "const": false + } + } + } + } + } + }, + { + "if": { + "properties": { + "subject": { + "properties": { + "subject_kind": { + "const": "working_tree" + } + } + } + } + }, + "then": { + "properties": { + "selection": { + "properties": { + "diff_kind": { + "enum": [ + "working-tree", + "allow-dirty" + ] + }, + "include_untracked": { + "const": true + } + } + } + } + } + }, + { + "if": { + "properties": { + "subject": { + "properties": { + "subject_kind": { + "const": "fixed_ref" + } + } + } + } + }, + "then": { + "properties": { + "selection": { + "properties": { + "diff_kind": { + "const": "fixed-head" + }, + "include_untracked": { + "const": false + } + } + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "complete" + } + } + }, + "then": { + "properties": { + "truncation": { + "properties": { + "occurred": { + "const": false + }, + "reasons": { + "maxItems": 0 + }, + "acceptance": { + "properties": { + "required": { + "const": false + }, + "accepted": { + "const": false + }, + "source": { + "type": "null" + } + } + } + } + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "accepted_truncation" + } + } + }, + "then": { + "properties": { + "truncation": { + "properties": { + "occurred": { + "const": true + }, + "reasons": { + "minItems": 1 + }, + "acceptance": { + "properties": { + "required": { + "const": true + }, + "accepted": { + "const": true + }, + "source": { + "const": "--accept-scope-truncation" + } + } + } + } + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "incomplete" + } + } + }, + "then": { + "properties": { + "truncation": { + "properties": { + "occurred": { + "const": true + }, + "reasons": { + "minItems": 1 + }, + "acceptance": { + "properties": { + "required": { + "const": true + }, + "accepted": { + "const": false + }, + "source": { + "type": "null" + } + } + } + } + } + } + } + } + ], + "additionalProperties": false, + "definitions": { + "sha1": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)", + "not": { + "pattern": "(^|/)\\.\\.(/|$)" + } + }, + "pathSet": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/path" + } + }, + "nonEmptyPathSet": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/path" + } + }, + "stringSet": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "changeEntry": { + "type": "object", + "required": [ + "status", + "old_path", + "new_path", + "similarity" + ], + "properties": { + "status": { + "enum": [ + "added", + "modified", + "deleted", + "renamed", + "copied", + "type_changed", + "unmerged", + "untracked", + "unknown" + ] + }, + "old_path": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/path" + } + ] + }, + "new_path": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/path" + } + ] + }, + "similarity": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 100 + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "enum": [ + "renamed", + "copied" + ] + } + } + }, + "then": { + "properties": { + "old_path": { + "$ref": "#/definitions/path" + }, + "new_path": { + "$ref": "#/definitions/path" + }, + "similarity": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "deleted" + } + } + }, + "then": { + "properties": { + "old_path": { + "$ref": "#/definitions/path" + }, + "new_path": { + "type": "null" + }, + "similarity": { + "type": "null" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "added", + "modified", + "type_changed", + "unmerged", + "untracked", + "unknown" + ] + } + } + }, + "then": { + "properties": { + "old_path": { + "type": "null" + }, + "new_path": { + "$ref": "#/definitions/path" + }, + "similarity": { + "type": "null" + } + } + } + } + ], + "additionalProperties": false + }, + "sensitiveSignal": { + "type": "object", + "required": [ + "id", + "source", + "matches", + "minimum_tier", + "required_reviewers", + "recommended_mode" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "source": { + "const": "path-regex" + }, + "matches": { + "$ref": "#/definitions/pathSet" + }, + "minimum_tier": { + "enum": [ + "express", + "standard", + "full" + ] + }, + "required_reviewers": { + "$ref": "#/definitions/stringSet" + }, + "recommended_mode": { + "enum": [ + "sequential", + "parallel" + ] + } + }, + "additionalProperties": false + }, + "scopeFlag": { + "type": "object", + "required": [ + "matched", + "paths" + ], + "properties": { + "matched": { + "type": "boolean" + }, + "paths": { + "$ref": "#/definitions/pathSet" + } + }, + "additionalProperties": false + }, + "expansionEntry": { + "type": "object", + "required": [ + "path", + "reason", + "source", + "evidence", + "limit" + ], + "properties": { + "path": { + "$ref": "#/definitions/path" + }, + "reason": { + "enum": [ + "same-stem-peer", + "call-site-hint", + "shared-helper-consumer" + ] + }, + "source": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "enum": [ + "peer-convention", + "symbol-reference", + "path-reference" + ] + }, + "limit": { + "type": "object", + "required": [ + "kind", + "maximum" + ], + "properties": { + "kind": { + "enum": [ + "per-source", + "per-symbol", + "global" + ] + }, + "maximum": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "scopeCounts": { + "type": "object", + "required": [ + "diff_hunks", + "expansion_source_paths", + "symbols_per_source", + "matches_per_query", + "expansion_entries" + ], + "properties": { + "diff_hunks": { + "type": "integer", + "minimum": 0 + }, + "expansion_source_paths": { + "type": "integer", + "minimum": 0 + }, + "symbols_per_source": { + "type": "integer", + "minimum": 0 + }, + "matches_per_query": { + "type": "integer", + "minimum": 0 + }, + "expansion_entries": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + } +} diff --git a/docs/architecture/script-variable-consumers.tsv b/docs/architecture/script-variable-consumers.tsv index 3922ebdb..a3a8237d 100644 --- a/docs/architecture/script-variable-consumers.tsv +++ b/docs/architecture/script-variable-consumers.tsv @@ -56,6 +56,7 @@ CODEX_GATE_STUB_* CODEX_GATE_STUB_SYNTHESIS_INJECT_FILE tests/shell/test-pr-gate CODEX_GATE_STUB_* CODEX_GATE_STUB_SYNTHESIS_MODE tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_SYNTHESIS_TAMPER_ARTIFACT tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_TAMPER_PREFLIGHT tests/shell/test-pr-gate.sh test +CODEX_GATE_STUB_* CODEX_GATE_STUB_TAMPER_SCOPE tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_VERDICT tests/shell/test-pr-gate.sh test CODEX_GATE_STUB_* CODEX_GATE_STUB_VERDICT_PREFIX_ONLY tests/shell/test-pr-gate.sh test CODEX_HOME CODEX_HOME hosts/codex/bin/install.sh production diff --git a/docs/review-model.md b/docs/review-model.md index a5e81b96..5c834dd5 100644 --- a/docs/review-model.md +++ b/docs/review-model.md @@ -211,9 +211,10 @@ outcomes, run IDs, and the evidence status behind independence claims. The v3 envelope adds an immutable subject: stable Git common-directory repository identity, optional remote identity, provenance-only observed root, base/head refs and commits, tree fingerprint, subject kind, dirty policy, and -created/finished observations. It links preflight evidence by digest and leaves -future scope-manifest or closure evidence explicitly unavailable until those -producers exist. Envelopes also embed the canonical policy result: +created/finished observations. It links preflight evidence and a +`gate_scope_manifest_v1` by digest; closure evidence remains explicitly +unavailable until that producer exists. Envelopes also embed the canonical +policy result: classification facts, every matched signal and path, minimum tier, required coverage, recommended mode, whether policy or the user selected the mode, recommendation divergence, enforcement status, and both policy-override and @@ -221,6 +222,23 @@ reviewer-override provenance. Repo-layout results with verified independence also carry a shell-owned attestation in the protected gate run directory. +Before any reviewer dispatch, the producer creates one manifest bound to that +immutable subject. It records the complete changed-path set (including rename +origins/destinations and in-scope untracked files), zero-context diff hunk +ranges, conventional paired tests, matched sensitive-path signals, and +public-interface/schema/config/install/CI/release/migration flags. It also adds +bounded same-stem peers, symbol call-site hints, and direct shared-helper +consumers. Every expansion entry states its reason, source, evidence kind, and +limit; the manifest explicitly says this is not a complete call graph. + +The manifest publishes its budgets, omitted counts/reasons, and a canonical +content digest. Any omission makes the gate `INCOMPLETE` before reviewer +dispatch unless the operator explicitly supplies `--accept-scope-truncation`; +accepted omissions remain visible as `accepted_truncation`. Sequential, +parallel, and synthesis briefs all carry the same artifact digest, so execution +mode cannot silently change the declared review scope. The manifest is a scope +declaration, not proof that a reviewer understood or exhaustively reviewed it. + `pmctl gate verify <result> [--cd <repo>] [--consumer <name>] [--json]` returns three independent axes: @@ -238,6 +256,10 @@ passing `--consumer` is authorizing and requires all three axes to pass. `gate wait` and `ship finish` consume this shared assessment rather than grepping `Final: GO`. +Historical v3 envelopes that record `scope_manifest: unavailable` remain +artifact-readable under default inspection, but they do not satisfy a named +consumer after the scope-manifest producer is installed. + Subject verification intentionally fingerprints the complete included tree, not only the diff. Its cost is therefore linear in tracked and included untracked files each time a subject is finalized or verified. This is bounded diff --git a/runtime/bin/pr-gate.sh b/runtime/bin/pr-gate.sh index e8063734..c88b866e 100755 --- a/runtime/bin/pr-gate.sh +++ b/runtime/bin/pr-gate.sh @@ -530,6 +530,680 @@ _gate_policy_scope_content_digest() { } | _gate_sha256_stream } +# Emit the exact status-bearing change set for the same comparison mode used by +# policy resolution. NUL-delimited Git output keeps paths with whitespace, +# tabs, or newlines intact until jq encodes them as JSON strings. +_gate_scope_changes_collect() { + local records raw status path old_path new_path similarity + records="$(mktemp "${TMPDIR:-/tmp}/gate-scope-changes.XXXXXX")" || return 2 + : > "$records" + + while IFS= read -r -d '' raw; do + status="${raw:0:1}" + similarity=null + case "$status" in + R|C) + IFS= read -r -d '' old_path || { + rm -f -- "$records" + return 2 + } + IFS= read -r -d '' new_path || { + rm -f -- "$records" + return 2 + } + [[ "${raw:1}" =~ ^[0-9]+$ ]] && similarity="${raw:1}" + jq -nc --arg status "$(if [[ "$status" == R ]]; then printf renamed; else printf copied; fi)" \ + --arg old "$old_path" --arg new "$new_path" \ + --argjson similarity "$similarity" \ + '{status:$status,old_path:$old,new_path:$new,similarity:$similarity}' \ + >> "$records" || { + rm -f -- "$records" + return 2 + } + ;; + *) + IFS= read -r -d '' path || { + rm -f -- "$records" + return 2 + } + case "$status" in + A) status=added ;; + M) status=modified ;; + D) status=deleted ;; + T) status=type_changed ;; + U) status=unmerged ;; + *) status=unknown ;; + esac + if [[ "$status" == deleted ]]; then + jq -nc --arg status "$status" --arg old "$path" \ + '{status:$status,old_path:$old,new_path:null,similarity:null}' \ + >> "$records" || { + rm -f -- "$records" + return 2 + } + else + jq -nc --arg status "$status" --arg new "$path" \ + '{status:$status,old_path:null,new_path:$new,similarity:null}' \ + >> "$records" || { + rm -f -- "$records" + return 2 + } + fi + ;; + esac + done < <( + case "$POLICY_DIFF_KIND" in + fixed-head) + git diff --find-renames --name-status -z "$BASE"..."$HEAD_REF" -- + ;; + allow-dirty) + git diff --find-renames --name-status -z "$BASE" -- + ;; + committed) + git diff --find-renames --name-status -z "$BASE"...HEAD -- + ;; + working-tree) + git diff --find-renames --name-status -z HEAD -- + ;; + *) + return 2 + ;; + esac + ) + + if [[ "$POLICY_SCOPE_INCLUDE_UNTRACKED" == true ]]; then + while IFS= read -r -d '' path; do + jq -nc --arg new "$path" \ + '{status:"untracked",old_path:null,new_path:$new,similarity:null}' \ + >> "$records" || { + rm -f -- "$records" + return 2 + } + done < <(git ls-files --others --exclude-standard -z) + fi + + jq -s 'sort_by((.new_path // .old_path),.status)' "$records" + status=$? + rm -f -- "$records" + return "$status" +} + +GATE_SCOPE_MAX_DIFF_HUNKS=512 +GATE_SCOPE_MAX_EXPANSION_SOURCES=256 +GATE_SCOPE_MAX_SYMBOLS_PER_SOURCE=1024 +GATE_SCOPE_MAX_MATCHES_PER_QUERY=64 +GATE_SCOPE_MAX_EXPANSION_ENTRIES=512 + +_gate_scope_diff_for_path() { + local path="$1" + case "$POLICY_DIFF_KIND" in + fixed-head) + git diff --unified=0 --no-color --no-ext-diff \ + "$BASE"..."$HEAD_REF" -- "$path" + ;; + allow-dirty) + git diff --unified=0 --no-color --no-ext-diff "$BASE" -- "$path" + ;; + committed) + git diff --unified=0 --no-color --no-ext-diff "$BASE"...HEAD -- "$path" + ;; + working-tree) + git diff --unified=0 --no-color --no-ext-diff HEAD -- "$path" + ;; + *) + return 2 + ;; + esac +} + +_gate_scope_numstat_for_path() { + local path="$1" + case "$POLICY_DIFF_KIND" in + fixed-head) git diff --numstat "$BASE"..."$HEAD_REF" -- "$path" ;; + allow-dirty) git diff --numstat "$BASE" -- "$path" ;; + committed) git diff --numstat "$BASE"...HEAD -- "$path" ;; + working-tree) git diff --numstat HEAD -- "$path" ;; + *) return 2 ;; + esac +} + +_gate_scope_path_exists() { + local path="$1" + if [[ "$POLICY_DIFF_KIND" == fixed-head ]]; then + git cat-file -e "${GATE_BINDING_HEAD_COMMIT}:$path" 2>/dev/null + else + [[ -f "$WORK_DIR/$path" && ! -L "$WORK_DIR/$path" ]] + fi +} + +_gate_scope_path_content() { + local path="$1" + if [[ "$POLICY_DIFF_KIND" == fixed-head ]]; then + git show "${GATE_BINDING_HEAD_COMMIT}:$path" 2>/dev/null + else + cat -- "$WORK_DIR/$path" + fi +} + +_gate_scope_hunks_collect() { + local changes_json="$1" output="$2" binary_output="$3" + local max_hunks="$GATE_SCOPE_MAX_DIFF_HUNKS" + local status path old_path line old_start old_lines + local new_start new_lines header line_count path_hunks=0 + local total_hunks=0 + : > "$output" + : > "$binary_output" + + while IFS= read -r -d '' status \ + && IFS= read -r -d '' path \ + && IFS= read -r -d '' old_path; do + [[ -n "$path" ]] || path="$old_path" + [[ -n "$path" ]] || continue + path_hunks=0 + if [[ "$status" == untracked ]]; then + if [[ -L "$WORK_DIR/$path" || ! -f "$WORK_DIR/$path" ]] \ + || ! grep -Iq . "$WORK_DIR/$path" 2>/dev/null; then + jq -nc --arg path "$path" '$path' >> "$binary_output" || return 2 + continue + fi + line_count="$(awk 'END { print NR+0 }' "$WORK_DIR/$path")" + header="@@ -0,0 +1,${line_count} @@ untracked" + total_hunks=$((total_hunks + 1)) + if [[ "$total_hunks" -le "$max_hunks" ]]; then + jq -nc --arg path "$path" --arg header "$header" \ + --argjson lines "$line_count" '{ + path:$path,source:"untracked", + old_start:0,old_lines:0,new_start:1,new_lines:$lines,header:$header + }' >> "$output" || return 2 + fi + continue + fi + + while IFS= read -r line; do + if [[ "$line" =~ ^@@[[:space:]]-([0-9]+)(,([0-9]+))?[[:space:]]\+([0-9]+)(,([0-9]+))?[[:space:]]@@ ]]; then + old_start="${BASH_REMATCH[1]}" + old_lines="${BASH_REMATCH[3]:-1}" + new_start="${BASH_REMATCH[4]}" + new_lines="${BASH_REMATCH[6]:-1}" + header="$line" + path_hunks=$((path_hunks + 1)) + total_hunks=$((total_hunks + 1)) + if [[ "$total_hunks" -le "$max_hunks" ]]; then + jq -nc --arg path "$path" --arg header "$header" \ + --argjson old_start "$old_start" --argjson old_lines "$old_lines" \ + --argjson new_start "$new_start" --argjson new_lines "$new_lines" '{ + path:$path,source:"tracked", + old_start:$old_start,old_lines:$old_lines, + new_start:$new_start,new_lines:$new_lines,header:$header + }' >> "$output" || return 2 + fi + fi + done < <(_gate_scope_diff_for_path "$path") + if [[ "$path_hunks" -eq 0 ]] \ + && _gate_scope_numstat_for_path "$path" | grep -q $'^-\t-'; then + jq -nc --arg path "$path" '$path' >> "$binary_output" || return 2 + fi + done < <(jq -j '.[] | + .status, "\u0000", (.new_path // ""), "\u0000", + (.old_path // ""), "\u0000"' <<<"$changes_json") + + GATE_SCOPE_OMITTED_DIFF_HUNKS=$((total_hunks > max_hunks ? total_hunks - max_hunks : 0)) +} + +_gate_scope_paired_tests_collect() { + local changed_paths_json="$1" records source base stem dir candidate + records="$(mktemp "${TMPDIR:-/tmp}/gate-scope-pairs.XXXXXX")" || return 2 + : > "$records" + while IFS= read -r -d '' source; do + _gate_scope_path_exists "$source" || continue + base="$(basename "$source")" + stem="${base%.*}" + dir="$(dirname "$source")" + case "$source" in + *.go) + [[ "$source" == *_test.go ]] || { + candidate="${source%.go}_test.go" + if _gate_scope_path_exists "$candidate"; then + jq -nc --arg source "$source" --arg test "$candidate" \ + '{source_path:$source,test_path:$test,reason:"language-convention"}' \ + >> "$records" || return 2 + fi + } + ;; + *.ts|*.tsx|*.js|*.jsx) + case "$base" in *.test.*|*.spec.*) continue ;; esac + for candidate in \ + "$dir/__tests__/$stem.test.ts" "$dir/__tests__/$stem.test.tsx" \ + "$dir/__tests__/$stem.spec.ts" "$dir/__tests__/$stem.spec.tsx" \ + "$dir/$stem.test.ts" "$dir/$stem.test.tsx" \ + "$dir/$stem.spec.ts" "$dir/$stem.spec.tsx" \ + "$dir/$stem.test.js" "$dir/$stem.spec.js"; do + candidate="${candidate#./}" + if _gate_scope_path_exists "$candidate"; then + jq -nc --arg source "$source" --arg test "$candidate" \ + '{source_path:$source,test_path:$test,reason:"language-convention"}' \ + >> "$records" || return 2 + fi + done + ;; + *.py) + [[ "$base" == test_*.py ]] || { + for candidate in "$dir/test_$base" "tests/test_$base"; do + candidate="${candidate#./}" + if _gate_scope_path_exists "$candidate"; then + jq -nc --arg source "$source" --arg test "$candidate" \ + '{source_path:$source,test_path:$test,reason:"language-convention"}' \ + >> "$records" || return 2 + fi + done + } + ;; + *.sh) + [[ "$base" == test-*.sh ]] || { + for candidate in "$dir/test-$base" "tests/shell/test-$base"; do + candidate="${candidate#./}" + if _gate_scope_path_exists "$candidate"; then + jq -nc --arg source "$source" --arg test "$candidate" \ + '{source_path:$source,test_path:$test,reason:"language-convention"}' \ + >> "$records" || return 2 + fi + done + } + ;; + esac + done < <(jq -j '.[] | ., "\u0000"' <<<"$changed_paths_json") + jq -s 'unique_by([.source_path,.test_path]) | + sort_by(.source_path,.test_path)' "$records" + local rc=$? + rm -f -- "$records" + return "$rc" +} + +_gate_scope_search_paths() { + local query="$1" search_kind="$2" result + local -a options=(-l -z -F) + [[ "$search_kind" == symbol ]] && options+=(-w) + if [[ "$POLICY_DIFF_KIND" == fixed-head ]]; then + while IFS= read -r -d '' result; do + printf '%s\0' "${result#*:}" + done < <(git grep "${options[@]}" "$query" "$GATE_BINDING_HEAD_COMMIT" -- \ + 2>/dev/null || true) + else + git grep "${options[@]}" "$query" -- 2>/dev/null || true + if [[ "$POLICY_SCOPE_INCLUDE_UNTRACKED" == true ]]; then + while IFS= read -r -d '' result; do + [[ -f "$WORK_DIR/$result" && ! -L "$WORK_DIR/$result" ]] || continue + if [[ "$search_kind" == symbol ]]; then + grep -IqlwF -- "$query" "$WORK_DIR/$result" 2>/dev/null \ + && printf '%s\0' "$result" + else + grep -IqlF -- "$query" "$WORK_DIR/$result" 2>/dev/null \ + && printf '%s\0' "$result" + fi + done < <(git ls-files --others --exclude-standard -z) + fi + fi +} + +_gate_scope_expansion_append() { + local output="$1" path="$2" reason="$3" source="$4" evidence="$5" + local limit_kind="$6" maximum="$7" + [[ -n "$path" && "$path" != /* && "$path" != ../* && "$path" != */../* ]] || return 0 + case "$path" in + .agent-trace|.agent-trace/*|.gate-briefs|.gate-briefs/*|.gate-results|.gate-results/*) + return 0 + ;; + esac + jq -nc --arg path "$path" --arg reason "$reason" --arg source "$source" \ + --arg evidence "$evidence" --arg kind "$limit_kind" \ + --argjson maximum "$maximum" '{ + path:$path,reason:$reason,source:$source,evidence:$evidence, + limit:{kind:$kind,maximum:$maximum} + }' >> "$output" +} + +_gate_scope_expansions_collect() { + local changed_paths_json="$1" output="$2" + local candidates sources source_count=0 source path base stem dir ext candidate + local query match eligible_count symbol_count + local symbol_limit="$GATE_SCOPE_MAX_SYMBOLS_PER_SOURCE" + local match_limit="$GATE_SCOPE_MAX_MATCHES_PER_QUERY" + local source_limit="$GATE_SCOPE_MAX_EXPANSION_SOURCES" + local expansion_limit="$GATE_SCOPE_MAX_EXPANSION_ENTRIES" + local omitted_sources=0 omitted_symbols=0 omitted_matches=0 omitted_entries=0 + local -a symbols=() + local -A query_seen=() + candidates="$(mktemp "${TMPDIR:-/tmp}/gate-scope-expansions.XXXXXX")" || return 2 + sources="$(mktemp "${TMPDIR:-/tmp}/gate-scope-sources.XXXXXX")" || { + rm -f -- "$candidates" + return 2 + } + : > "$candidates" + : > "$sources" + + while IFS= read -r -d '' source; do + _gate_scope_path_exists "$source" || continue + case "$source" in + *.sh|*.bash|*.go|*.py|*.js|*.jsx|*.ts|*.tsx|*.java|*.kt|*.rs) + printf '%s\0' "$source" >> "$sources" + ;; + esac + done < <(jq -j '.[] | ., "\u0000"' <<<"$changed_paths_json") + + while IFS= read -r -d '' source; do + source_count=$((source_count + 1)) + if [[ "$source_count" -gt "$source_limit" ]]; then + omitted_sources=$((omitted_sources + 1)) + continue + fi + base="$(basename "$source")" + stem="${base%.*}" + dir="$(dirname "$source")" + + for ext in sh bash go py js jsx ts tsx java kt rs md; do + candidate="$dir/$stem.$ext" + candidate="${candidate#./}" + [[ "$candidate" != "$source" ]] || continue + if _gate_scope_path_exists "$candidate" \ + && ! jq -e --arg path "$candidate" 'index($path) != null' \ + <<<"$changed_paths_json" >/dev/null; then + _gate_scope_expansion_append "$candidates" "$candidate" \ + same-stem-peer "$source" peer-convention per-source 1 || return 2 + fi + done + + if [[ "$source" == */lib/* || "$source" == lib/* \ + || "$source" == */shared/* || "$source" == shared/* ]]; then + eligible_count=0 + query_seen=() + while IFS= read -r -d '' match; do + [[ "$match" != "$source" ]] || continue + [[ -z "${query_seen[$match]:-}" ]] || continue + query_seen["$match"]=1 + jq -e --arg path "$match" 'index($path) != null' \ + <<<"$changed_paths_json" >/dev/null && continue + eligible_count=$((eligible_count + 1)) + if [[ "$eligible_count" -le "$match_limit" ]]; then + _gate_scope_expansion_append "$candidates" "$match" \ + shared-helper-consumer "$source" path-reference per-source "$match_limit" \ + || return 2 + else + omitted_matches=$((omitted_matches + 1)) + fi + done < <(_gate_scope_search_paths "$source" path) + fi + + mapfile -t symbols < <( + _gate_scope_path_content "$source" 2>/dev/null | + sed -nE \ + -e 's/^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\(\)[[:space:]]*(\{|$).*/\1/p' \ + -e 's/^[[:space:]]*func[[:space:]]+\([^)]*\)[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\(.*/\1/p' \ + -e 's/^[[:space:]]*func[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\(.*/\1/p' \ + -e 's/^[[:space:]]*(export[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\(.*/\3/p' \ + -e 's/^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+([A-Za-z_][A-Za-z0-9_]*).*/\2/p' \ + -e 's/^[[:space:]]*(export[[:space:]]+)?(const|let|var)[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=.*/\3/p' \ + -e 's/^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\(.*/\2/p' | + awk 'length($0) >= 3 && !seen[$0]++' | + LC_ALL=C sort + ) + symbol_count="${#symbols[@]}" + if [[ "$symbol_count" -gt "$symbol_limit" ]]; then + omitted_symbols=$((omitted_symbols + symbol_count - symbol_limit)) + symbols=("${symbols[@]:0:symbol_limit}") + fi + for query in "${symbols[@]}"; do + eligible_count=0 + query_seen=() + while IFS= read -r -d '' match; do + [[ "$match" != "$source" ]] || continue + [[ -z "${query_seen[$match]:-}" ]] || continue + query_seen["$match"]=1 + jq -e --arg path "$match" 'index($path) != null' \ + <<<"$changed_paths_json" >/dev/null && continue + eligible_count=$((eligible_count + 1)) + if [[ "$eligible_count" -le "$match_limit" ]]; then + _gate_scope_expansion_append "$candidates" "$match" \ + call-site-hint "$source#$query" symbol-reference per-symbol "$match_limit" \ + || return 2 + else + omitted_matches=$((omitted_matches + 1)) + fi + done < <(_gate_scope_search_paths "$query" symbol) + done + done < "$sources" + + jq -s 'unique_by([.path,.reason,.source,.evidence]) | + sort_by(.path,.reason,.source,.evidence)' "$candidates" | + jq --argjson limit "$expansion_limit" '.[:$limit]' > "$output" || { + rm -f -- "$candidates" "$sources" + return 2 + } + local total_entries + total_entries="$(jq -s 'unique_by([.path,.reason,.source,.evidence]) | length' \ + "$candidates")" || { + rm -f -- "$candidates" "$sources" + return 2 + } + [[ "$total_entries" -le "$expansion_limit" ]] \ + || omitted_entries=$((total_entries - expansion_limit)) + rm -f -- "$candidates" "$sources" + + GATE_SCOPE_OMITTED_EXPANSION_SOURCES="$omitted_sources" + GATE_SCOPE_OMITTED_SYMBOLS="$omitted_symbols" + GATE_SCOPE_OMITTED_SEARCH_MATCHES="$omitted_matches" + GATE_SCOPE_OMITTED_EXPANSION_ENTRIES="$omitted_entries" +} + +_gate_scope_flags_resolve() { + local changed_paths_json="$1" + jq -nc --argjson paths "$changed_paths_json" ' + def flag($pattern): + ($paths | map(select(test($pattern))) | unique | sort) as $matched | + {matched:($matched|length > 0),paths:$matched}; + { + public_interface:flag("^(cli|commands|skills|agents|core/schema)/|(^|/)(README|CONTRIBUTING)\\.md$|(^|/)(api|apis|contract|contracts)(/|$)"), + schema:flag("(^|/)(schema|schemas)(/|$)|\\.schema\\.json$"), + config:flag("(^|/)(config|configs|\\.github|\\.pm-dispatch)(/|$)|\\.(yaml|yml|toml|ini|conf)$|(^|/)\\.gitignore$"), + install:flag("(^|/)(install|uninstall)([^/]*$|/)|(^|/)(setup|bootstrap)(/|[-_.])"), + ci:flag("(^|/)(\\.github/workflows|ci)(/|$)|(^|/)(Dockerfile|Makefile)$"), + release:flag("(^|/)(CHANGELOG|RELEASE[^/]*)\\.md$|(^|/)release(s)?(/|[-_.])"), + migration:flag("(^|/)(migration|migrations|migrate)(/|[-_.])") + } + ' +} + +_gate_scope_manifest_write() { + local destination="$1" changes_json="$2" policy_json="$3" + local hunks_file binary_file expansion_file manifest_tmp + local changed_paths_json renamed_paths_json untracked_paths_json + local paired_tests_json sensitive_signals_json flags_json + local truncation_occurred=false truncation_accepted=false + local status=complete acceptance_source="" reasons_json content_digest + hunks_file="$(mktemp "${TMPDIR:-/tmp}/gate-scope-hunks.XXXXXX")" || return 2 + binary_file="$(mktemp "${TMPDIR:-/tmp}/gate-scope-binary.XXXXXX")" || { + rm -f -- "$hunks_file" + return 2 + } + expansion_file="$(mktemp "${TMPDIR:-/tmp}/gate-scope-expansion.XXXXXX")" || { + rm -f -- "$hunks_file" "$binary_file" + return 2 + } + manifest_tmp="$(mktemp "${destination}.tmp.XXXXXX")" || { + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" + return 2 + } + + changed_paths_json="$(jq -c '[ + .[] | .old_path, .new_path | select(. != null) + ] | unique | sort' <<<"$changes_json")" || return 2 + renamed_paths_json="$(jq -c '[.[] | + select(.status == "renamed") | + {from:.old_path,to:.new_path,similarity:(.similarity // 0)} + ] | sort_by(.from,.to)' <<<"$changes_json")" || return 2 + untracked_paths_json="$(jq -c '[.[] | + select(.status == "untracked") | .new_path + ] | unique | sort' <<<"$changes_json")" || return 2 + + _gate_scope_hunks_collect "$changes_json" "$hunks_file" "$binary_file" || { + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" "$manifest_tmp" + return 2 + } + paired_tests_json="$(_gate_scope_paired_tests_collect "$changed_paths_json")" || { + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" "$manifest_tmp" + return 2 + } + _gate_scope_expansions_collect "$changed_paths_json" "$expansion_file" || { + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" "$manifest_tmp" + return 2 + } + sensitive_signals_json="$(jq -c '[ + .matched_signals[] | select(.source == "path-regex") + ] | sort_by(.id)' <<<"$policy_json")" || return 2 + flags_json="$(_gate_scope_flags_resolve "$changed_paths_json")" || return 2 + + if [[ "$GATE_SCOPE_OMITTED_DIFF_HUNKS" -gt 0 \ + || "$GATE_SCOPE_OMITTED_EXPANSION_SOURCES" -gt 0 \ + || "$GATE_SCOPE_OMITTED_SYMBOLS" -gt 0 \ + || "$GATE_SCOPE_OMITTED_SEARCH_MATCHES" -gt 0 \ + || "$GATE_SCOPE_OMITTED_EXPANSION_ENTRIES" -gt 0 ]]; then + truncation_occurred=true + if [[ "$ACCEPT_SCOPE_TRUNCATION" == true ]]; then + truncation_accepted=true + status=accepted_truncation + acceptance_source=--accept-scope-truncation + else + status=incomplete + fi + fi + reasons_json="$(jq -nc \ + --argjson hunks "$GATE_SCOPE_OMITTED_DIFF_HUNKS" \ + --argjson sources "$GATE_SCOPE_OMITTED_EXPANSION_SOURCES" \ + --argjson symbols "$GATE_SCOPE_OMITTED_SYMBOLS" \ + --argjson matches "$GATE_SCOPE_OMITTED_SEARCH_MATCHES" \ + --argjson entries "$GATE_SCOPE_OMITTED_EXPANSION_ENTRIES" '[ + if $hunks > 0 then "diff-hunk-budget" else empty end, + if $sources > 0 then "expansion-source-budget" else empty end, + if $symbols > 0 then "symbol-budget" else empty end, + if $matches > 0 then "search-match-budget" else empty end, + if $entries > 0 then "expansion-entry-budget" else empty end + ]')" + + if ! jq -n \ + --arg status "$status" \ + --arg repository_key "$GATE_SUBJECT_REPOSITORY_KEY" \ + --arg base_commit "$GATE_BINDING_BASE_COMMIT" \ + --arg head_commit "$GATE_BINDING_HEAD_COMMIT" \ + --arg tree_fingerprint "$GATE_BINDING_SUBJECT_FINGERPRINT" \ + --arg subject_kind "$GATE_SUBJECT_KIND" \ + --arg diff_kind "$POLICY_DIFF_KIND" --arg base_ref "$BASE" \ + --arg head_ref "$HEAD_REF" \ + --arg acceptance_source "$acceptance_source" \ + --argjson include_untracked "$POLICY_SCOPE_INCLUDE_UNTRACKED" \ + --argjson changes "$changes_json" \ + --argjson changed_paths "$changed_paths_json" \ + --argjson renamed_paths "$renamed_paths_json" \ + --argjson untracked_paths "$untracked_paths_json" \ + --slurpfile hunks "$hunks_file" --slurpfile binary "$binary_file" \ + --argjson paired_tests "$paired_tests_json" \ + --argjson sensitive_signals "$sensitive_signals_json" \ + --argjson flags "$flags_json" --slurpfile expansion "$expansion_file" \ + --argjson truncation_occurred "$truncation_occurred" \ + --argjson truncation_accepted "$truncation_accepted" \ + --argjson omitted_hunks "$GATE_SCOPE_OMITTED_DIFF_HUNKS" \ + --argjson omitted_sources "$GATE_SCOPE_OMITTED_EXPANSION_SOURCES" \ + --argjson omitted_symbols "$GATE_SCOPE_OMITTED_SYMBOLS" \ + --argjson omitted_matches "$GATE_SCOPE_OMITTED_SEARCH_MATCHES" \ + --argjson omitted_entries "$GATE_SCOPE_OMITTED_EXPANSION_ENTRIES" \ + --argjson budget_hunks "$GATE_SCOPE_MAX_DIFF_HUNKS" \ + --argjson budget_sources "$GATE_SCOPE_MAX_EXPANSION_SOURCES" \ + --argjson budget_symbols "$GATE_SCOPE_MAX_SYMBOLS_PER_SOURCE" \ + --argjson budget_matches "$GATE_SCOPE_MAX_MATCHES_PER_QUERY" \ + --argjson budget_entries "$GATE_SCOPE_MAX_EXPANSION_ENTRIES" \ + --argjson reasons "$reasons_json" '{ + kind:"gate_scope_manifest_v1", + schema_version:1, + status:$status, + subject:{ + repository_key:$repository_key, + base_commit:$base_commit, + head_commit:$head_commit, + tree_fingerprint:$tree_fingerprint, + subject_kind:$subject_kind + }, + selection:{ + diff_kind:$diff_kind, + base_ref:$base_ref, + head_ref:$head_ref, + include_untracked:$include_untracked + }, + changes:{ + entries:$changes, + changed_paths:$changed_paths, + renamed_paths:$renamed_paths, + untracked_paths:$untracked_paths + }, + diff:{ + hunks:$hunks, + binary_or_special_paths:($binary | unique | sort) + }, + paired_tests:$paired_tests, + sensitive_signals:$sensitive_signals, + flags:$flags, + expansion:{ + claim:"bounded-hints-not-complete-call-graph", + entries:$expansion[0], + included_paths:([$expansion[0][].path] | unique | sort) + }, + truncation:{ + occurred:$truncation_occurred, + budgets:{ + diff_hunks:$budget_hunks, + expansion_source_paths:$budget_sources, + symbols_per_source:$budget_symbols, + matches_per_query:$budget_matches, + expansion_entries:$budget_entries + }, + omitted:{ + diff_hunks:$omitted_hunks, + expansion_source_paths:$omitted_sources, + symbols_per_source:$omitted_symbols, + matches_per_query:$omitted_matches, + expansion_entries:$omitted_entries + }, + reasons:$reasons, + acceptance:{ + required:$truncation_occurred, + accepted:$truncation_accepted, + source:(if $acceptance_source == "" then null else $acceptance_source end) + } + }, + content:{ + digest_algorithm:"sha256-canonical-json-without-content-digest", + digest:("") + } + }' > "$manifest_tmp"; then + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" "$manifest_tmp" + return 2 + fi + content_digest="$(jq -cS 'del(.content.digest)' "$manifest_tmp" | + _gate_sha256_stream)" || { + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" "$manifest_tmp" + return 2 + } + jq --arg digest "$content_digest" '.content.digest=$digest' \ + "$manifest_tmp" > "${manifest_tmp}.final" || { + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" \ + "$manifest_tmp" "${manifest_tmp}.final" + return 2 + } + mv -- "${manifest_tmp}.final" "$destination" || { + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" \ + "$manifest_tmp" "${manifest_tmp}.final" + return 2 + } + rm -f -- "$hunks_file" "$binary_file" "$expansion_file" "$manifest_tmp" +} + # Resolve risk/policy once for every gate consumer. The function owns the # relationship between change signals and assurance coordinates; caller paths # consume its JSON result instead of copying path regexes or policy floors. @@ -1059,6 +1733,9 @@ _kill_process_tree() { # --sequential compatibility spelling for --mode sequential # --allow-hooks execute repo-local .pm-dispatch hook scripts (trusted branches only) # --allow-dirty review the working tree as-is instead of failing on a dirty tree atop committed changes +# --accept-scope-truncation +# explicitly accept declared scope-manifest omissions caused by bounded +# hunk/expansion budgets; otherwise truncation stops before reviewer dispatch # --override-file <f> inject accepted-risk overrides into every reviewer brief; auto-discovered # from .gate-overrides.md at repo root when this flag is omitted. A relative # <f> is resolved against the working dir (--cd), not the caller's CWD, since @@ -1108,6 +1785,7 @@ TIMEOUT="1200" EXECUTOR_OPTION="auto" ALLOW_HOOKS=false # hooks require explicit --allow-hooks opt-in (security) ALLOW_DIRTY=false # gate refuses a dirty tree atop committed changes unless this opt-in +ACCEPT_SCOPE_TRUNCATION=false OVERRIDE_FILE="" # "default" → omit --model → the executor adapter applies its own pinned default # (for codex, resolved via share/codex-model-aliases.tsv; decoupled from ~/.codex/config.toml). @@ -1204,6 +1882,7 @@ while [[ $# -gt 0 ]]; do shift;; --allow-hooks) ALLOW_HOOKS=true; shift;; --allow-dirty) ALLOW_DIRTY=true; shift;; + --accept-scope-truncation) ACCEPT_SCOPE_TRUNCATION=true; shift;; --override-file) # Guard the operand explicitly: under `set -u` a bare `--override-file` with # no following arg would abort with a raw "unbound variable" instead of the @@ -1233,7 +1912,7 @@ while [[ $# -gt 0 ]]; do exit 0;; *) printf 'Unknown arg: %s\n' "$1" >&2 - printf 'Accepted: --cd --run-dir --tier --mode --brief --policy --reviewers --targeted --initial-result --reviewer-dir --scope --base --head --output --executor --model --effort --isolation --timeout --parallel --sequential --allow-hooks --allow-dirty --override-file --policy-override --test-cmd --test-timeout --skip-preflight-tests (-h for help)\n' >&2 + printf 'Accepted: --cd --run-dir --tier --mode --brief --policy --reviewers --targeted --initial-result --reviewer-dir --scope --base --head --output --executor --model --effort --isolation --timeout --parallel --sequential --allow-hooks --allow-dirty --accept-scope-truncation --override-file --policy-override --test-cmd --test-timeout --skip-preflight-tests (-h for help)\n' >&2 exit 2;; esac done @@ -1512,6 +2191,244 @@ else }' } + gate_scope_manifest_verify() { + local manifest_file="$1" repository_key="$2" base_commit="$3" + local head_commit="$4" tree_fingerprint="$5" subject_kind="$6" + local base_ref="$7" head_ref="$8" + local expected_digest actual_digest + [[ -s "$manifest_file" ]] || { + printf 'Error: gate scope manifest is missing or empty: %s\n' \ + "$manifest_file" >&2 + return 1 + } + expected_digest="$(jq -r '.content.digest // empty' "$manifest_file" 2>/dev/null)" + actual_digest="$(jq -cS 'del(.content.digest)' "$manifest_file" 2>/dev/null \ + | _gate_result_sha256_stream)" || return $? + if [[ ! "$expected_digest" =~ ^[a-f0-9]{64}$ \ + || "$actual_digest" != "$expected_digest" ]]; then + printf 'Error: gate scope manifest content digest mismatch: %s\n' \ + "$manifest_file" >&2 + return 1 + fi + jq -e \ + --arg repository_key "$repository_key" \ + --arg base_commit "$base_commit" \ + --arg head_commit "$head_commit" \ + --arg tree_fingerprint "$tree_fingerprint" \ + --arg subject_kind "$subject_kind" \ + --arg base_ref "$base_ref" \ + --arg head_ref "$head_ref" ' + def only_keys($allowed): + (keys | sort) == ($allowed | sort); + def strings_unique: + type == "array" and all(.[]; type == "string" and length > 0) and + length == (unique | length); + def safe_path: + type == "string" and length > 0 and + (startswith("/") | not) and + ((split("/") | index("..")) == null); + def paths_unique: + strings_unique and all(.[]; safe_path); + def exact_set($other): + (sort) == ($other | sort); + def scope_counts: + only_keys(["diff_hunks","expansion_source_paths", + "symbols_per_source","matches_per_query","expansion_entries"]) and + all(.[]; type == "number" and . >= 0 and floor == .); + def scope_flag($changed): + only_keys(["matched","paths"]) and + (.matched | type == "boolean") and + (.paths | paths_unique) and + (.matched == ((.paths | length) > 0)) and + all(.paths[]; . as $path | ($changed | index($path)) != null); + + only_keys(["kind","schema_version","status","subject","selection", + "changes","diff","paired_tests","sensitive_signals","flags", + "expansion","truncation","content"]) and + .kind == "gate_scope_manifest_v1" and .schema_version == 1 and + (.status | IN("complete","accepted_truncation","incomplete")) and + (.subject | + only_keys(["repository_key","base_commit","head_commit", + "tree_fingerprint","subject_kind"]) and + .repository_key == $repository_key and + .base_commit == $base_commit and + .head_commit == $head_commit and + .tree_fingerprint == $tree_fingerprint and + .subject_kind == $subject_kind and + (.repository_key | test("^[a-f0-9]{64}$")) and + (.base_commit | test("^[a-f0-9]{40}$")) and + (.head_commit | test("^[a-f0-9]{40}$")) and + (.tree_fingerprint | test("^[a-f0-9]{64}$")) and + (.subject_kind | IN("committed_head","working_tree","fixed_ref"))) and + (.selection | + only_keys(["diff_kind","base_ref","head_ref","include_untracked"]) and + (.diff_kind | IN("committed","working-tree","allow-dirty","fixed-head")) and + .base_ref == $base_ref and + .head_ref == $head_ref and + (.include_untracked | type == "boolean")) and + ((.subject.subject_kind == "committed_head" and + .selection.diff_kind == "committed") or + (.subject.subject_kind == "working_tree" and + (.selection.diff_kind == "working-tree" or + .selection.diff_kind == "allow-dirty")) or + (.subject.subject_kind == "fixed_ref" and + .selection.diff_kind == "fixed-head")) and + (.selection.include_untracked == + (.subject.subject_kind == "working_tree")) and + (.changes | + only_keys(["entries","changed_paths","renamed_paths","untracked_paths"]) and + (.entries | type == "array" and length > 0) and + all(.entries[]; + only_keys(["status","old_path","new_path","similarity"]) and + (.status | IN("added","modified","deleted","renamed","copied", + "type_changed","unmerged","untracked","unknown")) and + (.old_path == null or (.old_path | safe_path)) and + (.new_path == null or (.new_path | safe_path)) and + (if (.status | IN("renamed","copied")) + then + (.old_path | safe_path) and (.new_path | safe_path) and + (.similarity | type == "number" and . >= 0 and . <= 100 and floor == .) + elif .status == "deleted" + then (.old_path | safe_path) and .new_path == null and .similarity == null + else .old_path == null and (.new_path | safe_path) and .similarity == null + end)) and + (.changed_paths | paths_unique) and + (. as $changes | + ([.entries[] | .old_path,.new_path | select(. != null)] | unique) | + exact_set($changes.changed_paths)) and + (.renamed_paths | type == "array" and + all(.[]; + only_keys(["from","to","similarity"]) and + (.from | safe_path) and (.to | safe_path) and + (.similarity | type == "number" and . >= 0 and . <= 100 and floor == .))) and + (. as $changes | + ([.entries[] | select(.status == "renamed") | + {from:.old_path,to:.new_path,similarity:(.similarity // 0)}] | sort) == + ($changes.renamed_paths | sort)) and + (.untracked_paths | paths_unique) and + (. as $changes | + ([.entries[] | select(.status == "untracked") | .new_path] | unique) | + exact_set($changes.untracked_paths))) and + (.changes.changed_paths as $changed | + (.diff | + only_keys(["hunks","binary_or_special_paths"]) and + (.hunks | type == "array" and + all(.[]; + only_keys(["path","source","old_start","old_lines", + "new_start","new_lines","header"]) and + (.path | safe_path) and + (.source | IN("tracked","untracked")) and + all([.old_start,.old_lines,.new_start,.new_lines][]; + type == "number" and . >= 0 and floor == .) and + (.header | type == "string" and length > 0) and + (.path as $path | ($changed | index($path)) != null))) and + (.binary_or_special_paths | paths_unique) and + all(.binary_or_special_paths[]; + . as $path | ($changed | index($path)) != null)) and + (.paired_tests | type == "array" and + all(.[]; + only_keys(["source_path","test_path","reason"]) and + (.source_path | safe_path) and (.test_path | safe_path) and + .reason == "language-convention" and + (.source_path as $path | ($changed | index($path)) != null))) and + (.sensitive_signals | type == "array" and + ([.[].id] | strings_unique) and + all(.[]; + only_keys(["id","source","matches","minimum_tier", + "required_reviewers","recommended_mode"]) and + (.id | type == "string" and length > 0) and + .source == "path-regex" and + (.matches | strings_unique) and + all(.matches[]; . as $path | ($changed | index($path)) != null) and + (.minimum_tier | IN("express","standard","full")) and + (.required_reviewers | strings_unique) and + (.recommended_mode | IN("sequential","parallel")))) and + (.flags as $flags | + ($flags | + only_keys(["public_interface","schema","config","install","ci", + "release","migration"])) and + ($flags.public_interface | scope_flag($changed)) and + ($flags.schema | scope_flag($changed)) and + ($flags.config | scope_flag($changed)) and + ($flags.install | scope_flag($changed)) and + ($flags.ci | scope_flag($changed)) and + ($flags.release | scope_flag($changed)) and + ($flags.migration | scope_flag($changed)))) and + (.expansion | + only_keys(["claim","entries","included_paths"]) and + .claim == "bounded-hints-not-complete-call-graph" and + (.entries | type == "array" and + all(.[]; + only_keys(["path","reason","source","evidence","limit"]) and + (.path | safe_path) and + (.reason | IN("same-stem-peer","call-site-hint", + "shared-helper-consumer")) and + (.source | type == "string" and length > 0) and + (.evidence | IN("peer-convention","symbol-reference","path-reference")) and + (.limit | + only_keys(["kind","maximum"]) and + (.kind | IN("per-source","per-symbol","global")) and + (.maximum | type == "number" and . >= 1 and floor == .)))) and + (.included_paths | paths_unique) and + (. as $expansion | + ([.entries[].path] | unique | exact_set($expansion.included_paths)))) and + (. as $manifest | .truncation | + only_keys(["occurred","budgets","omitted","reasons","acceptance"]) and + (.occurred | type == "boolean") and + (.budgets | scope_counts and all(.[]; . >= 1)) and + (.omitted | scope_counts) and + (.reasons | strings_unique) and + all(.reasons[]; + IN("diff-hunk-budget","expansion-source-budget","symbol-budget", + "search-match-budget","expansion-entry-budget")) and + (.omitted as $o | + .occurred == ([$o[]] | any(. > 0)) and + (.reasons | exact_set([ + if $o.diff_hunks > 0 then "diff-hunk-budget" else empty end, + if $o.expansion_source_paths > 0 + then "expansion-source-budget" else empty end, + if $o.symbols_per_source > 0 then "symbol-budget" else empty end, + if $o.matches_per_query > 0 + then "search-match-budget" else empty end, + if $o.expansion_entries > 0 + then "expansion-entry-budget" else empty end + ]))) and + (($manifest.diff.hunks | length) <= .budgets.diff_hunks) and + (($manifest.expansion.entries | length) <= .budgets.expansion_entries) and + (.acceptance | + only_keys(["required","accepted","source"]) and + (.required | type == "boolean") and + (.accepted | type == "boolean") and + (.source == null or .source == "--accept-scope-truncation")) and + (if .occurred + then + .acceptance.required == true and + (if .acceptance.accepted + then .acceptance.source == "--accept-scope-truncation" + else .acceptance.source == null + end) + else + .acceptance == {required:false,accepted:false,source:null} and + (.reasons | length) == 0 + end)) and + ((.status == "complete" and .truncation.occurred == false) or + (.status == "accepted_truncation" and + .truncation.occurred == true and + .truncation.acceptance.accepted == true) or + (.status == "incomplete" and + .truncation.occurred == true and + .truncation.acceptance.accepted == false)) and + (.content | + only_keys(["digest_algorithm","digest"]) and + .digest_algorithm == "sha256-canonical-json-without-content-digest" and + (.digest | test("^[a-f0-9]{64}$"))) + ' "$manifest_file" >/dev/null || { + printf 'Error: gate scope manifest failed structural/claim verification: %s\n' \ + "$manifest_file" >&2 + return 1 + } + } + _gate_assurance_linked_evidence_verify() { local assurance_file="$1" assurance_dir label artifact expected_sha local linked_subject subject_fingerprint artifact_path actual_sha @@ -1564,6 +2481,31 @@ else "$artifact_path" >&2 return 1 fi + elif [[ "$label" == scope_manifest ]]; then + if ! gate_scope_manifest_verify "$artifact_path" \ + "$(jq -r '.subject.repository.key' "$assurance_file")" \ + "$(jq -r '.subject.base.commit' "$assurance_file")" \ + "$(jq -r '.subject.head.commit' "$assurance_file")" \ + "$linked_subject" \ + "$(jq -r '.subject.subject_kind' "$assurance_file")" \ + "$(jq -r '.subject.base.ref' "$assurance_file")" \ + "$(jq -r '.subject.head.ref' "$assurance_file")"; then + return 1 + fi + if ! jq -e --slurpfile scope "$artifact_path" ' + ([.policy.matched_signals[] | + select(.source == "path-regex")] | sort_by(.id)) == + ($scope[0].sensitive_signals | sort_by(.id)) + ' "$assurance_file" >/dev/null; then + printf 'Error: gate scope manifest sensitive signals do not match resolved policy: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + if [[ "$(jq -r '.status' "$artifact_path")" == incomplete ]]; then + printf 'Error: incomplete gate scope manifest cannot authorize a gate result: %s\n' \ + "$artifact_path" >&2 + return 1 + fi fi done < <( jq -r ' @@ -3237,6 +4179,7 @@ gate_finalize_assurance() { local result_sha assurance_sha subject_sha attestation_tmp run_ids_json attestation_pointer local finished_at subject_finish subject_json preflight_json evidence_json local evidence_destination evidence_destination_sha evidence_tmp + local scope_destination scope_destination_sha scope_tmp scope_json local -a capture_files=() final="$(grep -E '^Final: (GO|NO-GO)$' "$result_file" | awk '{print $2}')" @@ -3365,11 +4308,42 @@ gate_finalize_assurance() { subject_fingerprint:$subject_fingerprint }')" || return 1 fi - evidence_json="$(jq -nc --argjson preflight "$preflight_json" '{ + scope_destination="$(dirname "$assurance_file")/$(basename "$SCOPE_MANIFEST_PATH")" + if [[ "$SCOPE_MANIFEST_PATH" != "$scope_destination" ]]; then + _gate_assurance_destination_check "$scope_destination" || return 1 + if [[ -e "$scope_destination" ]]; then + scope_destination_sha="$( + _gate_result_sha256_file "$scope_destination" + )" || return $? + if [[ "$scope_destination_sha" != "$SCOPE_MANIFEST_DIGEST" ]]; then + printf 'Error: linked scope manifest destination already exists with different content: %s\n' \ + "$scope_destination" >&2 + return 1 + fi + else + scope_tmp="$(mktemp "${scope_destination}.tmp.XXXXXX")" || return 1 + if ! cp -- "$SCOPE_MANIFEST_PATH" "$scope_tmp" \ + || ! mv -- "$scope_tmp" "$scope_destination"; then + rm -f -- "$scope_tmp" + return 1 + fi + fi + SCOPE_MANIFEST_PATH="$scope_destination" + fi + scope_json="$(jq -nc \ + --arg artifact "$(basename "$SCOPE_MANIFEST_PATH")" \ + --arg sha256 "$SCOPE_MANIFEST_DIGEST" \ + --arg subject_fingerprint \ + "$(jq -r '.tree_fingerprint' <<<"$GATE_SUBJECT_INITIAL")" '{ + status:"verified", + artifact:$artifact, + sha256:$sha256, + subject_fingerprint:$subject_fingerprint + }')" || return 1 + evidence_json="$(jq -nc --argjson preflight "$preflight_json" \ + --argjson scope "$scope_json" '{ preflight:$preflight, - scope_manifest:{ - status:"unavailable",artifact:null,sha256:null,subject_fingerprint:null - }, + scope_manifest:$scope, closure:{ status:"unavailable",artifact:null,sha256:null,subject_fingerprint:null } @@ -3557,65 +4531,13 @@ _build_repo_ref_index() { } -# ── Find adjacent test files not in the diff ───────────────────────────────── -# For each changed source file, locate its companion test file if it exists and -# is not already included in the diff. Including adjacent tests allows reviewers -# to detect coverage gaps in unchanged test files alongside changed source. -# -# Go: <pkg>/<name>.go → <pkg>/<name>_test.go -# TypeScript: <dir>/<name>.ts(x) → <dir>/__tests__/<name>.test.ts(x) -# → <dir>/<name>.test.ts(x) -ADJACENT_TEST_FILES="" -while IFS= read -r f; do - [[ -z "$f" ]] && continue - case "$f" in - *.go) - base="$(basename "$f")" - if [[ "$base" != *_test.go ]]; then - testfile="${f%.go}_test.go" - if [[ -f "$WORK_DIR/$testfile" ]] && ! printf '%s\n' "$DIFF_FILES" | grep -qxF "$testfile"; then - ADJACENT_TEST_FILES="${ADJACENT_TEST_FILES}${testfile}"$'\n' - fi - fi - ;; - *.ts|*.tsx) - base="$(basename "$f")" - case "$base" in *.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx) continue ;; esac - bname="${base%.*}" - dname="$(dirname "$f")" - for candidate in \ - "${dname}/__tests__/${bname}.test.ts" \ - "${dname}/__tests__/${bname}.test.tsx" \ - "${dname}/__tests__/${bname}.spec.ts" \ - "${dname}/__tests__/${bname}.spec.tsx" \ - "${dname}/${bname}.test.ts" \ - "${dname}/${bname}.test.tsx" \ - "${dname}/${bname}.spec.ts" \ - "${dname}/${bname}.spec.tsx"; do - if [[ -f "$WORK_DIR/$candidate" ]] && ! printf '%s\n' "$DIFF_FILES" | grep -qxF "$candidate"; then - ADJACENT_TEST_FILES="${ADJACENT_TEST_FILES}${candidate}"$'\n' - fi - done - ;; - esac -done <<< "$DIFF_FILES" - -# ── Build combined review file list ────────────────────────────────────────── +# The scope manifest producer below owns paired-test discovery. Keep the +# pre-manifest list to the actual diff, then merge its declared pairs and +# bounded expansions once the immutable subject is available. ALL_REVIEW_FILES="$DIFF_FILES" -if [[ -n "$ADJACENT_TEST_FILES" ]]; then - ALL_REVIEW_FILES="$(printf '%s\n%s' "$ALL_REVIEW_FILES" "$ADJACENT_TEST_FILES" | sort -u | grep -v '^$')" -fi - -DIFF_FILE_ENTRIES="" -while IFS= read -r f; do - [[ -z "$f" ]] && continue - fp="$WORK_DIR/$f" - [[ -f "$fp" ]] && DIFF_FILE_ENTRIES="${DIFF_FILE_ENTRIES} - read: ${fp}"$'\n' -done <<< "$ALL_REVIEW_FILES" DIFF_STAT_INDENTED=$(printf '%s\n' "$DIFF_STAT" | sed 's/^/ /') REPO_REF_INDEX="$(_build_repo_ref_index "$WORK_DIR")" -ADJ_COUNT=$(printf '%s\n' "$ADJACENT_TEST_FILES" | grep -c '[^[:space:]]' 2>/dev/null || true) # Render the accepted-risk override context block injected into EVERY reviewer # and synthesis brief. Single source of truth: all three brief templates @@ -3679,7 +4601,6 @@ say 'pr-gate: policy minimum-tier=%s required-reviewers=%s recommended-mode=%s m "$(jq -r '.resolution.recommended_mode' <<<"$GATE_POLICY_RESOLUTION")" \ "$POLICY_MODE_SELECTION_SOURCE" "$POLICY_MODE_RECOMMENDATION_OVERRIDDEN" \ "$POLICY_SCOPE_FINGERPRINT" -[[ "${ADJ_COUNT:-0}" -gt 0 ]] && say ' adjacent test files added: %d\n' "$ADJ_COUNT" say 'result will be written to: %s\n\n' "$OUTPUT_FILE" # ── Pre-gate hook ────────────────────────────────────────────────────────── @@ -3794,6 +4715,83 @@ GATE_BINDING_SUBJECT_FINGERPRINT="$( jq -r '.tree_fingerprint' <<<"$GATE_SUBJECT_INITIAL" )" +# ── Immutable declared review scope ────────────────────────────────────────── +# Build one machine-owned manifest before any reviewer dispatch. The artifact +# is read-only reviewer context; its digest, not prompt prose, proves that +# sequential and parallel reviewers received the same declared scope. +mkdir -p "$WORK_DIR/.gate-results" +SCOPE_MANIFEST_PATH="$WORK_DIR/.gate-results/gate-scope-manifest-${TIMESTAMP}.json" +SCOPE_CHANGE_ENTRIES_JSON="$(_gate_scope_changes_collect)" || { + printf 'Error: unable to collect the gate scope change set\n' >&2 + exit 2 +} +if [[ "$(jq -r 'length' <<<"$SCOPE_CHANGE_ENTRIES_JSON")" -eq 0 ]]; then + printf 'Error: gate scope manifest found no changed paths\n' >&2 + exit 2 +fi +_gate_assurance_destination_check "$SCOPE_MANIFEST_PATH" || exit 2 +_gate_scope_manifest_write "$SCOPE_MANIFEST_PATH" \ + "$SCOPE_CHANGE_ENTRIES_JSON" "$GATE_POLICY_RESOLUTION" || { + printf 'Error: unable to create gate scope manifest\n' >&2 + exit 2 + } +SCOPE_MANIFEST_DIGEST="$(_gate_result_sha256_file "$SCOPE_MANIFEST_PATH")" || exit 2 +SCOPE_MANIFEST_CONTENT_DIGEST="$(jq -r '.content.digest' "$SCOPE_MANIFEST_PATH")" +SCOPE_MANIFEST_STATUS="$(jq -r '.status' "$SCOPE_MANIFEST_PATH")" +SCOPE_MANIFEST_EXPANSION_COUNT="$(jq -r '.expansion.entries | length' \ + "$SCOPE_MANIFEST_PATH")" +ADJACENT_TEST_FILES="$(jq -r ' + . as $manifest | + .paired_tests[] | + .test_path as $test | + select(($manifest.changes.changed_paths | index($test)) == null) | + $test +' "$SCOPE_MANIFEST_PATH")" +ADJ_COUNT="$(printf '%s\n' "$ADJACENT_TEST_FILES" \ + | grep -c '[^[:space:]]' 2>/dev/null || true)" + +SCOPE_DECLARED_REVIEW_FILES="$(jq -r '[ + .changes.entries[] | .new_path // empty +] + [.paired_tests[].test_path] + [.expansion.included_paths[]] | + unique | sort | .[]' "$SCOPE_MANIFEST_PATH")" +ALL_REVIEW_FILES="$(printf '%s\n%s\n' "$ALL_REVIEW_FILES" \ + "$SCOPE_DECLARED_REVIEW_FILES" | awk 'NF && !seen[$0]++')" +DIFF_FILE_ENTRIES="" +while IFS= read -r f; do + [[ -n "$f" ]] || continue + fp="$WORK_DIR/$f" + [[ -f "$fp" && ! -L "$fp" ]] \ + && DIFF_FILE_ENTRIES="${DIFF_FILE_ENTRIES} - read: ${fp}"$'\n' +done <<< "$ALL_REVIEW_FILES" +DIFF_FILE_ENTRIES="${DIFF_FILE_ENTRIES} - read: ${SCOPE_MANIFEST_PATH}"$'\n' + +printf -v SCOPE_MANIFEST_CONTEXT_BLOCK \ + ' Declared scope manifest (machine-owned; use this digest in every coverage claim):\n status: %s\n artifact: %s\n artifact_sha256: %s\n content_digest: %s\n subject_fingerprint: %s\n expansion_claim: bounded-hints-not-complete-call-graph\n' \ + "$SCOPE_MANIFEST_STATUS" "$SCOPE_MANIFEST_PATH" "$SCOPE_MANIFEST_DIGEST" \ + "$SCOPE_MANIFEST_CONTENT_DIGEST" "$GATE_BINDING_SUBJECT_FINGERPRINT" +say 'pr-gate: scope manifest status=%s sha256=%s expansions=%s artifact=%s\n' \ + "$SCOPE_MANIFEST_STATUS" "$SCOPE_MANIFEST_DIGEST" \ + "$SCOPE_MANIFEST_EXPANSION_COUNT" \ + "$(_preflight_log_display_path "$SCOPE_MANIFEST_PATH")" +[[ "$ADJ_COUNT" -gt 0 ]] \ + && say ' adjacent test files added: %d\n' "$ADJ_COUNT" + +if [[ "$SCOPE_MANIFEST_STATUS" == incomplete ]]; then + { + printf 'INCOMPLETE: declared scope exceeded bounded manifest budgets before reviewer dispatch.\n' + printf ' manifest: %s\n' "$(_preflight_log_display_path "$SCOPE_MANIFEST_PATH")" + printf ' omitted: %s\n' \ + "$(jq -c '.truncation.omitted' "$SCOPE_MANIFEST_PATH")" + printf ' reasons: %s\n' \ + "$(jq -c '.truncation.reasons' "$SCOPE_MANIFEST_PATH")" + printf ' Inspect the manifest, then rerun with --accept-scope-truncation only if those omissions are acceptable.\n' + } >&2 + if [[ "$GATE_OUTPUT_EXISTED" != true ]]; then + rm -f -- "$OUTPUT_FILE" + fi + exit 3 +fi + if [[ "$SKIP_PREFLIGHT_TESTS" != "true" && -n "$TEST_CMD_OVERRIDE" ]]; then # pr-gate.sh is designed to be copied standalone into any repo (copy-mode -- # see the file header), so it must not hardcode any repo-specific test @@ -4165,7 +5163,7 @@ context: Base: ${BASE}${HEAD_METADATA_LINE} Scope: ${SCOPE:-none} Date: $(date '+%Y-%m-%d') -${GATE_ASSURANCE_CONTEXT_BLOCK}${GATE_OVERRIDES_CONTEXT_BLOCK}${TEST_EVIDENCE_CONTEXT_BLOCK} +${GATE_ASSURANCE_CONTEXT_BLOCK}${SCOPE_MANIFEST_CONTEXT_BLOCK}${GATE_OVERRIDES_CONTEXT_BLOCK}${TEST_EVIDENCE_CONTEXT_BLOCK} ${MEMORY_CONTEXT_BLOCK} Verified reference files (exist in working tree -- check before citing): ${REPO_REF_INDEX} @@ -4400,7 +5398,7 @@ context: Base: ${BASE}${HEAD_METADATA_LINE} Scope: ${SCOPE:-none} Date: $(date '+%Y-%m-%d') -${GATE_ASSURANCE_CONTEXT_BLOCK}${GATE_OVERRIDES_CONTEXT_BLOCK}${TEST_EVIDENCE_CONTEXT_BLOCK} +${GATE_ASSURANCE_CONTEXT_BLOCK}${SCOPE_MANIFEST_CONTEXT_BLOCK}${GATE_OVERRIDES_CONTEXT_BLOCK}${TEST_EVIDENCE_CONTEXT_BLOCK} ${MEMORY_CONTEXT_BLOCK} Verified reference files (exist in working tree -- check before citing): ${REPO_REF_INDEX} @@ -4627,7 +5625,7 @@ context: Base: ${BASE}${HEAD_METADATA_LINE} Scope: ${SCOPE:-none} Date: $(date '+%Y-%m-%d') -${GATE_ASSURANCE_CONTEXT_BLOCK}${GATE_OVERRIDES_CONTEXT_BLOCK}${TEST_EVIDENCE_CONTEXT_BLOCK} +${GATE_ASSURANCE_CONTEXT_BLOCK}${SCOPE_MANIFEST_CONTEXT_BLOCK}${GATE_OVERRIDES_CONTEXT_BLOCK}${TEST_EVIDENCE_CONTEXT_BLOCK} Verified reference files (exist in working tree -- check before citing): ${REPO_REF_INDEX} Reviewer findings (embedded -- do NOT attempt to read any external reviewer output file): diff --git a/runtime/lib/gate-result-verify.sh b/runtime/lib/gate-result-verify.sh index d5b45045..22f0b1df 100644 --- a/runtime/lib/gate-result-verify.sh +++ b/runtime/lib/gate-result-verify.sh @@ -303,6 +303,252 @@ gate_subject_assess() { }' } +# gate_scope_manifest_verify <manifest> <repository-key> <base-commit> +# <head-commit> <tree-fingerprint> <subject-kind> +# <base-ref> <head-ref> +# +# JSON Schema owns the portable development contract. This jq predicate is the +# copy-mode runtime mirror: it verifies closed shapes, cross-field parity, +# truncation truthfulness, immutable-subject binding, and the self-addressed +# canonical content digest without requiring a jsonschema executable. +gate_scope_manifest_verify() { + local manifest_file="$1" repository_key="$2" base_commit="$3" + local head_commit="$4" tree_fingerprint="$5" subject_kind="$6" + local base_ref="$7" head_ref="$8" + local expected_digest actual_digest + [[ -s "$manifest_file" ]] || { + printf 'Error: gate scope manifest is missing or empty: %s\n' \ + "$manifest_file" >&2 + return 1 + } + expected_digest="$(jq -r '.content.digest // empty' "$manifest_file" 2>/dev/null)" + actual_digest="$(jq -cS 'del(.content.digest)' "$manifest_file" 2>/dev/null \ + | _gate_result_sha256_stream)" || return $? + if [[ ! "$expected_digest" =~ ^[a-f0-9]{64}$ \ + || "$actual_digest" != "$expected_digest" ]]; then + printf 'Error: gate scope manifest content digest mismatch: %s\n' \ + "$manifest_file" >&2 + return 1 + fi + jq -e \ + --arg repository_key "$repository_key" \ + --arg base_commit "$base_commit" \ + --arg head_commit "$head_commit" \ + --arg tree_fingerprint "$tree_fingerprint" \ + --arg subject_kind "$subject_kind" \ + --arg base_ref "$base_ref" \ + --arg head_ref "$head_ref" ' + def only_keys($allowed): + (keys | sort) == ($allowed | sort); + def strings_unique: + type == "array" and all(.[]; type == "string" and length > 0) and + length == (unique | length); + def safe_path: + type == "string" and length > 0 and + (startswith("/") | not) and + ((split("/") | index("..")) == null); + def paths_unique: + strings_unique and all(.[]; safe_path); + def exact_set($other): + (sort) == ($other | sort); + def scope_counts: + only_keys(["diff_hunks","expansion_source_paths", + "symbols_per_source","matches_per_query","expansion_entries"]) and + all(.[]; type == "number" and . >= 0 and floor == .); + def scope_flag($changed): + only_keys(["matched","paths"]) and + (.matched | type == "boolean") and + (.paths | paths_unique) and + (.matched == ((.paths | length) > 0)) and + all(.paths[]; . as $path | ($changed | index($path)) != null); + + only_keys(["kind","schema_version","status","subject","selection", + "changes","diff","paired_tests","sensitive_signals","flags", + "expansion","truncation","content"]) and + .kind == "gate_scope_manifest_v1" and .schema_version == 1 and + (.status | IN("complete","accepted_truncation","incomplete")) and + (.subject | + only_keys(["repository_key","base_commit","head_commit", + "tree_fingerprint","subject_kind"]) and + .repository_key == $repository_key and + .base_commit == $base_commit and + .head_commit == $head_commit and + .tree_fingerprint == $tree_fingerprint and + .subject_kind == $subject_kind and + (.repository_key | test("^[a-f0-9]{64}$")) and + (.base_commit | test("^[a-f0-9]{40}$")) and + (.head_commit | test("^[a-f0-9]{40}$")) and + (.tree_fingerprint | test("^[a-f0-9]{64}$")) and + (.subject_kind | IN("committed_head","working_tree","fixed_ref"))) and + (.selection | + only_keys(["diff_kind","base_ref","head_ref","include_untracked"]) and + (.diff_kind | IN("committed","working-tree","allow-dirty","fixed-head")) and + .base_ref == $base_ref and + .head_ref == $head_ref and + (.include_untracked | type == "boolean")) and + ((.subject.subject_kind == "committed_head" and + .selection.diff_kind == "committed") or + (.subject.subject_kind == "working_tree" and + (.selection.diff_kind == "working-tree" or + .selection.diff_kind == "allow-dirty")) or + (.subject.subject_kind == "fixed_ref" and + .selection.diff_kind == "fixed-head")) and + (.selection.include_untracked == + (.subject.subject_kind == "working_tree")) and + (.changes | + only_keys(["entries","changed_paths","renamed_paths","untracked_paths"]) and + (.entries | type == "array" and length > 0) and + all(.entries[]; + only_keys(["status","old_path","new_path","similarity"]) and + (.status | IN("added","modified","deleted","renamed","copied", + "type_changed","unmerged","untracked","unknown")) and + (.old_path == null or (.old_path | safe_path)) and + (.new_path == null or (.new_path | safe_path)) and + (if (.status | IN("renamed","copied")) + then + (.old_path | safe_path) and (.new_path | safe_path) and + (.similarity | type == "number" and . >= 0 and . <= 100 and floor == .) + elif .status == "deleted" + then (.old_path | safe_path) and .new_path == null and .similarity == null + else .old_path == null and (.new_path | safe_path) and .similarity == null + end)) and + (.changed_paths | paths_unique) and + (. as $changes | + ([.entries[] | .old_path,.new_path | select(. != null)] | unique) | + exact_set($changes.changed_paths)) and + (.renamed_paths | type == "array" and + all(.[]; + only_keys(["from","to","similarity"]) and + (.from | safe_path) and (.to | safe_path) and + (.similarity | type == "number" and . >= 0 and . <= 100 and floor == .))) and + (. as $changes | + ([.entries[] | select(.status == "renamed") | + {from:.old_path,to:.new_path,similarity:(.similarity // 0)}] | sort) == + ($changes.renamed_paths | sort)) and + (.untracked_paths | paths_unique) and + (. as $changes | + ([.entries[] | select(.status == "untracked") | .new_path] | unique) | + exact_set($changes.untracked_paths))) and + (.changes.changed_paths as $changed | + (.diff | + only_keys(["hunks","binary_or_special_paths"]) and + (.hunks | type == "array" and + all(.[]; + only_keys(["path","source","old_start","old_lines", + "new_start","new_lines","header"]) and + (.path | safe_path) and + (.source | IN("tracked","untracked")) and + all([.old_start,.old_lines,.new_start,.new_lines][]; + type == "number" and . >= 0 and floor == .) and + (.header | type == "string" and length > 0) and + (.path as $path | ($changed | index($path)) != null))) and + (.binary_or_special_paths | paths_unique) and + all(.binary_or_special_paths[]; + . as $path | ($changed | index($path)) != null)) and + (.paired_tests | type == "array" and + all(.[]; + only_keys(["source_path","test_path","reason"]) and + (.source_path | safe_path) and (.test_path | safe_path) and + .reason == "language-convention" and + (.source_path as $path | ($changed | index($path)) != null))) and + (.sensitive_signals | type == "array" and + ([.[].id] | strings_unique) and + all(.[]; + only_keys(["id","source","matches","minimum_tier", + "required_reviewers","recommended_mode"]) and + (.id | type == "string" and length > 0) and + .source == "path-regex" and + (.matches | strings_unique) and + all(.matches[]; . as $path | ($changed | index($path)) != null) and + (.minimum_tier | IN("express","standard","full")) and + (.required_reviewers | strings_unique) and + (.recommended_mode | IN("sequential","parallel")))) and + (.flags as $flags | + ($flags | + only_keys(["public_interface","schema","config","install","ci", + "release","migration"])) and + ($flags.public_interface | scope_flag($changed)) and + ($flags.schema | scope_flag($changed)) and + ($flags.config | scope_flag($changed)) and + ($flags.install | scope_flag($changed)) and + ($flags.ci | scope_flag($changed)) and + ($flags.release | scope_flag($changed)) and + ($flags.migration | scope_flag($changed)))) and + (.expansion | + only_keys(["claim","entries","included_paths"]) and + .claim == "bounded-hints-not-complete-call-graph" and + (.entries | type == "array" and + all(.[]; + only_keys(["path","reason","source","evidence","limit"]) and + (.path | safe_path) and + (.reason | IN("same-stem-peer","call-site-hint", + "shared-helper-consumer")) and + (.source | type == "string" and length > 0) and + (.evidence | IN("peer-convention","symbol-reference","path-reference")) and + (.limit | + only_keys(["kind","maximum"]) and + (.kind | IN("per-source","per-symbol","global")) and + (.maximum | type == "number" and . >= 1 and floor == .)))) and + (.included_paths | paths_unique) and + (. as $expansion | + ([.entries[].path] | unique | exact_set($expansion.included_paths)))) and + (. as $manifest | .truncation | + only_keys(["occurred","budgets","omitted","reasons","acceptance"]) and + (.occurred | type == "boolean") and + (.budgets | scope_counts and all(.[]; . >= 1)) and + (.omitted | scope_counts) and + (.reasons | strings_unique) and + all(.reasons[]; + IN("diff-hunk-budget","expansion-source-budget","symbol-budget", + "search-match-budget","expansion-entry-budget")) and + (.omitted as $o | + .occurred == ([$o[]] | any(. > 0)) and + (.reasons | exact_set([ + if $o.diff_hunks > 0 then "diff-hunk-budget" else empty end, + if $o.expansion_source_paths > 0 + then "expansion-source-budget" else empty end, + if $o.symbols_per_source > 0 then "symbol-budget" else empty end, + if $o.matches_per_query > 0 + then "search-match-budget" else empty end, + if $o.expansion_entries > 0 + then "expansion-entry-budget" else empty end + ]))) and + (($manifest.diff.hunks | length) <= .budgets.diff_hunks) and + (($manifest.expansion.entries | length) <= .budgets.expansion_entries) and + (.acceptance | + only_keys(["required","accepted","source"]) and + (.required | type == "boolean") and + (.accepted | type == "boolean") and + (.source == null or .source == "--accept-scope-truncation")) and + (if .occurred + then + .acceptance.required == true and + (if .acceptance.accepted + then .acceptance.source == "--accept-scope-truncation" + else .acceptance.source == null + end) + else + .acceptance == {required:false,accepted:false,source:null} and + (.reasons | length) == 0 + end)) and + ((.status == "complete" and .truncation.occurred == false) or + (.status == "accepted_truncation" and + .truncation.occurred == true and + .truncation.acceptance.accepted == true) or + (.status == "incomplete" and + .truncation.occurred == true and + .truncation.acceptance.accepted == false)) and + (.content | + only_keys(["digest_algorithm","digest"]) and + .digest_algorithm == "sha256-canonical-json-without-content-digest" and + (.digest | test("^[a-f0-9]{64}$"))) + ' "$manifest_file" >/dev/null || { + printf 'Error: gate scope manifest failed structural/claim verification: %s\n' \ + "$manifest_file" >&2 + return 1 + } +} + _gate_assurance_linked_evidence_verify() { local assurance_file="$1" assurance_dir label artifact expected_sha local linked_subject subject_fingerprint artifact_path actual_sha @@ -355,6 +601,31 @@ _gate_assurance_linked_evidence_verify() { "$artifact_path" >&2 return 1 fi + elif [[ "$label" == scope_manifest ]]; then + if ! gate_scope_manifest_verify "$artifact_path" \ + "$(jq -r '.subject.repository.key' "$assurance_file")" \ + "$(jq -r '.subject.base.commit' "$assurance_file")" \ + "$(jq -r '.subject.head.commit' "$assurance_file")" \ + "$linked_subject" \ + "$(jq -r '.subject.subject_kind' "$assurance_file")" \ + "$(jq -r '.subject.base.ref' "$assurance_file")" \ + "$(jq -r '.subject.head.ref' "$assurance_file")"; then + return 1 + fi + if ! jq -e --slurpfile scope "$artifact_path" ' + ([.policy.matched_signals[] | + select(.source == "path-regex")] | sort_by(.id)) == + ($scope[0].sensitive_signals | sort_by(.id)) + ' "$assurance_file" >/dev/null; then + printf 'Error: gate scope manifest sensitive signals do not match resolved policy: %s\n' \ + "$artifact_path" >&2 + return 1 + fi + if [[ "$(jq -r '.status' "$artifact_path")" == incomplete ]]; then + printf 'Error: incomplete gate scope manifest cannot authorize a gate result: %s\n' \ + "$artifact_path" >&2 + return 1 + fi fi done < <( jq -r ' @@ -413,6 +684,8 @@ gate_policy_applicability_assess() { if any($a.dispatch.outcomes[]; .status != "passed" or .evidence_status != "verified" or .run_id == null) then "review_dispatch_evidence_incomplete" else empty end, + if $a.evidence.scope_manifest.status != "verified" + then "scope_manifest_unavailable" else empty end, if $authorization_status != "verified" then $authorization_reason else empty end, if $consumer == "publish" and $a.evidence.closure.status != "verified" diff --git a/tests/bin/run-tests.sh b/tests/bin/run-tests.sh index 8b4f1d1d..a7b4ed9c 100755 --- a/tests/bin/run-tests.sh +++ b/tests/bin/run-tests.sh @@ -217,7 +217,7 @@ map_path() { add_suite test-pmctl-task; behavioral=1 ;; core/schema/preflight-evidence.schema.json) add_suite test-pr-gate; behavioral=1 ;; - core/schema/gate-assurance.schema.json|core/schema/gate-policy-override.schema.json|core/schema/gate-verification.schema.json) + core/schema/gate-assurance.schema.json|core/schema/gate-policy-override.schema.json|core/schema/gate-scope-manifest.schema.json|core/schema/gate-verification.schema.json) add_suite test-core-schemas; add_suite test-pr-gate add_suite test-pmctl-gate; behavioral=1 ;; runtime/lib/gate-result-verify.sh) diff --git a/tests/shell/test-core-schemas.sh b/tests/shell/test-core-schemas.sh index 3c5248b3..20fe56bb 100755 --- a/tests/shell/test-core-schemas.sh +++ b/tests/shell/test-core-schemas.sh @@ -741,6 +741,269 @@ case_preflight_reusable_evidence_requires_fingerprint() { rm -f "$tmpf" } +_gate_scope_manifest_valid_instance() { + jq -n '{ + kind:"gate_scope_manifest_v1", + schema_version:1, + status:"complete", + subject:{ + repository_key:("a" * 64), + base_commit:("b" * 40), + head_commit:("c" * 40), + tree_fingerprint:("d" * 64), + subject_kind:"committed_head" + }, + selection:{ + diff_kind:"committed", + base_ref:"main", + head_ref:"HEAD", + include_untracked:false + }, + changes:{ + entries:[{ + status:"modified", + old_path:null, + new_path:"runtime/bin/example.sh", + similarity:null + }], + changed_paths:["runtime/bin/example.sh"], + renamed_paths:[], + untracked_paths:[] + }, + diff:{ + hunks:[{ + path:"runtime/bin/example.sh", + source:"tracked", + old_start:10, + old_lines:1, + new_start:10, + new_lines:2, + header:"@@ -10 +10,2 @@" + }], + binary_or_special_paths:[] + }, + paired_tests:[{ + source_path:"runtime/bin/example.sh", + test_path:"tests/shell/test-example.sh", + reason:"language-convention" + }], + sensitive_signals:[{ + id:"public-contract", + source:"path-regex", + matches:["runtime/bin/example.sh"], + minimum_tier:"standard", + required_reviewers:["architecture-reviewer"], + recommended_mode:"parallel" + }], + flags:{ + public_interface:{matched:false,paths:[]}, + schema:{matched:false,paths:[]}, + config:{matched:false,paths:[]}, + install:{matched:false,paths:[]}, + ci:{matched:false,paths:[]}, + release:{matched:false,paths:[]}, + migration:{matched:false,paths:[]} + }, + expansion:{ + claim:"bounded-hints-not-complete-call-graph", + entries:[{ + path:"tests/shell/test-example.sh", + reason:"same-stem-peer", + source:"runtime/bin/example.sh", + evidence:"peer-convention", + limit:{kind:"per-source",maximum:64} + }], + included_paths:["tests/shell/test-example.sh"] + }, + truncation:{ + occurred:false, + budgets:{ + diff_hunks:512, + expansion_source_paths:256, + symbols_per_source:1024, + matches_per_query:64, + expansion_entries:512 + }, + omitted:{ + diff_hunks:0, + expansion_source_paths:0, + symbols_per_source:0, + matches_per_query:0, + expansion_entries:0 + }, + reasons:[], + acceptance:{required:false,accepted:false,source:null} + }, + content:{ + digest_algorithm:"sha256-canonical-json-without-content-digest", + digest:("e" * 64) + } + }' +} + +case_gate_scope_manifest_valid_complete() { + # Behavior: a complete canonical scope manifest passes the schema. + # Steps: + # 1. Build a manifest with subject, changes, review hints, and zero omissions. + # 2. Validate it against the public schema. + local name="gate-scope-manifest: complete canonical instance validates" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-scope-manifest.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-scope-valid-XXXXXX.json)" + _gate_scope_manifest_valid_instance > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + pass "$name" + else + fail "$name" "schema rejected a canonical complete scope manifest" + fi + rm -f "$tmpf" +} + +case_gate_scope_manifest_valid_accepted_truncation() { + # Behavior: a bounded omission is representable only with explicit acceptance. + # Steps: + # 1. Convert the canonical instance to accepted_truncation. + # 2. Record the omitted count, reason, and CLI acceptance source. + # 3. Assert the schema accepts the coherent state. + local name="gate-scope-manifest: explicit accepted truncation validates" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-scope-manifest.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-scope-accepted-XXXXXX.json)" + _gate_scope_manifest_valid_instance | + jq ' + .status = "accepted_truncation" | + .truncation.occurred = true | + .truncation.omitted.diff_hunks = 1 | + .truncation.reasons = ["diff-hunk-budget"] | + .truncation.acceptance = { + required:true, + accepted:true, + source:"--accept-scope-truncation" + } + ' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + pass "$name" + else + fail "$name" "schema rejected coherent accepted truncation" + fi + rm -f "$tmpf" +} + +case_gate_scope_manifest_inconsistent_status_rejected() { + # Behavior: complete status cannot conceal truncation acceptance. + # Steps: + # 1. Keep status complete but claim a truncation occurred and was accepted. + # 2. Assert the conditional schema rejects the contradictory state. + local name="gate-scope-manifest: complete status rejects hidden truncation" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-scope-manifest.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-scope-inconsistent-XXXXXX.json)" + _gate_scope_manifest_valid_instance | + jq ' + .truncation.occurred = true | + .truncation.omitted.diff_hunks = 1 | + .truncation.reasons = ["diff-hunk-budget"] | + .truncation.acceptance = { + required:true, + accepted:true, + source:"--accept-scope-truncation" + } + ' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted contradictory complete/truncated state" + else + pass "$name" + fi + rm -f "$tmpf" +} + +case_gate_scope_manifest_subject_selection_rejected() { + # Behavior: the declared diff selection must agree with the subject kind. + # Steps: + # 1. Change a committed-head fixture to fixed_ref without changing its + # committed diff kind. + # 2. Assert the conditional schema rejects the inconsistent binding. + local name="gate-scope-manifest: subject kind and diff selection must agree" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-scope-manifest.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-scope-selection-XXXXXX.json)" + _gate_scope_manifest_valid_instance | + jq '.subject.subject_kind = "fixed_ref"' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted fixed_ref with committed diff selection" + else + pass "$name" + fi + rm -f "$tmpf" +} + +case_gate_scope_manifest_escaping_path_rejected() { + # Behavior: manifest paths stay repository relative. + # Steps: + # 1. Replace a changed path with an escaping path. + # 2. Assert the schema rejects it. + local name="gate-scope-manifest: escaping repository path is rejected" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-scope-manifest.schema.json" tmpf + tmpf="$(mktemp /tmp/gate-scope-path-XXXXXX.json)" + _gate_scope_manifest_valid_instance | + jq '.changes.entries[0].new_path = "../outside.sh"' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + fail "$name" "schema accepted an escaping repository path" + else + pass "$name" + fi + rm -f "$tmpf" +} + +case_gate_scope_manifest_change_entry_status_shape_rejected() { + # Behavior: each change status has the same old/new path and similarity + # contract in the public schema as in the runtime verifier. + # Steps: + # 1. Mutate the canonical entry into invalid modified, renamed, and deleted + # status-specific shapes. + # 2. Validate each independently against the public schema. + # 3. Assert every contradictory shape is rejected. + local name="gate-scope-manifest: change status controls path shape" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-scope-manifest.schema.json" + local tmpf mutation + while IFS= read -r mutation; do + tmpf="$(mktemp /tmp/gate-scope-change-shape-XXXXXX.json)" + case "$mutation" in + modified-old-path) + _gate_scope_manifest_valid_instance | + jq '.changes.entries[0].old_path = "runtime/bin/example.sh"' > "$tmpf" + ;; + renamed-without-similarity) + _gate_scope_manifest_valid_instance | + jq ' + .changes.entries[0].status = "renamed" | + .changes.entries[0].old_path = "runtime/bin/old-example.sh" + ' > "$tmpf" + ;; + deleted-with-new-path) + _gate_scope_manifest_valid_instance | + jq ' + .changes.entries[0].status = "deleted" | + .changes.entries[0].old_path = "runtime/bin/example.sh" + ' > "$tmpf" + ;; + esac + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + rm -f "$tmpf" + fail "$name" "schema accepted invalid $mutation entry" + return + fi + rm -f "$tmpf" + done <<'CASES' +modified-old-path +renamed-without-similarity +deleted-with-new-path +CASES + pass "$name" +} + _gate_assurance_valid_instance() { jq -n '{ kind:"gate_assurance_v2", @@ -1182,6 +1445,12 @@ case_context_pack_invalid_source_domain_rejected case_context_pack_invalid_trust_level_rejected case_preflight_basic_evidence_needs_no_git_provenance case_preflight_reusable_evidence_requires_fingerprint +case_gate_scope_manifest_valid_complete +case_gate_scope_manifest_valid_accepted_truncation +case_gate_scope_manifest_inconsistent_status_rejected +case_gate_scope_manifest_subject_selection_rejected +case_gate_scope_manifest_escaping_path_rejected +case_gate_scope_manifest_change_entry_status_shape_rejected case_gate_assurance_valid_instance case_gate_assurance_v3_valid_instance case_gate_assurance_v2_rejects_v3_fields diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index 6a7b3b39..a9dba701 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -613,6 +613,133 @@ _mk_gate_result_v3_verified() { } ' "$sidecar" > "${sidecar}.tmp" mv "${sidecar}.tmp" "$sidecar" + _attach_gate_scope_manifest_v3 "$path" + _refresh_gate_result_v3_attestation "$path" +} + +_attach_gate_scope_manifest_v3() { + local path="$1" sidecar="${1}.assurance.json" + local manifest manifest_digest manifest_sha + manifest="$(dirname "$path")/gate-scope-manifest-fixture.json" + jq -n --slurpfile assurance "$sidecar" ' + $assurance[0].subject as $subject | { + kind:"gate_scope_manifest_v1", + schema_version:1, + status:"complete", + subject:{ + repository_key:$subject.repository.key, + base_commit:$subject.base.commit, + head_commit:$subject.head.commit, + tree_fingerprint:$subject.tree_fingerprint, + subject_kind:$subject.subject_kind + }, + selection:{ + diff_kind:( + if $subject.subject_kind == "fixed_ref" then "fixed-head" + elif $subject.subject_kind == "working_tree" then "working-tree" + else "committed" + end + ), + base_ref:$subject.base.ref, + head_ref:$subject.head.ref, + include_untracked:($subject.subject_kind == "working_tree") + }, + changes:{ + entries:[{ + status:"modified", + old_path:null, + new_path:"README.md", + similarity:null + }], + changed_paths:["README.md"], + renamed_paths:[], + untracked_paths:[] + }, + diff:{ + hunks:[{ + path:"README.md", + source:"tracked", + old_start:1, + old_lines:1, + new_start:1, + new_lines:1, + header:"@@ -1 +1 @@" + }], + binary_or_special_paths:[] + }, + paired_tests:[], + sensitive_signals:[], + flags:{ + public_interface:{matched:true,paths:["README.md"]}, + schema:{matched:false,paths:[]}, + config:{matched:false,paths:[]}, + install:{matched:false,paths:[]}, + ci:{matched:false,paths:[]}, + release:{matched:false,paths:[]}, + migration:{matched:false,paths:[]} + }, + expansion:{ + claim:"bounded-hints-not-complete-call-graph", + entries:[], + included_paths:[] + }, + truncation:{ + occurred:false, + budgets:{ + diff_hunks:512, + expansion_source_paths:256, + symbols_per_source:1024, + matches_per_query:64, + expansion_entries:512 + }, + omitted:{ + diff_hunks:0, + expansion_source_paths:0, + symbols_per_source:0, + matches_per_query:0, + expansion_entries:0 + }, + reasons:[], + acceptance:{required:false,accepted:false,source:null} + }, + content:{ + digest_algorithm:"sha256-canonical-json-without-content-digest", + digest:"" + } + } + ' > "${manifest}.tmp" + manifest_digest="$(jq -cS 'del(.content.digest)' "${manifest}.tmp" \ + | sha256sum | awk '{print $1}')" + jq --arg digest "$manifest_digest" '.content.digest = $digest' \ + "${manifest}.tmp" > "$manifest" + rm -f "${manifest}.tmp" + manifest_sha="$(sha256sum "$manifest" | awk '{print $1}')" + jq --arg artifact "$(basename "$manifest")" \ + --arg sha "$manifest_sha" ' + .evidence.scope_manifest = { + status:"verified", + artifact:$artifact, + sha256:$sha, + subject_fingerprint:.subject.tree_fingerprint + } + ' "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" +} + +_refresh_gate_scope_manifest_v3_links() { + local path="$1" sidecar="${1}.assurance.json" + local manifest manifest_digest manifest_sha + manifest="$(dirname "$path")/$(jq -r '.evidence.scope_manifest.artifact' \ + "$sidecar")" + manifest_digest="$(jq -cS 'del(.content.digest)' "$manifest" \ + | sha256sum | awk '{print $1}')" + jq --arg digest "$manifest_digest" '.content.digest = $digest' \ + "$manifest" > "${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" + manifest_sha="$(sha256sum "$manifest" | awk '{print $1}')" + jq --arg sha "$manifest_sha" '.evidence.scope_manifest.sha256 = $sha' \ + "$sidecar" > "${sidecar}.tmp" + mv "${sidecar}.tmp" "$sidecar" _refresh_gate_result_v3_attestation "$path" } @@ -859,6 +986,14 @@ case_verify_v3_policy_reason_codes() { closure) jq '.policy.consumer_policy = "maintainer"' "$sidecar" > "$fixture" ;; + scope) + jq '.evidence.scope_manifest = { + status:"unavailable", + artifact:null, + sha256:null, + subject_fingerprint:null + }' "$sidecar" > "$fixture" + ;; esac axis="$( gate_policy_applicability_assess "$fixture" "$consumer" verified @@ -882,6 +1017,7 @@ no-policy|policy_resolution_unavailable|generic enforcement|policy_enforcement_failed|generic independence|review_independence_unverified|generic dispatch|review_dispatch_evidence_incomplete|generic +scope|scope_manifest_unavailable|generic closure|closure_evidence_unavailable|publish CASES pass "$name" @@ -1011,6 +1147,7 @@ case_verify_v3_fixed_ref_ignores_working_tree() { .bindings.subject_fingerprint = $subject.tree_fingerprint ' "$sidecar" > "${sidecar}.tmp" mv "${sidecar}.tmp" "$sidecar" + _attach_gate_scope_manifest_v3 "$result" _refresh_gate_result_v3_attestation "$result" printf 'unrelated dirt\n' > "$_GATE_VERIFY_REPO/untracked.txt" set +e @@ -1090,10 +1227,10 @@ case_verify_v3_different_repo_same_content_is_stale() { } case_verify_v3_copy_replay_is_valid_but_not_authorizing() { - # Behavior: copying the self-contained result/sidecar pair preserves content - # validity and subject freshness, but protected dispatch applicability does - # not travel outside the canonical run partition. - local name="gate/verify: v3 copied pair is valid/current but not policy-authorizing" + # Behavior: copying the self-contained result/sidecar/evidence set preserves + # content validity and subject freshness, but protected dispatch + # applicability does not travel outside the canonical run partition. + local name="gate/verify: v3 copied artifact set is valid/current but not policy-authorizing" should_run "$name" || return 0 local result copied out code result="$(_gate_verify_result_path v3-copy-source)" @@ -1102,6 +1239,8 @@ case_verify_v3_copy_replay_is_valid_but_not_authorizing() { mkdir -p "$(dirname "$copied")" cp "$result" "$copied" cp "${result}.assurance.json" "${copied}.assurance.json" + cp "$(dirname "$result")/$(jq -r '.evidence.scope_manifest.artifact' \ + "${result}.assurance.json")" "$(dirname "$copied")/" set +e out="$( PM_DISPATCH_STATE_ROOT="$_GATE_VERIFY_STATE_ROOT" \ @@ -1149,6 +1288,154 @@ case_verify_v3_valid_but_policy_insufficient() { fi } +case_verify_v3_scope_policy_signal_mismatch_is_invalid() { + # Behavior: independently valid and re-attested scope evidence cannot + # authorize a result when its sensitive path signals differ from policy. + local name="gate/verify: v3 scope policy-signal mismatch invalidates artifact" + should_run "$name" || return 0 + local result sidecar manifest out report code + result="$(_gate_verify_result_path v3-scope-policy-signal-mismatch)" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + manifest="$(dirname "$result")/$(jq -r '.evidence.scope_manifest.artifact' \ + "$sidecar")" + jq '.sensitive_signals = [{ + id:"fixture-sensitive-path", + source:"path-regex", + matches:["README.md"], + minimum_tier:"express", + required_reviewers:["critic"], + recommended_mode:"sequential" + }]' "$manifest" > "${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" + _refresh_gate_scope_manifest_v3_links "$result" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + report="$(tail -n 1 <<<"$out")" + if [[ "$code" -eq 1 ]] \ + && [[ "$out" == *"sensitive signals do not match resolved policy"* ]] \ + && jq -e ' + .axes.artifact_valid.status == "fail" and + (.axes.artifact_valid.reason_codes | + index("artifact_integrity_failed")) != null + ' <<<"$report" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_incomplete_scope_manifest_is_invalid() { + # Behavior: recomputing every digest cannot turn an explicitly incomplete + # declared scope into authorizing linked evidence. + local name="gate/verify: v3 incomplete scope manifest invalidates artifact" + should_run "$name" || return 0 + local result sidecar manifest out report code + result="$(_gate_verify_result_path v3-incomplete-scope)" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + manifest="$(dirname "$result")/$(jq -r '.evidence.scope_manifest.artifact' \ + "$sidecar")" + jq ' + .status = "incomplete" | + .truncation.occurred = true | + .truncation.omitted.matches_per_query = 1 | + .truncation.reasons = ["search-match-budget"] | + .truncation.acceptance = { + required:true,accepted:false,source:null + } + ' "$manifest" > "${manifest}.tmp" + mv "${manifest}.tmp" "$manifest" + _refresh_gate_scope_manifest_v3_links "$result" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer generic --json 2>&1)" + code=$? + set -e + report="$(tail -n 1 <<<"$out")" + if [[ "$code" -eq 1 ]] \ + && [[ "$out" == *"incomplete gate scope manifest cannot authorize"* ]] \ + && jq -e ' + .axes.artifact_valid.status == "fail" and + (.axes.artifact_valid.reason_codes | + index("artifact_integrity_failed")) != null + ' <<<"$report" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + +case_verify_v3_scope_cross_field_mutations_are_invalid() { + # Behavior: a fresh manifest digest, assurance digest, and attestation cannot + # authorize cross-field claims that disagree with the manifest's own inputs. + # Steps: + # 1. Build an independently valid v3 artifact for each mutation. + # 2. Corrupt one entry shape, derived path set, flag, hunk, or truncation + # relationship and recompute every binding digest. + # 3. Assert pmctl gate verify rejects every re-attested artifact. + local name="gate/verify: v3 re-attested scope cross-field mutations are invalid" + should_run "$name" || return 0 + local mutation slug result sidecar manifest out report code + while IFS= read -r mutation; do + slug="${mutation//_/-}" + result="$(_gate_verify_result_path "v3-scope-$slug")" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + manifest="$(dirname "$result")/$(jq -r \ + '.evidence.scope_manifest.artifact' "$sidecar")" + case "$mutation" in + entry_shape) + jq '.changes.entries[0].old_path = "README.md"' \ + "$manifest" > "${manifest}.tmp" + ;; + changed_path_set) + jq '.changes.changed_paths += ["ghost.txt"]' \ + "$manifest" > "${manifest}.tmp" + ;; + flag_membership) + jq '.flags.public_interface = { + matched:true,paths:["ghost.txt"] + }' "$manifest" > "${manifest}.tmp" + ;; + hunk_membership) + jq '.diff.hunks[0].path = "ghost.txt"' \ + "$manifest" > "${manifest}.tmp" + ;; + truncation_coherence) + jq '.truncation.omitted.matches_per_query = 1' \ + "$manifest" > "${manifest}.tmp" + ;; + esac + mv "${manifest}.tmp" "$manifest" + _refresh_gate_scope_manifest_v3_links "$result" + set +e + out="$(_run_canonical_gate_verify "$result" \ + --consumer generic --json 2>&1)" + code=$? + set -e + report="$(tail -n 1 <<<"$out")" + if [[ "$code" -ne 1 ]] \ + || [[ "$out" != *"scope manifest failed structural/claim verification"* ]] \ + || ! jq -e ' + .axes.artifact_valid.status == "fail" and + (.axes.artifact_valid.reason_codes | + index("artifact_integrity_failed")) != null + ' <<<"$report" >/dev/null; then + fail "$name" "$mutation code=$code out=$out" + return + fi + done <<'CASES' +entry_shape +changed_path_set +flag_membership +hunk_membership +truncation_coherence +CASES + pass "$name" +} + case_verify_v3_linked_evidence_digest_tamper_is_invalid() { # Behavior: a linked evidence digest is part of artifact integrity, distinct # from whether the otherwise immutable subject is still current. @@ -2222,6 +2509,9 @@ case_verify_v3_linked_worktree_path_is_current case_verify_v3_different_repo_same_content_is_stale case_verify_v3_copy_replay_is_valid_but_not_authorizing case_verify_v3_valid_but_policy_insufficient +case_verify_v3_scope_policy_signal_mismatch_is_invalid +case_verify_v3_incomplete_scope_manifest_is_invalid +case_verify_v3_scope_cross_field_mutations_are_invalid case_verify_v3_linked_evidence_digest_tamper_is_invalid case_verify_v3_subject_binding_mismatch_is_invalid case_verify_v3_linked_preflight_subject_claim_mismatch_is_invalid diff --git a/tests/shell/test-pr-gate.sh b/tests/shell/test-pr-gate.sh index 691e494f..5917c66d 100755 --- a/tests/shell/test-pr-gate.sh +++ b/tests/shell/test-pr-gate.sh @@ -95,6 +95,16 @@ done reviewer_name="$(awk '$1 == "Reviewer:" { print $2; exit }' "$brief_file")" : "${reviewer_name:=stub-reviewer}" +if [[ -n "${CODEX_GATE_CAPTURE_SCOPE_DIR:-}" ]]; then + mkdir -p "$CODEX_GATE_CAPTURE_SCOPE_DIR" + capture_name="$reviewer_name" + [[ "$brief_file" == *-synthesis.md ]] && capture_name=synthesis + scope_digest="$(awk '$1 == "artifact_sha256:" { print $2; exit }' "$brief_file")" + scope_artifact="$(awk '$1 == "artifact:" { print $2; exit }' "$brief_file")" + printf '%s\t%s\n' "$scope_digest" "$scope_artifact" \ + > "$CODEX_GATE_CAPTURE_SCOPE_DIR/$capture_name" +fi + printf 'DISPATCH_STUB:%s\n' "${CODEX_GATE_STUB_MODE:-success}" if [[ -n "${CODEX_GATE_BRIEF_EXISTS_MARKER:-}" ]]; then @@ -174,6 +184,12 @@ if [[ "${CODEX_GATE_STUB_TAMPER_PREFLIGHT:-}" == "1" && "$brief_file" != *-synth [[ -n "$evidence_path" ]] && printf '\n' >> "$evidence_path" fi +# Simulate reviewer-side tampering with the machine-owned declared scope. +if [[ "${CODEX_GATE_STUB_TAMPER_SCOPE:-}" == "1" && "$brief_file" != *-synthesis.md ]]; then + scope_path=$(awk '$1 == "artifact:" { print $2; exit }' "$brief_file") + [[ -n "$scope_path" ]] && printf '\n' >> "$scope_path" +fi + # Simulate prefix-only verdict (loose regex bypass): writes "Verdict: approved" (invalid token # with the right prefix) to verify the anchored regex rejects it. # CODEX_GATE_STUB_VERDICT_PREFIX_ONLY=1: write an invalid prefix verdict instead of a valid one. @@ -3345,6 +3361,37 @@ test_preflight_artifact_tamper_aborts_gate() { fi } +# Behavior: the manifest artifact is re-hashed during assurance finalization, +# so a reviewer cannot change declared scope while retaining the original +# digest supplied to every selected reviewer. +# Steps: +# 1. Run a sequential gate whose adapter appends to the scope artifact. +# 2. Assert finalization rejects the linked digest before reporting success. +test_scope_manifest_tamper_aborts_gate() { + local name="scope-manifest/reviewer-tamper-aborts-gate" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" home repo runner out err result + home="$dir/home"; repo="$dir/repo"; runner="$dir/runner" + out="$dir/out"; err="$dir/err"; result="$dir/result.md" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo "$repo" docs + + set +e + CODEX_GATE_STUB_TAMPER_SCOPE=1 run_gate \ + "$home" "$runner" "$repo" "$out" "$err" \ + --base main --mode sequential --output "$result" + local code=$? + set -e + if [[ "$code" -ne 0 ]] \ + && grep -Fq "linked scope_manifest evidence digest mismatch" "$err"; then + pass "$name" + else + fail "$name" "tampered scope manifest was accepted: code=$code err=$(cat "$err")" + fi +} + # Behavior: valid structured coverage is summarized mechanically in the brief, # including each selected suite and the no-reflexive-rerun contract. test_preflight_structured_result_is_reused_in_brief() { @@ -3381,7 +3428,7 @@ PRODUCER set +e CODEX_GATE_CAPTURE_BRIEF="$brief" run_gate "$home" "$runner" "$repo" "$out" "$err" \ - --base main --test-cmd "./produce-result.sh" --output "$result" + --base main --mode sequential --test-cmd "./produce-result.sh" --output "$result" local code=$? set -e [[ "$code" -eq 0 ]] || { fail "$name" "exit $code, expected 0: $(cat "$err")"; return; } @@ -3879,7 +3926,12 @@ test_gate_result_frontmatter_and_escalation() { .bindings.head_commit == .subject.head.commit and .bindings.subject_fingerprint == .subject.tree_fingerprint and .evidence.preflight.status == "not_run" and - .evidence.scope_manifest.status == "unavailable" and + .evidence.scope_manifest.status == "verified" and + (.evidence.scope_manifest.artifact | + test("^gate-scope-manifest-[0-9]{8}-[0-9]{6}\\.json$")) and + (.evidence.scope_manifest.sha256 | test("^[a-f0-9]{64}$")) and + .evidence.scope_manifest.subject_fingerprint == + .subject.tree_fingerprint and .evidence.closure.status == "unavailable" and .coordinates.mode.resolved == "sequential" and .coordinates.independence.evidence_status == "unavailable" and @@ -4603,6 +4655,305 @@ test_untracked_binary_routes_to_standard() { pass "$name" } +# Behavior: the producer emits one complete manifest that binds the immutable +# subject, preserves rename/untracked inputs, and adds only explained bounded +# review hints; every parallel reviewer and synthesis receive its same digest. +# Steps: +# 1. Create a feature diff with a rename, shared-helper edit, paired test, +# sensitive old path, and untracked schema/migration inputs. +# 2. Run a parallel gate and inspect the linked machine-owned manifest. +# 3. Assert its self-digest, assurance link, flags/signals/expansions, and the +# digest captured independently from every dispatch brief. +test_scope_manifest_complete_and_shared_across_parallel_dispatch() { + local name="scope-manifest/complete-and-shared-parallel" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" + local runner="$dir/runner" out="$dir/out" err="$dir/err" + local captures="$dir/scope-captures" result assurance manifest + local artifact_digest content_digest captured_count captured_unique + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + git init -q -b main "$repo" + ( + cd "$repo" + git config user.email test@example.com + git config user.name 'Gate Test' + mkdir -p runtime/lib runtime/bin tests/shell app + write_managed_gitignore + printf 'shared_token() { printf old; }\n' > runtime/lib/shared.sh + printf '# Shared helper contract\n' > runtime/lib/shared.md + printf '. runtime/lib/shared.sh\nshared_token\n' > runtime/bin/use-shared.sh + printf '#!/usr/bin/env bash\nshared_token\n' > tests/shell/test-shared.sh + printf 'export const authenticate = true;\n' > app/auth.ts + git add . + git commit -q -m initial + git checkout -q -b feature + git mv app/auth.ts app/login.ts + printf 'shared_token() { printf new; }\n' > runtime/lib/shared.sh + git add app/login.ts runtime/lib/shared.sh + git commit -q -m change + mkdir -p core/schema migrations + printf '{"type":"object"}\n' > core/schema/sample.schema.json + printf 'ALTER TABLE example ADD COLUMN active boolean;\n' > migrations/001.sql + ) + + set +e + CODEX_GATE_CAPTURE_SCOPE_DIR="$captures" \ + run_gate "$home" "$runner" "$repo" "$out" "$err" \ + --base main --allow-dirty --mode parallel + local code=$? + set -e + [[ "$code" -eq 0 ]] || { + fail "$name" "exit $code, expected 0: $(tail -n 30 "$err" 2>/dev/null)" + return + } + result="$(awk -F'result: ' '/^result: /{path=$2} END{print path}' "$out")" + assurance="${result}.assurance.json" + [[ -s "$assurance" ]] || { + fail "$name" "missing assurance sidecar for $result" + return + } + manifest="$(dirname "$assurance")/$(jq -r '.evidence.scope_manifest.artifact' "$assurance")" + [[ -s "$manifest" ]] || { + fail "$name" "missing linked scope manifest" + return + } + if ! jq -e ' + .kind == "gate_scope_manifest_v1" and + .schema_version == 1 and .status == "complete" and + (.changes.entries | any( + .status == "renamed" and + .old_path == "app/auth.ts" and .new_path == "app/login.ts")) and + (.changes.untracked_paths == [ + "core/schema/sample.schema.json", + "migrations/001.sql" + ]) and + (.paired_tests | any( + .source_path == "runtime/lib/shared.sh" and + .test_path == "tests/shell/test-shared.sh")) and + ([.sensitive_signals[].id] | + index("security-sensitive-path") != null and + index("risk-sensitive-path") != null and + index("public-contract-path") != null) and + .flags.public_interface.matched and .flags.schema.matched and + .flags.migration.matched and + .expansion.claim == "bounded-hints-not-complete-call-graph" and + (.expansion.entries | any( + .path == "runtime/lib/shared.md" and + .reason == "same-stem-peer")) and + (.expansion.entries | any( + .path == "runtime/bin/use-shared.sh" and + .reason == "shared-helper-consumer")) and + (.expansion.entries | any( + .path == "runtime/bin/use-shared.sh" and + .reason == "call-site-hint")) and + (.truncation.occurred == false) + ' "$manifest" >/dev/null; then + fail "$name" "manifest omitted required scope facts: $(jq -c '{ + status,changes,paired_tests,sensitive_signals,flags,expansion,truncation + }' "$manifest" 2>/dev/null)" + return + fi + artifact_digest="$(sha256sum "$manifest" | awk '{print $1}')" + content_digest="$(jq -cS 'del(.content.digest)' "$manifest" | sha256sum | awk '{print $1}')" + [[ "$content_digest" == "$(jq -r '.content.digest' "$manifest")" ]] || { + fail "$name" "manifest self-digest mismatch" + return + } + if ! jq -e --arg digest "$artifact_digest" \ + --arg subject "$(jq -r '.subject.tree_fingerprint' "$manifest")" ' + .evidence.scope_manifest.status == "verified" and + .evidence.scope_manifest.sha256 == $digest and + .evidence.scope_manifest.subject_fingerprint == $subject + ' "$assurance" >/dev/null; then + fail "$name" "assurance did not bind the scope manifest" + return + fi + captured_count="$(find "$captures" -type f | wc -l | tr -d ' ')" + captured_unique="$(cut -f1 "$captures"/* | sort -u | wc -l | tr -d ' ')" + [[ "$captured_count" -ge 2 && "$captured_unique" -eq 1 ]] || { + fail "$name" "dispatch briefs did not share one manifest digest" + return + } + [[ "$(cut -f1 "$captures"/* | head -n 1)" == "$artifact_digest" ]] || { + fail "$name" "brief digest did not match the linked artifact" + return + } + pass "$name" +} + +# Behavior: the manifest producer transports a maximum-size expansion through +# a file descriptor instead of one jq argv value, preserving every entry even +# when the serialized array exceeds Linux MAX_ARG_STRLEN. +# Steps: +# 1. Create one changed source with eight symbols and 64 callers per symbol. +# 2. Run the gate so the bounded expansion contains exactly 512 entries. +# 3. Assert the complete serialized expansion exceeds 128 KiB and dispatch +# succeeds without an argv-size failure. +test_scope_manifest_large_expansion_uses_file_input() { + local name="scope-manifest/large-expansion-uses-file-input" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" + local runner="$dir/runner" out="$dir/out" err="$dir/err" + local long_stem source_path symbol result assurance manifest + local expansion_bytes code + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + git init -q -b main "$repo" + long_stem="$(printf 's%.0s' {1..220})" + source_path="src/${long_stem}.sh" + ( + cd "$repo" + git config user.email test@example.com + git config user.name 'Gate Test' + mkdir -p src callers + write_managed_gitignore + printf '# old implementation\n' > "$source_path" + for n in $(seq -w 1 64); do + for symbol in $(seq -w 1 8); do + printf 'scope_expansion_symbol_%s\n' "$symbol" + done > "callers/call-${n}.sh" + done + git add . + git commit -q -m initial + git checkout -q -b feature + for symbol in $(seq -w 1 8); do + printf 'scope_expansion_symbol_%s() { :; }\n' "$symbol" + done > "$source_path" + git add "$source_path" + git commit -q -m change + ) + + set +e + run_gate "$home" "$runner" "$repo" "$out" "$err" \ + --base main --mode sequential + code=$? + set -e + [[ "$code" -eq 0 ]] || { + fail "$name" "exit $code, expected 0: $(tail -n 30 "$err" 2>/dev/null)" + return + } + result="$(awk -F'result: ' '/^result: /{path=$2} END{print path}' "$out")" + assurance="${result}.assurance.json" + manifest="$(dirname "$assurance")/$(jq -r '.evidence.scope_manifest.artifact' "$assurance")" + expansion_bytes="$(jq -c '.expansion.entries' "$manifest" | wc -c | tr -d ' ')" + if ! jq -e ' + .status == "complete" and + (.expansion.entries | length) == 512 and + .truncation.occurred == false + ' "$manifest" >/dev/null \ + || [[ "$expansion_bytes" -le 131072 ]]; then + fail "$name" "large expansion was narrowed or too small: entries=$(jq -r '.expansion.entries | length' "$manifest" 2>/dev/null) bytes=$expansion_bytes" + return + fi + assert_not_contains "$name" "$err" "Argument list too long" || return + pass "$name" +} + +create_scope_truncation_repo() { + local repo="$1" + git init -q -b main "$repo" + ( + cd "$repo" + git config user.email test@example.com + git config user.name 'Gate Test' + write_managed_gitignore + for n in $(seq 1 1026); do + printf 'old line %s\n' "$n" + done > large.txt + git add . + git commit -q -m initial + git checkout -q -b feature + for n in $(seq 1 1026); do + if (( n % 2 == 1 )); then + printf 'new line %s\n' "$n" + else + printf 'old line %s\n' "$n" + fi + done > large.txt + git add large.txt + git commit -q -m change + ) +} + +# Behavior: a scope budget overflow is never silently narrowed; dispatch stops +# as INCOMPLETE unless the operator explicitly accepts the recorded omissions. +# Steps: +# 1. Create 513 distinct zero-context diff hunks against a 512-hunk budget. +# 2. Assert the default run exits 3 before dispatch with an incomplete artifact. +# 3. Repeat with the explicit acceptance flag and assert the linked manifest +# records accepted_truncation plus the exact omitted count and reason. +test_scope_manifest_truncation_requires_explicit_acceptance() { + local name="scope-manifest/truncation-requires-explicit-acceptance" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local runner="$dir/runner" home="$dir/home" + local repo_reject="$dir/reject-repo" repo_accept="$dir/accept-repo" + local out="$dir/out" err="$dir/err" result assurance manifest code + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_scope_truncation_repo "$repo_reject" + + set +e + run_gate "$home" "$runner" "$repo_reject" "$out" "$err" \ + --base main --mode sequential + code=$? + set -e + [[ "$code" -eq 3 ]] || { + fail "$name" "unaccepted truncation exit $code, expected 3" + return + } + assert_file_contains "$name" "$err" "INCOMPLETE: declared scope exceeded" || return + assert_not_contains "$name" "$err" "DISPATCH_STUB" || return + manifest="$(find "$repo_reject/.gate-results" \ + -maxdepth 1 -name 'gate-scope-manifest-*.json' -print -quit)" + if ! jq -e ' + .status == "incomplete" and + .truncation.occurred and + .truncation.omitted.diff_hunks == 1 and + .truncation.reasons == ["diff-hunk-budget"] and + .truncation.acceptance == { + required:true,accepted:false,source:null + } + ' "$manifest" >/dev/null; then + fail "$name" "unaccepted truncation artifact was not truthful" + return + fi + + create_scope_truncation_repo "$repo_accept" + set +e + run_gate "$home" "$runner" "$repo_accept" "$out" "$err" \ + --base main --mode sequential --accept-scope-truncation + code=$? + set -e + [[ "$code" -eq 0 ]] || { + fail "$name" "accepted truncation exit $code, expected 0: $(tail -n 30 "$err" 2>/dev/null)" + return + } + result="$(awk -F'result: ' '/^result: /{path=$2} END{print path}' "$out")" + assurance="${result}.assurance.json" + manifest="$(dirname "$assurance")/$(jq -r '.evidence.scope_manifest.artifact' "$assurance")" + if ! jq -e ' + .status == "accepted_truncation" and + .truncation.omitted.diff_hunks == 1 and + .truncation.reasons == ["diff-hunk-budget"] and + .truncation.acceptance == { + required:true, + accepted:true, + source:"--accept-scope-truncation" + } + ' "$manifest" >/dev/null; then + fail "$name" "explicit acceptance was not recorded in the manifest" + return + fi + pass "$name" +} + run_test test_gate_assurance_policy_snapshot_matches_sources run_test test_gate_policy_sources_control_default_coverage run_test test_gate_assurance_policy_snapshot_is_copy_mode_fallback @@ -4646,6 +4997,9 @@ run_test test_via_symlink run_test test_rename_sensitive_old_name run_test test_binary_file_routes_to_standard run_test test_untracked_binary_routes_to_standard +run_test test_scope_manifest_complete_and_shared_across_parallel_dispatch +run_test test_scope_manifest_large_expansion_uses_file_input +run_test test_scope_manifest_truncation_requires_explicit_acceptance run_test test_parallel_launches_per_reviewer run_test test_parallel_timeout_kills_hanging_reviewer run_test test_parallel_timeout_kills_hanging_synthesis @@ -4686,6 +5040,7 @@ run_test test_preflight_tree_drift_marks_evidence_stale run_test test_preflight_untracked_drift_marks_evidence_stale run_test test_preflight_invalid_rich_result_fails_closed run_test test_preflight_artifact_tamper_aborts_gate +run_test test_scope_manifest_tamper_aborts_gate run_test test_preflight_structured_result_is_reused_in_brief run_test test_parallel_frontmatter_parity_mismatch_aborts_gate run_test test_prompt_injection_detected diff --git a/tools/generate-gate-result-verifier-fallback.sh b/tools/generate-gate-result-verifier-fallback.sh index d710d97e..6166f78f 100755 --- a/tools/generate-gate-result-verifier-fallback.sh +++ b/tools/generate-gate-result-verifier-fallback.sh @@ -31,6 +31,7 @@ functions=( _gate_subject_common_dir _gate_subject_tree_fingerprint gate_subject_snapshot + gate_scope_manifest_verify _gate_assurance_linked_evidence_verify gate_assurance_verify gate_result_verify