diff --git a/README.md b/README.md index 581e53e..fd01aee 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ title: system-config category: reference component: overview status: active -version: 5.0.0 -last_updated: 2026-07-26 +version: 5.1.0 +last_updated: 2026-08-13 tags: [overview, chezmoi, mise, zsh, mcp, vscode, policy, agents] priority: critical --- @@ -40,6 +40,7 @@ never committed — secrets live in 1Password and are resolved at launch. | Terminal | 14 iTerm2 dynamic profiles + color presets | `iterm2/`, `scripts/install-iterm2-profiles.sh` | | Editor | reviewed subset of VS Code user settings, profiles, extensions | `vscode/`, `scripts/sync-vscode.sh` | | MCP | user-level server baseline across 7 hosts. For Claude Desktop this is the **managed `mcpServers` block only** — the app's `preferences`, `globalShortcut` and `coworkUserFilesPath` are preserved, and the app is a concurrent writer of the same file ([`docs/claude-macos-app.md`](docs/claude-macos-app.md)) | `scripts/mcp-servers.json`, `scripts/sync-mcp.sh` | +| Git identity | shared commit/push gate, runtime adapters, and a fail-open SessionStart mismatch announcer; fail-silent live hook registration remains operator-controlled | `home/dot_local/bin/executable_git-identity-*`, `docs/git-identity.md` | | Agent permissions | root-owned unconditional deny floor for Claude Code | `macos/claude-code/` | | Policy | canonical live policy consumed by a sibling repo | `policies/host-capability-substrate/` | | Maintenance | system update orchestration, health reporting | `scripts/system-update.sh`, `ng-doctor` | @@ -58,9 +59,11 @@ resolve server-side (§1, class "Account-managed") — both of which carry real authorization and are invisible to every mechanism below. Enumeration there is legibility, not governance. -1. **chezmoi** — `home/` is the source tree; 78 managed targets under `$HOME`. - Go templates, not Jinja2. Guard possibly absent map keys with `hasKey` before - reading them; `| default` cannot recover from `missingkey=error`. +1. **chezmoi** — `home/` is the source tree. Measure the current managed-target + inventory with `chezmoi managed`; do not copy its mutable total into general + architecture prose. Go templates, not Jinja2. Guard possibly absent map keys + with `hasKey` before reading them; `| default` cannot recover from + `missingkey=error`. 2. **Repo scripts** — `scripts/sync-mcp.sh`, `scripts/sync-vscode.sh`, `scripts/install-iterm2-profiles.sh`, `scripts/mcp-usage-collector.sh`. These write files chezmoi does not own, usually because the host application @@ -124,13 +127,15 @@ Owned elsewhere, deliberately: named templates - `jq` shape checks on the MCP and VS Code JSON manifests - `tests/system-update`, `tests/android`, `tests/vscode`, `tests/mcp-vscode`, - `tests/mcp-codex`, `tests/mcp-claude-desktop` + `tests/mcp-codex`, `tests/mcp-claude-desktop`, `tests/git-identity-gate` +- an isolated production-equivalent SessionStart launcher with a hostile outer + environment; no live agent configuration is read or changed - `scripts/policy-lint.sh`, `scripts/check-instruction-bridge.sh` - repo-contract greps (no fish surface, no stale path references) -**Known coverage gaps, stated rather than implied:** four of the ten suites — -`tests/git-identity-gate`, `tests/jr-style`, `tests/mise-config`, -`tests/policies` — are not invoked by the gate. `ng-doctor` reports 101 checks, +**Known coverage gaps, stated rather than implied:** three of the ten suites — +`tests/jr-style`, `tests/mise-config`, and `tests/policies` — are not invoked by +the gate. `ng-doctor` reports 101 checks, a number of which are unimplemented skip stubs rather than assertions, so a green summary is not by itself evidence of a healthy host. There is no secret or PII scanner in the gate. diff --git a/docs/agentic-hook-enforcement.md b/docs/agentic-hook-enforcement.md index 2f5c8f0..ae71149 100644 --- a/docs/agentic-hook-enforcement.md +++ b/docs/agentic-hook-enforcement.md @@ -3,8 +3,8 @@ title: Agentic Hook Enforcement Surfaces category: reference component: agentic_hook_enforcement status: active -version: 0.8.0 -last_updated: 2026-08-01 +version: 0.9.0 +last_updated: 2026-08-13 tags: [agentic, hooks, git-identity, claude, codex, copilot, cursor, devin, enforcement] priority: high --- @@ -30,6 +30,12 @@ is actually installed here + the items still to verify. > Codex/Cursor/Copilot were verified independently in a separate project. Devin > Desktop is installed, but neither its Cascade hook nor a standalone Devin CLI > hook is wired on this workstation. +> +> **Q1b is source-only in this change.** The shared Claude/Codex SessionStart +> announcer is built and covered by the synthetic suite, but it has not been +> deployed, appended to either live runtime config, reviewed for Codex trust, or +> live-accepted. All activation claims above remain specifically about the +> existing PreToolUse enforcement. This is a hook-mechanics doc; it carries **no identity values** (those stay in meta-inventory's internal registry — see the git-identity HARD RULES). @@ -50,8 +56,10 @@ meta-inventory's internal registry — see the git-identity HARD RULES). | Surface | Blocks? | Event / matcher | Input shape (command location) | Deny mechanism | |---|---|---|---|---| | Claude Code (CLI, Code tab, Cowork, remote) | yes | `PreToolUse` / `Bash` | `tool_name`, `tool_input.command`, top-level `cwd` | exit 2 + stderr | +| Claude Code session context | advisory | `SessionStart` | top-level absolute `cwd` | exit 0 + privacy-safe `systemMessage` / `hookSpecificOutput.additionalContext` | | Claude **Chat** tab / Claude Desktop chat | no | — (MCP only) | — | — | | Codex CLI | yes¹ | `PreToolUse` / `Bash` | `tool_name`, `tool_input.command`, `cwd` | exit 2 / `permissionDecision:"deny"` | +| Codex session context | advisory | `SessionStart` | top-level absolute `cwd` | exit 0 + privacy-safe `systemMessage` / `hookSpecificOutput.additionalContext` | | Copilot CLI | yes² | `preToolUse` (or PascalCase `PreToolUse`) / `bash` | camelCase `toolName`/`toolArgs.command`; **or** PascalCase `tool_name`/`tool_input.command` | **exit 0 + stdout `{"permissionDecision":"deny"}`** (exit 2 ≠ block) | | Cursor (agent) | partial³ | `beforeShellExecution` | top-level `command`, `cwd`, `workspace_roots` | `{"permission":"deny"}` | | Devin Desktop (Cascade) | yes | `pre_run_command` | `agent_action_name`, `tool_info.command_line`, `tool_info.cwd` | exit 2 | @@ -88,23 +96,36 @@ not registered as a workstation command. Its shell tool name is `exec`, not ## Adapter model -The engine is reused unchanged; only the per-runtime **adapter** differs. All -adapters share `git_identity_hooklib.py` (deployed to `~/.local/lib/`) for the -`shlex` command→repo resolver and the gate invocation. Deployed files: +The legacy gate policy and PreToolUse protocol are reused unchanged; only the +per-runtime **adapter** differs. Q1b adds a separate machine rendering of the +same evaluation for SessionStart. All enforcement adapters share +`git_identity_hooklib.py` (deployed to `~/.local/lib/`) for the `shlex` +command→repo resolver and the legacy gate invocation. Deployed files: | File (`~/.local/bin/`) | Runtimes | Input | Deny | |---|---|---|---| -| `git-identity-gate` | — (engine) | a repo path | exit 2 on mismatch | +| `git-identity-gate` | — (engine) | legacy repo path only; SessionStart loads callable `run_machine_v1()` outside the CLI grammar | legacy exit 2 unchanged; programmatic `confirmed_mismatch` only after strict registry qualification | | `git-identity-pretooluse` | **Claude Code, Codex** | `tool_name:"Bash"`, `tool_input.command`, `cwd` | exit 2 | | `git-identity-cursor` | **Cursor** | top-level `command`, `cwd` | stdout `{"permission":"deny"}` | | `git-identity-copilot` | **Copilot** | PascalCase `tool_name`/`tool_input` or camelCase `toolName`/`toolArgs` | **exit 0** + stdout `{"permissionDecision":"deny"}` | +| `git-identity-sessionstart` | **Claude Code, Codex** | SessionStart JSON with top-level absolute `cwd` | **exit 0**; warning JSON only for a confirmed mismatch | `git-identity-pretooluse` also covers **Devin CLI** input (matcher `exec`) once that command is installed. **Devin Desktop Cascade** (`tool_info.command_line`) needs a fifth adapter. Every adapter **fails open** on any error. -Tests: `tests/git-identity-gate/run.sh` (26 cases — engine, all adapter input -shapes, allow/block/advisory). Skips if the registry is absent. +Tests: `tests/git-identity-gate/run.sh` covers the engine, all enforcement +adapter input shapes, exact legacy compatibility, SessionStart +schema/privacy/fail-open behavior, machine-protocol qualification, a complete +production environment allowlist, environment poisoning, and real-gate +integration. The suite generates synthetic registries and temporary +repositories; it does not read the live private registry. The programmatic +machine entrypoint does not alter legacy CLI or PreToolUse decisions, argv, +statuses, or diagnostics. When `Q1B_PRODUCER_CHECKER` explicitly names the +producer checker, the differential receipt requires its normal success or +contract-failure completion marker and reports checker errors separately. With +that variable unset, the external differential is an explicit skip rather than +an inferred producer-acceptance result. ### Wiring (per-tool; current local state annotated, 2026-06-22) @@ -117,6 +138,33 @@ shapes, allow/block/advisory). Skips if the registry is absent. // {"version":1,"hooks":{"preToolUse":[{"type":"command","bash":"~/.local/bin/git-identity-copilot"}]}} [WIRED 2026-06-22 (~/.copilot/hooks/git-identity.json) + firing verified] ``` +Q1b adds no tracked hook configuration. After targeted owner deployment, the +owner manually appends one native `SessionStart` command entry to each of +`~/.claude/settings.json` and `~/.codex/hooks.json`, using the exact sanitized +command shown separately for both runtimes in the Q1b runbook. That registration +command is the production entry boundary: `/usr/bin/env -i` clears the ambient +environment before `/usr/bin/python3` invokes Apple's Xcode shim; +`2>/dev/null || :` suppresses pre-main diagnostics and converts pre-main +interpreter failure into a silent status-zero allow without consuming valid +warning JSON on stdout. Directly executing the Python file is not equivalent +production evidence because hostile Apple developer-tool selectors can affect +the shim before Python code begins. + +Once started, the announcer derives canonical `HOME` through the passwd API, +resolves `sys.executable`, and accepts the resolved interpreter only when the +target is a root-owned executable regular file with no group/world write bit. +It constructs a separate child environment containing only `HOME`, +`PATH=/usr/bin:/bin`, `LANG=C`, and `LC_ALL=C`, then uses the validated resolved +interpreter and a fixed `runpy` bootstrap to call the sibling gate's +`run_machine_v1()`. The direct gate CLI remains entirely legacy. + +The append must leave both existing PreToolUse registrations and Claude's +existing divergence SessionStart entry byte-for-byte unchanged. The owner then +performs Codex trust review and fresh-session wrong-root/correct-root acceptance. +None of those runtime actions or outcomes is claimed by this source change; the +exact commands and receipt requirements are in +[`docs/git-identity.md`](./git-identity.md#q1b-sourceruntime-ownership-handoff). + ## Backstop on GitHub.com (correction) Custom **server-side `pre-receive` hooks are GitHub *Enterprise Server* only — @@ -191,6 +239,10 @@ the agent only sees a **generic "blocked by a hook"** message, not the hook's ## Unresolved — verify before trusting (do not promote to "truth") +- **Q1b live activation:** source and synthetic tests are complete; targeted + deployment, the two manual SessionStart appends, Codex trust review, and live + wrong-root/correct-root acceptance remain owner work. Until those receipts + exist, do not claim the announcer is installed, trusted, or firing. - **Copilot project MCP key**: `.mcp.json` / `.github/mcp.json` — top-level `servers` (VS Code style) vs `mcpServers` (Copilot user-file style) is unresolved; verify against the installed CLI. @@ -207,7 +259,7 @@ the agent only sees a **generic "blocked by a hook"** message, not the hook's ## Staged plan (grounded in what's installed) 1. **Docs (this).** Verified matrix + corrections; unresolved items marked. ✅ -2. **Tier-1 adapters** — built + tested (26-case suite): Claude/Codex +2. **Tier-1 adapters** — built + tested: Claude/Codex (`git-identity-pretooluse`), Cursor (`git-identity-cursor`), Copilot (`git-identity-copilot`) + shared `git_identity_hooklib.py`. ✅ 3. **Tier-2 (document only):** Devin CLI and Devin Desktop Cascade (not wired). @@ -217,6 +269,9 @@ the agent only sees a **generic "blocked by a hook"** message, not the hook's (`copilot -p`, deny-via-stdout) — Codex/Cursor/Copilot in a separate project. #2893/#2392 remain known-open Copilot caveats (guardrail, not boundary). Only remaining open item: Cursor's crash/timeout fail-behavior. +5. **Q1b SessionStart announcement:** source + synthetic coverage done; + owner deployment, two native registrations, Codex trust review, and live + acceptance pending. The dual PreToolUse architecture is unchanged. ## Related diff --git a/docs/ai-tool-config-ownership.md b/docs/ai-tool-config-ownership.md index 1d0cc34..a8ca1be 100644 --- a/docs/ai-tool-config-ownership.md +++ b/docs/ai-tool-config-ownership.md @@ -3,8 +3,8 @@ title: AI Tool Configuration Ownership Map category: reference component: ai_tool_config_ownership status: active -version: 1.3.2 -last_updated: 2026-08-08 +version: 1.4.0 +last_updated: 2026-08-13 tags: [claude, codex, cursor, devin, copilot, gemini, aider, mcp, chezmoi, ownership, managed-settings, git-identity] priority: high --- @@ -16,8 +16,9 @@ describes one tool's semantics; this one describes the ownership boundary across all of them, and it is the document to read before editing any agent config file on this host. -**The one-sentence answer:** chezmoi manages 77 entries and *zero* of them are -AI-tool config; `scripts/sync-mcp.sh` renders one managed user-level MCP +**The one-sentence answer:** chezmoi's current target inventory is measured with +`chezmoi managed`, and *zero* of those targets are AI-tool config; +`scripts/sync-mcp.sh` renders one managed user-level MCP inventory into seven native host files; `macos/` sudo-installs one root-owned deny floor; everything else at global-user scope is app-written or hand-written, including every git-identity hook *registration*. @@ -88,7 +89,7 @@ currently overstate. | # | Mechanism | Writes | Scope | | --- | --- | --- | --- | -| 1 | **chezmoi** `home/` → `~/` **[V]** | 5 `mcp-*` wrappers, 4 `git-identity-*` adapters, `git_identity_hooklib.py`, `ng-doctor`, `system-update`, `agentic`, `workspace`, `workspace-doctor`, `jr-style.sh`, `~/.config/mcp/common.env` | Executables + one manifest. **No AI-tool config file.** | +| 1 | **chezmoi** `home/` → `~/` | 5 `mcp-*` wrappers, 4 deployed `git-identity-*` executables **[V]** plus the source-only Q1b `git-identity-sessionstart` pending owner deployment **[D]**, `git_identity_hooklib.py`, `ng-doctor`, `system-update`, `agentic`, `workspace`, `workspace-doctor`, `jr-style.sh`, `~/.config/mcp/common.env` | Executables + one manifest. **No AI-tool config file.** | | 2 | **`scripts/sync-mcp.sh`** **[V]** | the managed MCP inventory in 7 host-native files (§2.1) | The only writer of AI-host config | | 3 | **`scripts/sync-vscode.sh`** **[A]** | 54 managed keys + 6 retired-key deletions in one file | VS Code user settings | | 4 | **`macos/claude-code/` + `sudo install`** **[V]** | `/Library/Application Support/ClaudeCode/managed-settings.json` | Root deny floor | @@ -358,13 +359,14 @@ settings parse *strictly* (whole file rejected) **[D]**. This is the single most consequential ownership finding, and it is structural rather than a bug. -The repo ships **all four** git-identity adapters via chezmoi **[V]** and owns -**none of the four registration files** that arm them **[A]**: +The repo ships the shared gate, three enforcement adapters, and the Q1b +SessionStart announcer as chezmoi source. It owns **none** of the live +registration files that arm them: | Runtime | Adapter (repo-deployed) | Registration (hand-wired) | | --- | --- | --- | -| Claude Code | `~/.local/bin/git-identity-pretooluse` | `~/.claude/settings.json` `hooks.PreToolUse` | -| Codex | `~/.local/bin/git-identity-*` | `~/.codex/hooks.json` | +| Claude Code | `~/.local/bin/git-identity-pretooluse` **[V]**; Q1b source `home/dot_local/bin/executable_git-identity-sessionstart` **[D]** | `~/.claude/settings.json` `hooks.PreToolUse` and `hooks.SessionStart` | +| Codex | existing `~/.local/bin/git-identity-*` **[V]**; Q1b source `home/dot_local/bin/executable_git-identity-sessionstart` **[D]** | `~/.codex/hooks.json` `PreToolUse` and `SessionStart` | | Cursor | `~/.local/bin/git-identity-cursor` | `~/.cursor/hooks.json` | | Copilot | `~/.local/bin/git-identity-copilot` | `~/.copilot/hooks/git-identity.json` | @@ -372,6 +374,28 @@ All four registrations are dated 2026-06-21/22 — hand-wired in two sittings, reproducible on a new machine only from prose in `docs/agentic-hook-enforcement.md` **[A]**. +Those four are the existing **PreToolUse/shell enforcement registrations**. +Q1b adds only repository-owned announcer source, a backward-compatible gate +programmatic machine protocol, and tests. It does not deploy the new +executable or edit either live registration file. The owner must target-deploy +the executable, append exactly one SessionStart command registration to each of +`~/.claude/settings.json` and `~/.codex/hooks.json` using the byte-exact sanitized +launcher in [`docs/git-identity.md`](./git-identity.md#q1b-sourceruntime-ownership-handoff), +leave the two existing PreToolUse registrations and Claude's existing divergence +SessionStart entry byte-for-byte unchanged, perform Codex trust review, and run +fresh-session wrong-root/correct-root acceptance. The registration command is +the production entry boundary: `/usr/bin/env -i` removes the ambient environment +before the fixed Python invocation reaches Apple's Xcode shim, while +`2>/dev/null || :` makes any pre-main diagnostic or interpreter failure silent +and fail-open without redirecting valid warning stdout. The announcer then +derives canonical `HOME` through the passwd API, validates the resolved +root-owned `sys.executable`, and creates its separate four-variable Git/gate +child environment. A fixed `runpy` bootstrap calls `run_machine_v1()` without +entering the gate's legacy argv grammar. Direct file execution is not equivalent +production evidence. Until the deployment, exact-command receipt, trust review, +and live acceptance receipts exist, the SessionStart announcer is not installed, +trusted, or firing. The dual PreToolUse architecture is unchanged. + Three compounding facts: 1. **`ng-doctor` cannot detect the gap.** Its identity checks probe git state and @@ -381,10 +405,13 @@ Three compounding facts: *per-entry* sha256, so any future managed edit to `hooks.json` de-arms the Codex gate with no error **[A]**. Any managed-hooks design needs a re-trust step plus a real firing probe, not a file check. -3. **The adapters have no CI coverage.** All four are `python3`; - `scripts/validate-repo.sh` lints only shell **[A]**. They fail open by design, - so an import error disables commit-identity enforcement across all four - runtimes with no CI and no `ng-doctor` signal. +3. **Python lint remains absent, but executable behavior now has CI coverage.** + `scripts/validate-repo.sh` invokes `tests/git-identity-gate/run.sh`, which uses + generated registry data and temporary repositories to exercise the legacy + gate, its unchanged enforcement adapters, the qualified programmatic protocol, + and the SessionStart fail-open/privacy/environment contract. + `ng-doctor` checks deployed executable presence, not registration, Codex + trust, or actual hook dispatch. Net: a fresh `chezmoi apply` produces a machine where the gate *looks* deployed and never fires. @@ -456,7 +483,7 @@ the finding, not as a backlog. Read it in the past tense. | Parallel tree (all REMOVED 2026-07-25) | Authoritative tree | Why it was dangerous | | --- | --- | --- | -| `~/.local/share/chezmoi/` — 97 managed entries, full fish surface, `dot_config/claude/` **[V]** | `system-config/home/` — 77 entries | It is chezmoi's **default** source. One missing line in `chezmoi.toml` redirects every future apply to it | +| `~/.local/share/chezmoi/` — 97 managed entries, full fish surface, `dot_config/claude/` **[V]** | `system-config/home/` — authoritative managed set (measure current total) | It is chezmoi's **default** source. One missing line in `chezmoi.toml` redirects every future apply to it | | `~/.config/fish/` (live, unmanaged) + `~/.config/fish.disabled.20260508/` **[V]** | none — fish is retired | Two copies of a retired shell surface. `fish` is still installed at `/opt/homebrew/bin/fish` and still listed in `/etc/shells:12` | | `~/.config/workspace/config.yaml` — 3953 B, unmanaged, self-describes as "source of truth for all workspace definitions" **[V]** | `~/.config/workspaces/config.toml` — 961 B, chezmoi-managed | Singular vs plural, YAML vs TOML. The **unmanaged** one is 4× larger and claims authority | | `~/.config/claude/`, `~/.config/claude-code/` | `~/.claude/` | The `claude-code` one holds live plaintext credentials — §7.1 | @@ -490,7 +517,8 @@ stale `bun` install caches created by an unexpanded `$HOME`, in somebody added deliberately rather than fix the cause. Post-removal verification, all green **[V]**: `chezmoi source-path` still -resolves to `system-config/home`; `chezmoi managed` still returns 77; +resolves to `system-config/home`; `chezmoi managed` still returns the +authoritative managed set; `chezmoi apply --dry-run` produces **empty output** — proving the old source was not feeding anything and that removing chezmoi's *default* path did not strand it; all seven MCP targets still carry their nine servers; `validate-repo.sh` PASS; @@ -933,8 +961,52 @@ ls /etc/codex/ 2>/dev/null sudo profiles -P # AIV-4, needs root # 2. Repo ownership — the decisive test (OWN-2) -chezmoi managed | wc -l # expect 77 -chezmoi managed | grep -ciE '^\.(claude|codex|cursor|copilot|gemini|codeium)' # expect 0 +# Capture both commands before counting; preserve their stderr and own statuses. +receipt_dir="$(mktemp -d)" +chezmoi managed >"$receipt_dir/managed.stdout" 2>"$receipt_dir/managed.stderr" +managed_rc=$? +if [[ "$managed_rc" -ne 0 ]]; then + echo "chezmoi managed failed (exit $managed_rc); see $receipt_dir/managed.stderr" >&2 + exit 1 +fi +chezmoi managed --include=scripts \ + >"$receipt_dir/scripts.stdout" 2>"$receipt_dir/scripts.stderr" +scripts_rc=$? +if [[ "$scripts_rc" -ne 0 ]]; then + echo "chezmoi managed --include=scripts failed (exit $scripts_rc); see $receipt_dir/scripts.stderr" >&2 + exit 1 +fi + +managed_target_count=0 +sessionstart_target_count=0 +ai_tool_config_count=0 +while IFS= read -r managed_path; do + [[ -n "$managed_path" ]] || continue + managed_target_count=$((managed_target_count + 1)) + [[ "$managed_path" == *".local/bin/git-identity-sessionstart" ]] && \ + sessionstart_target_count=$((sessionstart_target_count + 1)) + case "$managed_path" in + .claude*|.codex*|.cursor*|.copilot*|.gemini*|.codeium*) + ai_tool_config_count=$((ai_tool_config_count + 1)) ;; + esac +done <"$receipt_dir/managed.stdout" + +managed_script_count=0 +while IFS= read -r managed_script; do + [[ -n "$managed_script" ]] || continue + managed_script_count=$((managed_script_count + 1)) +done <"$receipt_dir/scripts.stdout" + +if [[ "$managed_target_count" -ne 79 || "$ai_tool_config_count" -ne 0 || "$sessionstart_target_count" -ne 1 || "$managed_script_count" -ne 0 ]]; then + echo "unexpected managed-source receipt" >&2 + exit 1 +fi +printf 'managed_target_count=%s ai_tool_config_count=%s sessionstart_target_count=%s managed_script_count=%s\n' \ + "$managed_target_count" "$ai_tool_config_count" \ + "$sessionstart_target_count" "$managed_script_count" +# Intended post-commit Q1b source: 79 targets, zero AI-tool configs, one +# SessionStart executable target, and zero managed run scripts. Counts are +# source receipts, not deployment proof. grep -nE '^[A-Z_]+CONFIG=' scripts/sync-mcp.sh # expect 7 targets # 3. Codex managed-fence collision (§7.2) diff --git a/docs/git-identity.md b/docs/git-identity.md index 3c35809..53203b8 100644 --- a/docs/git-identity.md +++ b/docs/git-identity.md @@ -3,8 +3,8 @@ title: Git Identity Coherence (consumer of the meta-inventory registry) category: reference component: git_identity status: active -version: 0.10.1 -last_updated: 2026-06-22 +version: 0.11.0 +last_updated: 2026-08-13 tags: [git, github, identity, ssh, signing, gh, ng-doctor, meta-inventory, consumer] priority: high --- @@ -35,6 +35,12 @@ copies the matrix.** To change intended identity, the operator edits > Activation is a local, operator-controlled step (not chezmoi-managed). > Per-runtime detail: > [`docs/agentic-hook-enforcement.md`](./agentic-hook-enforcement.md). +> +> **Q1b source status (2026-08-13):** the fail-open SessionStart announcer and +> its synthetic test coverage are source owned by this repository. It is +> **not** deployed, registered, trusted, or live-accepted by this source change. +> The verified activation claims above apply only to the existing PreToolUse +> enforcement. ## The three identities @@ -127,6 +133,12 @@ python3 ~/Repos/verlyn13/meta-inventory/scripts/audit_identity.py --drift-only desktop agent; agent-scoped, `deny`-honored), and Copilot (`~/.copilot/hooks/git-identity.json`, `copilot -p`, deny-via-stdout) — Codex/Cursor/Copilot independently verified in a separate project. +- **Phase 2b / Q1b (source done; owner activation pending).** A shared, + fail-open SessionStart announcer asks the existing gate for an early decision + and supplies privacy-safe model context only for a confirmed mismatch. It + does not replace or modify either existing PreToolUse layer. Deployment, + native Claude/Codex registration, Codex trust review, and live wrong-root / + correct-root acceptance are owner actions described below. - **Phase 4 (done).** Promoted the remaining read-only `ng-doctor identity` checks from skip-stubs to live registry-vs-config diffs, covering the enforcement dimensions a doctor can verify locally: @@ -146,30 +158,107 @@ python3 ~/Repos/verlyn13/meta-inventory/scripts/audit_identity.py --drift-only - **Deferred.** Detached/CI consumption: fetch `registry.json` via a fine-grained `Contents: read` PAT into a gitignored path, then discard. -## Phase 2 — the identity gate +## Phase 2 — the identity gate and SessionStart announcement -Two stdlib-Python scripts (chezmoi-deployed to `~/.local/bin/`): +The shared engine and its stdlib-Python adapters are authored as chezmoi sources +for deployment to `~/.local/bin/`: - **`git-identity-gate [repo]`** — for the repo at `repo` (default CWD), resolve its `origin` `fullName` in the registry's `repos[]` and compare live `user.email` / `user.signingkey` / origin host vs `expectedAuthorEmail` / - `expectedSigningKey` / `expectedSshHostAlias`. + `expectedSigningKey` / `expectedSshHostAlias`. Its entire direct argv grammar + remains legacy: the first argument is the repository and every later argument + is ignored, including flag-like strings, literal `--`, `--machine-v1`, and + `--machine-protocol`. SessionStart instead loads the source without invoking + `main()` and calls the dedicated `run_machine_v1()` function for the repository + at CWD. - **`git-identity-pretooluse`** — PreToolUse adapter: reads the hook JSON, and only for a `git commit` / `git push` runs the gate in the session cwd. +- **`git-identity-sessionstart`** — SessionStart adapter: accepts the native + hook JSON on stdin, requires a valid absolute repository cwd, and emits valid + warning JSON only when the shared gate confirms a mismatch. The warning is + fixed text: it contains no registry path, entity, email, signing key, + fingerprint, host detail, or gate output. **Posture:** -- **Block** (exit 2) **only** on a confirmed mismatch. +- The legacy gate preserves its established PreToolUse contract: a comparison + mismatch exits 2 with the same human diagnostics, including historical + behavior when a mapped entity status is absent or unrecognized. +- The programmatic entrypoint emits `confirmed_mismatch` and exits 2 **only** + after the complete registry satisfies the producer contract owned by + `meta-inventory/scripts/check_registry_contract.py`. Machine qualification + validates the root and provenance metadata and cardinalities, every identity + lane, entity, account default, repository, and observed clone, and all required + types, canonical identifiers, joins, uniqueness rules, flattened identity + fields, and permitted nulls before the current repository is selected. Entity + flattened values must agree with their identity lane, and repository flattened + values must agree with the referenced entity. Thus a machine-confirmed result + implies that the producer checker accepts the registry; duplicate keys and + additional consumer-side canonical/path checks can conservatively suppress a + result. Malformed matched or unrelated records, missing observations, and all + exceptions produce only the generic nonconfirmed object and status 0. This + strict machine parser is not used by legacy CLI evaluation, and the producer + checker is a development/review oracle rather than a production subprocess or + runtime dependency. The implication is verified against the producer + checker's current contract for schema version `1` and the current provenance + marker. Unknown or bumped schema/provenance markers fail silently. A producer + contract tightening that retains those markers is not learned automatically: + it requires a coordinated consumer update and a newly classified differential + receipt. Therefore the current fail-silent posture is not an unqualified + guarantee about every future same-marker producer state. - **Allow** (exit 0) when unverifiable: not a git repo, registry absent (external private dependency), no `origin`, repo not in the registry, or a `needs_confirmation` entity (advisory). - **Fail open** on any internal error — a gate bug can never brick commits. - Never inspects branch shape (FF-direct-push preserved). Read-only. -**Env overrides:** `GIT_IDENTITY_GATE_REGISTRY` (point at a fetched/gitignored +The SessionStart adapter is advisory and always exits 0. Malformed input, +invalid or nonrepository cwd, an unregistered repo, absent gate, timeout, +unqualified exit 2, invalid machine output, or any internal failure produces no +stdout or stderr. The production entry boundary is the exact sanitized runtime +registration command in the Q1b handoff below, not direct shebang execution. +`/usr/bin/env -i` clears the ambient environment before `/usr/bin/python3` +invokes Apple's Xcode shim. The command's `2>/dev/null` suppresses Apple/Python +diagnostics emitted before `main()`, and `|| :` converts pre-main interpreter +failure into a silent status-zero allow. Neither affects valid warning JSON on +stdout. Direct execution is not equivalent production evidence because hostile +`SDKROOT`, `DEVELOPER_DIR`, `TOOLCHAINS`, or `COMMAND_MODE` values can affect the +shim before Python code begins. The fixed `#!/usr/bin/python3 -I` shebang remains +a source and fallback property, but it is not the pre-interpreter sanitation +boundary. + +Once Python starts, production derives `HOME` from the passwd database and +validates the resolved `sys.executable` target with `os.stat`, and fails open +unless it is an executable root-owned regular file with no group/world write +bit. Production executes that resolved file—not `/usr/bin/python3`, an +unresolved symlink, or an ambient path. It constructs the complete Git/gate +child environment as `HOME`, fixed `PATH=/usr/bin:/bin`, and fixed `LANG=C` / +`LC_ALL=C`; it inherits nothing else. Repository discovery uses `/usr/bin/git`. +The gate child uses the validated interpreter with `-I -c`, a fixed `runpy` +bootstrap, and the fixed sibling gate as a separate argv element; it never +invokes the gate's `__main__` path. The adapter warns only for an exact +`git-identity-gate/v1` `confirmed_mismatch` object with exit 2 and empty stderr. +It exposes no disable, registry, debug, interpreter, or alternate-gate control. +Its decision function accepts explicit `gate_path` and `gate_env` keyword +arguments only as the narrow test seam used with generated registry data. + +The deployed announcer and its sibling gate are user-owned +`~/.local/bin/git-identity-sessionstart` and +`~/.local/bin/git-identity-gate` targets. Q1b protects against ambient-process +environment poisoning and accidental runtime drift; replacement of those +user-owned targets by an attacker already operating as the same uid is outside +the accepted Q1b threat model. + +**Existing gate/PreToolUse env overrides:** `GIT_IDENTITY_GATE_REGISTRY` (point at a fetched/gitignored registry), `GIT_IDENTITY_GATE_DISABLE=1` (allow one command), `GIT_IDENTITY_GATE_BIN` (adapter→gate path, for tests), `GIT_IDENTITY_GATE_DEBUG` (log the allow reason). +These remain part of the unchanged PreToolUse architecture and are deliberately +unavailable to the production SessionStart adapter. -**Self-test:** `tests/git-identity-gate/run.sh` (skips if the registry is absent). +**Self-test:** `tests/git-identity-gate/run.sh`. It creates temporary repositories +and synthetic registries; CI does not depend on the live private registry. It +also freezes legacy status/stdout/stderr behavior while testing machine-protocol +qualification and environment poisoning independently. **Activation (local; not a chezmoi-managed file).** Apply chezmoi first so the scripts exist (`~/.local/bin/git-identity-gate`, `-pretooluse`, `-cursor`, @@ -189,6 +278,107 @@ As of 2026-06-22 this is wired for Claude (firing verified), Codex local activation state live in [`docs/agentic-hook-enforcement.md`](./agentic-hook-enforcement.md). +### Q1b source/runtime ownership handoff + +The source, tests, validation wiring, doctor source check, and this contract are +owned by `system-config`. Nothing in this repo owns the live hook +registration files. The owner completes activation in this order: + +1. Review the source diff, then preview only the three affected managed targets: + + ```bash + chezmoi apply --dry-run --exclude=scripts \ + ~/.local/bin/git-identity-gate \ + ~/.local/bin/git-identity-sessionstart \ + ~/.local/bin/ng-doctor + ``` + +2. Record the managed-source receipt without allowing a later pipeline command + to hide failure of `chezmoi managed`: + + ```bash + receipt_dir="$(mktemp -d)" + chezmoi managed >"$receipt_dir/managed.stdout" 2>"$receipt_dir/managed.stderr" + managed_rc=$? + if [[ "$managed_rc" -ne 0 ]]; then + echo "chezmoi managed failed (exit $managed_rc); see $receipt_dir/managed.stderr" >&2 + exit 1 + fi + + chezmoi managed --include=scripts \ + >"$receipt_dir/scripts.stdout" 2>"$receipt_dir/scripts.stderr" + scripts_rc=$? + if [[ "$scripts_rc" -ne 0 ]]; then + echo "chezmoi managed --include=scripts failed (exit $scripts_rc); see $receipt_dir/scripts.stderr" >&2 + exit 1 + fi + + managed_target_count=0 + sessionstart_target_count=0 + while IFS= read -r managed_path; do + [[ -n "$managed_path" ]] || continue + managed_target_count=$((managed_target_count + 1)) + if [[ "$managed_path" == *".local/bin/git-identity-sessionstart" ]]; then + sessionstart_target_count=$((sessionstart_target_count + 1)) + fi + done <"$receipt_dir/managed.stdout" + + managed_script_count=0 + while IFS= read -r managed_script; do + [[ -n "$managed_script" ]] || continue + managed_script_count=$((managed_script_count + 1)) + done <"$receipt_dir/scripts.stdout" + + if [[ "$managed_target_count" -ne 79 || "$sessionstart_target_count" -ne 1 || "$managed_script_count" -ne 0 ]]; then + echo "unexpected managed-source receipt" >&2 + exit 1 + fi + printf 'managed_target_count=%s sessionstart_target_count=%s managed_script_count=%s\n' \ + "$managed_target_count" "$sessionstart_target_count" "$managed_script_count" + printf 'receipt_dir=%s\n' "$receipt_dir" + ``` + +3. Apply those same three targets, and no broader home-directory apply: + + ```bash + chezmoi apply --exclude=scripts \ + ~/.local/bin/git-identity-gate \ + ~/.local/bin/git-identity-sessionstart \ + ~/.local/bin/ng-doctor + ``` + +4. Manually append exactly one runtime-native `SessionStart` command + registration to each live file. The command value in each newly appended + entry must match its block below byte-for-byte. + + **Claude SessionStart command (owner pending):** + + ```text + /usr/bin/env -i PATH=/usr/bin:/bin LANG=C LC_ALL=C /usr/bin/python3 -I /Users/verlyn13/.local/bin/git-identity-sessionstart 2>/dev/null || : + ``` + + **Codex SessionStart command (owner pending):** + + ```text + /usr/bin/env -i PATH=/usr/bin:/bin LANG=C LC_ALL=C /usr/bin/python3 -I /Users/verlyn13/.local/bin/git-identity-sessionstart 2>/dev/null || : + ``` + + Append the Claude entry to `~/.claude/settings.json` and the Codex entry to + `~/.codex/hooks.json`. Do not regenerate, normalize, reorder, or otherwise + rewrite either file. Capture the pre-edit bytes, then make the activation + receipt compare each newly appended command value byte-for-byte with the + applicable block above. The receipt must also prove that Claude's existing + divergence `SessionStart` entry and both existing `PreToolUse` registrations + remain byte-for-byte unchanged. +5. Perform the Codex hook trust review required by the changed hook-config + bytes. File presence is not evidence of trust or dispatch. +6. In fresh native sessions, perform the live acceptance pair: the intended + wrong-root checkout must show the early mismatch context, and the correct + agent root must remain silent. Preserve value-blind receipts. + +This Q1b source implementation performs none of steps 1–6. The dual +PreToolUse enforcement architecture remains unchanged throughout. + ## Multi-runtime enforcement + backstop `git-identity-gate` is a **tool-agnostic engine**; the PreToolUse adapter is just diff --git a/home/dot_local/bin/executable_git-identity-gate b/home/dot_local/bin/executable_git-identity-gate index df1226e..ea18d46 100644 --- a/home/dot_local/bin/executable_git-identity-gate +++ b/home/dot_local/bin/executable_git-identity-gate @@ -5,8 +5,11 @@ For the git repo at the given path (default: CWD), verify the live commit identity matches the EXPECTED identity from meta-inventory's entity registry. Posture: - * BLOCK (exit 2) ONLY on a confirmed mismatch of author email / signing key / - origin host vs the repo's registry entry. + * The direct legacy CLI preserves its established BLOCK behavior for any + author email / signing key / origin host comparison mismatch, including + historical invalid or unrecognized entity-state cases. + * The programmatic SessionStart entrypoint reports a confirmed mismatch only + after strict registry and confirmed-entity qualification. * ALLOW (exit 0) whenever it cannot verify — not a git repo, registry absent (external private dependency), no origin remote, repo not in the registry, or a `needs_confirmation` entity (advisory). @@ -19,17 +22,25 @@ Contract: docs/git-identity.md. The registry is INTERNAL and is never committed into this PUBLIC repo; it is read at runtime from: $GIT_IDENTITY_GATE_REGISTRY (override; e.g. a gitignored fetched copy), else ~/Repos/verlyn13/meta-inventory/exports/registry.json + +The direct CLI retains its complete legacy argv grammar. SessionStart calls the +separate programmatic ``run_machine_v1()`` entrypoint, which applies stricter +machine-only registry qualification without changing legacy behavior. """ from __future__ import annotations import json import os +import pwd import subprocess import sys from pathlib import Path DEFAULT_REGISTRY = "~/Repos/verlyn13/meta-inventory/exports/registry.json" +MACHINE_PROTOCOL = "git-identity-gate/v1" +MACHINE_CONFIRMED = '{"protocol":"git-identity-gate/v1","outcome":"confirmed_mismatch"}' +MACHINE_NOT_CONFIRMED = '{"protocol":"git-identity-gate/v1","outcome":"no_confirmed_mismatch"}' def _registry_path() -> Path: @@ -150,6 +161,326 @@ def _run(argv: list[str]) -> None: _allow("identity matches registry") +def _machine_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + """Reject duplicate JSON keys without exposing their names or values.""" + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate key") + result[key] = value + return result + + +def _machine_home() -> str: + home = pwd.getpwuid(os.getuid()).pw_dir + if type(home) is not str or not home or not os.path.isabs(home) or not os.path.isdir(home): + raise ValueError("invalid home") + return home + + +def _machine_signing_key(value: str, home: str) -> str | None: + if value == "~": + expanded = home + elif value.startswith("~/"): + expanded = os.path.join(home, value[2:]) + else: + expanded = value + return expanded if expanded and os.path.isabs(expanded) else None + + +def _machine_canonical_identifier(value: object) -> bool: + return type(value) is str and bool(value.strip()) and value == value.strip() + + +def _machine_nullable_canonical_string(value: object) -> bool: + return value is None or _machine_canonical_identifier(value) + + +def _machine_count(value: object) -> bool: + return type(value) is int and value >= 0 + + +def _machine_full_name(value: object) -> bool: + if not _machine_canonical_identifier(value) or value.count("/") != 1: + return False + owner, repository = value.split("/", 1) + return _machine_canonical_identifier(owner) and _machine_canonical_identifier( + repository + ) + + +def _machine_registry_contract( + reg: object, home: str +) -> tuple[ + list[tuple[dict[str, object], dict[str, str | None]]], + dict[str, dict[str, object]], +] | None: + """Validate the complete producer contract needed by machine mode.""" + if type(reg) is not dict: + return None + if reg.get("schemaVersion") != "1": + return None + if not _machine_canonical_identifier(reg.get("generatedAt")): + return None + + provenance = reg.get("provenance") + if type(provenance) is not dict: + return None + if provenance.get("source") != "meta-inventory registry v1": + return None + if provenance.get("visibility") != "internal": + return None + repo_source = provenance.get("repoSource") + if type(repo_source) is not str or repo_source not in {"db", "snapshot"}: + return None + if not _machine_count(provenance.get("entitiesVersion")): + return None + + lanes = reg.get("gitIdentityLanes") + entities = reg.get("entities") + account_defaults = reg.get("accountDefaults") + repos = reg.get("repos") + observed_clones = reg.get("observedClones") + if type(lanes) is not dict: + return None + if type(entities) is not list: + return None + if type(account_defaults) is not dict: + return None + if type(repos) is not list: + return None + if type(observed_clones) is not list: + return None + + for count_name, rows in ( + ("entityCount", entities), + ("repoCount", repos), + ("observedCloneCount", observed_clones), + ): + reported = provenance.get(count_name) + if not _machine_count(reported) or reported != len(rows): + return None + unresolved = provenance.get("unresolvedRepoCount") + if not _machine_count(unresolved) or unresolved != 0: + return None + + lane_specs: dict[str, dict[str, object]] = {} + for lane_name, raw_lane in lanes.items(): + if not _machine_canonical_identifier(lane_name): + return None + if type(raw_lane) is not dict: + return None + author_email = raw_lane.get("authorEmail") + signing_key = raw_lane.get("signingKey") + if not _machine_canonical_identifier(author_email): + return None + if not _machine_canonical_identifier(signing_key): + return None + if _machine_signing_key(signing_key, home) is None: + return None + lane_specs[lane_name] = raw_lane + + validated_entities: dict[str, dict[str, object]] = {} + for candidate in entities: + if type(candidate) is not dict: + return None + candidate_id = candidate.get("id") + if not _machine_canonical_identifier(candidate_id): + return None + if candidate_id in validated_entities: + return None + candidate_status = candidate.get("status") + if type(candidate_status) is not str or candidate_status not in { + "confirmed", + "needs_confirmation", + }: + return None + link_policy = candidate.get("linkPolicy") + if type(link_policy) is not str or link_policy not in {"link-ok", "no-link"}: + return None + if "sshHostAlias" not in candidate or not _machine_nullable_canonical_string( + candidate.get("sshHostAlias") + ): + return None + lane_name = candidate.get("gitIdentity") + if not _machine_canonical_identifier(lane_name): + return None + lane = lane_specs.get(lane_name) + if lane is None: + return None + if ( + "expectedAuthorEmail" not in candidate + or candidate.get("expectedAuthorEmail") != lane.get("authorEmail") + ): + return None + if ( + "expectedSigningKey" not in candidate + or candidate.get("expectedSigningKey") != lane.get("signingKey") + ): + return None + validated_entities[candidate_id] = candidate + + for account, entity_id in account_defaults.items(): + if not _machine_canonical_identifier(account): + return None + if not _machine_canonical_identifier(entity_id): + return None + if entity_id not in validated_entities: + return None + + validated_repos: list[ + tuple[dict[str, object], dict[str, str | None]] + ] = [] + repo_names: set[str] = set() + for candidate in repos: + if type(candidate) is not dict: + return None + candidate_name = candidate.get("fullName") + if not _machine_full_name(candidate_name): + return None + if candidate_name in repo_names: + return None + repo_names.add(candidate_name) + + github_account = candidate.get("githubAccount") + if not _machine_canonical_identifier(github_account): + return None + if candidate_name.split("/", 1)[0] != github_account: + return None + + candidate_entity_id = candidate.get("entityId") + if not _machine_canonical_identifier(candidate_entity_id): + return None + entity = validated_entities.get(candidate_entity_id) + if entity is None: + return None + + for flattened, entity_field in ( + ("expectedAuthorEmail", "expectedAuthorEmail"), + ("expectedSigningKey", "expectedSigningKey"), + ("expectedSshHostAlias", "sshHostAlias"), + ): + if flattened not in candidate: + return None + if candidate.get(flattened) != entity.get(entity_field): + return None + expected_email = candidate.get("expectedAuthorEmail") + expected_signing_key = candidate.get("expectedSigningKey") + expected_host = candidate.get("expectedSshHostAlias") + if not _machine_canonical_identifier(expected_email): + return None + if not _machine_canonical_identifier(expected_signing_key): + return None + normalized_signing_key = _machine_signing_key(expected_signing_key, home) + if normalized_signing_key is None: + return None + if not _machine_nullable_canonical_string(expected_host): + return None + validated_repos.append( + ( + candidate, + { + "expectedAuthorEmail": expected_email, + "expectedSigningKey": normalized_signing_key, + "expectedSshHostAlias": expected_host, + }, + ) + ) + + seen_clone_names: set[str] = set() + for candidate in observed_clones: + if type(candidate) is not dict: + return None + candidate_name = candidate.get("fullName") + if not _machine_canonical_identifier(candidate_name): + return None + if candidate_name in seen_clone_names or candidate_name not in repo_names: + return None + seen_clone_names.add(candidate_name) + if "remoteHostAlias" not in candidate or not _machine_nullable_canonical_string( + candidate.get("remoteHostAlias") + ): + return None + if not _machine_canonical_identifier(candidate.get("clonePath")): + return None + if candidate.get("isCloned") is not True: + return None + + return validated_repos, validated_entities + + +def _machine_confirmed_mismatch() -> bool: + """Strictly qualify a confirmed mismatch for the repository at CWD.""" + if os.environ.get("GIT_IDENTITY_GATE_DISABLE"): + return False + + root = _git(".", "rev-parse", "--show-toplevel") + if not root: + return False + + reg_path = _registry_path() + if not reg_path.is_file(): + return False + reg = json.loads( + reg_path.read_text(encoding="utf-8"), object_pairs_hook=_machine_object + ) + home = _machine_home() + validated = _machine_registry_contract(reg, home) + if validated is None: + return False + validated_repos, validated_entities = validated + + origin = _git(root, "remote", "get-url", "origin") + full_name = _full_name(origin) + if not full_name: + return False + + matching_repos = [ + (candidate, expected) + for candidate, expected in validated_repos + if candidate["fullName"] == full_name + ] + if len(matching_repos) != 1: + return False + entry, expected = matching_repos[0] + entity = validated_entities[entry["entityId"]] + if entity["status"] != "confirmed": + return False + + observed_email = _git(root, "config", "--get", "user.email") or None + observed_key_raw = _git(root, "config", "--get", "user.signingkey") or None + if observed_key_raw: + observed_key = _machine_signing_key(observed_key_raw, home) or observed_key_raw + else: + observed_key = None + observed_host = _host_alias(origin) + + comparisons = ( + (expected["expectedAuthorEmail"], observed_email), + (expected["expectedSigningKey"], observed_key), + (expected["expectedSshHostAlias"], observed_host), + ) + return any( + configured is not None + and observed is not None + and observed != configured + for configured, observed in comparisons + ) + + +def run_machine_v1() -> None: + """Emit one fixed privacy-safe v1 object and terminate with its status.""" + try: + confirmed = _machine_confirmed_mismatch() + except Exception: + confirmed = False + if confirmed: + sys.stdout.write(MACHINE_CONFIRMED + "\n") + sys.exit(2) + sys.stdout.write(MACHINE_NOT_CONFIRMED + "\n") + sys.exit(0) + + def main(argv: list[str]) -> None: try: _run(argv) diff --git a/home/dot_local/bin/executable_git-identity-sessionstart b/home/dot_local/bin/executable_git-identity-sessionstart new file mode 100644 index 0000000..5dd8c39 --- /dev/null +++ b/home/dot_local/bin/executable_git-identity-sessionstart @@ -0,0 +1,199 @@ +#!/usr/bin/python3 -I +"""Fail-open SessionStart announcement for a confirmed Git identity mismatch. + +The shared git-identity gate remains the decision authority. This adapter accepts +the SessionStart JSON envelope on stdin and emits fixed, privacy-safe context +only when that gate confirms a mismatch. All unverifiable and error paths are +silent allows. Production runtime registration clears the ambient environment +and suppresses pre-main diagnostics before the fixed launcher starts. This +module then validates the resolved ``sys.executable`` target and constructs the +separate four-variable environment used for Git discovery and gate evaluation. +""" + +from __future__ import annotations + +import json +import os +import pwd +import stat +import subprocess +import sys +from collections.abc import Mapping +from typing import Any + + +_FIXED_GATE_PATH = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "git-identity-gate" +) +_FIXED_GIT_PATH = "/usr/bin/git" +_MACHINE_BOOTSTRAP = ( + 'import runpy,sys; runpy.run_path(sys.argv[1])["run_machine_v1"]()' +) +_MACHINE_PROTOCOL = "git-identity-gate/v1" +_TIMEOUT_SECONDS = 5 + + +def _production_gate_env() -> dict[str, str] | None: + """Build the complete child environment from trusted fixed inputs.""" + try: + home = pwd.getpwuid(os.getuid()).pw_dir + except Exception: + return None + if not home or not os.path.isabs(home) or not os.path.isdir(home): + return None + return { + "HOME": home, + "PATH": "/usr/bin:/bin", + "LANG": "C", + "LC_ALL": "C", + } + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate machine-protocol field") + result[key] = value + return result + + +def _trusted_interpreter() -> str | None: + """Resolve and validate the interpreter that started this process.""" + try: + executable = sys.executable + if not isinstance(executable, str) or not executable or not os.path.isabs(executable): + return None + resolved = os.path.realpath(executable) + metadata = os.stat(resolved) + except Exception: + return None + mode = metadata.st_mode + if not stat.S_ISREG(mode): + return None + if metadata.st_uid != 0: + return None + if mode & (stat.S_IWGRP | stat.S_IWOTH): + return None + if not mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH): + return None + return resolved + + +def _repository_root(cwd: str, *, env: Mapping[str, str]) -> str | None: + try: + result = subprocess.run( + [_FIXED_GIT_PATH, "-C", cwd, "rev-parse", "--show-toplevel"], + capture_output=True, + cwd="/", + encoding="utf-8", + errors="strict", + stdin=subprocess.DEVNULL, + text=True, + timeout=_TIMEOUT_SECONDS, + env=dict(env), + ) + except Exception: + return None + + root = result.stdout.strip() if result.returncode == 0 else "" + if not root or not os.path.isabs(root) or not os.path.isdir(root): + return None + return root + + +def sessionstart_announcement( + cwd: object, + *, + gate_path: str, + gate_env: Mapping[str, str], +) -> dict[str, Any] | None: + """Return a warning payload only for a gate-confirmed mismatch. + + ``gate_path`` and ``gate_env`` are explicit only to support isolated tests. + Production ``main`` always supplies the fixed deployed gate and a new child + environment built only from trusted fixed values. + """ + if not isinstance(cwd, str) or not cwd or not os.path.isabs(cwd): + return None + if not os.path.isdir(cwd): + return None + + interpreter = _trusted_interpreter() + if interpreter is None: + return None + root = _repository_root(cwd, env=gate_env) + if root is None or not os.path.isabs(gate_path) or not os.path.isfile(gate_path): + return None + + try: + result = subprocess.run( + [interpreter, "-I", "-c", _MACHINE_BOOTSTRAP, gate_path], + capture_output=True, + cwd=root, + encoding="utf-8", + errors="strict", + stdin=subprocess.DEVNULL, + text=True, + timeout=_TIMEOUT_SECONDS, + env=dict(gate_env), + ) + except Exception: + return None + + if result.returncode != 2 or result.stderr != "": + return None + + try: + machine_result = json.loads( + result.stdout, + object_pairs_hook=_unique_json_object, + ) + except (TypeError, ValueError): + return None + if not isinstance(machine_result, dict): + return None + if set(machine_result) != {"protocol", "outcome"}: + return None + if machine_result.get("protocol") != _MACHINE_PROTOCOL: + return None + if machine_result.get("outcome") != "confirmed_mismatch": + return None + + return { + "systemMessage": ( + "Git identity mismatch: this checkout does not match its registered identity." + ), + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": ( + "A Git identity mismatch was confirmed for the current repository before " + "tool use. Do not make mutating changes from this checkout; switch to the " + "intended owner or agent checkout, or correct the checkout identity under " + "operator direction." + ), + }, + } + + +def main() -> None: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + return + gate_env = _production_gate_env() + if gate_env is None: + return + announcement = sessionstart_announcement( + payload.get("cwd"), + gate_path=_FIXED_GATE_PATH, + gate_env=gate_env, + ) + if announcement is not None: + json.dump(announcement, sys.stdout, separators=(",", ":")) + except Exception: + return + + +if __name__ == "__main__": + main() diff --git a/home/dot_local/bin/executable_ng-doctor.tmpl b/home/dot_local/bin/executable_ng-doctor.tmpl index 0a127fb..91fc04a 100755 --- a/home/dot_local/bin/executable_ng-doctor.tmpl +++ b/home/dot_local/bin/executable_ng-doctor.tmpl @@ -1542,9 +1542,10 @@ run_governance_checks() { # provenance.visibility=="internal", unresolvedRepoCount==0 before enforcing. # # Phase 1 promoted the read-only registry/compliance/drift checks. Phase 2 adds -# the deployed-script check for the commit/push gate; local PreToolUse activation -# remains operator-controlled in ~/.claude/settings.json. Phase 4 promotes the -# remaining read-only checks — they diff live git/SSH/gh config against the +# the deployed-script check for the commit/push gate and SessionStart announcer; +# local PreToolUse and SessionStart activation remains operator-controlled in +# each runtime's live configuration. Phase 4 promotes the remaining read-only +# checks — they diff live git/SSH/gh config against the # registry (reference impl: meta-inventory scripts/audit_identity.py) and SKIP # when the private registry / a required tool is absent, so ng-doctor stays green # on a machine without meta-inventory. Each derives everything from the registry @@ -1948,18 +1949,19 @@ PY check_identity_commit_push_gate() { # Phase 2 / Tier-1: the fail-closed gate ships as ~/.local/bin/git-identity-gate - # plus a shared hooklib and per-runtime adapters. This verifies they are - # deployed; the per-tool hook REGISTRATION (e.g. ~/.claude/settings.json, - # ~/.codex/hooks.json) is a separate step — see docs/agentic-hook-enforcement.md. + # plus a shared hooklib, per-runtime enforcement adapters, and a fail-open + # SessionStart announcer. This verifies they are deployed; per-tool hook + # REGISTRATION (e.g. ~/.claude/settings.json, ~/.codex/hooks.json) is a separate + # operator step — see docs/agentic-hook-enforcement.md. local missing=() [[ -x "$HOME/.local/bin/git-identity-gate" ]] || missing+=("git-identity-gate") [[ -f "$HOME/.local/lib/git_identity_hooklib.py" ]] || missing+=("git_identity_hooklib.py") local a - for a in git-identity-pretooluse git-identity-cursor git-identity-copilot; do + for a in git-identity-pretooluse git-identity-cursor git-identity-copilot git-identity-sessionstart; do [[ -x "$HOME/.local/bin/$a" ]] || missing+=("$a") done if [[ ${#missing[@]} -eq 0 ]]; then - pass "identity_commit_push_gate" "gate engine + hooklib + adapters (claude/codex, cursor, copilot) installed" + pass "identity_commit_push_gate" "gate engine + hooklib + enforcement adapters + SessionStart announcer installed" else skip "identity_commit_push_gate" "not yet deployed (run chezmoi apply): ${missing[*]}; per-tool hook registration is a separate step" fi diff --git a/scripts/validate-repo.sh b/scripts/validate-repo.sh index e4252a6..91dae32 100755 --- a/scripts/validate-repo.sh +++ b/scripts/validate-repo.sh @@ -27,6 +27,7 @@ tests/vscode/run.sh tests/mcp-vscode/run.sh tests/mcp-codex/run.sh tests/mcp-claude-desktop/run.sh +tests/git-identity-gate/run.sh scripts/policy-lint.sh scripts/check-instruction-bridge.sh diff --git a/tests/git-identity-gate/run.sh b/tests/git-identity-gate/run.sh index e53a512..d4d8474 100755 --- a/tests/git-identity-gate/run.sh +++ b/tests/git-identity-gate/run.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# Self-test for the Phase 2 git identity gate + PreToolUse adapter. -# Read-only: uses throwaway repos under a temp dir and the live (private) -# registry. Skips cleanly if the registry isn't present. +# Self-test for the git identity gate, mutation adapters, and SessionStart +# announcer. Uses only throwaway repos and generated synthetic registry data. # # tests/git-identity-gate/run.sh set -uo pipefail @@ -10,7 +9,9 @@ HERE="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$HERE/../.." && pwd)" GATE="$REPO_ROOT/home/dot_local/bin/executable_git-identity-gate" ADAPTER="$REPO_ROOT/home/dot_local/bin/executable_git-identity-pretooluse" -REGISTRY="${GIT_IDENTITY_GATE_REGISTRY:-$HOME/Repos/verlyn13/meta-inventory/exports/registry.json}" +SESSIONSTART="$REPO_ROOT/home/dot_local/bin/executable_git-identity-sessionstart" +PYTHON=/usr/bin/python3 +GIT=/usr/bin/git pass=0 fail=0 @@ -19,126 +20,2703 @@ check() { # expected actual label printf ' \033[32mok\033[0m %s\n' "$3" pass=$((pass + 1)) else - printf ' \033[31mFAIL\033[0m %s (expected exit %s, got %s)\n' "$3" "$1" "$2" + printf ' \033[31mFAIL\033[0m %s (expected %s, got %s)\n' "$3" "$1" "$2" fail=$((fail + 1)) fi } -run_gate() { # repo-path -> prints exit code - local rc=0 - python3 "$GATE" "$1" >/dev/null 2>&1 || rc=$? - echo "$rc" +has() { + case "$2" in + *"$1"*) printf ' \033[32mok\033[0m %s\n' "$3"; pass=$((pass + 1)) ;; + *) printf ' \033[31mFAIL\033[0m %s (missing %s)\n' "$3" "$1"; fail=$((fail + 1)) ;; + esac } -if [[ ! -f "$REGISTRY" ]]; then - echo "git-identity-gate self-test: SKIP (registry not present: $REGISTRY)" - exit 0 -fi +empty() { + if [[ -z "$1" ]]; then + printf ' \033[32mok\033[0m %s\n' "$2" + pass=$((pass + 1)) + else + printf ' \033[31mFAIL\033[0m %s (expected empty output)\n' "$2" + fail=$((fail + 1)) + fi +} TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT -mkrepo() { # name origin email -> prints path +BASE_GATE="$TMP/exact-base-git-identity-gate" +if ! "$PYTHON" - "$BASE_GATE" <<'PY' +import base64 +import hashlib +import sys +import zlib +from pathlib import Path + +encoded = """eNqdWOtu2zgW/u+n4KgLrDyw5aQzWCw8m2LSxO0EE6RBkna2yAaqYtE2p7IokJITI5PFPsQ+4T7JfocXXWwnKcZ/LFGH53znwsOPfPXdqNJqdCvyEc9XrFiXC5n/0AuC4F0isuE0k5qnbC5KJlKel6Jcs3lSchaeLxLN2et+1Ou9k4qVC26kFC8kS0r3vuI5K5JywcKUz5IqK8fs6Lfj/oCtuBKztZHKIMWmcrkUZa+2sUzK6YJrIzD55/nk6Gpy3CCYKblkS14mQ5HDRCnV+q+auY+Kz4Uu1RrAzqUuK8XHPca+Z29PPxz9ykJ+D5Sv++zD2elnJnOWwHY+E2oJN5dCG8NMzlhSIRKK8SXCwEZMi3ku8jn7ytdsBH2MSSXmImcL2GAri5ScBxCPgBARDrJ+eHr64Tdnfa/P7hY85wgCw+s0yXNZ+pD87z//ZfSa1OEcNAqTWw2dxjxUlVzlScYKJVaUkpQXPEeIpmsEOJceoOJLWfKBzQxpxpgFa5UOnDew+CXnPNWxC0hSCpl/8WENk3QlNCLdtw69Ozw5ZR/OJ2cmiPkaah0erhS0hYktlNtqzpYVYmT9vVVi+tWlWztVZ5NPkwvM1wWflhoiSY4c6EVSALaWBu0s0eVwJtVdolKWCgXJYVHpBZtl8o4JTeaTaYmkX0wOj4eU3TGQZaiHKtfsCwVzeMT+QVF4Y1M+H9nQfGEhWUCp5xzZk3e5+061fSSRQyges1RO9Qhqhr4Mo2UasatWJAnGydnV5OLs8BQhSendem39LXnaA0xyCF/OP749PTkyafmJykBQ4SQpLR5ALsWSmzqn4v3L+5Or+OR4cnZ1cvU5fn94NYkvJu9PLq8uPqMQJCwogPqJ8Wge2cJBtUqFip5xWkcpABRUFTzTHPr+PbqAVT3CxGyd7/8w6q6lEb8vpCr1qF5Kv2uZU0/o9czSi+NZRQsrjplYkigzJWwKRvd6bsxMcs9S+ydd3RZKTrluRtbaqqVOkYlbr/Mcr73e8eTd4cfTq8bhAxb8KfwA30MXYrEfjcle2GfDN8bU2KwDxeFYbgZCqSMSiaAO2aw0VzSENimUzKM5L8Pg6cQEfVpTm+j7fY8CSQop92MGLAP2faLm2jwbQPi3eIDTPhhwcL6JX4QyCa8DKAoGLBgeBQPXLoyumwEaS2GyJKuyqMqDK1VhPZXoG/4RNYZvB/t7/caE9V9FukzxDX9KFAiSmGHMfpzKlLODA7ZnyomhKmgiv5/yomThh8sJrf9BG+hl/Wi+9ceb5oI6OdRO4yQTiQ7t6uzEhP3BzrBK7XxAonbmxDZVkpwXsyLwJUFV3AmklqL2c7ANxEpe/zi+iXSRIUXBGGHd71/v3TyjTOvFeDR6Wt3fGnUjr84PAMX1cP8Z7WGwKMtCkwGkmZ6NraeM1bhHztT+tu3eZpxc8GdVlsV5suQvxh69AH2Sq5HZVczqTdh7Uf5S3TocLKQQD5gBj2rQC/Qfk1izZVLDnmZVytMocBX0jfnUWAR1lExx+tn65bDh0SerFUBSqXcEbrdA/Zls4o02VO3XAqt/r1iqZMGuqW/8fEM+23WS0ayxm0W7BKlw+7LeqrqnQNYYXvi9YnpaDDPx1dCznwnGuMmcQ6RfCLe+Hg9/vLEhBsVwVR9R5+lbx7WRLSj6kL8uGLZqVjiXTJbgZb+JoGkoRacQMRr9LkUeGi3Xw9fjGyOVcTfUZ28O2Gtrr121SQYKgIpN0OSbiu30iZfb9vHk7cf37RWMXYl6ILbW6E6JkoezoL35D4ncjJmxzcIHa/2x/688sOVI04nshXt1x7/N5PRriEZ4m/Elen2GXegacG828G5Z3mXYsFlwYqKLHXaeStBmKifLZInZbBLjGmSdpBrUc+4z9lA8dhzsCNRTIfhO3LcJcQ0O3MfAGtDeWBUpEcTu3u2pmZkguI7WyTKD0bZ2WjRwBgxaJcRsYYm6H7nGLirLbj0p3tCegmODrVQpQgbvNxQjQeCvKaaITEfYyxyzMoFCfgyPg/Ux21FAJ5eHb08nB/tR32vdqITXdSXQxo09evV0DXxbzVqT7ap1iyFIhU6Q0ZStRMKemdlzK7CQMQBh6RIs19po4dEr1h3bd/0tsp4piQI7aEgMTUZrVXw1xFLV3BCSoV7Iu2EpiwwcOAv6nSYPBduou+eeBt3cUDUyuMHduvuGFYuEjmci4+GOsNRMnSYUitNpqnWSap2gHNwu/eIUIeKSUSaTlBiKM0nEPSZmFXKiRzgoHgRVORv+HdvMbnb0KckqvsmGtmBWOWmmTPpgYKWuzAY4t1VB7zzHgYlbwvnw2DAJJ6JxAlgmn7jSoOeQ+g4Mej8gYZpsZXC0E7cCzXntBPxpLngGXUfxqFEB2KDMOKHxtMl6Y4uc0jJb8ZRY/JHEYcca3XvG1gK8oZlo6kP7mLiDrq9HlJapRaIIVIgwOqxURo9W0nc/z3ZoZkN9rEyntOqPu0qWWVCUpO6R28MztwCwkVN9hMo0E8WMmMuQ9WbArm8c0TajZPUMRhEcsO0aA2gU9YkOQGNiC9wseKhnPXqK4SPaQoeUxSIFQntfYYzb4ROfQNe/vRO2I/KOE75jN35w+0Gk1oPaEjx4eKzxu8O0LVWcISttxYPty4hgh4sO2UOt/dEeuzfnti4watfvi9je73Rdd7V7aG6AJiTgo4AJdAF0wHYcDXdouLSXRr/ytV2cgf2ruRXpM/dHO+1f6sUv+HhInLku9VtdQ25Xu901bduFEnogUBG36DtWSQe8iFVy941a3OXXV+/HpqrdAWnZMeXQttuQOK/GxaF9APQr0Te+Td5ETLM+ODXJJHrQxKn7hi5TCzbF5HVHSUEbQBh0Lv/8rWDTy3wdeN2bz84KHp+x0b5R3G3ChMTr3X5xVgylf9pM+5qyZabVljeY3wZPtYh8u9u6oG23E0NuELKXyE23V3ky1NkoL9e65MsJaFNr+02E5m2hifmjtY3NAWNjOu7MKGXmXpKo8ROXkOShv4Ss9f+5c8K3nxW6F6QDe3ZABYzRvO6nDbPePj4AWWy6eBybzhjHFOU4dv3QhJxm2DD+HzZMjwA=""" +source = zlib.decompress(base64.b64decode(encoded)) +expected = "8c9581ac80fc09b3cb07e2589515204c9e8b62aa87703ada3c160b7b90790267" +if hashlib.sha256(source).hexdigest() != expected: + raise SystemExit("embedded exact-base gate checksum mismatch") +Path(sys.argv[1]).write_bytes(source) +PY +then + echo "git-identity-gate self-test: could not materialize exact-base gate" >&2 + exit 1 +fi + +REGISTRY="$TMP/registry.json" +FIX_OK="synthetic-owner/registered-repo" +FIX_EMAIL="registered-author@example.test" +FIX_KEY="$TMP/synthetic-signing-key.pub" +FIX_NC="synthetic-owner/advisory-repo" + +if ! "$PYTHON" - "$REGISTRY" "$FIX_OK" "$FIX_EMAIL" "$FIX_KEY" "$FIX_NC" <<'PY' +import json +import sys + +registry, confirmed_repo, email, signing_key, advisory_repo = sys.argv[1:] +data = { + "schemaVersion": "1", + "generatedAt": "2026-01-01T00:00:00Z", + "provenance": { + "source": "meta-inventory registry v1", + "visibility": "internal", + "entitiesVersion": 1, + "repoSource": "snapshot", + "repoCount": 2, + "entityCount": 2, + "observedCloneCount": 0, + "unresolvedRepoCount": 0, + "hostReceipt": "synthetic-host-detail", + }, + "gitIdentityLanes": { + "confirmed-lane": { + "authorEmail": email, + "signingKey": signing_key, + }, + "advisory-lane": { + "authorEmail": "advisory-author@example.test", + "signingKey": "/synthetic/advisory-signing-key.pub", + }, + }, + "entities": [ + { + "id": "confirmed-entity-private", + "status": "confirmed", + "linkPolicy": "link-ok", + "sshHostAlias": "github.com", + "gitIdentity": "confirmed-lane", + "expectedAuthorEmail": email, + "expectedSigningKey": signing_key, + "privateName": "Synthetic Private Entity", + }, + { + "id": "advisory-entity-private", + "status": "needs_confirmation", + "linkPolicy": "link-ok", + "sshHostAlias": "github.com", + "gitIdentity": "advisory-lane", + "expectedAuthorEmail": "advisory-author@example.test", + "expectedSigningKey": "/synthetic/advisory-signing-key.pub", + }, + ], + "accountDefaults": {"synthetic-owner": "confirmed-entity-private"}, + "repos": [ + { + "fullName": confirmed_repo, + "githubAccount": "synthetic-owner", + "entityId": "confirmed-entity-private", + "expectedAuthorEmail": email, + "expectedSigningKey": signing_key, + "expectedSshHostAlias": "github.com", + "expectedKeyFingerprint": "SHA256:SYNTHETIC_PRIVATE_FINGERPRINT", + }, + { + "fullName": advisory_repo, + "githubAccount": "synthetic-owner", + "entityId": "advisory-entity-private", + "expectedAuthorEmail": "advisory-author@example.test", + "expectedSigningKey": "/synthetic/advisory-signing-key.pub", + "expectedSshHostAlias": "github.com", + }, + ], + "observedClones": [], +} +with open(registry, "w", encoding="utf-8") as handle: + json.dump(data, handle) +PY +then + echo "git-identity-gate self-test: could not generate synthetic registry" >&2 + exit 1 +fi + +mkrepo() { # name origin email signing-key -> prints path local d="$TMP/$1" - mkdir -p "$d" - git -C "$d" init -q - [[ -n "$2" ]] && git -C "$d" remote add origin "$2" - [[ -n "$3" ]] && git -C "$d" config user.email "$3" - echo "$d" + mkdir -p "$d" || return $? + "$GIT" -C "$d" init -q || return $? + if [[ -n "$2" ]]; then + "$GIT" -C "$d" remote add origin "$2" || return $? + fi + if [[ -n "$3" ]]; then + "$GIT" -C "$d" config user.email "$3" || return $? + fi + if [[ -n "$4" ]]; then + "$GIT" -C "$d" config user.signingkey "$4" || return $? + fi + printf '%s\n' "$d" || return $? } -# Pull a confirmed fixture (full_name + expected email) and a needs_confirmation -# fixture straight from the registry so the test never hardcodes repo names. -read -r FIX_OK FIX_EMAIL FIX_KEY FIX_NC < <(python3 - "$REGISTRY" <<'PY' -import json, sys -d = json.load(open(sys.argv[1])) -ent = {e["id"]: e for e in d["entities"]} -ok = okm = okk = nc = "-" -for r in d["repos"]: - s = ent.get(r.get("entityId"), {}).get("status") - if ok == "-" and s == "confirmed" and r.get("expectedAuthorEmail") and r.get("expectedSigningKey") and r.get("expectedSshHostAlias") == "github.com": - ok, okm, okk = r["fullName"], r["expectedAuthorEmail"], r["expectedSigningKey"] - if nc == "-" and s == "needs_confirmation": - nc = r["fullName"] -print(ok, okm, okk, nc) -PY -) +NONREPO_MKDIR=/bin/mkdir +make_nonrepo_fixture() { # path -> prints path + "$NONREPO_MKDIR" -p "$1" || return $? + printf '%s\n' "$1" || return $? +} + +run_gate() { # repo-path -> prints exit code + local rc=0 + GIT_IDENTITY_GATE_REGISTRY="$REGISTRY" "$PYTHON" "$GATE" "$1" >/dev/null 2>&1 || rc=$? + printf '%s\n' "$rc" +} + +run_gate_with_registry() { # repo-path registry-path -> prints exit code + local rc=0 + GIT_IDENTITY_GATE_REGISTRY="$2" "$PYTHON" "$GATE" "$1" >/dev/null 2>&1 || rc=$? + printf '%s\n' "$rc" +} echo "git-identity-gate self-test" +# --- harness: repository setup failures must never become allow fixtures --- +failing_git="$TMP/failing-git" +if ! printf '%s\n' '#!/bin/sh' 'exit 97' >"$failing_git" || \ + ! chmod 700 "$failing_git"; then + echo "git-identity-gate self-test: could not create failing Git fixture" >&2 + exit 1 +fi +failed_fixture_consumed="$TMP/failed-fixture-consumed" +make_failed_fixture_and_consume() { + local fixture_repo + fixture_repo="$(mkrepo must-not-be-consumed '' 'x@example.test' '')" || return $? + : >"$failed_fixture_consumed" || return $? + run_gate "$fixture_repo" +} +real_git="$GIT" +GIT="$failing_git" +failed_fixture_output='' +if failed_fixture_output="$(make_failed_fixture_and_consume)"; then + failed_fixture_rc=0 +else + failed_fixture_rc=$? +fi +GIT="$real_git" +check 97 "$failed_fixture_rc" "mkrepo propagates Git setup failure" +empty "$failed_fixture_output" "failed mkrepo returns no repository path" +failed_fixture_consumed_status=0 +[[ ! -e "$failed_fixture_consumed" ]] || failed_fixture_consumed_status=1 +check 0 "$failed_fixture_consumed_status" "failed fixture never reaches allow-path consumer" +failed_fixture_repo_status=0 +[[ ! -d "$TMP/must-not-be-consumed/.git" ]] || failed_fixture_repo_status=1 +check 0 "$failed_fixture_repo_status" "failed fixture is not a usable Git repository" + +failed_nonrepo_consumed="$TMP/failed-nonrepo-consumed" +make_failed_nonrepo_and_consume() { + local fixture_path + fixture_path="$(make_nonrepo_fixture "$TMP/plain-must-not-exist")" || return $? + : >"$failed_nonrepo_consumed" || return $? + run_gate "$fixture_path" +} +real_nonrepo_mkdir="$NONREPO_MKDIR" +NONREPO_MKDIR="$failing_git" +failed_nonrepo_output='' +if failed_nonrepo_output="$(make_failed_nonrepo_and_consume)"; then + failed_nonrepo_rc=0 +else + failed_nonrepo_rc=$? +fi +NONREPO_MKDIR="$real_nonrepo_mkdir" +check 97 "$failed_nonrepo_rc" "nonrepository fixture propagates mkdir failure" +empty "$failed_nonrepo_output" "failed nonrepository fixture returns no path" +failed_nonrepo_consumed_status=0 +[[ ! -e "$failed_nonrepo_consumed" ]] || failed_nonrepo_consumed_status=1 +check 0 "$failed_nonrepo_consumed_status" "failed nonrepository fixture never reaches allow assertion" +failed_nonrepo_path_status=0 +[[ ! -e "$TMP/plain-must-not-exist" ]] || failed_nonrepo_path_status=1 +check 0 "$failed_nonrepo_path_status" "failed nonrepository fixture is not created" + # --- gate: allow paths --- -plain="$TMP/plainfile" -mkdir -p "$plain" +if ! plain="$(make_nonrepo_fixture "$TMP/plain")"; then + echo "git-identity-gate self-test: could not create nonrepository fixture" >&2 + exit 1 +fi check 0 "$(run_gate "$plain")" "non-repo path allows" -check 0 "$(run_gate "$(mkrepo noorigin '' 'x@example.test')")" "repo without origin allows" -check 0 "$(run_gate "$(mkrepo unknownrepo 'git@github.com:nonexistent-xyz/repo.git' 'x@example.test')")" "repo not in registry allows" +if ! noorigin_repo="$(mkrepo noorigin '' 'x@example.test' '')"; then + echo "git-identity-gate self-test: could not create no-origin repository" >&2 + exit 1 +fi +check 0 "$(run_gate "$noorigin_repo")" "repo without origin allows" +if ! unknown_repo="$(mkrepo unknownrepo 'git@github.com:synthetic-owner/unregistered.git' 'x@example.test' '')"; then + echo "git-identity-gate self-test: could not create unregistered repository" >&2 + exit 1 +fi +check 0 "$(run_gate "$unknown_repo")" "repo not in synthetic registry allows" -# --- gate: match vs mismatch on a CONFIRMED repo --- -ok_repo="$(mkrepo okmatch "git@github.com:${FIX_OK}.git" "$FIX_EMAIL")" -git -C "$ok_repo" config user.signingkey "$FIX_KEY" # match expected key (else inherits the global one) -check 0 "$(run_gate "$ok_repo")" "matching identity allows" -bad_repo="$(mkrepo mismatch "git@github.com:${FIX_OK}.git" 'wrong@example.test')" -check 2 "$(run_gate "$bad_repo")" "mismatched identity BLOCKS (exit 2)" +# --- gate: match vs mismatch on a confirmed synthetic repo --- +if ! ok_repo="$(mkrepo okmatch "git@github.com:${FIX_OK}.git" "$FIX_EMAIL" "$FIX_KEY")"; then + echo "git-identity-gate self-test: could not create matching repository" >&2 + exit 1 +fi +check 0 "$(run_gate "$ok_repo")" "matching synthetic identity allows" +if ! bad_repo="$(mkrepo mismatch "git@github.com:${FIX_OK}.git" 'wrong-author@example.test' "$TMP/wrong-signing-key.pub")"; then + echo "git-identity-gate self-test: could not create mismatched repository" >&2 + exit 1 +fi +check 2 "$(run_gate "$bad_repo")" "mismatched synthetic identity BLOCKS (exit 2)" -# --- gate: needs_confirmation is advisory (allow even on mismatch) --- -if [[ "$FIX_NC" != "-" ]]; then - nc_repo="$(mkrepo ncrepo "git@github.com:${FIX_NC}.git" 'wrong@example.test')" - check 0 "$(run_gate "$nc_repo")" "needs_confirmation entity allows (advisory)" +# --- gate: needs_confirmation is advisory --- +if ! nc_repo="$(mkrepo advisory "git@github.com:${FIX_NC}.git" 'wrong-author@example.test' '')"; then + echo "git-identity-gate self-test: could not create advisory repository" >&2 + exit 1 fi +check 0 "$(run_gate "$nc_repo")" "needs_confirmation synthetic entity allows" # --- gate: unverifiable / override allow paths --- -rc=0; GIT_IDENTITY_GATE_REGISTRY=/nonexistent/registry.json python3 "$GATE" "$bad_repo" >/dev/null 2>&1 || rc=$? +rc=0 +GIT_IDENTITY_GATE_REGISTRY=/nonexistent/registry.json "$PYTHON" "$GATE" "$bad_repo" >/dev/null 2>&1 || rc=$? check 0 "$rc" "registry absent allows" -rc=0; GIT_IDENTITY_GATE_DISABLE=1 python3 "$GATE" "$bad_repo" >/dev/null 2>&1 || rc=$? -check 0 "$rc" "GIT_IDENTITY_GATE_DISABLE allows" +rc=0 +GIT_IDENTITY_GATE_REGISTRY="$REGISTRY" GIT_IDENTITY_GATE_DISABLE=1 "$PYTHON" "$GATE" "$bad_repo" >/dev/null 2>&1 || rc=$? +check 0 "$rc" "GIT_IDENTITY_GATE_DISABLE allows the shared gate" +malformed_registry="$TMP/registry-malformed.json" +printf '%s' '{not-json' >"$malformed_registry" +rc=0 +GIT_IDENTITY_GATE_REGISTRY="$malformed_registry" "$PYTHON" "$GATE" "$bad_repo" >/dev/null 2>&1 || rc=$? +check 0 "$rc" "malformed registry allows" + +debug_stdout="$TMP/debug-stdout" +debug_stderr="$TMP/debug-stderr" +rc=0 +GIT_IDENTITY_GATE_REGISTRY="$REGISTRY" GIT_IDENTITY_GATE_DEBUG=1 \ + "$PYTHON" "$GATE" "$ok_repo" >"$debug_stdout" 2>"$debug_stderr" || rc=$? +check 0 "$rc" "legacy debug match allows" +empty "$(<"$debug_stdout")" "legacy debug match emits no stdout" +has "git-identity-gate: allow (identity matches registry)" "$(<"$debug_stderr")" "legacy debug match reason is preserved" unresolved_registry="$TMP/registry-unresolved.json" -python3 - "$REGISTRY" "$unresolved_registry" <<'PY' -import json, sys -src, dest = sys.argv[1:3] -d = json.load(open(src)) -d.setdefault("provenance", {})["unresolvedRepoCount"] = 1 -json.dump(d, open(dest, "w")) +if ! "$PYTHON" - "$REGISTRY" "$unresolved_registry" <<'PY' +import json +import sys + +source, destination = sys.argv[1:] +with open(source, encoding="utf-8") as handle: + data = json.load(handle) +data["provenance"]["unresolvedRepoCount"] = 1 +with open(destination, "w", encoding="utf-8") as handle: + json.dump(data, handle) PY -rc=0; GIT_IDENTITY_GATE_REGISTRY="$unresolved_registry" python3 "$GATE" "$bad_repo" >/dev/null 2>&1 || rc=$? +then + echo "git-identity-gate self-test: could not generate unresolved registry" >&2 + exit 1 +fi +rc=0 +GIT_IDENTITY_GATE_REGISTRY="$unresolved_registry" "$PYTHON" "$GATE" "$bad_repo" >/dev/null 2>&1 || rc=$? check 0 "$rc" "registry with unresolved repos allows" -# --- adapter: only acts on git commit/push, blocks on a mismatch --- -adapter() { # json -> prints exit code - local rc=0 - printf '%s' "$1" | GIT_IDENTITY_GATE_BIN="$GATE" python3 "$ADAPTER" >/dev/null 2>&1 || rc=$? - echo "$rc" +# --- gate: preserve legacy invalid-entity behavior for programmatic qualification --- +registry_variants="$TMP/registry-variants" +mkdir -p "$registry_variants" +if ! "$PYTHON" - "$REGISTRY" "$registry_variants" <<'PY' +import copy +import json +import os +import sys + +source, destination = sys.argv[1:] +with open(source, encoding="utf-8") as handle: + base = json.load(handle) + +variants = {} + +data = copy.deepcopy(base) +data["repos"][0].pop("entityId") +variants["missing-entity-id.json"] = data + +data = copy.deepcopy(base) +data["repos"][0]["entityId"] = "absent-entity" +variants["entity-not-found.json"] = data + +data = copy.deepcopy(base) +data["entities"][0].pop("status") +variants["missing-status.json"] = data + +data = copy.deepcopy(base) +data["entities"][0]["status"] = "unknown" +variants["unknown-status.json"] = data + +data = copy.deepcopy(base) +data["entities"][0]["status"] = "unexpected" +variants["unrecognized-status.json"] = data + +data = copy.deepcopy(base) +data["entities"][0]["status"] = {"unexpected": True} +variants["unrecognized-status-type.json"] = data + +for filename, payload in variants.items(): + with open(os.path.join(destination, filename), "w", encoding="utf-8") as handle: + json.dump(payload, handle) +PY +then + echo "git-identity-gate self-test: could not generate entity-state registries" >&2 + exit 1 +fi + +missing_entity_id_registry="$registry_variants/missing-entity-id.json" +entity_not_found_registry="$registry_variants/entity-not-found.json" +missing_status_registry="$registry_variants/missing-status.json" +unknown_status_registry="$registry_variants/unknown-status.json" +unrecognized_status_registry="$registry_variants/unrecognized-status.json" +unrecognized_status_type_registry="$registry_variants/unrecognized-status-type.json" + +# The pre-edit receipt proves all of these legacy invocations block. Machine +# mode must not promote them to a confirmed mismatch. +check 2 "$(run_gate_with_registry "$bad_repo" "$missing_entity_id_registry")" "legacy missing entityId mismatch remains blocked" +check 2 "$(run_gate_with_registry "$bad_repo" "$entity_not_found_registry")" "legacy missing entity record mismatch remains blocked" +check 2 "$(run_gate_with_registry "$bad_repo" "$missing_status_registry")" "legacy missing entity status mismatch remains blocked" +check 2 "$(run_gate_with_registry "$bad_repo" "$unknown_status_registry")" "legacy unknown entity status mismatch remains blocked" +check 2 "$(run_gate_with_registry "$bad_repo" "$unrecognized_status_registry")" "legacy unrecognized entity status mismatch remains blocked" +check 2 "$(run_gate_with_registry "$bad_repo" "$unrecognized_status_type_registry")" "legacy non-string entity status mismatch remains blocked" + +# --- adapters: capture status and both streams explicitly --- +CAPTURE_RC=0 +CAPTURE_STDOUT='' +CAPTURE_STDERR='' +capture_adapter() { # executable registry json + local executable="$1" + local registry="$2" + local payload="$3" + local stdout_file="$TMP/adapter-stdout" + local stderr_file="$TMP/adapter-stderr" + CAPTURE_RC=0 + : >"$stdout_file" + : >"$stderr_file" + printf '%s' "$payload" | \ + GIT_IDENTITY_GATE_BIN="$GATE" GIT_IDENTITY_GATE_REGISTRY="$registry" \ + "$PYTHON" "$executable" >"$stdout_file" 2>"$stderr_file" || CAPTURE_RC=$? + CAPTURE_STDOUT="$(<"$stdout_file")" + CAPTURE_STDERR="$(<"$stderr_file")" } -check 0 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"git status\"}}")" "adapter ignores read-only git" -check 0 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"echo git commit\"}}")" "adapter ignores git words in arguments" -check 0 "$(adapter "{\"tool_name\":\"Read\",\"tool_input\":{}}")" "adapter ignores non-Bash tools" -check 2 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"git commit -m wip\"}}")" "adapter BLOCKS git commit on mismatch" -check 2 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"GIT_TRACE=1 git push\"}}")" "adapter gates env-prefixed git push" -check 0 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$ok_repo\",\"tool_input\":{\"command\":\"git push\"}}")" "adapter allows git push when identity matches" -check 2 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$ok_repo\",\"tool_input\":{\"command\":\"git -C $bad_repo commit -m wip\"}}")" "adapter gates git -C target repo" -check 2 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$ok_repo\",\"tool_input\":{\"command\":\"cd $bad_repo && git push\"}}")" "adapter gates cd-and-git target repo" - -# --- Codex reuses the Claude adapter unchanged (same Bash input shape) --- -check 2 "$(adapter "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"git commit -m wip\"}}")" "codex/claude adapter BLOCKS mismatch (shared Bash shape)" - -# --- Cursor adapter (beforeShellExecution: top-level command/cwd; deny JSON) --- + +capture_adapter "$ADAPTER" "$REGISTRY" "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"git status\"}}" +check 0 "$CAPTURE_RC" "adapter ignores read-only git" +empty "$CAPTURE_STDOUT" "read-only adapter emits no stdout" +empty "$CAPTURE_STDERR" "read-only adapter emits no stderr" +capture_adapter "$ADAPTER" "$REGISTRY" "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"echo git commit\"}}" +check 0 "$CAPTURE_RC" "adapter ignores git words in arguments" +capture_adapter "$ADAPTER" "$REGISTRY" '{"tool_name":"Read","tool_input":{}}' +check 0 "$CAPTURE_RC" "adapter ignores non-Bash tools" +capture_adapter "$ADAPTER" "$REGISTRY" "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"git commit -m wip\"}}" +check 2 "$CAPTURE_RC" "adapter BLOCKS git commit on mismatch" +has "author email mismatch" "$CAPTURE_STDERR" "legacy gate diagnostics reach PreToolUse stderr" +capture_adapter "$ADAPTER" "$REGISTRY" "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"GIT_TRACE=1 git push\"}}" +check 2 "$CAPTURE_RC" "adapter gates env-prefixed git push" +capture_adapter "$ADAPTER" "$REGISTRY" "{\"tool_name\":\"Bash\",\"cwd\":\"$ok_repo\",\"tool_input\":{\"command\":\"git push\"}}" +check 0 "$CAPTURE_RC" "adapter allows git push when identity matches" +capture_adapter "$ADAPTER" "$REGISTRY" "{\"tool_name\":\"Bash\",\"cwd\":\"$ok_repo\",\"tool_input\":{\"command\":\"git -C $bad_repo commit -m wip\"}}" +check 2 "$CAPTURE_RC" "adapter gates git -C target repo" +capture_adapter "$ADAPTER" "$REGISTRY" "{\"tool_name\":\"Bash\",\"cwd\":\"$ok_repo\",\"tool_input\":{\"command\":\"cd $bad_repo && git push\"}}" +check 2 "$CAPTURE_RC" "adapter gates cd-and-git target repo" +capture_adapter "$ADAPTER" "$missing_status_registry" "{\"tool_name\":\"Bash\",\"cwd\":\"$bad_repo\",\"tool_input\":{\"command\":\"git commit -m wip\"}}" +check 2 "$CAPTURE_RC" "legacy PreToolUse invalid-status block remains unchanged" + +# --- Cursor and Copilot adapters --- CURSOR_ADAPTER="$REPO_ROOT/home/dot_local/bin/executable_git-identity-cursor" COPILOT_ADAPTER="$REPO_ROOT/home/dot_local/bin/executable_git-identity-copilot" -emit() { printf '%s' "$2" | GIT_IDENTITY_GATE_BIN="$GATE" python3 "$1" 2>/dev/null; } -has() { case "$2" in *"$1"*) printf ' \033[32mok\033[0m %s\n' "$3"; pass=$((pass + 1)) ;; *) printf ' \033[31mFAIL\033[0m %s (missing %s)\n' "$3" "$1"; fail=$((fail + 1)) ;; esac; } -empty() { if [ -z "$1" ]; then printf ' \033[32mok\033[0m %s\n' "$2"; pass=$((pass + 1)); else printf ' \033[31mFAIL\033[0m %s (expected empty, got %s)\n' "$2" "$1"; fail=$((fail + 1)); fi; } - -has '"permission": "deny"' "$(emit "$CURSOR_ADAPTER" "{\"command\":\"git commit -m wip\",\"cwd\":\"$bad_repo\"}")" "cursor adapter denies mismatch commit" -empty "$(emit "$CURSOR_ADAPTER" "{\"command\":\"git push\",\"cwd\":\"$ok_repo\"}")" "cursor adapter allows matching push" -empty "$(emit "$CURSOR_ADAPTER" "{\"command\":\"ls -la\",\"cwd\":\"$bad_repo\"}")" "cursor adapter ignores non-git command" - -# --- Copilot adapter (preToolUse: exit-0 + stdout JSON deny; exit 2 != block) --- -has '"permissionDecision": "deny"' "$(emit "$COPILOT_ADAPTER" "{\"tool_name\":\"bash\",\"tool_input\":{\"command\":\"git commit -m wip\"},\"cwd\":\"$bad_repo\"}")" "copilot adapter (PascalCase) denies mismatch" -cc_payload=$(python3 -c 'import json,sys; print(json.dumps({"toolName":"bash","toolArgs":json.dumps({"command":"git push"}),"cwd":sys.argv[1]}))' "$bad_repo") -has '"permissionDecision": "deny"' "$(emit "$COPILOT_ADAPTER" "$cc_payload")" "copilot adapter (camelCase toolArgs string) denies mismatch" -empty "$(emit "$COPILOT_ADAPTER" "{\"tool_name\":\"bash\",\"tool_input\":{\"command\":\"git push\"},\"cwd\":\"$ok_repo\"}")" "copilot adapter allows matching push" -empty "$(emit "$COPILOT_ADAPTER" "{\"tool_name\":\"view\",\"tool_input\":{}}")" "copilot adapter ignores non-bash tool" -rc=0; printf '%s' "{\"tool_name\":\"bash\",\"tool_input\":{\"command\":\"git commit -m wip\"},\"cwd\":\"$bad_repo\"}" | GIT_IDENTITY_GATE_BIN="$GATE" python3 "$COPILOT_ADAPTER" >/dev/null 2>&1 || rc=$? -check 0 "$rc" "copilot adapter always exits 0 (deny is via stdout)" +capture_adapter "$CURSOR_ADAPTER" "$REGISTRY" "{\"command\":\"git commit -m wip\",\"cwd\":\"$bad_repo\"}" +check 0 "$CAPTURE_RC" "cursor adapter process exits 0 on deny" +has '"permission": "deny"' "$CAPTURE_STDOUT" "cursor adapter denies mismatch commit" +capture_adapter "$CURSOR_ADAPTER" "$REGISTRY" "{\"command\":\"git push\",\"cwd\":\"$ok_repo\"}" +check 0 "$CAPTURE_RC" "cursor matching process exits 0" +empty "$CAPTURE_STDOUT" "cursor adapter allows matching push" +capture_adapter "$CURSOR_ADAPTER" "$REGISTRY" "{\"command\":\"ls -la\",\"cwd\":\"$bad_repo\"}" +check 0 "$CAPTURE_RC" "cursor read-only process exits 0" +empty "$CAPTURE_STDOUT" "cursor adapter ignores non-git command" +capture_adapter "$CURSOR_ADAPTER" "$unknown_status_registry" "{\"command\":\"git commit -m wip\",\"cwd\":\"$bad_repo\"}" +check 0 "$CAPTURE_RC" "cursor invalid-status deny process remains exit 0" +has '"permission": "deny"' "$CAPTURE_STDOUT" "cursor invalid-status deny schema remains unchanged" + +capture_adapter "$COPILOT_ADAPTER" "$REGISTRY" "{\"tool_name\":\"bash\",\"tool_input\":{\"command\":\"git commit -m wip\"},\"cwd\":\"$bad_repo\"}" +check 0 "$CAPTURE_RC" "copilot deny process exits 0" +has '"permissionDecision": "deny"' "$CAPTURE_STDOUT" "copilot adapter (PascalCase) denies mismatch" +if ! cc_payload="$("$PYTHON" -c 'import json,sys; print(json.dumps({"toolName":"bash","toolArgs":json.dumps({"command":"git push"}),"cwd":sys.argv[1]}))' "$bad_repo")"; then + echo "git-identity-gate self-test: could not generate Copilot payload" >&2 + exit 1 +fi +capture_adapter "$COPILOT_ADAPTER" "$REGISTRY" "$cc_payload" +check 0 "$CAPTURE_RC" "copilot camelCase deny process exits 0" +has '"permissionDecision": "deny"' "$CAPTURE_STDOUT" "copilot adapter (camelCase toolArgs string) denies mismatch" +capture_adapter "$COPILOT_ADAPTER" "$REGISTRY" "{\"tool_name\":\"bash\",\"tool_input\":{\"command\":\"git push\"},\"cwd\":\"$ok_repo\"}" +check 0 "$CAPTURE_RC" "copilot matching process exits 0" +empty "$CAPTURE_STDOUT" "copilot adapter allows matching push" +capture_adapter "$COPILOT_ADAPTER" "$REGISTRY" '{"tool_name":"view","tool_input":{}}' +check 0 "$CAPTURE_RC" "copilot non-bash process exits 0" +empty "$CAPTURE_STDOUT" "copilot adapter ignores non-bash tool" +capture_adapter "$COPILOT_ADAPTER" "$unrecognized_status_type_registry" "{\"tool_name\":\"bash\",\"tool_input\":{\"command\":\"git commit -m wip\"},\"cwd\":\"$bad_repo\"}" +check 0 "$CAPTURE_RC" "copilot invalid-status deny process remains exit 0" +has '"permissionDecision": "deny"' "$CAPTURE_STDOUT" "copilot invalid-status deny schema remains unchanged" + +# --- exact-base legacy receipt + strict programmatic machine matrix --- +gate_matrix_results="$TMP/gate-matrix-results" +if ! "$PYTHON" -I - \ + "$GATE" "$BASE_GATE" "$REGISTRY" "$ok_repo" "$bad_repo" "$nc_repo" \ + "$unknown_repo" "$plain" "$registry_variants" "$TMP" "$ADAPTER" \ + "$CURSOR_ADAPTER" "$COPILOT_ADAPTER" >"$gate_matrix_results" <<'PY' +import contextlib +import copy +import ast +import hashlib +import io +import json +import os +import pwd +import re +import runpy +import shutil +import subprocess +import sys +import traceback +from pathlib import Path + +( + gate, + base_gate, + registry, + ok_repo, + bad_repo, + advisory_repo, + unknown_repo, + nonrepo, + registry_variants, + temp_root, + pretool_adapter, + cursor_adapter, + copilot_adapter, +) = sys.argv[1:] + +EXPECTED_BASE_OID = "1302b8e5392577b0b3f094e6353803c64de45330" +EXPECTED_BASE_SHA256 = "8c9581ac80fc09b3cb07e2589515204c9e8b62aa87703ada3c160b7b90790267" +MACHINE_BOOTSTRAP = 'import runpy,sys; runpy.run_path(sys.argv[1])["run_machine_v1"]()' +MACHINE_CONFIRMED = '{"protocol":"git-identity-gate/v1","outcome":"confirmed_mismatch"}\n' +MACHINE_NOT_CONFIRMED = '{"protocol":"git-identity-gate/v1","outcome":"no_confirmed_mismatch"}\n' +canonical_home = pwd.getpwuid(os.getuid()).pw_dir +resolved_python = os.path.realpath(sys.executable) +base_environment = { + "HOME": canonical_home, + "PATH": "/usr/bin:/bin", + "LANG": "C", + "LC_ALL": "C", +} +producer_checker = os.environ.get("Q1B_PRODUCER_CHECKER") +if producer_checker is not None: + checker_path = Path(producer_checker) + if ( + not checker_path.is_absolute() + or not checker_path.is_file() + or not os.access(checker_path, os.R_OK) + ): + raise RuntimeError("configured Q1B_PRODUCER_CHECKER is not a usable file") + + +PRODUCER_ACCEPTED = "accepted" +PRODUCER_CONTRACT_REJECTED = "contract_rejected" +PRODUCER_CHECKER_ERROR = "checker_error" +PRODUCER_FAILURE_FOOTER = re.compile( + rb"check-registry-contract: FAIL \(([1-9][0-9]*) contract violation\(s\)\)\n\Z" +) + + +def classify_producer_checker(status, stdout, stderr, path): + if not isinstance(stdout, bytes) or not isinstance(stderr, bytes): + return PRODUCER_CHECKER_ERROR + try: + expected_success = f"check-registry-contract: OK ({path})\n".encode( + "utf-8", errors="strict" + ) + except UnicodeEncodeError: + return PRODUCER_CHECKER_ERROR + if status == 0 and stdout == expected_success and stderr == b"": + return PRODUCER_ACCEPTED + + if ( + status == 1 + and stdout == b"" + and b"\r" not in stderr + and b"\x00" not in stderr + and b"Traceback (most recent call last):" not in stderr + ): + footer = PRODUCER_FAILURE_FOOTER.search(stderr) + diagnostics_raw = stderr[: footer.start()] if footer is not None else b"" + diagnostics = ( + diagnostics_raw[:-1].split(b"\n") + if diagnostics_raw.endswith(b"\n") + else [] + ) + if footer is not None and diagnostics: + try: + decoded_diagnostics = [line.decode("utf-8", errors="strict") for line in diagnostics] + except UnicodeDecodeError: + decoded_diagnostics = [] + if ( + int(footer.group(1)) == len(decoded_diagnostics) + and all( + line.startswith("check-registry-contract: ERROR: ") + for line in decoded_diagnostics + ) + ): + return PRODUCER_CONTRACT_REJECTED + + return PRODUCER_CHECKER_ERROR + + +def run_producer_checker(path, *, checker_path=producer_checker): + if checker_path is None: + raise RuntimeError("authoritative producer checker is not configured") + status = None + stdout = b"" + stderr = b"" + try: + result = subprocess.run( + [resolved_python, "-I", checker_path, "--path", str(path)], + capture_output=True, + env=base_environment, + timeout=5, + ) + status = result.returncode + stdout = result.stdout + stderr = result.stderr + if not isinstance(stdout, bytes) or not isinstance(stderr, bytes): + raise RuntimeError("producer checker did not return binary streams") + except subprocess.TimeoutExpired as exc: + if exc.stdout is not None and not isinstance(exc.stdout, bytes): + raise RuntimeError("producer checker timeout stdout was not bytes") + if exc.stderr is not None and not isinstance(exc.stderr, bytes): + raise RuntimeError("producer checker timeout stderr was not bytes") + stdout = exc.stdout if isinstance(exc.stdout, bytes) else b"" + stderr = exc.stderr if isinstance(exc.stderr, bytes) else b"" + except OSError: + pass + return { + "status": status, + "stdout": stdout, + "stderr": stderr, + "outcome": classify_producer_checker(status, stdout, stderr, path), + } + + +def require_producer_outcome(label, path, expected): + receipt = run_producer_checker(path) + if receipt["outcome"] != expected: + raise AssertionError( + f"{label}: expected producer {expected}, got {receipt['outcome']}" + ) + return receipt + + +def report(label, test): + try: + test() + except BaseException: + traceback.print_exc(file=sys.stderr) + print("1|" + label) + else: + print("0|" + label) + + +def load_without_main(path): + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + namespace = runpy.run_path(path) + assert stdout.getvalue() == "" + assert stderr.getvalue() == "" + return namespace + + +base_namespace = load_without_main(base_gate) +gate_namespace = load_without_main(gate) + + +@contextlib.contextmanager +def process_state(*, cwd, env): + saved_cwd = os.getcwd() + saved_env = dict(os.environ) + try: + os.chdir(cwd) + os.environ.clear() + os.environ.update(env) + yield + finally: + os.environ.clear() + os.environ.update(saved_env) + os.chdir(saved_cwd) + + +def capture_callable(function, argv, *, cwd, env): + stdout = io.StringIO() + stderr = io.StringIO() + status = 0 + with process_state(cwd=cwd, env=env): + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + function(argv) + except SystemExit as exc: + status = exc.code if isinstance(exc.code, int) else 1 + return { + "argv": list(argv), + "cwd": str(cwd), + "environment": dict(env), + "status": status, + "stdout": stdout.getvalue(), + "stderr": stderr.getvalue(), + } + + +def legacy_environment(registry_path, **extra): + result = {**base_environment, "GIT_IDENTITY_GATE_REGISTRY": str(registry_path)} + result.update(extra) + return result + + +def compare_legacy(argv, *, cwd, registry_path, extra_env=None): + env = legacy_environment(registry_path, **(extra_env or {})) + base_argv = [base_gate, *argv] + current_argv = [gate, *argv] + expected = capture_callable( + base_namespace["main"], base_argv, cwd=cwd, env=env + ) + actual = capture_callable( + gate_namespace["main"], current_argv, cwd=cwd, env=env + ) + assert expected["argv"][1:] == actual["argv"][1:] == list(argv) + assert expected["cwd"] == actual["cwd"] == str(cwd) + assert expected["environment"] == actual["environment"] == env + for key in ("status", "stdout", "stderr"): + assert actual[key] == expected[key], (key, argv, expected[key], actual[key]) + return actual + + +def make_repo(name, origin, email, signing_key): + path = Path(temp_root, "gate-matrix-repos", name) + path.mkdir(parents=True) + subprocess.run( + ["/usr/bin/git", "init", "-q", str(path)], + check=True, + env=base_environment, + ) + if origin: + subprocess.run( + ["/usr/bin/git", "-C", str(path), "remote", "add", "origin", origin], + check=True, + env=base_environment, + ) + if email: + subprocess.run( + ["/usr/bin/git", "-C", str(path), "config", "user.email", email], + check=True, + env=base_environment, + ) + if signing_key: + subprocess.run( + ["/usr/bin/git", "-C", str(path), "config", "user.signingkey", signing_key], + check=True, + env=base_environment, + ) + return str(path) + + +def exact_base_fixture_is_pinned(): + assert hashlib.sha256(Path(base_gate).read_bytes()).hexdigest() == EXPECTED_BASE_SHA256 + repository_root = Path(gate).parents[3] + history_probe = subprocess.run( + [ + "/usr/bin/git", + "-C", + str(repository_root), + "cat-file", + "-e", + EXPECTED_BASE_OID + "^{commit}", + ], + capture_output=True, + env=base_environment, + timeout=5, + ) + if history_probe.returncode == 0: + historical_gate = subprocess.run( + [ + "/usr/bin/git", + "-C", + str(repository_root), + "show", + EXPECTED_BASE_OID + + ":home/dot_local/bin/executable_git-identity-gate", + ], + capture_output=True, + env=base_environment, + timeout=5, + ) + assert historical_gate.returncode == 0 + assert historical_gate.stdout == Path(base_gate).read_bytes() + print( + "RECEIPT|embedded legacy oracle base OID and blob match Git history" + ) + else: + print( + "SKIP|Git history unavailable; embedded legacy oracle hash still verified" + ) + names = { + "_registry_path", + "_git", + "_host_alias", + "_full_name", + "_allow", + "_block", + "_run", + "main", + } + + def function_asts(path): + tree = ast.parse(Path(path).read_text(encoding="utf-8")) + return { + node.name: ast.dump(node, include_attributes=False) + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name in names + } + + def global_assignment_ast(path, name): + tree = ast.parse(Path(path).read_text(encoding="utf-8")) + matches = [ + ast.dump(node.value, include_attributes=False) + for node in tree.body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == name for target in node.targets) + ] + assert len(matches) == 1 + return matches[0] + + assert function_asts(gate) == function_asts(base_gate) + assert global_assignment_ast(gate, "DEFAULT_REGISTRY") == global_assignment_ast( + base_gate, "DEFAULT_REGISTRY" + ) + + +report("embedded legacy oracle is the exact base gate", exact_base_fixture_is_pinned) + + +def producer_checker_completion_classifier(): + path = "/synthetic/registry.json" + success = f"check-registry-contract: OK ({path})\n".encode("utf-8") + rejected = ( + b"check-registry-contract: ERROR: synthetic violation\n" + b"check-registry-contract: FAIL (1 contract violation(s))\n" + ) + assert classify_producer_checker(0, success, b"", path) == PRODUCER_ACCEPTED + assert ( + classify_producer_checker(1, b"", rejected, path) + == PRODUCER_CONTRACT_REJECTED + ) + abnormal = [ + (0, b"", b""), + (0, success[:-1], b""), + (0, success[:-1] + b"\r\n", b""), + (0, success + b"unexpected\n", b""), + (0, success + b"\x00", b""), + (0, b"\xff", b""), + (1, b"", b"check-registry-contract: ERROR: no footer\n"), + (1, b"", rejected[:-1]), + (1, b"", rejected + b"\n"), + (1, b"", rejected + b"unexpected\n"), + (1, b"", rejected[:-1] + b"\r\n"), + (1, b"", rejected[:-1] + b"\x00\n"), + ( + 1, + b"", + b"check-registry-contract: ERROR: invalid-utf8-\xff\n" + b"check-registry-contract: FAIL (1 contract violation(s))\n", + ), + (1, b"", b"Traceback (most recent call last):\nTypeError\n"), + (1, b"unexpected\n", rejected), + (2, b"", b"check-registry-contract: ERROR: read failed\n"), + (-9, b"", b""), + (3, b"", b""), + ] + for status, stdout, stderr in abnormal: + assert ( + classify_producer_checker(status, stdout, stderr, path) + == PRODUCER_CHECKER_ERROR + ) + + def run_fake(name, status, stdout, stderr): + fake_checker = Path(temp_root, f"fake-checker-{name}.py") + fake_checker.write_text( + "import sys\n" + f"sys.stdout.buffer.write({stdout!r})\n" + f"sys.stderr.buffer.write({stderr!r})\n" + f"raise SystemExit({status})\n", + encoding="utf-8", + ) + return run_producer_checker(path, checker_path=str(fake_checker)) + + transport_cases = [ + ("lf-success", 0, success, b"", PRODUCER_ACCEPTED), + ("crlf-success", 0, success[:-1] + b"\r\n", b"", PRODUCER_CHECKER_ERROR), + ("lf-failure", 1, b"", rejected, PRODUCER_CONTRACT_REJECTED), + ( + "crlf-failure", + 1, + b"", + rejected.replace(b"\n", b"\r\n"), + PRODUCER_CHECKER_ERROR, + ), + ("unterminated-footer", 1, b"", rejected[:-1], PRODUCER_CHECKER_ERROR), + ] + for name, status, stdout, stderr, expected in transport_cases: + receipt = run_fake(name, status, stdout, stderr) + assert receipt["status"] == status + assert receipt["stdout"] == stdout + assert receipt["stderr"] == stderr + assert receipt["outcome"] == expected + + +report( + "authoritative checker status and completion markers classify outcomes", + producer_checker_completion_classifier, +) + + +def programmatic_load_does_not_invoke_legacy_main(): + assert "run_machine_v1" in gate_namespace + assert gate_namespace["main"] is not gate_namespace["run_machine_v1"] + + +report("programmatic gate load does not invoke legacy main", programmatic_load_does_not_invoke_legacy_main) + + +def complete_legacy_receipt_matches_base(): + with open(registry, encoding="utf-8") as handle: + registry_data = json.load(handle) + expected_email = registry_data["repos"][0]["expectedAuthorEmail"] + expected_key = registry_data["repos"][0]["expectedSigningKey"] + full_name = registry_data["repos"][0]["fullName"] + match = make_repo("legacy-match", f"git@github.com:{full_name}.git", expected_email, expected_key) + email_mismatch = make_repo( + "legacy-email-mismatch", f"git@github.com:{full_name}.git", "wrong@example.test", expected_key + ) + key_mismatch = make_repo( + "legacy-key-mismatch", f"git@github.com:{full_name}.git", expected_email, str(Path(temp_root, "wrong-key.pub")) + ) + host_mismatch = make_repo( + "legacy-host-mismatch", f"git@wrong-host.invalid:{full_name}.git", expected_email, expected_key + ) + malformed = Path(temp_root, "legacy-malformed-registry.json") + malformed.write_text("{not-json", encoding="utf-8") + missing = Path(temp_root, "legacy-missing-registry.json") + + cases = [ + ("registered_confirmed_match", [match], match, registry, {}), + ("registered_confirmed_email_mismatch", [email_mismatch], email_mismatch, registry, {}), + ("registered_confirmed_signing_key_mismatch", [key_mismatch], key_mismatch, registry, {}), + ("registered_confirmed_origin_host_mismatch", [host_mismatch], host_mismatch, registry, {}), + ("needs_confirmation", [advisory_repo], advisory_repo, registry, {}), + ("missing_entityId", [email_mismatch], email_mismatch, str(Path(registry_variants, "missing-entity-id.json")), {}), + ("entity_ID_not_found", [email_mismatch], email_mismatch, str(Path(registry_variants, "entity-not-found.json")), {}), + ("missing_entity_status", [email_mismatch], email_mismatch, str(Path(registry_variants, "missing-status.json")), {}), + ("unknown_status", [email_mismatch], email_mismatch, str(Path(registry_variants, "unknown-status.json")), {}), + ("unrecognized_status", [email_mismatch], email_mismatch, str(Path(registry_variants, "unrecognized-status.json")), {}), + ("unrecognized_status_type", [email_mismatch], email_mismatch, str(Path(registry_variants, "unrecognized-status-type.json")), {}), + ("unregistered_repository", [unknown_repo], unknown_repo, registry, {}), + ("missing_registry", [email_mismatch], email_mismatch, str(missing), {}), + ("malformed_registry", [email_mismatch], email_mismatch, str(malformed), {}), + ("disable_override", [email_mismatch], email_mismatch, registry, {"GIT_IDENTITY_GATE_DISABLE": "1"}), + ("debug_confirmed_match", [match], match, registry, {"GIT_IDENTITY_GATE_DEBUG": "1"}), + ("debug_needs_confirmation", [advisory_repo], advisory_repo, registry, {"GIT_IDENTITY_GATE_DEBUG": "1"}), + ] + receipt = {} + for name, argv, cwd, registry_path, extra in cases: + receipt[name] = compare_legacy( + argv, cwd=cwd, registry_path=registry_path, extra_env=extra + ) + + adapter_cases = [ + ( + "pretool_codex_claude_confirmed_email_mismatch", + pretool_adapter, + json.dumps({"tool_name": "Bash", "cwd": email_mismatch, "tool_input": {"command": "git commit -m wip"}}), + ), + ( + "cursor_confirmed_email_mismatch", + cursor_adapter, + json.dumps({"command": "git commit -m wip", "cwd": email_mismatch}), + ), + ( + "copilot_confirmed_email_mismatch", + copilot_adapter, + json.dumps({"tool_name": "bash", "tool_input": {"command": "git commit -m wip"}, "cwd": email_mismatch}), + ), + ] + for name, adapter, payload in adapter_cases: + results = [] + for authority in (base_gate, gate): + env = { + **legacy_environment(registry), + "GIT_IDENTITY_GATE_BIN": authority, + } + result = subprocess.run( + [resolved_python, "-I", adapter], + input=payload, + text=True, + capture_output=True, + env=env, + timeout=5, + ) + results.append((result.returncode, result.stdout, result.stderr)) + assert results[1] == results[0], (name, results) + receipt[name] = results[1] + + expected_names = { + "registered_confirmed_match", + "registered_confirmed_email_mismatch", + "registered_confirmed_signing_key_mismatch", + "registered_confirmed_origin_host_mismatch", + "needs_confirmation", + "missing_entityId", + "entity_ID_not_found", + "missing_entity_status", + "unknown_status", + "unrecognized_status", + "unrecognized_status_type", + "unregistered_repository", + "missing_registry", + "malformed_registry", + "disable_override", + "debug_confirmed_match", + "debug_needs_confirmation", + "pretool_codex_claude_confirmed_email_mismatch", + "cursor_confirmed_email_mismatch", + "copilot_confirmed_email_mismatch", + } + assert set(receipt) == expected_names + + +report("complete 20-case legacy receipt is byte-identical to exact base", complete_legacy_receipt_matches_base) + + +def legacy_argv_grammar_matches_base(): + argv_parent = Path(temp_root, "argv-collision-repos") + argv_parent.mkdir() + for name in ("--", "--machine-v1", "--machine-protocol", "-flag-like"): + path = make_repo( + "../argv-collision-repos/" + name, + "git@github.com:synthetic-owner/registered-repo.git", + "wrong-author@example.test", + str(Path(temp_root, "wrong-signing-key.pub")), + ) + assert Path(path).name == name + + cases = [ + ([], bad_repo), + ([bad_repo], nonrepo), + ([bad_repo, ok_repo], nonrepo), + ([bad_repo, ok_repo, "ignored-third"], nonrepo), + ([ok_repo], nonrepo), + ([ok_repo, bad_repo], nonrepo), + ([ok_repo, bad_repo, "ignored-third"], nonrepo), + (["--"], str(argv_parent)), + (["--machine-v1"], str(argv_parent)), + (["--machine-protocol"], str(argv_parent)), + (["-flag-like"], str(argv_parent)), + (["other-flag-like"], str(argv_parent)), + ] + env = legacy_environment(registry) + observed = {} + for argv, cwd in cases: + results = [] + for executable in (base_gate, gate): + result = subprocess.run( + [resolved_python, "-I", executable, *argv], + cwd=cwd, + env=env, + text=True, + capture_output=True, + timeout=5, + ) + results.append((result.returncode, result.stdout, result.stderr)) + assert results[1] == results[0], (argv, cwd, results) + observed[tuple(argv)] = results[1] + + assert observed[(bad_repo, ok_repo)] == observed[(bad_repo,)] + assert observed[(bad_repo, ok_repo, "ignored-third")] == observed[(bad_repo,)] + assert observed[(ok_repo, bad_repo)] == observed[(ok_repo,)] + assert observed[("--machine-v1",)][0] == 2 + assert observed[("--machine-v1",)][1] == "" + assert "git-identity-gate: BLOCKED" in observed[("--machine-v1",)][2] + assert "git-identity-gate/v1" not in observed[("--machine-v1",)][2] + + +report("legacy CLI argv grammar and collisions match exact base", legacy_argv_grammar_matches_base) + + +def capture_machine(registry_path, repo_path): + stdout = io.StringIO() + stderr = io.StringIO() + status = 0 + env = legacy_environment(registry_path) + with process_state(cwd=repo_path, env=env): + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + gate_namespace["run_machine_v1"]() + except SystemExit as exc: + status = exc.code if isinstance(exc.code, int) else 1 + return status, stdout.getvalue(), stderr.getvalue() + + +def real_programmatic_machine_entrypoint(): + assert capture_machine(registry, ok_repo) == (0, MACHINE_NOT_CONFIRMED, "") + assert capture_machine(registry, bad_repo) == (2, MACHINE_CONFIRMED, "") + direct = subprocess.run( + [resolved_python, "-I", "-c", MACHINE_BOOTSTRAP, gate], + cwd=bad_repo, + env=legacy_environment(registry), + text=True, + capture_output=True, + timeout=5, + ) + assert (direct.returncode, direct.stdout, direct.stderr) == ( + 2, + MACHINE_CONFIRMED, + "", + ) + forbidden = [ + registry, + "synthetic-owner", + "registered-repo", + "confirmed-entity-private", + "registered-author@example.test", + "wrong-author@example.test", + "synthetic-signing-key.pub", + "SHA256:SYNTHETIC_PRIVATE_FINGERPRINT", + "github.com", + ] + assert all(value not in direct.stdout + direct.stderr for value in forbidden) + + +report("programmatic real-gate entrypoint is qualified and private", real_programmatic_machine_entrypoint) + + +with open(registry, encoding="utf-8") as handle: + strict_base = json.load(handle) + +fixture_counter = 0 +strict_fixture_count = 0 + + +def write_fixture(label, payload=None, raw=None): + global fixture_counter + fixture_counter += 1 + path = Path(temp_root, "strict-machine-fixtures", f"{fixture_counter:04d}-{label}.json") + path.parent.mkdir(exist_ok=True) + if raw is not None: + path.write_text(raw, encoding="utf-8") + else: + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def verify_fixture(label, payload=None, raw=None, *, confirmed=False, repo_path=bad_repo): + global strict_fixture_count + strict_fixture_count += 1 + path = write_fixture(label, payload=payload, raw=raw) + status, stdout, stderr = capture_machine(path, repo_path) + expected = (2, MACHINE_CONFIRMED, "") if confirmed else (0, MACHINE_NOT_CONFIRMED, "") + assert (status, stdout, stderr) == expected, (label, status, stdout, stderr) + compare_legacy([repo_path], cwd=repo_path, registry_path=path) + assert "PRIVATE_MACHINE_SENTINEL" not in stdout + stderr + return path + + +MISSING = object() +shape_values = [ + ("null", None), + ("array-empty", []), + ("array-truthy", [1]), + ("object-empty", {}), + ("object-truthy", {"x": 1}), + ("integer-zero", 0), + ("integer-one", 1), + ("float-zero", 0.0), + ("float-truthy", 1.5), + ("false", False), + ("true", True), + ("empty-string", ""), + ("whitespace-string", " "), + ("surrounding-whitespace-string", " value "), + ("valid-string", "value"), +] + + +def set_or_remove(container, key, value): + if value is MISSING: + container.pop(key, None) + else: + container[key] = copy.deepcopy(value) + + +def strict_registry_type_matrix(): + root_values = shape_values + [("valid-object", strict_base)] + for label, value in root_values: + verify_fixture("root-" + label, payload=copy.deepcopy(value), confirmed=value is strict_base) + + field_cases = [ + ("schemaVersion", lambda data: data, "schemaVersion", "1"), + ("provenance", lambda data: data, "provenance", strict_base["provenance"]), + ("visibility", lambda data: data["provenance"], "visibility", "internal"), + ("unresolvedRepoCount", lambda data: data["provenance"], "unresolvedRepoCount", 0), + ("repos", lambda data: data, "repos", strict_base["repos"]), + ("entities", lambda data: data, "entities", strict_base["entities"]), + ] + for field, select, key, valid_value in field_cases: + values = [("missing", MISSING), *shape_values, ("valid", valid_value)] + for label, value in values: + data = copy.deepcopy(strict_base) + set_or_remove(select(data), key, value) + valid = label == "valid" + if field == "schemaVersion" and value == "1": + valid = True + if field == "unresolvedRepoCount" and type(value) is int and value == 0: + valid = True + verify_fixture(f"{field}-{label}", payload=data, confirmed=valid) + + +report("strict root, provenance, repos, and entities type matrix", strict_registry_type_matrix) + + +def strict_record_field_matrix(): + observed_name = strict_base["repos"][0]["fullName"] + matched_entity = strict_base["repos"][0]["entityId"] + record_cases = [ + ("fullName", lambda data: data["repos"][0], "fullName", observed_name), + ("entityId", lambda data: data["repos"][0], "entityId", matched_entity), + ("entity-id", lambda data: data["entities"][0], "id", matched_entity), + ("entity-status", lambda data: data["entities"][0], "status", "confirmed"), + ] + for field, select, key, valid_value in record_cases: + for label, value in [("missing", MISSING), *shape_values, ("valid", valid_value)]: + data = copy.deepcopy(strict_base) + set_or_remove(select(data), key, value) + verify_fixture(f"{field}-{label}", payload=data, confirmed=label == "valid") + + invalid_elements = [ + ("null", None), + ("array-empty", []), + ("array-truthy", [1]), + ("object-empty", {}), + ("object-truthy", {"x": 1}), + ("integer", 1), + ("float", 1.5), + ("false", False), + ("true", True), + ("string", "invalid"), + ] + for collection in ("repos", "entities"): + for position in ("before", "after"): + for label, value in invalid_elements: + data = copy.deepcopy(strict_base) + index = 0 if position == "before" else len(data[collection]) + data[collection].insert(index, copy.deepcopy(value)) + verify_fixture(f"{collection}-element-{position}-{label}", payload=data) + + +report("strict repo/entity records and unrelated elements matrix", strict_record_field_matrix) + + +def strict_global_record_validation(): + cases = [] + + data = copy.deepcopy(strict_base) + data["repos"][1]["fullName"] = " " + cases.append(("unrelated-repo-whitespace-fullName", data)) + + data = copy.deepcopy(strict_base) + data["repos"][1]["entityId"] = ["PRIVATE_MACHINE_SENTINEL"] + cases.append(("unrelated-repo-array-entityId", data)) + + data = copy.deepcopy(strict_base) + data["repos"][1]["expectedAuthorEmail"] = ["PRIVATE_MACHINE_SENTINEL"] + cases.append(("unrelated-repo-array-expected-email", data)) + + data = copy.deepcopy(strict_base) + data["entities"][1]["id"] = " " + cases.append(("unrelated-entity-whitespace-id", data)) + + data = copy.deepcopy(strict_base) + data["entities"][1]["status"] = ["PRIVATE_MACHINE_SENTINEL"] + cases.append(("unrelated-entity-array-status", data)) + + data = copy.deepcopy(strict_base) + data["repos"][0]["entityId"] = " " + data["entities"][0]["id"] = " " + cases.append(("matched-paired-whitespace-identifiers", data)) + + data = copy.deepcopy(strict_base) + data["repos"][0]["expectedSigningKey"] = "/absolute/key.pub " + cases.append(("matched-signing-key-trailing-whitespace", data)) + + for label, payload in cases: + verify_fixture(label, payload=payload) + + +report("complete registry records are strictly validated before selection", strict_global_record_validation) + + +def strict_optional_identity_matrix(): + for field in ( + "expectedAuthorEmail", + "expectedSigningKey", + "expectedSshHostAlias", + ): + valid_value = strict_base["repos"][0][field] + cases = [("missing", MISSING), *shape_values, ("valid", valid_value)] + if field == "expectedSigningKey": + cases.extend( + [ + ("relative", "relative/key.pub"), + ("leading-whitespace", " /absolute/key.pub"), + ("trailing-whitespace-absolute", "/absolute/key.pub "), + ] + ) + for label, value in cases: + data = copy.deepcopy(strict_base) + set_or_remove(data["repos"][0], field, value) + verify_fixture( + f"{field}-{label}", + payload=data, + confirmed=label == "valid", + ) + + null_host = copy.deepcopy(strict_base) + null_host["entities"][0]["sshHostAlias"] = None + null_host["repos"][0]["expectedSshHostAlias"] = None + verify_fixture("expectedSshHostAlias-explicit-null", payload=null_host, confirmed=True) + + home_key = "~/synthetic-signing-key.pub" + home_expanded = copy.deepcopy(strict_base) + home_expanded["gitIdentityLanes"]["confirmed-lane"]["signingKey"] = home_key + home_expanded["entities"][0]["expectedSigningKey"] = home_key + home_expanded["repos"][0]["expectedSigningKey"] = home_key + verify_fixture("expectedSigningKey-home-expanded", payload=home_expanded, confirmed=True) + + +report("strict optional identity field matrix", strict_optional_identity_matrix) + + +def producer_contract_mutation_matrix(): + fixture_dir = Path(temp_root, "producer-contract-invalid-fixtures") + fixture_dir.mkdir() + mutations = [] + external_counts = { + "accepted_controls": 0, + "contract_rejected": 0, + "checker_error": 0, + "conservative_consumer_only": 0, + "producer_rejected_machine_confirmed": 0, + } + + def add(label, mutate): + data = copy.deepcopy(strict_base) + mutate(data) + mutations.append((label, data)) + + add("root-array", lambda data: data.clear() or data.update({"invalid": True})) + mutations[-1] = ("root-array", []) + add("schema-version", lambda data: data.__setitem__("schemaVersion", 1)) + add("generated-at-missing", lambda data: data.pop("generatedAt")) + add("generated-at-whitespace", lambda data: data.__setitem__("generatedAt", " ")) + add("provenance-container", lambda data: data.__setitem__("provenance", [])) + add("provenance-source-missing", lambda data: data["provenance"].pop("source")) + add("provenance-source", lambda data: data["provenance"].__setitem__("source", "wrong")) + add("provenance-visibility-missing", lambda data: data["provenance"].pop("visibility")) + add("provenance-visibility", lambda data: data["provenance"].__setitem__("visibility", "public")) + add("provenance-repo-source-missing", lambda data: data["provenance"].pop("repoSource")) + add("provenance-repo-source", lambda data: data["provenance"].__setitem__("repoSource", "other")) + add("provenance-entities-version-missing", lambda data: data["provenance"].pop("entitiesVersion")) + add("provenance-entities-version-bool", lambda data: data["provenance"].__setitem__("entitiesVersion", False)) + add("provenance-entity-count-missing", lambda data: data["provenance"].pop("entityCount")) + add("provenance-entity-count-bool", lambda data: data["provenance"].__setitem__("entityCount", False)) + add("provenance-entity-count", lambda data: data["provenance"].__setitem__("entityCount", 99)) + add("provenance-repo-count-missing", lambda data: data["provenance"].pop("repoCount")) + add("provenance-repo-count-string", lambda data: data["provenance"].__setitem__("repoCount", "2")) + add("provenance-repo-count", lambda data: data["provenance"].__setitem__("repoCount", 99)) + add("provenance-observed-count-missing", lambda data: data["provenance"].pop("observedCloneCount")) + add("provenance-observed-count-null", lambda data: data["provenance"].__setitem__("observedCloneCount", None)) + add("provenance-observed-count", lambda data: data["provenance"].__setitem__("observedCloneCount", 1)) + add("provenance-unresolved-missing", lambda data: data["provenance"].pop("unresolvedRepoCount")) + add("provenance-unresolved-bool", lambda data: data["provenance"].__setitem__("unresolvedRepoCount", False)) + add("provenance-unresolved", lambda data: data["provenance"].__setitem__("unresolvedRepoCount", 1)) + + add("lanes-container", lambda data: data.__setitem__("gitIdentityLanes", [])) + add("lane-whitespace-name", lambda data: data["gitIdentityLanes"].__setitem__(" ", data["gitIdentityLanes"].pop("advisory-lane"))) + add("lane-nonobject", lambda data: data["gitIdentityLanes"].__setitem__("advisory-lane", [])) + add("lane-author-email-missing", lambda data: data["gitIdentityLanes"]["advisory-lane"].pop("authorEmail")) + add("lane-signing-key-missing", lambda data: data["gitIdentityLanes"]["advisory-lane"].pop("signingKey")) + add("lane-signing-key-array", lambda data: data["gitIdentityLanes"]["advisory-lane"].__setitem__("signingKey", ["PRIVATE_MACHINE_SENTINEL"])) + + add("entities-container", lambda data: data.__setitem__("entities", {})) + add("entity-nonobject", lambda data: data["entities"].__setitem__(1, [])) + add("entity-id-missing", lambda data: data["entities"][1].pop("id")) + add("entity-status-missing", lambda data: data["entities"][1].pop("status")) + add("entity-status-array", lambda data: data["entities"][1].__setitem__("status", ["PRIVATE_MACHINE_SENTINEL"])) + add("entity-link-policy-missing", lambda data: data["entities"][1].pop("linkPolicy")) + add("entity-link-policy", lambda data: data["entities"][1].__setitem__("linkPolicy", "invalid")) + add("entity-host-missing", lambda data: data["entities"][1].pop("sshHostAlias")) + add("entity-host-array", lambda data: data["entities"][1].__setitem__("sshHostAlias", ["PRIVATE_MACHINE_SENTINEL"])) + add("entity-lane-missing", lambda data: data["entities"][1].pop("gitIdentity")) + add("entity-lane-unknown", lambda data: data["entities"][1].__setitem__("gitIdentity", "unknown-lane")) + add("entity-email-missing", lambda data: data["entities"][1].pop("expectedAuthorEmail")) + add("entity-email-array", lambda data: data["entities"][1].__setitem__("expectedAuthorEmail", ["PRIVATE_MACHINE_SENTINEL"])) + add("entity-email-lane-disagreement", lambda data: data["entities"][1].__setitem__("expectedAuthorEmail", "different@example.test")) + add("entity-key-missing", lambda data: data["entities"][1].pop("expectedSigningKey")) + add("entity-key-array", lambda data: data["entities"][1].__setitem__("expectedSigningKey", ["PRIVATE_MACHINE_SENTINEL"])) + add("entity-key-lane-disagreement", lambda data: data["entities"][1].__setitem__("expectedSigningKey", "/different/key.pub")) + + def duplicate_entity(data): + data["entities"].append(copy.deepcopy(data["entities"][0])) + data["provenance"]["entityCount"] += 1 + + add("entity-duplicate-id", duplicate_entity) + add("account-defaults-container", lambda data: data.__setitem__("accountDefaults", [])) + add("account-default-empty-key", lambda data: data["accountDefaults"].__setitem__("", "confirmed-entity-private")) + add("account-default-array-value", lambda data: data["accountDefaults"].__setitem__("synthetic-owner", ["PRIVATE_MACHINE_SENTINEL"])) + add("account-default-unknown-entity", lambda data: data["accountDefaults"].__setitem__("synthetic-owner", "unknown-entity")) + + add("repos-container", lambda data: data.__setitem__("repos", {})) + add("repo-nonobject", lambda data: data["repos"].__setitem__(1, [])) + add("repo-full-name-missing", lambda data: data["repos"][1].pop("fullName")) + add("repo-full-name-invalid-form", lambda data: data["repos"][1].__setitem__("fullName", "synthetic-owner/not/a-repo")) + add("repo-github-account-missing", lambda data: data["repos"][1].pop("githubAccount")) + add("repo-github-owner-disagreement", lambda data: data["repos"][1].__setitem__("githubAccount", "different-owner")) + add("repo-entity-id-array", lambda data: data["repos"][1].__setitem__("entityId", ["PRIVATE_MACHINE_SENTINEL"])) + add("repo-entity-id-unknown", lambda data: data["repos"][1].__setitem__("entityId", "unknown-entity")) + add("repo-email-missing", lambda data: data["repos"][1].pop("expectedAuthorEmail")) + add("repo-email-array", lambda data: data["repos"][1].__setitem__("expectedAuthorEmail", ["PRIVATE_MACHINE_SENTINEL"])) + add("repo-entity-email-disagreement", lambda data: data["repos"][1].__setitem__("expectedAuthorEmail", "different@example.test")) + add("repo-key-missing", lambda data: data["repos"][1].pop("expectedSigningKey")) + add("repo-key-array", lambda data: data["repos"][1].__setitem__("expectedSigningKey", ["PRIVATE_MACHINE_SENTINEL"])) + add("repo-entity-key-disagreement", lambda data: data["repos"][1].__setitem__("expectedSigningKey", "/different/key.pub")) + add("repo-host-missing", lambda data: data["repos"][1].pop("expectedSshHostAlias")) + add("repo-host-array", lambda data: data["repos"][1].__setitem__("expectedSshHostAlias", ["PRIVATE_MACHINE_SENTINEL"])) + add("repo-entity-host-disagreement", lambda data: data["repos"][1].__setitem__("expectedSshHostAlias", "different-host.invalid")) + + def duplicate_repo(data): + data["repos"].append(copy.deepcopy(data["repos"][0])) + data["provenance"]["repoCount"] += 1 + + add("repo-duplicate-full-name", duplicate_repo) + add("observed-container", lambda data: data.__setitem__("observedClones", {})) + + def set_observed(data, rows): + data["observedClones"] = rows + data["provenance"]["observedCloneCount"] = len(rows) + + valid_clone = { + "fullName": strict_base["repos"][0]["fullName"], + "remoteHostAlias": "github.com", + "clonePath": "/synthetic/clone", + "isCloned": True, + } + add("observed-nonobject", lambda data: set_observed(data, [[]])) + add("observed-full-name-missing", lambda data: set_observed(data, [{key: value for key, value in valid_clone.items() if key != "fullName"}])) + add("observed-unknown-repo", lambda data: set_observed(data, [{**valid_clone, "fullName": "synthetic-owner/unknown"}])) + add("observed-host-missing", lambda data: set_observed(data, [{key: value for key, value in valid_clone.items() if key != "remoteHostAlias"}])) + add("observed-host-array", lambda data: set_observed(data, [{**valid_clone, "remoteHostAlias": ["PRIVATE_MACHINE_SENTINEL"]}])) + add("observed-clone-path-missing", lambda data: set_observed(data, [{key: value for key, value in valid_clone.items() if key != "clonePath"}])) + add("observed-is-cloned-missing", lambda data: set_observed(data, [{key: value for key, value in valid_clone.items() if key != "isCloned"}])) + add("observed-is-cloned-false", lambda data: set_observed(data, [{**valid_clone, "isCloned": False}])) + add("observed-duplicate", lambda data: set_observed(data, [copy.deepcopy(valid_clone), copy.deepcopy(valid_clone)])) + + if producer_checker is not None: + require_producer_outcome( + "valid baseline", + registry, + PRODUCER_ACCEPTED, + ) + external_counts["accepted_controls"] += 1 + + for label, payload in mutations: + path = fixture_dir / f"{label}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + verify_fixture("producer-contract-" + label, payload=payload) + if producer_checker is not None: + expected = ( + PRODUCER_CHECKER_ERROR + if label == "entity-status-array" + else PRODUCER_CONTRACT_REJECTED + ) + receipt = require_producer_outcome(label, path, expected) + external_counts[expected] += 1 + if label == "entity-status-array": + assert receipt["status"] == 1 + assert b"check-registry-contract: FAIL (" not in receipt["stderr"] + assert b"Traceback (most recent call last):" in receipt["stderr"] + + many_to_one = copy.deepcopy(strict_base) + many_to_one["repos"].append( + { + **copy.deepcopy(many_to_one["repos"][0]), + "fullName": "synthetic-owner/second-confirmed-repo", + } + ) + many_to_one["provenance"]["repoCount"] += 1 + many_to_one_path = verify_fixture( + "producer-contract-many-repos-one-entity", + payload=many_to_one, + confirmed=True, + ) + if producer_checker is not None: + require_producer_outcome( + "many repositories to one entity", + many_to_one_path, + PRODUCER_ACCEPTED, + ) + external_counts["accepted_controls"] += 1 + + relative_signing_key = copy.deepcopy(strict_base) + for container in ( + relative_signing_key["gitIdentityLanes"]["confirmed-lane"], + relative_signing_key["entities"][0], + relative_signing_key["repos"][0], + ): + field = "signingKey" if "authorEmail" in container else "expectedSigningKey" + container[field] = "relative/synthetic-key.pub" + relative_path = verify_fixture( + "producer-accepted-consumer-only-relative-signing-key", + payload=relative_signing_key, + ) + + compact = json.dumps(strict_base, separators=(",", ":")) + duplicate_schema = compact.replace( + '{"schemaVersion":"1"', + '{"schemaVersion":"1","schemaVersion":"1"', + 1, + ) + duplicate_path = verify_fixture( + "producer-accepted-consumer-only-duplicate-key", + raw=duplicate_schema, + ) + + if producer_checker is None: + print( + "SKIP|authoritative producer differential not configured " + "(Q1B_PRODUCER_CHECKER unset)" + ) + else: + for label, path in ( + ("relative signing-key consumer check", relative_path), + ("duplicate-key consumer check", duplicate_path), + ): + require_producer_outcome(label, path, PRODUCER_ACCEPTED) + external_counts["conservative_consumer_only"] += 1 + print( + "RECEIPT|authoritative producer differential " + + " ".join( + f"{key}={value}" for key, value in external_counts.items() + ) + ) + assert len(mutations) >= 60 + + +report( + "machine contract fixtures and many-to-one cardinality", + producer_contract_mutation_matrix, +) + + +def strict_duplicates_combinations_and_privacy(): + duplicate_repo = copy.deepcopy(strict_base) + duplicate_repo["repos"].append(copy.deepcopy(duplicate_repo["repos"][0])) + verify_fixture("duplicate-matching-repo", payload=duplicate_repo) + + duplicate_entity = copy.deepcopy(strict_base) + duplicate_entity["entities"].append(copy.deepcopy(duplicate_entity["entities"][0])) + verify_fixture("duplicate-matching-entity", payload=duplicate_entity) + + invalid_optional = copy.deepcopy(strict_base) + invalid_optional["repos"][0]["expectedSshHostAlias"] = ["PRIVATE_MACHINE_SENTINEL"] + verify_fixture("valid-mismatch-plus-invalid-optional", payload=invalid_optional) + + observed_missing = copy.deepcopy(strict_base) + missing_observed_repo = make_repo( + "machine-missing-observed", + "git@github.com:synthetic-owner/registered-repo.git", + "", + "", + ) + subprocess.run( + ["/usr/bin/git", "-C", missing_observed_repo, "config", "user.email", ""], + check=True, + env=base_environment, + ) + subprocess.run( + ["/usr/bin/git", "-C", missing_observed_repo, "config", "user.signingkey", ""], + check=True, + env=base_environment, + ) + verify_fixture("missing-observed-not-mismatch", payload=observed_missing, repo_path=missing_observed_repo) + + compact = json.dumps(strict_base, separators=(",", ":")) + duplicate_raw = { + "root": compact.replace('{"schemaVersion":"1"', '{"schemaVersion":"1","schemaVersion":"1"', 1), + "provenance": compact.replace('"visibility":"internal"', '"visibility":"internal","visibility":"internal"', 1), + "repo": compact.replace('"fullName":"synthetic-owner/registered-repo"', '"fullName":"synthetic-owner/registered-repo","fullName":"synthetic-owner/registered-repo"', 1), + "entity": compact.replace('"id":"confirmed-entity-private"', '"id":"confirmed-entity-private","id":"confirmed-entity-private"', 1), + "optional": compact.replace('"expectedAuthorEmail":"registered-author@example.test"', '"expectedAuthorEmail":"registered-author@example.test","expectedAuthorEmail":"PRIVATE_MACHINE_SENTINEL"', 1), + } + for label, raw in duplicate_raw.items(): + verify_fixture("duplicate-key-" + label, raw=raw) + + assert strict_fixture_count >= 200 + + +report("strict duplicates, missing observations, invalid combinations, and privacy", strict_duplicates_combinations_and_privacy) + + +def machine_exception_is_generic(): + function = gate_namespace["run_machine_v1"] + globals_map = function.__globals__ + original = globals_map["_machine_confirmed_mismatch"] + try: + globals_map["_machine_confirmed_mismatch"] = lambda: (_ for _ in ()).throw(RuntimeError("PRIVATE_MACHINE_SENTINEL")) + status = 0 + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + function() + except SystemExit as exc: + status = exc.code + assert (status, stdout.getvalue(), stderr.getvalue()) == ( + 0, + MACHINE_NOT_CONFIRMED, + "", + ) + finally: + globals_map["_machine_confirmed_mismatch"] = original + + +report("machine exceptions emit only generic nonconfirmed outcome", machine_exception_is_generic) +PY +then + check 0 1 "gate legacy/strict Python sub-suite executes" +else + while IFS='|' read -r case_rc case_label; do + case "$case_rc" in + 0) check 0 "$case_rc" "$case_label" ;; + SKIP) printf ' \033[33mSKIP\033[0m %s\n' "$case_label" ;; + RECEIPT) printf ' \033[36mreceipt\033[0m %s\n' "$case_label" ;; + *) check 0 "$case_rc" "$case_label" ;; + esac + done <"$gate_matrix_results" +fi + +# --- SessionStart announcer --- +# The Python sub-suite imports the source directly so its only injection seam is +# the explicit gate_path/gate_env keyword pair on the decision function. +session_results="$TMP/sessionstart-results" +if ! "$PYTHON" -I - "$SESSIONSTART" "$GATE" "$REGISTRY" "$ok_repo" "$bad_repo" "$nc_repo" "$unknown_repo" "$plain" "$REPO_ROOT" "$registry_variants" "$TMP" >"$session_results" <<'PY' +import contextlib +import copy +import importlib.machinery +import importlib.util +import inspect +import io +import json +import os +import pwd +import shlex +import shutil +import stat +import subprocess +import sys +import traceback +from pathlib import Path + +( + source, + gate, + registry, + ok_repo, + bad_repo, + advisory_repo, + unknown_repo, + nonrepo, + repo_root, + registry_variants, + temp_root, +) = sys.argv[1:] + + +class NoStdinReads: + def _fail(self, *args, **kwargs): + raise AssertionError("extensionless import touched stdin") + + read = _fail + readline = _fail + readlines = _fail + __iter__ = _fail + + @property + def buffer(self): + return self._fail() + + +loader = importlib.machinery.SourceFileLoader("git_identity_sessionstart", source) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +old_stdin = sys.stdin +try: + sys.stdin = NoStdinReads() + spec.loader.exec_module(module) +finally: + sys.stdin = old_stdin + + +def report(label, test): + try: + test() + except BaseException: + traceback.print_exc(file=sys.stderr) + print("1|" + label) + else: + print("0|" + label) + + +canonical_home = pwd.getpwuid(os.getuid()).pw_dir +production_env = { + "HOME": canonical_home, + "PATH": "/usr/bin:/bin", + "LANG": "C", + "LC_ALL": "C", +} +minimal_gate_env = { + **production_env, + # The explicit test seam may supply a usable temporary directory for the + # Xcode Python runtime inside a sandbox. Production never includes it. + "TMPDIR": temp_root, + "GIT_IDENTITY_GATE_REGISTRY": registry, +} + + +def quiet_call(cwd, *, gate_path=gate, gate_env=minimal_gate_env): + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + result = module.sessionstart_announcement( + cwd, + gate_path=gate_path, + gate_env=gate_env, + ) + assert stdout.getvalue() == "" + assert stderr.getvalue() == "" + return result + + +def run_source(raw): + return subprocess.run( + ["/usr/bin/python3", "-I", source], + input=raw, + text=True, + capture_output=True, + timeout=3, + ) + + +def assert_silent_process(raw): + result = run_source(raw) + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def mismatch_payload(): + payload = quiet_call(bad_repo) + assert isinstance(payload, dict) + return payload + + +git_environment_names = [ + "GIT_IDENTITY_GATE_DISABLE", + "GIT_IDENTITY_GATE_REGISTRY", + "GIT_IDENTITY_GATE_BIN", + "GIT_IDENTITY_GATE_DEBUG", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_PREFIX", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_CONFIG", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_TRACE", + "GIT_OPTIONAL_LOCKS", + "GIT_OTHER_REPOSITORY_CONTEXT", +] + + +poison_environment_names = git_environment_names + [ + "HOME", + "PATH", + "XDG_CONFIG_HOME", + "XDG_CONFIG_DIRS", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "PYTHONHOME", + "PYTHONPATH", + "PYTHONUSERBASE", + "PYTHONSTARTUP", + "PYTHONINSPECT", + "PYTHONSAFEPATH", + "PYTHONNOUSERSITE", + "__PYVENV_LAUNCHER__", + "VIRTUAL_ENV", + "CONDA_PREFIX", + "PYENV_ROOT", + "MISE_DATA_DIR", + "MISE_CONFIG_DIR", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "DYLD_INSERT_LIBRARIES", + "LD_LIBRARY_PATH", + "DEVELOPER_DIR", + "SDKROOT", + "TOOLCHAINS", + "COMMAND_MODE", + "SSH_AUTH_SOCK", + "TMPDIR", + "TMP", + "TEMP", + "PWD", + "OLDPWD", + "CDPATH", + "USER", + "LOGNAME", + "EMAIL", + "SHELL", + "PAGER", + "LESS", + "NO_COLOR", + "TZ", + "LANG", + "LC_ALL", + "LC_CTYPE", +] + + +def production_allowlist(): + saved = dict(os.environ) + try: + os.environ.update({name: "/forged/q1b-value" for name in poison_environment_names}) + os.environ["GIT_ARBITRARY_FUTURE_CONTROL"] = "poisoned" + env = module._production_gate_env() + finally: + os.environ.clear() + os.environ.update(saved) + assert env == production_env + + +report("extensionless import never reads stdin or calls main", lambda: None) +report("production child environment is an exact trusted allowlist", production_allowlist) + + +def production_main_wiring(): + captured = {} + original = module.sessionstart_announcement + old_stdin, old_stdout, old_stderr = sys.stdin, sys.stdout, sys.stderr + + def fake(cwd, *, gate_path, gate_env): + captured.update(cwd=cwd, gate_path=gate_path, gate_env=gate_env) + return None + + try: + module.sessionstart_announcement = fake + sys.stdin = io.StringIO(json.dumps({"cwd": ok_repo})) + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + module.main() + assert sys.stdout.getvalue() == "" + assert sys.stderr.getvalue() == "" + finally: + module.sessionstart_announcement = original + sys.stdin, sys.stdout, sys.stderr = old_stdin, old_stdout, old_stderr + + assert captured["gate_path"] == module._FIXED_GATE_PATH + assert os.path.isabs(captured["gate_path"]) + assert Path(captured["gate_path"]).name == "git-identity-gate" + assert captured["gate_env"] == production_env + assert list(inspect.signature(module.main).parameters) == [] + + +report("production main fixes gate path and exposes no bypass", production_main_wiring) + + +def invalid_canonical_home_fails_open(): + original = module.pwd.getpwuid + + class InvalidPasswd: + pw_dir = "relative/home" + + try: + module.pwd.getpwuid = lambda uid: InvalidPasswd() + assert module._production_gate_env() is None + module.pwd.getpwuid = lambda uid: (_ for _ in ()).throw(KeyError(uid)) + assert module._production_gate_env() is None + finally: + module.pwd.getpwuid = original + + +report("invalid passwd home fails open", invalid_canonical_home_fails_open) + + +def fixed_subprocess_wiring(): + calls = [] + original = module.subprocess.run + protocol = '{"protocol":"git-identity-gate/v1","outcome":"confirmed_mismatch"}\n' + + def fake_run(argv, **kwargs): + calls.append((list(argv), dict(kwargs))) + if argv[0] == "/usr/bin/git": + return subprocess.CompletedProcess(argv, 0, stdout=bad_repo + "\n", stderr="") + return subprocess.CompletedProcess(argv, 2, stdout=protocol, stderr="") + + try: + module.subprocess.run = fake_run + result = module.sessionstart_announcement( + bad_repo, + gate_path=gate, + gate_env=production_env, + ) + finally: + module.subprocess.run = original + + assert result is not None + assert len(calls) == 2 + git_argv, git_kwargs = calls[0] + gate_argv, gate_kwargs = calls[1] + trusted_interpreter = module._trusted_interpreter() + assert trusted_interpreter is not None + assert git_argv == ["/usr/bin/git", "-C", bad_repo, "rev-parse", "--show-toplevel"] + assert gate_argv == [ + trusted_interpreter, + "-I", + "-c", + module._MACHINE_BOOTSTRAP, + gate, + ] + assert os.path.realpath(sys.executable) == trusted_interpreter + if sys.platform == "darwin": + assert trusted_interpreter != "/usr/bin/python3" + assert git_kwargs["cwd"] == "/" + assert gate_kwargs["cwd"] == bad_repo + for kwargs in (git_kwargs, gate_kwargs): + assert kwargs["env"] == production_env + assert kwargs["stdin"] is subprocess.DEVNULL + assert kwargs.get("shell", False) is False + assert kwargs["capture_output"] is True + + +report("repository and gate subprocesses use fixed executables and environment", fixed_subprocess_wiring) + + +def trusted_interpreter_validation_matrix(): + actual = module._trusted_interpreter() + assert actual == os.path.realpath(sys.executable) + actual_stat = os.stat(actual) + assert actual_stat.st_uid == 0 + assert stat.S_ISREG(actual_stat.st_mode) + assert actual_stat.st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + assert not actual_stat.st_mode & (stat.S_IWGRP | stat.S_IWOTH) + + interpreter_link = Path(temp_root, "trusted-interpreter-link") + interpreter_link.symlink_to(actual) + original_executable = module.sys.executable + original_stat = module.os.stat + try: + module.sys.executable = str(interpreter_link) + assert module._trusted_interpreter() == actual + + cases = [ + ("", None), + ("relative/python", None), + (str(Path(temp_root, "missing-interpreter")), None), + ] + for executable, expected in cases: + module.sys.executable = executable + assert module._trusted_interpreter() is expected + + class Metadata: + def __init__(self, mode, uid): + self.st_mode = mode + self.st_uid = uid + + fake_path = str(Path(temp_root, "synthetic-interpreter")) + metadata_cases = [ + (stat.S_IFDIR | 0o755, 0), + (stat.S_IFIFO | 0o755, 0), + (stat.S_IFREG | 0o755, os.getuid() or 501), + (stat.S_IFREG | 0o775, 0), + (stat.S_IFREG | 0o757, 0), + (stat.S_IFREG | 0o644, 0), + ] + module.sys.executable = fake_path + for mode, uid in metadata_cases: + module.os.stat = lambda path, mode=mode, uid=uid: Metadata(mode, uid) + assert module._trusted_interpreter() is None + + module.os.stat = lambda path: (_ for _ in ()).throw(OSError("missing")) + assert module._trusted_interpreter() is None + finally: + module.sys.executable = original_executable + module.os.stat = original_stat + + +report("resolved sys.executable trust validation matrix", trusted_interpreter_validation_matrix) + + +def rejected_interpreter_starts_no_subprocess(): + original_executable = module.sys.executable + original_run = module.subprocess.run + calls = [] + try: + module.sys.executable = "relative/python" + module.subprocess.run = lambda *args, **kwargs: calls.append((args, kwargs)) + assert quiet_call(bad_repo) is None + assert calls == [] + finally: + module.sys.executable = original_executable + module.subprocess.run = original_run + + +report("interpreter rejection starts neither Git nor gate", rejected_interpreter_starts_no_subprocess) + + +def main_warning_contract(): + warning = { + "systemMessage": "synthetic warning", + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "synthetic context", + }, + } + original = module.sessionstart_announcement + old_stdin, old_stdout, old_stderr = sys.stdin, sys.stdout, sys.stderr + try: + module.sessionstart_announcement = lambda cwd, *, gate_path, gate_env: warning + sys.stdin = io.StringIO(json.dumps({"cwd": bad_repo})) + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + result = module.main() + rendered = sys.stdout.getvalue() + assert result is None + assert json.loads(rendered) == warning + assert sys.stderr.getvalue() == "" + finally: + module.sessionstart_announcement = original + sys.stdin, sys.stdout, sys.stderr = old_stdin, old_stdout, old_stderr + + +report("confirmed mismatch main path returns normally with valid JSON", main_warning_contract) +report("correct registered agent root is silent", lambda: (_ for _ in ()).throw(AssertionError()) if quiet_call(ok_repo) is not None else None) +report("confirmed owner mismatch emits warning JSON", lambda: mismatch_payload()) + + +def required_schema(): + payload = mismatch_payload() + assert isinstance(payload.get("systemMessage"), str) and payload["systemMessage"].strip() + specific = payload.get("hookSpecificOutput") + assert isinstance(specific, dict) + assert specific.get("hookEventName") == "SessionStart" + assert isinstance(specific.get("additionalContext"), str) + assert specific["additionalContext"].strip() + json.loads(json.dumps(payload)) + + +report("warning JSON has the required SessionStart schema", required_schema) + + +def output_privacy(): + rendered = json.dumps(mismatch_payload()) + forbidden = [ + registry, + str(Path(registry).parent), + "synthetic-owner", + "registered-repo", + "confirmed-entity-private", + "Synthetic Private Entity", + "registered-author@example.test", + "wrong-author@example.test", + "synthetic-signing-key.pub", + "wrong-signing-key.pub", + "SHA256:SYNTHETIC_PRIVATE_FINGERPRINT", + "synthetic-host-detail", + "github.com", + ] + assert all(value not in rendered for value in forbidden) + + +report("warning output contains no private identity evidence", output_privacy) +report("malformed input fails open silently", lambda: assert_silent_process("{not-json")) +report("missing cwd fails open silently", lambda: assert_silent_process("{}")) +report("relative cwd fails open silently", lambda: assert_silent_process('{"cwd":"relative/path"}')) +report("invalid absolute cwd fails open silently", lambda: assert_silent_process('{"cwd":"/definitely/not/a/real/q1b/path"}')) +report("nonrepository cwd fails open silently", lambda: (_ for _ in ()).throw(AssertionError()) if quiet_call(nonrepo) is not None else None) +report("unregistered repository fails open silently", lambda: (_ for _ in ()).throw(AssertionError()) if quiet_call(unknown_repo) is not None else None) +report("missing gate fails open silently", lambda: (_ for _ in ()).throw(AssertionError()) if quiet_call(bad_repo, gate_path=str(Path(nonrepo) / "missing-gate")) is not None else None) + + +def synthetic_gate(name, source_text): + path = Path(temp_root, name) + path.write_text(source_text, encoding="utf-8") + return str(path) + + +valid_protocol = '{"protocol":"git-identity-gate/v1","outcome":"confirmed_mismatch"}\n' + + +def valid_protocol_warns(): + fake = synthetic_gate( + "valid-machine-gate.py", + "import sys\ndef run_machine_v1():\n" + + " sys.stdout.write(" + repr(valid_protocol) + ")\n" + + " sys.exit(2)\n", + ) + assert quiet_call(bad_repo, gate_path=fake) is not None + + +report("qualified machine protocol warns", valid_protocol_warns) + + +def unqualified_exit_two_is_silent(name, stdout, stderr=""): + fake = synthetic_gate( + name, + "import sys\ndef run_machine_v1():\n" + + " sys.stdout.write(" + repr(stdout) + ")\n" + + " sys.stderr.write(" + repr(stderr) + ")\n" + + " sys.exit(2)\n", + ) + assert quiet_call(bad_repo, gate_path=fake) is None + + +report("exit 2 without output is silent", lambda: unqualified_exit_two_is_silent("exit-two-empty.py", "")) +report("exit 2 with human stderr is silent", lambda: unqualified_exit_two_is_silent("exit-two-human.py", "", "human mismatch text\n")) +report("exit 2 with malformed JSON is silent", lambda: unqualified_exit_two_is_silent("exit-two-malformed.py", "{not-json")) +report( + "exit 2 with wrong protocol is silent", + lambda: unqualified_exit_two_is_silent( + "exit-two-wrong-protocol.py", + '{"protocol":"git-identity-gate/v2","outcome":"confirmed_mismatch"}\n', + ), +) +report( + "exit 2 with wrong outcome is silent", + lambda: unqualified_exit_two_is_silent( + "exit-two-wrong-outcome.py", + '{"protocol":"git-identity-gate/v1","outcome":"no_confirmed_mismatch"}\n', + ), +) +report( + "exit 2 with unexpected fields is silent", + lambda: unqualified_exit_two_is_silent( + "exit-two-extra-field.py", + '{"protocol":"git-identity-gate/v1","outcome":"confirmed_mismatch","detail":"private"}\n', + ), +) +report( + "exit 2 with duplicate fields is silent", + lambda: unqualified_exit_two_is_silent( + "exit-two-duplicate-field.py", + '{"protocol":"git-identity-gate/v1","protocol":"git-identity-gate/v1","outcome":"confirmed_mismatch"}\n', + ), +) +report( + "exit 2 with trailing protocol output is silent", + lambda: unqualified_exit_two_is_silent( + "exit-two-trailing.py", + valid_protocol + "trailing-output\n", + ), +) +report( + "valid-looking protocol plus stderr is silent", + lambda: unqualified_exit_two_is_silent( + "exit-two-stderr.py", + valid_protocol, + "unexpected stderr\n", + ), +) + + +def missing_machine_function_is_silent(): + fake = synthetic_gate("missing-machine-function.py", "VALUE = 1\n") + assert quiet_call(bad_repo, gate_path=fake) is None + + +report("missing programmatic machine function is silent", missing_machine_function_is_silent) + + +def wrong_machine_status_is_silent(): + fake = synthetic_gate( + "wrong-machine-status.py", + "import sys\ndef run_machine_v1():\n" + + " sys.stdout.write(" + repr(valid_protocol) + ")\n", + ) + assert quiet_call(bad_repo, gate_path=fake) is None + + +report("valid protocol with wrong child status is silent", wrong_machine_status_is_silent) + + +def launcher_open_failure_is_silent(): + missing = str(Path(temp_root, "disappeared-gate.py")) + original = module.os.path.isfile + try: + module.os.path.isfile = lambda path: True if path == missing else original(path) + assert quiet_call(bad_repo, gate_path=missing) is None + finally: + module.os.path.isfile = original + + +report("gate open failure is silent", launcher_open_failure_is_silent) + + +def timeout_fails_open(): + slow_gate = Path(nonrepo) / "slow-gate.py" + slow_gate.write_text( + "import time\ndef run_machine_v1():\n time.sleep(2)\n", + encoding="utf-8", + ) + old_timeout = module._TIMEOUT_SECONDS + module._TIMEOUT_SECONDS = 0.1 + try: + assert quiet_call(bad_repo, gate_path=str(slow_gate)) is None + finally: + module._TIMEOUT_SECONDS = old_timeout + + +report("gate timeout fails open silently", timeout_fails_open) + + +def internal_failure_fails_open(): + failing_gate = Path(nonrepo) / "failing-gate.py" + failing_gate.write_text( + "raise RuntimeError('private bootstrap failure')\n", + encoding="utf-8", + ) + assert quiet_call(bad_repo, gate_path=str(failing_gate)) is None + + +report("bootstrap import failure fails open without leaking output", internal_failure_fails_open) + + +def real_gate_integration(): + assert set(minimal_gate_env) == { + "HOME", + "PATH", + "LANG", + "LC_ALL", + "TMPDIR", + "GIT_IDENTITY_GATE_REGISTRY", + } + assert quiet_call(ok_repo, gate_path=gate, gate_env=minimal_gate_env) is None + assert quiet_call(bad_repo, gate_path=gate, gate_env=minimal_gate_env) is not None + + +report("synthetic real-gate integration uses explicit test injection", real_gate_integration) + + +def gate_env_for(registry_path): + return {**minimal_gate_env, "GIT_IDENTITY_GATE_REGISTRY": str(registry_path)} + + +def globally_invalid_registry_records_are_silent(): + with open(registry, encoding="utf-8") as handle: + base = json.load(handle) + cases = [] + + data = copy.deepcopy(base) + data["repos"][1]["fullName"] = " " + cases.append(("unrelated-repo-whitespace-fullName", data)) + + data = copy.deepcopy(base) + data["repos"][1]["entityId"] = ["PRIVATE_MACHINE_SENTINEL"] + cases.append(("unrelated-repo-array-entityId", data)) + + data = copy.deepcopy(base) + data["repos"][1]["expectedAuthorEmail"] = ["PRIVATE_MACHINE_SENTINEL"] + cases.append(("unrelated-repo-array-expected-email", data)) + + data = copy.deepcopy(base) + data["entities"][1]["id"] = " " + cases.append(("unrelated-entity-whitespace-id", data)) + + data = copy.deepcopy(base) + data["entities"][1]["status"] = ["PRIVATE_MACHINE_SENTINEL"] + cases.append(("unrelated-entity-array-status", data)) + + data = copy.deepcopy(base) + data["repos"][0]["entityId"] = " " + data["entities"][0]["id"] = " " + cases.append(("matched-paired-whitespace-identifiers", data)) + + data = copy.deepcopy(base) + data["repos"][0]["expectedSigningKey"] = "/absolute/key.pub " + cases.append(("matched-signing-key-trailing-whitespace", data)) + + fixture_dir = Path(temp_root, "sessionstart-global-registry-fixtures") + fixture_dir.mkdir() + for label, payload in cases: + path = fixture_dir / f"{label}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + assert quiet_call( + bad_repo, + gate_path=gate, + gate_env=gate_env_for(path), + ) is None + + +report("globally invalid registry records are silent through SessionStart", globally_invalid_registry_records_are_silent) + + +def producer_contract_mutations_are_silent(): + fixture_dir = Path(temp_root, "producer-contract-invalid-fixtures") + fixtures = sorted(fixture_dir.glob("*.json")) + assert len(fixtures) >= 60 + for path in fixtures: + assert quiet_call( + bad_repo, + gate_path=gate, + gate_env=gate_env_for(path), + ) is None + + +report("contract mutation matrix is silent through SessionStart", producer_contract_mutations_are_silent) + + +def unconfirmed_entity_states_are_silent(): + variants = [ + "missing-entity-id.json", + "entity-not-found.json", + "missing-status.json", + "unknown-status.json", + "unrecognized-status.json", + "unrecognized-status-type.json", + ] + for filename in variants: + path = Path(registry_variants, filename) + assert quiet_call(bad_repo, gate_path=gate, gate_env=gate_env_for(path)) is None + assert quiet_call(advisory_repo, gate_path=gate, gate_env=minimal_gate_env) is None + assert quiet_call( + bad_repo, + gate_path=gate, + gate_env=gate_env_for(Path(temp_root, "registry-malformed.json")), + ) is None + assert quiet_call( + bad_repo, + gate_path=gate, + gate_env=gate_env_for(Path(temp_root, "registry-missing.json")), + ) is None + + +report("programmatic entrypoint never confirms invalid or advisory entity states", unconfirmed_entity_states_are_silent) + + +def machine_gate_output_is_private(): + interpreter = module._trusted_interpreter() + assert interpreter is not None + result = subprocess.run( + [interpreter, "-I", "-c", module._MACHINE_BOOTSTRAP, gate], + cwd=bad_repo, + env=minimal_gate_env, + text=True, + capture_output=True, + timeout=3, + ) + assert result.returncode == 2 + assert result.stderr == "" + assert json.loads(result.stdout) == { + "protocol": "git-identity-gate/v1", + "outcome": "confirmed_mismatch", + } + forbidden = [ + registry, + "synthetic-owner", + "registered-repo", + "confirmed-entity-private", + "registered-author@example.test", + "wrong-author@example.test", + "synthetic-signing-key.pub", + "SHA256:SYNTHETIC_PRIVATE_FINGERPRINT", + "github.com", + ] + assert all(value not in result.stdout for value in forbidden) + + +report("machine gate protocol is exact and privacy-safe", machine_gate_output_is_private) + + +def fixed_shebang_ignores_path_selected_python(): + runtime = Path(temp_root, "fixed-shebang-runtime") + runtime.mkdir(exist_ok=True) + deployed_session = runtime / "git-identity-sessionstart" + deployed_gate = runtime / "git-identity-gate" + shutil.copyfile(source, deployed_session) + shutil.copyfile(gate, deployed_gate) + deployed_session.chmod(0o755) + deployed_gate.chmod(0o755) + + fake_bin = runtime / "fake-bin" + fake_bin.mkdir(exist_ok=True) + fake_python_marker = runtime / "fake-python-ran" + fake_python = fake_bin / "python3" + fake_python.write_text( + "#!/bin/sh\nprintf x > " + shlex.quote(str(fake_python_marker)) + "\nexit 91\n", + encoding="utf-8", + ) + fake_python.chmod(0o755) + + direct_env = dict(os.environ) + direct_env["PATH"] = str(fake_bin) + result = subprocess.run( + [str(deployed_session)], + input="{", + text=True, + capture_output=True, + env=direct_env, + timeout=5, + ) + assert deployed_session.read_bytes().splitlines()[0] == b"#!/usr/bin/python3 -I" + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + assert not fake_python_marker.exists() + + +report("fixed shebang bypasses PATH-selected Python", fixed_shebang_ignores_path_selected_python) + + +SANITIZED_REGISTRATION_COMMAND = ( + "/usr/bin/env -i PATH=/usr/bin:/bin LANG=C LC_ALL=C " + "/usr/bin/python3 -I /Users/verlyn13/.local/bin/git-identity-sessionstart " + "2>/dev/null || :" +) + + +def sanitized_production_launcher_boundary(): + runtime = Path(temp_root, "sanitized-launcher-runtime") + runtime.mkdir(exist_ok=True) + deployed_session = runtime / "git-identity-sessionstart" + deployed_gate = runtime / "git-identity-gate" + shutil.copyfile(source, deployed_session) + deployed_session.chmod(0o755) + + fixture_repos = {} + for name in ["correct", "mismatch", "unconfirmed", "malformed-protocol", "gate-error"]: + fixture_repo = runtime / name + fixture_repo.mkdir() + subprocess.run( + ["/usr/bin/git", "init", "-q", str(fixture_repo)], + check=True, + capture_output=True, + text=True, + env=production_env, + timeout=3, + ) + fixture_repos[name] = fixture_repo + + fake_bin = runtime / "fake-bin" + fake_bin.mkdir() + fake_python_marker = runtime / "fake-python-ran" + fake_git_marker = runtime / "fake-git-ran" + fake_python = fake_bin / "python3" + fake_git = fake_bin / "git" + fake_python.write_text( + "#!/bin/sh\nprintf x > " + shlex.quote(str(fake_python_marker)) + "\nexit 91\n", + encoding="utf-8", + ) + fake_git.write_text( + "#!/bin/sh\nprintf x > " + shlex.quote(str(fake_git_marker)) + "\nexit 92\n", + encoding="utf-8", + ) + fake_python.chmod(0o755) + fake_git.chmod(0o755) + + python_path = runtime / "python-path" + python_path.mkdir() + site_marker = runtime / "sitecustomize-ran" + (python_path / "sitecustomize.py").write_text( + "from pathlib import Path\nPath(" + repr(str(site_marker)) + ").write_text('loaded')\n", + encoding="utf-8", + ) + + forged_home = runtime / "forged-home" + forged_registry = forged_home / "Repos/verlyn13/meta-inventory/exports/registry.json" + forged_registry.parent.mkdir(parents=True) + shutil.copyfile(registry, forged_registry) + + forged_env = { + name: str(runtime / "poison" / name.lower()) for name in poison_environment_names + } + forged_env.update( + { + "HOME": str(forged_home), + "PATH": str(fake_bin), + "LANG": "forged_LOCALE", + "LC_ALL": "forged_LOCALE", + "PYTHONPATH": str(python_path), + "__PYVENV_LAUNCHER__": str(fake_python), + "CONDA_DEFAULT_ENV": "forged", + "PYENV_VERSION": "forged", + "MISE_ENV": "forged", + "SDKROOT": str(runtime / "fake-sdk"), + "DEVELOPER_DIR": str(runtime / "fake-developer-dir"), + "TOOLCHAINS": "forged.toolchain", + "COMMAND_MODE": "forged", + "DYLD_INSERT_LIBRARIES": str(runtime / "fake-insert.dylib"), + "GIT_IDENTITY_GATE_REGISTRY": str(forged_registry), + "GIT_IDENTITY_GATE_DISABLE": "1", + "GIT_IDENTITY_GATE_BIN": str(runtime / "fake-gate"), + "GIT_IDENTITY_GATE_DEBUG": "1", + "GIT_ARBITRARY_FUTURE_CONTROL": "poisoned", + } + ) + + hostile_child_values = { + key: value for key, value in forged_env.items() if key not in production_env + } + deployed_gate.write_text( + """import json +import os +import pwd +import sys + +expected_env = { + "HOME": pwd.getpwuid(os.getuid()).pw_dir, + "PATH": "/usr/bin:/bin", + "LANG": "C", + "LC_ALL": "C", +} +hostile_values = """ + + repr(hostile_child_values) + + """ + +def run_machine_v1(): + if any(os.environ.get(key) == value for key, value in hostile_values.items()): + sys.exit(71) + if any(os.environ.get(key) != value for key, value in expected_env.items()): + sys.exit(72) + + name = os.path.basename(os.getcwd()) + directory = os.path.dirname(__file__) + with open(os.path.join(directory, "gate-cwd-receipt"), "a", encoding="utf-8") as handle: + handle.write(name + "\\n") + with open(os.path.join(directory, "interpreter-receipt"), "a", encoding="utf-8") as handle: + handle.write(os.path.realpath(sys.executable) + "\\n") + + if name == "mismatch": + json.dump({"protocol": "git-identity-gate/v1", "outcome": "confirmed_mismatch"}, sys.stdout) + sys.exit(2) + if name == "unconfirmed": + json.dump({"protocol": "git-identity-gate/v1", "outcome": "no_confirmed_mismatch"}, sys.stdout) + sys.exit(0) + if name == "malformed-protocol": + sys.stdout.write("{not-json") + sys.exit(2) + if name == "gate-error": + sys.stderr.write("synthetic private diagnostic\\n") + sys.exit(2) + sys.exit(0) +""", + encoding="utf-8", + ) + deployed_gate.chmod(0o755) + + deployed_command = SANITIZED_REGISTRATION_COMMAND.replace( + "/Users/verlyn13/.local/bin/git-identity-sessionstart", + shlex.quote(str(deployed_session)), + ) + assert deployed_command.endswith(" 2>/dev/null || :") + + def invoke(raw): + return subprocess.run( + ["/bin/sh", "-c", deployed_command], + input=raw, + text=True, + capture_output=True, + env=forged_env, + timeout=5, + ) + + correct = invoke(json.dumps({"cwd": str(fixture_repos["correct"])})) + assert correct.returncode == 0 + assert correct.stdout == "" + assert correct.stderr == "", repr(correct.stderr) + + mismatch = invoke(json.dumps({"cwd": str(fixture_repos["mismatch"])})) + assert mismatch.returncode == 0 + assert mismatch.stderr == "" + assert mismatch.stdout != "" + warning = json.loads(mismatch.stdout) + assert warning["systemMessage"] + assert warning["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert warning["hookSpecificOutput"]["additionalContext"] + + for fixture in ["unconfirmed", "malformed-protocol", "gate-error"]: + result = invoke(json.dumps({"cwd": str(fixture_repos[fixture])})) + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + malformed_input = invoke("{not-json") + assert malformed_input.returncode == 0 + assert malformed_input.stdout == "" + assert malformed_input.stderr == "" + + pre_main_failure = deployed_command.replace( + "/usr/bin/python3", "/definitely/missing/q1b-python", 1 + ) + failed = subprocess.run( + ["/bin/sh", "-c", pre_main_failure], + input=json.dumps({"cwd": str(fixture_repos["mismatch"])}), + text=True, + capture_output=True, + env=forged_env, + timeout=5, + ) + assert (failed.returncode, failed.stdout, failed.stderr) == (0, "", "") + + receipt = (runtime / "gate-cwd-receipt").read_text(encoding="utf-8").splitlines() + assert receipt == ["correct", "mismatch", "unconfirmed", "malformed-protocol", "gate-error"] + interpreters = (runtime / "interpreter-receipt").read_text(encoding="utf-8").splitlines() + assert interpreters + assert set(interpreters) == {os.path.realpath(sys.executable)} + if sys.platform == "darwin": + assert interpreters[0] != "/usr/bin/python3" + assert not fake_python_marker.exists() + assert not fake_git_marker.exists() + assert not site_marker.exists() + + +report( + "sanitized production-equivalent launcher clears hostile pre-interpreter environment", + sanitized_production_launcher_boundary, +) + + +def registration_examples_are_exact(): + document = Path(repo_root, "docs", "git-identity.md").read_text(encoding="utf-8") + claude_marker = "**Claude SessionStart command (owner pending):**" + codex_marker = "**Codex SessionStart command (owner pending):**" + claude_start = document.index(claude_marker) + codex_start = document.index(codex_marker, claude_start) + claude_section = document[claude_start:codex_start] + codex_section = document[codex_start:] + assert claude_section.count(SANITIZED_REGISTRATION_COMMAND) == 1 + assert codex_section.count(SANITIZED_REGISTRATION_COMMAND) == 1 + assert document.count(SANITIZED_REGISTRATION_COMMAND) == 2 + + +report("Claude and Codex registration examples use the exact sanitized command", registration_examples_are_exact) + + +def validation_integration(): + validate = Path(repo_root, "scripts", "validate-repo.sh").read_text(encoding="utf-8") + assert any(line.strip() == "tests/git-identity-gate/run.sh" for line in validate.splitlines()) + + +report("repository validation invokes the git-identity suite", validation_integration) + + +def doctor_source_check(): + doctor = Path( + repo_root, "home", "dot_local", "bin", "executable_ng-doctor.tmpl" + ).read_text(encoding="utf-8") + marker = "check_identity_commit_push_gate() {" + start = doctor.index(marker) + end = doctor.index("\n}\n", start) + 3 + function_source = doctor[start:end] + + doctor_home = Path(temp_root, "doctor-home") + bin_dir = doctor_home / ".local/bin" + lib_dir = doctor_home / ".local/lib" + bin_dir.mkdir(parents=True, exist_ok=True) + lib_dir.mkdir(parents=True, exist_ok=True) + expected = [ + "git-identity-gate", + "git-identity-pretooluse", + "git-identity-cursor", + "git-identity-copilot", + "git-identity-sessionstart", + ] + for name in expected: + fixture = bin_dir / name + fixture.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fixture.chmod(0o755) + (lib_dir / "git_identity_hooklib.py").write_text("# fixture\n", encoding="utf-8") + + harness = ( + "pass() { printf 'PASS|%s|%s\\n' \"$1\" \"$2\"; }\n" + "skip() { printf 'SKIP|%s|%s\\n' \"$1\" \"$2\"; }\n" + + function_source + + "\ncheck_identity_commit_push_gate\n" + ) + + def invoke(): + return subprocess.run( + ["/bin/bash", "-c", harness], + text=True, + capture_output=True, + env={"HOME": str(doctor_home), "PATH": "/usr/bin:/bin"}, + timeout=3, + ) + + complete = invoke() + assert complete.returncode == 0 + assert complete.stderr == "" + assert complete.stdout.startswith("PASS|identity_commit_push_gate|") + assert "SessionStart announcer installed" in complete.stdout + + (bin_dir / "git-identity-sessionstart").unlink() + missing = invoke() + assert missing.returncode == 0 + assert missing.stderr == "" + assert missing.stdout.startswith("SKIP|identity_commit_push_gate|") + assert "git-identity-sessionstart" in missing.stdout + + +report("ng-doctor behavior requires the SessionStart executable", doctor_source_check) +PY +then + check 0 1 "SessionStart Python sub-suite executes" +else + while IFS='|' read -r case_rc case_label; do + check 0 "$case_rc" "$case_label" + done < "$session_results" +fi echo "git-identity-gate self-test: ${pass} passed, ${fail} failed" [[ "$fail" -eq 0 ]]