Skip to content
Merged
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
59 changes: 58 additions & 1 deletion .chock/bin/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def head_blob(self, path: str) -> str:
#: Kinds whose question a write can answer. A branch name is not in a tool call, so
#: forbidden_ref has nothing to read here; saying so beats passing it empty and calling
#: that an allow.
WRITE_PATH_KINDS = frozenset({"content_regex"})
WRITE_PATH_KINDS = frozenset({"content_regex", "script"})


#: Events at which a line-level waiver is honoured: the ones where a human staged the text. At
Expand Down Expand Up @@ -339,11 +339,68 @@ def _kind_test_integrity(ctx: GateContext, params: dict, _event: str) -> GateRes
return GateResult(allowed=not matches, matches=matches)


#: A script gate's budget to answer. Past it the script has not decided, and an undecided
#: gate refuses.
_SCRIPT_TIMEOUT_SECONDS = 30

#: The exit codes a script gate speaks -- this runner's own, so a policy's script reads like
#: the runner that calls it. Anything else is not a verdict.
_SCRIPT_ALLOW, _SCRIPT_BLOCK = 0, 1

_UNDECIDED = " -- refusing rather than allowing what it never judged"


def _kind_script(ctx: GateContext, params: dict, event: str) -> GateResult:
"""Hand the material to the policy's own script and carry back its verdict.

The script reads `{"event", "repo_root", "writes": {path: text}}` on stdin -- the staged
blobs at commit and push, the write itself at tool use and at the turn's end -- so one
script serves every surface the declarative kinds do, and reads them the same way. It
answers with an exit code: 0 allows; 1 refuses, with its own words on stderr. A missing
script, a crash or a timeout refuses too, in this runner's words: a gate that cannot
reach a decision never reports an allow it never established.
"""
named = str(params.get("script", ""))
script = ctx.repo_root / named
if not script.is_file():
return GateResult(allowed=False, message=f"script gate: {named!r} is not installed{_UNDECIDED}")
writes = {path: ctx.staged_blob(path) for path in ctx.staged_paths()}
if not writes:
return GateResult(allowed=True)
payload = json.dumps({"event": event, "repo_root": str(ctx.repo_root), "writes": writes})
try:
proc = subprocess.run( # noqa: S603 -- the script is the policy's own, named in its manifest
[sys.executable, str(script)],
input=payload,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=ctx.repo_root,
timeout=_SCRIPT_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired:
budget = f"gave no verdict within {_SCRIPT_TIMEOUT_SECONDS}s"
return GateResult(allowed=False, message=f"script gate: {script.name} {budget}{_UNDECIDED}")
except OSError as exc:
return GateResult(allowed=False, message=f"script gate: {script.name} could not run ({exc}){_UNDECIDED}")
spoken = ((proc.stderr or "") + (proc.stdout or "")).strip()
if proc.returncode == _SCRIPT_ALLOW:
return GateResult(allowed=True)
if proc.returncode == _SCRIPT_BLOCK:
return GateResult(allowed=False, message=spoken or f"blocked by {script.name}")
first = spoken.splitlines()[0] if spoken else ""
detail = f": {first}" if first else ""
return GateResult(allowed=False, message=f"script gate: {script.name} exited {proc.returncode}{detail}{_UNDECIDED}")


KINDS = {
"content_regex": _kind_content_regex,
"forbidden_ref": _kind_forbidden_ref,
"dependency_allowlist": _kind_dependency_allowlist,
"test_integrity": _kind_test_integrity,
"script": _kind_script,
}


Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

- **`kind: script` gate**: a `hook.gate` whose check is the policy's own program, for a check no
declarative kind can hold -- a parser, a flow model over a method body, a rule table larger
than `params`. The runner hands the script the same material every declarative kind reads
(`{"event", "repo_root", "writes"}` on stdin: the staged blobs at commit and push, the write
itself at tool use and at the turn's end) and carries back its exit code -- `0` allows, `1`
refuses in the script's own words. A missing script, a crash or a timeout refuses in the
runner's words, never allows. It is a write-path kind, so the existing emitters wire it to the
write fragment and to `stop` unchanged: a script-backed policy now reaches every vendor a
`content_regex` gate does. Until now a script could only be a shell guard (`--guard`,
argv-shaped, never shown the file being written) or a git-event script (commit and push only);
the gap a script-backed gate left was named in the catalog's own a11y changelog. `chock check`
refuses a script name that is not a bare `.py` file name, and one the policy does not ship.
- **`devin` plugin format**: `chock plugin build --format devin` packages a policy as a native
Devin plugin (`.devin-plugin/plugin.json` + `skills/<id>/SKILL.md` + a root-level `hooks.json`,
not the nested `hooks/hooks.json` every other format uses). Same guard, same adapter,
Expand Down
3 changes: 2 additions & 1 deletion docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ fields include:
## Gate

A hook's `hook.gate` in `manifest.yaml` selects a deterministic **kind** (`content_regex`, `forbidden_ref`,
`dependency_allowlist`), its `params`, the events it runs on (`on: [commit|push|tool_use]`), and the message
`dependency_allowlist`, `test_integrity`, or `script` for a check the policy's own program makes), its
`params`, the events it runs on (`on: [commit|push|tool_use]`), and the message
shown on block. `chock compile` flattens it to `gate.json`, which the vendored `.chock/bin/gate.py`
runner enforces at git-hook time. See [Gate DSL](../spec/gate-dsl.md).

Expand Down
30 changes: 29 additions & 1 deletion spec/gate-dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ For `artifact: hook` policies, the gate is declared under `hook.gate` in `manife

| field | required | type | notes |
|-------|----------|------|-------|
| `kind` | yes | string | `content_regex`, `forbidden_ref`, `dependency_allowlist`, `test_integrity`, or `egress_allowlist` (gateway-only) |
| `kind` | yes | string | `content_regex`, `forbidden_ref`, `dependency_allowlist`, `test_integrity`, `script`, or `egress_allowlist` (gateway-only) |
| `on` | yes | list | events: `commit`, `push`, `tool_use`. The key must be quoted `"on"` in YAML. |
| `action` | yes | string | `block`, `verify`, or `warn` |
| `message` | yes | string | printed to stderr when the gate blocks |
Expand Down Expand Up @@ -100,6 +100,34 @@ files matching `test_path_regex`), and a vacuous assertion added in place of a r
Only the staged diff is read (`removed_lines`/`added_lines`), so a file that already
contained fewer assertions before this commit does not block it.

### `kind: script`

| param | required | type | notes |
|-------|----------|------|-------|
| `script` | yes | string | a file name under the policy's `implementations/` directory: bare (no `/`, so nothing outside that directory) and `.py`, run by the runner's own interpreter |

For a check no declarative kind can hold -- one that has to parse, follow a value through a
method body, or read a rule table too large for `params`. The runner hands the policy's own
program the same material every kind above reads, as JSON on stdin:

```json
{"event": "tool_use", "repo_root": "/path/to/repo", "writes": {"src/App.java": "<file text>"}}
```

`event` is `commit`, `push` or `tool_use`. `writes` is the staged blobs at `commit` and
`push`, and the write itself at `tool_use` -- the file a tool call is about to write, or what
the turn left on disk at its end -- so one script serves every surface, and it reaches the
write path and the turn's end exactly as `content_regex` does. The script answers with its
exit code: `0` allows; `1` refuses, with its own words on stderr, which become the reason
shown (the gate's `message` is not printed for a script that spoke). Any other outcome -- a
missing script, a crash, a timeout (30s) -- refuses too, in the runner's words: a gate that
reaches no decision never reports an allow it never established.

`chock compile` rewrites `script` to the file's path from the repository root, which is all
the runner has. `chock check` refuses a name that is not a bare `.py` file name, and a script
the policy does not ship. The script is deterministic code under `implementations/`, so SEC-2
applies to it as to any guard.

## Runtime note

The emitted git-hook shim probes for a working Python 3 interpreter in the order `python3`, `python`, `py` and then calls `.chock/bin/gate.py`. This makes enforcement work on stock Windows as well as POSIX without `pip install`.
7 changes: 7 additions & 0 deletions src/chock/gate/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path
from typing import Any

from chock.compile.emitters import policy_rel_path
from chock.config import load_config
from chock.manifest import load_manifest

Expand Down Expand Up @@ -55,6 +56,12 @@ def build_gate_json(policy_dir: Path, repo_root: Path) -> dict[str, Any] | None:
spec["params"]["refs"] = [str(r) for r in resolved]
spec["params"].pop("config_key", None)

if spec["kind"] == "script":
# The manifest names a file under the policy's own implementations/. The compiled gate
# carries where that is from the repository root, which is all the runner has to go on.
script = str(spec["params"].get("script", ""))
spec["params"]["script"] = f"{policy_rel_path(policy_dir)}/implementations/{script}"

return spec


Expand Down
59 changes: 58 additions & 1 deletion src/chock/gate/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def head_blob(self, path: str) -> str:
#: Kinds whose question a write can answer. A branch name is not in a tool call, so
#: forbidden_ref has nothing to read here; saying so beats passing it empty and calling
#: that an allow.
WRITE_PATH_KINDS = frozenset({"content_regex"})
WRITE_PATH_KINDS = frozenset({"content_regex", "script"})


#: Events at which a line-level waiver is honoured: the ones where a human staged the text. At
Expand Down Expand Up @@ -339,11 +339,68 @@ def _kind_test_integrity(ctx: GateContext, params: dict, _event: str) -> GateRes
return GateResult(allowed=not matches, matches=matches)


#: A script gate's budget to answer. Past it the script has not decided, and an undecided
#: gate refuses.
_SCRIPT_TIMEOUT_SECONDS = 30

#: The exit codes a script gate speaks -- this runner's own, so a policy's script reads like
#: the runner that calls it. Anything else is not a verdict.
_SCRIPT_ALLOW, _SCRIPT_BLOCK = 0, 1

_UNDECIDED = " -- refusing rather than allowing what it never judged"


def _kind_script(ctx: GateContext, params: dict, event: str) -> GateResult:
"""Hand the material to the policy's own script and carry back its verdict.

The script reads `{"event", "repo_root", "writes": {path: text}}` on stdin -- the staged
blobs at commit and push, the write itself at tool use and at the turn's end -- so one
script serves every surface the declarative kinds do, and reads them the same way. It
answers with an exit code: 0 allows; 1 refuses, with its own words on stderr. A missing
script, a crash or a timeout refuses too, in this runner's words: a gate that cannot
reach a decision never reports an allow it never established.
"""
named = str(params.get("script", ""))
script = ctx.repo_root / named
if not script.is_file():
return GateResult(allowed=False, message=f"script gate: {named!r} is not installed{_UNDECIDED}")
writes = {path: ctx.staged_blob(path) for path in ctx.staged_paths()}
if not writes:
return GateResult(allowed=True)
payload = json.dumps({"event": event, "repo_root": str(ctx.repo_root), "writes": writes})
try:
proc = subprocess.run( # noqa: S603 -- the script is the policy's own, named in its manifest
[sys.executable, str(script)],
input=payload,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=ctx.repo_root,
timeout=_SCRIPT_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired:
budget = f"gave no verdict within {_SCRIPT_TIMEOUT_SECONDS}s"
return GateResult(allowed=False, message=f"script gate: {script.name} {budget}{_UNDECIDED}")
except OSError as exc:
return GateResult(allowed=False, message=f"script gate: {script.name} could not run ({exc}){_UNDECIDED}")
spoken = ((proc.stderr or "") + (proc.stdout or "")).strip()
if proc.returncode == _SCRIPT_ALLOW:
return GateResult(allowed=True)
if proc.returncode == _SCRIPT_BLOCK:
return GateResult(allowed=False, message=spoken or f"blocked by {script.name}")
first = spoken.splitlines()[0] if spoken else ""
detail = f": {first}" if first else ""
return GateResult(allowed=False, message=f"script gate: {script.name} exited {proc.returncode}{detail}{_UNDECIDED}")


KINDS = {
"content_regex": _kind_content_regex,
"forbidden_ref": _kind_forbidden_ref,
"dependency_allowlist": _kind_dependency_allowlist,
"test_integrity": _kind_test_integrity,
"script": _kind_script,
}


Expand Down
9 changes: 9 additions & 0 deletions src/chock/gate/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@
"allowlist_pragma": {"type": "string"},
},
},
"script": {
**_CLOSED_OBJECT,
"required": ["script"],
"properties": {
# A bare file name under the policy's own implementations/: no separator, so no
# way out of that directory, and .py only, which the runner's own interpreter runs.
"script": {"type": "string", "pattern": r"^[A-Za-z0-9._-]+\.py$"},
},
},
"egress_allowlist": {
**_CLOSED_OBJECT,
"required": ["allowed_hosts"],
Expand Down
29 changes: 27 additions & 2 deletions src/chock/validation/checks_gate_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import re
from pathlib import Path
from typing import Any

from chock.gate.runner import KINDS
Expand All @@ -13,8 +14,15 @@
_CATEGORY = "manifest_gate_params"


def _validate_gate(gate: dict[str, Any], gate_ref: str, report: Report, *, tool_use_allowed: bool = False) -> None:
"""Validate a gate spec: kind is known and params match the kind schema."""
def _validate_gate(
gate: dict[str, Any],
gate_ref: str,
report: Report,
*,
tool_use_allowed: bool = False,
artifact_dir: Path | None = None,
) -> None:
"""Validate a gate spec: kind is known, params match the kind schema, a named script is shipped."""
kind = gate.get("kind")
if not kind:
report.add(Finding(gate_ref, _CATEGORY, "error", "gate is missing 'kind'"))
Expand Down Expand Up @@ -49,6 +57,7 @@ def _validate_gate(gate: dict[str, Any], gate_ref: str, report: Report, *, tool_
if param_schema is not None:
params = gate.get("params") or {}
validator = schema_validator(param_schema)
seen = len(report.errors)
for exc in sorted(validator.iter_errors(params), key=lambda e: e.path):
path_str = "/".join(str(p) for p in exc.path)
report.add(
Expand All @@ -60,6 +69,22 @@ def _validate_gate(gate: dict[str, Any], gate_ref: str, report: Report, *, tool_
)
)
_validate_regex_params(params, gate_ref, report)
if kind == "script" and artifact_dir is not None and len(report.errors) == seen:
_validate_script_shipped(params, artifact_dir, gate_ref, report)


def _validate_script_shipped(params: dict[str, Any], artifact_dir: Path, gate_ref: str, report: Report) -> None:
"""The runner refuses when a named script is missing; validate says so before the first commit does."""
name = str(params.get("script", ""))
if not (Path(artifact_dir) / "implementations" / name).is_file():
report.add(
Finding(
f"{gate_ref} (params)",
_CATEGORY,
"error",
f"script gate names implementations/{name}, which is not there",
)
)


_REGEX_PARAM_SUFFIXES = ("_pattern", "_regex", "_pragma")
Expand Down
4 changes: 3 additions & 1 deletion src/chock/validation/checks_manifest_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def _check_manifest_block_needs_gate(artifact_dir: Path, manifest: dict[str, Any
hook = manifest.get("hook") or {}

if hook.get("gate"):
_validate_gate(hook["gate"], str(_manifest_ref(artifact_dir)), report, tool_use_allowed=True)
_validate_gate(
hook["gate"], str(_manifest_ref(artifact_dir)), report, tool_use_allowed=True, artifact_dir=artifact_dir
)
return
if hook.get("script"):
return # the script refuses at the declared event; checks_script_events pins it to disk
Expand Down
3 changes: 2 additions & 1 deletion src/chock/validation/schemas/manifest.hook.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"content_regex",
"forbidden_ref",
"dependency_allowlist",
"test_integrity"
"test_integrity",
"script"
]
},
"on": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- chock:hooks:start (compiled by chock -- edit .agents/policies/stability-script/) -->
```
on(commit|tool_use): block(script) script=tests/fixtures/emitter_stability/policies/sta...
Refused by the fixture's own script.
```
<!-- chock:hooks:end -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"kind": "script",
"on": [
"commit",
"tool_use"
],
"action": "block",
"message": "Refused by the fixture's own script.",
"params": {
"script": "tests/fixtures/emitter_stability/policies/stability-script/implementations/stability-script-gate.py"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Auto-generated by chock compile.
# Policy: stability-script
- name: chock-ci-gate (stability-script)
run: |
PY=""
for c in python3 python py; do
if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import tomllib' >/dev/null 2>&1; then PY="$c"; break; fi
done
[ -n "$PY" ] || { echo "gate: no python >= 3.11 (with tomllib) found on PATH" >&2; exit 2; }
base="${GITHUB_BASE_REF:?ci-gate needs GITHUB_BASE_REF -- run this step on the pull_request event}"
"$PY" .chock/bin/gate.py run --gate .chock/compiled/stability-script/ci-gate/gate.json --event ci --base "origin/$base" \
--head-ref "${GITHUB_HEAD_REF:-}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"kind": "script",
"on": [
"commit",
"tool_use"
],
"action": "block",
"message": "Refused by the fixture's own script.",
"params": {
"script": "tests/fixtures/emitter_stability/policies/stability-script/implementations/stability-script-gate.py"
}
}
Loading
Loading