chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
"""Tests for the codex-native policy hook entrypoint (``evaluate-policy``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from omnigent import codex_native_hook, native_policy_hook
|
||||
from omnigent.codex_native_bridge import (
|
||||
CodexNativeBridgeState,
|
||||
codex_home_for_bridge_dir,
|
||||
prepare_bridge_dir,
|
||||
write_bridge_state,
|
||||
write_policy_hook_config,
|
||||
)
|
||||
from tests.native_hook_helpers import make_failing_client
|
||||
|
||||
|
||||
class _DenyHttpxClient:
|
||||
"""
|
||||
Sync HTTP client stub that records the request and returns a DENY verdict.
|
||||
|
||||
Returns a real :class:`httpx.Response` so the hook exercises its real
|
||||
JSON parsing + verdict-mapping path rather than a mock's attributes.
|
||||
|
||||
:param headers: Headers passed to :class:`httpx.Client`.
|
||||
:param timeout: Timeout passed to :class:`httpx.Client`.
|
||||
"""
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
|
||||
"""
|
||||
Capture constructor inputs for later assertions.
|
||||
|
||||
:param headers: HTTP headers the hook builds for AP.
|
||||
:param timeout: HTTP timeout object.
|
||||
:returns: None.
|
||||
"""
|
||||
_DenyHttpxClient.captured["headers"] = headers
|
||||
|
||||
def __enter__(self) -> _DenyHttpxClient:
|
||||
"""
|
||||
Enter the context manager.
|
||||
|
||||
:returns: This stub client.
|
||||
"""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
"""
|
||||
Exit the context manager.
|
||||
|
||||
:param args: Exception details (unused).
|
||||
:returns: None.
|
||||
"""
|
||||
del args
|
||||
|
||||
def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
|
||||
"""
|
||||
Record the outgoing request and return a DENY EvaluationResponse.
|
||||
|
||||
:param url: Target Omnigent URL.
|
||||
:param json: Request body (the EvaluationRequest).
|
||||
:returns: A real 200 response carrying a DENY verdict.
|
||||
"""
|
||||
_DenyHttpxClient.captured["url"] = url
|
||||
_DenyHttpxClient.captured["json"] = json
|
||||
return httpx.Response(
|
||||
200,
|
||||
text='{"result":"POLICY_ACTION_DENY","reason":"rm blocked by admin policy"}',
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
|
||||
class _RaisesIfCalled:
|
||||
"""
|
||||
HTTP client stub that fails the test if the hook ever POSTs.
|
||||
|
||||
Used by fail-open tests where the hook must short-circuit (missing
|
||||
bridge state or policy config) before reaching the network.
|
||||
|
||||
:param headers: Headers passed to :class:`httpx.Client` (unused).
|
||||
:param timeout: Timeout passed to :class:`httpx.Client` (unused).
|
||||
"""
|
||||
|
||||
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
|
||||
"""
|
||||
Accept the constructor shape; do nothing.
|
||||
|
||||
:param headers: HTTP headers (unused).
|
||||
:param timeout: HTTP timeout (unused).
|
||||
:returns: None.
|
||||
"""
|
||||
del headers, timeout
|
||||
|
||||
def __enter__(self) -> _RaisesIfCalled:
|
||||
"""
|
||||
Enter the context manager.
|
||||
|
||||
:returns: This stub client.
|
||||
"""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
"""
|
||||
Exit the context manager.
|
||||
|
||||
:param args: Exception details (unused).
|
||||
:returns: None.
|
||||
"""
|
||||
del args
|
||||
|
||||
def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
|
||||
"""
|
||||
Fail loudly — the hook should never reach the network here.
|
||||
|
||||
:param url: Target Omnigent URL (unused).
|
||||
:param json: Request body (unused).
|
||||
:returns: Never returns.
|
||||
:raises AssertionError: Always.
|
||||
"""
|
||||
del url, json
|
||||
raise AssertionError(
|
||||
"evaluate-policy POSTed to Omnigent when it should have short-circuited "
|
||||
"(missing bridge state or policy_hook config)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""
|
||||
Create an isolated codex-native bridge directory with state.
|
||||
|
||||
Redirects the bridge root under ``tmp_path`` so the test never
|
||||
touches the real ``~/.omnigent`` tree, then writes a valid bridge
|
||||
state whose ``session_id`` the hook reads to build the Omnigent URL.
|
||||
|
||||
:param tmp_path: pytest temp directory.
|
||||
:param monkeypatch: pytest monkeypatch fixture.
|
||||
:returns: Prepared bridge directory.
|
||||
"""
|
||||
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "codex-native")
|
||||
bdir = prepare_bridge_dir("bridge_test")
|
||||
write_bridge_state(
|
||||
bdir,
|
||||
CodexNativeBridgeState(
|
||||
session_id="conv_active",
|
||||
socket_path=str(bdir / "app-server.sock"),
|
||||
thread_id="thread_abc",
|
||||
codex_home=str(bdir / "codex-home"),
|
||||
),
|
||||
)
|
||||
return bdir
|
||||
|
||||
|
||||
def _run_hook(
|
||||
bridge_dir: Path, payload: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
) -> int:
|
||||
"""
|
||||
Feed *payload* on stdin and run the ``evaluate-policy`` subcommand.
|
||||
|
||||
:param bridge_dir: The session's bridge directory.
|
||||
:param payload: The codex hook JSON payload, e.g.
|
||||
``{"hook_event_name": "PreToolUse", "tool_name": "Bash", ...}``.
|
||||
:param monkeypatch: pytest monkeypatch fixture.
|
||||
:returns: The hook process exit code.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
||||
return codex_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
|
||||
|
||||
|
||||
def test_pre_tool_use_converts_posts_and_returns_deny(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""
|
||||
A PreToolUse hook converts to proto, POSTs to AP, and maps DENY back.
|
||||
|
||||
This is the full codex enforcement path: read bridge state +
|
||||
policy_hook config → convert payload → POST /policies/evaluate →
|
||||
map the DENY verdict to ``permissionDecision: deny``. It fails if any
|
||||
link breaks (wrong URL/session, missing conversion, missing auth, or
|
||||
a mis-mapped verdict that would let the blocked command run).
|
||||
"""
|
||||
_DenyHttpxClient.captured = {}
|
||||
write_policy_hook_config(
|
||||
bridge_dir,
|
||||
ap_server_url="http://127.0.0.1:8787",
|
||||
ap_auth_headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", _DenyHttpxClient)
|
||||
|
||||
exit_code = _run_hook(
|
||||
bridge_dir,
|
||||
{
|
||||
"hook_event_name": "PreToolUse",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "rm -rf /"},
|
||||
},
|
||||
monkeypatch,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert exit_code == 0
|
||||
# URL is built from the bridge state's session_id, not the payload.
|
||||
assert _DenyHttpxClient.captured["url"] == (
|
||||
"http://127.0.0.1:8787/v1/sessions/conv_active/policies/evaluate"
|
||||
)
|
||||
# The codex payload is converted to the proto EvaluationRequest shape.
|
||||
sent = _DenyHttpxClient.captured["json"]
|
||||
assert sent["event"]["type"] == "PHASE_TOOL_CALL"
|
||||
assert sent["event"]["data"] == {"name": "Bash", "arguments": {"command": "rm -rf /"}}
|
||||
# Auth headers from policy_hook.json reach AP.
|
||||
assert _DenyHttpxClient.captured["headers"] == {"Authorization": "Bearer test-token"}
|
||||
# The DENY verdict maps back to codex's PreToolUse deny output.
|
||||
result = json.loads(captured.out)
|
||||
assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
|
||||
assert result["hookSpecificOutput"]["permissionDecisionReason"] == "rm blocked by admin policy"
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_user_prompt_submit_converts_posts_and_blocks(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""
|
||||
A UserPromptSubmit hook converts to PHASE_REQUEST and maps DENY to block.
|
||||
|
||||
This is the request-phase enforcement path for native Codex sessions
|
||||
(the server-level ``_evaluate_input_policy`` skips native message
|
||||
events). The prompt rides in ``event.data.text``; a DENY maps to the
|
||||
top-level ``decision: "block"`` contract — NOT ``permissionDecision`` —
|
||||
which drops the prompt before the model sees it. A break here means a
|
||||
blocked prompt would still reach the model.
|
||||
"""
|
||||
_DenyHttpxClient.captured = {}
|
||||
write_policy_hook_config(
|
||||
bridge_dir,
|
||||
ap_server_url="http://127.0.0.1:8787",
|
||||
ap_auth_headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", _DenyHttpxClient)
|
||||
|
||||
exit_code = _run_hook(
|
||||
bridge_dir,
|
||||
{
|
||||
"hook_event_name": "UserPromptSubmit",
|
||||
"prompt": "delete the prod database",
|
||||
},
|
||||
monkeypatch,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert exit_code == 0
|
||||
sent = _DenyHttpxClient.captured["json"]
|
||||
assert sent["event"]["type"] == "PHASE_REQUEST"
|
||||
assert sent["event"]["data"] == {"text": "delete the prod database"}
|
||||
# DENY → top-level decision/reason block (not permissionDecision).
|
||||
result = json.loads(captured.out)
|
||||
assert result == {"decision": "block", "reason": "rm blocked by admin policy"}
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_pre_tool_use_stamps_model_from_config(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
The hook stamps ``config.toml``'s model onto the request context.
|
||||
|
||||
This is the race-free model source for the codex cost gate: the hook
|
||||
reads the user's live ``/model`` selection from ``config.toml`` at gate
|
||||
time and puts it on ``event.context.model`` so the server evaluates
|
||||
against it (preferred over the engine's resolved model). If this
|
||||
regresses, a terminal ``/model`` downgrade never reaches the gate and
|
||||
the session stays wrongly blocked.
|
||||
"""
|
||||
_DenyHttpxClient.captured = {}
|
||||
home = codex_home_for_bridge_dir(bridge_dir)
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
# The current /model selection, as codex persists it to config.toml.
|
||||
(home / "config.toml").write_text('model = "gpt-5.4"\n')
|
||||
write_policy_hook_config(
|
||||
bridge_dir,
|
||||
ap_server_url="http://127.0.0.1:8787",
|
||||
ap_auth_headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", _DenyHttpxClient)
|
||||
|
||||
exit_code = _run_hook(
|
||||
bridge_dir,
|
||||
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}},
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
sent = _DenyHttpxClient.captured["json"]
|
||||
# The live model is carried in the request so the gate sees gpt-5.4.
|
||||
assert sent["event"]["context"]["model"] == "gpt-5.4"
|
||||
# The harness is stamped so the gate can tailor messages to codex.
|
||||
assert sent["event"]["context"]["harness"] == "codex-native"
|
||||
|
||||
|
||||
def test_pre_tool_use_stamps_harness_without_config_model(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The harness is stamped even when config.toml has no model.
|
||||
|
||||
The harness drives the deny message's switch-instruction wording, which
|
||||
must be correct regardless of whether the model is determinable — so it
|
||||
is stamped unconditionally (unlike the model, which is only stamped when
|
||||
config.toml provides one).
|
||||
"""
|
||||
_DenyHttpxClient.captured = {}
|
||||
# No config.toml written → read_codex_config_model returns None.
|
||||
write_policy_hook_config(
|
||||
bridge_dir,
|
||||
ap_server_url="http://127.0.0.1:8787",
|
||||
ap_auth_headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", _DenyHttpxClient)
|
||||
|
||||
exit_code = _run_hook(
|
||||
bridge_dir,
|
||||
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}},
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
sent = _DenyHttpxClient.captured["json"]
|
||||
assert sent["event"]["context"]["harness"] == "codex-native"
|
||||
# Model absent (no config) — stays unstamped, the gate falls back.
|
||||
assert "model" not in sent["event"]["context"]
|
||||
|
||||
|
||||
def test_missing_bridge_state_is_fail_open(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""
|
||||
With no bridge state, the hook emits nothing and never POSTs.
|
||||
|
||||
A bridge dir that has not been initialized must not crash codex or
|
||||
block tools — the hook returns 0 with no verdict. ``_RaisesIfCalled``
|
||||
asserts the network was never reached.
|
||||
"""
|
||||
monkeypatch.setattr("omnigent.codex_native_bridge._BRIDGE_ROOT", tmp_path / "codex-native")
|
||||
empty_dir = prepare_bridge_dir("bridge_no_state")
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", _RaisesIfCalled)
|
||||
|
||||
exit_code = _run_hook(
|
||||
empty_dir,
|
||||
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}},
|
||||
monkeypatch,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
# No verdict emitted → codex applies its own default (fail-open).
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_missing_policy_config_is_fail_open(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""
|
||||
With bridge state but no policy_hook config, the hook never POSTs.
|
||||
|
||||
The session has state but no Omnigent coordinates were written (e.g. a
|
||||
local run with no Omnigent server), so there is nothing to enforce against.
|
||||
The hook returns 0 with no output and does not touch the network.
|
||||
"""
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", _RaisesIfCalled)
|
||||
|
||||
exit_code = _run_hook(
|
||||
bridge_dir,
|
||||
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}},
|
||||
monkeypatch,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["connect_error", "non_2xx", "empty_body", "malformed_json"])
|
||||
def test_pre_tool_use_fails_closed_when_verdict_unavailable(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""
|
||||
A governed PreToolUse call denies when no usable verdict is returned.
|
||||
|
||||
For native harnesses this hook is the sole TOOL_CALL enforcement point,
|
||||
so a server outage / non-2xx / empty / malformed response must fail
|
||||
CLOSED (deny) instead of "no opinion" — the bypass reported in #536.
|
||||
"""
|
||||
write_policy_hook_config(bridge_dir, ap_server_url="http://127.0.0.1:8787", ap_auth_headers={})
|
||||
monkeypatch.setattr(native_policy_hook, "_EVALUATE_POLICY_RETRY_BUDGET_S", 0.0)
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", make_failing_client(mode))
|
||||
|
||||
exit_code = _run_hook(
|
||||
bridge_dir,
|
||||
{
|
||||
"hook_event_name": "PreToolUse",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "rm -rf /"},
|
||||
},
|
||||
monkeypatch,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
result = json.loads(captured.out)
|
||||
assert result["hookSpecificOutput"]["permissionDecision"] == "deny", result
|
||||
assert result["hookSpecificOutput"]["permissionDecisionReason"]
|
||||
|
||||
|
||||
def test_user_prompt_submit_fails_closed_on_error(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""
|
||||
A governed UserPromptSubmit blocks when no usable verdict is returned.
|
||||
|
||||
The request gate is the sole pre-turn enforcement point for native
|
||||
sessions — a server outage must not let a blocked request proceed.
|
||||
"""
|
||||
write_policy_hook_config(bridge_dir, ap_server_url="http://127.0.0.1:8787", ap_auth_headers={})
|
||||
monkeypatch.setattr(native_policy_hook, "_EVALUATE_POLICY_RETRY_BUDGET_S", 0.0)
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", make_failing_client("connect_error"))
|
||||
|
||||
payload: dict[str, object] = {"hook_event_name": "UserPromptSubmit", "prompt": "hello"}
|
||||
exit_code = _run_hook(bridge_dir, payload, monkeypatch)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
result = json.loads(captured.out)
|
||||
assert result["decision"] == "block"
|
||||
assert result["reason"]
|
||||
|
||||
|
||||
def test_post_tool_use_fails_open_on_error(
|
||||
bridge_dir: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""
|
||||
PostToolUse fails OPEN on a transport error — the tool already ran.
|
||||
|
||||
Mirroring the runner-side ``FAIL_CLOSED_PHASES``.
|
||||
"""
|
||||
write_policy_hook_config(bridge_dir, ap_server_url="http://127.0.0.1:8787", ap_auth_headers={})
|
||||
monkeypatch.setattr(native_policy_hook, "_EVALUATE_POLICY_RETRY_BUDGET_S", 0.0)
|
||||
monkeypatch.setattr(native_policy_hook.httpx, "Client", make_failing_client("connect_error"))
|
||||
payload: dict[str, object] = {
|
||||
"hook_event_name": "PostToolUse",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": "ls"},
|
||||
"tool_output": "ok",
|
||||
}
|
||||
|
||||
exit_code = _run_hook(bridge_dir, payload, monkeypatch)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert captured.out == ""
|
||||
Reference in New Issue
Block a user