Skip to content
This repository was archived by the owner on Jun 25, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ Real-time guardrails for coding agents. The CLI wires hooks into the agent's lif
│ ├── paths.py # XDG path helpers (bundle, launcher, config.env, Claude settings dir)
│ ├── utils.py # atomic_write, platform predicates, fetch_endpoint_id, detect_executable
│ ├── hooks/
│ │ └── hooks.py # Handler ABC + `ai-guard hook` command (in-process dispatch to a Handler)
│ │ ├── hooks.py # Handler ABC + `ai-guard hook` command (in-process dispatch to a Handler)
│ │ └── common.py # Agent-neutral helpers shared by handlers: branded blocked-tool
│ │ # payload, investigate-link builder, common span tags
│ ├── claude/
│ │ ├── handler.py # ClaudeHandler(Handler): handle_hook dispatch, hook methods,
│ │ │ # transcript → AI Guard messages, blocked-tool payloads
│ │ └── installer.py # ClaudeInstaller(AgentInstaller): merge/remove the hook block in settings.json
│ ├── codex/
│ │ ├── handler.py # CodexHandler(Handler): hook dispatch + rollout → AI Guard messages
│ │ ├── translate.py # OpenAI Responses-API rollout items → AI Guard messages
│ │ └── installer.py # CodexInstaller(AgentInstaller): merge/remove the hook block in hooks.json
│ └── installer/
│ ├── installer.py # `install` / `uninstall` Click commands + tiered field collection
│ ├── agent.py # AgentInstaller ABC + Field / Tier
Expand All @@ -48,6 +54,11 @@ Real-time guardrails for coding agents. The CLI wires hooks into the agent's lif
│ ├── Dockerfile # Multi-stage: PyInstaller binary + Claude Code runtime
│ ├── claude-settings.json # Wires every Claude Code hook to `ai-guard hook claude <Event>`
│ └── entrypoint.sh # Container entrypoint
├── docker/codex/
│ ├── Dockerfile # Multi-stage: PyInstaller binary + Codex CLI runtime
│ ├── codex-hooks.json # Wires every Codex hook to `ai-guard hook codex <Event>`
│ ├── config.toml # Enables hooks + bypasses hook-trust for the headless container
│ └── entrypoint.sh # Container entrypoint
├── scripts/
│ ├── install.sh # Bootstrap installer (download, verify, extract, hand off to `ai-guard install`)
│ └── build.sh # Build the onedir bundle locally + tar it into the release shape
Expand Down Expand Up @@ -131,6 +142,24 @@ Method names map 1:1 to the event names in `docker/claude/claude-settings.json`:
| `PostToolUseFailure` | `_post_tool_use_failure` | Same shape as PostToolUse but uses `event.error` as the tool content. |
| `UserPromptExpansion` | `_user_prompt_expansion` | Fires before a slash command / skill name expands into a prompt. Resolves the command/skill definition (`_fetch_command_expansion` → the `commands/<name>.md` file or the skill's `SKILL.md`) and injects it as a modelled `command`/`skill` tool call + result, so AI Guard sees what the expansion will inject. On abort returns `{"decision":"block","reason":…}` (the command is erased from context). |

## Currently wired hooks (Codex)

Codex CLI's hook contract mirrors Claude Code's (same `permissionDecision:"deny"` / `decision:"block"` /
`reason` payloads), so `CodexHandler` reuses the shared shaping in `hooks/common.py`. Differences from Claude:
the conversation is rebuilt from Codex's **rollout JSONL** (`$CODEX_HOME/sessions/.../rollout-*.jsonl`, OpenAI
Responses-API items — see `codex/translate.py`); there is no skill tool, no slash-command expansion, and no
subagent transcript file. Codex has no `SessionEnd` or `PostToolUseFailure` — `Stop` and a single `PostToolUse`
(which fires on success *and* non-zero exit) cover them. Wired events: `SessionStart`, `SubagentStart`,
`SubagentStop`, `Stop` (spans), `UserPromptSubmit` (deny), `PreToolUse` (deny), `PostToolUse` (block). Hooks need
Codex `>= 0.117.0` and the user must **trust** the hook inside Codex on first run (the installer cannot pre-trust it).

`UserPromptSubmit` is Codex's analog of Claude's `UserPromptExpansion` (Codex has no event by that name): it fires
before every prompt reaches the model with a `prompt` field, and `CodexHandler._user_prompt_submit` evaluates that
prompt plus, for explicit `$skill` / `/prompts:` references, the resolved definition (injected as a modelled tool
call + result). **Skills caveat:** Codex loads an *auto-activated* skill's `SKILL.md` with no tool call and no hook
event, so — unlike Claude's `Skill`-tool gate at `PreToolUse` — only explicitly-invoked (`$name`) skills are screened
today; auto-activated skill instructions are not.

## Adding a new Claude Code hook

1. Add a method `_<snake_case_hook_name>` on `ClaudeHandler`. Decorate with `@tracer.wrap(name=…, resource=AIGuardConstants.HOOK_RESOURCE)` if you want a span. Signature: `def _foo(self, event: dict[str, Any]) -> dict[str, Any] | None`.
Expand Down Expand Up @@ -196,13 +225,14 @@ CI (`.github/workflows/test.yml`) runs the source suite and a binary smoke test

## Running locally with Docker Compose

The `claude` container runs Claude Code with the ai-guard hooks pre-wired into `~/.claude/settings.json`; they evaluate in-process, so there's nothing else to start. A `mitmproxy` sidecar is an optional debugging HTTPS proxy (`HTTPS_PROXY`) that lets you watch Claude's calls to `api.anthropic.com` and ai-guard's AI Guard `/api/v2/ai-guard/evaluate` requests — it is not part of ai-guard.
The `claude` and `codex` containers run their respective agents with the ai-guard hooks pre-wired (Claude into `~/.claude/settings.json`, Codex into `~/.codex/hooks.json`); they evaluate in-process, so there's nothing else to start. The Codex container also ships a `config.toml` that bypasses Codex's hook-trust gate, which is impossible to satisfy in a headless container (never do this on a real workstation). A `mitmproxy` sidecar is an optional debugging HTTPS proxy (`HTTPS_PROXY`) that lets you watch the agent's calls to `api.anthropic.com` / `api.openai.com` and ai-guard's AI Guard `/api/v2/ai-guard/evaluate` requests — it is not part of ai-guard.

```bash
cp .env.example .env # set DD_API_KEY, DD_APP_KEY (the hooks read these from the container env)
docker compose build
docker compose up -d
docker exec -ti ai-guard-coding-agents-claude-1 claude
docker exec -ti ai-guard-coding-agents-codex-1 codex
```

mitmproxy UI: `http://localhost:8081` (password: `ai_guard`). The hook's log lands in `docker/.ai_guard/ai-guard.log` — the container's `$XDG_STATE_HOME/ai-guard` is bind-mounted there.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AI Guard for Coding Agents

![Claude Code](https://img.shields.io/badge/Claude_Code-ready-success?style=flat-square&logo=anthropic&logoColor=white)
![Codex CLI](https://img.shields.io/badge/Codex_CLI-roadmap-lightgrey?style=flat-square&logo=https%3A%2F%2Fraw.githubusercontent.com%2FDataDog%2Fai-guard-coding-agents%2Fmain%2Fdocs%2Fimages%2Fopenai.svg)
![Codex CLI](https://img.shields.io/badge/Codex_CLI-ready-success?style=flat-square&logo=https%3A%2F%2Fraw.githubusercontent.com%2FDataDog%2Fai-guard-coding-agents%2Fmain%2Fdocs%2Fimages%2Fopenai.svg)
![Cursor](https://img.shields.io/badge/Cursor-roadmap-lightgrey?style=flat-square&logo=cursor&logoColor=white)

> [!IMPORTANT]
Expand Down Expand Up @@ -107,6 +107,7 @@ Every path the installer creates or modifies is listed below — nothing else on
| `${XDG_CONFIG_HOME:-~/.config}/ai-guard/config.env` | Persisted configuration values (mode `0600`). | `*` |
| OS keychain (`ai-guard` service) | `DD_API_KEY` / `DD_APP_KEY` via keychain when available. | `*` |
| `${CLAUDE_CONFIG_DIR:-~/.claude}/settings.json` | Hook block under `hooks.*`. | `Claude Code` |
| `${CODEX_HOME:-~/.codex}/hooks.json` | Hook block under `hooks.*`. | `Codex CLI` |

Paths follow the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/) and honour
`$XDG_CONFIG_HOME` / `$XDG_STATE_HOME` if set.
Expand All @@ -117,6 +118,12 @@ application keys are credentials, so they are stored in the OS keychain (macOS K
[`keyring`](https://pypi.org/project/keyring/) library rather than in plaintext `config.env`. On a host with no usable
keychain backend (e.g. headless Linux without gnome-keyring) the installer falls back to keeping them in `config.env`.

> [!NOTE]
> **Codex CLI** requires hooks (`PreToolUse` / `PostToolUse`) introduced in Codex `0.117.0`, so install against a
> recent Codex. Codex also runs a command hook only after you **trust** it: the first time Codex loads the hook
> block ai-guard wrote to `~/.codex/hooks.json`, it prompts you to review and approve it (and re-prompts if the hook
> ever changes). Approve it once to activate the guardrail — the installer cannot pre-trust the hook for you.

### Uninstall

```sh
Expand Down
7 changes: 7 additions & 0 deletions ai-guard.spec
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@ a = Analysis(
# The Claude handler is imported lazily by the hook command, so the
# static analyser can't see it.
"aiguard.claude.handler",
"aiguard.codex",
"aiguard.codex.installer",
# The Codex handler/translator are imported lazily by the hook command,
# so the static analyser can't see them.
"aiguard.codex.handler",
"aiguard.codex.translate",
"aiguard.hooks",
"aiguard.hooks.hooks",
"aiguard.hooks.common",
"aiguard.installer",
"aiguard.installer.agent",
"aiguard.installer.installer",
Expand Down
31 changes: 29 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
services:
mitmproxy:
image: mitmproxy/mitmproxy:12.2.3
command: mitmweb --web-host 0.0.0.0 --set web_password=ai_guard --set "view_filter=~u ai-guard | ~u api.anthropic.com"
command: mitmweb --web-host 0.0.0.0 --set web_password=ai_guard --set "view_filter=~u ai-guard | ~u api.anthropic.com | ~u api.openai.com"
ports:
- "8080:8080"
- "8081:8081"
Expand All @@ -29,6 +29,33 @@ services:
- mitmproxy-data:/mitmproxy-certs:ro
- ./docker/.ai_guard:/home/claude/.local/state/ai-guard

codex:
build:
dockerfile: docker/codex/Dockerfile
env_file:
- .env
environment:
# The HTTP client read the standard proxy env vars, but not every client honoured them historically
# (openai/codex#4242), so set the full upper/lower-case set to maximise coverage.
HTTP_PROXY: http://mitmproxy:8080
HTTPS_PROXY: http://mitmproxy:8080
ALL_PROXY: http://mitmproxy:8080
http_proxy: http://mitmproxy:8080
https_proxy: http://mitmproxy:8080
all_proxy: http://mitmproxy:8080
NO_PROXY: localhost,127.0.0.1
no_proxy: localhost,127.0.0.1
CODEX_CA_CERTIFICATE: /tmp/ca-bundle.pem
depends_on:
- mitmproxy
security_opt:
- apparmor:unconfined
volumes:
- codex-home:/home/codex
- mitmproxy-data:/mitmproxy-certs:ro
- ./docker/.ai_guard:/home/codex/.local/state/ai-guard

volumes:
mitmproxy-data:
claude-home:
claude-home:
codex-home:
104 changes: 104 additions & 0 deletions docker/codex/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Unless explicitly stated otherwise all files in this repository are licensed
# under the Apache 2.0 License.
#
# This product includes software developed at Datadog (https://www.datadoghq.com/).
# Copyright 2026-Present Datadog, Inc.

FROM debian:12-slim AS builder

ENV DEBIAN_FRONTEND=noninteractive \
DD_FAST_BUILD=1

# ── System deps ───────────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
curl=7.88.1-10+deb12u14 \
ca-certificates=20230311+deb12u1 \
git=1:2.39.5-0+deb12u3 \
python3=3.11.2-1+b1 \
python3-dev=3.11.2-1+b1 \
python3-pip=23.0.1+dfsg-1 \
build-essential=12.9 \
autoconf=2.71-3 \
automake=1:1.16.5-1.3 \
libtool=2.4.7-7~deb12u1 \
bubblewrap=0.8.0-2+deb12u1 \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /usr/local/bin/uv

# ── Rust ───────────────────────────────────────────────────────────────
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

RUN git config --global http.version HTTP/1.1

WORKDIR /build

# ── Dependencies (cached unless pyproject.toml/uv.lock change) ────────────────
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --extra build --no-install-project

# ── Binary compilation (rebuilt when source changes) ──────────────────────────
COPY ai-guard.spec ./
COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --extra build && uv run pyinstaller ai-guard.spec


FROM debian:12-slim

ENV DEBIAN_FRONTEND=noninteractive

# ── System and demo deps ───────────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
curl=7.88.1-10+deb12u14 \
ca-certificates=20230311+deb12u1 \
dumb-init=1.2.5-2 \
git=1:2.39.5-0+deb12u3 \
openssh-client=1:9.2p1-2+deb12u9 \
&& rm -rf /var/lib/apt/lists/*

# ── Node.js 22 (required by the Codex CLI npm package) ───────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs=22.22.2-1nodesource1 \
&& rm -rf /var/lib/apt/lists/*

# ── Non-root user ─────────────────────────────────────────────────────────────
RUN groupadd -g 1000 codex && useradd -l -m -s /bin/bash -u 1000 -g 1000 codex

# ── Entrypoint ────────────────────────────────────────────────────────────────
COPY docker/codex/entrypoint.sh /entrypoint.sh

# ── Codex hooks + config (hooks run ai-guard in-process) ──────────────────────
COPY docker/codex/codex-hooks.json /home/codex/.codex/hooks.json
COPY docker/codex/config.toml /home/codex/.codex/config.toml

# ── Install ai-guard ─────────────────────────────────────────────────────────
COPY --from=builder /build/dist/ai-guard /home/codex/.local/share/ai-guard

# ── Fix permissions ───────────────────────────────────────────────────────────
RUN mkdir -p /home/codex/.local/bin \
&& ln -s /home/codex/.local/share/ai-guard/ai-guard /home/codex/.local/bin/ai-guard \
&& chown -R codex:codex /home/codex \
&& chmod 755 /entrypoint.sh

USER codex
ENV HOME=/home/codex \
NPM_CONFIG_PREFIX=/home/codex/.npm-global \
SSL_CERT_FILE=/tmp/ca-bundle.pem \
REQUESTS_CA_BUNDLE=/tmp/ca-bundle.pem \
GIT_SSL_CAINFO=/tmp/ca-bundle.pem \
CERT=/mitmproxy-certs/mitmproxy-ca-cert.pem

ENV PATH="/home/codex/.local/bin:/home/codex/.npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# ── Codex CLI ─────────────────────────────────────────────────────────────────
# Hooks (PreToolUse / PostToolUse) require Codex >= 0.117.0.
RUN npm install -g @openai/codex@0.140.0

# ── Working directory for interactive sessions ────────────────────────────────
WORKDIR /home/codex

ENTRYPOINT ["dumb-init", "--", "/entrypoint.sh"]
CMD ["sleep", "infinity"]
74 changes: 74 additions & 0 deletions docker/codex/codex-hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "ai-guard hook codex SessionStart"
}
]
}
],
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "ai-guard hook codex SubagentStart"
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "ai-guard hook codex SubagentStop"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "ai-guard hook codex Stop"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "ai-guard hook codex UserPromptSubmit"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "ai-guard hook codex PreToolUse"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "ai-guard hook codex PostToolUse"
}
]
}
]
}
}
14 changes: 14 additions & 0 deletions docker/codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# AI Guard reference Codex config for the demo container.
#
# Codex only runs a non-managed command hook after the user reviews and trusts
# it (it records the hook against a hash and re-prompts when it changes). That
# interactive trust step is impossible in a headless container, so we both
# enable the hooks feature and bypass the trust gate here. Do NOT copy
# bypass_hook_trust into a real workstation config — on a real machine, run
# `ai-guard install` and approve the hook inside Codex on first run instead.

[features]
hooks = true

[hooks]
bypass_hook_trust = true
39 changes: 39 additions & 0 deletions docker/codex/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/bash
# Unless explicitly stated otherwise all files in this repository are licensed
# under the Apache 2.0 License.
#
# This product includes software developed at Datadog (https://www.datadoghq.com/).
# Copyright 2026-Present Datadog, Inc.

set -euo pipefail

# Wait up to 60s for $1 to exist as a regular file. On a fresh `docker compose
# up`, the mitmproxy-data volume is empty and mitmproxy needs a moment to
# generate the CA cert; short-form `depends_on` does not wait for that.
wait_for_file() {
local path="$1" i
for i in $(seq 600); do
if [[ -f "$path" ]]; then
return 0
fi
sleep 0.1
done
return 1
}

# Trust the mitmproxy CA so HTTPS_PROXY traffic verifies (used only for the
# debugging proxy; ai-guard's hooks run in-process and need no service).
if [[ -n "${CERT:-}" ]]; then
if wait_for_file "$CERT"; then
cat /etc/ssl/certs/ca-certificates.crt "$CERT" > /tmp/ca-bundle.pem
else
echo "entrypoint: timed out waiting for $CERT; HTTPS_PROXY traffic will fail TLS verification" >&2
exit 1
fi
else
cp /etc/ssl/certs/ca-certificates.crt /tmp/ca-bundle.pem
fi

# AI Guard is wired into Codex via hooks (~/.codex/hooks.json); they invoke
# `ai-guard hook codex ...` in-process, so there is nothing to start here.
exec "$@"
Loading
Loading