Claude Code Plugin¶
The Claude Code plugin (plugins/claude-code) hooks into Claude Code sessions using the official plugin + hook system. It scans prompts and tool calls locally and enforces warn/redact/block policies inline — writing findings to a local SQLite store by default, with zero backend required.
Note
Same plugin, two interfaces. This is the same package that runs inside Claude Desktop — identical hooks, detection engine, and /aka:* commands.
This page covers the architecture and the Claude Code terminal CLI workflow (installation, hook internals, building from source). If you're running inside Claude Desktop, see the Claude Desktop guide for its Settings-based install flow — everything else on this page still applies.
Plugin structure¶
A Claude Code plugin is a directory that Claude Code loads at startup. AKA's layout:
plugins/claude-code/
├── .claude-plugin/
│ └── plugin.json # manifest — ONLY this file goes in .claude-plugin/
├── hooks/hooks.json # hook → script wiring (auto-discovered)
├── commands/ # /aka:* slash commands (auto-discovered)
│ ├── setup.md # /aka:setup — onboarding wizard
│ ├── dashboard.md # /aka:dashboard — launch the web dashboard (via the aka CLI)
│ ├── health.md # /aka:health — detection activity + posture
│ ├── findings.md # /aka:findings — recent findings (masked)
│ ├── recommend.md # /aka:recommend — top recommendations
│ ├── audit.md # /aka:audit — recent enforcement decisions
│ ├── scan.md # /aka:scan — scan working tree for code flaws
│ ├── tokens.md # /aka:tokens — token usage + estimated cost
│ └── exceptions.md # /aka:exceptions — active detection exceptions (read-only)
└── scripts/*.js # built scripts (tsup output, self-contained):
# hooks + query.js (read surface) + dashboard.js + onboard.js
The manifest is intentionally minimal. name becomes the command namespace, so commands/setup.md is invoked as /aka:setup:
{
"name": "aka",
"version": "0.9.5",
"description": "AI Traffic Control — inspect and govern AI prompts in Claude Code. Detection runs locally; events are recorded to a local SQLite store on your machine.",
"homepage": "https://github.com/akasecurity/ai-tc",
"author": { "name": "AKA Security" }
}
Warning
Do not add hooks / commands keys pointing at the default folders.
Claude Code auto-discovers hooks/hooks.json and commands/; manifest keys are only for non-default paths, and their formats are strict — commands must be a ./-prefixed array (["./custom/cmds/"]) and hooks a ./-prefixed string ("./config/hooks.json").
The malformed "hooks": "hooks/hooks.json" / "commands": "commands/" fails manifest validation. Check with claude plugin validate plugins/claude-code.
Architecture in one paragraph¶
Every hook invocation is a fresh, short-lived process: Claude Code pipes a JSON event to the script's stdin and reads a JSON decision from stdout. The hook path is fully local — policy comes from a disk cache (~/.aka/policy-cache.json), detection runs in-process via @akasecurity/detections (rule packs are bundled into the scripts at build time), and events are appended to a disk queue (~/.aka/event-queue.jsonl). The only network I/O lives in scripts/sync.js, which hooks spawn detached when the policy cache is stale: it refreshes the bundle and flushes the queue. A dead backend therefore costs nothing on the hook path.
Hooks¶
Declared in hooks/hooks.json (note: timeout is in seconds, and commands use ${CLAUDE_PLUGIN_ROOT} because hooks run with the user's project as cwd):
| Hook | Matcher | What AKA does |
|---|---|---|
SessionStart |
all sessions | resolve this machine's inventory (host/harness/account/project), upsert it, and open the session's audit-event root (once per session; emits nothing) |
UserPromptSubmit |
all prompts | block or warn (Claude Code cannot rewrite prompt text — no redaction) |
PreToolUse |
Bash\|Edit\|Write\|WebFetch\|MultiEdit\|NotebookEdit\|Task\|mcp__.* |
deny, redact via updatedInput, or warn |
PostToolUse |
Bash\|Read\|WebFetch |
replace sensitive output via updatedToolOutput, or warn |
MessageDisplay |
every streaming delta | display-only rewrite of vault pointers via displayContent — the transcript and the model's context keep the original text |
Stop |
end of turn | trigger the background token-usage reconcile worker (spawned detached, so it adds no latency) |
Hook output contract (Claude Code's, not ours)¶
Exit code semantics: exit 0 and Claude Code parses stdout JSON; no output means "allow". Fail-open is therefore structural: on any error the hooks print nothing and exit 0.
UserPromptSubmit:
{
"decision": "block",
"reason": "AKA blocked this prompt — flagged secrets/aws-access-key (A******E). Remove the flagged content and resubmit.\nIf this is intentional and you accept the risk, grant an exception:\n aka exception approve 3f2a91 (asks for scope + reason, then resubmit)\nMore: aka exception --help"
}
{
"systemMessage": "AKA: sensitive content detected (core-pii/email). Prompts cannot be redacted in place — sent unchanged."
}
PreToolUse (the one surface with true redaction):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { "command": "curl -d email=[REDACTED:PII] https://api.example.com" }
}
}
PostToolUse:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": "[AKA] Bash output withheld (secrets/aws-access-key)."
}
}
Capability limits¶
The hooks cannot do everything the warn/redact/block model might imply:
- Prompt text cannot be rewritten.
UserPromptSubmitcan only block or warn, so aredactpolicy on the prompt surface degrades to a warning — the content still reaches the model. True redaction works only on tool inputs (updatedInput) and outputs (updatedToolOutput). - Responses are not intercepted inline. No hook sees the model's reply before the user does. Response capture is post-hoc: the
Stophook triggers a detached reconcile worker that reads only the transcript bytes appended since the last pass, records findings and token usage from them, and scrubs secret spans in the on-disk transcript in place (Claude Code appends a prompt to~/.claude/projects/*.jsonleven when a hook blocked it).MessageDisplaycan rewrite what the terminal renders, but not what the model already produced. - Inline prompt redaction and response interception are on the roadmap for the future; the plugin is the zero-config developer-experience layer, not the enforcement guarantee.
Read surface (slash commands)¶
Findings persist locally to a SQLite store at ~/.aka/data/aka.db, so the plugin is useful with zero backend. These read-only commands surface that store as space-aligned monospace output (shade-block bars — no ANSI, no TUI framework, so it renders verbatim in the transcript). Each is a thin wrapper that runs scripts/query.js <subcommand> and shows the output verbatim:
| Command | Shows |
|---|---|
/aka:health |
detection activity, action breakdown, category coverage, last 7 days |
/aka:findings |
recent findings with rule, category, severity, action, and the masked match |
/aka:recommend |
findings grouped by category (ranked by severity, then frequency) + next steps |
/aka:audit |
the decision log — recent detections and the action AKA took |
/aka:scan |
scan working-tree source files for insecure code patterns (OWASP Top 10) |
/aka:tokens |
token usage per provider/model (reconciled from transcripts) + estimated cost |
/aka:exceptions |
active detection exceptions — masked value, rule, scope, expiry, uses |
/aka:detections |
installed detection packs — version, rules, enabled, policy, update available |
/aka:exceptions is deliberately read-only: creating, approving, or revoking an exception happens only out-of-band in a terminal via the aka CLI, never from inside a Claude Code session — the session being policed must not be able to grant its own bypass.
/aka:detections is read-only for the same reason: it shows each pack's installed version against the latest one the running plugin ships, and whether an update is pending — but applying an update happens out-of-band, via aka detections update in a terminal or the dashboard's Update button. Detection updates are never applied automatically: a plugin upgrade records what's newly available, and the installed packs keep scanning unchanged until you update them. The plugin scans with the installed snapshot (enabled packs only), so both the enable/disable toggle and pack versions genuinely control what runs.
When a detection blocks: exception guidance¶
A block (or redact) message now guides the user toward the sanctioned escape hatch instead of a dead end. Removing the flagged content remains the primary recommendation; when the block is intentional (say, a temporary credential the user will rotate), the message includes a copy-paste-complete command:
AKA blocked this prompt — flagged secrets/aws-access-key (A******E). Remove the flagged content and resubmit.
If this is intentional and you accept the risk, grant an exception:
aka exception approve 3f2a91 (asks for scope + reason, then resubmit)
More: aka exception --help
The masked preview reveals only the first and last character of the detected value (A******E), and the reference (3f2a91) points at a short-lived, fingerprint-only record of what was blocked, so aka exception approve can create the grant without the user ever retyping the secret. The approved value itself is never stored — only a keyed fingerprint and a masked preview. See Adding exceptions for the full walkthrough.
The raw matched secret is never stored or shown — only a masked form. All are read-only; they never mutate the database. They also work directly:
/aka:scan re-runs are cheap: every scanned file — including clean ones — is tracked in a local scan ledger (path + mtime + content hash), so an unchanged file is skipped without even being re-read. The ledger is keyed to a fingerprint of the active detection ruleset, so installing or updating a rule pack automatically rescans everything; findings themselves are deduplicated by content hash, so a re-run never duplicates them.
Files excluded by the repo's .gitignore are still scanned — local scratch and generated files are a common hiding place for real secrets — but their events carry metadata.gitignored: true and the scan summary reports those findings as informational, leaving enforcement policy to decide how loudly to treat them.
To exclude paths from scanning entirely, add a .akaignore file (gitignore syntax, honored at any directory level, deeper files and ! negations override shallower ones). Unlike .gitignore it is a hard skip: matching files are never read, never ledgered, and produce no findings. A .akaignore negation also overrides the scanner's built-in default skip list (node_modules, dist, vendor, target, …) — e.g. !vendor/ re-includes first-party code that lives under vendor/. The two files compose: .gitignore decides how loudly a finding is reported, .akaignore decides whether the file is looked at at all.
/aka:scan --discover (the multi-repo sweep) searches for git repositories under the current directory by default — never the home directory implicitly. A broader sweep requires an explicit --root <path> (e.g. --root ~), which the /aka:scan command only passes after naming the scope to the user and getting confirmation; --depth <n> bounds the recursion (default 4).
/aka:dashboard — the web dashboard¶
For the full graphical view, /aka:dashboard launches the OSS web-ui (the same one aka dashboard opens) against your local store and points the browser at http://localhost:4319/security. It runs scripts/dashboard.js, which delegates to the aka CLI: the web server is bundled in @akasecurity/cli, not the plugin, so the launcher spawns aka dashboard in the background and returns immediately (--port <N> is forwarded). If the aka CLI isn't installed it prints how to get it instead of failing.
Configuration¶
/aka:setup is a calibration wizard that records local preferences to ~/.aka/settings/settings.json (created 0600, owner-only) via scripts/onboard.js. It derives everything it shows from your real activity — never a demo number — and writes nothing to the policy store until you confirm:
{
"specVersion": 5,
"runMode": "standalone",
"policy": "redact",
"historicalAccess": "session-only",
"dataSharesInPlace": true,
"vaultKeyCustody": "file",
"vaultInlineReveal": "masked",
"onboardedAt": "2026-07-29T...",
"modelJudgeConsent": {
"acknowledgedAt": "2026-07-29T...",
"payloadVersion": 1
}
}
policy—redact(replace sensitive values in place where the host allows) orwarn(surface a warning, never modify content).historicalAccess—session-only(default) orfull. Consent for scanning pre-install surfaces — scratch/temp files, agent memory and prior conversation transcripts — for already-leaked secrets. Defaults tosession-onlyso historical scanning is always an explicit opt-in, never an assumed grant on upgrade; declining still leaves AKA the working tree, the live session, git history and pointed scans to review.dataSharesInPlace— in-place egress extraction on the scan paths; setfalseto stop all Data Shares writes.modelJudgeConsent— absent until granted. Records that you consented to the/aka:setuptriage sending findings to the model API to be rated, and the payload shape you agreed to. A consent recorded against an olderpayloadVersionis stale, and stale reads as revoked — you are asked again rather than silently held to a grant for a payload you never saw. This is a distinct consent fromhistoricalAccess, which only governs reading local transcripts. Revocable under Settings → Model-judge consent; revoking stops future scans but cannot recall data already sent. See Why the setup scan sends findings to the model.vaultConsent— absent until granted. Consent to keep a recoverable encrypted copy of detected values in the local secret vault instead of destroying them. Never an assumed grant on upgrade, because it is a custody change from one-way redaction. Revoking stops future vaulting; it does not erase what is already stored — purging the vault is the eraser.vaultKeyCustody— where the vault master key lives (filedefault, orkeychain).vaultInlineReveal— how a vault pointer renders in assistant prose:masked(default — a[scrubbed:…]badge built from descriptor data only, with no de-reference and no audit row),full(the real value plus the badge, capped per message), oroff(display untouched).maskedis the default because injected content can induce the model to echo every pointer it has seen; underfullthose echoes would render real secrets on screen.
The settings file is versioned (specVersion): each onboarding step is one more optional field, and an older settings.json still parses with the missing key taking its default — so a file written before historicalAccess existed loads as session-only. v2 added historicalAccess, v3 dataSharesInPlace, v4 modelJudgeConsent, v5 the secret-vault fields.
The model-judge egress¶
The false-positive/severity judgment that calibrates your posture needs the raw, unmasked findings to rate them accurately, so — only on the modelJudgeConsent grant above — it sends them to the model API through separate claude -p subprocesses (a large history is judged in several batches). Two things cross per finding: its raw value, and roughly 120 characters of surrounding transcript text on either side, with any other detectable secret in that window re-masked first. The filePath, the value's fingerprint, and its key version are dropped before egress.
Those subprocesses ask the claude CLI to write no transcript (CLAUDE_CODE_SKIP_PROMPT_HISTORY=1), so the raw values never enter your conversation or your scannable history — but that is transcript isolation, not network isolation. A copy of each value leaves the machine, because reaching the model is the whole point. The wizard acts only on the raw-free plan the subprocesses print back.
Declining leaves AKA on the conservative severity floor (high-impact categories at warn, observe-only at monitor), which it then tunes from live activity. Full rationale and the revocation limits are on the installation page.
Historical backfill scan¶
When historicalAccess: full is chosen, /aka:setup runs scripts/backfill.js at the end of onboarding. It sweeps prior Claude Code transcripts (~/.claude/projects/*/*.jsonl, all projects, last 30 days), extracts user prompts and assistant replies, and feeds each through the same detect→mask→record path the live hooks use — so secrets that leaked before AKA was installed surface in /findings alongside live ones. Findings carry their original transcript timestamp (not scan time), and only messages that actually match a rule are persisted. Tool inputs/outputs are not yet scanned. The scan is idempotent — it loads the store's existing content hashes and skips any message already recorded — so re-running /aka:setup re-scans without ever duplicating findings, and a cleared store re-scans in full. Fully fail-open. After install, no backfill is needed — the UserPromptSubmit/PreToolUse/PostToolUse hooks already see everything.
Configuration-inventory scan (skills & hooks)¶
Once per session (on SessionStart, under the same once-per-session claim as the inventory pass), the plugin scans the machine's Claude Code configuration surface and records it in the local store:
- Hooks from
~/.claude/settings.json(user scope),<project>/.claude/settings.json(project),<project>/.claude/settings.local.json(local), and each installed plugin'shooks/hooks.json(attributed to its plugin via~/.claude/plugins/installed_plugins.json— never by guessing directory layouts). - Skills from
~/.claude/skills/(personal), the project's<project>/.claude/skills/and top-level<project>/skills/, installed plugins'skills/directories, and every registered marketplace's skills (its rootskills/plus each plugin'splugins/*/skills/andexternal_plugins/*/skills/, from~/.claude/plugins/known_marketplaces.json) — name/description/version fromSKILL.mdfrontmatter, freshness from file mtime. Claude Code's built-inclaude-plugins-officialcatalog (matched by name, or its canonicalanthropics/claude-plugins-officialrepo even if the marketplace is renamed locally) is excluded — it is the tool's own bundled skills, not the user's config. A marketplace the user deliberately added, including Anthropic-published ones likeanthropics/skills, is surfaced. Skills are de-duplicated by inventory identity (source + name), so a personalpdfand a marketplacepdfremain distinct rows.
Everything is read-only: the scan never edits config, and never captures environment values or secrets — hook commands, matchers, scopes, and skill metadata only. Each artifact becomes a content-addressed inventory row (re-scans update it in place; an uninstalled artifact simply goes stale and stops rendering), and each scan writes one config_scan audit event recording counts and any files that failed to parse. A malformed settings file never breaks the scan — the remaining sources are still collected, and the failure is noted on the scan event. Fully fail-open, like every hook path.
Environment variables are not used: hooks are processes spawned by Claude Code, so a slash command cannot inject env into them — a file is the only reliable channel.
Unconfigured is a valid state: until /aka:setup runs, detection still uses the bundled rule packs and default actions (secret → block, pii → redact where possible), and the first prompt nudges the user to run the wizard.
Reading your local data¶
The plugin writes ~/.aka/data/aka.db and the OSS web dashboard
reads that same store directly (@akasecurity/persistence, Server Components) — web
dashboards over your local data with nothing leaving the machine and no server to
run. Launch it with aka dashboard (see the CLI).
Installing, developing, and testing¶
Loading the plugin needs the Claude Code CLI (the claude command). Install it with the native installer (a standalone binary, independent of your Node setup) or npm:
curl -fsSL https://claude.ai/install.sh | bash # recommended — native binary
# or: npm i -g @anthropic-ai/claude-code
Hooks invoke node at runtime, so node must be on the user's PATH — note this as an install prerequisite.
Load the plugin for a single session without a marketplace install:
pnpm turbo run build --filter=@akasecurity/ai-tc-claude-code # tsup bundles src → scripts/*.js (self-contained)
claude --plugin-dir ./plugins/claude-code # load for one session
# after a rebuild, run /reload-plugins inside the session
Inside the session, /hooks lists the registered hooks (confirm UserPromptSubmit, PreToolUse, PostToolUse) and /aka:setup runs the onboarding wizard. Submitting a prompt containing a fake AWS key (AKIAIOSFODNN7EXAMPLE) should be blocked with the AKA reason — then /aka:findings and /aka:health show the recorded detection.
Distribution (marketplace + npm)¶
End users do not clone this repo. The plugin is distributed as a published npm package, listed in a Claude Code marketplace catalog (.claude-plugin/marketplace.json at the repo root):
Run these in the Claude Code terminal CLI (claude) — /plugin marketplace management is not exposed in the IDE/editor extension surfaces. Running Claude Desktop instead? See Claude Desktop → Installing for the equivalent Settings-panel flow.
Removing the plugin later? See Uninstalling.
If you have the aka CLI installed, aka plugins install claude-code runs both steps for you (it delegates to the same claude plugin manager), and aka update claude-code updates the plugin later. Either way, restart Claude Code afterwards to load the change.
Pre-release distribution — private GitHub Packages. While AKA is pre-release we publish only private artifacts to GitHub Packages — nothing goes to the public npm registry. The
/plugin installabove resolves an npm source, so it can only be fetched with a valid~/.npmrcthat points the@akasecurityscope at GitHub Packages and carries a GitHub token withread:packagesaccess to our packages:Write it once (
chmod 600 ~/.npmrc) before running/plugin install; without it the marketplace fetch fails with401/403. This requirement disappears once the project is public.
Why npm rather than a git source: a Claude Code marketplace install copies the source as-is and never runs a build step, and the hook scripts (scripts/*.js) are git-ignored. A github / git-subdir source would therefore ship a plugin whose hooks.json points at files that don't exist. The npm source instead ships the built, self-contained bundle (the @akasecurity/* workspace deps and zod are inlined by tsup, so the published package has no runtime dependencies). node must still be on the user's PATH — the hooks invoke it.
Releasing is automated by .github/workflows/release-plugin.yml:
- Bump
versionin bothplugins/claude-code/package.jsonand.claude-plugin/plugin.json. - Push a tag
plugin-v<version>(e.g.plugin-v0.1.0). - CI builds with Turbo, verifies the tag matches both manifests, smoke-tests the fail-open contract, publishes to private GitHub Packages (
npm.pkg.github.com, authenticated with the workflow's built-inGITHUB_TOKEN), and attaches the tarball to a GitHub Release.
The marketplace's npm source is unpinned, so /plugin install and /plugin update resolve the latest published version — the npm package version is the single source of truth.
Hooks are also testable without Claude Code:
echo '{"prompt":"key AKIAIOSFODNN7EXAMPLE"}' | node plugins/claude-code/scripts/user-prompt-submit.js
# → {"decision":"block","reason":"AKA blocked this prompt — flagged secrets/aws-access-key (A******E). Remove the flagged content and resubmit.\n..."}
echo 'not json' | node plugins/claude-code/scripts/user-prompt-submit.js
# → no output, exit 0 (fail-open)
Fail-open guarantee¶
Three layers, all of which must hold:
- Hook entries wrap
main()in try/catch; the catch prints nothing and exits 0 (= allow). createPluginRuntime().processTextcatches internally and returns{ action: 'log' }.- Exit-code semantics: even an unhandled crash with a non-2 exit code is non-blocking for Claude Code.
Building a new adapter¶
To support a different AI tool, implement the AkaPluginAdapter interface from packages/plugin-sdk and reuse the same runtime: createPluginRuntime (local scan + policy), buildIngestEvent, registerRulePack, and syncWithBackend (out-of-band network). See packages/plugin-sdk/src/types.ts for the full interface.
Three adapters already exist alongside this one, and they are the most useful reference for what varies between hosts — event names, what a hook's output can express, and therefore which policies degrade:
- Codex CLI — same event names, but no output splicing and no
apply_patchevents yet - Antigravity — no prompt event at all, fail-closed hooks, deny-instead-of-redact
- Browser extension — no hook system; a content script plus a local native-messaging host
Adding a host is a registry entry (AGENT_PLUGINS in packages/local-ops/src/registry.ts, which joins the agent to the rows it records via its sourceTool) plus a thin plugin package — not a change to the CLI or the runtime.