diff --git a/README.md b/README.md index 975327e..f3e9dca 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ this repo. They are service-side state, not portable files. - `AGENTS.md`: repo-local maintenance guidance for this portable config repo. Use this for codex-portable process and review rules. - `codex/keybindings.json`: portable keyboard bindings. +- `codex/hooks.json` and `codex/hooks/`: reviewed Codex hooks installed into + the live Codex home. Hooks require `/hooks` trust review after install. - `codex/agents/`: reusable global custom agents installed into the live Codex home. Project-specific custom agents belong in the target repo. - `codex/skills/`: reusable global custom skills installed into the live Codex diff --git a/codex/hooks.json b/codex/hooks.json new file mode 100644 index 0000000..58c18cb --- /dev/null +++ b/codex/hooks.json @@ -0,0 +1,44 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^(Bash|apply_patch)$", + "hooks": [ + { + "type": "command", + "command": "sh \"${CODEX_HOME:-$HOME/.codex}/hooks/portable_guard.sh\"", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$home = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE '.codex' }; $script = Join-Path $home 'hooks\\portable_guard.ps1'; if (Test-Path -LiteralPath $script) { & $script }\"", + "timeout": 30, + "statusMessage": "Checking portable workflow guard" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CODEX_HOME:-$HOME/.codex}/hooks/portable_guard.sh\"", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$home = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE '.codex' }; $script = Join-Path $home 'hooks\\portable_guard.ps1'; if (Test-Path -LiteralPath $script) { & $script }\"", + "timeout": 30, + "statusMessage": "Checking understanding prompt" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CODEX_HOME:-$HOME/.codex}/hooks/portable_guard.sh\"", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$home = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE '.codex' }; $script = Join-Path $home 'hooks\\portable_guard.ps1'; if (Test-Path -LiteralPath $script) { & $script }\"", + "timeout": 30, + "statusMessage": "Checking git closeout" + } + ] + } + ] + } +} diff --git a/codex/hooks/README.md b/codex/hooks/README.md new file mode 100644 index 0000000..ac73377 --- /dev/null +++ b/codex/hooks/README.md @@ -0,0 +1,27 @@ +# Portable Codex Hooks + +These hooks are global Codex hooks carried by `codex-portable`. + +- Dirty worktree closeout: on `Stop`, asks Codex to continue once when the current repo or immediate child repos have dirty files or unpushed commits, so the final answer names the leftover state. +- Public artifact dash guard: on `PreToolUse`, blocks public commit, tag, and PR commands that contain Unicode dash characters, and blocks any patch that adds those characters. +- Understanding check focus: on `UserPromptSubmit`, asks Codex to answer only + the user's understanding check when the prompt directly asks "do you + understand what I mean", `dykwim`, or `ykwim`. + +The hook commands resolve from the active Codex home. If `CODEX_HOME` is set, +they run from that home instead of assuming the default `~/.codex`. + +The launchers fail open if the guard script or Python runner is missing. The +portable repo checks catch that state before the hook is treated as accepted. + +Opt-outs: + +- `CODEX_PORTABLE_DISABLE_DASH_GUARD=1` disables dash blocking. +- `CODEX_PORTABLE_DISABLE_GIT_CLOSEOUT=1` disables dirty or unpushed git + closeout. +- `CODEX_PORTABLE_DISABLE_CHILD_REPO_SCAN=1` keeps git closeout limited to the + current repository. +- `CODEX_PORTABLE_DISABLE_UNDERSTANDING_CHECK=1` disables understanding check + focus. + +Codex requires hook trust review after these hooks are installed into a live Codex home. Open `/hooks` and trust the current definitions. diff --git a/codex/hooks/portable_guard.ps1 b/codex/hooks/portable_guard.ps1 new file mode 100644 index 0000000..2f2a429 --- /dev/null +++ b/codex/hooks/portable_guard.ps1 @@ -0,0 +1,61 @@ +$ErrorActionPreference = "SilentlyContinue" + +if ($env:CODEX_HOME) { + $codexHome = $env:CODEX_HOME +} +else { + $codexHome = Join-Path $env:USERPROFILE ".codex" +} + +$guard = Join-Path $codexHome "hooks\portable_guard.py" +if (-not (Test-Path -LiteralPath $guard)) { + exit 0 +} + +$runners = @( + @("py", "-3"), + @("python3"), + @("python") +) + +foreach ($runner in $runners) { + $exe = $runner[0] + if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { + continue + } + + $runnerArgs = @() + if ($runner.Count -gt 1) { + $runnerArgs = @($runner[1..($runner.Count - 1)]) + } + + & $exe @runnerArgs "--version" *> $null + if ($LASTEXITCODE -eq 0) { + $pipelineInput = @($input) + if ($pipelineInput.Count -gt 0) { + $inputText = $pipelineInput -join [Environment]::NewLine + } + else { + $inputText = [Console]::In.ReadToEnd() + } + $oldPythonIoEncoding = [Environment]::GetEnvironmentVariable("PYTHONIOENCODING", "Process") + $oldOutputEncoding = $global:OutputEncoding + $oldConsoleOutputEncoding = [Console]::OutputEncoding + $utf8Encoding = New-Object System.Text.UTF8Encoding $false + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", "utf-8", "Process") + $global:OutputEncoding = $utf8Encoding + [Console]::OutputEncoding = $utf8Encoding + try { + $inputText | & $exe @runnerArgs $guard + $guardExitCode = $LASTEXITCODE + } + finally { + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", $oldPythonIoEncoding, "Process") + $global:OutputEncoding = $oldOutputEncoding + [Console]::OutputEncoding = $oldConsoleOutputEncoding + } + exit $guardExitCode + } +} + +exit 0 diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py new file mode 100644 index 0000000..16f08f8 --- /dev/null +++ b/codex/hooks/portable_guard.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Portable Codex hook guard for local workflow checks.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +DASH_CHARS = chr(0x2013) + chr(0x2014) +SHELL_ARG = r'"[^"]+"|\'[^\']+\'|\S+' +PUBLIC_COMMAND_RE = re.compile( + rf"\b(git(?:\s+(?:(?:-C|--git-dir|--work-tree|--namespace)\s+(?:{SHELL_ARG})|-c\s+(?:[^\s=]+=(?:{SHELL_ARG})|(?:{SHELL_ARG}))|--[A-Za-z0-9-]+=(?:{SHELL_ARG})|--[A-Za-z0-9-]+|-[A-Za-z]+))*\s+(?:commit|tag)|gh(?:\s+(?:(?:--repo|-R)\s+(?:{SHELL_ARG})|--repo=(?:{SHELL_ARG})))*\s+pr\s+\S+|gh\s+release)\b", + re.IGNORECASE, +) +FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL) +INLINE_CODE_RE = re.compile(r"`[^`\n]*`") +QUOTED_TEXT_RE = re.compile(r'"[^"\n]*"|(?.*$") +UNDERSTANDING_PHRASE_RE = re.compile( + r"\b(?:" + r"do\s+(?:you|u)\s+(?:understand|know)\s+what\s+i\s+mean" + r"|do\s+(?:you|u)\s+understand\s+me" + r"|you\s+know\s+what\s+i\s+mean" + r"|know\s+what\s+i\s+mean" + r")\b", + re.IGNORECASE, +) +UNDERSTANDING_ABBREVIATION_RE = re.compile(r"\b(?:dykwim|ykwim)\b", re.IGNORECASE) +UNDERSTANDING_CONTEXT = ( + "The user prompt contains an understanding check such as " + "'do you understand what I mean', 'dykwim', or 'ykwim'. " + "Only answer the understanding check. Do not use tools. Restate what you " + "think the user means in 1 to 3 sentences, call out any ambiguity, and stop. " + "Do not act on any other request in the same user prompt." +) +PHRASE_DISCUSSION_BEFORE_RE = re.compile( + r"(?:" + r"\b(?:docs?|example|fixture|quoted?|tests?)\b" + r"|\b(?:review|spec|verify)\s+(?:if|whether)\b" + r"|\bphrases?\s+like\b" + r"|\b(?:the|this|that)\s+(?:phrase|term|text|wording)\b" + r"|\bhook\s+(?:off\s+of|for|on|around)\b" + r"|\btrigger\s+(?:on|when|for)\b" + r"|\b(?:detect|detection|literal|match(?:er)?|regex|string)\b" + r")", + re.IGNORECASE, +) +PHRASE_DISCUSSION_AFTER_RE = re.compile( + r"\b(?:" + r"acronym|detect|detection|docs?|example|fixture|hook|literal|match(?:er)?|" + r"phrase|quoted?|regex|string|support(?:ed)?|tests?|trigger|is|means?|" + r"refers?|should" + r")\b", + re.IGNORECASE, +) +TERM_DEFINITION_RE = re.compile( + r"\b(?:what\s+(?:does|is)|define|meaning\s+of)\b", + re.IGNORECASE, +) + + +def env_enabled(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def read_input() -> dict: + try: + return json.loads(sys.stdin.read() or "{}") + except json.JSONDecodeError: + return {} + + +def write_json(value: dict) -> None: + print(json.dumps(value, separators=(",", ":"))) + + +def deny_pre_tool(reason: str) -> None: + write_json( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + ) + + +def add_context(event: str, context: str) -> None: + write_json( + {"hookSpecificOutput": {"hookEventName": event, "additionalContext": context}} + ) + + +def continue_turn(reason: str) -> None: + write_json({"decision": "block", "reason": reason}) + + +def tool_command(data: dict) -> str: + tool_input = data.get("tool_input") + if isinstance(tool_input, str): + return tool_input + if isinstance(tool_input, dict): + command = tool_input.get("command") + if isinstance(command, str): + return command + return "" + + +def has_unicode_dash(text: str) -> bool: + return any(char in text for char in DASH_CHARS) + + +def added_patch_lines(command: str) -> list[tuple[str, str]]: + current_path = "" + lines: list[tuple[str, str]] = [] + for line in command.splitlines(): + for prefix in ( + "*** Add File: ", + "*** Update File: ", + "*** Delete File: ", + "*** Move to: ", + ): + if line.startswith(prefix): + current_path = line[len(prefix) :].strip() + break + else: + if line.startswith("+") and not line.startswith("+++"): + lines.append((current_path, line[1:])) + return lines + + +def dash_guard(data: dict) -> bool: + if env_enabled("CODEX_PORTABLE_DISABLE_DASH_GUARD"): + return False + + tool_name = str(data.get("tool_name") or "") + command = tool_command(data) + + if tool_name == "Bash": + if PUBLIC_COMMAND_RE.search(command) and has_unicode_dash(command): + deny_pre_tool("Public artifact command contains an en dash or em dash. Use a plain hyphen instead.") + return True + return False + + if tool_name == "apply_patch": + for path, line in added_patch_lines(command): + if has_unicode_dash(line): + target = path or "patch" + deny_pre_tool(f"Patch adds an en dash or em dash in {target}. Use a plain hyphen instead.") + return True + return False + + +def strip_code_spans(text: str) -> str: + without_code = INLINE_CODE_RE.sub(" ", FENCED_CODE_RE.sub(" ", text)) + without_quotes = QUOTED_TEXT_RE.sub(" ", without_code) + return BLOCK_QUOTE_RE.sub(" ", without_quotes) + + +def sentence_context(text: str, start: int, end: int) -> str: + before = re.split(r"[.?!;\n]", text[:start])[-1] + after = re.split(r"[.?!;\n]", text[end:], maxsplit=1)[0] + return f"{before} {after}" + + +def is_phrase_discussion(text: str, start: int, end: int) -> bool: + before = re.split(r"[.?!;\n]", text[:start])[-1] + after = re.split(r"[.?!;\n]", text[end:], maxsplit=1)[0] + return bool( + PHRASE_DISCUSSION_BEFORE_RE.search(before) + or PHRASE_DISCUSSION_AFTER_RE.search(after) + ) + + +def is_term_definition_query(text: str, start: int, end: int) -> bool: + context = sentence_context(text, start, end) + return bool( + TERM_DEFINITION_RE.search(context) + or re.match(r"\s+means?\b", text[end:], re.IGNORECASE) + ) + + +def check_looks_direct(text: str, start: int, end: int) -> bool: + if is_phrase_discussion(text, start, end) or is_term_definition_query(text, start, end): + return False + + after = text[end:] + or_not = re.match(r"\s+or\s+not\b(.*)$", after, re.IGNORECASE) + if or_not: + return or_not.group(1).strip(" \t\r\n?!.,;:)]}") == "" + if "?" in after[:8]: + return True + return after.strip(" \t\r\n?!.,;:)]}") == "" + + +def has_understanding_check(prompt: str) -> bool: + text = strip_code_spans(prompt) + for match in UNDERSTANDING_PHRASE_RE.finditer(text): + if check_looks_direct(text, match.start(), match.end()): + return True + return any( + check_looks_direct(text, match.start(), match.end()) + for match in UNDERSTANDING_ABBREVIATION_RE.finditer(text) + ) + + +def understanding_check_context(data: dict) -> bool: + if env_enabled("CODEX_PORTABLE_DISABLE_UNDERSTANDING_CHECK"): + return False + + prompt = data.get("prompt") + if not isinstance(prompt, str) or not has_understanding_check(prompt): + return False + + add_context("UserPromptSubmit", UNDERSTANDING_CONTEXT) + return True + + +def run_git(repo: Path, args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + + +def git_root(path: Path) -> Path | None: + result = run_git(path, ["rev-parse", "--show-toplevel"]) + if result.returncode != 0: + return None + root = result.stdout.strip() + if not root: + return None + return Path(root) + + +def head_missing_from_remotes(repo: Path) -> bool: + remotes = run_git( + repo, + ["for-each-ref", "--format=%(refname:short)", "refs/remotes"], + ) + if remotes.returncode != 0: + return False + if not any(line.strip() for line in remotes.stdout.splitlines()): + return False + + contains = run_git( + repo, + ["branch", "-r", "--contains", "HEAD", "--format=%(refname:short)"], + ) + if contains.returncode != 0: + return False + return not any(line.strip() for line in contains.stdout.splitlines()) + + +def candidate_repos(cwd: Path) -> list[Path]: + repos: list[Path] = [] + root = git_root(cwd) + if root is not None: + repos.append(root) + + if not env_enabled("CODEX_PORTABLE_DISABLE_CHILD_REPO_SCAN"): + try: + for child in cwd.iterdir(): + if not child.is_dir(): + continue + if (child / ".git").exists(): + repos.append(child) + except OSError: + pass + + unique: list[Path] = [] + seen: set[str] = set() + for repo in repos: + key = str(repo.resolve()).lower() + if key not in seen: + seen.add(key) + unique.append(repo) + return unique[:30] + + +def repo_status_summary(repo: Path) -> str | None: + branch = run_git(repo, ["status", "--short", "--branch"]) + if branch.returncode != 0: + return None + + lines = [line for line in branch.stdout.splitlines() if line.strip()] + if not lines: + return None + + body = [line for line in lines[1:] if line.strip()] + header = lines[0] + has_ahead = "[ahead " in header or "ahead " in header + has_no_upstream = "..." not in header + upstream_gone = "[gone]" in header + has_unpushed_without_remote = ( + has_no_upstream or upstream_gone + ) and head_missing_from_remotes(repo) + + if not body and not has_ahead and not has_unpushed_without_remote: + return None + + details = [] + if body: + details.append(f"{len(body)} dirty entries") + if has_ahead or has_unpushed_without_remote: + details.append("unpushed commits") + if not details: + return None + return f"{repo}: {', '.join(details)}" + + +def dirty_worktree_closeout(data: dict) -> bool: + if data.get("stop_hook_active") or env_enabled("CODEX_PORTABLE_DISABLE_GIT_CLOSEOUT"): + return False + + cwd = Path(str(data.get("cwd") or os.getcwd())) + summaries = [] + for repo in candidate_repos(cwd): + summary = repo_status_summary(repo) + if summary: + summaries.append(summary) + + if not summaries: + return False + + shown = summaries[:8] + more = len(summaries) - len(shown) + lines = "\n".join(f"- {summary}" for summary in shown) + if more > 0: + lines += f"\n- {more} more repos omitted" + + continue_turn( + "Before final answer, explicitly mention these dirty or unpushed git states:\n" + f"{lines}" + ) + return True + + +def main() -> int: + data = read_input() + event = str(data.get("hook_event_name") or "") + + if event == "PreToolUse": + dash_guard(data) + return 0 + + if event == "UserPromptSubmit": + understanding_check_context(data) + return 0 + + if event == "Stop": + dirty_worktree_closeout(data) + return 0 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/codex/hooks/portable_guard.sh b/codex/hooks/portable_guard.sh new file mode 100644 index 0000000..41ba752 --- /dev/null +++ b/codex/hooks/portable_guard.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +if [ -n "$CODEX_HOME" ]; then + codex_home=$CODEX_HOME +else + codex_home=$HOME/.codex +fi + +guard=$codex_home/hooks/portable_guard.py +if [ ! -f "$guard" ]; then + exit 0 +fi + +if command -v python3 >/dev/null 2>&1; then + exec python3 "$guard" +fi + +if command -v python >/dev/null 2>&1; then + exec python "$guard" +fi + +exit 0 diff --git a/manifests/portable-files.toml b/manifests/portable-files.toml index cba8ab8..efcfd02 100644 --- a/manifests/portable-files.toml +++ b/manifests/portable-files.toml @@ -7,11 +7,13 @@ home = "$CODEX_HOME or %USERPROFILE%\\.codex" files = [ "AGENTS.md", + "hooks.json", "keybindings.json", ] dirs = [ "agents", + "hooks", ] # This allowlist models the current live personal skill store under the active diff --git a/manifests/tool-surfaces.md b/manifests/tool-surfaces.md index 0bedef5..36f1460 100644 --- a/manifests/tool-surfaces.md +++ b/manifests/tool-surfaces.md @@ -18,6 +18,7 @@ and cache paths out of this repo. | Web search | Reads current web sources | No | Medium | Use for unstable facts and source attribution. Do not encode search results as permanent rules without review. | | Skills | Load task-specific instructions, references, scripts, and assets | Yes | Medium | Keep descriptions concise. Move details into `SKILL.md` and references. | | Agents | Spawn focused Codex sessions with custom instructions | Yes | Medium | Keep agents narrow and explicit. Pure explorers should use read-only sandboxing. Critics that validate behavior can run tools while staying non-editing by role. | +| Hooks | Run trusted commands around Codex tool use and turn closeout | Yes | High | Keep hook code small, local, and reviewed. Hooks must not carry secrets, network calls, machine paths, or auth state. Runtime hooks should fail open when dependencies are missing, with `doctor.ps1` catching invalid portable copies. | ## Review Checklist diff --git a/scripts/common.ps1 b/scripts/common.ps1 index 7fbea68..b159979 100644 --- a/scripts/common.ps1 +++ b/scripts/common.ps1 @@ -56,7 +56,7 @@ function Get-PortableFileMap { $items = New-Object System.Collections.Generic.List[object] - foreach ($relative in @("AGENTS.md", "keybindings.json")) { + foreach ($relative in @("AGENTS.md", "hooks.json", "keybindings.json")) { $items.Add([pscustomobject]@{ Type = "file" RepoPath = Join-Path (Join-Path $RepoRoot "codex") $relative @@ -64,11 +64,13 @@ function Get-PortableFileMap { }) } - $items.Add([pscustomobject]@{ - Type = "dir" - RepoPath = Join-Path (Join-Path $RepoRoot "codex") "agents" - LivePath = Join-Path $CodexHome "agents" - }) + foreach ($relative in @("agents", "hooks")) { + $items.Add([pscustomobject]@{ + Type = "dir" + RepoPath = Join-Path (Join-Path $RepoRoot "codex") $relative + LivePath = Join-Path $CodexHome $relative + }) + } # This repo mirrors the current personal skill store under the active # Codex home. Project `.agents/skills` stay with the target repo. diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 index 96fd2bf..9ca3212 100644 --- a/scripts/doctor.ps1 +++ b/scripts/doctor.ps1 @@ -98,6 +98,11 @@ foreach ($path in @( ".github\workflows\portable-checks.yml", "AGENTS.md", "codex\AGENTS.md", + "codex\hooks.json", + "codex\hooks\README.md", + "codex\hooks\portable_guard.py", + "codex\hooks\portable_guard.ps1", + "codex\hooks\portable_guard.sh", "codex\keybindings.json", "codex\config.review.toml", "manifests\portable-files.toml", @@ -118,6 +123,70 @@ foreach ($path in @( } } +function Get-DoctorPythonRunner { + $candidates = New-Object System.Collections.Generic.List[object] + + if ($env:OS -eq "Windows_NT") { + $candidates.Add(@("py", "-3")) + } + + $candidates.Add(@("python3")) + $candidates.Add(@("python")) + + foreach ($candidate in $candidates) { + $exe = $candidate[0] + if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { + continue + } + + $runnerArgs = @() + if ($candidate.Count -gt 1) { + $runnerArgs = @($candidate[1..($candidate.Count - 1)]) + } + + & $exe @runnerArgs "--version" *> $null + if ($LASTEXITCODE -eq 0) { + return @($candidate) + } + } + + return @() +} + +function Invoke-DoctorPythonScript { + param( + [string[]]$Runner, + [string]$ScriptPath, + [string]$InputText + ) + + $exe = $Runner[0] + $runnerArgs = @() + if ($Runner.Count -gt 1) { + $runnerArgs = @($Runner[1..($Runner.Count - 1)]) + } + + $oldPythonIoEncoding = [Environment]::GetEnvironmentVariable("PYTHONIOENCODING", "Process") + $oldOutputEncoding = $global:OutputEncoding + $oldConsoleOutputEncoding = [Console]::OutputEncoding + $utf8Encoding = New-Object System.Text.UTF8Encoding $false + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", "utf-8", "Process") + $global:OutputEncoding = $utf8Encoding + [Console]::OutputEncoding = $utf8Encoding + try { + $output = $InputText | & $exe @runnerArgs $ScriptPath 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = ($output -join "`n") + } + } + finally { + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", $oldPythonIoEncoding, "Process") + $global:OutputEncoding = $oldOutputEncoding + [Console]::OutputEncoding = $oldConsoleOutputEncoding + } +} + $manifestPath = Join-Path $repoRoot "manifests\portable-files.toml" $manifestText = Get-Content -Raw -LiteralPath $manifestPath @@ -346,6 +415,356 @@ else { } } +$portableMap = @(Get-PortableFileMap -RepoRoot $repoRoot -CodexHome $liveHome) +foreach ($item in $portableMap) { + if (-not (Test-Path -LiteralPath $item.RepoPath)) { + $problems.Add("install map source missing: $($item.RepoPath)") + } +} + +$codexRoot = Get-DoctorFullPath -Path (Join-Path $repoRoot "codex") +$manifestCodexFiles = @( + Get-ManifestArrayValues -Text $manifestText -Section "codex" -Key "files" | + Sort-Object -Unique +) +$mappedCodexFiles = @( + $portableMap | + Where-Object { + $_.Type -eq "file" -and + (Get-DoctorFullPath -Path (Split-Path -Parent $_.RepoPath)) -eq $codexRoot + } | + ForEach-Object { Split-Path -Leaf $_.RepoPath } | + Sort-Object -Unique +) + +foreach ($file in $manifestCodexFiles) { + if ($mappedCodexFiles -notcontains $file) { + $problems.Add("codex file in manifest missing from install map: $file") + } +} + +foreach ($file in $mappedCodexFiles) { + if ($manifestCodexFiles -notcontains $file) { + $problems.Add("codex file in install map missing from manifest: $file") + } +} + +$manifestCodexDirs = @( + Get-ManifestArrayValues -Text $manifestText -Section "codex" -Key "dirs" | + Sort-Object -Unique +) +$mappedCodexDirs = @( + $portableMap | + Where-Object { + $_.Type -eq "dir" -and + (Get-DoctorFullPath -Path (Split-Path -Parent $_.RepoPath)) -eq $codexRoot + } | + ForEach-Object { Split-Path -Leaf $_.RepoPath } | + Sort-Object -Unique +) + +foreach ($dir in $manifestCodexDirs) { + if ($mappedCodexDirs -notcontains $dir) { + $problems.Add("codex dir in manifest missing from install map: $dir") + } +} + +foreach ($dir in $mappedCodexDirs) { + if ($manifestCodexDirs -notcontains $dir) { + $problems.Add("codex dir in install map missing from manifest: $dir") + } +} + +$hooksJsonPath = Join-Path $repoRoot "codex\hooks.json" +try { + [void](Get-Content -Raw -LiteralPath $hooksJsonPath | ConvertFrom-Json) +} +catch { + $problems.Add("invalid hooks.json: $($_.Exception.Message)") +} + +$hookGuardPath = Join-Path $repoRoot "codex\hooks\portable_guard.py" +$pythonRunner = @(Get-DoctorPythonRunner) +if ($pythonRunner.Count -eq 0) { + $problems.Add("no runnable Python found for portable hook guard") +} +elseif (Test-Path -LiteralPath $hookGuardPath) { + function Invoke-PortableGuardCase { + param( + [string]$Name, + [hashtable]$Payload + ) + + $inputText = $Payload | ConvertTo-Json -Compress -Depth 8 + $result = Invoke-DoctorPythonScript -Runner $pythonRunner -ScriptPath $hookGuardPath -InputText $inputText + if ($result.ExitCode -ne 0) { + $problems.Add("portable hook case failed: $Name") + return $null + } + + return $result.Output.Trim() + } + + function Test-PortableGuardDeny { + param( + [string]$Name, + [hashtable]$Payload + ) + + $output = Invoke-PortableGuardCase -Name $Name -Payload $Payload + if (-not $output) { + $problems.Add("portable hook did not deny: $Name") + return + } + + try { + $parsed = $output | ConvertFrom-Json + $decision = $parsed.hookSpecificOutput.permissionDecision + if ($decision -ne "deny") { + $problems.Add("portable hook unexpected decision for ${Name}: $decision") + } + } + catch { + $problems.Add("portable hook emitted invalid deny JSON for ${Name}: $output") + } + } + + function Test-PortableGuardBlock { + param( + [string]$Name, + [hashtable]$Payload + ) + + $output = Invoke-PortableGuardCase -Name $Name -Payload $Payload + if (-not $output) { + $problems.Add("portable hook did not block: $Name") + return + } + + try { + $parsed = $output | ConvertFrom-Json + if ($parsed.decision -ne "block") { + $problems.Add("portable hook unexpected block decision for ${Name}: $($parsed.decision)") + } + } + catch { + $problems.Add("portable hook emitted invalid block JSON for ${Name}: $output") + } + } + + function Test-PortableGuardContext { + param( + [string]$Name, + [hashtable]$Payload, + [string]$ExpectedContext + ) + + $output = Invoke-PortableGuardCase -Name $Name -Payload $Payload + if (-not $output) { + $problems.Add("portable hook did not emit context: $Name") + return + } + + try { + $parsed = $output | ConvertFrom-Json + if ($parsed.hookSpecificOutput.hookEventName -ne "UserPromptSubmit") { + $problems.Add("portable hook unexpected context event for ${Name}: $($parsed.hookSpecificOutput.hookEventName)") + } + if ($parsed.hookSpecificOutput.additionalContext -ne $ExpectedContext) { + $problems.Add("portable hook unexpected context for ${Name}: $($parsed.hookSpecificOutput.additionalContext)") + } + } + catch { + $problems.Add("portable hook emitted invalid context JSON for ${Name}: $output") + } + } + + function Test-PortableGuardSilent { + param( + [string]$Name, + [hashtable]$Payload + ) + + $output = Invoke-PortableGuardCase -Name $Name -Payload $Payload + if ($output) { + $problems.Add("portable hook unexpectedly emitted output for ${Name}: $output") + } + } + + $understandingContext = "The user prompt contains an understanding check such as 'do you understand what I mean', 'dykwim', or 'ykwim'. Only answer the understanding check. Do not use tools. Restate what you think the user means in 1 to 3 sentences, call out any ambiguity, and stop. Do not act on any other request in the same user prompt." + + $dash = [char]0x2014 + Test-PortableGuardDeny -Name "git commit dash" -Payload @{ + hook_event_name = "PreToolUse" + tool_name = "Bash" + tool_input = @{ + command = "git commit -m `"bad $dash msg`"" + } + } + Test-PortableGuardDeny -Name "git global option dash" -Payload @{ + hook_event_name = "PreToolUse" + tool_name = "Bash" + tool_input = @{ + command = "git -C repo commit -m `"bad $dash msg`"" + } + } + Test-PortableGuardDeny -Name "patch dash" -Payload @{ + hook_event_name = "PreToolUse" + tool_name = "apply_patch" + tool_input = "*** Begin Patch`n*** Add File: sample.md`n+bad $dash msg`n*** End Patch`n" + } + Test-PortableGuardSilent -Name "clean git command" -Payload @{ + hook_event_name = "PreToolUse" + tool_name = "Bash" + tool_input = @{ + command = "git status" + } + } + + $oldDashOptOut = [Environment]::GetEnvironmentVariable("CODEX_PORTABLE_DISABLE_DASH_GUARD", "Process") + [Environment]::SetEnvironmentVariable("CODEX_PORTABLE_DISABLE_DASH_GUARD", "1", "Process") + try { + Test-PortableGuardSilent -Name "dash guard opt-out" -Payload @{ + hook_event_name = "PreToolUse" + tool_name = "Bash" + tool_input = @{ + command = "git commit -m `"bad $dash msg`"" + } + } + } + finally { + [Environment]::SetEnvironmentVariable("CODEX_PORTABLE_DISABLE_DASH_GUARD", $oldDashOptOut, "Process") + } + + if (Get-Command git -ErrorAction SilentlyContinue) { + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "codex-portable-hook-$([guid]::NewGuid())" + try { + $repo = Join-Path $tempRoot "repo" + New-Item -ItemType Directory -Force $repo | Out-Null + & git -C $repo init -q *> $null + if ($LASTEXITCODE -ne 0) { + $problems.Add("portable hook git fixture failed to initialize") + } + else { + Test-PortableGuardSilent -Name "clean stop" -Payload @{ + hook_event_name = "Stop" + cwd = $repo + } + + Set-Content -LiteralPath (Join-Path $repo "dirty.txt") -Value "dirty" + Test-PortableGuardBlock -Name "dirty stop" -Payload @{ + hook_event_name = "Stop" + cwd = $repo + } + + $oldGitOptOut = [Environment]::GetEnvironmentVariable("CODEX_PORTABLE_DISABLE_GIT_CLOSEOUT", "Process") + [Environment]::SetEnvironmentVariable("CODEX_PORTABLE_DISABLE_GIT_CLOSEOUT", "1", "Process") + try { + Test-PortableGuardSilent -Name "git closeout opt-out" -Payload @{ + hook_event_name = "Stop" + cwd = $repo + } + } + finally { + [Environment]::SetEnvironmentVariable("CODEX_PORTABLE_DISABLE_GIT_CLOSEOUT", $oldGitOptOut, "Process") + } + } + } + finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + } + } + + Test-PortableGuardContext -Name "understanding explicit phrase" -ExpectedContext $understandingContext -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "Make the hook narrow, do you understand what i mean?" + } + Test-PortableGuardContext -Name "understanding contractions" -ExpectedContext $understandingContext -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "I don't think you're following, do you understand what i mean?" + } + Test-PortableGuardContext -Name "understanding dykwim question" -ExpectedContext $understandingContext -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "That should answer only the understanding check, DyKwIm?" + } + Test-PortableGuardContext -Name "understanding ykwim trailing" -ExpectedContext $understandingContext -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "Keep it focused on the meaning check, ykwim" + } + Test-PortableGuardSilent -Name "understanding implementation request" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "add a hook off of ``do you understand what i mean?`` / ``dykwim`` / ``ykwim`` etc." + } + Test-PortableGuardSilent -Name "understanding quoted examples" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = 'Detect "do you understand what i mean?" and "ykwim".' + } + Test-PortableGuardSilent -Name "understanding code block" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = 'Test this fixture: ```text do you understand what i mean? ```' + } + Test-PortableGuardSilent -Name "understanding fixture text" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "Example prompt: do you understand what i mean? should be covered." + } + Test-PortableGuardSilent -Name "understanding unquoted phrase discussion" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "The phrase do you understand what i mean is the one we care about." + } + Test-PortableGuardSilent -Name "understanding unquoted review discussion" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "Review whether do you understand what i mean should be supported." + } + Test-PortableGuardSilent -Name "understanding identifier text" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "Enable ykwim_enabled in the test config." + } + Test-PortableGuardSilent -Name "understanding definition query" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "What does dykwim mean?" + } + + $oldUnderstandingOptOut = [Environment]::GetEnvironmentVariable("CODEX_PORTABLE_DISABLE_UNDERSTANDING_CHECK", "Process") + [Environment]::SetEnvironmentVariable("CODEX_PORTABLE_DISABLE_UNDERSTANDING_CHECK", "1", "Process") + try { + Test-PortableGuardSilent -Name "understanding opt-out" -Payload @{ + hook_event_name = "UserPromptSubmit" + prompt = "Do you understand what I mean?" + } + } + finally { + [Environment]::SetEnvironmentVariable("CODEX_PORTABLE_DISABLE_UNDERSTANDING_CHECK", $oldUnderstandingOptOut, "Process") + } +} + +$hookLauncherPath = Join-Path $repoRoot "codex\hooks\portable_guard.ps1" +if (Test-Path -LiteralPath $hookLauncherPath) { + $oldCodexHome = [Environment]::GetEnvironmentVariable("CODEX_HOME", "Process") + [Environment]::SetEnvironmentVariable("CODEX_HOME", (Join-Path $repoRoot "codex"), "Process") + try { + $dash = [char]0x2014 + $payload = @{ + hook_event_name = "PreToolUse" + tool_name = "Bash" + tool_input = @{ + command = "git commit -m `"bad $dash msg`"" + } + } | ConvertTo-Json -Compress -Depth 8 + $launcherOutput = $payload | & $hookLauncherPath 2>&1 + if ($LASTEXITCODE -ne 0) { + $problems.Add("portable hook PowerShell launcher failed") + } + elseif (-not (($launcherOutput -join "`n") -match '"permissionDecision":"deny"')) { + $problems.Add("portable hook PowerShell launcher did not run guard") + } + } + finally { + [Environment]::SetEnvironmentVariable("CODEX_HOME", $oldCodexHome, "Process") + } +} + Write-Host "repo: $repoRoot" Write-Host "live: $liveHome"