diff --git a/.codex/README.md b/.codex/README.md index cf4703f87..7f927b0d4 100644 --- a/.codex/README.md +++ b/.codex/README.md @@ -51,3 +51,30 @@ This `.codex/` layer is the Codex-facing control plane for Taskdeck. It routes a - Use native `rg` for repository search; do not use ripgrep MCP on Windows unless it has been revalidated. - Use Codex-native patching for file edits and configured agents/worktrees only when runtime policy allows clean ownership splits. +## Pinned Bash Deny Floor + +Taskdeck owns one project `PreToolUse` handler in `hooks.json`. It matches only +`Bash` and routes through the repo-owned `invoke_deny_floor.sh` / +`invoke_deny_floor.ps1` launchers to the shared dispatcher installed from +reviewed `agent-harness` floor 1.6.19. This is a command tripwire, not whole-tool +containment; native tool/API writes remain governed by their own permissions and +the repository agreements. + +The `expected=` value in `hooks.json` remains the producer contract's +audit-only marker. Taskdeck's bridge separately verifies the installed +dispatcher's LF-normalized SHA-256 and `FLOOR_VERSION` before and after execution, then +adds `[Taskdeck Codex deny-floor adapter]` to every dispatcher denial. It finds +the operating-system account home without trusting inherited `HOME`, so the +POSIX path still resolves when this repo's Windows-focused environment settings +are present. The redundant `GIT_CONFIG_GLOBAL` export is intentionally absent: +`HOME` already selects the same `.runtime-codex/home/.gitconfig`, while the +extra selector is correctly treated by the shared floor as opaque Git execution +context and would block the safe activation control. + +Codex maps a linked worktree's hook source to the normal checkout that owns the +common Git directory. Therefore a worktree-only copy is not activation proof. +Review and trust the exact definition in a fresh normal checkout (or a fresh +standalone clone of the exact PR head) via `/hooks`; then run the allowed control +and handler-attributed non-writing deny canary documented in +`docs/TESTING_GUIDE.md`. Never edit trust state or use a bypass flag. + diff --git a/.codex/config.toml b/.codex/config.toml index c7821055e..4cad85fa8 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -35,7 +35,9 @@ PATH = "C:\\Users\\jekyt\\source\\Taskdeck\\.runtime-codex\\bin;C:\\Users\\Publi HOME = 'C:\Users\jekyt\source\Taskdeck\.runtime-codex\home' DOTNET_CLI_HOME = 'C:\Users\jekyt\source\Taskdeck\.runtime-codex\dotnet-home' NUGET_PACKAGES = 'C:\Users\jekyt\source\Taskdeck\.runtime-codex\nuget\packages' -GIT_CONFIG_GLOBAL = 'C:\Users\jekyt\source\Taskdeck\.runtime-codex\home\.gitconfig' +# HOME already selects .runtime-codex/home/.gitconfig. Do not also export +# GIT_CONFIG_GLOBAL: the shared deny floor correctly treats that override as +# opaque Git execution context and would block the safe activation control. [mcp_servers.openaiDeveloperDocs] url = "https://developers.openai.com/mcp" diff --git a/.codex/deny_floor_adapter.py b/.codex/deny_floor_adapter.py new file mode 100644 index 000000000..ef532786a --- /dev/null +++ b/.codex/deny_floor_adapter.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Taskdeck's attributed bridge to the reviewed shared Codex deny floor. + +The shared dispatcher remains owned by agent-harness and installed under the +operating-system account home. This bridge deliberately does not trust the +inherited HOME value because Taskdeck's Windows-focused Codex configuration can +be loaded in a POSIX session. It also turns every adapter/dispatcher failure +that occurs after a Bash payload is identified into an attributed deny. +""" + +from __future__ import annotations + +import ctypes +import hashlib +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable, Mapping, Sequence + + +EXPECTED_DISPATCHER_SHA256 = ( + "524fed5ae6630313f4a000a6e9a8c7deb7b1a7a6d424913d8b12c4536b79a97b" +) +EXPECTED_FLOOR_VERSION = "1.6.19 (2026-07-27)" +DISPATCHER_SUFFIX = ".claude/hooks/dispatch.py" +ATTRIBUTION = "[Taskdeck Codex deny-floor adapter]" +DISPATCH_TIMEOUT_SECONDS = 3.5 + + +class AdapterFailure(RuntimeError): + """A safe-to-report adapter contract failure.""" + + +def deny_document(reason: str) -> str: + """Return a Codex PreToolUse deny document with stable attribution.""" + + return json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": f"{ATTRIBUTION} {reason}", + } + }, + separators=(",", ":"), + ) + + +def normalized_text_sha256(path: Path) -> str: + """Hash UTF-8 text after the producer's CRLF/CR-to-LF normalization.""" + + text = path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _posix_account_home() -> Path: + import pwd + + return Path(pwd.getpwuid(os.getuid()).pw_dir) + + +def _windows_account_home() -> Path: + # CSIDL_PROFILE resolves the Windows account profile through the shell API + # instead of the process's mutable HOME/USERPROFILE variables. + buffer = ctypes.create_unicode_buffer(32768) + result = ctypes.windll.shell32.SHGetFolderPathW(None, 0x0028, None, 0, buffer) + if result != 0 or not buffer.value: + raise AdapterFailure("the operating-system account home could not be resolved") + return Path(buffer.value) + + +def account_home( + *, + platform_name: str | None = None, + posix_resolver: Callable[[], Path] = _posix_account_home, + windows_resolver: Callable[[], Path] = _windows_account_home, +) -> Path: + """Resolve the real account home without consulting inherited HOME.""" + + platform_name = os.name if platform_name is None else platform_name + resolved = windows_resolver() if platform_name == "nt" else posix_resolver() + if not resolved.is_absolute(): + raise AdapterFailure("the operating-system account home was not absolute") + return resolved + + +def dispatcher_path( + suffix: str, + *, + home_resolver: Callable[[], Path] = account_home, +) -> Path: + """Resolve the one accepted shared-dispatcher suffix under account home.""" + + if suffix != DISPATCHER_SUFFIX: + raise AdapterFailure("the shared-dispatcher suffix did not match the reviewed contract") + return home_resolver() / Path(*DISPATCHER_SUFFIX.split("/")) + + +def validate_bash_payload(raw_payload: str) -> None: + """Fail closed when the matched Bash hook receives unknown/malformed input.""" + + try: + payload = json.loads( + raw_payload, + parse_constant=lambda value: (_ for _ in ()).throw( + ValueError(f"invalid JSON constant {value}") + ), + ) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise AdapterFailure("the Bash hook payload was not valid JSON") from exc + + if not isinstance(payload, dict) or payload.get("tool_name") != "Bash": + raise AdapterFailure("the matched hook did not receive a Bash payload object") + if payload.get("hook_event_name") != "PreToolUse": + raise AdapterFailure("the Bash hook payload had an unknown hook event") + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + raise AdapterFailure("the Bash hook payload had no tool_input object") + command = tool_input.get("command") + if not isinstance(command, str) or not command.strip(): + raise AdapterFailure("the Bash hook payload had no non-empty command") + cwd = payload.get("cwd") + if not isinstance(cwd, str) or not cwd.strip() or not Path(cwd).is_absolute(): + raise AdapterFailure("the Bash hook payload had no absolute cwd") + + +def validate_dispatcher_identity( + dispatcher: Path, + *, + expected_hash: str = EXPECTED_DISPATCHER_SHA256, + expected_version: str = EXPECTED_FLOOR_VERSION, +) -> None: + """Verify the installed dispatcher is exactly the reviewed producer text.""" + + if not dispatcher.is_file(): + raise AdapterFailure("the shared dispatcher is missing") + try: + text = dispatcher.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise AdapterFailure("the shared dispatcher could not be read as UTF-8") from exc + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + actual_hash = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + if actual_hash != expected_hash: + raise AdapterFailure( + f"the shared dispatcher identity differs from the reviewed {expected_version} pin" + ) + version_match = re.search( + r'^FLOOR_VERSION\s*=\s*"([^"]*)"', text, flags=re.MULTILINE + ) + if version_match is None or version_match.group(1) != expected_version: + raise AdapterFailure( + f"the shared dispatcher version differs from the reviewed {expected_version} pin" + ) + + +def attribute_dispatcher_output(stdout: str) -> str | None: + """Accept canonical allow/deny output and attribute every deny to this adapter.""" + + if stdout == "": + return None + try: + document = json.loads( + stdout, + parse_constant=lambda value: (_ for _ in ()).throw( + ValueError(f"invalid JSON constant {value}") + ), + ) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise AdapterFailure("the shared dispatcher returned malformed output") from exc + if not isinstance(document, dict): + raise AdapterFailure("the shared dispatcher returned a non-object result") + output = document.get("hookSpecificOutput") + if not isinstance(output, dict): + raise AdapterFailure("the shared dispatcher returned no hookSpecificOutput object") + if output.get("hookEventName") != "PreToolUse": + raise AdapterFailure("the shared dispatcher returned an unknown hook event") + if output.get("permissionDecision") != "deny": + raise AdapterFailure("the shared dispatcher returned an unknown permission decision") + reason = output.get("permissionDecisionReason") + if not isinstance(reason, str) or not reason.strip(): + raise AdapterFailure("the shared dispatcher returned no denial reason") + output["permissionDecisionReason"] = f"{ATTRIBUTION} {reason}" + return json.dumps(document, separators=(",", ":")) + + +def invoke_dispatcher( + raw_payload: str, + dispatcher: Path, + *, + expected_hash: str = EXPECTED_DISPATCHER_SHA256, + expected_version: str = EXPECTED_FLOOR_VERSION, + environment: Mapping[str, str] | None = None, + interpreter: str | Path | None = None, + timeout_seconds: float = DISPATCH_TIMEOUT_SECONDS, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> str | None: + """Run the exact dispatcher and return attributed output, or None for allow.""" + + validate_bash_payload(raw_payload) + validate_dispatcher_identity( + dispatcher, + expected_hash=expected_hash, + expected_version=expected_version, + ) + executable = str(Path(sys.executable) if interpreter is None else interpreter) + try: + result = runner( + [ + executable, + "-I", + "-B", + str(dispatcher), + "--event", + "pre", + "--runtime", + "codex", + ], + input=raw_payload, + text=True, + capture_output=True, + timeout=timeout_seconds, + env=dict(os.environ if environment is None else environment), + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise AdapterFailure("the shared dispatcher timed out") from exc + except OSError as exc: + raise AdapterFailure("the shared dispatcher process could not start") from exc + # A global-floor deployment can replace this path while the child is + # running. Revalidate before any empty stdout is accepted as allow. + validate_dispatcher_identity( + dispatcher, + expected_hash=expected_hash, + expected_version=expected_version, + ) + if result.returncode != 0: + raise AdapterFailure("the shared dispatcher process failed") + if result.stderr != "": + raise AdapterFailure("the shared dispatcher wrote unexpected diagnostic output") + return attribute_dispatcher_output(result.stdout) + + +def parse_arguments(arguments: Sequence[str]) -> str: + """Accept only the exact reviewed wiring; unknown/malformed flags deny.""" + + expected = [ + "--dispatcher-suffix", + DISPATCHER_SUFFIX, + "--event", + "pre", + "--runtime", + "codex", + ] + if list(arguments) != expected: + raise AdapterFailure("the hook arguments differed from the reviewed Codex wiring") + return DISPATCHER_SUFFIX + + +def run(raw_payload: str, arguments: Sequence[str]) -> str | None: + """Execute the production bridge, converting every bridge failure to deny.""" + + try: + suffix = parse_arguments(arguments) + dispatcher = dispatcher_path(suffix) + return invoke_dispatcher(raw_payload, dispatcher) + except AdapterFailure as exc: + return deny_document(str(exc)) + except Exception as exc: # fail closed without exposing environment details + return deny_document(f"unexpected adapter failure ({exc.__class__.__name__})") + + +def main() -> int: + result = run(sys.stdin.read(), sys.argv[1:]) + if result is not None: + print(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 000000000..9901d52d1 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,19 @@ +{ + "description": "Taskdeck's reviewed, pinned Bash-command deny-floor adapter", + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "expected=524fed5ae6630313f4a000a6e9a8c7deb7b1a7a6d424913d8b12c4536b79a97b; dispatcher=$HOME/.claude/hooks/dispatch.py; wrapper=.codex/invoke_deny_floor.sh; /bin/sh \"$wrapper\" --dispatcher-suffix .claude/hooks/dispatch.py --event pre --runtime codex", + "commandWindows": "$expected='524fed5ae6630313f4a000a6e9a8c7deb7b1a7a6d424913d8b12c4536b79a97b'; $dispatcher=$env:USERPROFILE+'/.claude/hooks/dispatch.py'; $wrapper='.codex/invoke_deny_floor.ps1'; & $wrapper --dispatcher-suffix .claude/hooks/dispatch.py --event pre --runtime codex", + "timeout": 5, + "statusMessage": "Taskdeck: checking pinned irreversible-command policy" + } + ] + } + ] + } +} diff --git a/.codex/invoke_deny_floor.ps1 b/.codex/invoke_deny_floor.ps1 new file mode 100644 index 000000000..a6a84ccf2 --- /dev/null +++ b/.codex/invoke_deny_floor.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding(PositionalBinding = $false)] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$AdapterArguments +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Write-AdapterDeny { + param([Parameter(Mandatory = $true)][string]$Reason) + + $document = @{ + hookSpecificOutput = @{ + hookEventName = "PreToolUse" + permissionDecision = "deny" + permissionDecisionReason = "[Taskdeck Codex deny-floor adapter] $Reason" + } + } | ConvertTo-Json -Compress + [Console]::Out.WriteLine($document) + exit 0 +} + +try { + $core = Join-Path $PSScriptRoot "deny_floor_adapter.py" + if (-not (Test-Path -LiteralPath $core -PathType Leaf)) { + Write-AdapterDeny "Windows bridge is missing; fix the reviewed project hook before proceeding" + } + + $systemRoot = [Environment]::GetEnvironmentVariable("SystemRoot", "Machine") + if ([string]::IsNullOrWhiteSpace($systemRoot)) { + $systemRoot = $env:SystemRoot + } + $launcher = Join-Path $systemRoot "py.exe" + if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { + Write-AdapterDeny "verified Windows Python launcher is missing; fix the project hook before proceeding" + } + + $stderrPath = [IO.Path]::GetTempFileName() + try { + $output = @(& $launcher -3 -I -B $core @AdapterArguments 2> $stderrPath) + $code = $LASTEXITCODE + $stderr = [IO.File]::ReadAllText($stderrPath) + } + finally { + Remove-Item -LiteralPath $stderrPath -ErrorAction SilentlyContinue + } + if ($code -ne 0) { + Write-AdapterDeny "Windows bridge failed; fix the reviewed project hook before proceeding" + } + if ($stderr.Length -gt 0) { + Write-AdapterDeny "Windows bridge wrote unexpected diagnostic output; fix the reviewed project hook before proceeding" + } + if ($output.Count -gt 0) { + [Console]::Out.WriteLine(($output -join [Environment]::NewLine)) + } + exit 0 +} +catch { + Write-AdapterDeny "Windows launcher failed closed; fix the reviewed project hook before proceeding" +} diff --git a/.codex/invoke_deny_floor.sh b/.codex/invoke_deny_floor.sh new file mode 100644 index 000000000..3a3c8b0cd --- /dev/null +++ b/.codex/invoke_deny_floor.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Repo-owned POSIX launcher for the Taskdeck Codex deny-floor adapter. + +unset ENV BASH_ENV CDPATH +PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/opt/local/bin +export PATH + +deny() { + printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[Taskdeck Codex deny-floor adapter] POSIX launcher unavailable; fix the reviewed project hook before proceeding"}}' + exit 0 +} + +case "$0" in + */*) adapter_dir=${0%/*} ;; + *) adapter_dir=. ;; +esac +core="$adapter_dir/deny_floor_adapter.py" + +[ -f "$core" ] || deny +python=$(command -v python3 2>/dev/null) || deny +[ -n "$python" ] || deny + +exec "$python" -I -B "$core" "$@" diff --git a/.github/workflows/reusable-docs-governance.yml b/.github/workflows/reusable-docs-governance.yml index ea1650e77..d21908c67 100644 --- a/.github/workflows/reusable-docs-governance.yml +++ b/.github/workflows/reusable-docs-governance.yml @@ -48,8 +48,8 @@ jobs: & $script -SelfTest - - name: Validate failure ledger projection synchronization - run: python -m unittest discover -s scripts/agent_hooks -p "test_render_failure_ledger.py" + - name: Validate agent-hook contracts + run: python -m unittest discover -s scripts/agent_hooks -p "test_*.py" - name: Validate docs governance invariants run: node scripts/check-docs-governance.mjs diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index ab7bf1cc7..22342a2b2 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -45,7 +45,7 @@ Companion Active Docs: - **Repo hardening:** force-push and deletion blocked on `main` (otherwise deliberately relaxed); a 1,444-item snapshot synced 288 truncation-hidden project-priority mismatches (`#1474`), while `#1458` owns the durable complete-audit and remaining data-cleanup gate. - **`#1270` backlog triage:** 12 issues closed with dated notes; an adversarial pass bounced **13 of 25** proposed closures, including the `#1123` ship-gate item. `#1270` stays open — two of its own ACs are obsolete against ADR-0044 / REVIVAL_PLAN §6. - **Seeded:** `#1470` `#1473` `#1474` `#1475` `#1476` `#1480` `#1482`, plus upstream `agent-harness#56`. -- **Remaining T4 hold:** `#1457` only — do not run the published canaries yet. Its current head has red DCO, stale review evidence, and material main-branch drift; the 2026-07-26 audit also found its v1.6.5 pin behind the installed/producer v1.6.12 floor while root/common-directory hook discovery remains unresolved. Repair those prerequisites and regenerate exact-head CI/reviews before the maintainer-only `/hooks` trust and live-canary step. +- **Remaining T4 hold (`#1456`):** do not revive old unsigned PR `#1457`. The reviewed/deployed producer has converged at agent-harness `c056a77d22ec`, floor 1.6.19, normalized dispatcher SHA-256 `524fed5a...a97b`; the signed current-main replacement owns the consumer adapter, runtime identity/attribution bridge, hostile-environment regressions, and fail-fast verification flow. Land only after exact-head DCO, hosted CI/CI Extended/CodeQL, comment triage, and an independent review. The last gate remains maintainer-only: from a fresh normal checkout or standalone clone of the exact reviewed head, verify the single active definition in `/hooks`, run the allowed control, and capture the Taskdeck-handler-attributed non-writing deny canary. A linked worktree maps to the owning root checkout and is not valid activation evidence. ## Delivery update (2026-07-23/24, overnight wave) diff --git a/docs/STATUS.md b/docs/STATUS.md index f260c543f..fedb7df15 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -44,7 +44,7 @@ PR-queue clearing wave (2026-07-25, **8 PRs merged**, and the open-PR queue is d - **Repository hardening:** `main` previously permitted **force-pushes and deletions**; both are now blocked (deliberately relaxed otherwise — no required checks, no required approvals). The old project-priority helper silently truncated at 1,000 items; a one-off 1,444-item snapshot repaired 288 mismatched fields (`#1474`), but that was not durable proof of a clean project. The complete, fail-closed contract and the remaining label/data gate are tracked by `#1458`. - **Backlog triage (`#1270`):** 12 already-decided issues closed with dated pivot notes, after an adversarial pass **bounced 13 of 25 proposed closures** — including `#1123`, the v0.1 ship-gate item. `#1270` itself stays open: two of its own acceptance criteria are obsolete against ADR-0044 and REVIVAL_PLAN §6 and must not be executed as written. - **Seeded this wave:** `#1470`, `#1473`, `#1474`, `#1475` (the repo hook denies 6 of 8 *benign* commands), `#1476` (the configured-handler `smoke_test.py` / deny-floor matrix remains ungated; failure-ledger renderer synchronization now runs in Required Docs Governance), `#1480`, `#1482`, plus upstream `agent-harness#56` (`docker exec` is not unwrapped by the global floor at any tier). -- **Still open — producer/runtime-gated:** `#1457` is ready-for-review but cannot be salvaged in place: all four shared commits lack DCO sign-off, its v1.6.5 dispatcher pin is obsolete, five current Codex threads are unresolved, and no reviewed producer floor is both canonical and deployed. Rebuild a signed replacement from final `main` only after the agent-harness producer/deployment gate converges; then require exact-head automation/reviews followed by a fresh interactive `/hooks` trust session and handler-attributed allow/deny canaries. +- **`#1456` Codex adapter replacement is now producer-converged but still human-gated:** old PR `#1457` cannot be salvaged in place (four unsigned commits and five unresolved current threads) and remains only as superseded evidence. Reviewed `agent-harness` main `c056a77d22ec` and the deployed global dispatcher/smoke files are byte-identical floor 1.6.19; the signed replacement from current `main` pins normalized dispatcher SHA-256 `524fed5a...a97b`, resolves the OS account home independently of inherited `HOME`, verifies the runtime bytes/version, attributes denials to the Taskdeck handler, and removes the redundant `GIT_CONFIG_GLOBAL` selector that blocked the safe control without changing the `.runtime-codex/home/.gitconfig` selected by `HOME`. Exact-head hosted gates/review remain required, followed by a maintainer-run fresh normal-checkout or standalone-clone `/hooks` inspection, allowed `git status`, and handler-attributed non-writing force-push dry-run denial; linked-worktree/static/Claude evidence does not satisfy that final gate. Overnight wave (2026-07-23/24, **6 PRs merged** + 3 dependabot PRs — the four feature/substrate PRs below plus `#1447`, the docs sync that recorded the maintainer-merged `#1414` (its content is the 2026-07-18 post-merge comment further down), and `#1448` below; per-PR gate: two independent adversarial review lenses, all-severity findings fixed with posted evidence, full backend suite on the exact head where code changed, required CI green, bot window honored with a content sweep). The wave's own delivery sweep was `#1454`; `#1448`, `#1427`, and dependabot `#1441` merged *after* that sweep ran, so their entries below were added on 2026-07-25: - **`#1439` (`#1428`, `#1424`) — the approved revision is pinned at approve time and Apply executes exactly it.** Approve reads the latest saved revision, validates it through the approve-time gates, and persists its id as `AutomationProposal.ApprovedRevisionId` (null pin = approved the original operations; a post-approval raced revision can no longer change what Apply executes — closes the `#1428` race). The migration backfills Approved proposals to their latest revision saved **at or before `DecidedAt`** (an explicit, reviewed reversal of latest-revision parity: the invariant is "Apply executes exactly what the approver saw", so a raced post-decision revision is never backfilled; empirically-proven SQLite text-format comparison). Single-proposal read/decide paths return the EFFECTIVE operation set with `Presentation` rebuilt from it (`#1424`); Rejected proposals freeze to the latest revision saved at or before decision time; the MCP terminal stored preview is suppressed whenever an effective revision applies (one payload can no longer carry revision operations beside an original-operations `DiffPreview`). Full suite on head: 7430/0/1. **`#1444` closed this residual:** the review-queue **list** endpoint now returns the same EFFECTIVE operations and rebuilt `Presentation` the single reads do, so a queue card can no longer disagree with the detail view, the diff and Apply. The former "list maps originals" boundary existed only to avoid a per-proposal revision query; a two-phase batched read (payload-free metadata for the page, then payloads for the winning revisions only) removes that cost without loading revision payloads the reader will not use, and both read shapes now select through one shared dispatcher. **`#1462` closed the frontend half** (PR `#1469`, merged 2026-07-25): the `Proposal` type in `types/automation.ts` now exposes `approvedRevisionId` (contract only — no UI claim, per `#1298`), declared **required rather than optional** because `MapToDto` always assigns it and the API serializes with default options (bare `AddControllers()`, no `DefaultIgnoreCondition`), so an unpinned proposal arrives as an explicit `null` and never an absent key — which also turns a field-by-field rebuild that drops the pin into a compile error. It is held against a dead-code sweep by an exported type alias because `tsconfig.app.json` excludes `src/tests/**` from typecheck. Remaining tracked residuals: `#1453` (reject-time pin: the `RevisedAt <= DecidedAt` heuristic is not commit-visibility; display-only), `#1464` (the review surfaces' "recorded operations show the original submission" caveat is inverted now that list reads are effective-set-aware; terminal proposals only — now unblocked by `#1462`), `#1465` (dismissing a rejected proposal drops its decision-time frozen revision back to the originals), `#1467` (the list resolution is bounded in bytes but not in row count, and reads proposal state and revision rows from two snapshots; display-only), `#1468` (frontend specs are never type-checked — lifting `src/tests/**` from `tsconfig.app.json` and running `npm run typecheck` surfaces **415** pre-existing errors, all of them under `src/tests/`, against a clean baseline of 0), `#1470` (`docs/architecture/DATA_MODEL.md` omits `ApprovedRevisionId` and `DeferredUntil`, and the "855 lines, 37 entities" figure describing it is stale — the file is 897 lines, and the figure lives in three historical delivery-ledger entries rather than in the document itself; find them with `grep -rn "855 lines, 37 entities" docs/`). diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 1ef574713..33599dbf6 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -158,6 +158,87 @@ node scripts/check-github-ops-governance.mjs The native-Windows hook smoke test executes the configured `.claude/settings.json` command handlers with `CLAUDE_PROJECT_DIR` set, including PowerShell-hosted handlers, representative dangerous Bash-command denials, missing-launcher and missing-policy fail-closed probes, failure-ledger redaction, and pre-commit no-op behavior. Its payloads identify the `Bash` tool; it does not prove native PowerShell-tool interception. That T4 policy gap is tracked by [#1497](https://github.com/Chris0Jeky/Taskdeck/issues/1497). +### Codex pinned deny-floor adapter (`#1456`, T4) + +The Taskdeck Codex adapter is a Bash-command tripwire backed by the reviewed +agent-harness producer. The producer commit, installed dispatcher, audit marker, +and smoke program are one contract; do not substitute the installed smoke copy +for the reviewed producer program. Run this block from the root of the exact +Taskdeck head being verified. Every external command is checked immediately so +a later green command cannot overwrite an earlier failure: + +```powershell +$ErrorActionPreference = "Stop" +$HarnessRoot = "C:\Users\jekyt\source\agent-harness" +$TaskdeckRoot = (& git rev-parse --show-toplevel).Trim() +$ExpectedProducer = "c056a77d22ecdcdfb2389c4c83487eb54061f5aa" +$ExpectedPin = "524fed5ae6630313f4a000a6e9a8c7deb7b1a7a6d424913d8b12c4536b79a97b" + +if (Test-Path Env:GIT_CONFIG_GLOBAL) { + throw "This process inherited the retired GIT_CONFIG_GLOBAL selector; start a fresh session at the exact head" +} + +$Producer = (& git -C $HarnessRoot rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +if ($Producer -ne $ExpectedProducer) { throw "Reviewed agent-harness identity changed: $Producer" } +$ProducerStatus = @(& git -C $HarnessRoot status --porcelain=v1 --untracked-files=all) +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +if ($ProducerStatus.Count -ne 0) { throw "Reviewed agent-harness checkout is not clean" } + +$ActualPin = (& py -3 -I -B -c "import hashlib,pathlib,sys; p=pathlib.Path(sys.argv[1]); t=p.read_text(encoding='utf-8').replace(chr(13)+chr(10),chr(10)).replace(chr(13),chr(10)); print(hashlib.sha256(t.encode('utf-8')).hexdigest())" "$HarnessRoot\templates\hooks\dispatch.py").Trim() +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +if ($ActualPin -ne $ExpectedPin) { throw "Reviewed dispatcher identity changed: $ActualPin" } + +& py -3 -I -B "$HarnessRoot\harness.py" doctor --repo $TaskdeckRoot +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +$AuditJson = (& py -3 -I -B "$HarnessRoot\harness.py" audit --json $TaskdeckRoot) -join [Environment]::NewLine +$AuditExit = $LASTEXITCODE +if ($AuditExit -notin 0, 1) { exit $AuditExit } +$Audit = $AuditJson | ConvertFrom-Json -ErrorAction Stop +if ($Audit.unproven -ne 0) { throw "Harness audit left $($Audit.unproven) reality check(s) unproven" } +$BadReality = @($Audit.reality | Where-Object { $_.status -ne "ok" }) +if ($BadReality.Count -ne 0) { throw "Harness audit found a declared-vs-real mismatch" } +if ($AuditExit -ne 0) { + Write-Warning ("Unrelated repository-hygiene findings remain: " + ($Audit.issues -join "; ")) +} +& py -3 -I -B "$HarnessRoot\templates\hooks\smoke_test.py" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& py -3 -B -m unittest discover -s scripts/agent_hooks -p "test_*.py" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Get-Content -Raw .codex\hooks.json | ConvertFrom-Json -ErrorAction Stop | Out-Null +``` + +`doctor` must report one canonical root handler, one candidate, one current +audit-marker handler, a clean adapter contract, and no activation blocker. +The producer identity check also requires the entire agent-harness checkout to +be clean, including untracked files, and executes its programs with Python +isolated mode so inherited module paths cannot replace their standard-library +imports. +The broader harness audit may remain red on separately tracked instruction-budget +or stale-path hygiene; the block records those issues but still fails on invalid +output, an unproven reality check, or a declared-vs-real mismatch. The adapter suite +covers hostile inherited `HOME` and `GH_HOST`, a stripped `PATH`, exact runtime +identity, malformed/unknown payload or dispatcher results, and the real current +platform command. The producer smoke total is self-counting; record its emitted +total rather than copying an older count into this guide. + +Static checks and Claude-hook evidence do not prove Codex activation. The final +gate belongs to the maintainer in a completely fresh Codex session rooted at a +normal checkout or standalone clone whose `HEAD` is the exact reviewed PR head +(not this repo's linked `.worktrees/` checkout): + +1. Run `/hooks`, inspect the source path, and trust exactly one + `PreToolUse` / `Bash` Taskdeck handler. +2. Run `git status --short --branch`; it must execute normally. +3. Run the non-writing canary + `git push --force --dry-run origin HEAD:refs/heads/codex-hook-canary`. + It must stop before Git executes and its reason must begin + `[Taskdeck Codex deny-floor adapter] [floor 1.6.19 (2026-07-27)]`. + +If `/hooks` names a different source, shows zero or multiple active handlers, or +the canary lacks that exact handler attribution, stop. Do not hand-edit trust, +repeat an un-attributed canary, or treat static/hash/CI evidence as activation. + When MCP availability itself is part of the change, also run the active runtime's MCP listing/auth command if available. Do not claim remote MCP connectivity unless the current session actually verified it. When `.claude/settings.json` changes outside the agentic smoke path, also parse it with PowerShell: diff --git a/scripts/agent_hooks/smoke_test.py b/scripts/agent_hooks/smoke_test.py index a3d15711c..0e9dbd3d5 100644 --- a/scripts/agent_hooks/smoke_test.py +++ b/scripts/agent_hooks/smoke_test.py @@ -180,8 +180,8 @@ def test_failure_ledger_command_order() -> None: expect_text_order(path, renderer, synchronization_test, label) ci_workflow = (ROOT / ".github" / "workflows" / "reusable-docs-governance.yml").read_text(encoding="utf-8") - if 'run: python -m unittest discover -s scripts/agent_hooks -p "test_render_failure_ledger.py"' not in ci_workflow: - raise AssertionError("Required Docs Governance lost its failure-ledger synchronization test") + if 'run: python -m unittest discover -s scripts/agent_hooks -p "test_*.py"' not in ci_workflow: + raise AssertionError("Required Docs Governance lost its complete agent-hook contract suite") if "scripts/agent_hooks/render_failure_ledger.py" in ci_workflow: raise AssertionError("Required Docs Governance must not render before validating the checked-in projection") diff --git a/scripts/agent_hooks/test_codex_deny_floor_adapter.py b/scripts/agent_hooks/test_codex_deny_floor_adapter.py new file mode 100644 index 000000000..aa35fb951 --- /dev/null +++ b/scripts/agent_hooks/test_codex_deny_floor_adapter.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / ".codex" / "deny_floor_adapter.py" +SPEC = importlib.util.spec_from_file_location("taskdeck_deny_floor_adapter", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load {MODULE_PATH}") +adapter = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(adapter) + + +def payload(command: str = "git status --short --branch") -> str: + return json.dumps( + { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": command}, + "cwd": str(ROOT), + } + ) + + +def output_document(output: str) -> dict[str, object]: + parsed = json.loads(output) + if not isinstance(parsed, dict): + raise AssertionError(f"adapter output was not an object: {parsed!r}") + return parsed + + +def deny_reason(output: str) -> str: + parsed = output_document(output) + hook_output = parsed.get("hookSpecificOutput") + if not isinstance(hook_output, dict): + raise AssertionError(f"adapter output had no hookSpecificOutput: {parsed!r}") + if hook_output.get("permissionDecision") != "deny": + raise AssertionError(f"adapter output was not a deny: {parsed!r}") + reason = hook_output.get("permissionDecisionReason") + if not isinstance(reason, str): + raise AssertionError(f"adapter deny had no reason: {parsed!r}") + return reason + + +class CodexDenyFloorAdapterTests(unittest.TestCase): + def make_dispatcher(self, directory: Path, body: str) -> tuple[Path, str]: + source = ( + f'FLOOR_VERSION = "{adapter.EXPECTED_FLOOR_VERSION}"\n' + + textwrap.dedent(body).lstrip() + + "\n" + ) + path = directory / "dispatch.py" + path.write_text(source, encoding="utf-8") + digest = hashlib.sha256(source.encode("utf-8")).hexdigest() + return path, digest + + def invoke_fixture( + self, + dispatcher: Path, + digest: str, + *, + raw_payload: str | None = None, + environment: dict[str, str] | None = None, + ) -> str | None: + return adapter.invoke_dispatcher( + payload() if raw_payload is None else raw_payload, + dispatcher, + expected_hash=digest, + environment=environment, + timeout_seconds=2.0, + ) + + def test_contract_constants_match_hooks_definition(self) -> None: + hooks = json.loads((ROOT / ".codex" / "hooks.json").read_text(encoding="utf-8")) + self.assertEqual(hooks.get("description"), "Taskdeck's reviewed, pinned Bash-command deny-floor adapter") + groups = hooks["hooks"]["PreToolUse"] + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["matcher"], "^Bash$") + handlers = groups[0]["hooks"] + self.assertEqual(len(handlers), 1) + handler = handlers[0] + self.assertEqual(handler["type"], "command") + self.assertEqual(handler["timeout"], 5) + for field in ("command", "commandWindows"): + command = handler[field] + self.assertIn(adapter.EXPECTED_DISPATCHER_SHA256, command) + self.assertIn(".claude/hooks/dispatch.py", command) + self.assertIn("--event pre --runtime codex", command) + self.assertIn("invoke_deny_floor", command) + config = (ROOT / ".codex" / "config.toml").read_text(encoding="utf-8") + self.assertNotIn("\nGIT_CONFIG_GLOBAL =", config) + + def test_normalized_hash_matches_producer_line_ending_contract(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "dispatcher.py" + path.write_bytes(b"one\r\ntwo\rthree\n") + expected = hashlib.sha256(b"one\ntwo\nthree\n").hexdigest() + self.assertEqual(adapter.normalized_text_sha256(path), expected) + + def test_account_home_ignores_hostile_process_home(self) -> None: + hostile_environment = {"HOME": "C:\\foreign\\runtime-home", "USERPROFILE": "Z:\\spoofed"} + rooted = Path(Path.cwd().anchor or "/") + with mock.patch.dict(os.environ, hostile_environment, clear=False): + posix = adapter.account_home( + platform_name="posix", + posix_resolver=lambda: rooted / "real/account", + ) + windows = adapter.account_home( + platform_name="nt", + windows_resolver=lambda: rooted / "real/windows-account", + ) + self.assertEqual(posix, rooted / "real/account") + self.assertEqual(windows, rooted / "real/windows-account") + + def test_dispatcher_suffix_is_exact_and_account_anchored(self) -> None: + actual = adapter.dispatcher_path( + adapter.DISPATCHER_SUFFIX, + home_resolver=lambda: Path("/account/home"), + ) + self.assertEqual(actual, Path("/account/home/.claude/hooks/dispatch.py")) + with self.assertRaises(adapter.AdapterFailure): + adapter.dispatcher_path( + "../repo/dispatch.py", home_resolver=lambda: Path("/account/home") + ) + + def test_unknown_and_malformed_bash_payloads_fail_closed(self) -> None: + malformed = ( + "{not-json", + "[]", + "{}", + json.dumps({"tool_name": "Read", "tool_input": {"command": "git status"}}), + json.dumps({"tool_name": "Bash", "tool_input": []}), + json.dumps({"tool_name": "Bash", "tool_input": {"command": ""}}), + json.dumps({"tool_name": "Bash", "tool_input": {"command": 42}}), + json.dumps( + { + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git status"}, + "cwd": str(ROOT), + } + ), + json.dumps( + { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git status"}, + } + ), + json.dumps( + { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git status"}, + "cwd": "relative/path", + } + ), + ) + for raw in malformed: + with self.subTest(raw=raw), self.assertRaises(adapter.AdapterFailure): + adapter.validate_bash_payload(raw) + + def test_malformed_arguments_return_attributed_deny(self) -> None: + result = adapter.run(payload(), ["--event", "pre", "--runtime", "codex"]) + self.assertIsNotNone(result) + self.assertTrue(deny_reason(result).startswith(adapter.ATTRIBUTION)) + + def test_identity_mismatch_fails_closed_before_execution(self) -> None: + with tempfile.TemporaryDirectory() as directory: + dispatcher, _digest = self.make_dispatcher( + Path(directory), "raise SystemExit('must not execute')" + ) + with self.assertRaisesRegex(adapter.AdapterFailure, "identity differs"): + adapter.invoke_dispatcher( + payload(), dispatcher, expected_hash="0" * 64, timeout_seconds=1.0 + ) + + def test_dispatcher_replacement_during_execution_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + dispatcher, digest = self.make_dispatcher(Path(directory), "pass") + + def replacing_runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + dispatcher.write_text( + f'FLOOR_VERSION = "{adapter.EXPECTED_FLOOR_VERSION}"\n# replaced\n', + encoding="utf-8", + ) + return subprocess.CompletedProcess([], 0, "", "") + + with self.assertRaisesRegex(adapter.AdapterFailure, "identity differs"): + adapter.invoke_dispatcher( + payload(), + dispatcher, + expected_hash=digest, + runner=replacing_runner, + ) + + def test_empty_success_is_the_only_allow_shape(self) -> None: + with tempfile.TemporaryDirectory() as directory: + dispatcher, digest = self.make_dispatcher( + Path(directory), + """ + import json, sys + json.load(sys.stdin) + """, + ) + self.assertIsNone(self.invoke_fixture(dispatcher, digest)) + + whitespace_dispatcher, whitespace_digest = self.make_dispatcher( + Path(directory), "print(' ')" + ) + with self.assertRaises(adapter.AdapterFailure): + self.invoke_fixture(whitespace_dispatcher, whitespace_digest) + + windows_launcher = (ROOT / ".codex" / "invoke_deny_floor.ps1").read_text( + encoding="utf-8" + ) + self.assertNotIn("2>$null", windows_launcher) + self.assertIn("-3 -I -B", windows_launcher) + self.assertIn("Windows bridge wrote unexpected diagnostic output", windows_launcher) + posix_launcher = (ROOT / ".codex" / "invoke_deny_floor.sh").read_text( + encoding="utf-8" + ) + self.assertIn('"$python" -I -B', posix_launcher) + + def test_valid_deny_is_attributed_and_unknown_fields_are_preserved(self) -> None: + with tempfile.TemporaryDirectory() as directory: + dispatcher, digest = self.make_dispatcher( + Path(directory), + """ + import json, os, sys + json.load(sys.stdin) + print(json.dumps({ + "producerField": {"preserved": True}, + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "producer denied on " + os.environ.get("GH_HOST", "missing"), + "futureField": 7, + }, + })) + """, + ) + environment = dict(os.environ) + environment["GH_HOST"] = "hostile.enterprise.invalid" + result = self.invoke_fixture( + dispatcher, digest, environment=environment + ) + self.assertIsNotNone(result) + document = output_document(result) + self.assertEqual(document["producerField"], {"preserved": True}) + hook_output = document["hookSpecificOutput"] + self.assertEqual(hook_output["futureField"], 7) + self.assertEqual( + hook_output["permissionDecisionReason"], + f"{adapter.ATTRIBUTION} producer denied on hostile.enterprise.invalid", + ) + + def test_malformed_or_unknown_dispatcher_results_fail_closed(self) -> None: + bodies = { + "malformed": "print('not-json')", + "non-object": "print('[]')", + "missing-output": "print('{}')", + "unknown-event": """ + import json + print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse", "permissionDecision": "deny", "permissionDecisionReason": "x"}})) + """, + "unknown-decision": """ + import json + print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "x"}})) + """, + "missing-reason": """ + import json + print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny"}})) + """, + } + for label, body in bodies.items(): + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + dispatcher, digest = self.make_dispatcher(Path(directory), body) + with self.assertRaises(adapter.AdapterFailure): + self.invoke_fixture(dispatcher, digest) + + def test_nonzero_stderr_and_timeout_each_fail_closed(self) -> None: + bodies = { + "nonzero": "raise SystemExit(7)", + "stderr": "import sys; print('unexpected', file=sys.stderr)", + "whitespace-stderr": "import sys; print(' ', file=sys.stderr)", + "timeout": "import time; time.sleep(1)", + } + for label, body in bodies.items(): + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + dispatcher, digest = self.make_dispatcher(Path(directory), body) + timeout = 0.05 if label == "timeout" else 1.0 + with self.assertRaises(adapter.AdapterFailure): + adapter.invoke_dispatcher( + payload(), + dispatcher, + expected_hash=digest, + timeout_seconds=timeout, + ) + + def test_exact_platform_command_survives_hostile_environment(self) -> None: + hooks = json.loads((ROOT / ".codex" / "hooks.json").read_text(encoding="utf-8")) + handler = hooks["hooks"]["PreToolUse"][0]["hooks"][0] + environment = dict(os.environ) + environment["HOME"] = "C:\\foreign\\runtime-home" + environment["GH_HOST"] = "hostile.enterprise.invalid" + environment.pop("GIT_CONFIG_GLOBAL", None) + hostile_pythonpath = tempfile.TemporaryDirectory() + self.addCleanup(hostile_pythonpath.cleanup) + Path(hostile_pythonpath.name, "json.py").write_text( + "raise RuntimeError('hostile PYTHONPATH was imported')\n", encoding="utf-8" + ) + environment["PYTHONPATH"] = hostile_pythonpath.name + + if os.name == "nt": + system_root = os.environ.get("SystemRoot", r"C:\Windows") + powershell = Path(system_root) / "System32/WindowsPowerShell/v1.0/powershell.exe" + if not powershell.is_file(): + self.skipTest(f"Windows PowerShell not available at {powershell}") + try: + dispatcher = adapter.dispatcher_path(adapter.DISPATCHER_SUFFIX) + adapter.validate_dispatcher_identity(dispatcher) + except adapter.AdapterFailure as exc: + self.fail( + f"current installed {adapter.EXPECTED_FLOOR_VERSION} dispatcher unavailable: {exc}" + ) + environment["PATH"] = str(ROOT / "intentionally-missing-path") + command = handler["commandWindows"] + allow = subprocess.run( + [str(powershell), "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + cwd=ROOT, + env=environment, + input=payload(), + text=True, + capture_output=True, + timeout=12, + check=False, + ) + self.assertEqual(allow.returncode, 0, allow.stderr) + self.assertEqual(allow.stdout.strip(), "", allow.stdout) + deny = subprocess.run( + [str(powershell), "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + cwd=ROOT, + env=environment, + input=payload( + "git push --force --dry-run origin HEAD:refs/heads/codex-hook-canary" + ), + text=True, + capture_output=True, + timeout=12, + check=False, + ) + self.assertEqual(deny.returncode, 0, deny.stderr) + reason = deny_reason(deny.stdout) + self.assertTrue(reason.startswith(adapter.ATTRIBUTION), reason) + self.assertIn(f"[floor {adapter.EXPECTED_FLOOR_VERSION}]", reason) + else: + # Hosted POSIX runners do not install the user's shared floor. The + # exact adapter must still start with a stripped inherited PATH, + # ignore hostile HOME, and produce an attributed deny rather than + # silently allowing the command. + environment["PATH"] = "/intentionally/missing" + result = subprocess.run( + ["/bin/sh", "-c", handler["command"]], + cwd=ROOT, + env=environment, + input=payload(), + text=True, + capture_output=True, + timeout=12, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(deny_reason(result.stdout).startswith(adapter.ATTRIBUTION)) + + +if __name__ == "__main__": + unittest.main()