From a2dcf5d402f3893ec1a524cd98374af752004f1f Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Tue, 16 Jun 2026 11:14:01 -0400 Subject: [PATCH 01/12] add portable hooks --- codex/hooks.json | 31 +++++ codex/hooks/README.md | 8 ++ codex/hooks/portable_guard.py | 215 ++++++++++++++++++++++++++++++++++ manifests/portable-files.toml | 2 + scripts/common.ps1 | 14 ++- scripts/doctor.ps1 | 66 ++++++++++- 6 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 codex/hooks.json create mode 100644 codex/hooks/README.md create mode 100644 codex/hooks/portable_guard.py diff --git a/codex/hooks.json b/codex/hooks.json new file mode 100644 index 0000000..fd8bdb5 --- /dev/null +++ b/codex/hooks.json @@ -0,0 +1,31 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^(Bash|apply_patch)$", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.codex/hooks/portable_guard.py", + "commandWindows": "python -c \"import os, runpy; runpy.run_path(os.path.join(os.environ['USERPROFILE'], '.codex', 'hooks', 'portable_guard.py'), run_name='__main__')\"", + "timeout": 30, + "statusMessage": "Checking portable workflow guard" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ~/.codex/hooks/portable_guard.py", + "commandWindows": "python -c \"import os, runpy; runpy.run_path(os.path.join(os.environ['USERPROFILE'], '.codex', 'hooks', 'portable_guard.py'), run_name='__main__')\"", + "timeout": 30, + "statusMessage": "Checking git closeout" + } + ] + } + ] + } +} diff --git a/codex/hooks/README.md b/codex/hooks/README.md new file mode 100644 index 0000000..630c389 --- /dev/null +++ b/codex/hooks/README.md @@ -0,0 +1,8 @@ +# 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 patches that add those characters. + +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.py b/codex/hooks/portable_guard.py new file mode 100644 index 0000000..d52345b --- /dev/null +++ b/codex/hooks/portable_guard.py @@ -0,0 +1,215 @@ +#!/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) +PUBLIC_COMMAND_RE = re.compile( + r"\b(git\s+commit|git\s+tag|gh\s+pr\s+(create|edit)|gh\s+release)\b", + re.IGNORECASE, +) +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 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, 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: + 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 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 candidate_repos(cwd: Path) -> list[Path]: + repos: list[Path] = [] + root = git_root(cwd) + if root is not None: + repos.append(root) + else: + 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 + no_upstream = "..." not in header and not header.endswith("main") and not header.endswith("master") + + if not body and not has_ahead and not no_upstream: + return None + + details = [] + if body: + details.append(f"{len(body)} dirty entries") + if has_ahead: + details.append("unpushed commits") + if no_upstream: + details.append("no upstream shown") + if not details: + return None + return f"{repo}: {', '.join(details)}" + + +def dirty_worktree_closeout(data: dict) -> bool: + if data.get("stop_hook_active"): + 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 == "Stop": + dirty_worktree_closeout(data) + return 0 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/manifests/portable-files.toml b/manifests/portable-files.toml index bf93c42..e29413e 100644 --- a/manifests/portable-files.toml +++ b/manifests/portable-files.toml @@ -5,11 +5,13 @@ home = "%USERPROFILE%\\.codex" files = [ "AGENTS.md", + "hooks.json", "keybindings.json", ] dirs = [ "agents", + "hooks", ] skills = [ diff --git a/scripts/common.ps1 b/scripts/common.ps1 index 8c7d47c..01791a5 100644 --- a/scripts/common.ps1 +++ b/scripts/common.ps1 @@ -52,7 +52,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 @@ -60,11 +60,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 + }) + } foreach ($skill in @( "action-items-to-prs", diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 index 2e509d1..d213545 100644 --- a/scripts/doctor.ps1 +++ b/scripts/doctor.ps1 @@ -107,6 +107,70 @@ foreach ($skillFile in $skillFiles) { $manifestPath = Join-Path $repoRoot "manifests\portable-files.toml" $manifestText = Get-Content -Raw -LiteralPath $manifestPath + +function Get-ManifestList { + param( + [string]$Text, + [string]$Key + ) + + $match = [regex]::Match($Text, "(?ms)^$Key\s*=\s*\[(.*?)^\]") + if (-not $match.Success) { + return $null + } + + return @( + [regex]::Matches($match.Groups[1].Value, '"([^"]+)"') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique + ) +} + +$codexRoot = [System.IO.Path]::GetFullPath((Join-Path $repoRoot "codex")).TrimEnd("\") +$mappedItems = @(Get-PortableFileMap -RepoRoot $repoRoot -CodexHome $liveHome) + +foreach ($entry in @( + [pscustomobject]@{ Key = "files"; Type = "file" }, + [pscustomobject]@{ Key = "dirs"; Type = "dir" } +)) { + $manifestValues = Get-ManifestList -Text $manifestText -Key $entry.Key + if ($null -eq $manifestValues) { + $problems.Add("missing $($entry.Key) allowlist in portable manifest") + continue + } + + $mappedValues = @( + $mappedItems | + Where-Object { + $_.Type -eq $entry.Type -and + [System.IO.Path]::GetFullPath($_.RepoPath).TrimEnd("\").StartsWith( + "$codexRoot\", + [System.StringComparison]::OrdinalIgnoreCase + ) -and + -not [System.IO.Path]::GetFullPath($_.RepoPath).TrimEnd("\").StartsWith( + "$codexRoot\skills\", + [System.StringComparison]::OrdinalIgnoreCase + ) + } | + ForEach-Object { + [System.IO.Path]::GetFullPath($_.RepoPath).Substring($codexRoot.Length).TrimStart("\") + } | + Sort-Object -Unique + ) + + foreach ($value in $manifestValues) { + if ($mappedValues -notcontains $value) { + $problems.Add("$($entry.Type) in manifest missing from install map: $value") + } + } + + foreach ($value in $mappedValues) { + if ($manifestValues -notcontains $value) { + $problems.Add("$($entry.Type) in install map missing from manifest: $value") + } + } +} + $skillsMatch = [regex]::Match($manifestText, "(?ms)^skills\s*=\s*\[(.*?)^\]") if (-not $skillsMatch.Success) { $problems.Add("missing skills allowlist in portable manifest") @@ -120,7 +184,7 @@ else { $skillsRoot = [System.IO.Path]::GetFullPath((Join-Path (Join-Path $repoRoot "codex") "skills")).TrimEnd("\") $mappedSkills = @( - Get-PortableFileMap -RepoRoot $repoRoot -CodexHome $liveHome | + $mappedItems | Where-Object { $_.Type -eq "dir" -and [System.IO.Path]::GetFullPath($_.RepoPath).TrimEnd("\").StartsWith( From 28101aa42983bfb2e379d27463d149c9880541cb Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 11:39:12 -0400 Subject: [PATCH 02/12] fix portable hook guard --- codex/hooks/portable_guard.py | 2 +- scripts/doctor.ps1 | 66 +---------------------------------- 2 files changed, 2 insertions(+), 66 deletions(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index d52345b..afead81 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -13,7 +13,7 @@ DASH_CHARS = chr(0x2013) + chr(0x2014) PUBLIC_COMMAND_RE = re.compile( - r"\b(git\s+commit|git\s+tag|gh\s+pr\s+(create|edit)|gh\s+release)\b", + r"\b(git\s+commit|git\s+tag|gh\s+pr\s+(create|edit|comment|review)|gh\s+release)\b", re.IGNORECASE, ) def read_input() -> dict: diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 index d213545..2e509d1 100644 --- a/scripts/doctor.ps1 +++ b/scripts/doctor.ps1 @@ -107,70 +107,6 @@ foreach ($skillFile in $skillFiles) { $manifestPath = Join-Path $repoRoot "manifests\portable-files.toml" $manifestText = Get-Content -Raw -LiteralPath $manifestPath - -function Get-ManifestList { - param( - [string]$Text, - [string]$Key - ) - - $match = [regex]::Match($Text, "(?ms)^$Key\s*=\s*\[(.*?)^\]") - if (-not $match.Success) { - return $null - } - - return @( - [regex]::Matches($match.Groups[1].Value, '"([^"]+)"') | - ForEach-Object { $_.Groups[1].Value } | - Sort-Object -Unique - ) -} - -$codexRoot = [System.IO.Path]::GetFullPath((Join-Path $repoRoot "codex")).TrimEnd("\") -$mappedItems = @(Get-PortableFileMap -RepoRoot $repoRoot -CodexHome $liveHome) - -foreach ($entry in @( - [pscustomobject]@{ Key = "files"; Type = "file" }, - [pscustomobject]@{ Key = "dirs"; Type = "dir" } -)) { - $manifestValues = Get-ManifestList -Text $manifestText -Key $entry.Key - if ($null -eq $manifestValues) { - $problems.Add("missing $($entry.Key) allowlist in portable manifest") - continue - } - - $mappedValues = @( - $mappedItems | - Where-Object { - $_.Type -eq $entry.Type -and - [System.IO.Path]::GetFullPath($_.RepoPath).TrimEnd("\").StartsWith( - "$codexRoot\", - [System.StringComparison]::OrdinalIgnoreCase - ) -and - -not [System.IO.Path]::GetFullPath($_.RepoPath).TrimEnd("\").StartsWith( - "$codexRoot\skills\", - [System.StringComparison]::OrdinalIgnoreCase - ) - } | - ForEach-Object { - [System.IO.Path]::GetFullPath($_.RepoPath).Substring($codexRoot.Length).TrimStart("\") - } | - Sort-Object -Unique - ) - - foreach ($value in $manifestValues) { - if ($mappedValues -notcontains $value) { - $problems.Add("$($entry.Type) in manifest missing from install map: $value") - } - } - - foreach ($value in $mappedValues) { - if ($manifestValues -notcontains $value) { - $problems.Add("$($entry.Type) in install map missing from manifest: $value") - } - } -} - $skillsMatch = [regex]::Match($manifestText, "(?ms)^skills\s*=\s*\[(.*?)^\]") if (-not $skillsMatch.Success) { $problems.Add("missing skills allowlist in portable manifest") @@ -184,7 +120,7 @@ else { $skillsRoot = [System.IO.Path]::GetFullPath((Join-Path (Join-Path $repoRoot "codex") "skills")).TrimEnd("\") $mappedSkills = @( - $mappedItems | + Get-PortableFileMap -RepoRoot $repoRoot -CodexHome $liveHome | Where-Object { $_.Type -eq "dir" -and [System.IO.Path]::GetFullPath($_.RepoPath).TrimEnd("\").StartsWith( From e411e437aca150bf291d4e5dcaea9af34fb1096c Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 11:46:20 -0400 Subject: [PATCH 03/12] cover pr hook commands --- codex/hooks/portable_guard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index afead81..7cea7ad 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -13,7 +13,7 @@ DASH_CHARS = chr(0x2013) + chr(0x2014) PUBLIC_COMMAND_RE = re.compile( - r"\b(git\s+commit|git\s+tag|gh\s+pr\s+(create|edit|comment|review)|gh\s+release)\b", + r"\b(git\s+commit|git\s+tag|gh\s+pr\s+\S+|gh\s+release)\b", re.IGNORECASE, ) def read_input() -> dict: From 910cc232aeb1faa29b6dc84283876189fff3fbe3 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 11:53:19 -0400 Subject: [PATCH 04/12] honor codex home --- codex/hooks.json | 8 ++++---- codex/hooks/README.md | 3 +++ scripts/common.ps1 | 4 ++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/codex/hooks.json b/codex/hooks.json index fd8bdb5..96842db 100644 --- a/codex/hooks.json +++ b/codex/hooks.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "command": "python3 ~/.codex/hooks/portable_guard.py", - "commandWindows": "python -c \"import os, runpy; runpy.run_path(os.path.join(os.environ['USERPROFILE'], '.codex', 'hooks', 'portable_guard.py'), run_name='__main__')\"", + "command": "python3 \"${CODEX_HOME:-$HOME/.codex}/hooks/portable_guard.py\"", + "commandWindows": "python -c \"import os, runpy; home = os.environ.get('CODEX_HOME') or os.path.join(os.environ['USERPROFILE'], '.codex'); runpy.run_path(os.path.join(home, 'hooks', 'portable_guard.py'), run_name='__main__')\"", "timeout": 30, "statusMessage": "Checking portable workflow guard" } @@ -19,8 +19,8 @@ "hooks": [ { "type": "command", - "command": "python3 ~/.codex/hooks/portable_guard.py", - "commandWindows": "python -c \"import os, runpy; runpy.run_path(os.path.join(os.environ['USERPROFILE'], '.codex', 'hooks', 'portable_guard.py'), run_name='__main__')\"", + "command": "python3 \"${CODEX_HOME:-$HOME/.codex}/hooks/portable_guard.py\"", + "commandWindows": "python -c \"import os, runpy; home = os.environ.get('CODEX_HOME') or os.path.join(os.environ['USERPROFILE'], '.codex'); runpy.run_path(os.path.join(home, 'hooks', 'portable_guard.py'), run_name='__main__')\"", "timeout": 30, "statusMessage": "Checking git closeout" } diff --git a/codex/hooks/README.md b/codex/hooks/README.md index 630c389..595b2db 100644 --- a/codex/hooks/README.md +++ b/codex/hooks/README.md @@ -5,4 +5,7 @@ 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 patches that add those characters. +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`. + 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/scripts/common.ps1 b/scripts/common.ps1 index 01791a5..f3a23dd 100644 --- a/scripts/common.ps1 +++ b/scripts/common.ps1 @@ -12,6 +12,10 @@ function Get-CodexHome { return (Resolve-Path $CodexHome).Path } + if ($env:CODEX_HOME) { + return (Resolve-Path $env:CODEX_HOME).Path + } + $default = Join-Path $env:USERPROFILE ".codex" return (Resolve-Path $default).Path } From 6050fffce45852167123c179a361290f3de3c685 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 12:03:53 -0400 Subject: [PATCH 05/12] fix hook guard coverage --- codex/hooks/portable_guard.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index 7cea7ad..7436744 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -13,7 +13,7 @@ DASH_CHARS = chr(0x2013) + chr(0x2014) PUBLIC_COMMAND_RE = re.compile( - r"\b(git\s+commit|git\s+tag|gh\s+pr\s+\S+|gh\s+release)\b", + r"\b(git\s+commit|git\s+tag|gh(?:\s+(?:--repo|-R)\s+\S+)*\s+pr\s+\S+|gh\s+release)\b", re.IGNORECASE, ) def read_input() -> dict: @@ -45,6 +45,8 @@ def continue_turn(reason: str) -> None: 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): From 13ade802d819f39ccdaead912c8f164a1eaec493 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 12:14:05 -0400 Subject: [PATCH 06/12] fix git hook coverage --- codex/hooks/portable_guard.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index 7436744..c0d67c5 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -13,7 +13,7 @@ DASH_CHARS = chr(0x2013) + chr(0x2014) PUBLIC_COMMAND_RE = re.compile( - r"\b(git\s+commit|git\s+tag|gh(?:\s+(?:--repo|-R)\s+\S+)*\s+pr\s+\S+|gh\s+release)\b", + r"\b(git(?:\s+(?:(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|--[A-Za-z0-9-]+(?:=\S+)?|-[A-Za-z]+))*\s+(?:commit|tag)|gh(?:\s+(?:--repo|-R)\s+\S+)*\s+pr\s+\S+|gh\s+release)\b", re.IGNORECASE, ) def read_input() -> dict: @@ -122,15 +122,15 @@ def candidate_repos(cwd: Path) -> list[Path]: root = git_root(cwd) if root is not None: repos.append(root) - else: - try: - for child in cwd.iterdir(): - if not child.is_dir(): - continue - if (child / ".git").exists(): - repos.append(child) - except OSError: - pass + + 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() From 2f61c59ff010a203d754aa3b4c7bc8e2d928b6c1 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 12:18:20 -0400 Subject: [PATCH 07/12] fix quoted hook commands --- codex/hooks/portable_guard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index c0d67c5..dd4acd5 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -12,8 +12,9 @@ DASH_CHARS = chr(0x2013) + chr(0x2014) +SHELL_ARG = r'"[^"]+"|\'[^\']+\'|\S+' PUBLIC_COMMAND_RE = re.compile( - r"\b(git(?:\s+(?:(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|--[A-Za-z0-9-]+(?:=\S+)?|-[A-Za-z]+))*\s+(?:commit|tag)|gh(?:\s+(?:--repo|-R)\s+\S+)*\s+pr\s+\S+|gh\s+release)\b", + rf"\b(git(?:\s+(?:(?:-C|-c|--git-dir|--work-tree|--namespace)\s+(?:{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, ) def read_input() -> dict: From b4e2f3c515c3ea89b724ead9a4f465718223c792 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 12:22:58 -0400 Subject: [PATCH 08/12] fix hook edge cases --- codex/hooks/portable_guard.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index dd4acd5..c2d8a64 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -14,7 +14,7 @@ DASH_CHARS = chr(0x2013) + chr(0x2014) SHELL_ARG = r'"[^"]+"|\'[^\']+\'|\S+' PUBLIC_COMMAND_RE = re.compile( - rf"\b(git(?:\s+(?:(?:-C|-c|--git-dir|--work-tree|--namespace)\s+(?:{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", + 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, ) def read_input() -> dict: @@ -155,9 +155,8 @@ def repo_status_summary(repo: Path) -> str | None: body = [line for line in lines[1:] if line.strip()] header = lines[0] has_ahead = "[ahead " in header or "ahead " in header - no_upstream = "..." not in header and not header.endswith("main") and not header.endswith("master") - if not body and not has_ahead and not no_upstream: + if not body and not has_ahead: return None details = [] @@ -165,8 +164,6 @@ def repo_status_summary(repo: Path) -> str | None: details.append(f"{len(body)} dirty entries") if has_ahead: details.append("unpushed commits") - if no_upstream: - details.append("no upstream shown") if not details: return None return f"{repo}: {', '.join(details)}" From 871257d224363fd7a742c998e5867d16f0a25ece Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 12:32:23 -0400 Subject: [PATCH 09/12] detect local branch commits --- codex/hooks/portable_guard.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index c2d8a64..b13df39 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -118,6 +118,25 @@ def git_root(path: Path) -> Path | 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) @@ -155,14 +174,16 @@ def repo_status_summary(repo: Path) -> str | 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 + has_unpushed_no_upstream = has_no_upstream and head_missing_from_remotes(repo) - if not body and not has_ahead: + if not body and not has_ahead and not has_unpushed_no_upstream: return None details = [] if body: details.append(f"{len(body)} dirty entries") - if has_ahead: + if has_ahead or has_unpushed_no_upstream: details.append("unpushed commits") if not details: return None From 51f006a8b87cda8225c13237e87be1d081bd9316 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Fri, 19 Jun 2026 12:40:21 -0400 Subject: [PATCH 10/12] detect gone upstream commits --- codex/hooks/portable_guard.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index b13df39..b5c8f3c 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -175,15 +175,18 @@ def repo_status_summary(repo: Path) -> str | None: header = lines[0] has_ahead = "[ahead " in header or "ahead " in header has_no_upstream = "..." not in header - has_unpushed_no_upstream = has_no_upstream and head_missing_from_remotes(repo) + 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_no_upstream: + 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_no_upstream: + if has_ahead or has_unpushed_without_remote: details.append("unpushed commits") if not details: return None From b7fdbe967dc9d4b73ba890d32734ead879b217f9 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary <89481178+Doomsy1@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:49:50 -0400 Subject: [PATCH 11/12] hardened portable hook guardrails --- README.md | 2 + codex/hooks.json | 8 +- codex/hooks/README.md | 13 +- codex/hooks/portable_guard.ps1 | 54 ++++++ codex/hooks/portable_guard.py | 28 ++- codex/hooks/portable_guard.sh | 22 +++ manifests/tool-surfaces.md | 1 + scripts/common.ps1 | 4 - scripts/doctor.ps1 | 322 +++++++++++++++++++++++++++++++++ 9 files changed, 436 insertions(+), 18 deletions(-) create mode 100644 codex/hooks/portable_guard.ps1 create mode 100644 codex/hooks/portable_guard.sh 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 index 96842db..aa41d25 100644 --- a/codex/hooks.json +++ b/codex/hooks.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CODEX_HOME:-$HOME/.codex}/hooks/portable_guard.py\"", - "commandWindows": "python -c \"import os, runpy; home = os.environ.get('CODEX_HOME') or os.path.join(os.environ['USERPROFILE'], '.codex'); runpy.run_path(os.path.join(home, 'hooks', 'portable_guard.py'), run_name='__main__')\"", + "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" } @@ -19,8 +19,8 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CODEX_HOME:-$HOME/.codex}/hooks/portable_guard.py\"", - "commandWindows": "python -c \"import os, runpy; home = os.environ.get('CODEX_HOME') or os.path.join(os.environ['USERPROFILE'], '.codex'); runpy.run_path(os.path.join(home, 'hooks', 'portable_guard.py'), run_name='__main__')\"", + "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 index 595b2db..c731712 100644 --- a/codex/hooks/README.md +++ b/codex/hooks/README.md @@ -3,9 +3,20 @@ 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 patches that add those characters. +- 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. 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 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..5e01e28 --- /dev/null +++ b/codex/hooks/portable_guard.ps1 @@ -0,0 +1,54 @@ +$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") + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", "utf-8", "Process") + try { + $inputText | & $exe @runnerArgs $guard + $guardExitCode = $LASTEXITCODE + } + finally { + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", $oldPythonIoEncoding, "Process") + } + exit $guardExitCode + } +} + +exit 0 diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index b5c8f3c..3076523 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -17,6 +17,12 @@ 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, ) + + +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 "{}") @@ -79,6 +85,9 @@ def added_patch_lines(command: str) -> list[tuple[str, str]]: 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) @@ -143,14 +152,15 @@ def candidate_repos(cwd: Path) -> list[Path]: if root is not None: repos.append(root) - try: - for child in cwd.iterdir(): - if not child.is_dir(): - continue - if (child / ".git").exists(): - repos.append(child) - except OSError: - pass + 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() @@ -194,7 +204,7 @@ def repo_status_summary(repo: Path) -> str | None: def dirty_worktree_closeout(data: dict) -> bool: - if data.get("stop_hook_active"): + 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())) 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/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 4615bba..b159979 100644 --- a/scripts/common.ps1 +++ b/scripts/common.ps1 @@ -16,10 +16,6 @@ function Get-CodexHome { return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:CODEX_HOME) } - if ($env:CODEX_HOME) { - return (Resolve-Path $env:CODEX_HOME).Path - } - $default = Join-Path $env:USERPROFILE ".codex" return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($default) } diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 index 96fd2bf..a384316 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,63 @@ 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") + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", "utf-8", "Process") + try { + $output = $InputText | & $exe @runnerArgs $ScriptPath 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = ($output -join "`n") + } + } + finally { + [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", $oldPythonIoEncoding, "Process") + } +} + $manifestPath = Join-Path $repoRoot "manifests\portable-files.toml" $manifestText = Get-Content -Raw -LiteralPath $manifestPath @@ -346,6 +408,266 @@ 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-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") + } + } + + $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 + } + } + } +} + +$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" From 868b75f18ad6a4de20bd802c1c2c802c871dbeb9 Mon Sep 17 00:00:00 2001 From: Ario Barin Ostovary Date: Tue, 30 Jun 2026 09:49:34 -0400 Subject: [PATCH 12/12] add understanding check hook --- codex/hooks.json | 13 ++++ codex/hooks/README.md | 5 ++ codex/hooks/portable_guard.ps1 | 7 ++ codex/hooks/portable_guard.py | 120 +++++++++++++++++++++++++++++++++ scripts/doctor.ps1 | 97 ++++++++++++++++++++++++++ 5 files changed, 242 insertions(+) diff --git a/codex/hooks.json b/codex/hooks.json index aa41d25..58c18cb 100644 --- a/codex/hooks.json +++ b/codex/hooks.json @@ -14,6 +14,19 @@ ] } ], + "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": [ diff --git a/codex/hooks/README.md b/codex/hooks/README.md index c731712..ac73377 100644 --- a/codex/hooks/README.md +++ b/codex/hooks/README.md @@ -4,6 +4,9 @@ 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`. @@ -18,5 +21,7 @@ Opt-outs: 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 index 5e01e28..2f2a429 100644 --- a/codex/hooks/portable_guard.ps1 +++ b/codex/hooks/portable_guard.ps1 @@ -39,13 +39,20 @@ foreach ($runner in $runners) { $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 } diff --git a/codex/hooks/portable_guard.py b/codex/hooks/portable_guard.py index 3076523..16f08f8 100644 --- a/codex/hooks/portable_guard.py +++ b/codex/hooks/portable_guard.py @@ -17,6 +17,51 @@ 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: @@ -46,6 +91,12 @@ def deny_pre_tool(reason: str) -> None: ) +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}) @@ -106,6 +157,71 @@ def dash_guard(data: dict) -> bool: 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], @@ -238,6 +354,10 @@ def main() -> int: dash_guard(data) return 0 + if event == "UserPromptSubmit": + understanding_check_context(data) + return 0 + if event == "Stop": dirty_worktree_closeout(data) return 0 diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 index a384316..9ca3212 100644 --- a/scripts/doctor.ps1 +++ b/scripts/doctor.ps1 @@ -167,7 +167,12 @@ function Invoke-DoctorPythonScript { } $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]@{ @@ -177,6 +182,8 @@ function Invoke-DoctorPythonScript { } finally { [Environment]::SetEnvironmentVariable("PYTHONIOENCODING", $oldPythonIoEncoding, "Process") + $global:OutputEncoding = $oldOutputEncoding + [Console]::OutputEncoding = $oldConsoleOutputEncoding } } @@ -545,6 +552,33 @@ elseif (Test-Path -LiteralPath $hookGuardPath) { } } + 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, @@ -557,6 +591,8 @@ elseif (Test-Path -LiteralPath $hookGuardPath) { } } + $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" @@ -640,6 +676,67 @@ elseif (Test-Path -LiteralPath $hookGuardPath) { } } } + + 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"