Skip to content

Add signed Codex PreToolUse adapter - #162

Draft
Lrd0036 wants to merge 4 commits into
GenAI-Security-Project:integrationfrom
Lrd0036:refimpl/codex-pretool
Draft

Lrd0036 wants to merge 4 commits into
GenAI-Security-Project:integrationfrom
Lrd0036:refimpl/codex-pretool

Conversation

@Lrd0036

@Lrd0036 Lrd0036 commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

What changed

An ACS Guardian can now allow, deny or rewrite a Codex shell command or subagent spawn before it runs. Codex runs acs-codex-hook as its PreToolUse hook, which sends the call as a signed steps/toolCallRequest and returns the decision as Codex's own hook output.

The adapter is part of #22's acs package, at adapters/src/acs/integrations/codex/, and uses its signing, handshake and posture code. This push replaces the earlier branch, which was built on #22's old flat layout. It is three commits on integration:

Commit What it does
4ce2e59 Adds the adapter, the acs-codex-hook command, its tests, docs and live check, and registers it in the conformance runner, CI and the wheel check
27ee37f Follows #22's fail-closed defaults: ACS_DEFAULT_DENY and ACS_STARTUP_POSTURE deny unless set to opt out
0771f46 Gates subagent spawns, and makes a hook that cannot run block the call

What the last commit fixes

Live testing on Codex CLI 0.142.5 found two ways the earlier example configuration failed open:

  • A broken hook let every call through. Codex runs the call after any PreToolUse failure except exit 2. A missing binary, a crash or invalid output all count. The example now wraps the hook in sh -c '<hook> || { …; exit 2; }', which blocks instead. The adapter exits 0 on every path, so the fallback fires only when the hook cannot run.
  • Subagent spawns were ungated. Subagents are on by default, and the ^Bash$ matcher missed them. continue: false from SubagentStart does not stop a spawn, but a PreToolUse deny on the spawn_agent tool does. The matcher is now ^(Bash|spawn_agent)$, and the Guardian sees a spawn as a tool call named spawn_agent.

What happened to the earlier repair commits

Which issue does this implement

Closes #170

Part of #89

  • This is an editorial correction (typo, grammar, link, formatting) with no change in
    meaning, so it needs no issue

Base branch

  • integration, because this touches the specification, schemas, a reference
    implementation, an adapter, tests, or CI
  • main, because every changed path is on the documentation lane allowlist in
    CONTRIBUTING.md

Type of change

  • Specification change (schema, hooks, events, AgBOM)
  • Reference implementation or adapter
  • Documentation
  • Tooling or CI
  • Governance (licensing, security policy, contributor docs): one CONTRIBUTING.md line adds codex to the adapter test command

I tested this

  • I synced my branch with the base branch before opening this
  • uv run pytest -v passes on my machine: 1205 passed, 1 skipped
  • uv run mkdocs build --strict passes on my machine

Also run locally on 0771f46:

  • run_conformance.py claude codex cursor nat: 687 passed, 3 expected skips
  • lint-imports: 2 contracts kept
  • check_wheel.py on the built wheel, Python 3.11 and 3.12: no failed checks
  • live_verify.py against the real Codex CLI 0.142.5, under the example's matcher and wrapper: all 7 cases passed
Live case Result
Shell ALLOW, DENY, MODIFY The original command ran, nothing ran, or the rewritten command ran
Spawn ALLOW The subagent ran, and the Guardian also saw its Bash call
Spawn DENY The subagent never started
Spawn MODIFY The subagent started with the Guardian's rewritten task
Hook cannot run The command did not run

The live check uses a scripted model on localhost, so it tests enforcement, not model judgment. It passes hook trust with Codex's one-run bypass flag.

Checklist

  • Commits are signed off with git commit -s (required by the DCO)
  • Prose follows STYLE.md
  • No secrets, tokens, or internal URLs in the diff. The localhost URLs and dummy fixture credentials are intentional.

Security

  • This change has no security impact

This adapter decides whether Codex tool calls run. It denies on missing signing material, invalid input, a bad or mis-bound signature, a refusal code, and any MODIFY it cannot apply in full. ASK and DEFER become audited denies, because Codex does not support ask and runs the call anyway.

Codex still fails open in places this adapter cannot fix. The README lists them:

  • A hook that times out, or that Codex disables or does not trust, cannot deny anything.
  • send_input and resume_agent are not matched. Either can give a subagent new work.
  • A subagent's calls arrive under the parent's session ID. The adapter does not yet record Codex's agent_id, so the Guardian cannot tell them apart.
  • Hosted tools and input written to a running process skip PreToolUse.

@github-project-automation github-project-automation Bot moved this to Needs triage in ACS Project Tracker Sep 19, 2026
@acs-board-reconciler acs-board-reconciler Bot moved this from Needs triage to In progress in ACS Project Tracker Sep 19, 2026
@President

President commented Sep 20, 2026 •

Copy link
Copy Markdown

Thanks for the Codex slice. I found one interoperability issue in ensure_session_handshake.

The current code reads response.get("result") and then looks for a nested payload field:

server_hello = (response.get("result") or {}).get("payload")

ACS v0.1 returns ServerHello directly as result; there is no payload field inside it. The code should read the result itself:

server_hello = response.get("result")

The TypeScript Guardian uses this standard handshake shape too. Could this be corrected and covered by an interoperability test against the TypeScript Guardian?

@Lrd0036

Lrd0036 commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Yes! Thanks for catching this. d077978 fixes both fresh and cached reads, both Python response producers, and the affected fixtures. It adds 13 regressions while preserving signature verification, request binding, and whole-envelope caching. The repaired local adapter gate passed 304 tests with three expected live-product skips; the PR description now separates those results from the original commit's results.

The TypeScript portion is still open. I ran the TypeScript Guardian, and its direct ServerHello validates against the handshake schema... but the response is unsigned. Its README documents the authentication/signing bit. With a nonempty key and signature verification enabled, Codex rejects that peer. This is wire-shape compatibility and rejection coverage, not a successful signed integration, and the diagnostic is not committed in this repair.

Would a committed test that launches the TypeScript Guardian, validates its direct-result shape, and asserts rejection by the signed Python/Codex path meet the coverage expected for this repair, with positive signed end-to-end interoperability tracked separately pending TypeScript signing support?

The new head's Deploy Pages and adapter-tests runs both show action_required with no jobs reported, so public CI has not validated the repaired tree yet.

Follow-up: I’ve committed the TypeScript coverage in b2d4b49. It launches the actual Guardian, validates the direct ServerHello shape, and checks that the shared helper and default-deny Codex adapter reject its unsigned response. Signature verification remains enabled.
All three tests pass locally and now have a dedicated CI job. The headless adapter gate passes 304 checks with three expected skips. Successful signed interoperability still requires TypeScript signing support.
The current adapter-tests and Deploy Pages runs need maintainer approval.

@rocklambros

Copy link
Copy Markdown
Collaborator

@Lrd0036 you had the ServerHello fix up the same day @President found it, with 13 regression tests behind it, and b2d4b49 put a TypeScript interop job on top. The description also keeps local results and pending CI results in separate sections, which makes it easy to see what has run where.

I've got one housekeeping ask. integration is this repo's default branch, so GitHub closes any issue a PR names with Closes the moment that PR merges. Writing Closes #89 would shut #89 while result and lifecycle coverage are still open. I filed #170 for the tool-call slice you built, accepted it, and put it under #89 as a sub-issue.

Could you add Closes #170 on its own line under "Which issue does this implement"? Keep Refs #89 where it is. The ... menu at the top right of the description has an Edit option, or gh pr edit 162 --body-file <file> does it from a terminal.

@Lrd0036

Lrd0036 commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Done, thanks!

@Lrd0036

Lrd0036 commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Oops. Both authoritative and conformance failures come from the same citation guard in adapters/test_acs_core_conformance.py: an upstream diagram moved the specification’s line numbers.
The fix anchors citations to the named requirements within ACS-Core. Prod code and existing security assertions are unchanged. Looks like Codex and TypeScript checks already passed in CI.
Local validation with the fix applied:

  • Exact failing CI snapshot: 69 passed.
  • Combined PR/base tree: 307 passed, 3 expected skips.
  • Repository checks and strict docs build passed.
  • Fresh ServerHello, Codex, and TypeScript regression checks passed with zero skips.
    No regressions against the published PR head. The fix is still local; GitHub CI needs to rerun after I push it.

@Lrd0036

Lrd0036 commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed & pushed 49f5232. Citation guard now follows ACS-core reqs. instead of the fixed line numbers. Also addressed that workflow cleanup flagged by #179.
Both authoritative and conformance now pass. The full adapter run finished w/ 307 passed and 3 expected skips; Codex, TypeScript, DCO, and docs checks passed too. CI results. Production adapter code & existing assertations remain unchanged. Thanks!

rocklambros added a commit that referenced this pull request Sep 22, 2026
…179)

CodeQL default setup was turned on for this repository on 2026-09-21 and covers the Actions language. This adds zizmor, which the [OWASP GitHub Actions Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/GitHub_Actions_Security_Cheat_Sheet.html) recommends beside CodeQL for defense in depth. zizmor reads each workflow's logic and checks what CodeQL does not: hash pins no tag points to, version comments that disagree with their pin, App tokens broader than the job needs, and credentials a checkout leaves in `.git/config`.

Run against `integration` before this change with online audits on, zizmor 1.30.1 reported 15 findings. This clears 13. The other two are `github-app` findings on `board-reconcile.yml`, whose App token takes the installation's full grant instead of naming its repository and permissions. Token scopes are not changing right now, so those two stay open in code scanning, where they record the deferred decision instead of disappearing behind a suppression.

- **`pr-intake.yml`, 1 high.** zizmor flags every `pull_request_target`. This one checks out no code and reads the pull request body as data through an environment variable, which the workflow's header already explains. It carries an inline `zizmor: ignore` that points at that explanation.
- **Eleven checkouts across nine workflows, 11 low.** Each left the job's token in `.git/config`. None of those jobs pushes with git. They call `gh` with `GH_TOKEN` or touch no network at all, and `sync_version.yml` hands its push to `create-pull-request`, which configures its own token. All eleven now set `persist-credentials: false`. The checkout in `validate-owasp-metadata.yaml` also gains the `# v7.0.1` comment every other pin carries.
- **`reference-implementation.yml`, 1 medium.** The setup-bun pin `735343b` is v2.0.2, and its comment said v2.0.1. Only the comment changes. Dependabot's #83 already treats it as 2.0.2.

The new workflow runs zizmor from a `zizmor` dependency group in `uv.lock`, pinned with hashes. No new action enters the Actions allowlist, and Dependabot's uv ecosystem moves zizmor under the existing 7-day cooldown. The group is not a default group, so `uv sync --locked` in the deploy and test jobs never installs it. A default sync against this lockfile would uninstall it.

It runs on pull requests and pushes that touch `.github/`, an action definition, `pyproject.toml`, or `uv.lock`, and weekly, because the online audits compare pins against advisories published after the pin. Findings upload as SARIF under the category `zizmor`, and the job stays green on findings so code scanning tracks them and a ruleset can gate on them later. A tool or upload failure still fails the job. A pull request from a fork cannot upload with its read-only token, so it gets annotations instead. `upload-sarif` is pinned at v4.38.0 because v4.38.1 was three days old, inside the cooldown this repository applies to its dependencies.

Open pull requests #162 and #169 add workflows that zizmor will flag once they land, for unset `persist-credentials` and the same `# v2.0.1` setup-bun comment.

Verified locally: zizmor 1.30.1 with online audits reports only the two deferred `github-app` findings, with 1 ignored and 26 low-confidence findings suppressed by the default persona. The workflow's own command produces valid SARIF 2.1.0. `actionlint` is clean. `uv lock --check` passes under uv 0.9.9, the version CI pins, and the lockfile diff adds only zizmor. `uv run pytest` gives 278 passed, 1 skipped.

Signed-off-by: Rock Lambros <rock@rockcyber.com>
@bIackr0se

Copy link
Copy Markdown

I reran adapters/codex/live_verify.py at 49f5232 with Codex CLI 0.156.1 on macOS (2026-09-24). The localhost scripted Responses fixture emitted one fixed exec_command function call per case, with no model inference or external model request. ALLOW created only original.marker; DENY created neither marker; MODIFY created only replacement.marker. Each case captured handshake/hello followed by one independently schema- and signature-validated steps/toolCallRequest, and Codex exited 0. This adds local live evidence for the documented exec_command/Bash path. The run used inline hook configuration and the one-shot hook-trust bypass; it does not test project-file hook discovery or interactive trust, tool results or lifecycle events, hosted tools, or signed TypeScript Guardian interoperability.

@Lrd0036

Lrd0036 commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

I reran adapters/codex/live_verify.py at 49f5232 with Codex CLI 0.156.1 on macOS (2026-09-24). The localhost scripted Responses fixture emitted one fixed exec_command function call per case, with no model inference or external model request. ALLOW created only original.marker; DENY created neither marker; MODIFY created only replacement.marker. Each case captured handshake/hello followed by one independently schema- and signature-validated steps/toolCallRequest, and Codex exited 0. This adds local live evidence for the documented exec_command/Bash path. The run used inline hook configuration and the one-shot hook-trust bypass; it does not test project-file hook discovery or interactive trust, tool results or lifecycle events, hosted tools, or signed TypeScript Guardian interoperability.

Thanks for the rerun + identifying the project-file and trust gap. I followed up locally on 49f5232 with Codex CLI 0.156.1. In a disposavle project, Codex discovered the hook from .codex/hooks.json... I reviewed and trusted it. Separate ALLOW, DENY, and MODIFY runs then worked without the inline hook configuration or --dangerously-bypass-hook-trust: only the original command ran for ALLOW, neither ran for DENY, and only the replacement ran for MODIFY. Each trusted run sent a handshake and a schema- and signature-validated tool-call request.

Also ran DENY before trusting the hook. Codex sent no Guardian request and ran the original command. That gives us a live example of the missing-hook ambiguity @rocklambros and I discussed in #156: a Guardian record alone cannot tell us what happened when the Guardian never got the info.

No PR files changed. But this is evidence for project-file discovery and saved trust on the exec_command path.

@Lrd0036
Lrd0036 requested a review from artmaro as a code owner September 24, 2026 13:41
@rocklambros rocklambros changed the title Add signed Codex PreToolUse adapter Add signed Codex PreToolUse adapter . Oct 5, 2026
@rocklambros rocklambros changed the title Add signed Codex PreToolUse adapter . Add signed Codex PreToolUse adapter Oct 5, 2026
Port the Codex adapter from GenAI-Security-Project#162 onto the package layout GenAI-Security-Project#22 lands, as
acs.integrations.codex with an acs-codex-hook command. The adapter now
goes through acs.client for the handshake, signing, the round trip and
the fail posture, so it follows the same §4.1, §6.4 and §10 rules as the
other hosts, the key_id and 32-byte secret included.

What stays Codex's own: an event without its session, turn or tool-call
id, or with a non-object input, is denied with no traffic. ASK and DEFER
deny, since Codex fails a PreToolUse ask and runs the call. A MODIFY is
applied only as parameter_overrides, and only as a command override on
Bash and apply_patch. A negotiated decision timeout over 10 s denies
before the step is sent.

Two behaviors change from GenAI-Security-Project#162. An ALLOW prints nothing, so Codex's own
approval policy and sandbox still decide, as GenAI-Security-Project#22 does for Claude Code
and Cursor; the Codex hook docs do not say whether an explicit allow
skips approval. A ServerHello that fails its checks, an unreadable
response and a dependency that fails to import now follow the shared
posture rules instead of always denying.

The live check passed against Codex CLI 0.142.5 on macOS: ALLOW ran the
original command, DENY ran neither, MODIFY ran only the replacement.

Signed-off-by: Lrd0036 <lrd0036@auburn.edu>
…adapter's tests and docs

GenAI-Security-Project#22 now denies by default: a session whose handshake gets no answer
denies unless the hook sets ACS_STARTUP_POSTURE=proceed (741d151), and
a call with no usable decision denies unless the hook sets
ACS_DEFAULT_DENY=0 (b9e1f5e). The Codex adapter already uses the shared
config, so only its tests and README still described the old defaults.

The two posture tests now check each combination: an unanswered
handshake proceeds only under ACS_STARTUP_POSTURE=proceed, and a hook
whose adapter cannot load proceeds only when both postures opt in. The
README rows for ACS_DEFAULT_DENY and ACS_STARTUP_POSTURE match.

Signed-off-by: Lrd0036 <lrd0036@auburn.edu>
Codex 0.142.5 runs a call after any PreToolUse failure except exit 2, so a
missing or broken acs-codex-hook let every call through. The example now runs
the hook under sh -c with an exit-2 fallback.

Codex also ignores continue: false from SubagentStart, and multi_agent is on by
default, so the ^Bash$ matcher left spawns ungated. The example now matches
spawn_agent too, and the Guardian sees a spawn as a steps/toolCallRequest.

The live check runs under the example's matcher and fallback, and it adds spawn
ALLOW, DENY and MODIFY cases and a hook-cannot-run case. All seven pass on Codex
CLI 0.142.5. A unit test pins the example's matcher and fallback.

Signed-off-by: Lrd0036 <lrd0036@auburn.edu>
@Lrd0036
Lrd0036 force-pushed the refimpl/codex-pretool branch from 5b5bfac to 0771f46 Compare November 6, 2026 03:21
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

This pull request's description says it closes #170, which is on the project roadmap. When it merges, GitHub closes that issue and the roadmap counts it as delivered. Please confirm the change delivers the whole issue before merging.

@Lrd0036
Lrd0036 marked this pull request as draft November 6, 2026 03:23
…gents

Codex offers apply_patch to every catalog model, and the hook receives it as
tool_name apply_patch with the patch in tool_input.command. The example matcher
left it out, so a file write the Guardian denied as a shell command could go
through as a patch. The matcher now includes apply_patch.

tool_use_id is the model's own call ID, so a later turn can reuse it, and a
subagent runs under the parent's session in a turn of its own. The request ID
now includes turn_id, so a reused call ID is not taken for a replay.

A subagent's steps now carry Codex's agent_id and agent_type as
metadata.codex_agent_id and metadata.codex_agent_type.

The example drops env: sh sets the ACS_* variables itself.

mapping.md records the MODIFY result from the interactive CLI: under
-a untrusted, Codex still asks before a rewritten command runs, and the prompt
shows the rewritten command.

The live check adds apply_patch ALLOW, DENY and MODIFY cases and checks that
a subagent's Bash call names the subagent. All 10 cases pass on Codex CLI
0.142.5.

Signed-off-by: Lrd0036 <lrd0036@auburn.edu>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

[RefImpl] Codex adapter: tool calls through PreToolUse

4 participants