koopa plugin
Skills for using koopa in any project: what to run, why a tool resolves where it does, and how to diagnose problems. The plugin also ships agent shell-safety hooks (see Hooks below). It has no commands or subagents.
Skills and hooks enable together: installing the plugin turns on both, and there is no per-component off switch.
plugin.json is the canonical Agent Plugins 1.0 (agent-plugins.org) package
identity: Copilot and Codex read it directly. .claude-plugin/plugin.json
and gemini-extension.json are generated from it by
koopa develop generate-agent-config; edit plugin.json, not those two,
and regenerate.
The dotfiles declare this plugin for Claude Code and Copilot CLI, and
koopa update refreshes it (lang/python/src/koopa/agent_plugins.py).
To use this plugin from Gemini CLI: gemini extensions link ~/.local/share/koopa/plugins/koopa.
Contents
skills/koopa-cli— command syntax: install, update, list, configure.skills/koopa-env— why a tool resolves from koopa vs. Homebrew/the system, andKOOPA_AUTO_ACTIVATEfor non-interactive/agentic sessions.skills/koopa-troubleshoot—koopa system check/info, install logs, and whykoopa system prune-appsneeds confirmation before running.skills/koopa-claude-permissions: Claude Code permission system internals (protected paths, permission modes, allow/ask/deny precedence, the PreToolUsepermissionDecisionhook contract).skills/koopa-agent-guards: what each guard hook blocks or asks about, why, and the correct alternative; thekoopa.*git config keys; and how not to work around a block.skills/koopa-git-history-surgery: rewriting git history safely withgit filter-repo(removing secrets, rewriting author emails, de-duplicating commits).skills/koopa-git-recovery: recovering from git branch/state accidents (wrong-branch reset, alias side effects, untracked-file checkout collisions, stash cleanup).skills/koopa-maintain-claude-config: maintaining Claude Code configuration (CLAUDE.md, rules, hooks, skills): auditing bloat and deciding where a new rule belongs.skills/koopa-python: Python virtual environment lessons (why moving a project breaks.venv/bin/activate, and why direnvPATH_addsurvives it).skills/koopa-todo-org: org-modetodo.orgconventions for project task tracking.skills/koopa-workflow-guidance: workflow orchestration (plan mode etiquette, subagent discipline, session hygiene, git commit conventions).
Hooks
Thirteen PreToolUse hooks in five groups, plus two Claude Code only hooks on
SessionStart and Stop. Nine run on Bash, registered in this order
(guard_sensitive_paths.py last). guard_tmp.py has its own group on
Bash|Write|Edit|MultiEdit|NotebookEdit, guard_report_path.py has a third group
on Write|Edit|MultiEdit, guard_keywords.py has a fourth on
Edit|Write|MultiEdit|NotebookEdit, and guard_env_files.py has a fifth on
Edit|Write|Read|Bash. guard_settings_keywords.py (SessionStart and Stop) and
guard_workspace_root.py (SessionStart) are registered in hooks/hooks.json only, with
no matcher. The Copilot file has the thirteen PreToolUse hooks and no SessionStart or
Stop entry.
These are opinionated defaults. They ship on for every user of the plugin, they are
strict on purpose, and they encode one person's safety habits: no software installs
by the agent, no uv, no rm -rf outside $TMPDIR, no /tmp scratch files, session
reports go to your Obsidian vault instead of a reports/ folder, no reading or editing
of .env files, a prompt before the agent touches ~/Documents or ~/Downloads, a
warning when a Claude Code session is not rooted in a git repository, and (once you
configure keywords) no configured keyword written into a publishable repository. If
that is not how you work, do not install the plugin. There is no per-hook switch and
no override. To run a blocked command, run it yourself in the terminal (in Claude
Code, type ! before the command).
-
block_tmux_kill_server.py: blockstmux ... kill-server. It tears down every session on the socket, including concurrent jobs you cannot see or recover. Usekill-session -t <name>. -
block_destructive_git.py: hard-blocksreset --hard,clean -fd, force pushes,git rm -rf,checkout -- .,branch -D, andgit push origin mainormaster(alsox:main) from the current repo;--force-with-leaseand+refspeccount as force pushes. A push after acdorgit -Cinto a different repo is allowed. A push tomainthrough another remote name, or a baregit push, is not matched by this rule. -
audit_secrets_before_push.py: on a realgit push, lists the files in the outgoing commit range (@{u}..HEAD, elseorigin/<branch>..HEAD) and blocks when a secret-shaped filename appears (an environment file,.pem,.p12,credentials.json,secrets.*,*_key.*). The repository comes fromCLAUDE_PROJECT_DIR, else the payloadcwd, else the current directory. A git failure (not a repo, no range) never blocks. -
guard_installs.py: blocks commands that install software:pip,npm,pnpm,yarn,cargo,gem,go install,brew,apt,dnf,yum,pacman,port,pipx,poetry add,uv add,cpan/cpanm,R install.packages,BiocManager::install,renv::install,koopa install, andcurl ... | sh. The agent surfaces the command and you install it yourself. -
block_uv.py: blocksuvanduvxin any form, including insidebash -c,ssh host '...', and substitutions. When the parser cannot decide it fails closed on a broad text pattern. -
guard_broad_scan.py: blocksfind,grep -r,rg, andfdrooted at~,$HOME,/,/Users,/home, or a bare home directory. Deeper paths pass. -
guard_rm.py: denies recursive-forcerm(andfind -delete,find -exec rm) unless every target is strictly inside$TMPDIR, in any flag spelling and behind wrappers. It also auto-approves plainrm/rmdirthat stay inside$TMPDIR(permissionDecision: allow), and has no opinion on anything else. It is inactive as an allow when$TMPDIRis unset or too broad, but still denies. -
guard_tmp.py: denies writing scratch files to/tmp,/var/tmp, or/dev/shm(redirects,tee,touch,mkdir,cp/mvdestinations, interpreter code, and the Write/Edit tools) and tells the model to usemktemp -d. It is inactive when$TMPDIRis unset or itself under/tmp. -
guard_hook_writes.py: asks (permissionDecision: ask, never denies) before a Bash command rewrites a hook or settings file:.agents/hooks,.claude/hooks, or.claude/settings*.json, viased -i, a redirect,tee,mv,rm,cponto it, or interpreter code. Reads, running a hook, and editing a source checkout stay quiet. It is a speed bump, not a boundary: a script that writes the file itself is not seen. The Copilot registration setsAGENT_HOOK_HARNESS=copilotso the ask uses Copilot's output shape. -
guard_report_path.py: when an Obsidian vault is detected (KOOPA_BUCKET, elseOBSIDIAN_VAULT_DIR, else Obsidian's own registry,obsidian.json), blocks a write to a datedreports/YYYY/MM/DD/orreports/YYYY-MM-DD/path, or directly under the project's top-levelreports/, and names the vault path to use. With no vault detected it does nothing. A delete, or the source side of a move, is never blocked.hooks/obsidian_vault.pyis its vault resolver. -
guard_keywords.py: blocks a Write/Edit that would put a configured restricted keyword into a publishable repository. It is inert until you configure it, and the plugin ships no keywords. It reads three git config keys. The dotfilespre-commithook reads the same keys:koopa.keywords: checked in the file path and in the new content.koopa.contentKeywords: checked in the new content only (for a keyword such as a username that appears in every absolute path under your home).koopa.allowedRemotes: remotes where the keywords are legitimate; an entry matches when it is a case-sensitive substring of theoriginURL.
git config --global --add koopa.keywords acmecorp git config --global --add koopa.contentKeywords jdoe42 git config --global --add koopa.allowedRemotes git.acmecorp.exampleDeprecated aliases:
koopa.restrictedTokens,koopa.restrictedTokensContentOnlyandkoopa.corpRemoteAllowlistare still read for one more release and will be removed in a later release. For each setting the hook uses the union of the new key and its old alias (de-duplicated), so a config renamed at a different time stays guarded.Each value is a case-insensitive regular expression that must not touch an ASCII letter or digit on either side (
acmecorpdoes not matchacmecorporation). Only the new content is checked, never the text an edit replaces. A match blocks only when the target is absolute, its directory exists inside a git work tree,originis not allowlisted, and the path is not gitignored; any git failure (5 second timeout per call) allows the write. An invalid pattern is skipped with one warning line. Any unexpected error exits 0, because the gitpre-commithook is the backstop. The block message is generic and does not echo the keyword. A delete, or the source side of a move, is never checked. -
guard_env_files.py: blocks reading or editing.envand.env.local, through the file tools (Edit,Write,Read, Copilotview,edit,create,apply_patch) and through a shell command that names either file (cat .env,grep KEY .env,python3 -c "open('.env')"). A token check, not a verb check, sols .envis blocked too. Loading the file without showing it is allowed:source .envor. .envat the start of a command, andload_dotenv(...)..envrc,.env.example,.venvand a.env/directory do not match. A tool input it cannot read exits 2 (a credential check must not pass on input it cannot inspect), and so does any internal error. It is a text check, not a boundary: a glob or$(printf ...)gets around it. -
guard_sensitive_paths.py: asks (permissionDecision: ask, never denies) before a Bash command references~/Documentsor~/Downloads, spelled~,$HOME,${HOME}or/Users/<name>../MyDocuments,~/Documents-oldand/opt/Users/x/Documentsdo not match. With no configuration every reference asks. The Copilot registration setsAGENT_HOOK_HARNESS=copilotso the ask uses Copilot's output shape. Exemptions come from the git config keykoopa.allowedPaths: multi-valued, each value a path relative to your home directory that starts withDocumentsorDownloads. A reference is exempt when it sits at or under an allowed path, matched at a path-segment boundary (Documents/notesexempts~/Documents/notes/a.mdbut not~/Documents/notes-old), and not when the rest of the path climbs with... Every reference in the command must be exempt. The example values are made up:git config --global --add koopa.allowedPaths Documents/notes git config --global --add koopa.allowedPaths Documents/tasks -
guard_settings_keywords.py(Claude Code only): onSessionStartandStop, scans$CLAUDE_PROJECT_DIR/.claude/settings.jsonfor a keyword from the same threeguard_keywords.pyconfig keys (and their deprecated aliases), under the same boundary rule and the same publishable-repository test. It exists because Claude Code writes a "don't ask again" permission rule itself, which is not a tool call, so noPreToolUsehook sees it. OnSessionStartit adds the line numbers to the session context; onStopit exits 2 with the message on stderr, unlessstop_hook_activeis true (the loop guard). The message never echoes the keyword. It is left out of the Copilot file because Copilot is documented to keep approvals in a user-level file outside the repository, and itsagentStopevent expects a JSONdecision, not exit 2. Copilot's hooks reference documents astop_hook_activeflag foragentStop; this has not been checked in a live session. -
guard_workspace_root.py(Claude Code only): onSessionStart, prints one warning line (never blocks) when$CLAUDE_PROJECT_DIRis not inside any git repository, since a session rooted outside a repo never loads the project's.mcp.jsonand.claude/settings.json. A worktree passes.CLAUDE_ALLOW_NONREPO_ROOT=1silences it. It is left out of the Copilot file because it readsCLAUDE_PROJECT_DIR, which Copilot does not set.
The two Claude Code only hooks gate themselves: unless CLAUDE_PROJECT_DIR is set and
non-empty they exit 0 without output. Codex reads the same hooks/hooks.json, and this
gate keeps them quiet if Codex runs them without that variable.
Files:
hooks/hooks.json: Claude Code and Codex format, commands use${CLAUDE_PLUGIN_ROOT}. It also holds theSessionStartandStopentries.com.github.copilot/hooks/hooks.json: Copilot format (version: 1), commands use${PLUGIN_ROOT},timeout5.PreToolUseonly.- Each hook is a single Python entry point, with no
jqdependency. It reads Claudetool_inputand CopilottoolCalls[]payloads and asks the shell parser inhooks/shell_command_scan.py(throughhooks/guard_git_scan.py,hooks/guard_installs_scan.pyorhooks/uv_command_scan.pywhere a hook has its own scanner) whether a command would run the thing, so text that merely mentions it passes. When the parser cannot decide, a text pattern decides (for destructive git with nocdexemption).guard_broad_scan.pyis text based only. A block prints the reason to stderr and exits 2;guard_rm.pyalso prints an allow decision on stdout forrminside$TMPDIR. The ask hooks (guard_hook_writes.py,guard_sensitive_paths.py) print an ask decision on stdout.guard_workspace_root.pyprints a plain warning line on stdout. Empty or malformed stdin exits 0. hooks/hook_adapter.py,hooks/shell_command_scan.py,hooks/guard_git_scan.py,hooks/guard_installs_scan.py,hooks/uv_command_scan.py,hooks/guard_rm.py,hooks/guard_tmp.py,hooks/guard_hook_writes.py,hooks/guard_report_path.py,hooks/guard_keywords.py,hooks/guard_env_files.pyandhooks/obsidian_vault.pyare bundled copies of the dotfiles engine and duplicate it until the dotfiles drop theirs. Keep them verbatim. The hook files must stay valid Python 3.10, becausepython3may be an old system interpreter.hooks/guard_sensitive_paths.pyandhooks/guard_settings_keywords.pyimport their git config helpers fromhooks/guard_keywords.py, so those files travel together.
Not verified (needs a human):
- That Claude Code, Codex, Copilot CLI, and VS Code actually load these hook files from the installed plugin and run the hooks.
- That
${PLUGIN_ROOT}expands in Copilot CLI hook commands. It is documented for VS Code plugin hooks but not confirmed for Copilot CLI. - That Codex reads
hooks/hooks.jsonand sets${CLAUDE_PLUGIN_ROOT}. - That a block (exit 2 with the reason JSON on stderr) is shown to the model by each harness as the reason for the denial.
- That the 5 second Copilot timeout is enough for the secrets hook on a large outgoing range. A timeout makes the hook exit 0 (no block).
- That a Claude Code
permissionDecision: allowfromguard_rm.pyis honored the same way by Codex, Copilot CLI, and VS Code (Claude Code is the documented contract; the others are unconfirmed). Where it is ignored,rmin$TMPDIRsimply prompts as usual. - That the
Bash|Write|Edit|MultiEdit|NotebookEditmatcher forguard_tmp.pymatches Copilot CLI's own tool names (create,apply_patch). The hook recognizes them in the payload, but the matcher may not select them. - That
$TMPDIRis set in the hook's environment in each harness.guard_rm.pyandguard_tmp.pyread it directly. - That
AGENT_HOOK_HARNESS=copilotin theenv ... python3 ...command prefix ofguard_hook_writes.pyis honored by Copilot CLI and VS Code, and that each harness accepts the barepermissionDecision: askshape it prints for Copilot. - That the
Write|Edit|MultiEditmatcher forguard_report_path.pymatches Copilot CLI's own file tool names, and that a Codex session reports file writes in a payload the hook recognizes (it readsapply_patchenvelopes). - That the Obsidian registry path (
~/Library/Application Support/obsidian/obsidian.jsonor~/.config/obsidian/obsidian.json) matches every Obsidian install, including Flatpak and Snap builds. - That Copilot CLI's
edit/createtool shapes and itsapply_patchenvelope are checked end to end byguard_keywords.pyin a live session (the tests feed recorded-shape payloads), and that theEdit|Write|MultiEdit|NotebookEditmatcher selects Copilot's own tool names. The shared adapter reads neither aNotebookEditpayload'snotebook_pathnor its cell source, so that tool is matched but not actually checked. - That the hook's regular expressions (Python
re) agree with thepre-commithook'sgrep -Eon every pattern. POSIX classes such as[[:alpha:]]are not supported by Pythonre. - That Codex runs the
SessionStartandStophooks fromhooks/hooks.jsonat all, and what environment and payload it gives them. A hook that Codex does not run is simply inactive. - That the Claude Code only self-gate holds under Codex:
guard_settings_keywords.pyandguard_workspace_root.pyassume Codex does not setCLAUDE_PROJECT_DIR. If Codex does set it, both hooks run there;guard_settings_keywords.pythen relies on Codex sendingstop_hook_activeonStop, and a harness that does not could loop on exit 2. - That Claude Code sets
CLAUDE_PROJECT_DIRforSessionStartandStophook commands in every launch mode (terminal, IDE extension, headlessclaude -p), and that it expands${CLAUDE_PLUGIN_ROOT}there. - That
Edit|Write|Read|Bashselects Copilot CLI's own tool names forguard_env_files.py(the hook readsview,edit,createandapply_patchshapes in the payload), and that Copilot CLI and VS Code accept the barepermissionDecision: askthatguard_sensitive_paths.pyprints underAGENT_HOOK_HARNESS=copilot. - That a
SessionStarthook's stdout (guard_workspace_root.py) andadditionalContext(guard_settings_keywords.py) are shown to the model in each Claude Code version.