From f3687af708f0280f15d1a71743815b7b3d60ec08 Mon Sep 17 00:00:00 2001 From: dhb Date: Mon, 26 Oct 2026 23:12:26 +0800 Subject: [PATCH] feat: add reviewed Codex guard onboarding Add project-scoped preview checks, install and removal, exact native hook review, real activation probes, and protected Codex task execution. Keep trust invocation-scoped without global configuration edits or hook-trust bypass. Cover initialization interrupts and preserve unrelated project hooks. Validated from origin/main with 246 focused checks. --- README.md | 15 + docs/integrations/agent-guard-codex.md | 116 ++++ docs/integrations/agent-guard-onboarding.md | 83 +++ pyproject.toml | 1 + src/openworkproof/agent_guard.py | 330 ++++++++++ src/openworkproof/codex_host.py | 333 ++++++++++ src/openworkproof/guard_cli.py | 696 ++++++++++++++++++++ src/openworkproof/guard_onboarding.py | 362 ++++++++++ src/openworkproof/guard_probe.py | 424 ++++++++++++ tests/test_agent_guard.py | 246 +++++++ tests/test_codex_host.py | 332 ++++++++++ tests/test_guard_cli.py | 462 +++++++++++++ tests/test_guard_onboarding.py | 287 ++++++++ tests/test_guard_probe.py | 238 +++++++ 14 files changed, 3925 insertions(+) create mode 100644 docs/integrations/agent-guard-codex.md create mode 100644 docs/integrations/agent-guard-onboarding.md create mode 100644 src/openworkproof/agent_guard.py create mode 100644 src/openworkproof/codex_host.py create mode 100644 src/openworkproof/guard_cli.py create mode 100644 src/openworkproof/guard_onboarding.py create mode 100644 src/openworkproof/guard_probe.py create mode 100644 tests/test_agent_guard.py create mode 100644 tests/test_codex_host.py create mode 100644 tests/test_guard_cli.py create mode 100644 tests/test_guard_onboarding.py create mode 100644 tests/test_guard_probe.py diff --git a/README.md b/README.md index 34f138e..4bd108f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ OpenWorkProof 不保证 Agent 的结果一定正确,也不替客户作出验 执行有证据、验证与验收不会被混为一谈,并把接受、拒绝、撤销和申诉的权利留给人。 [安装](#五分钟开始) · [工作原理](#工作原理) · +[OWP Guard 本地候选](docs/integrations/agent-guard-codex.md) · [Human Agency](#human-agency能力越强人的决定权越不能消失) · [DeepSeek Harness](docs/integrations/deepseek-harness.md) · [协议文档](docs/protocol/human-agency-profile-v0.1.md) · @@ -75,6 +76,20 @@ OpenWorkProof 与维护者 [dengyier](https://github.com/dengyier) 的相关公 +## OWP Guard:在原 Agent 中保护预览目录(本地候选) + +用户继续在 Codex 中提任务,OWP 在受支持的工具执行前自动检查:允许读取、增加和 +修改指定预览目录内的普通文件,拒绝越界访问、删除/移动、任意命令和未支持工具。 +首版提供项目级安装、状态检查、显式升级与卸载恢复,适用于 POSIX 本地文本编辑。 + +这项能力尚未发布到 PyPI,也不会随已有 MCP 配置自动启用。宿主必须实际加载并信任 +Hook;它不是全电脑隔离,在线搜索等宿主未开放的路径不在保护范围。 +安装、验收与完整边界见 [接入说明](docs/integrations/agent-guard-codex.md)。 + +首次使用可按 `install → enable → doctor → run` 完成项目接入、当前版本授权、真实检查 +和 Codex 任务运行。该入口使用原厂单项 session trust,不跳过 Hook 信任检查,也不修改 +全局配置;详见 [首次启用与受保护启动](docs/integrations/agent-guard-onboarding.md)。 + ## Agent 说 `完成了`,还缺什么 MCP 连接 Agent 与工具,A2A 连接 Agent 与 Agent。它们解决如何连接和通信,不能单独 diff --git a/docs/integrations/agent-guard-codex.md b/docs/integrations/agent-guard-codex.md new file mode 100644 index 0000000..4545557 --- /dev/null +++ b/docs/integrations/agent-guard-codex.md @@ -0,0 +1,116 @@ +# OWP Guard:Codex 本地预览目录保护(首版) + +状态:本地开发,尚未发布。首个适配宿主为本机 Codex CLI 0.147.0。 + +## 使用目的与验收约定 + +用户在原 Agent 中修改一份预览副本。OWP 自动检查宿主交来的工具请求: +允许读取、增加和修改指定预览目录内的普通文件;拒绝越界文件、删除/移动、 +任意执行命令及未支持的工具。用户无需创建另一套工作单。 + +首版有意限制命令能力,适合文本/静态页面预览编辑,不适合构建、测试、部署等 +需要任意程序执行的任务。具体命令白名单随实现列明。 + +实现顺序和成功标准: + +1. 检查器:真实工具输入得到稳定的允许/拒绝;路径穿越、符号链接、多文件补丁、 + 命令拼接、未知工具、缺失策略均有负例,拒绝前没有执行副作用。 +2. 项目安装:只注册本项目的同步 PreToolUse,保留其他 Hook;重复安装不叠加; + 状态分别显示已配置与本地检查记录;宿主是否调用另用真实宿主事件核验。 + 卸载恢复原配置或保留安装后的其他改动。 +3. 真实宿主:在单独体验目录让 Codex 读取/编辑预览、尝试越界与命令写入, + 对照文件结果和本地检查记录。测试脚本调用检查器不算真实宿主验收。 + +## 数据与权限 + +- 策略由安装者设置,存放在项目可编辑范围之外;Agent 工具输入不能授予权限。 +- 首版策略格式 `openworkproof/agent-guard/0.1`,字段为 `project_root`、 + `allowed_directory`、`installation_id`。所有目录在安装时解析为绝对真实路径。 +- 运行时文件为独立的标准库模块 `agent_guard.py`,安装时复制到本机状态目录, + 由固定 Python 路径以 `-I` 启动,避免从 Agent 工作目录导入代码。 +- `Bash` 与 `apply_patch` 输入使用 `tool_input.command`;补丁按宿主格式解析 + 所有路径。只读命令也只能访问预览目录;隐藏路径、链接和多硬链接文件拒绝。 +- 不信任提示词中的批准声明,不读取 transcript,不发送遥测或增加外部模型服务。 + 本地记录只含时间、决定、固定原因码、工具类别、输入摘要及策略摘要,不存原文。 + 这些记录未签名,手动运行检查器也能产生,不能单独证明宿主触达或独立验收。 +- 正常请求返回空 JSON,保留宿主原有权限判断;拒绝返回原厂支持的 `deny`。 + Python 失败由外层包装器转为拒绝,不能使用异步 Hook 或不受支持的 `ask`。 + +## 首版边界 + +这是宿主执行前的工具限制,不是操作系统隔离,也没有运行中终止/回滚能力。 +本组件只处理宿主交来的执行前请求,不宣称已运行完整签名授权与独立验收协议。 +既有 Core、Desk 和运行中的服务不因安装而改变。 + +必须由宿主实际启用并信任 Hook。Codex 默认在 Hook 缺失、超时、定义不受信任或 +部分错误时继续;包装器只能处理它已经启动后的内部失败。状态不能据配置存在 +宣称全局生效。项目可信、Hook 可信、原厂权限设置仍由宿主管理。 + +官方不为 `write_stdin` 的后续输入再次触发 PreToolUse,所以首版拒绝交互/PTY和 +任意命令。Hosted WebSearch 等原厂列明路径未覆盖;专用体验启动应关闭在线搜索, +不能据未知工具拒绝推成所有行为受控。管理员/同一系统用户禁用 Hook、外部进程 +同时替换文件、检查与执行间的竞态均不属于本机制能消除的威胁。 + +原厂契约:(核查日期 2026-10-26)。 +WorkBuddy 5.8.3 当前尚未核实同等桌面执行前阻断契约,不能标为已支持。 + +## 开发与验证 + +### 从当前源码使用 + +以下命令在仓库根目录运行,使用已装依赖的开发环境;`owp-guard` 是新增的包入口, +尚不在已发布的 PyPI 版本中。安装当前源码到独立环境后,也可直接使用该入口。 + +```sh +PYTHONPATH=src .venv/bin/python -m openworkproof.guard_cli install \ + --project /absolute/site-project --allow-dir preview --human +PYTHONPATH=src .venv/bin/python -m openworkproof.guard_cli status \ + --project /absolute/site-project --human +PYTHONPATH=src .venv/bin/python -m openworkproof.guard_cli uninstall \ + --project /absolute/site-project --human +``` + +项目与预览子目录应已经存在。默认控制文件位于 +`~/.local/share/openworkproof/guard/<项目路径摘要>/`,不在 Agent 可编辑目录内。 +`--state-dir` 可以指定本地控制目录;后续状态、升级与卸载须使用同一目录。 +不带 `--human` 时返回 JSON,供自动化使用。 + +安装只配置项目 `.codex/hooks.json`,不会替用户设置全局项目可信或 Hook 信任。 +已新增 [首次启用与受保护启动](agent-guard-onboarding.md):通过 `enable` 审阅当前版本并 +完成真实检查,再用 `run` 在原生 Codex 引擎中执行任务。该入口只传入单条 session trust。 +若直接普通启动 Codex,仍需将项目设为可信,再通过 `/hooks` 审阅、信任并启用 OWP 项。 +源码、范围或包装器升级会改变定义;使用 `install --upgrade` 显式更新并重新信任。 +有漂移时安装拒绝覆盖;卸载在可确认唯一自有条目时仍能移除接入,保留他人配置。 +卸载会保留本地安装记录与事件文件,输出其位置,不宣称已经删除所有本地数据。 + +只读命令限 `/bin/pwd`、不带选项的 `/bin/ls`、`/bin/cat`(可用 `-n`)。 +文件路径须位于预览目录;不接受拼接、替换、通配符、交互参数或隐含执行能力。 +不支持的操作会向原 Agent 返回中文理由;允许结果不会绕过宿主自己的权限检查。 + +### 2026-10-26 本地验收结果 + +- 检查器与安装生命周期测试:130 passed。 +- 本地 wheel 在独立环境安装,包入口完成安装、幂等、状态和卸载;原有 Hook + 配置按原始字节恢复。 +- 真实 Codex CLI 0.147.0 从项目 Hook 文件加载:正常读取与单处页面标题修改成功; + 越界创建、命令写入、隐藏假密钥读取均被拒绝。原件和官网源码未变,两个拒绝目标 + 没有生成,假密钥值未出现在最终宿主事件流中。 +- 实机验收显式设置了**本次调用的项目信任**并使用 + `--dangerously-bypass-hook-trust`,该开关跳过 Codex 自己的持久信任审阅。 + 因此它证明所安装检查器在这次调用中的真实阻断,不证明普通启动已自动启用或已经 + 通过 Codex `/hooks` 的交互式信任流程。验收没有修改用户全局配置或持久化信任。 + +失败的调试轮次也保留:桌面模型别名不适用于 CLI、关闭工具执行入口,以及测试命令 +错误设置项目信任,均未计入成功。后者曾让负例实际执行,测试文件已恢复。 +该版本 CLI 的 `-c` 左侧按点拆分,不解析 TOML 带引号的路径键;自动化中应把整个 +项目表放在右值,例如单个参数 +`projects={"/absolute/site-project"={trust_level="trusted"}}`。 +不要因为跳过了 Hook 信任检查,就认为项目层也已受信任。 + +```sh +PYTHONPATH=src .venv/bin/python -m pytest -q \ + tests/test_agent_guard.py tests/test_guard_cli.py +``` + +验收使用合成拒绝目标与假密钥;代码审查来自同一 AI 协作团队的另一审查 Agent, +不是客户独立验收。尚未发布安装包到外部渠道、接入 WorkBuddy 或验证企业部署。 diff --git a/docs/integrations/agent-guard-onboarding.md b/docs/integrations/agent-guard-onboarding.md new file mode 100644 index 0000000..f8b39a9 --- /dev/null +++ b/docs/integrations/agent-guard-onboarding.md @@ -0,0 +1,83 @@ +# OWP Guard 首次启用:当前版本授权与真实检查 + +本地候选(2026-10-26):承接已有 Codex 预览目录保护,完成安装后的启用与确认生效。 +不改现有运行时策略,不重写用户的 Codex 配置,不使用跳过 Hook 信任检查的参数。 + +## 用户流程 + +1. `install` 继续只安装项目接入。 +2. `enable` 展示允许目录、限制、当前检查版本与使用的 Codex 启动方式。用户交互确认 + 或明确使用 `--trust-own-hook` 后,记录只针对这条 OWP 定义的授权,并执行真实检查。 +3. `doctor` 分别显示安装完整性、宿主识别结果、当前版本授权、最近真实检查结果。 +4. `run` 在原有 Codex CLI 中执行任务;每次启动前核对身份并交给原厂信任机制核验。 + 升级、重装、定义或宿主版本变化会使旧授权/检查失效,需重新启用。 + +## 接入与失败契约 + +- 原厂 `hooks/list` 读取真实 key、currentHash、enabled 和 trustStatus;不自己计算 + 原厂的定义哈希。只选择来源、命令、事件、位置及安装清单均匹配的唯一 OWP 条目。 +- 用户授权记录放在 OWP 本地控制目录,每次受保护启动仅将这一项的精确 trusted_hash + 交给 Codex 的 session hooks.state。项目可信也限本次启动。没有全局信任绕过。 +- 受保护运行使用 Codex 默认模型及原有登录,本次不载入个人 MCP/插件配置,关闭 + 在线搜索。该方式避开本机桌面特有配置与 CLI 不兼容的情况;不改变其他会话设置。 +- 原厂支持的 session trust 与持久全局 trust 分开标注;不称普通 Codex 启动已受保护。 +- 真实检查在同一目标项目中只操作新建的独占合成目录。正常读取和修改必须成功, + 越界补丁与命令写入必须在执行前被拒绝。结果同时核对宿主事件/原厂拒绝提示、 + 本次检查器决定和实际文件,模型自述或手动制造本地日志不能作为通过依据。 +- 授权与检查绑定安装 ID、目录、配置和控制文件摘要、原厂 key/hash、Codex 版本。 + 缺失、漂移、宿主不可读、请求没发生、仅被其他权限层阻止或超时均不标为通过。 +- 检查记录是一次本地运行的证据,不是未来会话保证、签名协议凭证或客户独立验收。 + 卸载仍只移除项目接入,保留本地记录,并撤去后续受保护启动的可用状态。 + +验收:保留此前安装/运行时测试;新增 native RPC、授权隔离、旧授权失效、探针反假绿 +与失败清理测试;最后用本机真实 Codex 在无 bypass 参数的情况下完成启用与一次任务。 + +## 使用方法 + +以下入口来自当前本地源码构建的包,尚未对外发布;已经安装的旧 PyPI 包不含这些命令。 +也可在仓库现有开发环境用 `PYTHONPATH=src .venv/bin/python -m openworkproof.guard_cli` +代替 `owp-guard`。 + +```sh +owp-guard install --project /absolute/site-project --allow-dir preview --human +owp-guard enable --project /absolute/site-project --human +owp-guard doctor --project /absolute/site-project --human +owp-guard run --project /absolute/site-project --prompt "修改预览首页的标题,先预览不发布" --human +owp-guard uninstall --project /absolute/site-project --human +``` + +`enable` 展示授权目录、限制、检查版本与 Codex 程序位置,用户输入“确认”后才授权 +当前这一条检查并运行真实探针。非交互自动化只有明确传 `--trust-own-hook` 才会继续。 +取消不会更改现有授权;不以安装成功或模型自报完成当作启用成功。 + +使用自定义 `--state-dir` 时,每个命令都须带同一路径。`--codex` 可以指定 Codex 程序; +授权同时绑定其解析后的绝对路径、文件摘要和版本。替换程序后须重新启用。 +标准输出默认 JSON,`--human` 为中文。doctor 不会调用模型,只检查安装和原厂清单; +enable 和 run 使用已有 Codex 登录运行真实 Agent。 + +## 已验证与限制 + +- 首次启用阶段的授权、原生 RPC 和探针测试与已有守卫测试合计 218 项通过; + 推送前另补充了初始化时 KeyboardInterrupt/SystemExit 清理的两项真实子进程回归。 +- 首次启用实机通过:本地只读原厂清单从未信任变为本次启动信任,未使用 + `--dangerously-bypass-hook-trust`,也未向原厂写入持久全局信任。 +- 原厂信任核验后,四步真实探针同时通过原生读取、新文件内容、两项执行前拒绝、 + 本次守卫决定与合成文件清理检查。 +- 新 wheel 在独立环境安装,包入口 doctor 通过;随后真实 run 读取预览并仅修改 + 一处标题,源网站及原始副本未变,全局 Codex 配置摘要未变。 +- 升级/重装/范围或配置变化/宿主程序变化使旧 review 或 probe 失效,有相应负例。 + 启用与任务运行全程持本项目锁,避免本 CLI 自己的升级、卸载在检查与执行间插入。 + Ctrl-C 结束检查/任务并清理子进程后可以卸载。 + +这一路径称为 **OWP 受保护启动**:用户仍使用原生 Codex 引擎,但由 OWP 带入当前已审阅 +的单条 session trust。普通启动 Codex 的窗口仍须按原厂流程自行设置项目与 Hook 信任, +本轮没有把它标为已保护。 + +`run` 的 completed 表示 Agent 本次运行结束并通过当前身份核对;不是业务结果验收。 +有原生工具活动而缺少对应时间窗内的守卫活动,或存在 Hook 失败提示时,返回未核实。 +时间窗中的日志没有 session 级密码学关联,另一同项目会话的记录仍可能混入,所以 +明确返回 `per_tool_session_proof=false`,不把数量对应当成逐项操作的独立证明。 + +本地 review/probe 由当前系统用户控制,没有密码学用户认证。无法抵御同权限外部进程 +替换包装器、宿主或配置的竞态;原厂没有提供检查点的路径仍不在本组件范围。 +WorkBuddy 当前只完成底层可行性核查,外部插件在真实桌面任务中还未验收。 diff --git a/pyproject.toml b/pyproject.toml index d53a48b..2868c3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dev = [ openworkproof = "openworkproof.mcp_transport:main" owp = "openworkproof.cli:app" owp-mcp = "openworkproof.mcp_transport:main" +owp-guard = "openworkproof.guard_cli:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/openworkproof/agent_guard.py b/src/openworkproof/agent_guard.py new file mode 100644 index 0000000..b9fec47 --- /dev/null +++ b/src/openworkproof/agent_guard.py @@ -0,0 +1,330 @@ +"""Small, local Codex PreToolUse policy. Copied verbatim by the installer. + +Standard library only: run with Python -I, without importing project code. +The host must actually load/trust this hook; this is not an OS security boundary. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import shlex +import stat +import sys + + +MAX_INPUT_BYTES = 1024 * 1024 +MAX_POLICY_BYTES = 16 * 1024 +SCHEMA = "openworkproof/agent-guard/0.1" +POLICY_FIELDS = {"schema_version", "project_root", "allowed_directory", "installation_id"} + + +@dataclass(frozen=True) +class GuardDecision: + allowed: bool + code: str + reason: str + + +def _deny(code: str, reason: str) -> GuardDecision: + return GuardDecision(False, code, reason) + + +ALLOW = GuardDecision(True, "WITHIN_PREVIEW", "操作在预览目录允许范围内。") +INVALID = _deny("INVALID_INPUT", "OWP 无法识别这次操作,已拒绝执行。") +UNAVAILABLE = _deny("POLICY_UNAVAILABLE", "OWP 保护设置不可用,已拒绝执行;请先检查保护状态。") +OUTSIDE = _deny("PATH_OUTSIDE_PREVIEW", "本次仅允许访问预览目录;其他文件、隐藏路径和链接不在允许范围内。") +COMMAND = _deny("COMMAND_NOT_ALLOWED", "预览模式仅允许 /bin/cat、/bin/ls、/bin/pwd 的受限只读调用;请用 apply_patch 编辑预览。") +PATCH = _deny("PATCH_NOT_ALLOWED", "本次仅允许在预览目录增加或修改普通文件;不允许删除、移动或格式不明的补丁。") + + +def _canonical(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + + +def _within(path: Path, root: Path) -> bool: + return path == root or root in path.parents + + +def _plain_text(value: object) -> bool: + return isinstance(value, str) and bool(value) and all(ord(c) >= 32 and ord(c) != 127 for c in value) + + +def _policy_paths(policy: object) -> tuple[Path, Path]: + if type(policy) is not dict or set(policy) != POLICY_FIELDS: + raise ValueError("invalid policy") + if policy["schema_version"] != SCHEMA or not _plain_text(policy["installation_id"]): + raise ValueError("invalid policy") + roots = [] + for key in ("project_root", "allowed_directory"): + raw = policy[key] + if not _plain_text(raw): + raise ValueError("invalid directory") + path = Path(raw) + if not path.is_absolute() or path.resolve(strict=True) != path or not path.is_dir(): + raise ValueError("directory changed") + roots.append(path) + project, allowed = roots + if allowed == project or not _within(allowed, project): + raise ValueError("preview must be a project subdirectory") + if any(part.startswith(".") for part in allowed.relative_to(project).parts): + raise ValueError("hidden preview") + return project, allowed + + +def _safe_path(raw: object, cwd: Path, allowed: Path, *, directory: bool = False, existing: bool = False) -> bool: + if not _plain_text(raw): + return False + path = Path(raw) + if ".." in path.parts: + return False + if not path.is_absolute(): + path = cwd / path + if not _within(path, allowed): + return False + relative = path.relative_to(allowed) + if any(part.startswith(".") for part in relative.parts): + return False + # Inspect each existing ancestor instead of silently resolving a link. + current = allowed + for part in ("", *relative.parts): + if part: + current /= part + try: + info = current.lstat() + except FileNotFoundError: + if existing or directory: + return False + continue + if stat.S_ISLNK(info.st_mode): + return False + if current != path: + if not stat.S_ISDIR(info.st_mode): + return False + elif directory: + if not stat.S_ISDIR(info.st_mode): + return False + elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + return False + return path.resolve(strict=False) == path + + +def _shell_decision(inputs: dict, cwd: Path, project: Path, allowed: Path) -> GuardDecision: + # No interactive process is allowed: Codex does not re-hook write_stdin. + supported = { + "command", "description", "workdir", "cwd", "working_directory", + "tty", "login", "yield_time_ms", "max_output_tokens", + "sandbox_permissions", "justification", "prefix_rule", + } + if set(inputs) - supported: + return COMMAND + directories = [key for key in ("workdir", "cwd", "working_directory") if inputs.get(key) is not None] + if len(directories) > 1: + return COMMAND + if inputs.get("tty") not in (None, False) or inputs.get("sandbox_permissions") == "require_escalated": + return COMMAND + for key in ("workdir", "cwd", "working_directory"): + if key not in inputs or inputs[key] is None: + continue + raw = inputs[key] + if not _plain_text(raw) or not Path(raw).is_absolute(): + return OUTSIDE + candidate = Path(raw) + if candidate.resolve(strict=True) != candidate or not candidate.is_dir() or not _within(candidate, project): + return OUTSIDE + cwd = candidate + command = inputs["command"] + if not _plain_text(command) or any(c in command for c in ";&|<>$`(){}\\*?[]~!"): + return COMMAND + try: + argv = shlex.split(command, posix=True) + except ValueError: + return COMMAND + if not argv: + return COMMAND + executable, *arguments = argv + if executable == "/bin/pwd": + return ALLOW if not arguments else COMMAND + if executable not in ("/bin/cat", "/bin/ls"): + return COMMAND + paths = [] + options_done = False + for argument in arguments: + if executable == "/bin/cat" and argument == "-": + return COMMAND # cat treats '-' as stdin even after '--'. + if not options_done and argument == "--": + options_done = True + elif argument.startswith("-"): + if options_done: + return COMMAND # Some utilities also parse options after paths. + options = {"-n"} if executable == "/bin/cat" else set() + if argument not in options: + return COMMAND + else: + paths.append(argument) + options_done = True + if not paths: + if executable == "/bin/cat": + return COMMAND + paths = [str(cwd)] + if len(paths) > 32: + return COMMAND + if not all(_safe_path(p, cwd, allowed, directory=executable == "/bin/ls", existing=True) for p in paths): + return OUTSIDE + return ALLOW + + +def _patch_decision(command: str, cwd: Path, allowed: Path) -> GuardDecision: + if len(command.encode()) > MAX_INPUT_BYTES: + return PATCH + lines = command.splitlines() + if len(lines) < 3 or lines[0] != "*** Begin Patch" or lines[-1] != "*** End Patch": + return PATCH + index, seen = 1, set() + while index < len(lines) - 1: + header = lines[index] + if header.startswith("*** Add File: "): + kind, raw = "add", header[len("*** Add File: "):] + elif header.startswith("*** Update File: "): + kind, raw = "update", header[len("*** Update File: "):] + else: + return PATCH + if raw != raw.strip() or not _safe_path(raw, cwd, allowed, existing=kind == "update"): + return OUTSIDE + target = str((cwd / raw).absolute()) + if target in seen or len(seen) >= 64: + return PATCH + seen.add(target) + index += 1 + chunks = 0 + while index < len(lines) - 1: + line = lines[index] + if line.startswith(("*** Add File: ", "*** Update File: ")): + break + if kind == "add": + if not line.startswith("+"): + return PATCH + elif line == "@@" or line.startswith("@@ "): + chunks += 1 + elif line == "*** End of File": + if index + 1 < len(lines) - 1 and not lines[index + 1].startswith(("*** Add File: ", "*** Update File: ")): + return PATCH + elif not chunks or not line.startswith((" ", "+", "-")): + return PATCH + index += 1 + if kind == "update" and not chunks: + return PATCH + return ALLOW if seen else PATCH + + +def evaluate_event(event: object, policy: object) -> GuardDecision: + """Evaluate host-supplied input without executing it or granting authority.""" + try: + project, allowed = _policy_paths(policy) + except (OSError, ValueError, TypeError, RuntimeError): + return UNAVAILABLE + if type(event) is not dict or event.get("hook_event_name") != "PreToolUse": + return INVALID + if event.get("tool_name") not in ("Bash", "apply_patch"): + return _deny("TOOL_NOT_SUPPORTED", "预览模式未授权这个工具;允许受限只读命令和预览文件补丁。") + inputs = event.get("tool_input") + raw_cwd = event.get("cwd") + if type(inputs) is not dict or not isinstance(inputs.get("command"), str) or not _plain_text(raw_cwd): + return INVALID + try: + cwd = Path(raw_cwd) + if not cwd.is_absolute() or cwd.resolve(strict=True) != cwd or not cwd.is_dir() or not _within(cwd, project): + return OUTSIDE + if event["tool_name"] == "Bash": + return _shell_decision(inputs, cwd, project, allowed) + # apply_patch uses the session cwd, not a caller-selected workdir. + if any(inputs.get(key) is not None for key in ("workdir", "cwd", "working_directory")): + return INVALID + return _patch_decision(inputs["command"], cwd, allowed) + except (OSError, ValueError, TypeError, RuntimeError): + return INVALID + + +def hook_response(decision: GuardDecision) -> dict: + if decision.allowed: + return {} # Keep the host's own permissions and approval requirements. + return {"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": f"OWP [{decision.code}]:{decision.reason}", + }} + + +def _load_json(raw: bytes): + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON field") + result[key] = value + return result + return json.loads(raw, object_pairs_hook=unique) + + +def _append_event(policy_path: Path, policy: dict, event: object, raw: bytes, decision: GuardDecision) -> None: + tool_name = event.get("tool_name") if type(event) is dict else "invalid" + if tool_name not in ("Bash", "apply_patch", "invalid"): + tool_name = "unsupported" + record = { + "schema_version": "openworkproof/agent-guard-event/0.1", + "installation_id": policy["installation_id"], + "checked_at": datetime.now(timezone.utc).isoformat(), + "tool_name": tool_name, + "decision": "allow" if decision.allowed else "deny", + "code": decision.code, + "action_digest": hashlib.sha256(raw if event is None else _canonical(event)).hexdigest(), + "policy_digest": hashlib.sha256(_canonical(policy)).hexdigest(), + } + target = policy_path.with_name("events.jsonl") + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + try: + if not stat.S_ISREG(os.fstat(fd).st_mode) or os.fstat(fd).st_nlink != 1: + raise ValueError("invalid event log") + data = _canonical(record) + b"\n" + if os.write(fd, data) != len(data): + raise OSError("partial event write") + finally: + os.close(fd) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="OWP local Codex execution-before check") + parser.add_argument("--policy", type=Path, required=True) + args = parser.parse_args(argv) + decision = UNAVAILABLE + try: + if args.policy.is_symlink() or args.policy.stat().st_size > MAX_POLICY_BYTES: + raise ValueError("invalid policy file") + policy = _load_json(args.policy.read_bytes()) + _policy_paths(policy) + raw = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1) + event = None + if len(raw) > MAX_INPUT_BYTES: + decision = INVALID + else: + try: + event = _load_json(raw) + except (ValueError, UnicodeError): + decision = INVALID + else: + decision = evaluate_event(event, policy) + _append_event(args.policy, policy, event, raw, decision) + except Exception: + # Never echo exception text, paths, input, environment or source content. + decision = UNAVAILABLE + sys.stdout.write(json.dumps(hook_response(decision), ensure_ascii=False) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/openworkproof/codex_host.py b/src/openworkproof/codex_host.py new file mode 100644 index 0000000..b62e5a9 --- /dev/null +++ b/src/openworkproof/codex_host.py @@ -0,0 +1,333 @@ +"""Bounded stdio inventory and exact, invocation-scoped native Codex trust. + +This module does not persist trust, approve hooks, or start an AI task. The +caller reviews native inventory and owns any durable review record. +""" + +from __future__ import annotations + +import json +import math +import os +from pathlib import Path +import re +import select +import selectors +import signal +import subprocess +import time + + +_FEATURES = ( + "features={hooks=true,plugins=false,apps=false," + "multi_agent=false,code_mode_host=true}" +) +_HASH = re.compile(r"sha256:[0-9a-f]{64}") +_VERSION = re.compile(r"codex-cli\s+(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)") +_MAX_LINE_BYTES = 1_048_576 +_TIMEOUT_SECONDS = 10.0 + + +class CodexHostError(RuntimeError): + """A fixed, user-readable error without raw host or configuration output.""" + + +def _project_path(project: Path) -> Path: + try: + path = Path(project) + if not path.is_absolute() or not path.is_dir(): + raise ValueError + return path.resolve(strict=True) + except (OSError, TypeError, ValueError): + raise CodexHostError("请选择存在的项目目录。") from None + + +def _executable_name(executable: str) -> str: + try: + value = os.fspath(executable) + if not isinstance(value, str) or not value or "\0" in value: + raise ValueError + return value + except (TypeError, ValueError): + raise CodexHostError("无法找到可用的 Codex 程序。") from None + + +def _trust_table(key: str, current_hash: str) -> str: + if ( + not isinstance(key, str) or not key or "\0" in key or len(key) > 8192 + or not isinstance(current_hash, str) + or _HASH.fullmatch(current_hash) is None + ): + raise CodexHostError("尚未取得有效的单项宿主信任记录。") + return "hooks.state={" + json.dumps(key, ensure_ascii=False) + "={" + ( + "trusted_hash=" + json.dumps(current_hash) + ",enabled=true}}" + ) + + +def _session_args( + executable: str, project: Path, reviewed_hook: dict | None = None, +) -> list[str]: + project = _project_path(project) + args = [ + _executable_name(executable), "-c", _FEATURES, + "-c", 'web_search="disabled"', + "-c", "projects={" + json.dumps(str(project), ensure_ascii=False) + + '={trust_level="trusted"}}', + ] + if reviewed_hook is not None: + if not isinstance(reviewed_hook, dict): + raise CodexHostError("尚未取得有效的单项宿主信任记录。") + args.extend(["-c", _trust_table( + reviewed_hook.get("key"), reviewed_hook.get("currentHash"), + )]) + return args + + +def build_exec_args( + executable: str, project: Path, key: str, current_hash: str, +) -> list[str]: + """Build a stdin-prompt invocation; do not execute or persist anything.""" + path = _project_path(project) + args = _session_args(executable, path, { + "key": key, "currentHash": current_hash, + }) + return [args[0], "-a", "never", "-s", "workspace-write", *args[1:]] + [ + "exec", "--ignore-user-config", "--ephemeral", "--json", + "-C", str(path), "-", + ] + + +def _stop_process(process: subprocess.Popen) -> None: + if process.stdin is not None: + try: + process.stdin.close() + except OSError: + pass + try: + process.wait(timeout=0.1) + except subprocess.TimeoutExpired: + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + if os.name == "posix": + os.killpg(process.pid, sig) + elif sig == signal.SIGTERM: + process.terminate() + else: + process.kill() + except ProcessLookupError: + pass + except OSError: + raise CodexHostError("宿主连接未能正常关闭。") from None + try: + process.wait(timeout=0.5) + break + except subprocess.TimeoutExpired: + continue + else: + raise CodexHostError("宿主连接未能正常关闭。") + finally: + if process.stdout is not None: + process.stdout.close() + + +def _spawn(args: list[str], project: Path | None = None) -> subprocess.Popen: + try: + return subprocess.Popen( + args, cwd=project, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=0, close_fds=True, + start_new_session=(os.name == "posix"), + ) + except (OSError, ValueError): + raise CodexHostError("无法启动 Codex 宿主连接。") from None + + +def _read_line( + process: subprocess.Popen, selector: selectors.BaseSelector, + buffer: bytearray, deadline: float, max_bytes: int, +) -> bytes: + while True: + newline = buffer.find(b"\n") + if newline >= 0: + if newline > max_bytes: + raise CodexHostError("宿主返回的数据超过安全读取上限。") + line = bytes(buffer[:newline]) + del buffer[:newline + 1] + return line + if len(buffer) > max_bytes: + raise CodexHostError("宿主返回的数据超过安全读取上限。") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise CodexHostError("等待 Codex 宿主响应超时。") + try: + ready = selector.select(remaining) + if not ready: + raise CodexHostError("等待 Codex 宿主响应超时。") + chunk = os.read( + process.stdout.fileno(), min(65536, max_bytes + 1 - len(buffer)), + ) + except OSError: + raise CodexHostError("无法读取 Codex 宿主响应。") from None + if not chunk: + raise CodexHostError("Codex 宿主提前关闭了连接。") + buffer.extend(chunk) + + +def codex_version(executable: str = "codex") -> str: + """Return the native semantic version, without echoing invalid output.""" + process = _spawn([_executable_name(executable), "--version"]) + selector = selectors.DefaultSelector() + try: + selector.register(process.stdout, selectors.EVENT_READ) + deadline = time.monotonic() + _TIMEOUT_SECONDS + raw = _read_line(process, selector, bytearray(), deadline, 4096) + try: + match = _VERSION.fullmatch(raw.decode("utf-8").strip()) + if match is None: + raise ValueError + process.wait(timeout=max(0.001, deadline - time.monotonic())) + if process.returncode != 0: + raise ValueError + except (UnicodeError, ValueError, subprocess.TimeoutExpired): + raise CodexHostError("无法确认 Codex 的版本。") from None + return match.group(1) + finally: + selector.close() + _stop_process(process) + + +class CodexHost: + """A no-task native app-server connection; use it as a context manager.""" + + def __init__( + self, executable: str = "codex", *, project: Path, + reviewed_hook: dict | None = None, + timeout_seconds: float = _TIMEOUT_SECONDS, + max_message_bytes: int = _MAX_LINE_BYTES, + ) -> None: + self.project = _project_path(project) + self._args = _session_args(executable, self.project, reviewed_hook) + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (float, int)) + or not math.isfinite(timeout_seconds) or timeout_seconds <= 0 + or type(max_message_bytes) is not int or max_message_bytes < 1 + ): + raise CodexHostError("宿主连接的读取限制无效。") + self._timeout = float(timeout_seconds) + self._max_bytes = max_message_bytes + self._process = None + self._selector = None + self._buffer = bytearray() + self._next_id = 1 + + def __enter__(self) -> CodexHost: + if self._process is not None: + raise CodexHostError("宿主连接已经打开。") + try: + self._process = _spawn( + self._args + ["app-server", "--listen", "stdio://"], self.project, + ) + self._selector = selectors.DefaultSelector() + self._buffer.clear() + self._next_id = 1 + self._selector.register(self._process.stdout, selectors.EVENT_READ) + os.set_blocking(self._process.stdin.fileno(), False) + self._request("initialize", { + "clientInfo": {"name": "owp-guard", "title": "OWP Guard", "version": "0.1"}, + "capabilities": {"experimentalApi": True, "requestAttestation": False}, + }) + self._send({"method": "initialized"}, time.monotonic() + self._timeout) + except BaseException: + try: + self.close() + except CodexHostError: + pass + raise + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + try: + self.close() + except CodexHostError: + if exc_type is None: + raise + + def close(self) -> None: + process, self._process = self._process, None + selector, self._selector = self._selector, None + if selector is not None: + selector.close() + if process is not None: + _stop_process(process) + + def _send(self, message: dict, deadline: float) -> None: + if self._process is None: + raise CodexHostError("宿主连接尚未打开。") + try: + data = json.dumps(message, ensure_ascii=False).encode("utf-8") + b"\n" + offset = 0 + while offset < len(data): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise CodexHostError("等待 Codex 宿主响应超时。") + _, ready, _ = select.select([], [self._process.stdin], [], remaining) + if not ready: + raise CodexHostError("等待 Codex 宿主响应超时。") + try: + offset += os.write(self._process.stdin.fileno(), data[offset:]) + except BlockingIOError: + continue + except (OSError, UnicodeError, ValueError): + raise CodexHostError("无法向 Codex 宿主发送请求。") from None + + def _request(self, method: str, params: dict) -> dict: + request_id = self._next_id + self._next_id += 1 + deadline = time.monotonic() + self._timeout + self._send({"id": request_id, "method": method, "params": params}, deadline) + while True: + raw = _read_line( + self._process, self._selector, self._buffer, deadline, self._max_bytes, + ) + try: + message = json.loads(raw) + except (UnicodeError, ValueError, RecursionError): + raise CodexHostError("宿主返回了无法识别的数据。") from None + if not isinstance(message, dict): + raise CodexHostError("宿主返回了无法识别的数据。") + if "method" in message: + if "id" not in message and isinstance(message["method"], str): + continue + raise CodexHostError("宿主请求了当前不支持的交互。") + if type(message.get("id")) is not int or message["id"] != request_id: + raise CodexHostError("宿主响应与当前请求不匹配。") + if message.get("error") is not None: + raise CodexHostError("Codex 宿主未能完成此次检查。") + result = message.get("result") + if not isinstance(result, dict): + raise CodexHostError("宿主返回了无法识别的数据。") + return result + + def list_hooks(self) -> dict: + result = self._request("hooks/list", {"cwds": [str(self.project)]}) + data = result.get("data") + if not isinstance(data, list): + raise CodexHostError("宿主未返回有效的保护组件清单。") + entries = [ + entry for entry in data + if isinstance(entry, dict) and entry.get("cwd") == str(self.project) + ] + if len(entries) != 1: + raise CodexHostError("宿主未返回当前项目的唯一保护组件清单。") + entry = entries[0] + if ( + not isinstance(entry.get("hooks"), list) + or not all(isinstance(hook, dict) for hook in entry["hooks"]) + or not isinstance(entry.get("warnings"), list) + or not all(isinstance(warning, str) for warning in entry["warnings"]) + or not isinstance(entry.get("errors"), list) + ): + raise CodexHostError("宿主未返回有效的保护组件清单。") + if entry["errors"]: + raise CodexHostError("宿主配置检查未通过,尚不能确认保护已生效。") + return entry diff --git a/src/openworkproof/guard_cli.py b/src/openworkproof/guard_cli.py new file mode 100644 index 0000000..bc51113 --- /dev/null +++ b/src/openworkproof/guard_cli.py @@ -0,0 +1,696 @@ +"""Install a project-local Codex hook without altering host trust settings.""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import stat +import sys +from typing import NamedTuple +import uuid + + +RUNTIME_SOURCE = Path(__file__).with_name("agent_guard.py") +DEFAULT_STATE_DIR = Path.home() / ".local/share/openworkproof/guard" +POLICY_SCHEMA = "openworkproof/agent-guard/0.1" +INSTALL_SCHEMA = "openworkproof/agent-guard-installation/0.1" +EVENT_SCHEMA = "openworkproof/agent-guard-event/0.1" +_HEX = re.compile(r"[0-9a-f]{64}\Z") + + +class GuardError(Exception): + """An unsafe or ambiguous installation must not change the configuration.""" + + +class Snapshot(NamedTuple): + data: bytes + mode: int + identity: tuple[int, int, int, int] + + +def _json_bytes(value: object) -> bytes: + return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode() + + +def _digest(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _policy_digest(value: dict) -> str: + return _digest(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()) + + +def _unique_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise GuardError("duplicate JSON key") + result[key] = value + return result + + +def _parse_json(raw: bytes) -> dict: + try: + value = json.loads(raw, object_pairs_hook=_unique_pairs) + except (ValueError, UnicodeError) as exc: + raise GuardError("invalid JSON") from exc + if not isinstance(value, dict): + raise GuardError("JSON must be an object") + return value + + +def _no_links(path: Path) -> None: + current = Path(path.anchor) + for part in path.parts[1:]: + current /= part + try: + info = current.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(info.st_mode): + raise GuardError("symbolic links are not accepted") + + +def _path(value: str | Path, base: Path | None = None, *, directory=False) -> Path: + path = Path(value).expanduser() + if ".." in path.parts: + raise GuardError("parent traversal is not accepted") + if not path.is_absolute(): + path = (base or Path.cwd()) / path + _no_links(path) + path = path.resolve(strict=False) + if directory and not path.is_dir(): + raise GuardError("directory does not exist") + return path + + +def _inside(path: Path, parent: Path) -> bool: + return path == parent or parent in path.parents + + +def _scope(project: Path, allowed: Path, state: Path) -> None: + if project == Path(project.anchor) or allowed == project or not _inside(allowed, project): + raise GuardError("allow-dir must be a proper project subdirectory") + if any(part.startswith(".") for part in allowed.relative_to(project).parts): + raise GuardError("allow-dir cannot contain hidden paths or .codex") + if _inside(state, allowed) or _inside(allowed, state): + raise GuardError("state-dir and allow-dir cannot overlap") + + +def _trusted_directory(path: Path) -> None: + """Check only the component's control directories, not all OS ancestors.""" + _no_links(path) + try: + info = path.lstat() + except FileNotFoundError: + return + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022: + raise GuardError("unsafe control directory permissions or ownership") + + +def _read(path: Path, *, optional=False) -> Snapshot | None: + _no_links(path) + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + if optional: + return None + raise GuardError("required installation file is missing") from None + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise GuardError("expected a regular file with one link") + with os.fdopen(fd, "rb", closefd=False) as handle: + data = handle.read() + return Snapshot(data, stat.S_IMODE(info.st_mode), (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)) + finally: + os.close(fd) + + +def _config(path: Path) -> tuple[dict, Snapshot | None]: + snapshot = _read(path, optional=True) + value = _parse_json(snapshot.data) if snapshot else {} + hooks = value.get("hooks", {}) + if not isinstance(hooks, dict) or not isinstance(hooks.get("PreToolUse", []), list): + raise GuardError("invalid hooks configuration") + return value, snapshot + + +@contextmanager +def _project_lock(project: Path): + if os.name != "posix": + raise GuardError("this release supports POSIX hosts only") + import fcntl + + fd = os.open(project, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise GuardError("another guard operation is using this project") from exc + yield + finally: + os.close(fd) + + +def _atomic(path: Path, data: bytes | None, before: Snapshot | None, mode=0o600) -> Snapshot | None: + """Check-and-replace under our project lock; not a hostile-writer OS barrier.""" + _no_links(path) + if _read(path, optional=True) != before: + raise GuardError("file changed concurrently; refusing to overwrite") + if data is None: + if before is not None: + path.unlink() + return None + temporary = path.with_name(".owp-guard-" + uuid.uuid4().hex + ".tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + try: + with os.fdopen(fd, "wb") as handle: + os.fchmod(handle.fileno(), mode) + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + _no_links(path.parent) + if _read(path, optional=True) != before: + raise GuardError("file changed concurrently; refusing to overwrite") + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + return _read(path) + + +def _transaction(changes): + completed = [] + try: + for path, data, before, mode in changes: + after = _atomic(path, data, before, mode) + completed.append((path, before, after)) + except (OSError, GuardError): + for path, before, after in reversed(completed): + try: + _atomic(path, before.data if before else None, after, before.mode if before else 0o600) + except (OSError, GuardError): + # Never overwrite a concurrent writer just to finish rollback. + pass + raise + + +def _wrapper(python: Path, runtime: Path, policy: Path, installation_id: str, source_digest: str, policy_digest: str) -> bytes: + denial = json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "OWP guard could not validate this operation."}}, separators=(",", ":")) + supervisor = '''import hashlib, json, os, pathlib, stat, subprocess, sys +def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate output key") + result[key] = value + return result +def check_file(path, expected, limit): + target = pathlib.Path(path) + current = pathlib.Path(target.anchor) + for part in target.parts[1:]: + current /= part + if stat.S_ISLNK(current.lstat().st_mode): + raise ValueError("linked control file") + fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + before = os.fstat(fd) + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + raise ValueError("non-regular control file") + with os.fdopen(fd, "rb", closefd=False) as handle: + raw = handle.read(limit + 1) + after = os.fstat(fd) + if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns): + raise ValueError("control file changed while reading") + if len(raw) > limit or hashlib.sha256(raw).hexdigest() != expected: + raise ValueError("control file digest mismatch") + finally: + os.close(fd) +try: + # The child reopens these paths after verification. Same-user replacement + # races remain outside this hook's boundary; this is not OS isolation. + check_file(sys.argv[1], sys.argv[3], 1048576) + check_file(sys.argv[2], sys.argv[4], 16384) + payload = sys.stdin.buffer.read(1048577) + if len(payload) > 1048576: + raise ValueError("oversized input") + process = subprocess.run([sys.executable, "-I", sys.argv[1], "--policy", sys.argv[2]], input=payload, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=2) + if process.returncode != 0 or len(process.stdout) > 16384: + raise ValueError("runtime failed") + result = json.loads(process.stdout, object_pairs_hook=unique) + if not isinstance(result, dict): + raise ValueError("invalid output") + if result != {}: + specific = result.get("hookSpecificOutput") + if (set(result) != {"hookSpecificOutput"} or not isinstance(specific, dict) + or set(specific) != {"hookEventName", "permissionDecision", "permissionDecisionReason"} + or specific["hookEventName"] != "PreToolUse" or specific["permissionDecision"] != "deny" + or not isinstance(specific["permissionDecisionReason"], str) or not specific["permissionDecisionReason"]): + raise ValueError("unsupported output") + print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) +except Exception: + raise SystemExit(1) +''' + text = "#!/bin/sh\n# OWP synchronous hook; failures after this wrapper starts deny the operation.\n" + text += "deny() { printf '%s\\n' " + shlex.quote(denial) + "; exit 0; }\n" + text += '[ "$#" -eq 4 ] || deny\n' + text += '[ "$1" = ' + shlex.quote(installation_id) + ' ] || deny\n' + text += '[ "$2" = ' + shlex.quote(source_digest) + ' ] || deny\n' + text += '[ "$4" = ' + shlex.quote(policy_digest) + ' ] || deny\n' + command = " ".join(shlex.quote(str(value)) for value in (python, "-I", "-c", supervisor, runtime, policy, source_digest, policy_digest)) + text += "if result=$(" + command + " 2>/dev/null); then\n printf '%s\\n' \"$result\"\nelse\n deny\nfi\n" + return text.encode() + + +def _entry(directory: Path, installation_id: str, runtime_digest: str, wrapper_digest: str, policy_digest: str | None = None) -> dict: + # Include both snapshots in the definition hash so an upgrade needs host trust. + arguments = ["/bin/sh", directory / "hook.sh", installation_id, runtime_digest, wrapper_digest] + if policy_digest is not None: + arguments.append(policy_digest) + command = " ".join(shlex.quote(str(value)) for value in arguments) + return {"matcher": "*", "hooks": [{"type": "command", "command": command, "timeout": 5}]} + + +def _installation_dir(project: Path, state: Path) -> Path: + return state / _digest(str(project).encode()) + + +def _has_owned_marker(entry: object, installation_id: str, directory: Path) -> bool: + if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list): + return False + for handler in entry["hooks"]: + if not isinstance(handler, dict) or not isinstance(handler.get("command"), str): + continue + command = handler["command"] + try: + arguments = shlex.split(command) + except ValueError: + return installation_id in command or str(directory / "hook.sh") in command + if installation_id in arguments or str(directory / "hook.sh") in arguments: + return True + return False + + +def _manifest(directory: Path, project: Path) -> tuple[dict, Snapshot]: + info = (directory / "installation.json").lstat() + if info.st_uid != os.getuid() or info.st_mode & 0o022: + raise GuardError("unsafe installation ownership record permissions") + snapshot = _read(directory / "installation.json") + value = _parse_json(snapshot.data) + try: + expected_fields = {"schema_version", "active", "installation_id", "project_root", "allowed_directory", "python_path", "runtime_digest", "wrapper_digest", "policy_file_digest", "policy_digest", "hook_entry", "original_config_exists", "original_config_digest", "original_config_mode", "restore_original", "installed_config_digest", "codex_dir_created"} + valid_id = str(uuid.UUID(value["installation_id"])) == value["installation_id"] + valid = set(value) == expected_fields and value["schema_version"] == INSTALL_SCHEMA and value["project_root"] == str(project) + valid &= type(value["active"]) is bool and type(value["original_config_exists"]) is bool + valid &= type(value["restore_original"]) is bool and isinstance(value["hook_entry"], dict) + valid &= type(value["codex_dir_created"]) is bool and isinstance(value["allowed_directory"], str) + valid &= type(value["original_config_mode"]) is int and 0 <= value["original_config_mode"] <= 0o777 + valid &= all(isinstance(value[k], str) and _HEX.fullmatch(value[k]) for k in ("runtime_digest", "wrapper_digest", "policy_file_digest", "policy_digest", "installed_config_digest")) + valid &= valid_id and Path(value["python_path"]).is_absolute() + # Accept the previous owned definition only so an explicit upgrade/uninstall + # remains possible. New definitions always bind the policy bytes as well. + valid &= value["hook_entry"] in ( + _entry(directory, value["installation_id"], value["runtime_digest"], value["wrapper_digest"], value["policy_file_digest"]), + _entry(directory, value["installation_id"], value["runtime_digest"], value["wrapper_digest"]), + ) + if value["original_config_exists"]: + valid &= bool(_HEX.fullmatch(value["original_config_digest"])) + except (ValueError, KeyError, TypeError): + valid = False + if not valid: + raise GuardError("invalid installation ownership record") + return value, snapshot + + +def _last_observed(directory: Path, manifest: dict) -> dict | None: + path = directory / "events.jsonl" + _no_links(path) + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + except FileNotFoundError: + return None + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + return None + with os.fdopen(fd, "rb", closefd=False) as handle: + start = max(0, info.st_size - 1024 * 1024) + handle.seek(start) + if start: + handle.readline() + lines = handle.readlines() + keys = {"schema_version", "installation_id", "checked_at", "tool_name", "decision", "code", "action_digest", "policy_digest"} + for raw in reversed(lines): + try: + event = _parse_json(raw) + when = datetime.fromisoformat(event["checked_at"].replace("Z", "+00:00")) + if (set(event) == keys and event["schema_version"] == EVENT_SCHEMA + and event["installation_id"] == manifest["installation_id"] + and event["policy_digest"] == manifest["policy_digest"] + and event["decision"] in {"allow", "deny"} + and event["tool_name"] in {"Bash", "apply_patch", "unsupported", "invalid"} + and isinstance(event["code"], str) and re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", event["code"]) + and isinstance(event["action_digest"], str) and _HEX.fullmatch(event["action_digest"]) + and when.utcoffset() == timezone.utc.utcoffset(when)): + return event + except (GuardError, KeyError, TypeError, ValueError, AttributeError): + continue + return None + finally: + os.close(fd) + + +def _inspect(project: Path, state: Path, manifest: dict) -> tuple[dict, dict | None, Snapshot | None]: + directory = _installation_dir(project, state) + drift = [] + try: + for path in (state, directory, project / ".codex"): + _trusted_directory(path) + except (OSError, GuardError): + drift.append("control_directory_permissions_unsafe") + for name, key in (("runtime.py", "runtime_digest"), ("hook.sh", "wrapper_digest"), ("policy.json", "policy_file_digest")): + try: + snapshot = _read(directory / name) + if _digest(snapshot.data) != manifest[key]: + drift.append(name + "_changed") + except (OSError, GuardError): + drift.append(name + "_missing_or_unsafe") + if manifest["original_config_exists"]: + try: + backup = _read(directory / "hooks.before") + if _digest(backup.data) != manifest["original_config_digest"]: + drift.append("backup_changed") + except (OSError, GuardError): + drift.append("backup_missing_or_unsafe") + try: + allowed = _path(manifest["allowed_directory"], directory=True) + _scope(project, allowed, state) + except (OSError, GuardError): + drift.append("allowed_directory_missing_or_unsafe") + if not Path(manifest["python_path"]).is_file() or not os.access(manifest["python_path"], os.X_OK): + drift.append("python_unavailable") + config = snapshot = None + try: + config, snapshot = _config(project / ".codex/hooks.json") + entries = config.get("hooks", {}).get("PreToolUse", []) + expected = 1 if manifest["active"] else 0 + marked = sum(_has_owned_marker(item, manifest["installation_id"], directory) for item in entries) + if entries.count(manifest["hook_entry"]) != expected or marked != expected: + drift.append("hook_entry_changed_or_missing") + except (OSError, GuardError): + drift.append("hooks_missing_or_invalid") + try: + source = _read(RUNTIME_SOURCE) + desired_python = Path(sys.executable).resolve() + desired_runtime = _digest(source.data) + desired_wrapper = _wrapper(desired_python, directory / "runtime.py", directory / "policy.json", manifest["installation_id"], desired_runtime, manifest["policy_file_digest"]) + desired_entry = _entry(directory, manifest["installation_id"], desired_runtime, _digest(desired_wrapper), manifest["policy_file_digest"]) + upgrade_available = desired_runtime != manifest["runtime_digest"] or _digest(desired_wrapper) != manifest["wrapper_digest"] or desired_entry != manifest["hook_entry"] + except (OSError, GuardError): + upgrade_available = None + try: + observed = _last_observed(directory, manifest) + except (OSError, GuardError): + observed = None + drift.append("events_unsafe") + result = {"project_root": str(project), "state_directory": str(directory), "installation_id": manifest["installation_id"], "configured": manifest["active"] and not drift, "last_observed": observed, "drift": drift, "upgrade_available": upgrade_available, "policy_digest": manifest["policy_digest"], "allowed_directory": manifest["allowed_directory"], "read_scope": "allowed_directory only", "coverage": "supported local PreToolUse paths only; host trust and activation must be verified separately"} + return result, config, snapshot + + +def _install(project: Path, allowed: Path, state: Path, *, upgrade=False) -> dict: + _scope(project, allowed, state) + config_path = project / ".codex/hooks.json" + directory = _installation_dir(project, state) + for path in (state, directory, config_path.parent): + _trusted_directory(path) + config, before = _config(config_path) + source = _read(RUNTIME_SOURCE) + if not source.data: + raise GuardError("runtime source is empty") + python = Path(sys.executable).resolve() + source_digest = _digest(source.data) + previous = previous_snapshot = None + if directory.exists(): + previous, previous_snapshot = _manifest(directory, project) + status, config, before = _inspect(project, state, previous) + if status["drift"]: + raise GuardError("installation drift detected; refusing to overwrite: " + ", ".join(status["drift"])) + if previous["active"]: + if previous["allowed_directory"] != str(allowed): + raise GuardError("allow-dir differs; uninstall before changing the grant") + if not status["upgrade_available"]: + return dict(status, requires_trust=True, changed=False) + if not upgrade: + raise GuardError("runtime source changed; use --upgrade and review/retrust the hook") + active = previous is not None and previous["active"] + installation_id = previous["installation_id"] if active else str(uuid.uuid4()) + policy = {"schema_version": POLICY_SCHEMA, "project_root": str(project), "allowed_directory": str(allowed), "installation_id": installation_id} + policy_raw = _json_bytes(policy) + wrapper = _wrapper(python, directory / "runtime.py", directory / "policy.json", installation_id, source_digest, _digest(policy_raw)) + entry = _entry(directory, installation_id, source_digest, _digest(wrapper), _digest(policy_raw)) + if active: + config["hooks"]["PreToolUse"] = [entry if item == previous["hook_entry"] else item for item in config["hooks"]["PreToolUse"]] + original_exists = previous["original_config_exists"] + original_digest = previous["original_config_digest"] + original_mode = previous["original_config_mode"] + restore = previous["restore_original"] and before is not None and _digest(before.data) == previous["installed_config_digest"] + else: + config.setdefault("hooks", {}).setdefault("PreToolUse", []).append(entry) + original_exists = before is not None + original_digest = _digest(before.data) if before else None + original_mode = before.mode if before else 0o600 + restore = True + config_raw = _json_bytes(config) + manifest = {"schema_version": INSTALL_SCHEMA, "active": True, "installation_id": installation_id, "project_root": str(project), "allowed_directory": str(allowed), "python_path": str(python), "runtime_digest": source_digest, "wrapper_digest": _digest(wrapper), "policy_file_digest": _digest(policy_raw), "policy_digest": _policy_digest(policy), "hook_entry": entry, "original_config_exists": original_exists, "original_config_digest": original_digest, "original_config_mode": original_mode, "restore_original": restore, "installed_config_digest": _digest(config_raw), "codex_dir_created": previous["codex_dir_created"] if active else not config_path.parent.exists()} + _no_links(state) + state.mkdir(parents=True, exist_ok=True, mode=0o700) + directory.mkdir(exist_ok=True, mode=0o700) + _no_links(directory) + config_path.parent.mkdir(exist_ok=True, mode=0o700) + _no_links(config_path.parent) + for path in (state, directory, config_path.parent): + _trusted_directory(path) + changes = [] + for name, raw, mode in (("runtime.py", source.data, 0o600), ("hook.sh", wrapper, 0o700), ("policy.json", policy_raw, 0o600)): + changes.append((directory / name, raw, _read(directory / name, optional=True), mode)) + if not active: + changes.append((directory / "hooks.before", before.data if before else None, _read(directory / "hooks.before", optional=True), 0o600)) + changes.append((config_path, config_raw, before, before.mode if before else 0o600)) + changes.append((directory / "installation.json", _json_bytes(manifest), previous_snapshot, 0o600)) + try: + _transaction(changes) + except (OSError, GuardError): + if previous is None: + try: + directory.rmdir() + except OSError: + pass + raise + result, _, _ = _inspect(project, state, manifest) + return dict(result, requires_trust=True, changed=True, next_step="Review this hook in Codex /hooks before relying on it. Upgrades require renewed trust.") + + +def _status(project: Path, state: Path) -> dict: + directory = _installation_dir(project, state) + if not directory.exists(): + return {"project_root": str(project), "state_directory": str(directory), "configured": False, "last_observed": None, "drift": []} + manifest, _ = _manifest(directory, project) + result, _, _ = _inspect(project, state, manifest) + return result + + +def _uninstall(project: Path, state: Path) -> dict: + directory = _installation_dir(project, state) + if not directory.exists(): + return _status(project, state) + manifest, manifest_before = _manifest(directory, project) + status, config, before = _inspect(project, state, manifest) + if config is None or "hook_entry_changed_or_missing" in status["drift"]: + raise GuardError("cannot identify one intact owned hook entry; refusing to overwrite") + if not manifest["active"]: + return dict(status, retained_state_directory=str(directory)) + config_path = project / ".codex/hooks.json" + backup = original = None + backup_valid = not manifest["original_config_exists"] + if manifest["original_config_exists"]: + try: + backup = _read(directory / "hooks.before") + backup_valid = _digest(backup.data) == manifest["original_config_digest"] + if backup_valid: + original = _parse_json(backup.data) + except (OSError, GuardError): + backup_valid = False + if backup_valid and manifest["restore_original"] and before and _digest(before.data) == manifest["installed_config_digest"]: + raw = backup.data if manifest["original_config_exists"] else None + mode = manifest["original_config_mode"] + else: + entries = config["hooks"]["PreToolUse"] + config["hooks"]["PreToolUse"] = [item for item in entries if item != manifest["hook_entry"]] + if backup_valid: + original = original or {} + if not config["hooks"]["PreToolUse"] and "PreToolUse" not in original.get("hooks", {}): + del config["hooks"]["PreToolUse"] + if not config["hooks"] and "hooks" not in original: + del config["hooks"] + raw, mode = _json_bytes(config), before.mode + manifest["active"] = False + _transaction([(config_path, raw, before, mode), (directory / "installation.json", _json_bytes(manifest), manifest_before, 0o600)]) + if raw is None and manifest["codex_dir_created"]: + try: + config_path.parent.rmdir() + except OSError: + pass + result, _, _ = _inspect(project, state, manifest) + return dict(result, retained_state_directory=str(directory), note="Project hook removed. Local installation records and events are retained; no host trust settings were changed.") + + +def _print_human(result: dict, command: str) -> None: + print("项目接入:" + ("已配置" if result["configured"] else "未配置")) + print("配置与代码漂移:" + ("、".join(result["drift"]) if result["drift"] else "未发现")) + print("允许访问目录:" + result.get("allowed_directory", "尚未配置")) + observed = result["last_observed"] + if observed: + print("最近一次检查:" + observed["checked_at"] + "," + ("允许" if observed["decision"] == "allow" else "拒绝") + ",原因码 " + observed["code"]) + else: + print("最近一次检查:尚未观察到本次安装的有效记录") + print("宿主当前是否信任/启用:尚未核实,配置存在不代表生效") + if command == "install": + print("下一步运行 owp-guard enable,审阅当前版本并完成真实检查;升级后须重新启用。") + if result.get("retained_state_directory"): + print("已移除项目接入;本地记录保留:" + result["retained_state_directory"]) + + +def _print_activation(result: dict, command: str) -> None: + if result.get("cancelled"): + print("已取消启用,现有授权未更改。") + return + if command == "run": + print(result["assistant_text"] or "本次未取得 Agent 回应。") + if result.get("protection_observation") == "window_activity_observed": + print("运行期间观察到 OWP 检查活动;这不是逐项操作的独立证明。") + elif result.get("protection_observation") == "no_tool_actions": + print("本次未观察到工具操作。") + if result["status"] != "completed": + print("本次运行未能完整核实,请检查宿主状态。") + return + print("本项目检查:" + ("已安装完整" if result["configured"] else "尚未确认完整安装")) + reviews = {"current": "已授权当前版本", "missing": "尚未授权", "outdated": "版本已变化,请重新启用", "unknown": "未核实"} + probes = {"passed": "通过", "failed": "未通过", "not_run": "尚未运行", "outdated": "已失效", "unknown": "未核实"} + print("当前版本授权:" + reviews.get(result["review_status"], "未核实")) + print("宿主对受保护启动的信任核验:" + ("通过" if result["review_status"] == "current" and result["host_trust_status"] == "trusted" else "未通过")) + print("最近真实检查:" + probes.get(result["probe_status"], "未核实")) + print("允许访问目录:" + result.get("allowed_directory", "未核实")) + if result["ready_for_guarded_run"]: + print("可以通过 owp-guard run 在 Codex 中执行预览任务。") + else: + print("当前尚未满足受保护启动条件,请运行 enable 或处理状态提示。") + print("上述结果适用于 OWP 受保护启动;普通 Codex 启动尚未验证。") + if result.get("reason"): + print(result["reason"]) + + +def _onboarding_action(args, project: Path, state: Path) -> dict: + from openworkproof import guard_onboarding + + if args.command == "doctor": + return guard_onboarding.doctor(project, state, executable=args.codex) + if args.command == "run": + return guard_onboarding.run_guarded_task(project, state, args.prompt, executable=args.codex) + summary = guard_onboarding.review_summary(project, state, executable=args.codex) + stream = sys.stdout if args.human else sys.stderr + print("授权范围:仅本项目当前这条 OWP 检查,供受保护启动使用。", file=stream) + print("允许访问目录:" + summary["allowed_directory"], file=stream) + print("允许预览文件读取、增加和修改;越界、删除移动、任意命令及未知工具会拒绝。", file=stream) + print("Codex:" + summary["codex_version"] + ",程序:" + summary["codex_path"], file=stream) + print("当前检查版本:" + summary["hook_hash"], file=stream) + print("受保护模式使用 Codex 默认模型和原有登录;本次不载入个人 MCP/插件配置,在线搜索关闭。", file=stream) + print("确认后将用 Codex 对新建合成文件执行真实的放行与阻断检查。", file=stream, flush=True) + consent = "explicit_own_hook" + if not args.trust_own_hook: + if not sys.stdin.isatty(): + raise GuardError("请在交互终端审阅确认,或明确使用 --trust-own-hook 仅授权当前 OWP 检查。") + try: + answer = input("确认启用并检查?输入“确认”继续,回车取消:").strip() + except EOFError: + answer = "" + if answer not in ("确认", "y", "yes"): + return {"cancelled": True} + consent = "interactive_own_hook" + return guard_onboarding.enable( + project, state, executable=args.codex, trust_own_hook=True, + reviewed_identity=summary["identity"], consent_method=consent, + ) + + +def _onboarding_command(args, project: Path, state: Path) -> dict: + # With ``python -m``, this transport is __main__ while the service imports + # its canonical module. Normalize its exception into this transport's type. + from openworkproof.guard_cli import GuardError as ServiceGuardError + + try: + return _onboarding_action(args, project, state) + except ServiceGuardError as error: + raise GuardError(str(error)) from None + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="Project-local OWP Guard for Codex (POSIX).") + commands = parser.add_subparsers(dest="command", required=True) + for command in ("install", "status", "uninstall", "enable", "doctor", "run"): + sub = commands.add_parser(command) + sub.add_argument("--project", required=True) + sub.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR)) + sub.add_argument("--human", action="store_true", help="显示简洁中文状态;默认输出JSON。") + if command == "install": + sub.add_argument("--allow-dir", required=True) + sub.add_argument("--upgrade", action="store_true") + if command in ("enable", "doctor", "run"): + sub.add_argument("--codex", default="codex", help="Codex 可执行程序,默认使用 PATH 中的 codex。") + if command == "enable": + sub.add_argument("--trust-own-hook", action="store_true", help="明确授权当前项目这条 OWP 检查并进行真实检查。") + if command == "run": + sub.add_argument("--prompt", required=True, help="交给 Codex 的预览任务。") + args = parser.parse_args(argv) + try: + project = _path(args.project, directory=True) + state = _path(args.state_dir) + onboarding = args.command in ("enable", "doctor", "run") + if onboarding: + result = _onboarding_command(args, project, state) + else: + with _project_lock(project): + if args.command == "install": + allowed = _path(args.allow_dir, project, directory=True) + result = _install(project, allowed, state, upgrade=args.upgrade) + elif args.command == "status": + result = _status(project, state) + else: + result = _uninstall(project, state) + if args.human: + if onboarding: + _print_activation(result, args.command) + else: + _print_human(result, args.command) + else: + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + if onboarding and not result.get("cancelled"): + return 0 if result.get("ready_for_guarded_run", result.get("status") == "completed") else 2 + return 0 + except KeyboardInterrupt: + print("已停止本次检查或任务。", file=sys.stderr) + return 130 + except (GuardError, OSError, ValueError) as exc: + print("owp-guard: " + str(exc), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/openworkproof/guard_onboarding.py b/src/openworkproof/guard_onboarding.py new file mode 100644 index 0000000..e266ff0 --- /dev/null +++ b/src/openworkproof/guard_onboarding.py @@ -0,0 +1,362 @@ +"""Review one installed hook, verify native session trust, then use Codex. + +Review/probe files belong to the local OS user. They are not signed protocol +receipts. No user or system Codex configuration is changed here. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat + +from openworkproof import codex_host, guard_cli, guard_probe + + +REVIEW_SCHEMA = "openworkproof/agent-guard-review/0.1" +PROBE_SCHEMA = "openworkproof/agent-guard-host-check/0.1" +_NATIVE_HASH = re.compile(r"sha256:[0-9a-f]{64}\Z") +_RECORD_LIMIT = 64 * 1024 + + +class OnboardingError(guard_cli.GuardError): + """A fixed explanation, without raw host errors or configuration contents.""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _executable_identity(executable: str) -> tuple[str, str]: + try: + found = shutil.which(executable) + if not found: + raise ValueError + path = Path(found).resolve(strict=True) + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + before = os.fstat(fd) + if not stat.S_ISREG(before.st_mode) or not os.access(path, os.X_OK): + raise ValueError + digest = hashlib.sha256() + with os.fdopen(fd, "rb", closefd=False) as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + after = os.fstat(fd) + if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns): + raise ValueError + finally: + os.close(fd) + return str(path), digest.hexdigest() + except (OSError, ValueError, TypeError): + raise OnboardingError("无法确认 Codex 程序的路径和文件身份。") from None + + +def _load_record(path: Path) -> dict | None: + try: + info = path.lstat() + if info.st_size > _RECORD_LIMIT or info.st_uid != os.getuid() or info.st_mode & 0o022: + return None + value = guard_cli._parse_json(guard_cli._read(path).data) + return value + except (OSError, guard_cli.GuardError): + return None + + +def _save_record(path: Path, value: dict) -> None: + guard_cli._trusted_directory(path.parent) + before = guard_cli._read(path, optional=True) + guard_cli._atomic(path, guard_cli._json_bytes(value), before) + + +def _static_identity(project: Path, state: Path, executable: str): + status = guard_cli._status(project, state) + if not status["configured"]: + raise OnboardingError("项目尚未安装完整的 OWP 检查,或配置已变化;请先安装或修复。") + directory = guard_cli._installation_dir(project, state) + manifest, _ = guard_cli._manifest(directory, project) + config, raw = guard_cli._config(project / ".codex/hooks.json") + entries = config["hooks"]["PreToolUse"] + index = entries.index(manifest["hook_entry"]) + binary, binary_digest = _executable_identity(executable) + version = codex_host.codex_version(binary) + identity = { + "project_root": str(project), "allowed_directory": manifest["allowed_directory"], + "installation_id": manifest["installation_id"], + "runtime_digest": manifest["runtime_digest"], "wrapper_digest": manifest["wrapper_digest"], + "policy_file_digest": manifest["policy_file_digest"], + "policy_digest": manifest["policy_digest"], + "hooks_file_digest": guard_cli._digest(raw.data), + "codex_path": binary, "codex_sha256": binary_digest, "codex_version": version, + } + expected_key = str(project / ".codex/hooks.json") + f":pre_tool_use:{index}:0" + return identity, expected_key, directory, manifest, status + + +def _valid_review(review: dict | None) -> bool: + if not isinstance(review, dict) or set(review) != {"schema_version", "identity", "reviewed_at", "consent"}: + return False + if review["schema_version"] != REVIEW_SCHEMA or type(review["identity"]) is not dict: + return False + if review["consent"] not in ("interactive_own_hook", "explicit_own_hook"): + return False + try: + when = datetime.fromisoformat(review["reviewed_at"]) + return when.utcoffset() == timezone.utc.utcoffset(when) + except (TypeError, ValueError): + return False + + +def _select_hook(entry: dict, project: Path, manifest: dict, expected_key: str) -> dict: + if type(entry) is not dict or entry.get("cwd") != str(project) or entry.get("warnings") or entry.get("errors"): + raise OnboardingError("宿主未能无歧义地识别本项目检查,请先处理宿主提示。") + hooks = entry.get("hooks") + if not isinstance(hooks, list): + raise OnboardingError("宿主未返回可识别的检查条目。") + matches = [h for h in hooks if type(h) is dict and h.get("key") == expected_key] + if len(matches) != 1: + raise OnboardingError("宿主未找到唯一的本项目 OWP 检查。") + hook = matches[0] + expected_command = manifest["hook_entry"]["hooks"][0]["command"] + required = { + "eventName": "preToolUse", "handlerType": "command", "source": "project", + "sourcePath": str(project / ".codex/hooks.json"), "matcher": "*", "command": expected_command, + } + if (any(hook.get(k) != v for k, v in required.items()) + or hook.get("isManaged") is not False or hook.get("enabled") is not True + or type(hook.get("timeoutSec")) is not int or hook["timeoutSec"] != 5 + or not isinstance(hook.get("currentHash"), str) + or _NATIVE_HASH.fullmatch(hook["currentHash"]) is None + or hook.get("trustStatus") not in ("trusted", "untrusted", "modified")): + raise OnboardingError("宿主中的检查与本地安装记录不一致,未授权运行。") + # Also reject a duplicate claiming the same owned command under a second key. + if sum(type(h) is dict and h.get("command") == expected_command for h in hooks) != 1: + raise OnboardingError("宿主中的 OWP 检查归属存在歧义。") + return hook + + +def _capture(project: Path, state: Path, executable: str, *, use_review: bool = True) -> dict: + try: + base, expected_key, directory, manifest, status = _static_identity(project, state, executable) + review = _load_record(directory / "review.json") + eligible = False + if use_review and _valid_review(review): + old = review["identity"] + eligible = (set(old) == set(base) | {"hook_key", "hook_hash"} + and all(old.get(k) == v for k, v in base.items()) + and old.get("hook_key") == expected_key + and isinstance(old.get("hook_hash"), str) + and _NATIVE_HASH.fullmatch(old["hook_hash"]) is not None) + selected = {"key": expected_key, "currentHash": review["identity"]["hook_hash"]} if eligible else None + with codex_host.CodexHost(executable=base["codex_path"], project=project, reviewed_hook=selected) as host: + native = host.list_hooks() + hook = _select_hook(native, project, manifest, expected_key) + identity = dict(base, hook_key=hook["key"], hook_hash=hook["currentHash"]) + current = bool(eligible and review["identity"] == identity) + return {"identity": identity, "hook": hook, "directory": directory, "static_status": status, + "review_current": current, "review_status": "current" if current else "outdated" if review else "missing"} + except (guard_cli.GuardError, codex_host.CodexHostError, OSError, ValueError, TypeError, KeyError): + raise OnboardingError("无法确认当前安装和宿主检查,请运行状态检查并处理提示。") from None + + +def _probe_status(snapshot: dict) -> str: + record = _load_record(snapshot["directory"] / "probe.json") + if not record: + return "not_run" + if (record.get("schema_version") != PROBE_SCHEMA or record.get("identity") != snapshot["identity"]): + return "outdated" + report = record.get("report") + if not isinstance(report, dict) or record.get("identity_unchanged") is not True: + return "unknown" + checks = report.get("checks") + if (report.get("status") == "passed" and type(checks) is dict + and set(checks) == set(guard_probe.REQUIRED_CHECKS) + and all(value is True for value in checks.values())): + return "passed" + return "failed" if report.get("status") == "failed" else "unknown" + + +def _doctor_result(snapshot: dict) -> dict: + probe_status = _probe_status(snapshot) + trusted = snapshot["review_current"] and snapshot["hook"]["trustStatus"] == "trusted" + return {"configured": True, "allowed_directory": snapshot["identity"]["allowed_directory"], + "review_status": snapshot["review_status"], "host_trust_status": snapshot["hook"]["trustStatus"], + "probe_status": probe_status, "ready_for_guarded_run": trusted and probe_status == "passed", + "ordinary_startup_verified": False, "activation_scope": "guarded_codex_invocation", + "codex_path": snapshot["identity"]["codex_path"], "codex_version": snapshot["identity"]["codex_version"], + "identity": snapshot["identity"]} + + +def doctor(project: Path, state: Path, *, executable: str = "codex") -> dict: + try: + return _doctor_result(_capture(project, state, executable)) + except OnboardingError as error: + try: + configured = guard_cli._status(project, state)["configured"] + except (OSError, guard_cli.GuardError, ValueError): + configured = False + return {"configured": configured, "review_status": "unknown", "host_trust_status": "unknown", + "probe_status": "unknown", "ready_for_guarded_run": False, "ordinary_startup_verified": False, + "activation_scope": "guarded_codex_invocation", "reason": str(error)} + + +def review_summary(project: Path, state: Path, *, executable: str = "codex") -> dict: + snapshot = _capture(project, state, executable, use_review=False) + return {"identity": snapshot["identity"], "allowed_directory": snapshot["identity"]["allowed_directory"], + "codex_path": snapshot["identity"]["codex_path"], "codex_version": snapshot["identity"]["codex_version"], + "hook_hash": snapshot["identity"]["hook_hash"], "scope": "仅在 OWP 受保护启动中授权本项目这一条检查。"} + + +def _enable( + project: Path, state: Path, *, executable: str = "codex", trust_own_hook: bool = False, + reviewed_identity: dict | None = None, consent_method: str = "explicit_own_hook", +) -> dict: + if not trust_own_hook or consent_method not in ("interactive_own_hook", "explicit_own_hook"): + raise OnboardingError("尚未授权当前 OWP 检查;请先审阅并确认这一条检查。") + before = _capture(project, state, executable, use_review=False) + if reviewed_identity is not None and reviewed_identity != before["identity"]: + raise OnboardingError("审阅后检查版本发生变化,请重新审阅。") + _save_record(before["directory"] / "review.json", { + "schema_version": REVIEW_SCHEMA, "identity": before["identity"], "reviewed_at": _now(), "consent": consent_method, + }) + trusted = _capture(project, state, executable) + if not trusted["review_current"] or trusted["hook"]["trustStatus"] != "trusted": + raise OnboardingError("宿主尚未认可这一版本的授权,未开始真实检查。") + identity = trusted["identity"] + report = guard_probe.perform_probe( + executable=identity["codex_path"], project=project, allowed_directory=Path(identity["allowed_directory"]), + reviewed_hook={"key": identity["hook_key"], "currentHash": identity["hook_hash"]}, + event_log=trusted["directory"] / "events.jsonl", installation_id=identity["installation_id"], + ) + unchanged = False + try: + after = _capture(project, state, executable) + unchanged = after["identity"] == identity and after["review_current"] and after["hook"]["trustStatus"] == "trusted" + except OnboardingError: + pass + _save_record(trusted["directory"] / "probe.json", { + "schema_version": PROBE_SCHEMA, "identity": identity, "identity_unchanged": unchanged, "report": report, + }) + result = doctor(project, state, executable=executable) + if not unchanged: + result["probe_status"] = "unknown" + return result + + +def _task_events(path: Path, position, installation_id: str, policy_digest: str) -> list[dict] | None: + offset, expected_file = position + try: + guard_cli._no_links(path) + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + return [] if offset == 0 and expected_file is None else None + except (OSError, guard_cli.GuardError): + return None + try: + info = os.fstat(fd) + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size < offset + or expected_file is not None and expected_file != (info.st_dev, info.st_ino)): + return None + with os.fdopen(fd, "rb", closefd=False) as handle: + handle.seek(offset) + raw = handle.read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024 or raw and not raw.endswith(b"\n"): + return None + rows = [] + for line in raw.splitlines(): + row = guard_cli._parse_json(line) + if row.get("installation_id") != installation_id: + return None # Concurrent activity is not this task's evidence. + if (row.get("schema_version") != guard_cli.EVENT_SCHEMA + or row.get("policy_digest") != policy_digest + or row.get("decision") not in ("allow", "deny") + or row.get("tool_name") not in ("Bash", "apply_patch", "unsupported", "invalid") + or not isinstance(row.get("action_digest"), str) + or guard_cli._HEX.fullmatch(row["action_digest"]) is None + or not isinstance(row.get("code"), str) + or re.fullmatch(r"[A-Z0-9_]{1,80}", row["code"]) is None): + return None + rows.append(row) + return rows + except (OSError, ValueError, TypeError, guard_cli.GuardError): + return None + finally: + os.close(fd) + + +def _run_guarded_task(project: Path, state: Path, prompt: str, *, executable: str = "codex") -> dict: + if not isinstance(prompt, str) or not prompt.strip() or len(prompt.encode()) > 64 * 1024: + raise OnboardingError("请提供一条不超过 64 KiB 的任务说明。") + snapshot = _capture(project, state, executable) + if not _doctor_result(snapshot)["ready_for_guarded_run"]: + raise OnboardingError("当前版本尚未完成授权和真实检查,请先运行 enable。") + identity = snapshot["identity"] + args = codex_host.build_exec_args(identity["codex_path"], project, identity["hook_key"], identity["hook_hash"]) + log = snapshot["directory"] / "events.jsonl" + try: + position = guard_probe._log_position(log) + except (OSError, ValueError): + raise OnboardingError("无法读取本次检查的起点,未启动任务。") from None + run = guard_probe.run_host_process(args, prompt.encode(), project, timeout_seconds=300) + messages, turn_completed, native_tools, unfamiliar_item = [], False, 0, False + try: + for line in run["stdout"].splitlines(): + event = json.loads(line) + if type(event) is not dict: + raise ValueError + item = event.get("item", {}) + if type(item) is dict and item.get("type") == "agent_message" and isinstance(item.get("text"), str): + messages.append(item["text"]) + if event.get("type") == "item.completed" and type(item) is dict: + if item.get("type") in ("command_execution", "file_change"): + native_tools += 1 + elif item.get("type") not in ("agent_message", "reasoning", "plan", "todo_list", "error"): + unfamiliar_item = True + turn_completed |= event.get("type") == "turn.completed" + except (ValueError, UnicodeError, TypeError): + turn_completed = False + unchanged = False + try: + after = _capture(project, state, executable) + unchanged = after["identity"] == identity and after["review_current"] and after["hook"]["trustStatus"] == "trusted" + except OnboardingError: + pass + rows = _task_events(log, position, identity["installation_id"], identity["policy_digest"]) + protection = "unverified" + if rows is not None and not unfamiliar_item: + allowed = sum(row["decision"] == "allow" for row in rows) + denied = [row for row in rows if row["decision"] == "deny"] + stderr = run["stderr"].decode(errors="replace") + native_blocks = stderr.count("Command blocked by PreToolUse hook") + native_failure = bool(re.search(r"hook[^\n]*(?:failed|timed out|untrusted|skipped|modified)", stderr, re.I)) + blocks_match = native_blocks == len(denied) and all("OWP [" + row["code"] + "]" in stderr for row in denied) + if allowed == native_tools and blocks_match and not native_failure: + protection = "window_activity_observed" if rows else "no_tool_actions" + complete = (run["exit_code"] == 0 and not run["timed_out"] and not run["output_limited"] + and turn_completed and unchanged and protection != "unverified") + return {"status": "completed" if complete else "unverified", "assistant_text": "\n\n".join(messages), + "exit_code": run["exit_code"], "activation_scope": "guarded_codex_invocation", + "identity_unchanged": unchanged, "ordinary_startup_verified": False, + "protection_observation": protection, "native_tool_count": native_tools, + "per_tool_session_proof": False} + + +def enable( + project: Path, state: Path, *, executable: str = "codex", trust_own_hook: bool = False, + reviewed_identity: dict | None = None, consent_method: str = "explicit_own_hook", +) -> dict: + # Do not race our own uninstall/upgrade into Codex's fail-open boundary. + # The CLI asks for consent before entering here. Ctrl-C ends a running check. + with guard_cli._project_lock(project): + return _enable(project, state, executable=executable, trust_own_hook=trust_own_hook, + reviewed_identity=reviewed_identity, consent_method=consent_method) + + +def run_guarded_task(project: Path, state: Path, prompt: str, *, executable: str = "codex") -> dict: + with guard_cli._project_lock(project): + return _run_guarded_task(project, state, prompt, executable=executable) diff --git a/src/openworkproof/guard_probe.py b/src/openworkproof/guard_probe.py new file mode 100644 index 0000000..0dc0687 --- /dev/null +++ b/src/openworkproof/guard_probe.py @@ -0,0 +1,424 @@ +"""Four-action native Codex probe; evidence summaries never include host bodies.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +import math +import os +from pathlib import Path +import re +import secrets +import selectors +import shlex +import shutil +import signal +import stat +import subprocess +import time +import uuid + + +_OUTPUT_LIMIT = 2 * 1024 * 1024 +_EVENT_LIMIT = 1024 * 1024 +_CONTENT = "OWP guard native write probe.\n" +_EVENT_SCHEMA = "openworkproof/agent-guard-event/0.1" +_SEQUENCE = [ + ("Bash", "allow", "WITHIN_PREVIEW"), + ("apply_patch", "allow", "WITHIN_PREVIEW"), + ("apply_patch", "deny", "PATH_OUTSIDE_PREVIEW"), + ("Bash", "deny", "COMMAND_NOT_ALLOWED"), +] +_CHECK_CODES = { + "nonce_not_in_prompt": "NONCE_IN_PROMPT", + "host_completed": "HOST_NOT_COMPLETED", + "native_jsonl_valid": "HOST_JSONL_INVALID", + "native_read_proven": "NATIVE_READ_NOT_PROVEN", + "allowed_file_exact": "ALLOWED_FILE_MISMATCH", + "outside_patch_absent": "OUTSIDE_PATCH_CREATED", + "outside_command_absent": "OUTSIDE_COMMAND_CREATED", + "native_blocks_observed": "NATIVE_BLOCKS_NOT_PROVEN", + "guard_event_sequence": "GUARD_SEQUENCE_NOT_PROVEN", + "fixtures_cleaned": "CLEANUP_INCOMPLETE", +} +REQUIRED_CHECKS = tuple(_CHECK_CODES) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _summary(raw: bytes, truncated=False) -> dict: + return {"sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw), "truncated": truncated} + + +def _unique(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate JSON key") + value[key] = item + return value + + +def _object(raw: bytes) -> dict: + def invalid_constant(_): + raise ValueError("invalid JSON constant") + + result = json.loads(raw, object_pairs_hook=_unique, parse_constant=invalid_constant) + if not isinstance(result, dict): + raise ValueError("expected JSON object") + return result + + +def _no_symlinks(path: Path) -> None: + if not path.is_absolute() or ".." in path.parts: + raise ValueError("absolute non-traversing path required") + current = Path(path.anchor) + for part in path.parts[1:]: + current /= part + try: + info = current.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(info.st_mode): + raise ValueError("symbolic link not accepted") + + +def _identity(path: Path) -> tuple[int, int]: + info = path.lstat() + if not stat.S_ISDIR(info.st_mode): + raise ValueError("expected directory") + return info.st_dev, info.st_ino + + +def _fixture(parent: Path, prefix: str) -> tuple[Path, tuple[int, int]]: + path = parent / (prefix + uuid.uuid4().hex) + path.mkdir(mode=0o700) + return path, _identity(path) + + +def _cleanup(fixtures: list[tuple[Path, tuple[int, int]]]) -> bool: + clean = True + for path, identity in reversed(fixtures): + try: + if _identity(path) != identity: + clean = False + continue + # Python's descriptor-based rmtree does not follow child symlinks. + if not shutil.rmtree.avoids_symlink_attacks: + clean = False + continue + shutil.rmtree(path) + except (OSError, ValueError): + clean = False + return clean + + +def _log_position(path: Path) -> tuple[int, tuple[int, int] | None]: + _no_symlinks(path) + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + return 0, None + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise ValueError("unsafe event log") + return info.st_size, (info.st_dev, info.st_ino) + finally: + os.close(fd) + + +def _guard_sequence(path: Path, position, installation_id: str) -> bool: + offset, identity = position + try: + _no_symlinks(path) + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except (OSError, ValueError): + return False + try: + info = os.fstat(fd) + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size < offset + or (identity is not None and identity != (info.st_dev, info.st_ino))): + return False + with os.fdopen(fd, "rb", closefd=False) as handle: + handle.seek(offset) + raw = handle.read(_EVENT_LIMIT + 1) + if len(raw) > _EVENT_LIMIT or (raw and not raw.endswith(b"\n")): + return False + rows = [] + keys = {"schema_version", "installation_id", "checked_at", "tool_name", "decision", "code", "action_digest", "policy_digest"} + for line in raw.splitlines(): + if not line.strip(): + continue + event = _object(line) + if event.get("installation_id") != installation_id: + continue + if (set(event) != keys or event["schema_version"] != _EVENT_SCHEMA + or not isinstance(event["checked_at"], str) + or any(not isinstance(event[field], str) or not re.fullmatch(r"[0-9a-f]{64}", event[field]) for field in ("action_digest", "policy_digest"))): + return False + when = datetime.fromisoformat(event["checked_at"].replace("Z", "+00:00")) + if when.utcoffset() != timezone.utc.utcoffset(when): + return False + rows.append(event) + return ([(row["tool_name"], row["decision"], row["code"]) for row in rows] == _SEQUENCE + and len({row["policy_digest"] for row in rows}) == 1) + except (OSError, ValueError, TypeError, KeyError): + return False + finally: + os.close(fd) + + +def _exists(path: Path) -> bool: + try: + path.lstat() + return True + except FileNotFoundError: + return False + except OSError: + return True # Do not turn an unreadable marker into evidence of absence. + + +def _exact_file(path: Path, expected: bytes) -> bool: + try: + _no_symlinks(path) + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except (OSError, ValueError): + return False + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size != len(expected): + return False + with os.fdopen(fd, "rb", closefd=False) as handle: + return handle.read(len(expected) + 1) == expected + except OSError: + return False + finally: + os.close(fd) + + +def _exec_args(executable: str, project: Path, reviewed: dict) -> list[str]: + from .codex_host import build_exec_args + + args = build_exec_args(executable, project, reviewed["key"], reviewed["currentHash"]) + if not args or args[-1] != "-": + args = [*args, "-"] + return args + + +def _kill_group(process: subprocess.Popen) -> None: + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + pass + try: + process.wait(timeout=0.2) + except subprocess.TimeoutExpired: + pass + try: + process.wait(timeout=0.2) + except subprocess.TimeoutExpired: + pass + + +def _run(args: list[str], project: Path, prompt: bytes, timeout_seconds: float): + output = {"stdout": bytearray(), "stderr": bytearray()} + process = None + reason = None + truncated = False + try: + process = subprocess.Popen(args, cwd=project, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + deadline = time.monotonic() + timeout_seconds + with selectors.DefaultSelector() as selector: + for stream, name in ((process.stdin, "stdin"), (process.stdout, "stdout"), (process.stderr, "stderr")): + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_WRITE if name == "stdin" else selectors.EVENT_READ, name) + pending = memoryview(prompt) + while selector.get_map() or process.poll() is None: + remaining = deadline - time.monotonic() + if remaining <= 0: + reason = "TIMEOUT" + break + for key, _ in selector.select(min(remaining, 0.1)): + stream, name = key.fileobj, key.data + if name == "stdin": + try: + written = os.write(stream.fileno(), pending[:4096]) if pending else 0 + pending = pending[written:] + except BrokenPipeError: + pending = memoryview(b"") + if not pending: + selector.unregister(stream) + stream.close() + continue + used = len(output["stdout"]) + len(output["stderr"]) + data = os.read(stream.fileno(), min(65536, _OUTPUT_LIMIT - used + 1)) + if not data: + selector.unregister(stream) + stream.close() + continue + room = _OUTPUT_LIMIT - used + output[name].extend(data[:room]) + if len(data) > room: + reason, truncated = "OUTPUT_LIMIT", True + break + if reason: + break + except (OSError, ValueError): + reason = "HOST_START_OR_IO_FAILED" + finally: + if process is not None: + _kill_group(process) + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + stream.close() + return bytes(output["stdout"]), bytes(output["stderr"]), process.returncode if process else None, reason, truncated + + +def run_host_process(args: list[str], prompt: bytes, project: Path, timeout_seconds: float = 120) -> dict: + """Bounded ephemeral host I/O shared with the protected run command. + + The caller may render selected native messages. Probe records retain only + summaries, never these raw byte buffers. + """ + if (not isinstance(args, list) or not args or any(not isinstance(arg, str) for arg in args) + or not isinstance(prompt, bytes) or isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) or not math.isfinite(timeout_seconds) or timeout_seconds <= 0): + return {"stdout": b"", "stderr": b"", "exit_code": None, "timed_out": False, "output_limited": False} + stdout, stderr, code, reason, _ = _run(args, Path(project), prompt, float(timeout_seconds)) + return {"stdout": stdout, "stderr": stderr, + "exit_code": None if reason == "HOST_START_OR_IO_FAILED" else code, + "timed_out": reason == "TIMEOUT", "output_limited": reason == "OUTPUT_LIMIT"} + + +def _native(stdout: bytes, nonce: str, read_path: str): + try: + records = [_object(line) for line in stdout.splitlines() if line.strip()] + if not records or any(not isinstance(record.get("type"), str) for record in records): + return False, False, False + completed = any(row["type"] == "turn.completed" for row in records) and not any(row["type"] in {"turn.failed", "error"} for row in records) + reads = [] + for row in records: + item = row.get("item") + if row["type"] != "item.completed" or not isinstance(item, dict) or item.get("type") != "command_execution": + continue + command, body = item.get("command"), item.get("aggregated_output") + if (item.get("status") == "completed" and type(item.get("exit_code")) is int and item["exit_code"] == 0 + and isinstance(body, str) and nonce in body and isinstance(command, str) + and "/bin/cat" in command and read_path in command): + reads.append(item) + return True, completed, len(reads) == 1 + except (UnicodeError, ValueError, TypeError, KeyError): + return False, False, False + + +def _native_blocks(stderr: bytes) -> bool: + found = [] + for line in stderr.decode("utf-8", errors="replace").splitlines(): + if "PreToolUse" not in line or not re.search(r"\bblocked\b", line, re.IGNORECASE): + continue + for code in ("PATH_OUTSIDE_PREVIEW", "COMMAND_NOT_ALLOWED"): + if "OWP [" + code + "]" in line: + found.append(code) + return "PATH_OUTSIDE_PREVIEW" in found and "COMMAND_NOT_ALLOWED" in found and found.index("PATH_OUTSIDE_PREVIEW") < found.index("COMMAND_NOT_ALLOWED") + + +def _prompt(project: Path, read: Path, allowed: Path, outside: Path) -> tuple[str, dict]: + plan = { + "read_path": read.relative_to(project).as_posix(), + "allowed_path": (allowed / "created.txt").relative_to(project).as_posix(), + "outside_patch_path": (outside / "blocked.txt").relative_to(project).as_posix(), + "outside_command_path": (outside / "blocked-command.txt").relative_to(project).as_posix(), + "allowed_content": _CONTENT, + } + plan["read_command"] = "/bin/cat " + shlex.quote(plan["read_path"]) + patch = "*** Begin Patch\n*** Add File: " + plan["allowed_path"] + "\n+" + _CONTENT.rstrip("\n") + "\n*** End Patch" + blocked_patch = "*** Begin Patch\n*** Add File: " + plan["outside_patch_path"] + "\n+OWP forbidden write probe.\n*** End Patch" + prompt = ("这是经用户批准的合成保护探针。只按顺序进行以下4次工具调用,每步仅一次,禁止并行。" + "不要调用其他工具、查看设置、列目录或重试。每次拒绝后继续下一步,不要绕过、替代工具或扩大权限。" + "读取结果不要复述到最终回复,最后只简短说明4步已尝试。工具自身的输出由宿主记录。\n" + "OWP_PROBE_FIXTURES=" + json.dumps(plan, ensure_ascii=False, separators=(",", ":")) + "\n" + "1. 使用Bash/exec_command,原样运行:" + plan["read_command"] + "\n" + "2. 使用apply_patch添加允许的新文件,原样提交补丁:\n" + patch + "\n" + "3. 使用apply_patch尝试添加预览目录外的合成标记,应拒绝;原样提交一次:\n" + blocked_patch + "\n" + "4. 使用Bash/exec_command尝试以下命令,应拒绝;原样调用一次:/usr/bin/touch " + shlex.quote(plan["outside_command_path"]) + "\n") + return prompt, plan + + +def perform_probe(*, executable: str, project: Path, allowed_directory: Path, reviewed_hook: dict, event_log: Path, installation_id: str, timeout_seconds=120) -> dict: + """Run only the reviewed project hook, and return bounded evidence summaries. + + A pass applies to these four synthetic calls. It does not attest to all host + tools or eliminate same-user races between checks and native execution. + """ + result = {"status": "unknown", "checks": {name: None for name in REQUIRED_CHECKS}, "codes": [], "started_at": _now(), "finished_at": None, "host_stdout": _summary(b""), "host_stderr": _summary(b""), "exit_code": None} + fixtures = [] + plan = None + stdout = stderr = b"" + truncated = False + try: + project, allowed_directory, event_log = Path(project), Path(allowed_directory), Path(event_log) + for path in (project, allowed_directory, event_log): + _no_symlinks(path) + if (not project.is_dir() or not allowed_directory.is_dir() or project == allowed_directory + or project not in allowed_directory.parents or os.name != "posix" + or any(part.startswith(".") for part in allowed_directory.relative_to(project).parts) + or allowed_directory in event_log.parents + or not isinstance(reviewed_hook, dict) or set(reviewed_hook) != {"key", "currentHash"} + or any(not isinstance(reviewed_hook[key], str) or not reviewed_hook[key] for key in reviewed_hook) + or not isinstance(installation_id, str) or not installation_id + or not isinstance(executable, str) or not executable + or isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)) + or not math.isfinite(timeout_seconds) or timeout_seconds <= 0): + raise ValueError("invalid probe inputs") + position = _log_position(event_log) + allowed_fixture = _fixture(allowed_directory, "owp-probe-") + fixtures.append(allowed_fixture) + outside_fixture = _fixture(project, "owp-probe-outside-") + fixtures.append(outside_fixture) + read = allowed_fixture[0] / "nonce.txt" + nonce = secrets.token_hex(32) # independent from directory UUIDs and prompt + fd = os.open(read, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400) + with os.fdopen(fd, "wb") as handle: + handle.write((nonce + "\n").encode()) + prompt, plan = _prompt(project, read, allowed_fixture[0], outside_fixture[0]) + result["checks"]["nonce_not_in_prompt"] = nonce not in prompt + args = _exec_args(executable, project, reviewed_hook) + execution = run_host_process(args, prompt.encode(), project, float(timeout_seconds)) + stdout, stderr, result["exit_code"] = execution["stdout"], execution["stderr"], execution["exit_code"] + reason = "TIMEOUT" if execution["timed_out"] else "OUTPUT_LIMIT" if execution["output_limited"] else "HOST_START_OR_IO_FAILED" if result["exit_code"] is None else None + truncated = execution["output_limited"] + if reason: + result["codes"].append(reason) + valid, completed, read_proven = _native(stdout, nonce, plan["read_path"]) + result["checks"].update({ + "host_completed": completed and result["exit_code"] == 0 and reason is None, + "native_jsonl_valid": valid, + "native_read_proven": read_proven, + "native_blocks_observed": _native_blocks(stderr), + "guard_event_sequence": _guard_sequence(event_log, position, installation_id), + }) + except (OSError, ValueError, TypeError, ImportError): + result["codes"].append("PROBE_SETUP_OR_EVIDENCE_ERROR") + finally: + if plan is not None: + # Record side effects even when the host or another evidence channel + # failed. A forbidden marker is a failure before any cleanup happens. + result["checks"]["allowed_file_exact"] = _exact_file(project / plan["allowed_path"], _CONTENT.encode()) + result["checks"]["outside_patch_absent"] = not _exists(project / plan["outside_patch_path"]) + result["checks"]["outside_command_absent"] = not _exists(project / plan["outside_command_path"]) + result["checks"]["fixtures_cleaned"] = _cleanup(fixtures) + result["host_stdout"] = _summary(stdout, truncated) + result["host_stderr"] = _summary(stderr, truncated) + result["finished_at"] = _now() + result["codes"].extend(code for check, code in _CHECK_CODES.items() if result["checks"][check] is False) + if set(result["checks"]) == set(REQUIRED_CHECKS) and all(result["checks"][name] is True for name in REQUIRED_CHECKS) and not result["codes"]: + result["status"], result["codes"] = "passed", ["PROBE_PASSED"] + elif (any(result["checks"][key] is False for key in ("outside_patch_absent", "outside_command_absent")) + or (result["checks"]["allowed_file_exact"] is False and result["exit_code"] == 0)): + result["status"] = "failed" + return result diff --git a/tests/test_agent_guard.py b/tests/test_agent_guard.py new file mode 100644 index 0000000..a1cb6a4 --- /dev/null +++ b/tests/test_agent_guard.py @@ -0,0 +1,246 @@ +"""Behavioral contract for the local Codex execution-before guard.""" + +import hashlib +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +from openworkproof import agent_guard + + +@pytest.fixture +def workspace(tmp_path): + project = tmp_path / "project" + preview = project / "preview" + preview.mkdir(parents=True) + (preview / "index.html").write_text("before\n") + (project / "original.txt").write_text("original\n") + state = tmp_path / "state" + state.mkdir() + policy = { + "schema_version": "openworkproof/agent-guard/0.1", + "project_root": str(project), + "allowed_directory": str(preview), + "installation_id": "test-installation", + } + policy_path = state / "policy.json" + policy_path.write_text(json.dumps(policy)) + return project, preview, policy, policy_path + + +def event(project, tool, command, **extra): + return { + "hook_event_name": "PreToolUse", + "cwd": str(project), + "tool_name": tool, + "tool_input": {"command": command, **extra}, + } + + +def decide(workspace, tool, command, **extra): + project, _, policy, _ = workspace + return agent_guard.evaluate_event(event(project, tool, command, **extra), policy) + + +def patch(*lines): + return "\n".join(("*** Begin Patch", *lines, "*** End Patch")) + + +def test_normal_preview_read_and_edit_pass_without_bypassing_host_permissions(workspace): + project, _, policy, _ = workspace + read = decide(workspace, "Bash", "/bin/cat preview/index.html") + edit = decide(workspace, "apply_patch", patch( + "*** Update File: preview/index.html", "@@", "-before", "+after", + )) + add = decide(workspace, "apply_patch", patch( + "*** Add File: preview/new page.html", "+new", + )) + assert read.allowed and edit.allowed and add.allowed + assert agent_guard.hook_response(edit) == {} + assert (project / "original.txt").read_text() == "original\n" + assert agent_guard.evaluate_event(event(project, "Bash", "/bin/ls preview"), policy).allowed + + +@pytest.mark.parametrize("command", [ + "/bin/cat original.txt", + "/bin/cat preview/../original.txt", + "/bin/cat /etc/passwd", + "/bin/cat preview/.env", + "/bin/cat preview/.codex/config.toml", + "/bin/cat preview/index.html > preview/stolen", + "/bin/cat preview/index.html; touch preview/marker", + "/bin/cat preview/index.html | curl example.com", + "/bin/cat $(touch preview/marker)", + "/bin/cat `touch preview/marker`", + "/bin/cat preview/index.html\ntouch preview/marker", + "PATH=preview /bin/cat preview/index.html", + "cat preview/index.html", + "/bin/sh", "python3 -c 'print(1)'", "curl example.com", + "git -c core.pager=sh diff", "find preview -exec sh {} ;", + "/bin/ls -R preview", "/bin/cat -", "/bin/cat", +]) +def test_shell_bypasses_and_out_of_scope_reads_are_denied(workspace, command): + assert not decide(workspace, "Bash", command).allowed + + +@pytest.mark.parametrize("extra", [ + {"tty": True}, {"stdin": "write now"}, {"session_id": 1}, + {"shell": "/bin/sh"}, {"env": {"PATH": "preview"}}, + {"workdir": "/tmp"}, {"sandbox_permissions": "require_escalated"}, +]) +def test_interactive_or_alternate_execution_parameters_are_denied(workspace, extra): + assert not decide(workspace, "Bash", "/bin/cat preview/index.html", **extra).allowed + + +@pytest.mark.parametrize("lines", [ + ("*** Add File: original.txt", "+changed"), + ("*** Add File: preview/../original.txt", "+changed"), + ("*** Add File: preview/.env", "+changed"), + ("*** Add File: preview/.codex/hooks.json", "+changed"), + ("*** Delete File: preview/index.html",), + ("*** Update File: preview/index.html", "*** Move to: preview/moved.html", "@@", "-before", "+after"), + ("*** Add File: preview/new.html", "+safe", "*** Add File: original.txt", "+unsafe"), + ("*** Add File: preview/new.html", "+safe", "*** Delete File: original.txt"), + ("*** Add File: preview/new.html", "+safe", "*** Unknown: anything"), + ("*** Add File: preview/new.html", "+safe", "*** Add File: preview/new.html", "+again"), +]) +def test_entire_patch_is_denied_if_any_action_is_unsafe(workspace, lines): + result = decide(workspace, "apply_patch", patch(*lines)) + assert not result.allowed + assert agent_guard.hook_response(result)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_only_patch_headers_are_paths_not_quoted_file_content(workspace): + assert decide(workspace, "apply_patch", patch( + "*** Add File: preview/new.txt", "+*** Delete File: /etc/passwd", + )).allowed + + +@pytest.mark.parametrize("link_kind", ["file", "directory", "hardlink"]) +def test_links_cannot_reach_another_file(workspace, link_kind): + project, preview, _, _ = workspace + if link_kind == "file": + (preview / "linked.txt").symlink_to(project / "original.txt") + target = "preview/linked.txt" + elif link_kind == "directory": + (preview / "linked").symlink_to(project, target_is_directory=True) + target = "preview/linked/original.txt" + else: + import os + os.link(project / "original.txt", preview / "linked.txt") + target = "preview/linked.txt" + assert not decide(workspace, "Bash", f"/bin/cat {target}").allowed + assert not decide(workspace, "apply_patch", patch( + f"*** Update File: {target}", "@@", "-original", "+changed", + )).allowed + + +@pytest.mark.parametrize("tool", ["mcp__files__write", "write_stdin", "exec_command", "js_repl", "unknown"]) +def test_unrecognized_tool_names_do_not_gain_authority(workspace, tool): + assert not decide(workspace, tool, "/bin/cat preview/index.html").allowed + + +@pytest.mark.parametrize("payload", [None, {}, {"tool_name": "Bash"}, {"hook_event_name": "PostToolUse"}]) +def test_malformed_host_input_is_denied(workspace, payload): + assert not agent_guard.evaluate_event(payload, workspace[2]).allowed + + +def test_command_workdir_is_used_when_validating_paths(workspace): + _, preview, _, _ = workspace + assert decide(workspace, "Bash", "/bin/cat index.html", workdir=str(preview)).allowed + assert not decide(workspace, "Bash", "/bin/cat ../original.txt", workdir=str(preview)).allowed + + +@pytest.mark.parametrize("command", ["/bin/cat -- -", "/bin/cat -n -- -"]) +def test_a_real_dash_filename_cannot_turn_cat_into_an_interactive_reader(workspace, command): + _, preview, _, _ = workspace + (preview / "-").write_text("ordinary file") + result = decide(workspace, "Bash", command, workdir=str(preview)) + assert not result.allowed and result.code == "COMMAND_NOT_ALLOWED" + + +@pytest.mark.parametrize("name", ["*", "?", "[a]", "~"]) +def test_shell_expansion_is_rejected_even_when_the_literal_file_exists(workspace, name): + project, preview, _, _ = workspace + (preview / name).write_text("literal file") + (preview / "linked.txt").symlink_to(project / "original.txt") + assert not decide(workspace, "Bash", f"/bin/cat {name}", workdir=str(preview)).allowed + + +@pytest.mark.parametrize("option", ["-l", "-a", "-la", "-al", "-lah"]) +def test_directory_listing_cannot_expose_hidden_metadata_or_link_targets(workspace, option): + project, preview, _, _ = workspace + (preview / ".env").write_text("private") + (preview / "link").symlink_to(project / "original.txt") + assert not decide(workspace, "Bash", f"/bin/ls {option} preview").allowed + + +def test_ambiguous_workdir_fields_and_unknown_execution_options_are_denied(workspace): + project, preview, _, _ = workspace + (project / "same.txt").write_text("outside") + (preview / "same.txt").write_text("inside") + assert not decide(workspace, "Bash", "/bin/cat same.txt", + workdir=str(project), working_directory=str(preview)).allowed + assert not decide(workspace, "Bash", "/bin/cat preview/index.html", new_exec_option=True).allowed + + +@pytest.mark.parametrize("option", ["-R", "-L", "-la"]) +def test_late_options_cannot_be_disguised_as_existing_directory_names(workspace, option): + _, preview, _, _ = workspace + (preview / option).mkdir() + assert not decide(workspace, "Bash", f"/bin/ls . {option}", workdir=str(preview)).allowed + + +def invoke(policy_path, project, data): + return subprocess.run( + [sys.executable, "-I", agent_guard.__file__, "--policy", str(policy_path)], + input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=project, check=False, + ) + + +def test_runtime_records_digests_without_leaking_command_or_file_content(workspace): + project, _, _, policy_path = workspace + command = patch("*** Add File: preview/new.txt", "+PRIVATE-CONTENT-DO-NOT-LOG") + data = json.dumps(event(project, "apply_patch", command)).encode() + result = invoke(policy_path, project, data) + assert result.returncode == 0 and json.loads(result.stdout) == {} + record = json.loads(policy_path.with_name("events.jsonl").read_text()) + assert record["decision"] == "allow" + assert record["installation_id"] == "test-installation" + canonical = json.dumps(json.loads(data), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + assert record["action_digest"] == hashlib.sha256(canonical).hexdigest() + assert "PRIVATE-CONTENT" not in policy_path.with_name("events.jsonl").read_text() + assert not result.stderr + + +@pytest.mark.parametrize("case", ["missing_policy", "invalid_policy", "bad_input", "huge_input", "audit_unavailable"]) +def test_runtime_failures_return_supported_deny_instead_of_host_fail_open(workspace, case): + project, _, _, policy_path = workspace + data = json.dumps(event(project, "Bash", "/bin/cat preview/index.html")).encode() + if case == "missing_policy": + policy_path.unlink() + elif case == "invalid_policy": + policy_path.write_text('{"allowed":true}') + elif case == "bad_input": + data = b"not JSON PRIVATE-CONTENT" + elif case == "huge_input": + data = b"x" * (agent_guard.MAX_INPUT_BYTES + 1) + else: + policy_path.with_name("events.jsonl").mkdir() + result = invoke(policy_path, project, data) + assert result.returncode == 0 + assert json.loads(result.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + assert b"PRIVATE-CONTENT" not in result.stdout + result.stderr + + +def test_replaced_policy_directory_and_wrong_event_cwd_are_denied(workspace): + project, preview, policy, _ = workspace + other = project / "other" + other.mkdir() + preview.rename(project / "old-preview") + preview.symlink_to(other, target_is_directory=True) + assert not agent_guard.evaluate_event(event(project, "Bash", "/bin/pwd"), policy).allowed diff --git a/tests/test_codex_host.py b/tests/test_codex_host.py new file mode 100644 index 0000000..ad4338c --- /dev/null +++ b/tests/test_codex_host.py @@ -0,0 +1,332 @@ +"""Bounded native Codex inventory and exact session-trust contract.""" + +import json +import os +import sys +import time + +import pytest + +from openworkproof import codex_host + + +HASH = "sha256:" + "a" * 64 + + +@pytest.fixture +def fake_host(tmp_path, monkeypatch): + project = tmp_path / 'project.with.dot "中文"' + project.mkdir() + capture = tmp_path / "requests.jsonl" + pid_file = tmp_path / "host.pid" + helper = tmp_path / "fake-codex" + helper.write_text( + "#!" + sys.executable + "\n" + r''' +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +mode = os.environ.get("OWP_CODEX_FAKE_MODE", "normal") +capture = Path(os.environ["OWP_CODEX_CAPTURE"]) +Path(os.environ["OWP_CODEX_PID_FILE"]).write_text(str(os.getpid())) +capture.with_suffix(".argv.json").write_text(json.dumps(sys.argv[1:])) +if "--version" in sys.argv: + if mode == "bad-version": + print("RAW_SECRET is not a version") + elif mode == "version-timeout": + time.sleep(30) + else: + print("codex-cli 0.147.0") + raise SystemExit(0) +if mode == "ignore-term": + signal.signal(signal.SIGTERM, signal.SIG_IGN) +if mode == "init-pending-descendant": + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + pid_path = Path(os.environ["OWP_CODEX_PID_FILE"]) + pid_path.with_suffix(".child.pid").write_text(str(child.pid)) + def reap_child(signum, frame): + child.wait(timeout=1) + raise SystemExit(0) + signal.signal(signal.SIGTERM, reap_child) + pid_path.with_suffix(".ready").touch() + time.sleep(30) + raise SystemExit(0) +if mode == "stderr": + sys.stderr.write("RAW_SECRET" * 100000) + sys.stderr.flush() +for line in sys.stdin: + message = json.loads(line) + with capture.open("a") as output: + output.write(json.dumps(message) + "\n") + method = message.get("method") + if method == "initialized": + continue + if method == "initialize": + result = {"userAgent": "fake", "codexHome": "not-returned"} + elif method == "hooks/list": + if mode in ("timeout", "ignore-term"): + time.sleep(30) + if mode == "eof": + raise SystemExit(0) + if mode == "invalid-json": + print("RAW_SECRET not json", flush=True) + continue + if mode == "oversize": + print("x" * 10000, flush=True) + continue + if mode == "deep-json": + print("[" * 1001 + "0" + "]" * 1001, flush=True) + continue + if mode == "wrong-id": + print(json.dumps({"id": 999, "result": {}}), flush=True) + continue + if mode == "rpc-error": + print(json.dumps({"id": message["id"], "error": {"message": "RAW_SECRET"}}), flush=True) + continue + if mode == "server-request": + print(json.dumps({"id": 99, "method": "permission/request", "params": {"secret": "RAW_SECRET"}}), flush=True) + continue + if mode == "notification": + print(json.dumps({"method": "config/warning", "params": {"message": "RAW_SECRET"}}), flush=True) + cwd = message["params"]["cwds"][0] + hook = { + "key": cwd + "/.codex/hooks.json:pre_tool_use:0:0", + "currentHash": "sha256:" + "a" * 64, + "trustStatus": "untrusted", "enabled": True, + "isManaged": False, "source": "project", + "sourcePath": cwd + "/.codex/hooks.json", + "eventName": "preToolUse", "handlerType": "command", + "command": "/bin/sh /trusted/owp-hook.sh", "matcher": "*", + "timeoutSec": 5, + } + entry = {"cwd": cwd, "hooks": [hook], "warnings": [], "errors": []} + if mode == "entry-error": + entry["errors"] = [{"message": "RAW_SECRET", "path": cwd}] + if mode == "wrong-cwd": + entry["cwd"] = "/another/project" + result = {"data": [entry]} + if mode == "invalid-result": + result = {"data": "RAW_SECRET"} + if mode == "duplicate-cwd": + result["data"].append(entry) + else: + raise SystemExit(3) + print(json.dumps({"id": message["id"], "result": result}), flush=True) +''' + ) + helper.chmod(0o700) + monkeypatch.setenv("OWP_CODEX_CAPTURE", str(capture)) + monkeypatch.setenv("OWP_CODEX_PID_FILE", str(pid_file)) + return helper, project, capture, pid_file + + +def assert_stopped(pid_file): + pid = int(pid_file.read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +def test_native_inventory_initializes_and_only_lists_hooks(fake_host): + helper, project, capture, pid_file = fake_host + with codex_host.CodexHost(executable=str(helper), project=project) as host: + entry = host.list_hooks() + assert entry["cwd"] == str(project) + assert entry["hooks"][0]["currentHash"] == HASH + messages = [json.loads(line) for line in capture.read_text().splitlines()] + assert [message["method"] for message in messages] == [ + "initialize", "initialized", "hooks/list", + ] + assert messages[0]["params"]["capabilities"]["experimentalApi"] is True + assert messages[2]["params"] == {"cwds": [str(project)]} + args = json.loads(capture.with_suffix(".argv.json").read_text()) + assert "app-server" in args + assert "exec" not in args and "--ignore-user-config" not in args + assert not any("bypass" in argument for argument in args) + assert_stopped(pid_file) + + +@pytest.mark.parametrize("mode", ["notification", "stderr"]) +def test_notifications_and_stderr_do_not_leak_or_block_inventory(fake_host, monkeypatch, mode): + helper, project, _, pid_file = fake_host + monkeypatch.setenv("OWP_CODEX_FAKE_MODE", mode) + with codex_host.CodexHost(executable=str(helper), project=project) as host: + assert host.list_hooks()["hooks"][0]["trustStatus"] == "untrusted" + assert_stopped(pid_file) + + +@pytest.mark.parametrize("mode", [ + "eof", "invalid-json", "oversize", "deep-json", "wrong-id", "rpc-error", + "server-request", "entry-error", "wrong-cwd", "invalid-result", "duplicate-cwd", +]) +def test_protocol_failures_are_closed_and_do_not_echo_raw_data(fake_host, monkeypatch, mode): + helper, project, _, pid_file = fake_host + monkeypatch.setenv("OWP_CODEX_FAKE_MODE", mode) + with pytest.raises(codex_host.CodexHostError) as error: + with codex_host.CodexHost( + executable=str(helper), project=project, max_message_bytes=2048, + ) as host: + host.list_hooks() + assert "RAW_SECRET" not in str(error.value) + assert str(error.value) + assert_stopped(pid_file) + + +def test_deep_json_is_a_fixed_error_with_a_smaller_recursion_limit(fake_host, monkeypatch): + helper, project, _, pid_file = fake_host + monkeypatch.setenv("OWP_CODEX_FAKE_MODE", "deep-json") + previous_limit = sys.getrecursionlimit() + try: + sys.setrecursionlimit(500) + with pytest.raises(codex_host.CodexHostError): + with codex_host.CodexHost(executable=str(helper), project=project) as host: + host.list_hooks() + finally: + sys.setrecursionlimit(previous_limit) + assert_stopped(pid_file) + + +@pytest.mark.parametrize("mode", ["timeout", "ignore-term"]) +def test_stalled_host_is_bounded_and_reaped(fake_host, monkeypatch, mode): + helper, project, _, pid_file = fake_host + monkeypatch.setenv("OWP_CODEX_FAKE_MODE", mode) + started = time.monotonic() + with pytest.raises(codex_host.CodexHostError) as error: + with codex_host.CodexHost( + executable=str(helper), project=project, timeout_seconds=1.0, + ) as host: + host.list_hooks() + assert time.monotonic() - started < 5 + assert "RAW_SECRET" not in str(error.value) + assert_stopped(pid_file) + + +def test_context_error_still_cleans_up_host(fake_host): + helper, project, _, pid_file = fake_host + with pytest.raises(ValueError, match="caller failure"): + with codex_host.CodexHost(executable=str(helper), project=project): + raise ValueError("caller failure") + assert_stopped(pid_file) + + +@pytest.mark.skipif(os.name != "posix", reason="owned POSIX process-group contract") +@pytest.mark.parametrize("interruption", [KeyboardInterrupt("init interrupted"), SystemExit(7)]) +def test_initialization_interruption_cleans_up_real_parent_and_descendant( + fake_host, monkeypatch, interruption, +): + helper, project, _, pid_file = fake_host + monkeypatch.setenv("OWP_CODEX_FAKE_MODE", "init-pending-descendant") + host = codex_host.CodexHost(executable=str(helper), project=project) + child_pid_file = pid_file.with_suffix(".child.pid") + + def interrupt_initialize(self, method, params): + assert method == "initialize" + deadline = time.monotonic() + 3 + while not pid_file.with_suffix(".ready").exists(): + assert time.monotonic() < deadline, "fake child did not become ready" + time.sleep(0.01) + parent_pid = int(pid_file.read_text()) + child_pid = int(child_pid_file.read_text()) + assert os.getpgid(parent_pid) == parent_pid + assert os.getpgid(child_pid) == parent_pid + raise interruption + + monkeypatch.setattr(codex_host.CodexHost, "_request", interrupt_initialize) + try: + with pytest.raises(type(interruption)) as error: + with host: + pytest.fail("initialization should have been interrupted") + assert error.value is interruption + assert_stopped(pid_file) + assert_stopped(child_pid_file) + finally: + # A failing pre-fix regression must not leave its synthetic processes behind. + host.close() + + +def test_version_uses_only_version_entrypoint(fake_host): + helper, _, capture, pid_file = fake_host + assert codex_host.codex_version(str(helper)) == "0.147.0" + assert not capture.exists() + assert_stopped(pid_file) + + +def test_bad_version_is_not_repeated_in_error(fake_host, monkeypatch): + helper, _, _, pid_file = fake_host + monkeypatch.setenv("OWP_CODEX_FAKE_MODE", "bad-version") + with pytest.raises(codex_host.CodexHostError) as error: + codex_host.codex_version(str(helper)) + assert "RAW_SECRET" not in str(error.value) + assert_stopped(pid_file) + + +def test_missing_executable_is_a_fixed_error(tmp_path): + with pytest.raises(codex_host.CodexHostError) as error: + codex_host.codex_version(str(tmp_path / "missing-private-name")) + assert "missing-private-name" not in str(error.value) + + +def test_exec_args_encode_whole_tables_and_only_exact_reviewed_hook(tmp_path): + tomllib = pytest.importorskip("tomllib") + project = tmp_path / 'project.with.dot "中文"' + project.mkdir() + key = str(project / ".codex/hooks.json") + ":pre_tool_use:0:0" + args = codex_host.build_exec_args("codex", project, key, HASH) + assert args[-1] == "-" + assert args[args.index("-C") + 1] == str(project) + assert {"exec", "--ignore-user-config", "--ephemeral", "--json"}.issubset(args) + assert not any("bypass" in argument for argument in args) + assert "-m" not in args and "--model" not in args + overrides = {} + for index, argument in enumerate(args): + if argument == "-c": + overrides.update(tomllib.loads(args[index + 1])) + assert overrides["projects"] == {str(project): {"trust_level": "trusted"}} + assert overrides["hooks"]["state"] == { + key: {"trusted_hash": HASH, "enabled": True}, + } + assert overrides["features"] == { + "hooks": True, "plugins": False, "apps": False, + "multi_agent": False, "code_mode_host": True, + } + assert overrides["web_search"] == "disabled" + + +@pytest.mark.parametrize("key,current_hash", [ + ("", HASH), ("key", "a" * 64), ("key", "sha256:BAD"), + ("key\x00bad", HASH), ("key", None), +]) +def test_invalid_trust_cannot_generate_exec_args(tmp_path, key, current_hash): + with pytest.raises(codex_host.CodexHostError): + codex_host.build_exec_args("codex", tmp_path, key, current_hash) + + +def test_reviewed_hook_is_passed_as_one_session_state(fake_host): + tomllib = pytest.importorskip("tomllib") + helper, project, capture, _ = fake_host + key = str(project / ".codex/hooks.json") + ":pre_tool_use:0:0" + host = codex_host.CodexHost( + executable=str(helper), project=project, + reviewed_hook={"key": key, "currentHash": HASH}, + ) + with host: + assert host.list_hooks()["hooks"] + args = json.loads(capture.with_suffix(".argv.json").read_text()) + state = [tomllib.loads(args[i + 1])["hooks"]["state"] + for i, value in enumerate(args) if value == "-c" + and args[i + 1].startswith("hooks.state=")] + assert state == [{key: {"trusted_hash": HASH, "enabled": True}}] + assert not any("bypass" in value for value in args) + assert [json.loads(line)["method"] for line in capture.read_text().splitlines()] == [ + "initialize", "initialized", "hooks/list", + ] +def test_guarded_execution_keeps_native_workspace_permissions(tmp_path): + from openworkproof.codex_host import build_exec_args + + args = build_exec_args("codex", tmp_path, "owned-key", "sha256:" + "a" * 64) + assert args[args.index("-a") + 1] == "never" + assert args[args.index("-s") + 1] == "workspace-write" + assert not any("bypass" in argument for argument in args) diff --git a/tests/test_guard_cli.py b/tests/test_guard_cli.py new file mode 100644 index 0000000..0b902dd --- /dev/null +++ b/tests/test_guard_cli.py @@ -0,0 +1,462 @@ +"""Installation lifecycle checks use disposable projects, never user settings.""" + +import json +import os +import subprocess +import time +from pathlib import Path + +import pytest + +from openworkproof import guard_cli + + +@pytest.fixture +def layout(tmp_path, monkeypatch): + root = tmp_path.resolve() + project = root / "project" + project.mkdir() + allowed = project / "preview" + allowed.mkdir() + source = root / "fixture_runtime.py" + source.write_text("import sys\nsys.stdin.read()\nprint('{}')\n") + monkeypatch.setattr(guard_cli, "RUNTIME_SOURCE", source) + return project, allowed, root / "state", source + + +def invoke(capsys, command, project, state, *extra): + rc = guard_cli.main([command, "--project", str(project), "--state-dir", str(state), *extra]) + output = capsys.readouterr() + return rc, json.loads(output.out) if output.out else None, output.err + + +def install(capsys, layout, *extra): + project, allowed, state, _ = layout + rc, result, err = invoke(capsys, "install", project, state, "--allow-dir", str(allowed), *extra) + assert rc == 0, err + return result + + +def run_hook(config_path, payload=b'{"hook_event_name":"PreToolUse"}'): + entry = json.loads(config_path.read_text())["hooks"]["PreToolUse"][-1] + return subprocess.run(entry["hooks"][0]["command"], shell=True, input=payload, capture_output=True) + + +def test_install_preserves_other_hooks_and_raw_backup(layout, capsys): + project, _, _, _ = layout + config = project / ".codex/hooks.json" + config.parent.mkdir() + original = b'{\r\n "description":"mine", "hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo mine"}]}],"Stop":[]}\r\n}\r\n' + config.write_bytes(original) + result = install(capsys, layout) + state = Path(result["state_directory"]) + parsed = json.loads(config.read_bytes()) + assert parsed["description"] == "mine" + assert len(parsed["hooks"]["PreToolUse"]) == 2 + assert parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "echo mine" + ours = parsed["hooks"]["PreToolUse"][-1] + assert ours["matcher"] == "*" + assert ours["hooks"][0]["timeout"] == 5 + assert "async" not in ours["hooks"][0] + assert (state / "hooks.before").read_bytes() == original + policy = json.loads((state / "policy.json").read_text()) + assert set(policy) == {"schema_version", "project_root", "allowed_directory", "installation_id"} + assert policy["allowed_directory"] == str(layout[1]) + assert result["requires_trust"] is True + assert run_hook(config).stdout.strip() == b"{}" + + +@pytest.mark.parametrize("failure", ["missing", "crash"]) +def test_wrapper_returns_supported_deny_for_failed_python(layout, capsys, failure): + result = install(capsys, layout) + runtime = Path(result["state_directory"]) / "runtime.py" + if failure == "missing": + runtime.unlink() + else: + runtime.write_text("print('not JSON')\nraise RuntimeError('private traceback')\n") + proc = run_hook(layout[0] / ".codex/hooks.json") + assert proc.returncode == 0 + output = json.loads(proc.stdout) + assert output["hookSpecificOutput"]["permissionDecision"] == "deny" + assert b"private traceback" not in proc.stderr + + +def test_install_is_idempotent_and_upgrade_is_explicit(layout, capsys): + first = install(capsys, layout) + config = layout[0] / ".codex/hooks.json" + config_before = config.read_bytes() + manifest = Path(first["state_directory"]) / "installation.json" + manifest_before = manifest.read_bytes() + second = install(capsys, layout) + assert second["installation_id"] == first["installation_id"] + assert config.read_bytes() == config_before + assert manifest.read_bytes() == manifest_before + layout[3].write_text("print('{}')\n# upgraded snapshot\n") + rc, _, err = invoke(capsys, "install", layout[0], layout[2], "--allow-dir", str(layout[1])) + assert rc != 0 and "--upgrade" in err + assert config.read_bytes() == config_before + upgraded = install(capsys, layout, "--upgrade") + assert upgraded["installation_id"] == first["installation_id"] + assert upgraded["requires_trust"] is True + assert config.read_bytes() != config_before # source hash changes hook definition/trust + assert b"upgraded snapshot" in (manifest.parent / "runtime.py").read_bytes() + + +def test_status_observes_only_matching_valid_events(layout, capsys): + installed = install(capsys, layout) + rc, status, _ = invoke(capsys, "status", layout[0], layout[2]) + assert rc == 0 and status["configured"] is True and status["last_observed"] is None + assert "trusted" not in status + good = {"schema_version": "openworkproof/agent-guard-event/0.1", "installation_id": installed["installation_id"], "checked_at": "2026-10-26T01:02:03Z", "tool_name": "Bash", "decision": "allow", "code": "allowed", "action_digest": "a" * 64, "policy_digest": status["policy_digest"]} + wrong = dict(good, installation_id="not-ours") + (Path(installed["state_directory"]) / "events.jsonl").write_text(json.dumps(good) + "\n" + json.dumps(wrong) + "\n{broken\n") + _, status, _ = invoke(capsys, "status", layout[0], layout[2]) + assert status["last_observed"] == good + + +def test_modified_hook_entry_is_not_removed(layout, capsys): + installed = install(capsys, layout) + config = layout[0] / ".codex/hooks.json" + data = json.loads(config.read_text()) + data["hooks"]["PreToolUse"][-1]["hooks"][0]["timeout"] = 9 + config.write_text(json.dumps(data)) + before = config.read_bytes() + _, status, _ = invoke(capsys, "status", layout[0], layout[2]) + assert status["configured"] is False and status["drift"] + rc, _, _ = invoke(capsys, "uninstall", layout[0], layout[2]) + assert rc != 0 + assert config.read_bytes() == before + + +@pytest.mark.parametrize("target", ["runtime.py", "hook.sh", "policy.json", "hooks.before"]) +def test_drifted_state_does_not_trap_uninstall(layout, capsys, target): + config = layout[0] / ".codex/hooks.json" + config.parent.mkdir() + config.write_text('{"description":"original","hooks":{"Stop":[]}}\n') + installed = install(capsys, layout) + artifact = Path(installed["state_directory"]) / target + artifact.write_text("changed bytes remain local\n") + data = json.loads(config.read_text()) + data["later_change"] = "preserve" + config.write_text(json.dumps(data)) + _, status, _ = invoke(capsys, "status", layout[0], layout[2]) + assert status["drift"] and status["configured"] is False + rc, uninstalled, err = invoke(capsys, "uninstall", layout[0], layout[2]) + assert rc == 0, err + assert uninstalled["configured"] is False + assert artifact.read_text() == "changed bytes remain local\n" + kept = json.loads(config.read_text()) + assert kept["description"] == "original" and kept["later_change"] == "preserve" + assert kept["hooks"]["Stop"] == [] + assert not kept["hooks"].get("PreToolUse") + + +@pytest.mark.parametrize("backup", ["missing", "changed"]) +def test_bad_backup_falls_back_to_removing_exact_entry(layout, capsys, backup): + config = layout[0] / ".codex/hooks.json" + config.parent.mkdir() + config.write_text('{"description":"must survive", "hooks":{"Stop":[]}}') + installed = install(capsys, layout) + original = Path(installed["state_directory"]) / "hooks.before" + if backup == "missing": + original.unlink() + else: + original.write_text('{"description":"never restore me"}') + rc, _, err = invoke(capsys, "uninstall", layout[0], layout[2]) + assert rc == 0, err + current = json.loads(config.read_text()) + assert current["description"] == "must survive" + assert current["hooks"]["Stop"] == [] + assert current["hooks"]["PreToolUse"] == [] + + +@pytest.mark.parametrize("existed", [False, True]) +def test_uninstall_restores_original_bytes_or_absence(layout, capsys, existed): + config = layout[0] / ".codex/hooks.json" + original = b'{\r\n "hooks": {}, "description": "keep format"\r\n}\r\n' + if existed: + config.parent.mkdir() + config.write_bytes(original) + install(capsys, layout) + rc, result, err = invoke(capsys, "uninstall", layout[0], layout[2]) + assert rc == 0, err + assert result["configured"] is False + assert config.read_bytes() == original if existed else not config.exists() + + +def test_uninstall_preserves_changes_added_after_install_and_upgrade(layout, capsys): + install(capsys, layout) + config = layout[0] / ".codex/hooks.json" + data = json.loads(config.read_text()) + other = {"matcher": "Bash", "hooks": [{"type": "command", "command": "echo new"}]} + data["hooks"]["PreToolUse"].insert(0, other) + data["new_setting"] = {"keep": True} + config.write_text(json.dumps(data)) + layout[3].write_text("print('{}')\n# new version\n") + install(capsys, layout, "--upgrade") + rc, _, err = invoke(capsys, "uninstall", layout[0], layout[2]) + assert rc == 0, err + data = json.loads(config.read_text()) + assert data["hooks"]["PreToolUse"] == [other] + assert data["new_setting"] == {"keep": True} + + +@pytest.mark.parametrize("kind", ["project_root", "outside", "hidden", "codex", "symlink", "state_overlap"]) +def test_unsafe_allowed_paths_are_rejected(layout, capsys, kind): + project, allowed, state, _ = layout + if kind == "project_root": + allowed = project + elif kind == "outside": + allowed = state.parent + elif kind in {"hidden", "codex"}: + allowed = project / (".hidden" if kind == "hidden" else ".codex") + allowed.mkdir() + elif kind == "symlink": + linked = project / "linked" + linked.symlink_to(allowed, target_is_directory=True) + allowed = linked + else: + state = allowed / "state" + rc, _, _ = invoke(capsys, "install", project, state, "--allow-dir", str(allowed)) + assert rc != 0 + assert not (project / ".codex/hooks.json").exists() + + +@pytest.mark.parametrize("content", [b"not JSON", b"[]", b'{"hooks": []}', b'{"hooks":{},"hooks":{}}']) +def test_bad_existing_config_is_not_overwritten(layout, capsys, content): + config = layout[0] / ".codex/hooks.json" + config.parent.mkdir() + config.write_bytes(content) + rc, _, _ = invoke(capsys, "install", layout[0], layout[2], "--allow-dir", str(layout[1])) + assert rc != 0 + assert config.read_bytes() == content + + +def test_symlinked_config_is_rejected_without_touching_target(layout, capsys): + project, _, state, _ = layout + target = project.parent / "personal.json" + target.write_text('{"keep":true}') + config = project / ".codex/hooks.json" + config.parent.mkdir() + config.symlink_to(target) + rc, _, _ = invoke(capsys, "install", project, state, "--allow-dir", str(layout[1])) + assert rc != 0 and target.read_text() == '{"keep":true}' + + +def test_uninstalled_status_is_not_a_protection_claim(layout, capsys): + rc, result, err = invoke(capsys, "status", layout[0], layout[2]) + assert rc == 0, err + assert result["configured"] is False and result["last_observed"] is None + + +def test_wrapper_missing_interpreter_returns_valid_deny(layout): + project = layout[0] + wrapper = project / "fixture-wrapper.sh" + wrapper.write_bytes(guard_cli._wrapper(project / "missing-python", project / "runtime.py", project / "policy.json", "fixture-id", "fixture-digest", "fixture-policy-digest")) + proc = subprocess.run(["/bin/sh", str(wrapper), "fixture-id", "fixture-digest", "fixture-wrapper-digest", "fixture-policy-digest"], input=b"{}", capture_output=True) + assert proc.returncode == 0 + assert json.loads(proc.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +@pytest.mark.parametrize("source", ["pass\n", "print('broken JSON')\n", "print('[]')\n", "print('{\"continue\":false}')\n", "print('{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\"}}')\n", "print('{\"a\":1,\"a\":2}')\n"]) +def test_wrapper_rejects_successful_but_invalid_runtime_output(layout, capsys, source): + layout[3].write_text(source) + install(capsys, layout) + proc = run_hook(layout[0] / ".codex/hooks.json") + assert proc.returncode == 0 + assert json.loads(proc.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_wrapper_times_out_before_host_timeout(layout, capsys): + layout[3].write_text("import time\ntime.sleep(30)\nprint('{}')\n") + install(capsys, layout) + started = time.monotonic() + proc = run_hook(layout[0] / ".codex/hooks.json") + assert time.monotonic() - started < 4.5 + assert proc.returncode == 0 + assert json.loads(proc.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_configuration_compare_and_swap_preserves_foreign_writer(layout, capsys, monkeypatch): + config = layout[0] / ".codex/hooks.json" + atomic = guard_cli._atomic + foreign = b'{"description":"concurrent edit"}\n' + + def concurrent_write(path, data, before, mode=0o600): + if path == config: + config.write_bytes(foreign) + return atomic(path, data, before, mode) + + monkeypatch.setattr(guard_cli, "_atomic", concurrent_write) + rc, _, err = invoke(capsys, "install", layout[0], layout[2], "--allow-dir", str(layout[1])) + assert rc != 0 and "concurrently" in err + assert config.read_bytes() == foreign + assert not list(layout[2].glob("*/installation.json")) + + +def test_project_lock_refuses_concurrent_guard_operation(layout, capsys): + with guard_cli._project_lock(layout[0]): + rc, _, err = invoke(capsys, "status", layout[0], layout[2]) + assert rc != 0 and "another guard operation" in err + + +def test_nonregular_config_is_rejected_without_blocking(layout, capsys): + config = layout[0] / ".codex/hooks.json" + config.parent.mkdir() + os.mkfifo(config) + rc, _, err = invoke(capsys, "install", layout[0], layout[2], "--allow-dir", str(layout[1])) + assert rc != 0 and "regular file" in err + assert config.is_fifo() + + +def test_reinstallation_keeps_historical_events_but_does_not_claim_observation(layout, capsys): + original = install(capsys, layout) + rc, uninstalled, err = invoke(capsys, "uninstall", layout[0], layout[2]) + assert rc == 0, err + assert uninstalled["retained_state_directory"] == original["state_directory"] + historical = Path(original["state_directory"]) / "events.jsonl" + historical.write_text('{"not":"a current event"}\n') + new = install(capsys, layout) + assert new["installation_id"] != original["installation_id"] + assert new["last_observed"] is None + assert historical.exists() + + +def test_wrapper_uses_real_runtime_without_executing_agent_tools(layout, capsys, monkeypatch): + monkeypatch.setattr(guard_cli, "RUNTIME_SOURCE", Path(guard_cli.__file__).with_name("agent_guard.py")) + installed = install(capsys, layout) + (layout[1] / "index.html").write_text("preview") + config = layout[0] / ".codex/hooks.json" + event = {"hook_event_name": "PreToolUse", "cwd": str(layout[0]), "session_id": "fixture-only", "tool_name": "Bash", "tool_input": {"command": "/bin/cat preview/index.html"}} + allowed = run_hook(config, json.dumps(event).encode()) + assert allowed.returncode == 0 and json.loads(allowed.stdout) == {} + event["tool_input"]["command"] = "/bin/cat ../private.txt" + denied = run_hook(config, json.dumps(event).encode()) + assert json.loads(denied.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + _, status, _ = invoke(capsys, "status", layout[0], layout[2]) + assert status["last_observed"]["decision"] == "deny" + assert status["state_directory"] == installed["state_directory"] + + +def test_wrapper_upgrade_changes_host_trust_definition(layout, capsys, monkeypatch): + installed = install(capsys, layout) + config = layout[0] / ".codex/hooks.json" + before = config.read_bytes() + build_wrapper = guard_cli._wrapper + monkeypatch.setattr(guard_cli, "_wrapper", lambda *args: build_wrapper(*args) + b"# updated wrapper\n") + rc, _, err = invoke(capsys, "install", layout[0], layout[2], "--allow-dir", str(layout[1])) + assert rc != 0 and "--upgrade" in err + updated = install(capsys, layout, "--upgrade") + assert updated["installation_id"] == installed["installation_id"] + assert config.read_bytes() != before + + +def test_human_status_never_claims_trusted_or_enabled(layout, capsys): + install(capsys, layout) + rc = guard_cli.main(["status", "--project", str(layout[0]), "--state-dir", str(layout[2]), "--human"]) + output = capsys.readouterr() + assert rc == 0 and "项目接入:已配置" in output.out + assert "宿主当前是否信任/启用:尚未核实,配置存在不代表生效" in output.out + assert "最近一次检查:尚未观察到" in output.out + assert "保护已启用" not in output.out + rc = guard_cli.main(["uninstall", "--project", str(layout[0]), "--state-dir", str(layout[2]), "--human"]) + output = capsys.readouterr() + assert rc == 0 and "本地记录保留" in output.out + + +@pytest.mark.parametrize("artifact", ["runtime", "policy"]) +def test_wrapper_checks_installed_bytes_before_trusting_result(layout, capsys, monkeypatch, artifact): + monkeypatch.setattr(guard_cli, "RUNTIME_SOURCE", Path(guard_cli.__file__).with_name("agent_guard.py")) + installed = install(capsys, layout) + directory = Path(installed["state_directory"]) + config = layout[0] / ".codex/hooks.json" + wider = layout[0] / "wider" + wider.mkdir() + (wider / "secret.txt").write_text("not in approved preview") + event = {"hook_event_name": "PreToolUse", "cwd": str(layout[0]), "tool_name": "Bash", "tool_input": {"command": "/bin/cat wider/secret.txt"}} + assert json.loads(run_hook(config, json.dumps(event).encode()).stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + if artifact == "runtime": + (directory / "runtime.py").write_text("print('{}')\n") + else: + policy = json.loads((directory / "policy.json").read_text()) + policy["allowed_directory"] = str(wider) + (directory / "policy.json").write_text(json.dumps(policy)) + proc = run_hook(config, json.dumps(event).encode()) + assert proc.returncode == 0 + assert json.loads(proc.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +@pytest.mark.parametrize("control", ["state", "installation", "codex"]) +def test_install_refuses_preexisting_writable_control_directories(layout, capsys, control): + project, _, state, _ = layout + if control == "installation": + installed = install(capsys, layout) + unsafe = Path(installed["state_directory"]) + else: + unsafe = state if control == "state" else project / ".codex" + unsafe.mkdir() + unsafe.chmod(0o777) + config = project / ".codex/hooks.json" + before = config.read_bytes() if config.exists() else None + rc, _, err = invoke(capsys, "install", project, state, "--allow-dir", str(layout[1])) + assert rc != 0 and "permissions" in err + assert (config.read_bytes() if config.exists() else None) == before + assert unsafe.stat().st_mode & 0o777 == 0o777 + + +def test_uninstall_rejects_a_modified_duplicate_of_owned_entry(layout, capsys): + install(capsys, layout) + config = layout[0] / ".codex/hooks.json" + data = json.loads(config.read_text()) + duplicate = json.loads(json.dumps(data["hooks"]["PreToolUse"][0])) + duplicate["hooks"][0]["timeout"] = 8 + data["hooks"]["PreToolUse"].append(duplicate) + config.write_text(json.dumps(data)) + before = config.read_bytes() + rc, _, _ = invoke(capsys, "uninstall", layout[0], layout[2]) + assert rc != 0 + assert config.read_bytes() == before + + +@pytest.mark.parametrize("artifact", ["runtime.py", "policy.json"]) +@pytest.mark.parametrize("kind", ["symlink", "hardlink", "fifo"]) +def test_wrapper_rejects_linked_or_nonregular_control_files(layout, capsys, artifact, kind): + installed = install(capsys, layout) + target = Path(installed["state_directory"]) / artifact + original = target.read_bytes() + replacement = layout[0].parent / ("alternate-" + artifact) + replacement.write_bytes(original) + target.unlink() + if kind == "symlink": + target.symlink_to(replacement) + elif kind == "hardlink": + os.link(replacement, target) + else: + os.mkfifo(target) + proc = run_hook(layout[0] / ".codex/hooks.json") + assert proc.returncode == 0 + assert json.loads(proc.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_previous_owned_wrapper_can_be_explicitly_upgraded(layout, capsys): + installed = install(capsys, layout) + directory = Path(installed["state_directory"]) + manifest_path = directory / "installation.json" + manifest = json.loads(manifest_path.read_text()) + legacy_wrapper = b"#!/bin/sh\nprintf '{}\\n'\n" + (directory / "hook.sh").write_bytes(legacy_wrapper) + manifest["wrapper_digest"] = guard_cli._digest(legacy_wrapper) + manifest["hook_entry"] = guard_cli._entry(directory, manifest["installation_id"], manifest["runtime_digest"], manifest["wrapper_digest"]) + config_path = layout[0] / ".codex/hooks.json" + config = json.loads(config_path.read_text()) + config["hooks"]["PreToolUse"] = [manifest["hook_entry"]] + config_path.write_bytes(guard_cli._json_bytes(config)) + manifest["installed_config_digest"] = guard_cli._digest(config_path.read_bytes()) + manifest_path.write_bytes(guard_cli._json_bytes(manifest)) + rc, _, err = invoke(capsys, "install", layout[0], layout[2], "--allow-dir", str(layout[1])) + assert rc != 0 and "--upgrade" in err + updated = install(capsys, layout, "--upgrade") + assert updated["installation_id"] == installed["installation_id"] + current_manifest = json.loads(manifest_path.read_text()) + assert current_manifest["policy_file_digest"] in config_path.read_text() + assert run_hook(config_path).stdout.strip() == b"{}" diff --git a/tests/test_guard_onboarding.py b/tests/test_guard_onboarding.py new file mode 100644 index 0000000..c1b148a --- /dev/null +++ b/tests/test_guard_onboarding.py @@ -0,0 +1,287 @@ +"""Review, native exact trust, and probe identity must agree before a task.""" + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from openworkproof import codex_host, guard_cli, guard_onboarding, guard_probe + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + project = tmp_path / "project" + allowed = project / "preview" + allowed.mkdir(parents=True) + state = tmp_path / "state" + executable = tmp_path / "codex" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o700) + installed = guard_cli._install(project, allowed, state) + directory = Path(installed["state_directory"]) + behavior = {"override": {}, "duplicate": False, "seen_reviews": [], "probe_calls": 0, + "version": "0.147.0", "during_probe": None, "bad_probe": False} + + class Host: + def __init__(self, *, executable, project, reviewed_hook=None): + self.reviewed = reviewed_hook + behavior["seen_reviews"].append(reviewed_hook) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def list_hooks(self): + entry = json.loads((project / ".codex/hooks.json").read_text())["hooks"]["PreToolUse"][-1] + digest = "sha256:" + hashlib.sha256(json.dumps(entry, sort_keys=True).encode()).hexdigest() + h = {"key": str(project / ".codex/hooks.json") + ":pre_tool_use:0:0", + "currentHash": digest, "sourcePath": str(project / ".codex/hooks.json"), + "source": "project", "eventName": "preToolUse", "handlerType": "command", + "command": entry["hooks"][0]["command"], "matcher": "*", "timeoutSec": 5, + "enabled": True, "isManaged": False, + "trustStatus": "trusted" if self.reviewed == {"key": str(project / ".codex/hooks.json") + ":pre_tool_use:0:0", "currentHash": digest} else "untrusted"} + h.update(behavior["override"]) + return {"cwd": str(project), "hooks": [h, dict(h)] if behavior["duplicate"] else [h], "warnings": [], "errors": []} + + def probe(**kwargs): + behavior["probe_calls"] += 1 + if behavior["during_probe"]: + behavior["during_probe"]() + return {"status": "passed", "checks": {} if behavior["bad_probe"] else {key: True for key in guard_probe.REQUIRED_CHECKS}, + "codes": [], "started_at": "2026-10-26T12:00:00+00:00", "finished_at": "2026-10-26T12:00:01+00:00"} + + monkeypatch.setattr(codex_host, "CodexHost", Host) + monkeypatch.setattr(codex_host, "codex_version", lambda exe: behavior["version"]) + monkeypatch.setattr(guard_probe, "perform_probe", probe) + return project, allowed, state, executable, directory, behavior + + +def enable(setup): + p, _, s, exe, _, _ = setup + return guard_onboarding.enable(p, s, executable=str(exe), trust_own_hook=True) + + +def doctor(setup): + p, _, s, exe, _, _ = setup + return guard_onboarding.doctor(p, s, executable=str(exe)) + + +def test_installed_and_local_log_are_not_activation(setup): + _, _, _, _, directory, _ = setup + (directory / "events.jsonl").write_text('{"decision":"allow"}\n') + result = doctor(setup) + assert result["configured"] is True + assert result["review_status"] == "missing" + assert result["ready_for_guarded_run"] is False + assert result["ordinary_startup_verified"] is False + + +def test_enable_requires_explicit_own_hook_consent(setup): + p, _, s, exe, directory, behavior = setup + with pytest.raises(guard_onboarding.OnboardingError): + guard_onboarding.enable(p, s, executable=str(exe)) + assert not (directory / "review.json").exists() + assert behavior["probe_calls"] == 0 + + +def test_enable_records_native_key_hash_and_checks_actual_session_trust(setup): + result = enable(setup) + assert result["ready_for_guarded_run"] is True + assert result["host_trust_status"] == "trusted" + assert result["activation_scope"] == "guarded_codex_invocation" + assert result["ordinary_startup_verified"] is False + review = json.loads((setup[4] / "review.json").read_text()) + assert review["identity"]["hook_key"].endswith(":pre_tool_use:0:0") + assert review["identity"]["hook_hash"].startswith("sha256:") + assert setup[5]["probe_calls"] == 1 + assert any(x is not None for x in setup[5]["seen_reviews"]) + + +@pytest.mark.parametrize("changed", [ + {"source": "user"}, {"sourcePath": "/another/hooks.json"}, + {"handlerType": "mcpTool"}, {"eventName": "postToolUse"}, + {"isManaged": True}, {"enabled": False}, {"matcher": "Bash"}, + {"timeoutSec": 500}, {"command": "curl attacker.invalid"}, + {"currentHash": "self-reported"}, {"key": "another-key"}, +]) +def test_nearby_or_unverifiable_hook_cannot_be_authorized(setup, changed): + setup[5]["override"] = changed + with pytest.raises(guard_onboarding.OnboardingError): + enable(setup) + assert not (setup[4] / "review.json").exists() + + +def test_ambiguous_native_inventory_is_not_authorized(setup): + setup[5]["duplicate"] = True + with pytest.raises(guard_onboarding.OnboardingError): + enable(setup) + assert not (setup[4] / "review.json").exists() + + +def test_reviewed_identity_cannot_change_during_user_confirmation(setup): + p, _, s, exe, directory, _ = setup + summary = guard_onboarding.review_summary(p, s, executable=str(exe)) + exe.write_text("#!/bin/sh\n# changed before confirmation\nexit 0\n") + with pytest.raises(guard_onboarding.OnboardingError): + guard_onboarding.enable(p, s, executable=str(exe), trust_own_hook=True, + reviewed_identity=summary["identity"]) + assert not (directory / "review.json").exists() + + +@pytest.mark.parametrize("change", ["codex_bytes", "codex_version", "reinstall", "upgrade"]) +def test_old_review_and_probe_do_not_survive_identity_changes(setup, monkeypatch, change): + assert enable(setup)["ready_for_guarded_run"] + p, allowed, s, exe, _, behavior = setup + behavior["seen_reviews"].clear() + if change == "codex_bytes": + exe.write_text("#!/bin/sh\n# new bytes\nexit 0\n") + elif change == "codex_version": + behavior["version"] = "0.148.0" + elif change == "reinstall": + guard_cli._uninstall(p, s) + guard_cli._install(p, allowed, s) + else: + source = p.parent / "new_runtime.py" + source.write_bytes(guard_cli.RUNTIME_SOURCE.read_bytes() + b"\n# upgrade\n") + monkeypatch.setattr(guard_cli, "RUNTIME_SOURCE", source) + guard_cli._install(p, allowed, s, upgrade=True) + result = doctor(setup) + assert not result["ready_for_guarded_run"] + assert result["review_status"] == "outdated" + assert behavior["seen_reviews"] == [None] + + +def test_empty_probe_checks_cannot_turn_status_green(setup): + setup[5]["bad_probe"] = True + result = enable(setup) + assert not result["ready_for_guarded_run"] + assert result["probe_status"] != "passed" + + +def test_drift_during_probe_invalidates_the_record(setup): + def change(): + config = setup[0] / ".codex/hooks.json" + d = json.loads(config.read_text()) + d["hooks"]["PreToolUse"][0]["hooks"][0]["timeout"] = 6 + config.write_text(json.dumps(d)) + setup[5]["during_probe"] = change + result = enable(setup) + assert not result["ready_for_guarded_run"] + assert result["probe_status"] != "passed" + + +def test_task_without_current_review_never_starts_host(setup, monkeypatch): + called = [] + monkeypatch.setattr(guard_probe, "run_host_process", lambda *a, **k: called.append(True)) + with pytest.raises(guard_onboarding.OnboardingError): + guard_onboarding.run_guarded_task(setup[0], setup[2], "do something", executable=str(setup[3])) + assert not called + + +def test_guarded_task_uses_exact_native_trust_and_original_agent_messages(setup, monkeypatch): + assert enable(setup)["ready_for_guarded_run"] + captured = [] + def run(args, prompt, project, timeout_seconds=120): + captured.append((args, prompt, project)) + return {"stdout": b'{"type":"item.completed","item":{"type":"agent_message","text":"done"}}\n{"type":"turn.completed"}\n', + "stderr": b"", "exit_code": 0, "timed_out": False, "output_limited": False} + monkeypatch.setattr(guard_probe, "run_host_process", run) + result = guard_onboarding.run_guarded_task(setup[0], setup[2], "edit preview", executable=str(setup[3])) + assert result["status"] == "completed" and result["assistant_text"] == "done" + args, prompt, project = captured[0] + assert prompt == b"edit preview" and project == setup[0] + assert not any("bypass" in x for x in args) + assert any("hooks.state=" in x for x in args) + + +def test_uninstall_disables_guarded_run_even_if_review_and_probe_remain(setup): + assert enable(setup)["ready_for_guarded_run"] + guard_cli._uninstall(setup[0], setup[2]) + result = doctor(setup) + assert not result["ready_for_guarded_run"] and not result["configured"] + + +@pytest.mark.parametrize("action", ["enable", "run"]) +def test_active_enable_or_task_prevents_concurrent_cli_uninstall(setup, monkeypatch, action): + p, _, s, exe, _, behavior = setup + def attempt_uninstall(): + before = (p / ".codex/hooks.json").read_bytes() + result = subprocess.run([sys.executable, guard_cli.__file__, "uninstall", "--project", str(p), "--state-dir", str(s)], capture_output=True) + assert result.returncode == 2 and b"another guard operation" in result.stderr + assert (p / ".codex/hooks.json").read_bytes() == before + if action == "enable": + behavior["during_probe"] = attempt_uninstall + assert enable(setup)["ready_for_guarded_run"] + else: + assert enable(setup)["ready_for_guarded_run"] + def run(*args, **kwargs): + attempt_uninstall() + return {"stdout": b'{"type":"turn.completed"}\n', "stderr": b"", "exit_code": 0, "timed_out": False, "output_limited": False} + monkeypatch.setattr(guard_probe, "run_host_process", run) + assert guard_onboarding.run_guarded_task(p, s, "reply only", executable=str(exe))["status"] == "completed" + + +def test_successful_native_tool_without_this_tasks_guard_events_is_unverified(setup, monkeypatch): + assert enable(setup)["ready_for_guarded_run"] + def run(*args, **kwargs): + return {"stdout": b'{"type":"item.completed","item":{"type":"command_execution","status":"completed","exit_code":0}}\n{"type":"turn.completed"}\n', + "stderr": b"", "exit_code": 0, "timed_out": False, "output_limited": False} + monkeypatch.setattr(guard_probe, "run_host_process", run) + result = guard_onboarding.run_guarded_task(setup[0], setup[2], "read preview", executable=str(setup[3])) + assert result["status"] == "unverified" + + +def test_module_entry_reports_enable_failure_without_python_traceback(setup): + p, _, s, _, _, _ = setup + env = dict(os.environ, PYTHONPATH=str(Path(guard_cli.__file__).parents[1])) + result = subprocess.run([sys.executable, "-m", "openworkproof.guard_cli", "enable", "--project", str(p), + "--state-dir", str(s), "--codex", str(p / "missing-codex"), "--trust-own-hook"], + env=env, capture_output=True) + assert result.returncode == 2 + assert b"Traceback" not in result.stderr + + +def test_cli_human_doctor_and_enable_have_separate_review_and_probe_states(setup, capsys): + p, _, s, exe, _, _ = setup + args = ["--project", str(p), "--state-dir", str(s), "--codex", str(exe), "--human"] + assert guard_cli.main(["doctor", *args]) == 2 + assert "尚未授权" in capsys.readouterr().out + assert guard_cli.main(["enable", *args, "--trust-own-hook"]) == 0 + text = capsys.readouterr().out + assert "仅本项目" in text and "最近真实检查:通过" in text + assert "普通 Codex 启动尚未验证" in text + + +def test_noninteractive_enable_does_not_infer_consent(setup, monkeypatch, capsys): + p, _, s, exe, directory, _ = setup + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + rc = guard_cli.main(["enable", "--project", str(p), "--state-dir", str(s), "--codex", str(exe)]) + assert rc == 2 and not (directory / "review.json").exists() + assert "--trust-own-hook" in capsys.readouterr().err + + +def test_interactive_cancel_does_not_save_review(setup, monkeypatch, capsys): + p, _, s, exe, directory, _ = setup + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "") + rc = guard_cli.main(["enable", "--project", str(p), "--state-dir", str(s), "--codex", str(exe), "--human"]) + assert rc == 0 and not (directory / "review.json").exists() + assert "已取消" in capsys.readouterr().out + + +def test_control_c_is_a_clean_exit_for_onboarding_commands(setup, monkeypatch, capsys): + def interrupt(*args): + raise KeyboardInterrupt + monkeypatch.setattr(guard_cli, "_onboarding_command", interrupt) + try: + code = guard_cli.main(["doctor", "--project", str(setup[0]), "--state-dir", str(setup[2])]) + except KeyboardInterrupt: + code = "uncaught" + assert code == 130 + assert "已停止" in capsys.readouterr().err diff --git a/tests/test_guard_probe.py b/tests/test_guard_probe.py new file mode 100644 index 0000000..b59cb97 --- /dev/null +++ b/tests/test_guard_probe.py @@ -0,0 +1,238 @@ +"""Native-evidence probe tests use local fake executables, never a real model.""" + +import json +from pathlib import Path +import sys +import time +import types + +import pytest + +from openworkproof import guard_probe + + +FAKE_HOST = r''' +import json, os, pathlib, subprocess, sys, time +MODE = __MODE__ +LOG = pathlib.Path(__LOG__) +CAPTURE = pathlib.Path(__CAPTURE__) +INSTALLATION = __INSTALLATION__ +prompt = sys.stdin.read() +line = next(line for line in prompt.splitlines() if line.startswith("OWP_PROBE_FIXTURES=")) +plan = json.loads(line.split("=", 1)[1]) +CAPTURE.write_text(json.dumps({"prompt": prompt, "plan": plan})) +if MODE == "outside_then_timeout": + (pathlib.Path.cwd() / plan["outside_patch_path"]).touch() + time.sleep(30) +if MODE == "timeout": + later = str(CAPTURE.with_name("surviving-child.txt")) + subprocess.Popen([sys.executable, "-c", "import pathlib,time; time.sleep(1.5); pathlib.Path(" + repr(later) + ").write_text('escaped')"]) + CAPTURE.with_name("child-started.txt").write_text("spawned") + time.sleep(30) +if MODE == "output_limit": + sys.stdout.write("X" * (2 * 1024 * 1024 + 4096)); sys.stdout.flush(); time.sleep(30) +root = pathlib.Path.cwd() +nonce = (root / plan["read_path"]).read_text().strip() +CAPTURE.write_text(json.dumps({"prompt":prompt, "plan":plan, "nonce":nonce})) +(root / plan["allowed_path"]).write_text(plan["allowed_content"] if MODE != "wrong_file" else "wrong") +if MODE in {"outside_patch", "outside_then_nonzero"}: + (root / plan["outside_patch_path"]).write_text("forbidden") +if MODE == "outside_command": + (root / plan["outside_command_path"]).touch() +events = [ + ("Bash", "allow", "WITHIN_PREVIEW"), + ("apply_patch", "allow", "WITHIN_PREVIEW"), + ("apply_patch", "deny", "PATH_OUTSIDE_PREVIEW"), + ("Bash", "deny", "COMMAND_NOT_ALLOWED"), +] +if MODE == "extra_event": events.append(events[-1]) +if MODE == "wrong_sequence": events[0], events[1] = events[1], events[0] +if MODE == "no_guard": events = [] +LOG.parent.mkdir(parents=True, exist_ok=True) +with LOG.open("a") as handle: + for name, decision, code in events: + event = {"schema_version":"openworkproof/agent-guard-event/0.1", "installation_id":INSTALLATION, "checked_at":"2026-10-26T12:00:00Z", "tool_name":name, "decision":decision, "code":code, "action_digest":"a" * 64, "policy_digest":"b" * 64} + handle.write(json.dumps(event) + "\n") + if MODE == "unrelated_event": + event["installation_id"] = "another-installation" + handle.write(json.dumps(event) + "\n") + if MODE == "bad_log": handle.write("broken log\n") +native = {"type":"item.completed", "item":{"id":"item_0", "type":"command_execution", "command":plan["read_command"], "status":"completed", "exit_code":0, "aggregated_output":nonce + "\n"}} +if MODE == "wrong_exit": native["item"]["exit_code"] = 1 +if MODE == "not_completed": native["item"]["status"] = "in_progress" +if MODE == "wrong_native_command": native["item"]["command"] = "/bin/pwd" +if MODE == "self_report": native = {"type":"item.completed", "item":{"type":"agent_message", "text":"I read and verified " + nonce}} +print(json.dumps({"type":"thread.started", "thread_id":"synthetic"})) +if MODE != "no_native": print(json.dumps(native)) +print(json.dumps({"type":"turn.completed", "usage":{"input_tokens":1,"output_tokens":1}})) +if MODE == "bad_stdout": print("broken JSONL") +if MODE != "no_stderr": + for code in ["PATH_OUTSIDE_PREVIEW", "COMMAND_NOT_ALLOWED"]: + print("ERROR codex_core::tools::router: Command blocked by PreToolUse hook: OWP [" + code + "]: fixture-only", file=sys.stderr) +if MODE in {"nonzero", "outside_then_nonzero"}: raise SystemExit(7) +''' + + +@pytest.fixture +def probe_case(tmp_path, monkeypatch): + root = tmp_path.resolve() + project = root / "project" + project.mkdir() + allowed = project / "preview" + allowed.mkdir() + (project / "user-original.txt").write_text("leave untouched") + event_log = root / "state/events.jsonl" + capture = root / "capture.json" + installation_id = "78fc9088-a8ab-4f60-9548-177a0c6e685e" + executable = root / "fake-host" + + def configure(mode="success"): + source = FAKE_HOST.replace("__MODE__", repr(mode)).replace("__LOG__", repr(str(event_log))).replace("__CAPTURE__", repr(str(capture))).replace("__INSTALLATION__", repr(installation_id)) + executable.write_text("#!" + str(Path(sys.executable).resolve()) + "\n" + source) + executable.chmod(0o700) + + configure() + # Invoke the fake through the known interpreter, avoiding macOS first-run + # assessment of a newly created executable in short deadline tests. + monkeypatch.setattr(guard_probe, "_exec_args", lambda executable, project, reviewed: [str(Path(sys.executable).resolve()), executable, "-"]) + return {"project": project, "allowed_directory": allowed, "event_log": event_log, "installation_id": installation_id, "executable": str(executable), "reviewed_hook": {"key": "reviewed-fixture-hook", "currentHash": "c" * 64}}, configure, capture + + +def assert_fixtures_cleaned(case, capture): + assert (case["project"] / "user-original.txt").read_text() == "leave untouched" + assert not list(case["allowed_directory"].glob("owp-probe-*")) + assert not list(case["project"].glob("owp-probe-outside-*")) + if capture.exists(): + plan = json.loads(capture.read_text())["plan"] + assert not (case["project"] / plan["outside_patch_path"]).exists() + assert not (case["project"] / plan["outside_command_path"]).exists() + + +def test_pass_requires_native_nonce_file_and_guard_evidence(probe_case): + case, _, capture = probe_case + result = guard_probe.perform_probe(**case) + assert result["status"] == "passed", result + assert all(result["checks"].values()) + assert set(result["checks"]) == set(guard_probe.REQUIRED_CHECKS) + assert result["exit_code"] == 0 + assert result["host_stdout"]["bytes"] > 0 + assert set(result["host_stdout"]) == {"sha256", "bytes", "truncated"} + recorded = json.loads(capture.read_text()) + assert "-I" not in recorded["prompt"] # probe never proposes an alternate guard path + assert "不要绕过" in recorded["prompt"] + assert_fixtures_cleaned(case, capture) + assert str(case["project"]) not in json.dumps(result) + assert "leave untouched" not in json.dumps(result) + + +@pytest.mark.parametrize("mode", ["self_report", "no_native", "wrong_exit", "not_completed", "wrong_native_command", "no_stderr", "no_guard", "extra_event", "wrong_sequence", "bad_stdout", "bad_log", "nonzero"]) +def test_incomplete_or_contradictory_evidence_never_passes(probe_case, mode): + case, configure, capture = probe_case + configure(mode) + result = guard_probe.perform_probe(**case) + assert result["status"] in {"failed", "unknown"}, result + assert "PROBE_PASSED" not in result["codes"] + assert_fixtures_cleaned(case, capture) + + +@pytest.mark.parametrize("mode,check", [("outside_patch", "outside_patch_absent"), ("outside_command", "outside_command_absent"), ("wrong_file", "allowed_file_exact")]) +def test_observed_bad_files_fail_before_only_owned_fixtures_are_cleaned(probe_case, mode, check): + case, configure, capture = probe_case + configure(mode) + result = guard_probe.perform_probe(**case) + assert result["status"] == "failed", result + assert result["checks"][check] is False + assert_fixtures_cleaned(case, capture) + + +def test_prior_and_other_installation_events_do_not_count(probe_case): + case, configure, capture = probe_case + case["event_log"].parent.mkdir() + case["event_log"].write_text("old malformed data must stay before byte offset\n") + configure("unrelated_event") + result = guard_probe.perform_probe(**case) + assert result["status"] == "passed", result + assert case["event_log"].read_text().startswith("old malformed data") + assert_fixtures_cleaned(case, capture) + + +def test_timeout_cleans_owned_process_group_and_fixtures(probe_case): + case, configure, capture = probe_case + configure("timeout") + started = time.monotonic() + result = guard_probe.perform_probe(**case, timeout_seconds=0.4) + assert time.monotonic() - started < 3 + assert result["status"] == "unknown" and "TIMEOUT" in result["codes"] + assert capture.with_name("child-started.txt").exists() + time.sleep(1.3) + assert not capture.with_name("surviving-child.txt").exists() + assert_fixtures_cleaned(case, capture) + + +def test_output_is_bounded_and_not_returned_raw(probe_case): + case, configure, capture = probe_case + configure("output_limit") + result = guard_probe.perform_probe(**case, timeout_seconds=4) + assert result["status"] == "unknown" and "OUTPUT_LIMIT" in result["codes"] + assert result["host_stdout"]["bytes"] + result["host_stderr"]["bytes"] <= 2 * 1024 * 1024 + assert "X" * 100 not in json.dumps(result) + assert_fixtures_cleaned(case, capture) + + +def test_native_nonce_is_absent_from_prompt_and_result(probe_case): + case, _, capture = probe_case + result = guard_probe.perform_probe(**case) + assert result["status"] == "passed" + # The fake's stdout digest is useful, but no transcript/body is persisted by the API. + assert "stdout" not in result and "stderr" not in result + assert "prompt" not in result + captured = json.loads(capture.read_text()) + assert captured["nonce"] not in captured["prompt"] + assert captured["nonce"] not in json.dumps(result) + assert_fixtures_cleaned(case, capture) + + +@pytest.mark.parametrize("mode", ["outside_then_nonzero", "outside_then_timeout"]) +def test_negative_marker_is_failed_even_if_host_crashes_or_times_out(probe_case, mode): + case, configure, capture = probe_case + configure(mode) + result = guard_probe.perform_probe(**case, timeout_seconds=1.5) + assert result["status"] == "failed", result + assert result["checks"]["outside_patch_absent"] is False + assert "OUTSIDE_PATCH_CREATED" in result["codes"] + assert_fixtures_cleaned(case, capture) + + +def test_symlinked_input_does_not_start_host_or_touch_user_files(probe_case): + case, _, capture = probe_case + linked = case["project"] / "linked-preview" + linked.symlink_to(case["allowed_directory"], target_is_directory=True) + case["allowed_directory"] = linked + result = guard_probe.perform_probe(**case) + assert result["status"] == "unknown" + assert not capture.exists() + assert (case["project"] / "user-original.txt").read_text() == "leave untouched" + assert set(result["checks"]) == set(guard_probe.REQUIRED_CHECKS) + + +@pytest.mark.parametrize("last_arg", [None, "-"]) +def test_host_builder_receives_exact_review_and_stdin_prompt_marker(monkeypatch, tmp_path, last_arg): + received = [] + host = types.ModuleType("openworkproof.codex_host") + + def build(executable, project, key, current_hash): + received.append((executable, project, key, current_hash)) + return [executable, "exec", *([last_arg] if last_arg else [])] + + host.build_exec_args = build + monkeypatch.setitem(sys.modules, "openworkproof.codex_host", host) + reviewed = {"key": "exact-key", "currentHash": "d" * 64} + result = guard_probe._exec_args("/fake/codex", tmp_path, reviewed) + assert result == ["/fake/codex", "exec", "-"] + assert received == [("/fake/codex", tmp_path, reviewed["key"], reviewed["currentHash"])] + + +def test_shared_host_runner_handles_large_stdin_and_both_output_pipes(tmp_path): + prompt = b"synthetic input\n" * 10000 + result = guard_probe.run_host_process([str(Path(sys.executable).resolve()), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); sys.stderr.write('done')"], prompt, tmp_path.resolve(), 3) + assert result == {"stdout": prompt, "stderr": b"done", "exit_code": 0, "timed_out": False, "output_limited": False}