Aperion Shield — Local MCP Guardrails for AI Coding Agents
A tiny, local MCP server that sits between your IDE (Cursor, Claude Code, Cline, Continue, Windsurf, Zed, or any MCP host) and the tools your AI agent uses. It evaluates every tools/call against a 45-rule starter shieldset, an adaptive scoring layer (workspace probe, decision memory, burst detector), and an optional identity-gate that requires a verified human (ID.me, Okta, mock) before the most dangerous operations are allowed through. v0.7 adds Git pre-commit + pre-push hooks so the same rules run on the way to git even when the agent reaches around MCP via a direct shell. No cloud, no telemetry, no account. Free forever for individual developers.
v0.7 is the release that closes the MCP-bypass gap and turns Shield into a policy tuner you can run against your own logs.
aperion-shield --install-hooks(new headline): writes a managed Gitpre-commitandpre-pushhook into.git/hooks/. Same engine, sameshieldset.yaml, same severity tiers — now enforced on everygit commitandgit pusheven when the agent reaches around MCP via a direct shell. Idempotent (re-runnable), coexists with husky / pre-commit / lefthook via--chain-existing, honours bothgit --no-verifyandSHIELD_HOOKS_DISABLE=1for the rare legitimate bypass. Full section ↓aperion-shield --suggest-rules(new): reads your localshield_evalJSON-Lines audit log and emits text / markdown / yaml-patch suggestions across three classes — rules that never fire, rules that get consistently demoted by decision memory, and noisyWarnrules that should probably be downgraded.--suggest-format yaml-patchproduces splice-ready snippets forshieldset.yamlwith rationale comments. Full section ↓- Four new IDE quickstarts: Cline, Continue, Windsurf, Zed (joining Cursor + Claude Code). Schema differences are documented per-IDE in the README. Zed in particular uses
context_servers(notmcpServers) with a nestedcommand:{path,args}shape. - Test count: 192 (was 148 in v0.6.0). The +44 is 26 new unit tests (hooks install round-trips, diff parser fixtures, protected-branch glob matching, env override, audit JSONL parsing, RuleStats aggregator) and 18 new end-to-end integration tests (real tempdir git repos exercising install/uninstall idempotency, DROP DATABASE in a migration blocked,
rm -rf /in a shell script blocked, force-push tomainblocked, fast-forward allowed, audit-derived suggestion correctness, YAML splice shape).
v0.6.0 (2026-05-18) — earlier in this minor line
aperion-shield --diff: native Rust pre-merge behaviour-diff explainer for shieldset changes. Runs the engine over the same corpus under two shieldsets and emits a per-rule attribution of which lines flipped — text, markdown (PR-comment friendly), or JSON. CI gates:--fail-if-flipped,--fail-if-loosened,--fail-if-allows-loosened N. See shieldset-as-code Layer 4.- Three Dependabot advisories closed:
rustls-webpki 0.101.7 → 0.103.13(transitive viareqwest 0.12/rustls 0.23/hyper 1.x). Closes RUSTSEC-2026-0098, -0099, -0104.cargo auditnow clean against an empty ignore list. - OIDC callback server refactored to the hyper 1.x API. Identity-gated rules (ID.me, mock, Smartflow-mediated) work unchanged.
What it is
Aperion Shield is a local MCP middleman. You point your IDE at aperion-shield instead of pointing it directly at the upstream MCP server. Shield spawns the upstream as a child process, transparently proxies every JSON-RPC frame between IDE and tool, and intercepts tools/call requests so it can apply guardrail rules before they reach the tool.
DROP TABLE customers;, rm -rf $HOME, git push --force main, an unscoped UPDATE users SET …, or a similarly destructive operation, Shield catches it on your machine and either blocks it or waits for you to type approve <ticket-id> in a terminal — before the upstream tool ever sees the call.
It is not a hosted service. There is nothing to sign up for. The binary is a single self-contained executable; the rule engine and a starter ruleset are baked in. No internet access is required at runtime.
Why it exists
An AI coding agent operating in your IDE is, in effect, an autonomous user with broad tool access — your database, your terminal, your git remotes, your filesystem, your cloud APIs. Existing controls miss the most dangerous category of failure:
| Layer | Catches | Misses |
|---|---|---|
| IDE permission prompts | First-time access to a new tool / repo | The 200th call after you clicked "Always allow" |
| MCP server allowlists | Tools the key isn't allowed to call | Allowed-but-destructive calls |
| Your own code review | Bad code that lands in a PR | Tool calls that never produce a diff |
Aperion Shield closes that gap with a single principle: any operation that can destroy data, history, or production state stops for a half-second pause. Either it's denied outright (Critical), or it sits in a local approval inbox until you type approve or deny (High), or it passes through with a warning logged (Medium).
How it works
Shield is a faithful MCP stdio bridge. It transparently relays every JSON-RPC frame except tools/call, which it intercepts:
┌──────────────┐ stdin/stdout ┌────────────────────┐ stdin/stdout ┌──────────────────────┐
│ Your IDE │ ───────────────► │ aperion-shield │ ───────────────► │ upstream MCP server │
│ (Cursor / │ ◄─────────────── │ (rule engine + │ ◄─────────────── │ (npx @mcp/..., │
│ Claude Code)│ │ approval inbox) │ │ uvx ..., custom) │
└──────────────┘ └────────────────────┘ └──────────────────────┘
On a tools/call the engine walks the JSON-RPC params against each pre-compiled rule. SQL-aware extractors automatically pull from query, sql, or statement keys, so rules don't have to know each tool's parameter schema. If a rule fires, the engine returns one of four decisions:
- Allow — pass straight through (the common case).
- Allow with warn — pass through, log a warning to stderr.
- Block — return a JSON-RPC error to the IDE; the tool never runs.
- Approval required — pause until a human types
approve <ticket-id>in the local inbox (or auto-deny after a timeout).
Quick install
Pick whichever installation route fits your environment. All three produce the same aperion-shield binary.
Homebrew (macOS / Linux)
brew install AperionAI/tap/aperion-shield
Docker
docker run --rm -i \
ghcr.io/aperionai/shield:latest \
--help
Binary
# Linux / macOS / Windows
# x86_64 + aarch64
github.com/AperionAI/shield
/releases
Cursor setup
Open ~/.cursor/mcp.json (or settings → MCP). Find the MCP server you want to protect (e.g. postgres) and rewrite it so the command becomes aperion-shield and the original command moves to args after a -- separator:
{
"mcpServers": {
"postgres-shielded": {
"command": "aperion-shield",
"args": [
// Optional: start in shadow mode for the first day
"--shadow",
"--",
"npx", "-y", "@modelcontextprotocol/server-postgres",
"postgresql://localhost/mydb"
]
}
}
}
Restart Cursor. From the agent's perspective nothing has changed — the same tools appear, with the same names and schemas. Shield only steps in when a rule fires.
Claude Code setup
Open ~/.claude/config.json (or run claude mcp add) and wrap the upstream the same way:
{
"mcpServers": {
"github-shielded": {
"command": "aperion-shield",
"args": [
"--rules", "/path/to/shield.yaml",
"--",
"npx", "-y", "@modelcontextprotocol/server-github"
],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..." }
}
}
}
Cline · Continue · Windsurf · Zed (new in v0.7)
Same wrapper pattern, different config paths and (in one case) a different schema name:
| IDE | Config file | Schema notes |
|---|---|---|
| Cline | .vscode/cline_mcp_settings.json (per-workspace) or ~/.cline/cline_mcp_settings.json |
Same mcpServers shape as Cursor. |
| Continue | ~/.continue/config.json |
Note: top-level mcpServers is an array, not a map. Each entry takes name, command, args. |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
Identical to Cursor's mcpServers map schema. |
| Zed | ~/.config/zed/settings.json |
Uses context_servers (not mcpServers) with a nested command: {path, args} shape. Set "command": {"path": "aperion-shield", "args": ["--", "npx", "-y", "@modelcontextprotocol/server-postgres"]}. |
The README quickstart has fully-fleshed config snippets for each of these.
Docker
The published image at ghcr.io/aperionai/shield:latest is a multi-arch distroless image (~12 MB). Useful when running Shield inside a container alongside a containerised MCP server, or as a sidecar in CI:
docker run --rm -i \
-v "$PWD/shield.yaml:/etc/aperion-shield/shield.yaml:ro" \
-v "$PWD/.aperion-shield:/work/.aperion-shield" \
-w /work \
ghcr.io/aperionai/shield:latest \
--rules /etc/aperion-shield/shield.yaml \
-- \
npx -y @modelcontextprotocol/server-postgres "$PG_URL"
Binary or build from source
# Download from GitHub Releases (Linux x86_64 / aarch64,
# macOS x86_64 / aarch64, Windows x86_64)
curl -L -o aperion-shield.tar.gz \
https://github.com/AperionAI/shield/releases/download/shield-v0.7.0/aperion-shield-shield-v0.7.0-aarch64-apple-darwin.tar.gz
tar -xzf aperion-shield.tar.gz
./aperion-shield --version
# Or build from source — requires rustc 1.85+
git clone https://github.com/AperionAI/shield
cd shield
cargo build --release --locked
./target/release/aperion-shield --version
Operating modes
Shield runs in one of three modes, controlled by a CLI flag. The recommended path for any new ruleset is to start in --shadow for a day to surface false positives, then flip to enforce.
--shadow
Every decision is downgraded to allow + log. Use for the first day so you can read the warnings and tune the ruleset without breaking your workflow. Default for the bundled ruleset until you opt-in to enforce.
enforce (default)
Critical rules block; High rules send to the local approval inbox; Medium rules pass with a warning; Low rules pass silently. This is the steady-state mode once you trust the ruleset.
--auto-deny-high
Like enforce, but anything tagged High auto-denies instead of queueing for human approval. Use this on CI runners or unattended agent loops where there's no human to type approve.
Severity tiers and outcomes
| Severity | Outcome (enforce) | Returned to IDE | Latency on hit |
|---|---|---|---|
| Critical | Hard block | JSON-RPC error shield_blocked | None — instant deny |
| High | Local approval queue | JSON-RPC error shield_approval_required + ticket_id | Until you type approve / deny (default timeout 60s) |
| Medium | Allow with warn | Pass through; stderr warning | None |
| Low | Audit only | Pass through silently | None |
Local approval inbox
When a High-severity rule fires, Shield prints a structured warning to stderr (which Cursor and Claude Code surface in their tool-call panel) and waits up to 60 seconds for a decision in a local file:
# Cursor / Claude Code tool-call panel shows:
[shield] APPROVAL_REQUIRED rule='sql.unscoped_update' ticket='shld_4f17a3'
[shield] To approve, write 'approve shld_4f17a3' to ./.aperion-shield/inbox (waiting 60s)
# In a terminal in your project directory:
echo "approve shld_4f17a3" >> .aperion-shield/inbox
# Or deny:
echo "deny shld_4f17a3" >> .aperion-shield/inbox
./.aperion-shield/inbox in whichever directory the IDE launched aperion-shield from (usually your project root). The file is append-only, line-oriented, and never read after the ticket either decides or times out — so it never grows unbounded.
On timeout, the call is denied by default. Set --approval-timeout 600 to wait longer, or use --auto-approve-low if you want Low/Medium to skip the inbox entirely (default already does this).
Built-in default ruleset — 45 rules, 12 categories
Shield ships with a 45-rule starter shieldset baked into the binary so the engine never starts ruleless. The corpus spans the destructive patterns AI coding agents most often produce: ad-hoc database surgery, git history rewrites, filesystem and credential touches, supply-chain footguns, privilege escalation, cloud-resource deletes, Kubernetes / Docker cluster-wipes, plus a sliding-window anomaly detector and five LLM-response “plan” matchers.
shield.yaml with --rules (see Custom rules).
| Category | Rule ID | Severity | What it catches |
|---|---|---|---|
| SQL 9 | sql.drop_database | Critical | DROP DATABASE on any SQL tool |
sql.drop_table_or_schema | High | DROP TABLE / DROP SCHEMA / TRUNCATE TABLE | |
sql.alter_table_drop_column | High | ALTER TABLE … DROP COLUMN — irreversible & breaks downstream readers | |
sql.unscoped_delete | High | DELETE FROM <t> with no WHERE clause | |
sql.unscoped_update | High | UPDATE <t> SET … with no WHERE clause — or a tautological WHERE that selects exactly the rows the SET would change (e.g. WHERE col = FALSE paired with SET col = TRUE). v0.5.0 | |
sql.grant_or_revoke_all | Medium | GRANT ALL / REVOKE ALL on schemas or roles | |
sql.revoke_from_public | High | REVOKE … FROM PUBLIC can lock out every role at once | |
sql.copy_from_program | Critical | COPY … FROM PROGRAM — Postgres RCE chain | |
sql.load_data_infile | High | LOAD DATA INFILE — server-side file read / exfil | |
| Git 5 | git.force_push_protected | Critical | git push --force to main/master/prod/release/* |
git.history_rewrite | High | git filter-repo, filter-branch, reset --hard HEAD~ | |
git.push_mirror_or_all_force | High | git push --mirror / --all --force — every ref at once | |
git.branch_force_delete | Medium | git branch -D | |
git.checkout_dot_discards | Medium | git checkout . — discards every uncommitted change | |
| Filesystem 6 | fs.recursive_delete_root | Critical | rm -rf /, rm -rf $HOME, rm -rf ~, rm -rf $PWD |
fs.sensitive_path_write_or_delete | High | Write or delete touching /etc, /var, /usr, SSH dirs, credentials | |
fs.dd_to_block_device | Critical | dd if=… of=/dev/sd* | |
fs.find_delete_sweep | High | Recursive find … -delete — silent subtree sweep | |
fs.world_writable_chmod | High | chmod 777 on sensitive paths | |
fs.chown_root_recursive | High | Recursive chown root — can lock the user out of their own files | |
| Secrets 3 | secret.env_to_network | Critical | Read a secret source & pipe to network sink in one line — exfil pattern |
secret.read_ssh_or_aws_key | High | Direct read of ~/.ssh/id_*, ~/.aws/credentials, etc. | |
secret.cloud_kv_dump | High | Bulk read of AWS Secrets Manager / SSM / GCP / Vault KV | |
| Supply chain 2 | supply.curl_pipe_sh | Critical | curl | sh-style fetch-and-execute with no inspection |
supply.untrusted_pkg_registry | High | Install from non-default registry — supply-chain / dependency-confusion risk | |
| Reverse shell 1 | shell.reverse_shell | Critical | Reverse-shell payload pattern — never auto-allowed |
| Privilege 2 | privilege.sudo_destructive | High | Sudo-prefixed destructive command — requires human approval |
privilege.setuid_grant | High | Granting setuid / extra capabilities — escalation risk | |
| Cloud 5 | cloud.aws_s3_recursive_delete | High | Bulk S3 delete — irreversible if versioning is off |
cloud.aws_rds_skip_snapshot | Critical | RDS delete with no final snapshot — data is gone forever | |
cloud.terraform_destroy_auto_approve | High | terraform destroy -auto-approve — skips human confirmation | |
cloud.gcloud_sql_delete | High | Delete a Cloud SQL instance — irreversible | |
cloud.az_group_delete | High | Delete an entire Azure resource group, skipping the confirmation | |
| Kubernetes 4 | k8s.delete_namespace | High | Namespace delete propagates to every resource inside |
k8s.delete_all | High | kubectl delete --all — rarely what you actually want | |
k8s.drain_node | Medium | Drain a node — every workload on it gets evicted | |
k8s.helm_uninstall | Medium | Helm uninstall — removes every workload from the release | |
| Docker 2 | docker.system_prune_aggressive | High | docker system prune -af --volumes — caches AND volumes gone |
docker.rm_force_volumes | Medium | Force-remove a container with its volumes — data inside is gone | |
| Anomaly 1 | anomaly.destructive_burst | High | 5+ destructive tool calls per (actor, server, tool) in 5 min |
| LLM response 5 | llm.suggests_drop_database | High | Assistant plan contains destructive SQL (DROP DATABASE / TRUNCATE TABLE) |
llm.suggests_force_push | Medium | Assistant plan suggests force-push to a protected branch | |
llm.suggests_rm_rf | Medium | Assistant plan suggests rm -rf / or similar | |
llm.suggests_curl_pipe_sh | Medium | Assistant plan suggests curl | sh-style install | |
llm.suggests_secret_exfil | High | Assistant plan reads a secret and pipes it to a network endpoint |
Custom rules
Author your own rules in YAML and pass them with --rules:
aperion-shield --rules ./shield.yaml -- npx @modelcontextprotocol/server-postgres "$PG_URL"
Shield searches in this order when --rules isn't given:
- The path in
SHIELD_RULESET_PATH ./.aperion-shield/shield.yaml(project-local)~/.aperion-shield/shield.yaml(per-user)- The built-in default ruleset
Rule schema — shield.yaml
shieldset:
version: 1
rules:
# SQL — Critical (no WHERE clause is fine if you say DROP DATABASE)
- id: sql.drop_database
severity: Critical
where: tool_call
match:
tool: ["execute_sql", "postgres.query", "mysql.query"]
sql_matches: ['(?i)\bDROP\s+DATABASE\b']
reason: "DROP DATABASE is never auto-allowed."
# SQL — High (application-layer predicate; regex can't express "no WHERE")
- id: sql.unscoped_update
severity: High
where: tool_call
match:
tool: ["execute_sql", "postgres.query", "mysql.query"]
sql_predicates: ["unscoped_update"]
reason: "Unbounded UPDATE affects every row. Also catches tautological WHERE clauses (e.g. `WHERE col = FALSE` paired with `SET col = TRUE`)."
# Git — Critical
- id: git.force_push_protected
severity: Critical
where: tool_call
match:
tool: ["run_terminal", "bash", "shell"]
any_param_matches:
- '\bgit\s+push\s+.*(--force|-f|\+)\s+\S+\s+(main|master|prod|release/[^\s]+)\b'
reason: "Force-push to a protected branch is forbidden."
Match keys
tool: […]— optional whitelist of tool names. Omit to match every tool.any_param_matches: ['regex', …]— match any string inparams, recursively.sql_matches: ['regex', …]— match against extracted SQL strings only (recognisesquery/sql/statement).sql_predicates: ['name', …]— application-layer SQL matchers; see below.text_matches: ['regex', …]— match free-form text fields.
Regex flavour: Shield uses Rust's regex crate, which does not support look-behind ((?<=…)) or back-references. Anything you can write in RE2 works. For patterns the regex engine can't express (like “UPDATE without WHERE”), use sql_predicates instead.
SQL predicates — when regex isn't enough
Some destructive SQL patterns require parsing the statement, not just regex-matching it. Shield ships application-layer predicates for the two most common cases:
| Predicate | What it matches |
|---|---|
unscoped_update | UPDATE <table> SET … with no WHERE clause — or a tautological WHERE that selects exactly the rows the SET would change. Six tautology patterns are detected (boolean opposites, IS NULL paired with SET <value>, inequality paired with equality, etc.). Genuine scope-narrowing — WHERE created_at > NOW() - INTERVAL '7 days', WHERE tenant_id = $1 — passes through. v0.5.0 |
unscoped_delete | DELETE FROM <table> with no WHERE clause — would delete every row. |
UPDATE users SET email_verified = true; -- forgot the WHERE — is reliably caught. New in v0.5.0: the rule also catches the agent's favourite work-around, UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE, where the WHERE selects exactly the rows the SET would change — functionally equivalent to no WHERE. AND-combined predicates are handled conservatively: if any branch narrows scope by something other than the SET target, the call passes.
Adaptive scoring — context-aware severity (v0.2+)
The 45-rule base corpus tells Shield what is potentially destructive. The adaptive layer tells Shield how serious this specific match is right now. Every rule hit runs through four lightweight signals and a composite scorer before producing a final decision. Each signal can be disabled with a flag if you'd rather have raw rule output.
| Signal | What it does | How it adjusts severity | Disable flag |
|---|---|---|---|
| Workspace probe | Sniffs the working dir for “prod-looking” markers: .env.production, terraform/prod, docker-compose.prod.yaml, a non-localhost DATABASE_URL, etc. | +1 tier on a hit (Medium → High, High → Critical) | --no-workspace-probe |
| Decision memory | Hashes (rule_id, tool, redacted_param_fingerprint). Remembers the last N approvals and denials per fingerprint on disk. | −1 tier after 3 approvals of the same fingerprint; +1 tier after a recent deny | --no-memory |
| Burst detector | Sliding window over destructive matches across all rules; runs in-process (no Redis needed). | +1 tier while a wave is in progress, plus the anomaly.destructive_burst rule fires once the threshold is crossed | --no-burst |
| Composite scorer | Sums the raw rule severity (Critical=4, High=3, Medium=2, Low=1) plus signal adjustments and maps the total back to a final tier. | Reported in the JSON output as composite_severity, composite_points, and adjustments[] | — (always on; disabling all signals leaves it a no-op) |
Workspace probe
Runs once at startup, cached for the lifetime of the process. Sees only file names (never contents) inside the working directory tree. The list of prod markers is open source and easy to extend; pull requests welcome.
Decision memory
Stored at ./.aperion-shield/memory.json — append-only, line-oriented, capped at 1 MB. After 3 approvals of the same destructive-but-routine call (e.g. a daily find ./logs -delete by the same agent on the same machine), Shield demotes that fingerprint by one tier and reports memory_demote as an adjustment. A recent deny of the same fingerprint within 24h promotes by one tier.
Burst detector
In-process sliding-window counter over destructive matches (any SQL/git/fs/cloud/k8s rule). When the count crosses the threshold inside the configured window, every subsequent destructive match within that window gets a burst_bump adjustment. Storage is in-memory only — single-process by design.
# Example JSON output when adaptive layer kicks in
{
"decision": "block",
"primary_rule_id": "fs.find_delete_sweep",
"raw_severity": "High",
"composite_severity": "Critical",
"composite_points": 5,
"adjustments": ["workspace_is_prod", "burst_bump"],
"reason": "Recursive find ... -delete can sweep an entire subtree silently.",
"safer_alternative": "Run `find ... -print` first, eyeball the list, then re-run with `-delete`."
}
Identity-gated rules (v0.4+)
For the most dangerous category of operations — production database writes, signed commits to release branches, customer-data exports — Shield can require a verified human identity before allowing the call through. Approving from the local inbox is not enough; an actual person has to complete an OAuth flow and prove they are who they say they are.
Add an identity: block to any rule. The engine will return a new decision variant — identity_verification_required — which holds the tool call until the user finishes the verification flow in their browser. A signed proof is cached locally for the duration the rule says.
shieldset:
version: 1
rules:
- id: scm.commit_to_main
severity: High
where: tool_call
match:
tool: ["run_terminal", "bash"]
any_param_matches:
- '\bgit\s+commit\b.*\b(main|master|release/)'
identity:
provider: idme # or: mock, okta, custom
required: true
scope: scm.commit
max_age_seconds: 900 # proof good for 15 min
loa: 2 # NIST Identity Assurance Level
allowed_subjects: # optional allow-list (sub, email, idme|sub)
- "idme|abc123-def456"
- "[email protected]"
reason: "Commits to main require a verified human."
~/.aperion-shield/identity-key, mode 0600). The cache key is (subject, scope) — the same verified user re-using the same scope inside max_age_seconds hits the cache and never sees the browser flow twice.
Built-in providers
| Provider | Kind | Status | What it's for |
|---|---|---|---|
mock | Always-verify | Ready | Tests, demos, smoke runs. Signs a real proof but never prompts; ships an explicit warning every time it's used. |
idme | OAuth 2.0 + PKCE, ID.me as IdP | Sandbox pending | NIST IAL2 biometric verification. Production. Set IDME_CLIENT_ID / IDME_CLIENT_SECRET to activate; reports ready=false until both are present. |
okta | OIDC | Roadmap | Enterprise SSO; uses your existing Okta tenant as the verifier. |
custom | Trait-implemented | Ready | Implement the IdentityProvider trait against any IdP that returns a signed claim. ~80 LOC of Rust. |
identity.yaml — provider configuration
Provider configuration is discovered in this order: --identity-config <PATH> → $APERION_SHIELD_IDENTITY_CONFIG → ~/.aperion-shield/identity.yaml → built-in mock-only defaults.
providers:
- id: idme
kind: id_me
client_id_env: IDME_CLIENT_ID
client_secret_env: IDME_CLIENT_SECRET
sandbox: true # swap to false in production
- id: dev
kind: mock
always_verify: true # helpful for local dev only
identity: block today — adding identity gates is an explicit policy choice and the binary will warn loudly on startup if you ask for an identity provider that isn't configured. Disable the whole subsystem with --no-identity if you want to ship rules but turn the gate off.
# Cache inspection / management
aperion-shield --identity-list # show cached proofs (sub, scope, expires_at)
aperion-shield --identity-flush # drop every cached proof
Org Mode — central policy via Smartflow (v0.5+)
For teams that want one policy across every developer laptop and every CI runner, aperion-shield can be enrolled against a Smartflow control plane. Once enrolled, the binary pulls its shieldset from Smartflow, ships audit events back, and delegates identity verification through the central dashboard. Standalone mode (no enrollment record) still works exactly the same — Org Mode is purely additive.
The trust boundary is a per-device verification key (vkey) issued at enrollment and stored at ~/.aperion-shield/orgmode.json with mode 0600. Every request to the Smartflow control plane is HMAC-authed with that vkey.
# 1) Issue a one-time enrollment token from the Smartflow dashboard:
# https://<your-smartflow>/dashboard/shield_fleet.html → "Enroll a device"
# 2) Enroll on the laptop / runner:
aperion-shield \
--enroll \
--smartflow-url https://smartflow.example.com \
--token enroll_o7s9...3jk \
--device-name "alice-laptop" \
--enroll-email [email protected]
# 3) Verify:
aperion-shield --status
# → enrolled device=alice-laptop group=engineering policy=v17 killswitch=off
# Run as normal -- policy + identity + audit now go through Smartflow:
aperion-shield -- npx -y @modelcontextprotocol/server-postgres "$PG_URL"
Central policy with hot-reload
Once enrolled, the binary polls Smartflow every 30 seconds for the active shieldset version of its policy group. On a version bump it fetches the new YAML, compiles a new engine, and atomically swaps it in via a tokio::sync::watch channel — no restart needed, no in-flight tool call is interrupted. Failures fall back to the previously cached policy; if everything is unreachable the binary falls back to the built-in 45-rule defaults.
Audit shipping & fleet killswitch
Every actionable decision (Block / Approval / Warn / Identity) is buffered and POSTed in batches every 5 seconds to /api/enterprise/shield/events. On graceful shutdown the in-flight batch is drained before exit. The dashboard's Shield Settings page renders a live, filterable timeline of these events across the entire fleet.
An admin can flip the fleet-wide killswitch from the dashboard. Enrolled devices pick it up on the next 30-second poll and downgrade every decision to AllowWithWarn until the switch is cleared — useful for incident response when a rule is misbehaving.
aperion-shield --disenroll (add --revoke to also revoke the vkey server-side). The local orgmode.json is shredded and the next start uses the built-in defaults.
Shieldset-as-code: the --diff behaviour explainer (v0.6+)
Every rule change is a behaviour change against thousands of real agent commands. Tightening one regex can add 50 approval prompts to your team's day. Loosening one can silently let a destructive call through. Neither should land without someone reading the diff and someone else verifying the impact.
aperion-shield --diff closes that gap. It runs the engine over the same JSON-Lines corpus under two different shieldsets — once with the current rules, once with the proposed rules — and emits a per-rule attribution of which lines flipped because of which rule change.
aperion-shield --diff \
--rules-before main-shieldset.yaml \
--rules-after pr-shieldset.yaml \
--corpus tests/corpus/team-cursor-history.jsonl \
--format markdown
Three output formats:
text— humans, terminal default. Decision-distribution table, ruleset changes (added / removed / modified / unchanged), per-rule behaviour deltas with sample flipped lines, summary, and a guidance line ("based on this corpus, expect ~27 more daily approval prompts").markdown— PR-comment friendly. Pipe straight togh pr comment --body-file -and reviewers see the rule-attributed behaviour diff at the top of the PR conversation.json— machine consumers. Stable schema, source-compatible with the prior Python prototype.
Three CI gate flags:
| Flag | Exits 1 when | Use it for |
|---|---|---|
--fail-if-flipped | Any decision changed | Strict gate on critical-tier shieldsets where any behaviour change needs explicit sign-off |
--fail-if-loosened | Any line moved toward a more permissive decision | The gate most teams want — tightening is fine, loosening requires a human |
--fail-if-allows-loosened N | More than N lines flipped to allow | Permits warn→allow on a case-by-case basis up to a threshold |
Engine::evaluate path the proxy uses, so the decisions in the report are exactly the decisions a live wrapped agent would see against either shieldset. Memory and burst detector are disabled in diff runs (they're stateful and would make the two engines non-comparable); the workspace probe stays on.
Full PR-review pattern, including the four-layer test stack (load · golden corpus · workflow corpus · behaviour diff): docs/shieldset-as-code.md.
Git hooks: --install-hooks (new in v0.7)
The single biggest objection to MCP-only enforcement, repeated on HN and in every Cline/Continue thread, is the same: "the agent just opens a shell and reaches around your guardrail." v0.7 closes that gap by running the same engine on the way to git — regardless of whether the destructive change came from an MCP tools/call, a generated migration, or a direct shell command an agent ran in your terminal.
$ aperion-shield --install-hooks
[aperion-shield] writing pre-commit -> .git/hooks/pre-commit
[aperion-shield] writing pre-push -> .git/hooks/pre-push
[aperion-shield] both hooks installed (managed by APERION-SHIELD-HOOK v1)
Re-running is safe: the hooks are idempotent and replace only Shield-managed installs (marked with an APERION-SHIELD-HOOK banner). If a husky / pre-commit / lefthook hook is already present, Shield refuses to clobber it; pass --chain-existing to back the existing hook up to .aperion-backup and chain Shield in front of it.
--check-staged (pre-commit)
The installed pre-commit hook calls aperion-shield --check-staged, which:
- Runs
git diff --cached --unified=0and feeds every added/modified line through the engine, classified by file kind (.sql→ SQL evaluator,.sh/ Dockerfile / Makefile → shell evaluator, source files → code-path evaluator). - Skips comment-only lines, pure whitespace, and any file exceeding the configurable size cap (default 2 MB, override with
SHIELD_HOOK_MAX_BYTES). - Exits with a meaningful exit code:
0= clean,1= at least oneBlockverdict (commit refused),2= at least oneApprovalverdict (commit refused — approvals are an MCP-runtime concept, not a commit-time one),3= operational error (engine load failure, etc.).
Example block from a real .sql migration:
$ git add migrations/2026_05_20_drop_user_table.sql
$ git commit -m "WIP"
[aperion-shield/check-staged] blocking 1 finding:
migrations/2026_05_20_drop_user_table.sql:3
rule: sql.drop_database (severity=Critical)
line: DROP DATABASE app_prod;
why : DROP DATABASE is non-recoverable; use a separate ops runbook,
not a checked-in migration.
[aperion-shield] commit refused. bypass options:
- git commit --no-verify (one-time, your decision)
- SHIELD_HOOKS_DISABLE=1 git commit (one-time, env override)
- aperion-shield --uninstall-hooks (remove entirely)
--check-pushed-refs (pre-push)
The installed pre-push hook calls aperion-shield --check-pushed-refs, which reads git's pre-push stdin protocol (<local-ref> <local-sha> <remote-ref> <remote-sha> per line) and refuses the push if:
- A protected branch (defaults:
main,master,release/*; override withSHIELD_PROTECTED_BRANCHES=trunk,deploy/*) is being deleted (remote-sha is000…), OR - A protected branch is being force-pushed — detected via
git merge-base --is-ancestor <remote-sha> <local-sha>; if the remote tip is not reachable from the local tip, this push rewrites history.
Same --no-verify and SHIELD_HOOKS_DISABLE=1 bypasses apply.
Coexistence with husky / pre-commit / lefthook
--install-hooks looks at the existing .git/hooks/<name> file before writing. Three outcomes:
- No file present → Shield writes its hook directly. Installed.
- A Shield-managed file is present (banner matches) → Shield refreshes it in place. Refreshed.
- A foreign hook is present (husky shim, pre-commit harness, lefthook stub, hand-written script) → Shield refuses by default to avoid silent destruction. Pass
--chain-existingand Shield will rename the foreign hook to.aperion-backupand write a chained script that runs both, Shield first.
Uninstall is symmetrical: aperion-shield --uninstall-hooks removes only Shield-managed files and restores any .aperion-backup sidecar it created on install.
Tune your shieldset from your own audit log: --suggest-rules (new in v0.7)
Static rule sets drift. After two weeks of real use, some rules never fire (your team genuinely never runs that operation), some get approved-and-demoted 9 times out of 10 (the rule is wrong about the local risk tier), and some Warn rules are pure noise. --suggest-rules reads your own shield_eval JSON-Lines audit log and tells you, with rationale, what to change.
Capture, then analyse
# 1. Redirect Shield's audit-log stream to a file
$ aperion-shield -- <upstream-cmd> 2> ~/.aperion-shield/audit.jsonl & ...
# (use Shield normally for at least a few hundred operations)
# 2. Ask for suggestions
$ aperion-shield --suggest-rules \
--audit-log ~/.aperion-shield/audit.jsonl \
--suggest-window-days 14 \
--suggest-min-occurrences 10 \
--suggest-format yaml-patch
Three suggestion classes
| Class | When it fires | What it suggests |
|---|---|---|
RULE_NEVER_FIRES |
Rule exists in your shieldset but has zero matches in the analysis window | Consider removing — or document why it exists as a defence-in-depth tripwire |
CONSISTENTLY_DEMOTED |
≥ N matches, > 70% of decisions were Allow/AllowWithWarn after the adaptive layer downgraded the rule |
Drop the rule's severity by one tier, or refine the predicate so it stops catching legitimate work |
NOISY_WARN |
Rule with severity: Warn matches very frequently with no human intervention recorded |
Either drop to severity: Info or remove — repeated warnings train your team to ignore the audit log |
Output formats
--suggest-format text(default): human-readable, terminal-friendly.--suggest-format markdown: paste-ready for a team RFC or a sprint-planning ticket.--suggest-format yaml-patch: splice-ready snippets forshieldset.yaml, each annotated with a# rationale: ...comment block (occurrence count, demotion rate, time window).
--suggest-rules is a static analysis pass over your own audit log — Shield does not phone home, and v0.7 does not change that.
Aperion Shield vs Smartflow Shield (enterprise)
Aperion Shield is the free, local edition. Smartflow's enterprise Shield is the same rule engine wired into Smartflow's gateway — same YAML schema, same severity model — but with the things you need when teams of agents start running in production.
| Capability | Aperion Shield (free) | Smartflow Shield (enterprise) |
|---|---|---|
| Local MCP wrapping | ✅ | ✅ |
| 45-rule starter shieldset | ✅ | 15-rule starter (proxy-resident); extend with your own YAML |
| LLM-response inspection (catches plans before they run) | ✅ 5 rules | ✅ |
| Sliding-window burst detector | ✅ in-process | ✅ Redis-backed |
| Adaptive scoring (workspace probe, decision memory, composite) | ✅ | — |
| Identity-gated rules (ID.me / Okta / mock / custom) | ✅ local OAuth | ✅ org-mediated |
One-shot --check mode (CI / batch validation) | ✅ | — |
| Org-mode enrollment with Smartflow control plane | ✅ client side | ✅ server side |
| Team-wide centrally managed shieldset (hot-reload) | via Org Mode | ✅ |
| Fleet dashboard (devices, policies, audit timeline, killswitch) | — | ✅ |
| Web-UI approval queue (no terminal needed) | — | ✅ |
| Tamper-evident HMAC-chained audit log | — | ✅ |
| SSO / OIDC approver identity | — | ✅ |
| Spend & activity dashboards | — | ✅ |
| Same YAML rule schema | ✅ | ✅ |
| License | Apache 2.0 | Commercial |
| Cost | Free, forever | Per-seat or per-deployment |
aperion-shield binary enrolled against Smartflow gets the centrally managed shieldset, fleet dashboard, and HMAC-chained audit chain on the server side while keeping the local rule engine, identity gate, and adaptive layer client-side. See Org Mode above.
The expected progression is: a developer installs Aperion Shield on their laptop, validates that the ruleset catches the right things on real workflows, then their company adopts Smartflow when they need team-wide observability, the approval dashboard, and centrally managed policy.
Privacy & telemetry
A future opt-in flag (--telemetry) is reserved for an anonymised public block ticker — a once-per-minute aggregate count of (rule_id, severity) tuples sent to a community endpoint, intended to power a public dashboard like a CVE tracker. It is currently stubbed and gated on legal/DPO review; setting the flag today prints a notice and exits without sending anything.
CLI flags
aperion-shield [OPTIONS] -- <upstream-mcp-command> [args...]
Rules & engine
--rules <PATH> Path to a shield.yaml ruleset. Falls back to
$SHIELD_RULESET_PATH, ./.aperion-shield/shield.yaml,
~/.aperion-shield/shield.yaml, then the built-in
45-rule default.
--shadow Downgrade every decision to allow+log. Useful for
the first day of a new ruleset to surface false
positives without breaking workflows.
--auto-deny-high Auto-deny any High-severity hit instead of queueing
for human approval. Use in CI or unattended agents.
Adaptive layer
--no-workspace-probe Disable the workspace prod-marker probe.
--no-memory Disable the decision-memory layer.
--no-burst Disable the in-process burst detector.
Identity gating
--identity-config <PATH> Override the default identity.yaml discovery.
--no-identity Disable identity gating entirely; rules with
an identity: block fall back to Block/Approval.
--identity-list Print cached identity proofs and exit.
--identity-flush Drop every cached identity proof and exit.
Org Mode (v0.5+)
--enroll Enroll against a Smartflow control plane.
Requires --smartflow-url and --token.
--smartflow-url <URL> Smartflow base URL (with --enroll).
--token <TOKEN> One-time enrollment token (with --enroll).
--device-name <NAME> Friendly device name (with --enroll).
--enroll-email <EMAIL> Owner email (with --enroll).
--status Print enrollment status and exit.
--disenroll Remove the local enrollment record.
--revoke With --disenroll, also revoke the vkey
server-side via DELETE /api/enterprise/devices/{id}.
One-shot evaluation
--check Read tool-call descriptors from stdin (one JSON
object per line) and print the engine's decision
for each as JSON. No MCP / upstream needed.
Exit 0 if all expectations met, 1 otherwise.
--workspace <PATH> Override the workspace root for --check / --diff.
Behaviour diff (v0.6+)
--diff Run the engine over a corpus twice (under two
shieldsets) and emit a per-rule attribution of
which lines flipped. The pre-merge review tool.
--rules-before <PATH> Current (main-branch) shieldset YAML.
--rules-after <PATH> Proposed (PR-branch) shieldset YAML.
--corpus <PATH> JSON-Lines corpus path (default: stdin).
--format <FMT> text | markdown | json (default: text).
--max-samples <N> Flipped-line samples per rule (default: 3).
--fail-if-flipped Exit 1 if any line's decision changed.
--fail-if-loosened Exit 1 if any line moved to a more permissive
decision. The CI gate most teams want.
--fail-if-allows-loosened <N>
Exit 1 if more than N lines flipped to `allow`.
Git hooks (v0.7+)
--install-hooks Write managed pre-commit + pre-push hooks into
.git/hooks/. Idempotent. See --chain-existing
for husky / pre-commit / lefthook coexistence.
--uninstall-hooks Remove Shield-managed hooks and restore any
.aperion-backup sidecars created on install.
--repo <PATH> Operate on the given repo root (default: cwd).
--chain-existing On --install-hooks, if a foreign hook is
present, rename it to .aperion-backup and
chain Shield in front of it. Without this
flag, Shield refuses to clobber.
--check-staged Engine pass over `git diff --cached` output.
Used by the installed pre-commit hook.
Exit 0=clean, 1=Block, 2=Approval, 3=error.
--check-pushed-refs Engine pass over git's pre-push stdin format.
Used by the installed pre-push hook.
Refuses force-pushes / protected-branch
deletions. Override protected branches with
SHIELD_PROTECTED_BRANCHES=trunk,deploy/*.
Audit-driven rule tuning (v0.7+)
--suggest-rules Read a shield_eval audit log (JSON Lines) and
print rule-tuning suggestions: rules that
never fire, rules consistently demoted by
the adaptive layer, and noisy Warn rules.
--audit-log <PATH> Path to the captured audit log.
--suggest-window-days <N> Only consider records within the last N days.
--suggest-min-occurrences <N>
Threshold under which a rule is too sparse
to make a recommendation. Default 5.
--suggest-format <FMT> text | markdown | yaml-patch (default: text).
`yaml-patch` produces splice-ready snippets
for shieldset.yaml with rationale comments.
Privacy / telemetry
--telemetry <MODE> [Reserved] Opt in to the anonymised public block
ticker. Currently prints a notice and exits.
Misc
--version Print version and exit.
--help Print this help and exit.
Troubleshooting
The IDE shows “MCP server failed to start”
Run the same command in a terminal — Shield's startup error (typically a missing upstream command or a malformed shield.yaml) prints on stderr. Most failures are misquoted args between aperion-shield and the -- separator.
I want to bypass Shield for a single dangerous call I really mean
Use the local approval inbox: when Shield prompts, type approve <ticket-id> in a terminal in your project root. There is no global “disable” switch — that's intentional.
My MCP server uses a non-stdio transport (SSE / HTTP)
v0.7.0 supports stdio only. SSE / HTTP MCP transports are on the roadmap; track GitHub issues.
An identity-gated rule says provider not ready
The provider you referenced in the rule's identity: block is configured but missing credentials. For idme, set IDME_CLIENT_ID and IDME_CLIENT_SECRET in the environment. aperion-shield --identity-list will report the readiness state of every configured provider.
Org Mode: policy isn't hot-reloading
Policy is polled every 30 s. After publishing a new version in the dashboard, wait one poll cycle (look for hot-reloaded policy: group=… version=… rules=… on stderr). If you don't see the log line, check aperion-shield --status for connectivity; falling back to the previously cached policy is the design.
Source & releases
- github.com/AperionAI/shield — source code, issue tracker, contribution guide
- github.com/AperionAI/shield/releases — binaries for Linux / macOS / Windows, x86_64 & aarch64
ghcr.io/aperionai/shield— multi-arch Docker image- v0.1.0 release notes — initial public release
- Smartflow Shield (enterprise) — proxy-resident edition, fleet dashboard, approval queue, audit chain