Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .github/workflows/adapter_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ jobs:
run: uv sync --locked --python 3.12 --group test
- name: Run the shared and Claude Code and Cursor suites
run: uv run python run_conformance.py claude cursor
- name: Check that imports flow integrations, client, core
run: uv run lint-imports

package:
# Builds the sdist and a wheel from it, then installs the wheel outside the
Expand Down
7 changes: 5 additions & 2 deletions adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,16 @@ adapters/
│ │ ├── hook.py # the installed hook command (Claude Code, Cursor)
│ │ ├── adapter.py # the hook, or the NAT middleware
│ │ └── wire.py # writes the framework's own config
│ ├── guardian/example_guardian.py
│ ├── guardian/example_guardian.py # the example Guardian: wire handling, replay, skew and the session chain
│ ├── examples/destructive_bash.py # the example Guardian's toy policy
│ └── testing/ # CaptureGuardian and the shared test harness
├── tests/ # common/, core/, client/, package/, conformance/, integrations/<framework>/
├── docs/<framework>/ # README, mapping.md, example config, example payloads
└── scripts/check_wheel.py # installs a built wheel outside the repo and checks it
```

Imports flow `integrations → client → core`, and none of the three imports `guardian`, `examples` or `testing`; `uv run lint-imports` checks both rules in CI.

Installing the package (`uv tool install ./adapters`, or `pip install ./adapters`) puts these commands on your PATH: `acs-claude-code-hook`, `acs-cursor-hook <event>`, `acs-claude-code-wire`, `acs-cursor-wire`, `acs-nat-wire` and `acs-example-guardian`. For NAT, install the `nat` extra (`pip install './adapters[nat]'`); NAT then finds `_type: acs_guardian` through the package's `nat.components` entry point.

---
Expand All @@ -252,6 +255,6 @@ Installing the package (`uv tool install ./adapters`, or `pip install ./adapters
5. Add a row to the status table above.
6. Open a PR against `GenAI-Security-Project/agent-control-standard`.

Keep module-level state constant or set once at startup. Read `ACS_GUARDIAN_URL`, `ACS_DEFAULT_DENY`, `ACS_STARTUP_POSTURE`, `ACS_HANDSHAKE` and `ACS_DISABLED` once with `acs.client.Config.from_env` at the entry point, pass the `Config` and `FailPosture` down, gate each step on `Config.gate(open_session(...))`, build envelopes with `acs.core.request_envelope` and send them unsigned to `acs.client.round_trip`, which signs as the last step and returns either the decision or the `FailPosture.resolve` resolution to write in the host's shape, and keep the open turn with `acs.client.open_turn` / `current_turn` / `close_turn`.
Keep module-level state constant or set once at startup. Read `ACS_GUARDIAN_URL`, `ACS_DEFAULT_DENY`, `ACS_STARTUP_POSTURE`, `ACS_REQUIRE_PROFILES`, `ACS_HANDSHAKE` and `ACS_DISABLED` once with `acs.client.Config.from_env` at the entry point, pass the `Config` and `FailPosture` down, gate each step on `Config.gate(open_session(...))`, build envelopes with `acs.core.request_envelope` and send them unsigned to `acs.client.round_trip`, which signs as the last step and returns either the decision or the `FailPosture.resolve` resolution to write in the host's shape, and keep the open turn with `acs.client.open_turn` / `current_turn` / `close_turn`.

The bar for "reference implementation" status is: round-trip tests pass against the example Guardian, documented configuration for users, and an explicit conformance posture statement matching the format in the Claude Code adapter's README.
2 changes: 1 addition & 1 deletion adapters/docs/claude_code/mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Claude Code can run the hooks of parallel tool calls at the same time. `UserProm

## Conformance posture

The Claude Code adapter implements ACS-Core's mandatory floor. Every row below describes what the code in `adapter.py` actually does — this table drifted from the code once (PR #22 review caught six false claims in it), so treat the code as ground truth and this table as its description:
The Claude Code adapter covers ACS-Core's mandatory floor as listed below. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Three ACS-Core requirements from later specification changes follow in separate PRs: the `key_id` taken from configuration (§10, #272); decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273); and a signature on every request, where today an adapter with no secret configured sends unsigned requests (§10). Every row below describes what the code in `adapter.py` actually does — this table drifted from the code once (PR #22 review caught six false claims in it), so treat the code as ground truth and this table as its description:

- Handshake: `handshake/hello` is performed on the first hook event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. The ServerHello's response envelope is signature-verified and bound to the ClientHello before it is stored; its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json) and is recorded as `method_not_evaluated`; the hook returns no output, so Claude Code's own permission flow still applies, and a result never cites a request that was not sent. With no negotiated list, every mapped hook is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step.
- Turns: a `UserPromptSubmit` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `Stop` closes it with `steps/turnEnd`. The `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step; session-level steps (`sessionStart`/`sessionEnd`) carry none.
Expand Down
2 changes: 1 addition & 1 deletion adapters/docs/cursor/mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Cursor's documentation does not say whether hooks of one session overlap, so eve

## Conformance posture

The Cursor adapter implements ACS-Core's mandatory floor in the same shape as the Claude Code adapter:
The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Claude Code adapter. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Three ACS-Core requirements from later specification changes follow in separate PRs: the `key_id` taken from configuration (§10, #272); decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273); and a signature on every request, where today an adapter with no secret configured sends unsigned requests (§10). The floor it covers:

- Handshake: `handshake/hello` performed on the first event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`proceed` lets the step run with nothing sent to the Guardian; `refuse` denies it). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. ServerHello signature-verified, bound to the ClientHello, its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json), recorded as `method_not_evaluated`; permission events and `beforeSubmitPrompt` return their explicit allow shapes, since `failClosed` reads an empty reply as a failed hook. With no negotiated list, every mapped event is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step.
- Turns: a `beforeSubmitPrompt` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `stop` closes it with `steps/turnEnd`. `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step.
Expand Down
2 changes: 1 addition & 1 deletion adapters/docs/nat/mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ NAT can invoke several wrapped functions at once in one process. The builder ope

## Conformance posture

The NAT adapter implements ACS-Core's mandatory floor:
The NAT adapter covers ACS-Core's mandatory floor. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Three ACS-Core requirements from later specification changes follow in separate PRs: the `key_id` taken from configuration (§10, #272); decision failures recorded with their failure class and counted at `sessionEnd` (§6.4, #273); and a signature on every request, where today an adapter with no secret configured sends unsigned requests (§10). The floor it covers:

- Hook taxonomy: every wrapped function call surfaces as `toolCallRequest` + `toolCallResult`.
- Dispositions: ALLOW / DENY / MODIFY supported normatively; ASK / DEFER substituted to DENY with audit (HITL composition is the recommended path).
Expand Down
17 changes: 16 additions & 1 deletion adapters/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ nat = "acs.integrations.nat"
acs_guardian = "acs.integrations.nat.adapter"

[dependency-groups]
test = ["pytest>=8"]
test = ["import-linter>=2", "pytest>=8"]

[tool.hatch.build.targets.wheel]
packages = ["src/acs"]
Expand All @@ -46,3 +46,18 @@ path = "hatch_build.py"

[tool.pytest.ini_options]
testpaths = ["tests"]

# Imports flow integrations -> client -> core; checked in CI with `lint-imports`.
[tool.importlinter]
root_package = "acs"

[[tool.importlinter.contracts]]
name = "Integrations use the client, the client uses core, and core uses neither"
type = "layers"
layers = ["acs.integrations", "acs.client", "acs.core"]

[[tool.importlinter.contracts]]
name = "The protocol, the client and the integrations import no Guardian, example or test code"
type = "forbidden"
source_modules = ["acs.core", "acs.client", "acs.integrations"]
forbidden_modules = ["acs.guardian", "acs.examples", "acs.testing"]
1 change: 1 addition & 0 deletions adapters/src/acs/examples/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Example policies for the example Guardian; not part of the client or the protocol."""
76 changes: 76 additions & 0 deletions adapters/src/acs/examples/destructive_bash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""The example Guardian's toy policy: commands that destroy a filesystem or a disk, found with bounded regex scans."""
from __future__ import annotations

import re

from acs.client import audit_event


# ----- Destructive-Bash regex set -----

DESTRUCTIVE_BASH_PATTERNS: tuple[re.Pattern, ...] = (
# `rm` + a flag token containing both r and f with other letters
# anywhere (-rf, -rfv, -vrf, ...), then a path starting `/`, `~`,
# or `$HOME`; the trailing [a-zA-Z]* keeps `-rfv` matching.
re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive\s+--force|--force\s+--recursive)\b.*?\s+(/|~|\$HOME)", re.IGNORECASE),
re.compile(r"\brm\s+(-rf|-fr|--recursive\s+--force|--force\s+--recursive)\s+(/|~|\$HOME)(\s|$)", re.IGNORECASE),
# Recursive and force given as separate flag tokens in any order (`rm -r -f /`,
# `rm -f -R ~`). The lookaheads only scan the flag tokens before the path.
re.compile(r"\brm(?=(?:\s+-[a-zA-Z-]+)*\s+-(?:[a-zA-Z]*r|-recursive\b))(?=(?:\s+-[a-zA-Z-]+)*\s+-(?:[a-zA-Z]*f|-force\b))(?:\s+-[a-zA-Z-]+)+\s+(/|~|\$HOME)", re.IGNORECASE),
re.compile(r"\brm\s+.*--no-preserve-root\b", re.IGNORECASE),
re.compile(r"\bmkfs(\.\w+)?\s+", re.IGNORECASE),
re.compile(r"\bdd\s+.*\bof=/dev/", re.IGNORECASE),
re.compile(r":\(\)\s*\{"),
re.compile(r">\s*/dev/(sd[a-z]|nvme|hd[a-z]|disk)", re.IGNORECASE),
# The path ends at whitespace, not at a word boundary, because no boundary
# sits between `/` or `~` and the space after it.
re.compile(r"\bfind\s+(/|~|\$HOME)\S*\s.*-delete\b", re.IGNORECASE),
re.compile(r"\bfind\s+(/|~|\$HOME)\S*\s.*-exec\s+rm\b", re.IGNORECASE),
re.compile(r"\bchmod\s+(-R\s+)?[0-7]*7{2,}[0-7]*\s+(/etc|/usr|/bin|/sbin)", re.IGNORECASE),
)


# Maximum command string length to scan with destructive-pattern regexes.
# Real shell commands are tiny; longer inputs are either non-shell data
# routed through the wrong tool or a regex-DoS attempt.
DESTRUCTIVE_SCAN_MAX_LEN = 8 * 1024 # 8 KiB

# ----- Bounded regex scanning (defends against regex DoS / input bombing) -----

def scan_destructive_bash_safely(cmd: str, *, max_len: int = DESTRUCTIVE_SCAN_MAX_LEN):
"""Run destructive-pattern regex scanning ONLY if cmd is below max_len.

Returns:
None — command is short and matched no destructive pattern
a re.Pattern — command matched (caller decides what to do)
"input_too_large" — command exceeds max_len; caller MUST treat as
suspicious and MUST NOT silently allow (skipping the scan is
not the same as the scan returning "safe").

Scans with DESTRUCTIVE_BASH_PATTERNS above, the one canonical pattern set.
"""
if len(cmd) > max_len:
audit_event("destructive_scan_skipped_oversized",
cmd_length=len(cmd), max_len=max_len)
return "input_too_large"
for pat in DESTRUCTIVE_BASH_PATTERNS:
if pat.search(cmd):
return pat
return None


def matches_destructive_bash(cmd: str) -> str | re.Pattern | None:
"""Returns:
None — safe (no destructive pattern matched)
re.Pattern — a destructive pattern matched
"too_large" — input exceeds the regex-scan cap; caller MUST treat
as suspicious (we don't know if it's destructive).

The 8 KiB cap defends against regex DoS via crafted huge commands.
"""
if len(cmd) > DESTRUCTIVE_SCAN_MAX_LEN:
return "too_large"
for pat in DESTRUCTIVE_BASH_PATTERNS:
if pat.search(cmd):
return pat
return None
Loading
Loading