Skip to main content

Hooks-based enforcement + accountability for AI coding agents

Project description

Aegis

Hooks-based policy enforcement + accountability for AI coding agents. Aegis evaluates every action an agent attempts — at the runtime's hook boundary, before the action executes — against declarative policy and returns allow / deny / ask. Enforcement runs outside the model's context window in a separate process, so a prompt injection or jailbreak cannot override a denied action: the model isn't the thing being asked. (It's a policy layer, not a sandbox — see Limitations.)

It ships secure-by-default: with zero configuration it blocks the catastrophic, irreversible actions an agent can take with your privileges — destructive SQL and migrations, secret exfiltration, recursive deletes, history-rewriting git, persistence installs, and uncontrolled sub-agent fan-out — and it detects rogue agent sessions (unidentified processes claiming an agent name without a valid cryptographic token) and reaps them in enforce mode. Everything beyond that is configurable, and the rule engine is a plugin platform: individuals, orgs, and MCP providers add their own guards without forking.


Contents


Threat model

An autonomous coding agent runs with your privileges: your shell, your repo, your database credentials, your cloud keys. It also reads untrusted input all day — web pages, dependencies, issue threads, tool output — any of which can carry a prompt-injection payload. If an agent is hijacked, the blast radius is everything you can touch.

The usual mitigation is a system prompt ("don't run destructive commands"). That is advisory. The instruction lives in the model's context window, where it can be overridden, out-argued, or injected around. It is a request, not a control.

Aegis moves enforcement to a boundary the model cannot reach.

Assumes the agent is untrusted at the point of action — not because the model is malicious, but because it runs with full privileges and ingests attacker-controlled text. Every tool call is treated as potentially hostile and gated where the model has no influence.

Defends against: a hijacked or injected agent doing damage with your credentials — secret theft, exfiltration, data/schema destruction, history rewrites, persistence; an agent encoding/wrapping/splitting its way around an instruction-level guard; an agent trying to disable the guard or impersonate another identity.

Trust boundary. The control runs in a separate process the runtime invokes, reading policy from disk. The model can emit any text it wants; it cannot change the exit code Aegis returns. That is the line.

Non-goals (stated plainly). Aegis is a policy layer, not a sandbox. It does not contain a process that has already reached a shell and is running relative commands from an attacker-chosen directory, and it does not replace OS isolation. For hostile code or hard multi-tenant isolation, run the agent under a restricted OS user / container and let Aegis enforce policy on top — defense in depth, not a silver bullet.

How it works: the hook boundary

Modern agent runtimes expose hooks — the runtime calls out to an external program at lifecycle points (before a tool runs, after it runs, at session start, at stop). Aegis is that program.

On PreToolUse, the runtime hands Aegis the pending tool call and asks "may I run this?". Aegis evaluates it and answers:

  • allow — exit 0, the tool runs.
  • deny — exit code 2; the runtime refuses the action and feeds Aegis's reason back to the model as context.
  • ask — surfaced to the human operator for interactive confirmation. The runtime pauses the agent and presents the pending action in its permission UI (Claude Code shows a [y/n] prompt with the tool call details and Aegis's reason). The human approves or rejects; the agent never decides. Use ask for actions that are dangerous but situationally legitimate — production deploys, schema changes on a staging DB, force-pushes to a feature branch — where you want a human in the loop without a blanket deny.

(Exit code 2 is Claude Code's block contract; other runtimes signal a block their own way — the adapter maps the decision to each.)

The decision is made in a separate process, from policy on disk, with no dependence on anything the model said. A jailbroken model still cannot run a denied command, because the model is not the thing being asked — the runtime is.

agent runtime ──PreToolUse(tool, args)──> aegis hook ──> policy engine ──> allow/deny/ask
     ^                                                         |
     └──────────────── deny (exit 2) + reason <───────────────┘

A blocked call, end to end

  1. You run aegis install --project . — it merges a PreToolUse hook (and the rest of the runtime's hook surface — 26 events) into the project's .claude/settings.json.
  2. Mid-session the agent decides to run psql -c "DROP TABLE users".
  3. Before executing, Claude Code invokes aegis hook PreToolUse, piping it the tool call as JSON on stdin.
  4. Aegis normalizes it to an Event (shell, with the command text) and runs the engine; the migration guard matches DROP TABLE.
  5. Aegis writes the reason to stderr and exits 2. Claude Code refuses to run the command and hands the model the reason — which it can act on (choose a safe path) but not override.
  6. A record lands in the audit log; aegis report now shows the denial, attributed to the session/identity.

No model cooperation was needed at any step. If the agent retries with bash -c "psql ..." or a base64-encoded variant, step 4 still matches — see Evasion resistance.

Architecture

A hook payload flows through one pipeline:

native hook payload -> adapter -> normalized Event -> policy engine -> Decision -> audit

Event model (events.py) — runtime-agnostic, so one policy works across runtimes. Every call normalizes to an Event with a HookEvent and an ActionClass taxonomy (read / edit / write / shell / git / subagent / mcp / net / other). A policy can target a whole class ("deny all shell") without knowing each runtime's tool names.

Aegis covers the runtime's full hook surface (26 Claude Code events), not just tool calls. BLOCKABLE (in events.py) marks the 13 events where a deny actually stops the action; the rest are observational (recorded for accountability). Beyond the core tool-use guards, lifecycle rules (aegis/lifecycle/) cover:

Concern Events What it does
Config integrity ConfigChange / FileChanged / CwdChanged / Setup / InstructionsLoaded Hard-blocks a spawned agent from rewriting the policy/settings that enforce it (the "can't neuter the guard" block); records cwd escapes and secrets-file touches for the audit trail (CwdChanged/FileChanged are observational).
Sub-agent / Teams SubagentStart / SubagentStop / TaskCreated / TaskCompleted / TeammateIdle Spawn-depth governance at fan-out time; opt-in "did-it-do-the-task" completion gate; per-sub-agent token/cost attribution.
Session / compaction SessionEnd / PreCompact / PostCompact / StopFailure / Notification Opt-in gate on auto-compaction (checkpoint before context is destroyed); session-lifecycle audit.
Interaction / MCP input PostToolUseFailure / PermissionRequest / Elicitation / ElicitationResult Opt-in auto-deny of human-only permission prompts and MCP elicitation side-channels for unattended agents.
Worktree WorktreeCreate / WorktreeRemove Extends workspace confinement to git worktrees — an agent can't git worktree add ../escape its way out of its project root.

Policy engine (engine.py) — evaluates an Event in three stages, first-deny-wins, fail-open per rule (one broken rule can never brick the agent):

  1. Built-in rules — the secure-by-default guard set (below). Disable with AEGIS_NO_BUILTINS=1.
  2. Plugin rules — custom guards registered by you/your org/an MCP provider.
  3. Declarative rules — your YAML policy (highest priority first, first match wins).

The order is deliberate: built-ins run first and only ever deny or abstain, so a permissive declarative rule — even tools: ["*"] for admins — can never re-open a built-in protection. To relax a built-in you disable it explicitly (AEGIS_NO_BUILTINS, or a narrower toggle), never by out-prioritizing it. This is what keeps a prompt-injected but fully-authorized agent from reading secrets or dropping tables.

Every decision is written to a structured audit log that powers accountability.

The built-in guard catalog

Two tiers. Non-escapable guards protect against the catastrophic and cannot be waved through. Escapable guards block by default but accept an explicit, recorded confirmation — append # aegis-allow to a shell command, or -- aegis-allow to SQL — so legitimate dangerous work is still possible with intent.

Agents cannot self-escape. The # aegis-allow override is honored only for human operators. A spawned agent (identified by AEGIS_AGENT_NAME) cannot wave itself past an escapable guard — the override is silently ignored. This prevents a hijacked agent from appending the escape token to its own commands.

Guard What it detects Escapable
Rogue-session gate On SessionStart, a process claiming an agent identity (AEGIS_AGENT_NAME) without a valid signed token is classified as rogue. In monitor mode the session is logged; under AEGIS_IDENTITY_ENFORCE=1 it is denied and reaped. Surfaces an attestation-challenge honeypot that instruction-following rogues self-report to. No
Containment Reads of credential stores (~/.ssh, ~/.aws, ~/.azure, ~/.gnupg, ~/.kube, .netrc, gh/docker config, id_rsa/id_ed25519/.ppk, browser login DBs, the Windows DPAPI/vault); file exfiltration (curl --upload-file/-T/-d @, Invoke-WebRequest -InFile); persistence (registry autorun, schtasks /create, Register-ScheduledTask, new services, startup folder, cron) No
Self-protection Deleting/moving/overwriting .aegis or .claude, editing .claude/settings.json, policy files, or Aegis's own engine source (aegis/*.py), running aegis uninstall or aegis pull. Covers shell redirects (>, >>, tee, Set-Content, Out-File) to config paths — not just delete/move verbs. The governor cannot be removed or neutered by the governed. No
Evasion blocking Obfuscated / encoded commands: PowerShell -EncodedCommand/-enc, base64 -d | bash, FromBase64String, char-code construction. Encoding is itself a signal — denied outright so the policy can inspect what actually runs. (The de-obfuscation layer in Evasion resistance normalizes for the other guards; this guard catches the encoding as a standalone tell.) No
Migration / destructive SQL DROP/TRUNCATE/ALTER ... DROP, DELETE/UPDATE without WHERE, and migration resets (Prisma, Alembic, Supabase, Rails/Rake, Django, Knex, Flyway, dbmate) — read from shell commands and DB MCP tool args (execute_sql, apply_migration). Note: the migration guard matches broad keywords (drop, truncate) to minimize false negatives; a benign ALTER TABLE ... DROP COLUMN old_col will trip it. Use the -- aegis-allow escape on legitimate migration commands, or write a declarative rule scoping the guard to your workflow. Yes
Destructive git force-push, reset --hard, rebase, commit --amend, branch -D, clean -f Yes
Recursive force delete rm -rf and equivalents across bash, PowerShell (Remove-Item -Recurse -Force), and cmd (rmdir /s, del /s); also find -delete/-exec rm, shred, truncate -s 0, dd of=/dev/... Yes
Sub-agent governance Agent/Task fan-out from a spawned agent (uncontrolled cost/blast radius). Enforced at the tool call (PreToolUse, hard block) and also surfaced at SubagentStart for the audit trail (SubagentStart is observational). Configurable (AEGIS_ALLOW_SUBAGENTS)
Forced install review A dependency install (pip install incl. -r, npm/pnpm/yarn/bun install, poetry/pipenv/uv/pipx, cargo, go, conda/mamba, path-qualified ./venv/bin/pip) is denied until its manifest is fully read this session — then surfaced as a human ask with a digest (dep count, unpinned specs, URL/VCS deps, install scripts). A skim (a Read that stops short, or a grep/head peek) never satisfies coverage; editing a file after reading re-arms the gate. See Forced install review for exactly what this does and does not stop. Human # aegis-allow / AEGIS_ALLOW_INSTALL=1; agent cannot self-escape
Fetch-to-shell / DNS-C2 A network fetch piped straight into an interpreter (curl … | sh, wget … | bash, iex(iwr …)) and DNS-TXT command/payload retrieval (dig/kdig/nslookup/host … TXT) — remote code, or a DNS-delivered payload, that was never read. Catches the common single-command shape; see the section below for what it misses. Human # aegis-allow; agent cannot self-escape
Branch strands Creating a new branch (git checkout -b / git switch -c) while the current branch has commits not in main — prevents stranding unmerged work. Checks actual git state. Yes (AEGIS_ALLOW_STRAND=1)
Network egress Outbound destinations (tool URLs, curl/Invoke-WebRequest) against an allow/deny host list Policy-driven
Workspace confinement File mutations (Edit/Write) outside the agent's project root. The root binds to the identity — a token's project claim or AEGIS_PROJECT — or to policy (workspace.root / project). Reads are unaffected. No, once a project is bound (hard block)
MCP server-config protection Writes to MCP server-definition config files (.mcp.json, ~/.claude.json's mcpServers, .cursor/mcp.json, .vscode/mcp.json, claude_desktop_config.json, Windsurf's mcp_config.json) via Edit/Write or an MCP filesystem tool; shell redirects, deletes/moves, and in-place edits (sed -i, cp, dd, …) onto those paths; and CLI mcp add-style registration. A planted/altered server entry's command/args/url/env auto-executes on every future session — a durable, cross-session backdoor no other guard covers. See MCP server-config protection. Human AEGIS_ALLOW_MCP_CONFIG=1 (Edit/Write/MCP-tool) or # aegis-allow (shell); agent cannot self-escape

The migration guard is worth calling out: an agent doesn't need a shell to wipe a database — it can call a DB MCP tool's execute_sql directly. Aegis reads the SQL out of the tool arguments, so DROP TABLE is caught with no shell involved — a vector most guardrails miss entirely.

Lifecycle guards (the rest of the hook surface)

Beyond the 12 core guards above, Aegis covers the full Claude Code hook surface (26 events; see Architecture) with 9 lifecycle guards in aegis/lifecycle/ — together the 21 built-in rules. Some fire unconditionally; four are opt-in via policy knobs (team / compaction / permission / mcp — see Policy).

Guard Event(s) What it does On by default?
Config-change protection ConfigChange (blockable) Hard-blocks a spawned agent from rewriting Aegis's own policy_settings / local_settings or any enforcement/config/engine-source file mid-session — the "can't neuter the guard" guarantee. Humans/orchestrators may reconfigure. Yes
Cwd confinement CwdChanged (observational) Records an agent stepping its working directory outside its confinement root. Surfaced for the audit trail, not blocked (CwdChanged is not blockable). Off unless a confinement root is set. When confined
Secrets-file flag FileChanged (observational) Flags (for review) a spawned agent changing a watched .env / .envrc. Surfaced, not blocked. Conservative — external/human edits stay plain audit records. Yes
Worktree confinement WorktreeCreate (blockable) Hard-blocks git worktree add to a path outside the confinement root — an agent can't worktree its way out of its project. When confined
Sub-agent spawn depth SubagentStart (observational) Records a spawned agent fanning out to sub-agents (mirrors the PreToolUse block at spawn time, for the audit trail). Yes (records)
Task-completion gate TaskCompleted (blockable) Blocks marking a team task done without an explicit verification signal — the "did-it-do-the-task" gate. Opt-in (team.require_verification)
Pre-compact gate PreCompact (blockable) Blocks auto context compaction so a human can checkpoint before context is destroyed. Opt-in (compaction.block_auto)
Permission-escalation PermissionRequest (blockable) Auto-denies a spawned/unattended agent's human-only permission prompts instead of hanging on them. Opt-in (permission.deny_escalation)
Elicitation governance Elicitation / ElicitationResult (blockable) Denies MCP elicitation side-channels for a spawned agent. Opt-in (mcp.block_elicitation)

Observational events with no enforcing rule (PostToolUse, PostToolUseFailure, SubagentStop, TeammateIdle, SessionStart/SessionEnd, Setup, InstructionsLoaded, Stop/StopFailure, PostCompact, Notification, WorktreeRemove) are still recorded to the audit log for accountability.

Containment is a known-paths denylist (the locations above) — deliberately high-signal, not exhaustive: a secret at an unlisted path, or copied first and then read, can slip it. Pair it with least privilege so the agent can't reach what isn't listed.

Forced install review

A recurring way an agent gets compromised is an unread install: it clones a repo, follows the setup notes, and runs pip install -r requirements.txt (or npm install) without ever looking at what it pulls in. Public proofs-of-concept — e.g. Mozilla 0DIN's June 2026 "clean repo" reverse-shell — ride exactly this: a repository with no malicious files, whose dependency (or a later error message) carries the payload.

Forced install review makes the install go through a real read instead of around it:

  1. Deny until fully read. An install is refused until the manifest that determines what it pulls in — requirements.txt (and nested -r/-c includes), package.json
    • lockfile, pyproject.toml/poetry.lock, Gemfile, Cargo.toml, go.mod, … — has been fully read in this session. "Fully read" is enforced by a coverage ledger (aegis/review.py): each Read records the line interval it actually returned, keyed to the file's content hash; the union must span the whole file. A Read with a limit that stops short, a grep/head/tail peek, or a read of a different file with the same name does not satisfy it, and editing the file after reading re-arms the gate.
  2. Then ask. Once coverage is satisfied the install is surfaced to the human as an ask with a factual digest — dependency count, unpinned specs, URL/VCS/local deps, and whether install-time scripts are present — so the approval is informed.

A targeted install of named packages (pip install requests, npm install lodash, pip install --upgrade pip) has no manifest to read and goes straight to the ask (the package names are summarized in the digest). A local-path install (pip install ./pkg) gates the build files of that directory.

Config (policy.install_review) — built in, on by default at mode: ask:

install_review:
  mode: ask            # off | monitor (log the would-be decision to the audit, allow) | ask
  deep: false          # also require reading the LOCAL setup.py / postinstall scripts
  require_pinned: false # deny unpinned specs (force exact == / a lockfile)
  allow: []            # regexes on the command that skip the gate

The read-gate is non-escapable by a spawned agent — it cannot # aegis-allow its own install (the override is honored only for a human). Bypasses for legitimate use: a human # aegis-allow, AEGIS_ALLOW_INSTALL=1, an allow: regex, or mode: monitor while you pilot. No-execute fetches (pip download, npm pack) are not installs and don't gate.

What this does and does not stop — honest kill-chain

Against the 0DIN clean-repo chain, mapped to the tool calls Aegis actually sees:

Step Action Blocked?
Blind pip install -r requirements.txt (unread) Denied until the manifest is fully read, then a human ask Yes — this is the value
The installed package's install-time code (setup.py/postinstall) runs inside pip's subprocess Below the hook boundary — Aegis is not a sandbox and does not see inside a subprocess No
A second-stage command the package's error tells the agent to run (e.g. python3 -m axiom init) A plain module run; no guard matches No (unless it surfaces a guarded shell shape)
That command resolves a DNS TXT record, base64-decodes, and execs a reverse shell — in-process Never a separate tool call; dig … TXT only trips the guard if the payload shells out No

So this gate blocks the blind/poisoned-manifest step and forces a human decision; it does not by itself stop a payload that lives in a dependency's install-time code or in a later step. deep mode force-reads the local setup.py/pyproject.toml (catching a malicious pip install .), not a downloaded third-party package's code. The real containment for the in-subprocess payload is OS isolation + deny-by-default egress — pair them with this gate; it is a policy layer, not a sandbox.

Known limits (so you're not surprised)

  • Coverage proves bytes entered context, not comprehension. A full read puts the whole manifest in front of the model/human; it can't prove they understood it, and a benign name (requests) tells you nothing about a typosquat.
  • Coverage trusts the runtime's tool output. The ledger believes the Read content the runtime delivers at PostToolUse (same trust the hook itself rests on). It does not defend a caller that forges its own hook payloads — that's the "the hook can be skipped" case the server-side gate addresses. On a runtime that doesn't deliver Read content, coverage fails closed (install blocked) — use a human override or mode: monitor.
  • Denylist detection can be laundered. Manifest contents fed through xargs/$(…), or a brand-new installer name, can dodge detection; a download-to-temp-then-execute split across two statements dodges the fetch-to-shell guard. Deny-by-default egress is the backstop.
  • The ledger has no TTL. A full read earlier in the session satisfies the gate until the file's content changes.

MCP server-config protection

MCP server-definition config files (.mcp.json, ~/.claude.json's mcpServers, .cursor/mcp.json, .vscode/mcp.json, claude_desktop_config.json, Windsurf's mcp_config.json) declare a command / args / url / env per server. That definition is auto-executed on every future session start — the runtime launches it as part of bringing up the agent's tool surface, with no further approval. A hijacked or prompt-injected agent that plants or edits an entry here plants a durable, cross-session backdoor that outlives the current session entirely: the next launch (yours, a teammate's, or CI's) runs whatever command/args the attacker wrote, with the same privileges any other tool already has.

This is a distinctly agentic attack surface — unique to tool-calling runtimes — that none of the other built-ins cover: self-protection guards Aegis's own config (.aegis, .claude/settings.json); containment/persistence guard OS-level persistence (cron, registry, scheduled tasks). Neither looks at MCP server definitions.

What's covered:

  1. Edit/Write/MCP-tool writes to a known MCP config path (checked via the generic path argument, so this catches an MCP filesystem-server tool's write — not just Claude Code's own Edit/Write — regardless of whether that server calls its path argument file_path, path, target_file, filename, file, or uri).
  2. Shell mutation of a config path: redirects/tee/Set-Content, delete/move, recursive force-delete, and in-place edits or copy-over-target (sed -i, perl -i, batch-mode vim/ex, cp, dd, or a python/node/ruby/perl one-liner that writes the file).
  3. CLI registrationclaude mcp add …, codex mcp add …, and the bare reference-CLI form mcp add … (anchored to command-start position so it doesn't false-positive on a commit message or grep hit that merely contains the phrase).

Reads (Read, Grep, Glob) are unaffected — only mutation is gated, so scaffolding a project from a template or auditing an existing .mcp.json works normally.

Config (policy.mcp_config) — built in, on by default at mode: deny:

mcp_config:
  mode: deny            # deny | ask | monitor | off
  allow: []              # regexes on the path/command that skip the gate

ask surfaces the change to a human for interactive approval instead of a hard deny — the same shape as install_review's digest-then-ask. monitor logs the would-be decision to the audit and allows, for piloting before enforcing.

Escapable only by a human: a trailing # aegis-allow on the shell form (the natural place for it — there's no syntax-safe way to embed a comment inside a JSON Edit/Write payload), or the env toggle AEGIS_ALLOW_MCP_CONFIG=1 for the Edit/Write/MCP-tool form. A spawned agent cannot set its own environment for a hook invocation it doesn't control, and (per Evasion resistance, below) cannot use the # aegis-allow token on its own commands either — so neither escape hatch is agent-self-usable.

Known limits: this is a known-paths denylist — an unlisted or newly-invented runtime's config file, or a symlink pointing outside the recognized set, isn't covered. A third-party MCP CLI that isn't Claude Code / Codex / Cursor / Windsurf / Gemini and doesn't use the bare mcp add phrasing can register a server without tripping the CLI check (the config-file write itself is still the backstop in that case). Like the other denylist guards here, pair with least privilege and deny-by-default egress for defense in depth.

Evasion resistance

A denylist that scans only the raw command loses to a determined agent. Aegis normalizes before it matches:

  • De-obfuscation — token-splitting quotes/escapes are stripped (r"m" -rf), PowerShell -EncodedCommand/-enc base64 is decoded, base64 -d pipelines are decoded, and inner interpreter code is expanded (bash -c "...", powershell -c, cmd /c, python -c, node -e) — recursively. The guards scan the de-obfuscated surface, so wrapping or encoding can't hide a delete, a DROP, or a key read.
  • Alternate toolsfind -delete / -exec rm, shred, truncate -s 0, dd of=/dev/... are caught alongside rm.
  • Obfuscation is itself a signal — encoded commands, base64-piped-to-a-shell, FromBase64String, and char-code construction are denied outright.
  • No self-escape — the # aegis-allow override is honored only for a human / orchestrator. A spawned agent (AEGIS_AGENT_NAME set) cannot wave itself past an escapable guard.

Honest limit: this raises the cost of evasion sharply — it is not a sandbox. The ceiling is deny-by-default plus OS-level privilege separation (run agents under a restricted token). Aegis is the policy layer; pair it with least privilege.

Identity and rogue-agent defense

A name in an environment variable is forgeable — any process can claim to be any agent. Aegis issues cryptographic identities and enforces them.

Identity is attribution + authorization, not injection immunity. A signed key proves who an agent is and what it may do — it does nothing to stop an authorized agent that gets prompt-injected (the key stays valid; the attacker just drives the agent). What stops a hijacked-but-signed agent is the action layer: the built-in guards evaluate the action, not the caller, and run before your declarative rules, so a signed admin that tries to read your keys or drop a table is denied by containment/migration regardless of role — and, like any agent, it can't # aegis-allow its way past an escapable guard. Identity's job against injection is to shrink blast radius (least-privilege RBAC) and give exact attribution, not to make the agent immune.

  • Signed identity (identity.py) — Ed25519 tokens bound to {agent, role, session, project, caps, exp}, minted with aegis issue and verified against the issuer public key. RBAC roles derive from the verified token, not a forgeable env string.
  • Attestation gate + honeypot (attest.py) — at SessionStart, a process that claims an agent identity (AEGIS_AGENT_NAME) without a valid token is rogue. The denial carries an attestation challenge: a directive a human ignores but an autonomous, instruction-following agent obeys — self-reporting its model/operator via aegis attest, where it's classified (verified / forged / unregistered / noise), logged, and blocklisted. A rogue agent identifies itself into the trap. (This catches instruction-following rogues; a targeted attacker whose agent ignores the challenge won't self-report — the identity gate + reaper are the backstop.)
  • The reaper (reaper.py) — under enforcement, a rogue session isn't just denied a tool call; its process tree is terminated.
  • Phased rolloutMONITOR by default (record + allow, so tokens can roll out fleet-wide first); AEGIS_IDENTITY_ENFORCE=1 flips it to deny-and-reap.
  • Server-side gate (gate.py) — the same refusal as plain Python, so it holds even on a surface that bypasses hooks (an HTTP API, a hosted control plane, an MCP server). The hook can be skipped; an import-level refusal cannot.

Token lifecycle. Your spawner mints a token — aegis issue <agent> --role reader --caps read,plan — and sets it as AEGIS_AGENT_TOKEN in the agent's environment at launch (alongside AEGIS_AGENT_NAME). The hook reads both: a name with a valid token is a real, attributable identity whose role/caps drive RBAC; a name without one is rogue. Tokens carry an expiry (exp, default 12h) and are signed by an issuer key in ~/.aegis/identity/ — verifiers only need the public half. Roll out in phases: stay in MONITOR (rogue sessions logged, allowed) until issuance covers every spawn path, then AEGIS_IDENTITY_ENFORCE=1 to deny-and-reap.

The spawner contract — assign identity at launch

Identity is assigned by whatever launches the agent (your orchestrator, CI job, or shell), by setting a few environment variables before the agent runs. Set them and every action is attributed, scoped, and — with a token — cryptographically proven:

# 1. Mint a signed identity bound to a role + project (recommended):
export AEGIS_AGENT_TOKEN="$(aegis issue code-reviewer --role reader --caps read,plan --project "$PWD")"
# 2. Label + scope the session (these alone are a quick, unsigned setup):
export AEGIS_AGENT_NAME="code-reviewer"   # who (also drives the rogue gate)
export AEGIS_SESSION_ID="$CI_RUN_ID"      # which run (Claude Code sets this itself)
export AEGIS_PROJECT="$PWD"               # confine file edits to this repo
# 3. Launch the agent. Every hook now records who / which-session and hard-blocks
#    writes outside the project.

Precedence, weakest to strongest: a vanilla install attributes records to the runtime (agent: claude-code) and the OS user; AEGIS_AGENT_NAME / agent_label add a real label; a signed token makes the identity unforgeable and carries the project binding that confines edits. Even with nothing set you never see null.

For a repo that wants zero per-launch setup, declare the defaults in policy:

agent_label: ci-bot     # label records when no AEGIS_AGENT_NAME is set
project: .              # hard-block Edit/Write outside this repo (reads stay free)

Policy

Declarative YAML. First matching rule by priority wins; otherwise default_action applies. Rules match on any combination of event, tool glob, action class, identity/role, and argument constraints (glob or regex):

default_action: allow          # use "deny" for deny-by-default
on_error: allow                # engine error -> fail open (allow) or closed (deny)

egress:                        # network egress governance
  default: deny
  allow: ["api.github.com", "*.internal"]

rules:
  - name: admin-allow-all
    priority: 300
    action: allow
    tools: ["*"]
    roles: [admin]             # role comes from the verified identity token

  - name: block-secret-files
    priority: 150
    action: deny
    actions: [read, edit, write]
    argument_patterns: { file_path: "*.env*" }

  - name: confirm-prod
    priority: 120
    action: ask
    actions: [shell]
    regex: { command: "\\b(kubectl|helm)\\b.*\\bprod\\b|terraform\\s+apply" }

Validate with aegis validate.

Top-level fields (besides rules): default_action, on_error (allow/deny), egress (network governance), plugins (custom guard modules), workspace ({root, allow} confinement), project (bind file mutations to a repo — hard-block out-of-project edits), agent_label (default record label when no AEGIS_AGENT_NAME is set), install_review (forced dependency-manifest review — see Forced install review), and mcp_config (MCP server-config protection mode/allowlist — see MCP server-config protection). workspace/project give a repo zero-config attribution + confinement.

Four opt-in lifecycle knobs enable the gated lifecycle guards (see Lifecycle guards); each is a small mapping, off unless set:

team:        { require_verification: true }  # gate TaskCompleted on a verification signal
compaction:  { block_auto: true }            # block auto context compaction (PreCompact)
permission:  { deny_escalation: true }       # auto-deny a spawned agent's permission prompts
mcp:         { block_elicitation: true }     # block MCP elicitation side-channels

Configuration & state

Resolution is env -> project-local -> home, so a repo can ship its own guardrails while a user or org sets global defaults:

What Resolved from Default
Policy AEGIS_POLICIES -> ./.aegis/policies -> ~/.aegis/policies none needed (built-ins still apply)
Audit log AEGIS_AUDIT ~/.aegis/audit.jsonl
Identity keystore AEGIS_HOME ~/.aegis/identity/
Detections + blocklist AEGIS_HOME ~/.aegis/detections.jsonl, ~/.aegis/blocklist.txt

Switches: AEGIS_NO_BUILTINS (turn off the default guard set), AEGIS_IDENTITY_ENFORCE (deny + reap rogue sessions), AEGIS_ALLOW_SUBAGENTS, AEGIS_ALLOW_INSTALL (bypass the forced install-review gate), AEGIS_ALLOW_STRAND (permit branching with unmerged work), AEGIS_ALLOW_MCP_CONFIG (human confirmation to write an MCP server config via Edit/Write/MCP-tool — see MCP server-config protection), AEGIS_WORKSPACE / AEGIS_PROJECT (confinement root; AEGIS_PROJECT also binds the identity), AEGIS_FAIL_CLOSED (deny on an unparseable payload instead of fail-open).

Agent environment (set by your spawner at agent launch):

Var Purpose
AEGIS_AGENT_NAME The agent's claimed identity. Triggers the rogue-session gate if set without a valid token.
AEGIS_AGENT_TOKEN Signed Ed25519 identity token (minted with aegis issue). Proves the agent is real.
AEGIS_SESSION_ID Session id for audit attribution. Claude Code supplies one natively; set this for runtimes that don't (else a per-process fallback is recorded).
AEGIS_PROJECT Project root the agent is confined to — file mutations outside it are hard-blocked. A token's project claim takes precedence.
AEGIS_IDENTITY Manual identity override (non-token path). Falls back to OS user if unset.
AEGIS_ROLES Comma-separated roles for RBAC (non-token path; token roles take precedence).
AEGIS_AGENT_MODEL Model name (e.g. claude-sonnet-4-6). Recorded in attestation metadata.
AEGIS_AGENT_OPERATOR Operator/owner string. Recorded in attestation metadata.

Extending Aegis

A rule is just (event, policy) -> Decision | None. Ship a module that registers rules and point Aegis at it via AEGIS_PLUGINS or a policy plugins: list — no fork:

# acme_guards.py
from aegis.plugins import register_rule
from aegis.policy import Decision, Action

@register_rule
def no_prod_writes(event, policy):
    if event.action.value == "write" and "prod" in str(event.args.get("file_path", "")):
        return Decision(Action.DENY, "no-prod-writes", "writes to prod require review")
    return None

Because rules receive policy, a custom guard can read its own configuration out of the policy file — orgs distribute one policy with both their rules and their config.

Embedding Aegis in your MCP server

Hooks govern the agent runtime. If you build an MCP server, you can also enforce policy on the tool side — inside your server, where a client cannot bypass it. It's the same engine (built-ins + your plugins + your declarative rules + the server-side identity gate), invoked at the tool boundary instead of at a hook.

Setup

  1. Install Aegis in the server's environment (the same interpreter that runs the MCP server):
    pip install -e /path/to/AEGIS        # PyPI packaging planned
    
  2. Give it a policy — optional; built-ins apply with none. Aegis resolves the policy dir from AEGIS_POLICIES -> ./.aegis/policies -> ~/.aegis/policies:
    mkdir -p .aegis/policies
    cp /path/to/AEGIS/policies/example.yaml .aegis/policies/
    aegis validate
    
  3. Guard your tools — decorate the handlers, or call check / guard inline.
  4. Map the decision to your protocol — return an error to the client on a block.

Wire it into your tools (FastMCP — runnable copy in examples/mcp_server.py)

from mcp.server.fastmcp import FastMCP
from aegis import mcp as aegis

app = FastMCP("vault")

@app.tool()
@aegis.guarded                       # tool name defaults to the function name
def get_secret(key: str) -> str:
    return read_secret(key)          # only runs if policy allows

# or enforce inline, with full control of the response:
@app.tool()
def run_sql(query: str) -> str:
    d = aegis.check("run_sql", {"query": query})
    if d.blocked:
        return f"refused by Aegis policy: {d.message}"   # agent sees a clean refusal
    return execute(query)

aegis.guarded evaluates the handler's keyword arguments as the tool arguments (how FastMCP invokes tools) and raises aegis.Denied on a block — let it propagate (FastMCP surfaces it as a tool error) or catch it:

try:
    aegis.guard("delete_record", {"id": rec_id})
except aegis.Denied as e:
    return {"error": e.decision.message, "rule": e.decision.rule}

Identity (multi-tenant servers)

By default the caller identity comes from the environment (AEGIS_AGENT_NAME / AEGIS_AGENT_TOKEN). A server that authenticates its callers should pass identity explicitly so RBAC rules apply:

aegis.check("run_sql", {"query": q}, identity_name=req.user, roles=req.roles)

With AEGIS_IDENTITY_ENFORCE=1, the server-side gate refuses a rogue caller before any rule runs — a guarantee that holds even though the client controls the hooks. An MCP provider ships stronger safety to its users without trusting the client to behave.

Enforcement surfaces

The same policy enforces across three surfaces, so coverage doesn't depend on any single integration:

  • Agent-runtime hooks — Claude Code today (native hooks; aegis install merges into .claude/settings.json without clobbering existing config). Any runtime with a command hook via the generic JSON adapter. (Adapter status: ADAPTERS.md.)

  • MCP transport — enforce inside an MCP server (above).

  • git / CI — the universal change-boundary floor. Runtime hooks need a runtime you can hook; this surface doesn't. It enforces the same policy on the diff, so it covers any agent — or human — that produces commits, including agents whose runtime has no usable hook surface.

    • aegis install-git [--repo .] writes .git/hooks/pre-commit and pre-push, appending to any existing hook scripts (non-clobber).
    • pre-commit evaluates each staged file (git diff --cached) as a git action; a violation — committing a .env, or any path your policy forbids — blocks the commit before it's recorded.
    • pre-push evaluates the push itself (operation + branch); a rule denying branch: main stops a direct push to main.
    • aegis ci --base origin/<base> evaluates the files changed vs a base ref for pull-request enforcement: it prints GitHub ::error file=...:: annotations and exits non-zero to fail the check. The shipped .github/workflows/aegis.yml runs it on every PR.

    Git operations are modeled as action: git events (operation = commit/push, plus file and branch), so the same rules and built-ins apply — you author one policy and it enforces in the editor, at commit, at push, and in CI:

    - name: no-commit-secrets
      action: deny
      actions: [git]
      argument_patterns: { operation: commit, file: "*.env*" }
    - name: no-direct-push-to-main
      action: deny
      actions: [git]
      argument_patterns: { operation: push, branch: main }
    

    Scope: git/CI sees a change at commit / push / PR time (the diff), not every keystroke — it's the floor that complements the live runtime hooks, not a replacement for them.

Performance & operations

A hook runs once per tool call: the runtime spawns aegis hook, which starts a short-lived Python process, loads policy, evaluates, and exits. For a single project that's negligible — but two things matter:

  • cryptography is imported lazily (only when a token is actually verified), so the common no-identity path stays light.
  • Global install has a real cost. In ~/.claude/settings.json the hook fires for every tool call in every session; stacked with other global hooks, the per-call process spawns add up and can bog a busy machine. Prefer project-scoped installs for the repos you actually want guarded. A persistent-daemon hook (one process, many calls) is the planned path to comfortable always-on global enforcement.
  • Uninstall is path-aware. Installed hooks are tagged (_source: aegis) and matched by command, so aegis uninstall removes them whether they were wired by bare name or absolute path. To purge by hand, delete entries whose command contains aegis from the relevant settings.json.
  • Fail-open is deliberate. If the hook can't run (missing interpreter, moved install), the action proceeds unguarded rather than blocking your work — so a pipx install (a stable path) is the durable way to run it. A payload Aegis can't parse is handled the same way — logged to stderr and recorded in the audit (rule: payload-parse-error) rather than silently allowed; set AEGIS_FAIL_CLOSED=1 (or on_error: deny) to block unparseable payloads.

Accountability

Every decision is appended to a structured audit log — one JSON record per line: {ts, event, tool, action, decision, rule, identity, agent, session_id, cwd, args, usage}, at ~/.aegis/audit.jsonl (override with AEGIS_AUDIT). Point a whole team's logs at one directory and the reports aggregate fleet-wide.

  • aegis report — the rap sheet: per-session and per-identity rollups, the full denial trail, token/cost totals, and a verdict per session (flags like many-denials).
  • aegis who --tool <T> --path <P> — blame: which identities/sessions touched a tool or path.
  • aegis detections — caught rogue-agent attestations.

Token & cost tracking

Aegis captures token usage and cost metadata from every hook payload — whatever the runtime sends (input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, num_turns, total_cost, cost_usd, duration_ms, model) — and writes it into the audit record's usage field. Extraction is best-effort and never fails: if the runtime sends no usage data, the field is omitted.

The report aggregates usage at every level:

  • Summary — fleet-wide totals across all sessions.
  • Per-session — how many tokens and how much cost each agent session consumed.
  • Per-identity — the same rollup grouped by who ran the session.
$ aegis report
...
  Token usage:  380 in / 180 out  ·  cache: 10 read  ·  12 turns  ·  $0.05
  Sessions:
    s1 (alice / bot-a)   2 calls   180 in / 80 out
    s2 (bob / bot-b)     2 calls   200 in / 100 out   $0.05

This gives you the answer to "how much did that agent session cost?" and "which identity is burning the most tokens?" — from the same audit stream that already tracks what each agent did and whether it was allowed.

Who it's for

  • Solo devs using AI agents — you're giving Claude/Copilot/Cursor shell access to your machine. Aegis is the guardrail between "please help me refactor" and rm -rf / or your SSH keys ending up in a curl upload. Install it, get the secure-by-default set, never think about it until it saves you.

  • Teams and orgs — you have 5, 50, or 500 agents running across repos. You need one policy that governs all of them, accountability that shows who did what, and identity that distinguishes your CI agent from your code-review agent from a rogue process. Ship a policy YAML in your org's base repo config; Aegis enforces it everywhere.

  • MCP server authors / tool providers — you're building tools that agents call. You can't trust the client runtime to enforce limits — embed Aegis in your server and the guards hold regardless of what the client does. An execute_sql tool that refuses DROP TABLE inside the server is safer than one that hopes the agent's system prompt says "don't drop tables."

  • Security / platform teams — you need to prove agents can't exfiltrate secrets, can't wipe databases, can't install persistence, and you need an audit trail that shows it. Aegis gives you the enforcement + the receipts.

Aegis vs. alternatives

Aegis System prompt guardrails Sandbox / container MCP-only gateway
Enforcement boundary Runtime hook (separate process, outside context window) Inside the model's context window OS-level process isolation MCP transport layer
Bypassed by prompt injection? No — model can't change the exit code Yes — the instruction is in the window the attacker is injecting into No (different layer) Depends on implementation
Secure by default? Yes — built-in guards with zero config No — you write every rule as prose N/A (isolation, not policy) Varies
Covers shell + MCP + git? Yes — three enforcement surfaces from one policy Shell only (if the model cooperates) Shell only MCP only
Identity + accountability? Ed25519 signed tokens, audit log, blame, rogue detection None None Varies
Agent evasion resistance? Normalization + de-obfuscation + recursive expansion None — the model decides what counts Full (can't escape the container) Depends
Setup pip install + aegis install --project . Edit a prompt Docker/VM config Proxy config

Aegis + a sandbox is the strongest posture. Aegis enforces policy (what actions are allowed); a sandbox enforces isolation (what the process can reach). They're complementary: Aegis catches the DROP TABLE that a sandbox can't distinguish from a SELECT; the sandbox contains the novel evasion that Aegis's denylist hasn't seen.

Install

Requirements: Python 3.10+. Cross-platform engine; the shell-guard patterns cover bash, PowerShell, and cmd (a few are Windows-specific, e.g. DPAPI / registry autorun).

From source (PyPI packaging planned):

git clone https://github.com/Thepizzapie/AEGIS && cd AEGIS
pip install -e .            # or: pipx install .   (puts `aegis` on PATH for good)

aegis on PATH — read this. pip install -e . inside a venv only wires aegis into that venv, but the runtime hooks call aegis from wherever the agent runs. So either install with pipx (a stable global aegis), or install scoped with an absolute command: aegis install --project <repo> --command "/abs/path/to/.venv/bin/aegis hook".

Wire it into a repo (merge-safe — it never clobbers existing hooks):

aegis install --project /path/to/your/repo
aegis validate

Prefer project-scoped over global. aegis install --global governs every Claude Code session and spawns a process per tool call — on a busy machine that adds up. Scope it to the repos you actually want guarded. Remove anytime with aegis uninstall --project <repo> (or --global).

Try it (no Claude Code needed)

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | aegis hook PreToolUse
# [Aegis] Recursive force delete is blocked. Append '# aegis-allow' to confirm.
echo $?            # -> 2  (the action is refused)
aegis report       # the decision is in the audit trail

Works the same under PowerShell — a piped UTF-8 BOM is tolerated; use $LASTEXITCODE instead of echo $?.

CLI reference

Command Purpose
aegis hook <event> The entrypoint the runtime's hooks call — reads a tool-call JSON on stdin, evaluates policy, emits the decision.
aegis install / uninstall Wire/unwire the hooks in a Claude Code settings.json (merge-safe, idempotent). Flags: --project <path>, --global, --command <cmd>.
aegis install-git [--repo .] Write git pre-commit + pre-push hooks (append to existing, non-clobber). The hook invokes aegis by absolute path (resolved automatically; override with --command), so it works in a venv without aegis on PATH.
aegis git-hook <commit|push> The entrypoint the git hooks call — evaluates staged files (commit) or the push target (push).
aegis ci [--base origin/main] CI check: evaluate changed files vs a base ref, emit ::error annotations, exit non-zero on violation.
aegis validate [-d <dir>] Validate policy YAML files.
aegis pull <source> Pull org policy from a directory, file, or URL into the local policy dir.
aegis issue <agent> Mint a signed Ed25519 identity token. Flags: --role, --caps, --project.
aegis attest Submit an agent attestation (the honeypot endpoint rogue agents self-report to).
aegis detections List caught rogue-agent attestations.
aegis report Accountability rap sheet: per-session/identity rollups, denial trail, token/cost totals, session verdicts. Flags: --audit <path>, --json.
aegis who Blame: which identities/sessions touched a tool or path. Flags: --tool, --path, --json.
aegis adapters List available runtime adapters.

Design principles

  • Outside the context window. Enforcement is a property of the runtime boundary, not a request to the model. That is the whole point.
  • Secure by default. The dangerous defaults are on with zero config; relaxing them is a deliberate, recorded act.
  • Fail-open per rule. A buggy or slow rule can never wedge or brick a session — it is skipped, never fatal.
  • Escapable with intent, or not at all. Catastrophic/irreversible actions (secrets, self-removal, rogue sessions) are non-negotiable; merely dangerous ones accept a recorded # aegis-allow.
  • Runtime-agnostic core. Adapters translate; the engine and policy are shared.

Limitations

  • Not a sandbox. Aegis gates tool calls; it doesn't isolate a process. An agent already at a raw shell can run relative commands Aegis only sees as a shell action — pair it with an OS-restricted user/container for hostile-code isolation.
  • Denylist guards are heuristic. The built-in shell guards match known-dangerous command shapes; they raise the cost of evasion sharply but are not exhaustive by construction. Known-uncovered forms include: exfiltration via cloud CLIs (aws s3 cp, gsutil, rclone) or an in-process requests.post; in-place policy edits via sed -i / perl -i or a chmod 000 on the policy file; and git -c inline-config force-push. These fail safe for file mutations routed through the runtime's Edit/Write tools (caught by path, not verb), but a raw shell can still reach them. Deny-by-default (allowlist) is the stronger posture for high-security setups and is supported per-surface (network egress today; a shell allowlist preset is planned). If you find a bypass, that's a bug worth reporting.
  • Identity is as strong as the keystore. v1 persists the issuer key on disk; a process with your full privileges could read it. True unforgeability needs the key in a broker process plus OS privilege separation — the module isolates the key source so that hardening is a swap, not a rewrite.
  • Deep hooks are per-runtime. Native support is Claude Code today; other runtimes are covered by the generic JSON adapter or the git/CI floor.

License

Apache-2.0 — see LICENSE for details.

For questions about team, enterprise, or MCP-provider licensing, open an issue or get in touch.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

aegis_hooks-0.1.3.tar.gz (156.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

aegis_hooks-0.1.3-py3-none-any.whl (99.4 kB view details)

Uploaded Python 3

File details

Details for the file aegis_hooks-0.1.3.tar.gz.

File metadata

  • Download URL: aegis_hooks-0.1.3.tar.gz
  • Upload date:
  • Size: 156.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for aegis_hooks-0.1.3.tar.gz
Algorithm Hash digest
SHA256 7e3717bc6c9d22a1f3f5bf6d5d01c4885eae29c83ee4861cc6f234c5c3535d60
MD5 cded19aa7614a0c0bf2976bf5ef6b6bb
BLAKE2b-256 7d57a5c23e7b2ddbd8a39e39339630cb7aff1ed3120716b27e9003babf77671d

See more details on using hashes here.

Provenance

The following attestation bundles were made for aegis_hooks-0.1.3.tar.gz:

Publisher: publish.yml on Thepizzapie/AEGIS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file aegis_hooks-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: aegis_hooks-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 99.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for aegis_hooks-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 8bc3041a154cb34dedaed07f8231cf56861cc7f1b219026b646ae34da5ed3c6a
MD5 2d00051f3973037b6223062a92941cea
BLAKE2b-256 e4db5e019f79a471c2b9ed2f92dbd99199ca2bfb178eddb790a95ef3218a3505

See more details on using hashes here.

Provenance

The following attestation bundles were made for aegis_hooks-0.1.3-py3-none-any.whl:

Publisher: publish.yml on Thepizzapie/AEGIS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page