mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
feat(cc-hooks): PreToolUse policy engine + day-1 enforce cohort (age-bhsz, age-wnyt)
One dispatcher over a policies-as-data registry (hooks-manifest.v2): deny|route|audit modes, waivers, once-per-session messaging, hashed guardrail telemetry, fail-open on missing registry/jq. Predicate discipline (pure-only may block) enforced mechanically by lint-policies.sh. Day-1 cohort: git-add-_beads, provenance-ledger hand-append, cp-into- installed-skills. Ships INERT; opt-in installer wires Bash + Edit|Write PreToolUse matchers. 25 bats contract tests.
This commit is contained in:
@@ -165,6 +165,53 @@ positive value"), the criterion whose absence killed 2.x hooks (#511).
|
||||
|
||||
Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md)
|
||||
|
||||
## Policy Dispatch Engine (opt-in)
|
||||
|
||||
The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher —
|
||||
[hooks/policy-dispatch.sh](hooks/policy-dispatch.sh) — evaluating a
|
||||
**policies-as-data** registry
|
||||
([policies/policies.json](policies/policies.json), contract
|
||||
`schemas/hooks-manifest.v2.schema.json`) instead of N hand-wired settings
|
||||
entries. This is the membrane at tool-call altitude: same vocabulary, lower
|
||||
altitude than the pawl/gate at push time.
|
||||
|
||||
Per policy: dcg-style id (`domain.object:token`), `mode: deny | route | audit`,
|
||||
matchers (tool + `command`/`file_path` regex), a `route_message` that names THE
|
||||
correct tool, a rationale, and a pre-registered `value_proof` (the ADR-0002
|
||||
lease-on-life: no proof accruing → retire the policy).
|
||||
|
||||
**Predicate discipline, schema-enforced** (the #511 anti-lesson): only
|
||||
`predicate_class: pure` — syntactic mistake-tokens over the command or file
|
||||
path — may `deny`/`route`. Lookup/stateful predicates ship `audit`-only until
|
||||
promoted with reviewed fires.
|
||||
[scripts/lint-policies.sh](scripts/lint-policies.sh) enforces this mechanically
|
||||
(jq-only; runs in bats and CI).
|
||||
|
||||
Semantics: happy path = exit 0, zero output. `deny` = exit 2 + one stderr
|
||||
route line (full message once per session, short line after — every attempt
|
||||
still blocks). `route` = exit 0 + `permissionDecision:"ask"` JSON. `audit` =
|
||||
allow + record. Every fire appends one hashed guardrail-telemetry line
|
||||
(`token_class` = policy id, plus `mode`/`decision`). Waive once with
|
||||
`AOP_WAIVE=<policy-id>`, or a `policy-waivers` file line
|
||||
`<policy-id> <expiry-epoch>`. Missing registry or jq fails OPEN.
|
||||
|
||||
Day-1 enforce cohort (age-wnyt, all pure-regex, high-pain):
|
||||
|
||||
| Policy | Blocks | Routes to |
|
||||
|---|---|---|
|
||||
| `core.git:add-beads-ledger` | `git add` naming `_beads/` (private ledger leak is one-way) | push the ledger repo itself — never `git add _beads` in the public tree |
|
||||
| `core.provenance:ledger-hand-append` | redirect/`tee`/Edit/Write onto `docs/provenance/ledger.jsonl` (hash-chained, sealed) | `ao provenance add` |
|
||||
| `core.skills:copy-into-installed` | `cp`/`rsync`/`mv` INTO `~/.claude|.codex|.gemini/skills` (dest-position enforced) | `ao skills link` |
|
||||
|
||||
Ships INERT — opt-in installer (lints the registry before wiring):
|
||||
|
||||
```bash
|
||||
scripts/install-policy-dispatch.sh # user scope; --project for project scope
|
||||
```
|
||||
|
||||
Contract tests: `tests/scripts/policy-dispatch.bats` (block+message+telemetry
|
||||
per policy, stray-stdout hazard, waivers, audit/route modes, fail-open).
|
||||
|
||||
## Writing Your Own Hook
|
||||
|
||||
**Minimal Python:**
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://agentops.dev/schemas/hooks-manifest.v2.schema.json",
|
||||
"title": "hooks-manifest.v2 — policies-as-data admission-control registry",
|
||||
"description": "Registry consumed by skills/cc-hooks/hooks/policy-dispatch.sh (age-bhsz, epic age-4qw1). v2 replaces v1's event->matcher->command wiring with declarative policies evaluated by ONE dispatcher per event slot. The predicate discipline encodes the #511 anti-lesson structurally: only pure syntactic predicates (regex over tool_input.command or tool_input.file_path) may block; lookup/stateful predicates are audit-only until promoted with reviewed-fire evidence.",
|
||||
"type": "object",
|
||||
"required": ["schema", "policies"],
|
||||
"properties": {
|
||||
"schema": { "const": "hooks-manifest.v2" },
|
||||
"comment": { "type": "string" },
|
||||
"policies": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/policy" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"policy": {
|
||||
"type": "object",
|
||||
"required": ["id", "predicate_class", "mode", "matchers", "route_message", "rationale", "value_proof"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "dcg-style namespaced id: domain.object:token",
|
||||
"pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$"
|
||||
},
|
||||
"predicate_class": {
|
||||
"type": "string",
|
||||
"enum": ["pure", "lookup", "stateful"],
|
||||
"description": "pure = regex over command/file_path alone; lookup = one deterministic local check; stateful = coordination state. Only pure may deny or route."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["deny", "route", "audit"]
|
||||
},
|
||||
"matchers": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["tools", "field", "pattern"],
|
||||
"properties": {
|
||||
"tools": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": "string", "enum": ["Bash", "Edit", "Write"] }
|
||||
},
|
||||
"field": { "type": "string", "enum": ["command", "file_path"] },
|
||||
"pattern": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"route_message": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Every deny/route names THE correct tool (tools-truth rule): one actionable redirect, not a bare refusal."
|
||||
},
|
||||
"rationale": { "type": "string", "minLength": 1 },
|
||||
"value_proof": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Pre-registered lease-on-life metric per ADR-0002:51-59; no proof accruing -> retire the policy."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "predicate_class": { "enum": ["lookup", "stateful"] } }
|
||||
},
|
||||
"then": {
|
||||
"properties": { "mode": { "const": "audit" } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# install-policy-dispatch.sh — opt-in installer for the PreToolUse policy
|
||||
# dispatcher + policies-as-data registry (age-bhsz, epic age-4qw1).
|
||||
#
|
||||
# AgentOps is hookless by default — the engine ships INERT. Run this script
|
||||
# explicitly to activate it on a host. It copies the dispatcher and the policy
|
||||
# registry into ~/.claude/hooks/aop/ and wires PreToolUse matchers for Bash and
|
||||
# Edit|Write into a Claude settings.json. Idempotent: re-running refreshes the
|
||||
# installed copies and is a settings no-op once wired.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/install-policy-dispatch.sh # user settings (~/.claude/settings.json)
|
||||
# scripts/install-policy-dispatch.sh --project # project settings (.claude/settings.json)
|
||||
# SETTINGS=/path/to/settings.json scripts/install-policy-dispatch.sh
|
||||
set -euo pipefail
|
||||
umask 022
|
||||
|
||||
# shellcheck disable=SC1007,SC1091
|
||||
. "$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/repo-root.sh"
|
||||
repo_root="$(resolve_repo_root)"
|
||||
src_dispatch="${repo_root}/skills/cc-hooks/hooks/policy-dispatch.sh"
|
||||
src_policies="${repo_root}/skills/cc-hooks/policies/policies.json"
|
||||
lint="${repo_root}/skills/cc-hooks/scripts/lint-policies.sh"
|
||||
[[ -f "$src_dispatch" ]] || { echo "ERROR: dispatcher missing: ${src_dispatch}" >&2; exit 1; }
|
||||
[[ -f "$src_policies" ]] || { echo "ERROR: registry missing: ${src_policies}" >&2; exit 1; }
|
||||
command -v jq >/dev/null || { echo "ERROR: jq required" >&2; exit 1; }
|
||||
|
||||
# Never install a registry that fails its own contract.
|
||||
bash "$lint" "$src_policies"
|
||||
|
||||
settings="${SETTINGS:-}"
|
||||
if [[ -z "$settings" ]]; then
|
||||
case "${1:-}" in
|
||||
--project) settings=".claude/settings.json" ;;
|
||||
*) settings="${HOME}/.claude/settings.json" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
hooks_dir="${HOME}/.claude/hooks/aop"
|
||||
mkdir -p "$hooks_dir"
|
||||
install -m 0755 "$src_dispatch" "${hooks_dir}/policy-dispatch.sh"
|
||||
install -m 0644 "$src_policies" "${hooks_dir}/policies.json"
|
||||
dst="${hooks_dir}/policy-dispatch.sh"
|
||||
echo "✓ installed ${dst} (+ policies.json beside it)"
|
||||
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
[[ -f "$settings" ]] || echo '{}' > "$settings"
|
||||
|
||||
if [[ -s "$settings" ]]; then
|
||||
backup="${settings}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
cp -p "$settings" "$backup"
|
||||
echo "✓ backed up settings → ${backup}"
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
jq --arg cmd "$dst" '
|
||||
.hooks //= {} |
|
||||
.hooks.PreToolUse //= [] |
|
||||
reduce ("Bash", "Edit|Write") as $m (.;
|
||||
if any(.hooks.PreToolUse[]?; .matcher == $m and any((.hooks // [])[]?; .command == $cmd))
|
||||
then .
|
||||
else .hooks.PreToolUse += [{
|
||||
"matcher": $m,
|
||||
"hooks": [ { "type": "command", "command": $cmd } ]
|
||||
}]
|
||||
end
|
||||
)
|
||||
' "$settings" > "$tmp" && mv "$tmp" "$settings"
|
||||
trap - EXIT
|
||||
|
||||
if grep -qF "$dst" "$settings"; then
|
||||
echo "✓ wired PreToolUse (Bash, Edit|Write) policy dispatcher into ${settings}"
|
||||
else
|
||||
echo "ERROR: failed to wire dispatcher into ${settings}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Policy dispatcher active for this Claude scope. SILENT on every clean call;"
|
||||
echo "deny policies block with a one-line route to the correct tool; every fire"
|
||||
echo "lands one hashed telemetry line in \${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl."
|
||||
echo "Waive once: AOP_WAIVE=<policy-id> <your command>"
|
||||
echo "Uninstall: remove the two PreToolUse matchers for ${dst} from ${settings}, then rm -rf ${hooks_dir}"
|
||||
@@ -369,8 +369,8 @@
|
||||
{
|
||||
"name": "cc-hooks",
|
||||
"source_skill": "skills/cc-hooks",
|
||||
"source_hash": "d85a55382bdf1fc91a1de99e48451197f0b79b33e3b2dfb809084b2a5fb89fd9",
|
||||
"generated_hash": "51fd3f0daf67d12e848e3b69ee3eb7cea4793bd719fce89efa0b6a5b29d5c155"
|
||||
"source_hash": "db4cdf0960a2fb7f184f4ff8a05e3dcc5c948abc56380a74d0ff04cb54d48a2b",
|
||||
"generated_hash": "4d7866732fefa0a7d98fa7d3d4c3cbad82baaaf5786021a2878b72485e149ac4"
|
||||
},
|
||||
{
|
||||
"name": "codebase-recon",
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"generator": "codex-sync",
|
||||
"source_skill": "skills/cc-hooks",
|
||||
"layout": "modular",
|
||||
"source_hash": "d85a55382bdf1fc91a1de99e48451197f0b79b33e3b2dfb809084b2a5fb89fd9",
|
||||
"generated_hash": "51fd3f0daf67d12e848e3b69ee3eb7cea4793bd719fce89efa0b6a5b29d5c155"
|
||||
"source_hash": "db4cdf0960a2fb7f184f4ff8a05e3dcc5c948abc56380a74d0ff04cb54d48a2b",
|
||||
"generated_hash": "4d7866732fefa0a7d98fa7d3d4c3cbad82baaaf5786021a2878b72485e149ac4"
|
||||
}
|
||||
|
||||
@@ -150,6 +150,53 @@ positive value"), the criterion whose absence killed 2.x hooks (#511).
|
||||
|
||||
Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md)
|
||||
|
||||
## Policy Dispatch Engine (opt-in)
|
||||
|
||||
The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher —
|
||||
[hooks/policy-dispatch.sh](hooks/policy-dispatch.sh) — evaluating a
|
||||
**policies-as-data** registry
|
||||
([policies/policies.json](policies/policies.json), contract
|
||||
`schemas/hooks-manifest.v2.schema.json`) instead of N hand-wired settings
|
||||
entries. This is the membrane at tool-call altitude: same vocabulary, lower
|
||||
altitude than the pawl/gate at push time.
|
||||
|
||||
Per policy: dcg-style id (`domain.object:token`), `mode: deny | route | audit`,
|
||||
matchers (tool + `command`/`file_path` regex), a `route_message` that names THE
|
||||
correct tool, a rationale, and a pre-registered `value_proof` (the ADR-0002
|
||||
lease-on-life: no proof accruing → retire the policy).
|
||||
|
||||
**Predicate discipline, schema-enforced** (the #511 anti-lesson): only
|
||||
`predicate_class: pure` — syntactic mistake-tokens over the command or file
|
||||
path — may `deny`/`route`. Lookup/stateful predicates ship `audit`-only until
|
||||
promoted with reviewed fires.
|
||||
[scripts/lint-policies.sh](scripts/lint-policies.sh) enforces this mechanically
|
||||
(jq-only; runs in bats and CI).
|
||||
|
||||
Semantics: happy path = exit 0, zero output. `deny` = exit 2 + one stderr
|
||||
route line (full message once per session, short line after — every attempt
|
||||
still blocks). `route` = exit 0 + `permissionDecision:"ask"` JSON. `audit` =
|
||||
allow + record. Every fire appends one hashed guardrail-telemetry line
|
||||
(`token_class` = policy id, plus `mode`/`decision`). Waive once with
|
||||
`AOP_WAIVE=<policy-id>`, or a `policy-waivers` file line
|
||||
`<policy-id> <expiry-epoch>`. Missing registry or jq fails OPEN.
|
||||
|
||||
Day-1 enforce cohort (age-wnyt, all pure-regex, high-pain):
|
||||
|
||||
| Policy | Blocks | Routes to |
|
||||
|---|---|---|
|
||||
| `core.git:add-beads-ledger` | `git add` naming `_beads/` (private ledger leak is one-way) | push the ledger repo itself — never `git add _beads` in the public tree |
|
||||
| `core.provenance:ledger-hand-append` | redirect/`tee`/Edit/Write onto `docs/provenance/ledger.jsonl` (hash-chained, sealed) | `ao provenance add` |
|
||||
| `core.skills:copy-into-installed` | `cp`/`rsync`/`mv` INTO `~/.claude|.codex|.gemini/skills` (dest-position enforced) | `ao skills link` |
|
||||
|
||||
Ships INERT — opt-in installer (lints the registry before wiring):
|
||||
|
||||
```bash
|
||||
scripts/install-policy-dispatch.sh # user scope; --project for project scope
|
||||
```
|
||||
|
||||
Contract tests: `tests/scripts/policy-dispatch.bats` (block+message+telemetry
|
||||
per policy, stray-stdout hazard, waivers, audit/route modes, fail-open).
|
||||
|
||||
## Writing Your Own Hook
|
||||
|
||||
**Minimal Python:**
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# policy-dispatch.sh — ONE PreToolUse dispatcher over a policies-as-data registry
|
||||
# (age-bhsz / epic age-4qw1: admission control — the membrane at tool-call altitude).
|
||||
#
|
||||
# Reads the PreToolUse JSON once from stdin, evaluates every applicable policy
|
||||
# from the registry, and emits one decision:
|
||||
# deny -> exit 2 + route message on stderr (blocks the tool call)
|
||||
# route -> exit 0 + permissionDecision:"ask" JSON on stdout (surfaces a dialog)
|
||||
# audit -> exit 0, silent; the fire is only recorded in telemetry
|
||||
# Happy path: exit 0, ZERO output (stray stdout on exit-0 is parsed as JSON by
|
||||
# the harness and breaks the tool call — see skills/cc-hooks/SKILL.md).
|
||||
#
|
||||
# Predicate discipline (the #511 anti-lesson, schema-enforced by
|
||||
# scripts/lint-policies.sh + schemas/hooks-manifest.v2.schema.json): predicates
|
||||
# are SYNTACTIC mistake-tokens only — pure regex over tool_input.command or
|
||||
# tool_input.file_path. Policies with predicate_class other than "pure" are
|
||||
# structurally barred from deny/route until promoted from audit.
|
||||
#
|
||||
# Registry resolution order: $AOP_POLICIES, then policies.json beside this
|
||||
# script (installed layout), then ../policies/policies.json (repo layout).
|
||||
#
|
||||
# Waivers: AOP_WAIVE="id1,id2" env (one-shot), or a waiver file
|
||||
# ($AGENTOPS_HOME/policy-waivers, default ~/.agents/ao/policy-waivers) with
|
||||
# lines "<policy-id> <expiry-unix-epoch>".
|
||||
#
|
||||
# Telemetry: one JSONL line per fire (deny, route, audit, waived) appended to
|
||||
# $AGENTOPS_GUARDRAIL_TELEMETRY (default $AGENTOPS_HOME/guardrail-telemetry.jsonl,
|
||||
# AGENTOPS_HOME defaulting to ~/.agents/ao). Schema is a superset of the
|
||||
# installed-skill-edit-guard line: {ts, session, token_class, path_sha256} plus
|
||||
# {mode, decision}. The matched value is hashed, never stored raw. Telemetry
|
||||
# failure never changes the exit decision.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck disable=SC1007 # CDPATH= scopes an empty CDPATH to the cd, intentionally
|
||||
script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
registry="${AOP_POLICIES:-}"
|
||||
if [ -z "$registry" ]; then
|
||||
if [ -f "${script_dir}/policies.json" ]; then
|
||||
registry="${script_dir}/policies.json"
|
||||
else
|
||||
registry="${script_dir}/../policies/policies.json"
|
||||
fi
|
||||
fi
|
||||
# Fail OPEN if the registry or jq is unavailable: an admission layer that
|
||||
# bricks every tool call on a missing file is worse than no layer (dcg
|
||||
# precedent: fail-open on timeout).
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
[ -f "$registry" ] || exit 0
|
||||
|
||||
input="$(cat)"
|
||||
tool="$(printf '%s' "$input" | jq -r '.tool_name // ""')"
|
||||
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')"
|
||||
fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')"
|
||||
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
|
||||
[ -n "$tool" ] || exit 0
|
||||
|
||||
hash_value() {
|
||||
# SHA-256 of $1 for telemetry privacy; empty string when no hasher exists.
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | sha256sum | cut -d' ' -f1
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//'
|
||||
fi
|
||||
}
|
||||
|
||||
emit_telemetry() {
|
||||
# $1 policy id, $2 mode, $3 decision, $4 matched value
|
||||
local h
|
||||
h="$(hash_value "$4")"
|
||||
[ -n "$h" ] || return 0
|
||||
local tdir="${AGENTOPS_HOME:-${HOME}/.agents/ao}"
|
||||
local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
|
||||
mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
|
||||
local line
|
||||
line="$(jq -nc \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg session "$sid" \
|
||||
--arg token_class "$1" \
|
||||
--arg path_sha256 "$h" \
|
||||
--arg mode "$2" \
|
||||
--arg decision "$3" \
|
||||
'{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision}' \
|
||||
)" || return 0
|
||||
printf '%s\n' "$line" >> "$tfile" 2>/dev/null || return 0
|
||||
}
|
||||
|
||||
waived() {
|
||||
# $1 policy id -> 0 when a waiver applies.
|
||||
case ",${AOP_WAIVE:-}," in
|
||||
*",$1,"*) return 0 ;;
|
||||
esac
|
||||
local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME}/.agents/ao}/policy-waivers}"
|
||||
[ -f "$wfile" ] || return 1
|
||||
local now id expiry
|
||||
now="$(date +%s)"
|
||||
while read -r id expiry _; do
|
||||
[ "$id" = "$1" ] || continue
|
||||
case "$expiry" in (*[!0-9]*|'') continue ;; esac
|
||||
[ "$expiry" -gt "$now" ] && return 0
|
||||
done < "$wfile"
|
||||
return 1
|
||||
}
|
||||
|
||||
deny_id=""; deny_msg=""; deny_val=""
|
||||
route_id=""; route_msg=""; route_val=""
|
||||
|
||||
# Iterate matchers flattened as unit-separator-joined fields:
|
||||
# id / mode / field / pattern / route_message. NOT @tsv — TSV escaping mangles
|
||||
# backslashes inside regex patterns (\. arrives as \\.), silently breaking
|
||||
# every pattern that escapes a metacharacter.
|
||||
while IFS=$'\x1f' read -r pid pmode pfield ppattern pmsg; do
|
||||
[ -n "$pid" ] || continue
|
||||
case "$pfield" in
|
||||
command) val="$cmd" ;;
|
||||
file_path) val="$fpath" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ -n "$val" ] || continue
|
||||
printf '%s' "$val" | grep -qE "$ppattern" || continue
|
||||
if waived "$pid"; then
|
||||
emit_telemetry "$pid" "$pmode" "waived" "$val"
|
||||
continue
|
||||
fi
|
||||
case "$pmode" in
|
||||
deny)
|
||||
if [ -z "$deny_id" ]; then deny_id="$pid"; deny_msg="$pmsg"; deny_val="$val"; fi
|
||||
;;
|
||||
route)
|
||||
if [ -z "$route_id" ]; then route_id="$pid"; route_msg="$pmsg"; route_val="$val"; fi
|
||||
;;
|
||||
audit)
|
||||
emit_telemetry "$pid" "audit" "audit" "$val"
|
||||
;;
|
||||
esac
|
||||
done < <(jq -r --arg tool "$tool" '
|
||||
.policies[]
|
||||
| . as $p
|
||||
| .matchers[]
|
||||
| select(.tools | index($tool))
|
||||
| [$p.id, $p.mode, .field, .pattern, ($p.route_message // "")]
|
||||
| join("")
|
||||
' "$registry" 2>/dev/null)
|
||||
|
||||
if [ -n "$deny_id" ]; then
|
||||
emit_telemetry "$deny_id" "deny" "deny" "$deny_val"
|
||||
sdir="${TMPDIR:-/tmp}/aop-policy-dispatch"
|
||||
sentinel="${sdir}/${sid//\//_}-${deny_id//[^a-zA-Z0-9]/_}"
|
||||
if [ -f "$sentinel" ]; then
|
||||
printf '⛔ policy %s: blocked (reason shown earlier this session).\n' "$deny_id" >&2
|
||||
else
|
||||
mkdir -p "$sdir" 2>/dev/null || true
|
||||
: > "$sentinel" 2>/dev/null || true
|
||||
printf '⛔ policy %s\n%s\n' "$deny_id" "$deny_msg" >&2
|
||||
fi
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ -n "$route_id" ]; then
|
||||
emit_telemetry "$route_id" "route" "ask" "$route_val"
|
||||
jq -nc --arg reason "policy ${route_id}: ${route_msg}" \
|
||||
'{hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"ask", permissionDecisionReason:$reason}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"schema": "hooks-manifest.v2",
|
||||
"comment": "Policies-as-data registry for policy-dispatch.sh (age-bhsz, epic age-4qw1). Predicate discipline: only predicate_class 'pure' (syntactic regex over tool_input.command / tool_input.file_path) may carry mode deny|route; lookup/stateful predicates ship audit-only until promoted with reviewed fires. Enforced by skills/cc-hooks/scripts/lint-policies.sh against schemas/hooks-manifest.v2.schema.json. Patterns are POSIX ERE (BSD grep -E compatible: no \\b, no lookaround).",
|
||||
"policies": [
|
||||
{
|
||||
"id": "core.git:add-beads-ledger",
|
||||
"predicate_class": "pure",
|
||||
"mode": "deny",
|
||||
"matchers": [
|
||||
{
|
||||
"tools": [
|
||||
"Bash"
|
||||
],
|
||||
"field": "command",
|
||||
"pattern": "(^|[;&|][[:space:]]*)git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+add[[:space:]]([^;&|]*[[:space:]/=])?_beads"
|
||||
}
|
||||
],
|
||||
"route_message": "_beads/ is the PRIVATE bead ledger (its own git repo) — never stage it into the public tree; the leak is one-way. Sync it by pushing the ledger repo itself: (cd _beads && git push)",
|
||||
"rationale": "CLAUDE.md footgun row + memory agentops-br-private-ledger. Only the explicit '_beads' path form is matched; 'git add -A' silently sweeping _beads/ is the stateful variant and stays out of deny per predicate discipline.",
|
||||
"value_proof": "declining fire-attempt rate in guardrail telemetry (token_class core.git:add-beads-ledger); retire on false-positive evidence"
|
||||
},
|
||||
{
|
||||
"id": "core.provenance:ledger-hand-append",
|
||||
"predicate_class": "pure",
|
||||
"mode": "deny",
|
||||
"matchers": [
|
||||
{
|
||||
"tools": [
|
||||
"Bash"
|
||||
],
|
||||
"field": "command",
|
||||
"pattern": "(>>?[[:space:]]*[^[:space:];&|]*docs/provenance/ledger\\.jsonl)|(tee[[:space:]]+(-a[[:space:]]+)?[^[:space:];&|]*docs/provenance/ledger\\.jsonl)"
|
||||
},
|
||||
{
|
||||
"tools": [
|
||||
"Edit",
|
||||
"Write"
|
||||
],
|
||||
"field": "file_path",
|
||||
"pattern": "(^|/)docs/provenance/ledger\\.jsonl$"
|
||||
}
|
||||
],
|
||||
"route_message": "docs/provenance/ledger.jsonl is HASH-CHAINED (prev_hash/payload_hash/hash on every record) and SEALED — a hand-written row breaks VerifyChain for every record after it. Append through the owning command instead: ao provenance add (schema-validated, sealed onto the chain tip)",
|
||||
"rationale": "Memory pawl-gated-land-flow ('SEALED, never hand-append'). Reads (grep/jq/cat with no redirect onto the file) never match.",
|
||||
"value_proof": "declining fire-attempt rate (token_class core.provenance:ledger-hand-append); retire on false-positive evidence"
|
||||
},
|
||||
{
|
||||
"id": "core.skills:copy-into-installed",
|
||||
"predicate_class": "pure",
|
||||
"mode": "deny",
|
||||
"matchers": [
|
||||
{
|
||||
"tools": [
|
||||
"Bash"
|
||||
],
|
||||
"field": "command",
|
||||
"pattern": "(^|[;&|][[:space:]]*)(cp|rsync|mv)[[:space:]][^;&|]*[[:space:]][^[:space:];&|]*\\.(claude|codex|gemini)/skills(/[^[:space:];&|]*)?[[:space:]]*([;&|]|$)"
|
||||
}
|
||||
],
|
||||
"route_message": "~/.claude/skills (and .codex/.gemini) are INSTALLED/symlinked copies — a cp/rsync/mv into them writes through the symlink into whatever branch the factory checkout is on, or is overwritten on install. Link instead: ao skills link (repo) or link-skill --all (dotfiles). Closes the Bash gap of the Edit|Write-only installed-skill-edit-guard.",
|
||||
"rationale": "Global CLAUDE.md 'Never cp into ~/.claude/skills'. Destination position is enforced: the installed-skills path must be the LAST token of the command segment, so copying FROM an installed dir out to the repo never fires.",
|
||||
"value_proof": "declining fire-attempt rate (token_class core.skills:copy-into-installed); retire on false-positive evidence"
|
||||
}
|
||||
]
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# lint-policies.sh — mechanical enforcement of the hooks-manifest.v2 contract
|
||||
# (age-bhsz). jq + grep only (no jsonschema dependency), so the discipline is
|
||||
# checkable in bats, pre-commit, and CI alike.
|
||||
#
|
||||
# Checks, in order:
|
||||
# 1. registry parses as JSON and declares schema hooks-manifest.v2
|
||||
# 2. every policy has id / predicate_class / mode / matchers / route_message /
|
||||
# rationale / value_proof
|
||||
# 3. id matches domain.object:token and is unique
|
||||
# 4. mode is deny|route|audit; predicate_class is pure|lookup|stateful
|
||||
# 5. PREDICATE DISCIPLINE: predicate_class != pure => mode == audit
|
||||
# (the #511 anti-lesson — stateful guards are barred from blocking
|
||||
# until promoted from audit with reviewed fires)
|
||||
# 6. matcher tools are Bash|Edit|Write; field is command|file_path
|
||||
# 7. every pattern compiles under grep -E on this host
|
||||
#
|
||||
# Usage: lint-policies.sh [registry.json] (default: ../policies/policies.json)
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck disable=SC1007 # CDPATH= scopes an empty CDPATH to the cd, intentionally
|
||||
script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
registry="${1:-${script_dir}/../policies/policies.json}"
|
||||
|
||||
fail() { printf 'lint-policies: FAIL: %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required"
|
||||
[ -f "$registry" ] || fail "registry not found: ${registry}"
|
||||
|
||||
jq empty "$registry" 2>/dev/null || fail "not valid JSON: ${registry}"
|
||||
|
||||
schema="$(jq -r '.schema // ""' "$registry")"
|
||||
[ "$schema" = "hooks-manifest.v2" ] || fail "schema must be hooks-manifest.v2, got: '${schema}'"
|
||||
|
||||
count="$(jq '.policies | length' "$registry")"
|
||||
[ "$count" -ge 1 ] || fail "policies array is empty"
|
||||
|
||||
# Required fields present and non-empty on every policy.
|
||||
missing="$(jq -r '
|
||||
.policies[]
|
||||
| . as $p
|
||||
| ["id","predicate_class","mode","matchers","route_message","rationale","value_proof"][]
|
||||
| select(($p[.] // "") == "" or ($p[.] == null))
|
||||
| ($p.id // "<no-id>") + " missing " + .
|
||||
' "$registry")"
|
||||
[ -z "$missing" ] || fail "$missing"
|
||||
|
||||
# id format + uniqueness.
|
||||
bad_id="$(jq -r '.policies[].id | select(test("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$") | not)' "$registry")"
|
||||
[ -z "$bad_id" ] || fail "id not domain.object:token: ${bad_id}"
|
||||
dup_id="$(jq -r '[.policies[].id] | group_by(.) | map(select(length > 1) | .[0]) | .[]' "$registry")"
|
||||
[ -z "$dup_id" ] || fail "duplicate policy id: ${dup_id}"
|
||||
|
||||
# Enums.
|
||||
bad_mode="$(jq -r '.policies[] | select(.mode | IN("deny","route","audit") | not) | .id' "$registry")"
|
||||
[ -z "$bad_mode" ] || fail "invalid mode on: ${bad_mode}"
|
||||
bad_class="$(jq -r '.policies[] | select(.predicate_class | IN("pure","lookup","stateful") | not) | .id' "$registry")"
|
||||
[ -z "$bad_class" ] || fail "invalid predicate_class on: ${bad_class}"
|
||||
|
||||
# THE DISCIPLINE RULE: non-pure predicates may only audit.
|
||||
undisciplined="$(jq -r '.policies[] | select(.predicate_class != "pure" and .mode != "audit") | .id' "$registry")"
|
||||
[ -z "$undisciplined" ] || fail "predicate discipline violation (non-pure predicate in blocking mode): ${undisciplined}"
|
||||
|
||||
# Matcher shape.
|
||||
bad_tool="$(jq -r '.policies[] | .id as $id | .matchers[].tools[] | select(IN("Bash","Edit","Write") | not) | $id + " tool " + .' "$registry")"
|
||||
[ -z "$bad_tool" ] || fail "invalid matcher tool: ${bad_tool}"
|
||||
bad_field="$(jq -r '.policies[] | .id as $id | .matchers[] | select(.field | IN("command","file_path") | not) | $id' "$registry")"
|
||||
[ -z "$bad_field" ] || fail "invalid matcher field on: ${bad_field}"
|
||||
|
||||
# Every pattern must compile under grep -E on this host.
|
||||
# join(), not @tsv: TSV escaping mangles backslashes inside patterns.
|
||||
while IFS=$'\x1f' read -r pid pattern; do
|
||||
[ -n "$pid" ] || continue
|
||||
if ! printf '' | grep -qE "$pattern" 2>/dev/null; then
|
||||
# grep exits 1 on no-match with a VALID pattern; only exit >1 is a compile error.
|
||||
rc=$?
|
||||
[ "$rc" -le 1 ] || fail "pattern does not compile (grep -E rc=${rc}) on ${pid}: ${pattern}"
|
||||
fi
|
||||
done < <(jq -r '.policies[] | .id as $id | .matchers[] | [$id, .pattern] | join("")' "$registry")
|
||||
|
||||
printf 'lint-policies: OK (%s policies)\n' "$count"
|
||||
@@ -165,6 +165,53 @@ positive value"), the criterion whose absence killed 2.x hooks (#511).
|
||||
|
||||
Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md)
|
||||
|
||||
## Policy Dispatch Engine (opt-in)
|
||||
|
||||
The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher —
|
||||
[hooks/policy-dispatch.sh](hooks/policy-dispatch.sh) — evaluating a
|
||||
**policies-as-data** registry
|
||||
([policies/policies.json](policies/policies.json), contract
|
||||
`schemas/hooks-manifest.v2.schema.json`) instead of N hand-wired settings
|
||||
entries. This is the membrane at tool-call altitude: same vocabulary, lower
|
||||
altitude than the pawl/gate at push time.
|
||||
|
||||
Per policy: dcg-style id (`domain.object:token`), `mode: deny | route | audit`,
|
||||
matchers (tool + `command`/`file_path` regex), a `route_message` that names THE
|
||||
correct tool, a rationale, and a pre-registered `value_proof` (the ADR-0002
|
||||
lease-on-life: no proof accruing → retire the policy).
|
||||
|
||||
**Predicate discipline, schema-enforced** (the #511 anti-lesson): only
|
||||
`predicate_class: pure` — syntactic mistake-tokens over the command or file
|
||||
path — may `deny`/`route`. Lookup/stateful predicates ship `audit`-only until
|
||||
promoted with reviewed fires.
|
||||
[scripts/lint-policies.sh](scripts/lint-policies.sh) enforces this mechanically
|
||||
(jq-only; runs in bats and CI).
|
||||
|
||||
Semantics: happy path = exit 0, zero output. `deny` = exit 2 + one stderr
|
||||
route line (full message once per session, short line after — every attempt
|
||||
still blocks). `route` = exit 0 + `permissionDecision:"ask"` JSON. `audit` =
|
||||
allow + record. Every fire appends one hashed guardrail-telemetry line
|
||||
(`token_class` = policy id, plus `mode`/`decision`). Waive once with
|
||||
`AOP_WAIVE=<policy-id>`, or a `policy-waivers` file line
|
||||
`<policy-id> <expiry-epoch>`. Missing registry or jq fails OPEN.
|
||||
|
||||
Day-1 enforce cohort (age-wnyt, all pure-regex, high-pain):
|
||||
|
||||
| Policy | Blocks | Routes to |
|
||||
|---|---|---|
|
||||
| `core.git:add-beads-ledger` | `git add` naming `_beads/` (private ledger leak is one-way) | push the ledger repo itself — never `git add _beads` in the public tree |
|
||||
| `core.provenance:ledger-hand-append` | redirect/`tee`/Edit/Write onto `docs/provenance/ledger.jsonl` (hash-chained, sealed) | `ao provenance add` |
|
||||
| `core.skills:copy-into-installed` | `cp`/`rsync`/`mv` INTO `~/.claude|.codex|.gemini/skills` (dest-position enforced) | `ao skills link` |
|
||||
|
||||
Ships INERT — opt-in installer (lints the registry before wiring):
|
||||
|
||||
```bash
|
||||
scripts/install-policy-dispatch.sh # user scope; --project for project scope
|
||||
```
|
||||
|
||||
Contract tests: `tests/scripts/policy-dispatch.bats` (block+message+telemetry
|
||||
per policy, stray-stdout hazard, waivers, audit/route modes, fail-open).
|
||||
|
||||
## Writing Your Own Hook
|
||||
|
||||
**Minimal Python:**
|
||||
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# policy-dispatch.sh — ONE PreToolUse dispatcher over a policies-as-data registry
|
||||
# (age-bhsz / epic age-4qw1: admission control — the membrane at tool-call altitude).
|
||||
#
|
||||
# Reads the PreToolUse JSON once from stdin, evaluates every applicable policy
|
||||
# from the registry, and emits one decision:
|
||||
# deny -> exit 2 + route message on stderr (blocks the tool call)
|
||||
# route -> exit 0 + permissionDecision:"ask" JSON on stdout (surfaces a dialog)
|
||||
# audit -> exit 0, silent; the fire is only recorded in telemetry
|
||||
# Happy path: exit 0, ZERO output (stray stdout on exit-0 is parsed as JSON by
|
||||
# the harness and breaks the tool call — see skills/cc-hooks/SKILL.md).
|
||||
#
|
||||
# Predicate discipline (the #511 anti-lesson, schema-enforced by
|
||||
# scripts/lint-policies.sh + schemas/hooks-manifest.v2.schema.json): predicates
|
||||
# are SYNTACTIC mistake-tokens only — pure regex over tool_input.command or
|
||||
# tool_input.file_path. Policies with predicate_class other than "pure" are
|
||||
# structurally barred from deny/route until promoted from audit.
|
||||
#
|
||||
# Registry resolution order: $AOP_POLICIES, then policies.json beside this
|
||||
# script (installed layout), then ../policies/policies.json (repo layout).
|
||||
#
|
||||
# Waivers: AOP_WAIVE="id1,id2" env (one-shot), or a waiver file
|
||||
# ($AGENTOPS_HOME/policy-waivers, default ~/.agents/ao/policy-waivers) with
|
||||
# lines "<policy-id> <expiry-unix-epoch>".
|
||||
#
|
||||
# Telemetry: one JSONL line per fire (deny, route, audit, waived) appended to
|
||||
# $AGENTOPS_GUARDRAIL_TELEMETRY (default $AGENTOPS_HOME/guardrail-telemetry.jsonl,
|
||||
# AGENTOPS_HOME defaulting to ~/.agents/ao). Schema is a superset of the
|
||||
# installed-skill-edit-guard line: {ts, session, token_class, path_sha256} plus
|
||||
# {mode, decision}. The matched value is hashed, never stored raw. Telemetry
|
||||
# failure never changes the exit decision.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck disable=SC1007 # CDPATH= scopes an empty CDPATH to the cd, intentionally
|
||||
script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
registry="${AOP_POLICIES:-}"
|
||||
if [ -z "$registry" ]; then
|
||||
if [ -f "${script_dir}/policies.json" ]; then
|
||||
registry="${script_dir}/policies.json"
|
||||
else
|
||||
registry="${script_dir}/../policies/policies.json"
|
||||
fi
|
||||
fi
|
||||
# Fail OPEN if the registry or jq is unavailable: an admission layer that
|
||||
# bricks every tool call on a missing file is worse than no layer (dcg
|
||||
# precedent: fail-open on timeout).
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
[ -f "$registry" ] || exit 0
|
||||
|
||||
input="$(cat)"
|
||||
tool="$(printf '%s' "$input" | jq -r '.tool_name // ""')"
|
||||
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')"
|
||||
fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')"
|
||||
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
|
||||
[ -n "$tool" ] || exit 0
|
||||
|
||||
hash_value() {
|
||||
# SHA-256 of $1 for telemetry privacy; empty string when no hasher exists.
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | sha256sum | cut -d' ' -f1
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//'
|
||||
fi
|
||||
}
|
||||
|
||||
emit_telemetry() {
|
||||
# $1 policy id, $2 mode, $3 decision, $4 matched value
|
||||
local h
|
||||
h="$(hash_value "$4")"
|
||||
[ -n "$h" ] || return 0
|
||||
local tdir="${AGENTOPS_HOME:-${HOME}/.agents/ao}"
|
||||
local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
|
||||
mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
|
||||
local line
|
||||
line="$(jq -nc \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg session "$sid" \
|
||||
--arg token_class "$1" \
|
||||
--arg path_sha256 "$h" \
|
||||
--arg mode "$2" \
|
||||
--arg decision "$3" \
|
||||
'{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision}' \
|
||||
)" || return 0
|
||||
printf '%s\n' "$line" >> "$tfile" 2>/dev/null || return 0
|
||||
}
|
||||
|
||||
waived() {
|
||||
# $1 policy id -> 0 when a waiver applies.
|
||||
case ",${AOP_WAIVE:-}," in
|
||||
*",$1,"*) return 0 ;;
|
||||
esac
|
||||
local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME}/.agents/ao}/policy-waivers}"
|
||||
[ -f "$wfile" ] || return 1
|
||||
local now id expiry
|
||||
now="$(date +%s)"
|
||||
while read -r id expiry _; do
|
||||
[ "$id" = "$1" ] || continue
|
||||
case "$expiry" in (*[!0-9]*|'') continue ;; esac
|
||||
[ "$expiry" -gt "$now" ] && return 0
|
||||
done < "$wfile"
|
||||
return 1
|
||||
}
|
||||
|
||||
deny_id=""; deny_msg=""; deny_val=""
|
||||
route_id=""; route_msg=""; route_val=""
|
||||
|
||||
# Iterate matchers flattened as unit-separator-joined fields:
|
||||
# id / mode / field / pattern / route_message. NOT @tsv — TSV escaping mangles
|
||||
# backslashes inside regex patterns (\. arrives as \\.), silently breaking
|
||||
# every pattern that escapes a metacharacter.
|
||||
while IFS=$'\x1f' read -r pid pmode pfield ppattern pmsg; do
|
||||
[ -n "$pid" ] || continue
|
||||
case "$pfield" in
|
||||
command) val="$cmd" ;;
|
||||
file_path) val="$fpath" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ -n "$val" ] || continue
|
||||
printf '%s' "$val" | grep -qE "$ppattern" || continue
|
||||
if waived "$pid"; then
|
||||
emit_telemetry "$pid" "$pmode" "waived" "$val"
|
||||
continue
|
||||
fi
|
||||
case "$pmode" in
|
||||
deny)
|
||||
if [ -z "$deny_id" ]; then deny_id="$pid"; deny_msg="$pmsg"; deny_val="$val"; fi
|
||||
;;
|
||||
route)
|
||||
if [ -z "$route_id" ]; then route_id="$pid"; route_msg="$pmsg"; route_val="$val"; fi
|
||||
;;
|
||||
audit)
|
||||
emit_telemetry "$pid" "audit" "audit" "$val"
|
||||
;;
|
||||
esac
|
||||
done < <(jq -r --arg tool "$tool" '
|
||||
.policies[]
|
||||
| . as $p
|
||||
| .matchers[]
|
||||
| select(.tools | index($tool))
|
||||
| [$p.id, $p.mode, .field, .pattern, ($p.route_message // "")]
|
||||
| join("")
|
||||
' "$registry" 2>/dev/null)
|
||||
|
||||
if [ -n "$deny_id" ]; then
|
||||
emit_telemetry "$deny_id" "deny" "deny" "$deny_val"
|
||||
sdir="${TMPDIR:-/tmp}/aop-policy-dispatch"
|
||||
sentinel="${sdir}/${sid//\//_}-${deny_id//[^a-zA-Z0-9]/_}"
|
||||
if [ -f "$sentinel" ]; then
|
||||
printf '⛔ policy %s: blocked (reason shown earlier this session).\n' "$deny_id" >&2
|
||||
else
|
||||
mkdir -p "$sdir" 2>/dev/null || true
|
||||
: > "$sentinel" 2>/dev/null || true
|
||||
printf '⛔ policy %s\n%s\n' "$deny_id" "$deny_msg" >&2
|
||||
fi
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ -n "$route_id" ]; then
|
||||
emit_telemetry "$route_id" "route" "ask" "$route_val"
|
||||
jq -nc --arg reason "policy ${route_id}: ${route_msg}" \
|
||||
'{hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"ask", permissionDecisionReason:$reason}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"schema": "hooks-manifest.v2",
|
||||
"comment": "Policies-as-data registry for policy-dispatch.sh (age-bhsz, epic age-4qw1). Predicate discipline: only predicate_class 'pure' (syntactic regex over tool_input.command / tool_input.file_path) may carry mode deny|route; lookup/stateful predicates ship audit-only until promoted with reviewed fires. Enforced by skills/cc-hooks/scripts/lint-policies.sh against schemas/hooks-manifest.v2.schema.json. Patterns are POSIX ERE (BSD grep -E compatible: no \\b, no lookaround).",
|
||||
"policies": [
|
||||
{
|
||||
"id": "core.git:add-beads-ledger",
|
||||
"predicate_class": "pure",
|
||||
"mode": "deny",
|
||||
"matchers": [
|
||||
{
|
||||
"tools": [
|
||||
"Bash"
|
||||
],
|
||||
"field": "command",
|
||||
"pattern": "(^|[;&|][[:space:]]*)git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+add[[:space:]]([^;&|]*[[:space:]/=])?_beads"
|
||||
}
|
||||
],
|
||||
"route_message": "_beads/ is the PRIVATE bead ledger (its own git repo) — never stage it into the public tree; the leak is one-way. Sync it by pushing the ledger repo itself: (cd _beads && git push)",
|
||||
"rationale": "CLAUDE.md footgun row + memory agentops-br-private-ledger. Only the explicit '_beads' path form is matched; 'git add -A' silently sweeping _beads/ is the stateful variant and stays out of deny per predicate discipline.",
|
||||
"value_proof": "declining fire-attempt rate in guardrail telemetry (token_class core.git:add-beads-ledger); retire on false-positive evidence"
|
||||
},
|
||||
{
|
||||
"id": "core.provenance:ledger-hand-append",
|
||||
"predicate_class": "pure",
|
||||
"mode": "deny",
|
||||
"matchers": [
|
||||
{
|
||||
"tools": [
|
||||
"Bash"
|
||||
],
|
||||
"field": "command",
|
||||
"pattern": "(>>?[[:space:]]*[^[:space:];&|]*docs/provenance/ledger\\.jsonl)|(tee[[:space:]]+(-a[[:space:]]+)?[^[:space:];&|]*docs/provenance/ledger\\.jsonl)"
|
||||
},
|
||||
{
|
||||
"tools": [
|
||||
"Edit",
|
||||
"Write"
|
||||
],
|
||||
"field": "file_path",
|
||||
"pattern": "(^|/)docs/provenance/ledger\\.jsonl$"
|
||||
}
|
||||
],
|
||||
"route_message": "docs/provenance/ledger.jsonl is HASH-CHAINED (prev_hash/payload_hash/hash on every record) and SEALED — a hand-written row breaks VerifyChain for every record after it. Append through the owning command instead: ao provenance add (schema-validated, sealed onto the chain tip)",
|
||||
"rationale": "Memory pawl-gated-land-flow ('SEALED, never hand-append'). Reads (grep/jq/cat with no redirect onto the file) never match.",
|
||||
"value_proof": "declining fire-attempt rate (token_class core.provenance:ledger-hand-append); retire on false-positive evidence"
|
||||
},
|
||||
{
|
||||
"id": "core.skills:copy-into-installed",
|
||||
"predicate_class": "pure",
|
||||
"mode": "deny",
|
||||
"matchers": [
|
||||
{
|
||||
"tools": [
|
||||
"Bash"
|
||||
],
|
||||
"field": "command",
|
||||
"pattern": "(^|[;&|][[:space:]]*)(cp|rsync|mv)[[:space:]][^;&|]*[[:space:]][^[:space:];&|]*\\.(claude|codex|gemini)/skills(/[^[:space:];&|]*)?[[:space:]]*([;&|]|$)"
|
||||
}
|
||||
],
|
||||
"route_message": "~/.claude/skills (and .codex/.gemini) are INSTALLED/symlinked copies — a cp/rsync/mv into them writes through the symlink into whatever branch the factory checkout is on, or is overwritten on install. Link instead: ao skills link (repo) or link-skill --all (dotfiles). Closes the Bash gap of the Edit|Write-only installed-skill-edit-guard.",
|
||||
"rationale": "Global CLAUDE.md 'Never cp into ~/.claude/skills'. Destination position is enforced: the installed-skills path must be the LAST token of the command segment, so copying FROM an installed dir out to the repo never fires.",
|
||||
"value_proof": "declining fire-attempt rate (token_class core.skills:copy-into-installed); retire on false-positive evidence"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# lint-policies.sh — mechanical enforcement of the hooks-manifest.v2 contract
|
||||
# (age-bhsz). jq + grep only (no jsonschema dependency), so the discipline is
|
||||
# checkable in bats, pre-commit, and CI alike.
|
||||
#
|
||||
# Checks, in order:
|
||||
# 1. registry parses as JSON and declares schema hooks-manifest.v2
|
||||
# 2. every policy has id / predicate_class / mode / matchers / route_message /
|
||||
# rationale / value_proof
|
||||
# 3. id matches domain.object:token and is unique
|
||||
# 4. mode is deny|route|audit; predicate_class is pure|lookup|stateful
|
||||
# 5. PREDICATE DISCIPLINE: predicate_class != pure => mode == audit
|
||||
# (the #511 anti-lesson — stateful guards are barred from blocking
|
||||
# until promoted from audit with reviewed fires)
|
||||
# 6. matcher tools are Bash|Edit|Write; field is command|file_path
|
||||
# 7. every pattern compiles under grep -E on this host
|
||||
#
|
||||
# Usage: lint-policies.sh [registry.json] (default: ../policies/policies.json)
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck disable=SC1007 # CDPATH= scopes an empty CDPATH to the cd, intentionally
|
||||
script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
registry="${1:-${script_dir}/../policies/policies.json}"
|
||||
|
||||
fail() { printf 'lint-policies: FAIL: %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required"
|
||||
[ -f "$registry" ] || fail "registry not found: ${registry}"
|
||||
|
||||
jq empty "$registry" 2>/dev/null || fail "not valid JSON: ${registry}"
|
||||
|
||||
schema="$(jq -r '.schema // ""' "$registry")"
|
||||
[ "$schema" = "hooks-manifest.v2" ] || fail "schema must be hooks-manifest.v2, got: '${schema}'"
|
||||
|
||||
count="$(jq '.policies | length' "$registry")"
|
||||
[ "$count" -ge 1 ] || fail "policies array is empty"
|
||||
|
||||
# Required fields present and non-empty on every policy.
|
||||
missing="$(jq -r '
|
||||
.policies[]
|
||||
| . as $p
|
||||
| ["id","predicate_class","mode","matchers","route_message","rationale","value_proof"][]
|
||||
| select(($p[.] // "") == "" or ($p[.] == null))
|
||||
| ($p.id // "<no-id>") + " missing " + .
|
||||
' "$registry")"
|
||||
[ -z "$missing" ] || fail "$missing"
|
||||
|
||||
# id format + uniqueness.
|
||||
bad_id="$(jq -r '.policies[].id | select(test("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$") | not)' "$registry")"
|
||||
[ -z "$bad_id" ] || fail "id not domain.object:token: ${bad_id}"
|
||||
dup_id="$(jq -r '[.policies[].id] | group_by(.) | map(select(length > 1) | .[0]) | .[]' "$registry")"
|
||||
[ -z "$dup_id" ] || fail "duplicate policy id: ${dup_id}"
|
||||
|
||||
# Enums.
|
||||
bad_mode="$(jq -r '.policies[] | select(.mode | IN("deny","route","audit") | not) | .id' "$registry")"
|
||||
[ -z "$bad_mode" ] || fail "invalid mode on: ${bad_mode}"
|
||||
bad_class="$(jq -r '.policies[] | select(.predicate_class | IN("pure","lookup","stateful") | not) | .id' "$registry")"
|
||||
[ -z "$bad_class" ] || fail "invalid predicate_class on: ${bad_class}"
|
||||
|
||||
# THE DISCIPLINE RULE: non-pure predicates may only audit.
|
||||
undisciplined="$(jq -r '.policies[] | select(.predicate_class != "pure" and .mode != "audit") | .id' "$registry")"
|
||||
[ -z "$undisciplined" ] || fail "predicate discipline violation (non-pure predicate in blocking mode): ${undisciplined}"
|
||||
|
||||
# Matcher shape.
|
||||
bad_tool="$(jq -r '.policies[] | .id as $id | .matchers[].tools[] | select(IN("Bash","Edit","Write") | not) | $id + " tool " + .' "$registry")"
|
||||
[ -z "$bad_tool" ] || fail "invalid matcher tool: ${bad_tool}"
|
||||
bad_field="$(jq -r '.policies[] | .id as $id | .matchers[] | select(.field | IN("command","file_path") | not) | $id' "$registry")"
|
||||
[ -z "$bad_field" ] || fail "invalid matcher field on: ${bad_field}"
|
||||
|
||||
# Every pattern must compile under grep -E on this host.
|
||||
# join(), not @tsv: TSV escaping mangles backslashes inside patterns.
|
||||
while IFS=$'\x1f' read -r pid pattern; do
|
||||
[ -n "$pid" ] || continue
|
||||
if ! printf '' | grep -qE "$pattern" 2>/dev/null; then
|
||||
# grep exits 1 on no-match with a VALID pattern; only exit >1 is a compile error.
|
||||
rc=$?
|
||||
[ "$rc" -le 1 ] || fail "pattern does not compile (grep -E rc=${rc}) on ${pid}: ${pattern}"
|
||||
fi
|
||||
done < <(jq -r '.policies[] | .id as $id | .matchers[] | [$id, .pattern] | join("")' "$registry")
|
||||
|
||||
printf 'lint-policies: OK (%s policies)\n' "$count"
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bats
|
||||
# age-bhsz + age-wnyt — contract for the PreToolUse policy dispatcher and the
|
||||
# day-1 enforce cohort (git-add-_beads, ledger hand-append, cp-into-skills).
|
||||
#
|
||||
# GWT from the bead: GIVEN a registered deny policy and a matching tool call,
|
||||
# WHEN the dispatcher runs, THEN the call blocks (exit 2) with the routing
|
||||
# message and a telemetry line records it; GIVEN audit mode, THEN the call
|
||||
# proceeds and only the events record.
|
||||
#
|
||||
# Fixture fidelity: every case round-trips the REAL PreToolUse JSON input shape
|
||||
# (tool_name / tool_input / session_id) built with jq — never a hand-built
|
||||
# string — matching the harness contract in skills/cc-hooks/references/HOOK-EVENTS.md.
|
||||
|
||||
DISPATCH="${DISPATCH:-$BATS_TEST_DIRNAME/../../skills/cc-hooks/hooks/policy-dispatch.sh}"
|
||||
LINT="${LINT:-$BATS_TEST_DIRNAME/../../skills/cc-hooks/scripts/lint-policies.sh}"
|
||||
REGISTRY="${REGISTRY:-$BATS_TEST_DIRNAME/../../skills/cc-hooks/policies/policies.json}"
|
||||
|
||||
setup() {
|
||||
export TMPDIR="$(mktemp -d)"
|
||||
export AGENTOPS_GUARDRAIL_TELEMETRY="$TMPDIR/telemetry.jsonl"
|
||||
export AOP_POLICIES="$REGISTRY"
|
||||
export AOP_WAIVER_FILE="$TMPDIR/waivers"
|
||||
unset AOP_WAIVE || true
|
||||
}
|
||||
teardown() { rm -rf "$TMPDIR"; }
|
||||
|
||||
# $1 = tool_name, $2 = field (command|file_path), $3 = value, $4 = session id
|
||||
run_dispatch() {
|
||||
jq -nc --arg tool "$1" --arg k "$2" --arg v "$3" \
|
||||
--arg s "${4:-sess-$RANDOM-$BATS_TEST_NUMBER}" \
|
||||
'{tool_name:$tool, tool_input:{($k):$v}, session_id:$s}' \
|
||||
| bash "$DISPATCH"
|
||||
}
|
||||
|
||||
telemetry_lines() {
|
||||
[ -f "$AGENTOPS_GUARDRAIL_TELEMETRY" ] || { echo 0; return; }
|
||||
wc -l < "$AGENTOPS_GUARDRAIL_TELEMETRY" | tr -d ' '
|
||||
}
|
||||
|
||||
# ---------- registry hygiene ------------------------------------------------
|
||||
|
||||
@test "lint: shipped registry passes the v2 contract" {
|
||||
run bash "$LINT" "$REGISTRY"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"OK"* ]]
|
||||
}
|
||||
|
||||
@test "lint: a non-pure predicate in deny mode is rejected (predicate discipline)" {
|
||||
bad="$TMPDIR/bad.json"
|
||||
jq '.policies[0].predicate_class = "stateful"' "$REGISTRY" > "$bad"
|
||||
run bash "$LINT" "$bad"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"predicate discipline"* ]]
|
||||
}
|
||||
|
||||
# ---------- policy (a): core.git:add-beads-ledger ---------------------------
|
||||
|
||||
@test "FIRE deny: git add _beads/issues.jsonl blocks with route message + telemetry" {
|
||||
run run_dispatch Bash command "git add _beads/issues.jsonl"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"core.git:add-beads-ledger"* ]]
|
||||
[[ "$output" == *"pushing the ledger repo itself"* ]]
|
||||
[ "$(telemetry_lines)" -eq 1 ]
|
||||
run jq -r '.token_class + " " + .decision' "$AGENTOPS_GUARDRAIL_TELEMETRY"
|
||||
[ "$output" = "core.git:add-beads-ledger deny" ]
|
||||
}
|
||||
|
||||
@test "FIRE deny: chained 'cd x && git add _beads' still blocks" {
|
||||
run run_dispatch Bash command "cd /tmp/x && git add _beads"
|
||||
[ "$status" -eq 2 ]
|
||||
}
|
||||
|
||||
@test "SILENT: git add of a normal path — exit 0, zero output, zero telemetry" {
|
||||
run run_dispatch Bash command "git add docs/research/notes.md"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
[ "$(telemetry_lines)" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "SILENT: _beads mentioned outside a git add segment does not fire" {
|
||||
run run_dispatch Bash command "grep -r _beads docs/ && git add docs/a.md"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "SILENT: git add of a file merely containing the substring (my_beadsfile) does not fire" {
|
||||
run run_dispatch Bash command "git add src/my_beadsfile.go"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
# ---------- policy (b): core.provenance:ledger-hand-append ------------------
|
||||
|
||||
@test "FIRE deny: >> redirect onto the provenance ledger blocks, routes to provenance add" {
|
||||
run run_dispatch Bash command "echo '{}' >> docs/provenance/ledger.jsonl"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"ao provenance add"* ]]
|
||||
[ "$(telemetry_lines)" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "FIRE deny: tee -a onto the provenance ledger blocks" {
|
||||
run run_dispatch Bash command "some-cmd | tee -a docs/provenance/ledger.jsonl"
|
||||
[ "$status" -eq 2 ]
|
||||
}
|
||||
|
||||
@test "FIRE deny: Write tool targeting the ledger file_path blocks" {
|
||||
run run_dispatch Write file_path "docs/provenance/ledger.jsonl"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"core.provenance:ledger-hand-append"* ]]
|
||||
}
|
||||
|
||||
@test "SILENT: reading the ledger (jq, no redirect) does not fire" {
|
||||
run run_dispatch Bash command "jq -r '.hash' docs/provenance/ledger.jsonl | tail -1"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "SILENT: the owning append command itself does not fire" {
|
||||
run run_dispatch Bash command "./cli/bin/ao provenance add decision-1 artifact-2 --relation wasGeneratedBy"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
# ---------- policy (c): core.skills:copy-into-installed ---------------------
|
||||
|
||||
@test "FIRE deny: cp -r into ~/.claude/skills blocks, routes to ao skills link" {
|
||||
run run_dispatch Bash command "cp -r skills/foo $HOME/.claude/skills/"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"ao skills link"* ]]
|
||||
}
|
||||
|
||||
@test "FIRE deny: rsync into ~/.codex/skills/foo blocks" {
|
||||
run run_dispatch Bash command "rsync -a build/ $HOME/.codex/skills/foo"
|
||||
[ "$status" -eq 2 ]
|
||||
}
|
||||
|
||||
@test "SILENT: copying FROM an installed skills dir OUT to the repo does not fire" {
|
||||
run run_dispatch Bash command "cp $HOME/.claude/skills/foo/SKILL.md /tmp/inspect.md"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
# ---------- dispatcher mechanics -------------------------------------------
|
||||
|
||||
@test "deny on second attempt in the same session STILL blocks (short message)" {
|
||||
sid="same-session-$BATS_TEST_NUMBER"
|
||||
run run_dispatch Bash command "git add _beads/x" "$sid"
|
||||
[ "$status" -eq 2 ]
|
||||
run run_dispatch Bash command "git add _beads/x" "$sid"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"blocked"* ]]
|
||||
}
|
||||
|
||||
@test "waiver via AOP_WAIVE allows the call and records decision=waived" {
|
||||
AOP_WAIVE="core.git:add-beads-ledger" run run_dispatch Bash command "git add _beads/x"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
run jq -r '.decision' "$AGENTOPS_GUARDRAIL_TELEMETRY"
|
||||
[ "$output" = "waived" ]
|
||||
}
|
||||
|
||||
@test "waiver file with unexpired entry allows the call" {
|
||||
echo "core.git:add-beads-ledger $(( $(date +%s) + 3600 ))" > "$AOP_WAIVER_FILE"
|
||||
run run_dispatch Bash command "git add _beads/x"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "waiver file with EXPIRED entry still blocks" {
|
||||
echo "core.git:add-beads-ledger $(( $(date +%s) - 10 ))" > "$AOP_WAIVER_FILE"
|
||||
run run_dispatch Bash command "git add _beads/x"
|
||||
[ "$status" -eq 2 ]
|
||||
}
|
||||
|
||||
@test "audit mode: call proceeds silently and only the telemetry records" {
|
||||
audited="$TMPDIR/audited.json"
|
||||
jq '.policies |= map(if .id == "core.git:add-beads-ledger" then .mode = "audit" else . end)' \
|
||||
"$REGISTRY" > "$audited"
|
||||
AOP_POLICIES="$audited" run run_dispatch Bash command "git add _beads/x"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
run jq -r '.decision' "$AGENTOPS_GUARDRAIL_TELEMETRY"
|
||||
[ "$output" = "audit" ]
|
||||
}
|
||||
|
||||
@test "route mode: exit 0 with permissionDecision ask JSON on stdout" {
|
||||
routed="$TMPDIR/routed.json"
|
||||
jq '.policies |= map(if .id == "core.git:add-beads-ledger" then .mode = "route" else . end)' \
|
||||
"$REGISTRY" > "$routed"
|
||||
AOP_POLICIES="$routed" run run_dispatch Bash command "git add _beads/x"
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$output" | jq -e '.hookSpecificOutput.permissionDecision == "ask"'
|
||||
}
|
||||
|
||||
@test "stray-stdout hazard: every exit-0 path emits NOTHING on stdout (non-route)" {
|
||||
for cmd in "ls -la" "git status" "echo hello"; do
|
||||
run run_dispatch Bash command "$cmd"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
done
|
||||
}
|
||||
|
||||
@test "unmatched tool (Read) is silent even with a matching-looking input" {
|
||||
run run_dispatch Read command "git add _beads/x"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "missing registry fails OPEN (exit 0, silent)" {
|
||||
AOP_POLICIES="$TMPDIR/does-not-exist.json" run run_dispatch Bash command "git add _beads/x"
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "telemetry hashes the matched value — raw command never lands in the file" {
|
||||
run run_dispatch Bash command "git add _beads/issues.jsonl"
|
||||
[ "$status" -eq 2 ]
|
||||
! grep -q "issues.jsonl" "$AGENTOPS_GUARDRAIL_TELEMETRY"
|
||||
run jq -r '.path_sha256 | length' "$AGENTOPS_GUARDRAIL_TELEMETRY"
|
||||
[ "$output" = "64" ]
|
||||
}
|
||||
Reference in New Issue
Block a user