Skip to content

About

Safety hooks for AI coding agents: block destructive commands, guard secrets, audit every tool call

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

agentguard

Tests License Bash Platform

Guardrails and an audit trail for AI coding agents.

AgentGuard is a set of hook scripts that sit between your coding agent and your machine. One implementation covers Claude Code, Codex, Gemini CLI, Grok, Muse, and OpenCode, so every agent you run gets the same policy.

  • Before a tool call runs, it blocks the classic footguns: rm -rf ~, git push --force, git reset --hard, sudo, chmod 777, edits to .env files or SSH keys, and interactive commands that would hang the agent. Each block tells the agent why, and usually what to do instead.
  • After a tool call, it warns when command output looks like a leaked credential, and it formats and lints edited files through sley, so the agent sees findings while it is still working on the file.
  • Throughout, it records every prompt, tool call, and guard decision in a local, owner-only audit log that you can replay with agentguard-telemetry show.

AgentGuard never edits your agent's configuration. It ships hook fragments that you merge yourself; the quickstart shows how.

This is a personal project, tuned to its author's workflow. The default policy is opinionated, so read it before you install.

Default Policy

Action What triggers it
Block rm -rf on /, ~, $HOME, its parent, ., or ..; sudo and doas; git reset --hard; git clean -f; git push --force (--force-with-lease stays allowed), --mirror, and --prune; chmod 777; kill of PID 1, 0, or -1; interactive commands such as editors, man, top-style monitors, debuggers, and watch; edits to .env, SSH keys, *.pem, .netrc, and credential files
Block (opinionated) grep -r (use rg), tree-walking find (use fd), and raw git absorb (use git absorb-and-rebase)
Warn other rm -rf, kill -9, killall, pkill, world-writable chmod, a foreground sleep of 60 seconds or more (suggests wait-until, which ships with AgentGuard), credentials in command output, and repeated edits to one file (blocked after 10)
Remind review the diff before committing; commit-message format and public-repo leak checks on git commit; after a push or gh pr create, the pull request is not done until its checks pass (suggests git pr-watch when it is installed)
After edits format and lint edited files with sley and return findings to the agent (AGENTGUARD_POST_EDIT_LINT=0 turns lint off)
Record every hook event in the audit log (AGENTGUARD_TELEMETRY=0 turns it off)

Base Hook Policy has the full rules. Your own policy goes in hook extensions beside the installed hooks; see examples/.

Install

Requirements:

  • Bash 4 or newer at a standard path (/bin/bash, /usr/bin/bash, /usr/local/bin/bash, /opt/homebrew/bin/bash, or Termux's). Without one, agent-hook-pre-bash blocks every shell command; macOS's /bin/bash 3.2 is too old, so install Homebrew Bash there. See Dependencies for why the path is fixed.
  • jq.
  • For post-edit formatting, which is always on, and lint, which is on by default: sley and checkrun, plus checkrun's own requirements, yq and Python 3.11 or newer. Without sley, every edit returns a blocking sley command missing error. Without yq, edit-time formatting and lint are skipped; the commit gate's lint check still reports the missing yq.

Install AgentGuard and its post-edit chain, then install jq and yq with your package manager:

for repo in agentguard sley checkrun; do
  curl -fsSL "https://raw.githubusercontent.com/cgraf78/$repo/main/install.sh" | bash
done

Quickstart: Claude Code

The installer puts the hooks on disk but wires them into no agent. This merges AgentGuard's Claude Code fragment into ~/.claude/settings.json, keeping your existing settings and hooks and backing up the original file the first time. AG is the curl installer's checkout; point it at your own checkout if you manage one:

AG=${XDG_DATA_HOME:-$HOME/.local/share}/cgraf78/checkouts/agentguard
INT=$AG/share/agentguard/integrations
mkdir -p ~/.claude
[ -f ~/.claude/settings.json ] || echo '{}' > ~/.claude/settings.json
[ -e ~/.claude/settings.json.bak ] || cp ~/.claude/settings.json ~/.claude/settings.json.bak
jq -n --arg agent claude \
  --slurpfile d ~/.claude/settings.json \
  --slurpfile s "$INT/claude/hooks.json" \
  -f "$INT/_shared/reconcile-hooks.jq" > ~/.claude/settings.json.new &&
  mv ~/.claude/settings.json.new ~/.claude/settings.json

Rerun the same commands after each update; reconciliation replaces the previous AgentGuard hooks and leaves yours alone. The fragment calls the hooks by bare name, so ~/.local/bin (or your BIN_DIR) must be on the PATH that Claude Code starts with. Start a new session; when the agent tries git push --force, the hook refuses and tells it why:

BLOCKED: git push --force rewrites remote history without a lease. Use --force-with-lease for reviewed restacks.

Other runtimes have their own fragments under share/agentguard/integrations/<runtime>/, each with a README on how to consume it; see Native Agent Integrations.

Installer details

The curl installer keeps a durable managed checkout under $XDG_DATA_HOME when that path is absolute, or under $HOME/.local/share otherwise, and publishes links to its commands and manual pages. Rerunning the curl command safely updates the clean managed checkout before republishing the links.

To choose and manage the checkout yourself instead, keep it at a stable path and run:

./install.sh

The installer creates checkout-backed symlinks for every public command under $HOME/.local/bin and for each matching manual page under $HOME/.local/share/man/man1. Set PREFIX to relocate both trees, or set BIN_DIR and MAN_DIR independently. Re-running the installer is safe and retargets existing symlinks, but it refuses to replace a non-symlink path. Moving or deleting the checkout breaks the installed links.

The direct command links preserve AgentGuard's secure-launcher boundary and allow consumer hook extensions to remain adjacent to the invoked links. The installer deliberately leaves lib/agentguard/ and share/agentguard/ in the checkout and creates no completion tree. Use their absolute paths in this checkout, or resolve them through shdeps if you use it.

Public API

  • bin/agent-hook-* files are the PATH-visible hook entry points.
  • bin/agentguard-classify-command emits JSON command facts for non-hook policy audits that need AgentGuard's conservative shell command-word model.
  • bin/agent-hook-telemetry records each hook payload in the per-session audit trail, and bin/agentguard-telemetry lists, shows, and prunes those records. See Audit Telemetry.
  • bin/claude-session-name names Claude transcript sessions for agent-hook-session-end-claude and for manual transcript backfills.
  • bin/wait-until waits for one concrete condition with a bounded deadline, the replacement agent-hook-pre-bash suggests for long foreground sleeps. See Script Notes.
  • lib/agentguard/agentguard.sh is the sourceable detection API for non-hook callers. It exposes agentguard_is_session, agentguard_agent_name, and agentguard_session_id so launchers share one runtime-identity contract.
  • lib/agentguard/hook-helpers.sh is the hook-runtime API used by hook entry points and hook extensions. Sourced extensions can read AGENTGUARD_CMD_TRIMMED, AGENTGUARD_CMD_LINE1, AGENTGUARD_EDIT_FILES, and AGENTGUARD_EDIT_FILE after the matching parser helper runs.
  • lib/agentguard/watch.sh and lib/agentguard/watch-github.sh track watch obligations: work an agent started (pull-request checks, for one) that a watcher such as git pr-watch --wait should follow to the end. Hook extensions record and re-arm wrapped commands through their documented helpers; docs/watch-protocol.md describes the state layout, the rules, and the gaps.
  • share/agentguard/shell.sh is a stable no-op shell loader for integration harnesses that source each dependency's shell API uniformly.
  • share/agentguard/integrations/ contains canonical native hook fragments for Claude Code, Codex, Gemini CLI, Grok, and Muse, plus the OpenCode runtime adapter.
  • agent-hook-pre-edit warns after AGENTGUARD_EDIT_CHURN_WARN edits to a file and blocks after AGENTGUARD_EDIT_CHURN_BLOCK edits. Defaults are 5 and 10. Run agentguard-churn-bypass on for a deliberate mid-session edit pass (writes a session-scoped marker the agent itself can manage); AGENTGUARD_EDIT_CHURN_BYPASS=1 in the hook/launcher environment bypasses the same guard at launch time. An export in an agent tool shell cannot reach hook processes, so it never bypasses the guard.
  • Guard hooks (agent-hook-pre-*) fail closed. Hosts block a tool call only on exit status 2 (OpenCode's adapter on any nonzero status) and treat every other status as a non-blocking hook error that runs the tool unguarded, so an unexpected exit status, a Bash runtime error raised inside AgentGuard's own files (even one a command substitution swallowed), or a missing library becomes a block with an AgentGuard internal error reason. Advisory hooks (post-tool, lifecycle, prompt, Stop, notification) keep their status: they cannot undo a completed call, and blocking a prompt or a Stop would trap the session.
  • agent-hook-pre-bash bounds its own work, because a hook that outlives the host timeout is also a non-blocking error. Commands over 256 KiB, with more than 2048 commands, or with one command of more than 2048 words are blocked, as is any command whose classification does not finish within AGENTGUARD_CLASSIFY_TIMEOUT seconds (default 30, maximum 3600). Like the other AGENTGUARD_* settings, the timeout is read from the hook environment, which an agent's tool shell cannot change.
  • agent-hook-pre-bash can guard a broad bare-Git work tree when an integration sets AGENTGUARD_PROTECTED_BARE_GIT_DIR. Optional companion settings are AGENTGUARD_PROTECTED_BARE_GIT_WORK_TREE (defaults to $HOME), AGENTGUARD_PROTECTED_BARE_GIT_ALIASES (space-separated shell variable names that should resolve to the protected Git dir), AGENTGUARD_PROTECTED_BARE_GIT_LAUNCHER (the PATH-visible git wrapper to model), and the AGENTGUARD_PROTECTED_BARE_GIT_{STATUS,LS_FILES,CLEAN,ADD}_MESSAGE remediation strings. The guard covers unscoped status, ls-files, clean, and whole-tree git add (-A, ., :/, globs); git add accepts explicit paths without a -- separator, and git add -u is allowed because it restages tracked files only.

Source non-binary assets by their path in the checkout. If you manage AgentGuard with shdeps, resolve them through it so install locations stay under its contract:

. "$(shdeps dep-file cgraf78/agentguard lib/agentguard/agentguard.sh)"

agentguard_session_id [fallback_namespace] prints a caller-supplied AGENTGUARD_SESSION_ID first, then a native runtime id when one is available. For detected runtimes without a native id it returns a namespaced parent-process fallback, which is stable for the direct-call lifetime. The optional namespace labels only a generic fallback; native ids and runtime-specific fallbacks still win. With neither a detected runtime nor a caller namespace, an ordinary human shell returns status 1 and prints nothing. Hook entry points additionally have JSON payloads and therefore use their JSON-aware session-state layer in hook-helpers.sh.

Audit Telemetry

Every hook invocation is recorded, so you can audit exactly what an agent session did: each prompt, every tool call with its full input and output, every guard decision, and session lifecycle events. Records are owner-only JSON files, one per hook invocation, under:

${XDG_STATE_HOME:-~/.local/state}/agentguard/telemetry/sessions/<agent>/<session-key>/
agentguard-telemetry sessions       # recent sessions across all agents
agentguard-telemetry show           # timeline of the latest session
agentguard-telemetry show current   # the session running this command
agentguard-telemetry show <key> --json | jq ...

agent-hook-telemetry is the passive recorder that the native integrations register for every event and tool; the other hooks add decision records. Set AGENTGUARD_TELEMETRY=0 to disable recording. Location, schema, retention, runtime coverage, and audit recipes are in docs/telemetry.md.

Native Agent Integrations

AgentGuard publishes native, policy-free integration assets:

Runtime Asset
Shared lifecycle share/agentguard/integrations/_shared/reconcile-hooks.jq
Claude Code share/agentguard/integrations/claude/hooks.json
Codex share/agentguard/integrations/codex/hooks.toml
Gemini CLI share/agentguard/integrations/gemini/hooks.json
Grok share/agentguard/integrations/grok/hooks.json
Muse share/agentguard/integrations/muse/hooks.json
OpenCode share/agentguard/integrations/opencode/agentguard.js

The declarative fragments contain only AgentGuard hook activation: native event names and matchers, timeouts, runtime identity, session identity, and hook commands. They intentionally exclude permissions, models, MCP servers, UI settings, and machine policy. Consumers choose whether to enable an integration and remain responsible for merging or installing the asset without overwriting unmanaged configuration. AgentGuard never mutates an agent's configuration. Declarative consumers should apply the shared reconciler so the current provider generation can replace changed commands and retire events while preserving user hooks and runtime-owned state.

The OpenCode adapter translates OpenCode's plugin callbacks into AgentGuard's shared hook schema. AGENTGUARD_OPENCODE_NAME can override the default opencode identity for a compatible runtime, and AGENTGUARD_OPENCODE_TIMEOUT_SCALE scales hook timeouts for tests. The adapter sets AGENTGUARD_STOP_BLOCK_ENDS_TURN=1 for the Stop hook because OpenCode does not continue a turn when that hook exits 2. Hook executables are invoked directly with shell startup controls removed so their documented launcher boundary remains intact.

Examples

examples/ contains a neutral environment-policy hook extension and complete reconciliation fixtures with expected output. Repo tests load the extension through the real pre-Bash hook, execute the provider-owned jq filter, compare its output, and prove a second reconciliation is unchanged.

Dependencies

  • Bash 4 or newer for hook scripts that use the command classifier. On every platform, agent-hook-pre-bash and agentguard-classify-command validate and re-exec /opt/homebrew/bin/bash, /usr/local/bin/bash, /data/data/com.termux/files/usr/bin/bash (Termux), /bin/bash, or /usr/bin/bash, in that order, from a fixed privileged /bin/bash -p bootstrap. HOME and PATH never select the interpreter. Direct executable invocation through the privileged shebang is the supported security boundary, but it also requires a trusted or sanitized dynamic-loader environment. On Linux, this includes LD_PRELOAD, LD_AUDIT, and LD_LIBRARY_PATH; platform equivalents such as macOS DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, and related DYLD_* controls must likewise be removed or trusted before process startup. The -p option changes Bash behavior only after the dynamic loader starts it; -p cannot protect before Bash loads. Explicit interpreter invocation such as bash script-path is outside this security boundary: Bash can run caller-controlled BASH_ENV code that changes or forges behavior before the script's first instruction, and no shell script can undo or reliably detect those earlier effects. Rejection of an otherwise ordinary, detectable explicit invocation is best-effort only, not a security guarantee. Before candidate discovery, the fixed platform awk (/usr/bin/awk, or Termux's /data/data/com.termux/files/usr/bin/awk) must return an exact clean or dirty environment sentinel. Raw exported-function entries trigger a clean fixed-Bash environment rebuild: valid ordinary exported names and values travel as NUL-delimited records over the clean process's initial stdin, never as command-line operands, while BASH_ENV, ENV, recursion, POSIX and compatibility controls, shell-internal state, legacy re-entry markers, and every BASH_FUNC_*%% entry are excluded. BASH_XTRACEFD is made non-exported instead of unset so Bash cannot close its caller-owned target. Collision-free private descriptors below Bash's reserved fd 255 preserve caller stdin and stdout, including a closed stdin, while pre-source stdout carries the handshake; those descriptors close before application code runs. Exhausting that portable descriptor range fails closed. Each candidate proves Bash 4 semantics and sources the launcher in that same process with the original stdin, stdout, argv, and exit status. Both directly executed entry points exit with status 2 rather than continuing unguarded when no compatible Bash is available or a fixed inspection or scrub tool fails.
  • jq for hook payload parsing and JSON responses.
  • OpenCode supplies the Node.js runtime for its adapter. On POSIX systems, the adapter uses Python 3 and ps when available to sweep an entire timed-out process session. Python runs in isolated mode from a neutral directory; direct process-group cleanup remains the fallback.
  • cgraf78/sley, which runs cgraf78/checkrun, is a hard runtime dependency for hooks that format files. agent-hook-post-edit invokes the PATH-visible sley hook format --stdin batch CLI (falling back to sley hook format-file per file on older sley installs without the batch entry), then sley hook lint --stdin on every edited file, the same checks the commit gate runs, and returns findings as agent context (AGENTGUARD_POST_EDIT_LINT=0 turns that lint off). Commit readiness belongs in native VCS hooks so human and agent workflows share one path; edit-time lint only reports earlier, and also covers files that no commit checks.

Optional integrations are detected at runtime: hm enables Hive Memory hook context when its config is available through HIVE_MEMORY_CONFIG, an absolute XDG_CONFIG_HOME, or ~/.config; sl, git, and jj enable repository status context; term-notify-sound plays the agent-hook-stop and agent-hook-notification terminal notifications; term-pane-mark marks the agent's tmux pane busy from a submitted prompt or the first tool of a notification-started turn. Background child work keeps the pane busy when the host exposes a reliable parent task snapshot or child turn identity. The pane terminal's foreground agent owns the mark, so launched agents cannot clear it independently (both commands come from tmux-tools); and claude-templates enables a Claude-specific maintenance hook when that command is installed.

When the host supplies session and turn IDs, busy marks carry a length-prefixed session/turn token so a delayed Stop cannot clear newer work. The foreground-terminal ownership check remains mandatory. Codex's shared app-server executes hooks in a detached backend, which cannot identify an attached TUI from process ancestry; such hooks deliberately mark no pane. Interactive codex --no-daemon keeps the hook parent in the foreground terminal and supports this integration without guessing a session-to-terminal association. Grok team completion relies on its next parent Stop reporting the current background task list; the child Stop payload has no stable task ID to match against that list. Claude marks each subagent and teammate turn from its SubagentStart to its SubagentStop. Claude's task snapshots list an idle teammate as running until it shuts down, so they only retire children whose Stop never arrived, and Claude's idle prompt, sent once the parent and every child agent are idle, clears any that remain. Claude runs no hook when Esc interrupts the parent, so that turn's mark stays until a later turn ends. The same state records each child's latest start, so hook extensions can ask whether one child is still working through _hook_child_working (see Shared Helpers).

The launcher trust boundary includes the fixed platform Bash, awk, and env paths and the absolute Bash candidate files above. Termux uses its corresponding fixed paths below /data/data/com.termux/files/usr/bin; other platforms use /bin/bash -p, /usr/bin/awk, and /usr/bin/env. The selected fixed Bash interpreter is the bootstrap trust anchor and must be a working Bash. Candidate entry programs reject accidental non-Bash executables and avoid a validate-then-reopen race, but they cannot authenticate a deliberately malicious file already installed at a trusted path. The HOME and PATH variables cannot add candidate paths. After bootstrap, PATH-visible dependencies such as jq and optional integration CLIs remain caller-trusted.

Tests

Run test/agentguard-test to execute repo-owned hook suites. Behavioral coverage for hook entry points lives here, including the protected bare-Git guard that the command classifier exercises. Downstream consumers should keep only install and wiring smoke tests. Consumers that need command detection should call agentguard-classify-command rather than sourcing private _hook_* classifier helpers. Independent suites use four workers by default; set AGENTGUARD_TEST_JOBS to a positive integer to tune that bound for a specific host. Hook latency budgets always run alone.

Lifecycle

Base hooks follow the same shape:

set -u
_HOOK_SELF="${AGENTGUARD_HOOK_SELF:-$0}"
_HOOK_BIN_DIR=$(_agentguard_script_dir "${BASH_SOURCE[0]}")
source "$_HOOK_BIN_DIR/../lib/agentguard/hook-helpers.sh"
# parse input and run base logic
_hook_source_extensions
_hook_finish

The secure pre-Bash bootstrap is generated into its two entry points from support/secure-launcher.sh.template. _agentguard_script_dir (and its _agentguard_script_parent helper) is generated into every launcher from support/script-resolver.sh.template. The resolver cannot be loaded from lib/ or a PATH-visible command because it is what locates lib/, and launchers sometimes invoke hooks with a minimal PATH (the reason agent-hook-pre-bash re-execs a modern Bash). Run support/sync-hook-bootstrap after editing the templates; the test entrypoint runs --check against the authoritative launcher manifest to prevent missing or stale copies.

The split between _HOOK_SELF and _HOOK_BIN_DIR is intentional: _HOOK_SELF keeps extension discovery adjacent to the invoked symlink in ~/.local/bin, while _HOOK_BIN_DIR resolves through that symlink to load dependency libraries.

Extension scripts are sourced, not executed:

  • -<agent> files (for example -claude, -codex, -gemini, -grok, -muse, or -opencode) are selected by appending the detected agent name to the hook's filename, so any runtime identity can have its own extension.
  • -work files are a second, site-local extension slot, for example for an employer's policy.

Each hook emits one JSON response through _hook_finish.

Shared Helpers

hook-helpers.sh provides:

  • accumulators: _hook_block, _hook_warn, _hook_remind, _hook_context (_hook_warn and _hook_remind also add model-visible hook context; use _hook_context directly for context that should not appear as a warning or reminder in stderr)
  • parsers: _hook_parse_command, _hook_parse_edit_files, _hook_parse_mcp
  • tool payload adapters: _hook_tool_stdout, _hook_tool_stderr, _hook_tool_response_is_string (Claude-style objects, Codex's combined output string, and Muse's JSON envelope string, unwrapped only when the detected runtime is Muse)
  • Hive Memory adapters: _hook_hm_session_start, _hook_hm_prompt_submit, _hook_hm_tool_complete, _hook_hm_stop
  • once markers: _hook_prompt_cycle_reset, _hook_once_per_prompt, _hook_once_per_session
  • state helpers: _hook_edit_churn_file, _hook_counter_read, _hook_counter_increment, _hook_counter_reset, _hook_now (current epoch), _hook_write_atomic
  • child lifecycle: _hook_child_working <agent-id> succeeds while that child has a turn in progress in this session (a Claude child from its SubagentStart to its SubagentStop, as busy marks track it); missing, unreadable, or older team state, and a last start six hours old or more, read as not working
  • target directory setup: _hook_cd_to_target
  • extension loading: _hook_source_agent, _hook_source_work
  • final JSON emission: _hook_finish

The per-session state directory is computed once at source time and refreshed after hook JSON is read. A neutral AGENTGUARD_SESSION_ID wins when a launcher supplies one. Managed Codex hooks prefer JSON session_id after stdin is available, because nested Codex launches can inherit an outer CODEX_THREAD_ID. Without JSON, Codex uses CODEX_THREAD_ID or its parent process key; Grok uses GROK_SESSION_ID, then its nearest grok ancestor key, then grok-$PPID; Muse uses MUSE_SESSION_ID; Claude uses CLAUDE_CODE_CURRENT_SESSION_ID or CLAUDE_CODE_SESSION_ID; and Gemini uses its nearest gemini ancestor key or gemini-$PPID. Any remaining hook uses the pid of its nearest muse ancestor when that is the closest agent process (covering id-less Muse), and otherwise falls back to $$ (for example id-less Claude or OpenCode).

The state root itself is per-user, never a shared, predictable /tmp directory: an absolute $XDG_RUNTIME_DIR/agentguard/hook-state when available (a per-user directory the system clears on logout), falling back to an absolute $XDG_STATE_HOME, then ~/.local/state, and finally a uid-scoped tmp path only when neither an absolute XDG root nor HOME is available. Relative XDG roots are ignored as required by the XDG base-directory contract.

A tier is used only when the hook can actually write it. A sandboxed agent can deny writes to a tier that exists and passes test -w (Grok's Landlock workspace profile denies $XDG_RUNTIME_DIR while hooks still see it set), so each hook probes with a real zero-byte append and takes the first tier that accepts it, in the fixed order above. The sandbox is fixed for a session's lifetime, so all of a session's hooks agree on the tier, and an unsandboxed session keeps the runtime tier as before. The $XDG_STATE_HOME and ~/.local/state tiers survive logout, so SessionStart prunes their session directories with nothing written for AGENTGUARD_STATE_PRUNE_DAYS (default 30; 0 disables pruning). SessionEnd removes nothing, because resumed sessions reuse their key and expect their markers. The tmp fallback is never pruned.

Launchers should set AGENTGUARD_NAME with AGENTGUARD_SESSION_ID when they know the concrete agent. AGENTGUARD_SESSION_ID alone falls back to the generic agent identity.

Adding an Agent

To add a new managed agent runtime:

  • add a canonical, policy-free native fragment or runtime adapter under share/agentguard/integrations/
  • make every managed hook command set AGENTGUARD_NAME=<agent> and AGENTGUARD_SESSION_ID=<stable-id-or-empty>
  • map the agent's hook payload schema into the shared agent-hook-* scripts instead of adding policy directly to the agent config
  • add -<agent> extension scripts only for behavior that is truly runtime-specific
  • set AGENTGUARD_STOP_BLOCK_ENDS_TURN=1 for agent-hook-stop when the agent logs a Stop hook's exit 2 and ends the turn anyway
  • update detect.sh only when the runtime exposes reliable process or environment signals
  • add tests that verify native event mapping, hook environment injection, session identity, payload parsing, and referenced hook executables
  • if the agent supports skills or extensions, update the gstack registration helper or document why it is unsupported

Base Hook Policy

  • agent-hook-pre-bash blocks destructive rm -rf targets such as /, ~, $HOME, $HOME's parent, ., .., and their /* globs, and warns on other rm -rf usage. It also blocks grep -r/-R/--recursive (use rg), tree-walking find (use fd), raw git absorb (use git absorb-and-rebase), sudo/doas, git reset --hard, git clean -f, git push --force/-f/+refspec (--force-with-lease stays allowed), git push --mirror, git push --prune, interactive TTY commands (editors, man/info, top-style monitors, debuggers, watch; vim/vi/view/nvim are exempt with a batch flag such as -es or --headless; nvim also supports the explicit --server VALUE --remote-expr VALUE client form, and gdb supports -batch/--batch and -batch-silent/--batch-silent. Values and operands that merely spell these flags do not select those modes. man is exempt with an explicit cat pager such as man -P cat, man --pager=cat, or MANPAGER=cat man). A literal command-prefix PAGER=cat also works when MANPAGER does not override it; other pagers, nonempty MANOPT, and unsupported modes stay blocked. It also blocks kill targeting PID 1, 0, or -1, chmod 777, Git or Sapling commit-authoring commands inside command substitutions, and the protected bare-Git scans described above. It warns on kill -9/SIGKILL, killall, pkill, and world-writable chmod modes. It reminds agents to run a review/simplify pass and inspect the final diff before commit-class commands (metadata-only changes skip that reminder), and adds commit-message format and public-repo leak reminders for git commit. It warns, never blocks, on a foreground sleep totalling 60 seconds or more (GNU operands such as 300, 5m, 1h, or 1.5m, summed), pointing at wait-until or the runtime's background execution: a fixed sleep holds the whole agent turn, and polling the agent's own background-task output is pointless when the host notifies on completion. A sleep backgrounded with & (alone or in a backgrounded list or pipeline), a tool call the host runs in the background (Claude Code run_in_background, Gemini CLI is_background), and sleep text inside quotes, heredocs, or echo stay silent. The check is pure Bash string work on the fast path; the background flag costs one jq call, and only after a long sleep has matched. Background detection is a deliberate heuristic with known misses in both directions (for example, timeout 30 sleep 300 still warns), acceptable because it only gates advice. wait-until --cmd payloads get the same guards as bash -c payloads, since wait-until runs them with bash -c. It warns when git pr-watch --wait is launched detached (&, nohup, setsid, disown), and on Claude Code when it runs in the foreground without run_in_background, because either way nobody receives the watcher's result; AGENTGUARD_WATCH_BLOCK=1 blocks instead (see watch obligations).

  • agent-hook-pre-search dispatches file-search tool calls to agent-specific extensions and enforces nothing itself. agent-hook-pre-search-muse blocks Muse file searches that do not follow symlinks; uninspectable payloads fail open.

  • agent-hook-post-bash scans command stdout for high-confidence credential patterns. After a successful git push or gh pr create, ready, reopen, or update-branch, or output showing a push summary or a new pull-request URL (pushes run through a script or terminal multiplexer), it reminds once per prompt that the pull request is not done until its checks pass on the pushed commit. git pr-open, git pr-submit, git pr-restack, and git pr-sync-stack count as pushes too, and so do pull-request URLs from any GitHub host. When git pr-watch is installed, creating a pull request records it as a watch obligation for the session, a later push or gh pr ready|reopen|update-branch re-arms it, a successful gh pr close or gh pr merge (with a merge method, without --auto) naming it forgets it (a reopen revives it; see the watch protocol's gaps), and the reminder recommends git pr-watch --wait <pull requests>; with nothing recorded, the general reminder recommends git pr-watch --wait <pr> too, falling back to gh pr checks --watch only without git pr-watch (AGENTGUARD_PR_WATCH_HINT replaces that advice everywhere; AGENTGUARD_WATCH=0 turns obligations off). Hook extensions that unwrap commands run the same pass through _hook_gh_watch_pass. See docs/watch-protocol.md. Output extraction is centralized so agent-specific payload names do not leak into the base hook: it reads Claude-style output objects, Codex's combined output string, and Muse's JSON output envelope (unwrapped only when the detected runtime is Muse, whose exit_code, or without one a failed or timed_out terminal_status, also decides whether the command failed).

  • agent-hook-pre-edit parses edited paths, blocks edits to secret and credential files (such as .env, SSH keys, *.pem, credentials, and .netrc), reminds once per user prompt on code/config edits to apply AGENTS.md design/workflow/code-style guidance plus any loaded language-specific rule fragments, warns or blocks repeated edits to the same file unless bypassed (agentguard-churn-bypass on, or AGENTGUARD_EDIT_CHURN_BYPASS in the hook environment), and leaves room for environment-specific generated-file or readonly-file guards.

  • agent-hook-post-edit formats changed files through sley hook format --stdin, one batch per directory so repo detection matches the old per-file path. It then lints every edited file with sley hook lint --stdin, the same checks the commit gate runs, and returns findings as agent context, so a lint or schema error surfaces at edit time, including in generated or untracked files no commit checks. Lint covers whole files, so findings can predate the edit. Commit readiness and verification stay with the native commit hooks. Edit-time lint is on by default. It adds a lint run to every edit (about 0.2 s per batch, more for large shell scripts, where shellcheck dominates); set AGENTGUARD_POST_EDIT_LINT=0 in the hook/launcher environment to turn it off for all file types. Formatting still runs, and the commit gate still lints. Like the churn knobs, an export in an agent tool shell cannot reach hook processes.

  • agent-hook-pre-mcp guards MCP calls: it blocks a server after three consecutive failures, warns on exact search_files leaf-tool calls without a path filter, and warns once on exact knowledge_load leaf-tool calls because large docs can consume significant context. The circuit breaker is half-open: 60 seconds after the last failure or probe, the next call goes through as a probe. The counter is per server and shared by every agent in the session. The block message says when that retry is allowed and quotes the last failing tool and the start of its error, since the blocked agent may never have seen it and repeated failures are often bad arguments rather than a down server. The expensive-call warnings are skipped for a blocked call.

  • agent-hook-post-mcp tracks MCP failure streaks for that circuit breaker: a success resets it, a failure (including a failed probe) counts, records the tool and error, and restarts the cooldown.

  • agent-hook-session-start detects repo type in sl, git, then jj order, reports uncommitted changes, warns on high disk usage, and injects Hive Memory startup context when hm is installed and configured.

  • agent-hook-session-end parses session metadata for agent-specific naming or sync extensions.

  • agent-hook-compact handles PreCompact: it tells Hive Memory the host is about to drop earlier context (see delivery tracking below).

  • agent-hook-prompt-submit lets hm hook prompt-submit handle memory-intent reminders and context refresh decisions, then resets prompt-cycle state used by once-per-prompt guidance. The hook passes prompt text and path facts; it does not decide what should be written.

  • agent-hook-stop asks Hive Memory for any pending-memory reminder, runs its extensions, checks the session's watch obligations (once per prompt, a stderr reminder naming recorded pull requests with no live watcher and no green, merged, or closed result from a check after their last push or submit; with AGENTGUARD_WATCH_BLOCK=1 on Claude Code, one block per push, submit, or watcher exit instead, a stall exit never re-arming it; a session that recorded nothing pays only a directory test), and plays a terminal notification for the first Stop after a valid prompt that ends the foreground turn. A Stop an extension blocks to keep the agent working stays silent, so the Stop that later ends the turn rings although the host flags it as a continuation; other re-entrant and repeated callbacks stay silent until another valid prompt re-arms the shared AgentGuard state. A block ends the turn anyway on Codex and on hosts that set AGENTGUARD_STOP_BLOCK_ENDS_TURN=1 (the OpenCode adapter does), so that Stop rings, and so does a Stop that dies first (a crashed extension or a host timeout's HUP or TERM) without a block. A SIGKILL timeout (OpenCode's) cannot ring. Codex Stop continuations are currently suppressed because affected Codex releases persist their synthetic messages with replay-invalid item IDs; prompt-submit still surfaces memory reminders before Stop. Grok Stop always emits empty JSON because Grok treats Stop context as keep-working feedback; an extension block still exits with status 2. When the host supplies a session and turn identity, completion notifications admit only the current foreground turn. Child/background prompts cannot rearm the parent's notification, and delayed Stops cannot consume a newer turn's bell. Prompt rearming and Stop publication share a bounded lock; a successful rearm finishes after the previous publisher. A timed-out or failed rearm suppresses subsequent claims until a later successful prompt; it cannot retract a notification already admitted and in flight. Legacy hosts without turn identities keep their existing prompt-cycle behavior.

    Tagged lifecycle notifications use Python 3's kernel file lock; Python 3 is required on hosts supplying turn identities. Without it those notifications fail silent. Kernel locks release after the final synchronous publisher exits, including forced termination; the private attention-lock file stays in place and must not be removed while the session is active.

    agent-hook-notification plays notifications for host attention events such as permission requests.

Hive Memory integration is deliberately centralized behind hm hook <event>. The shell hooks pass only event facts: agent id, session id, and the best available active path. Store affinity, project resolution, context freshness, and refresh policy live in hm, so agent-specific or local extension files do not need to duplicate memory policy. Clearing a pending-memory reminder needs one more piece that AgentGuard does not ship: a launcher or wrapper that exports HIVE_MEMORY_AGENT_ID and HIVE_MEMORY_SESSION_ID into the agent's tool shell, matching the agent name and session key the hooks pass to hm hook. With them, hm remember and hm note write receipts that clear the reminder after the next successful tool event. Without them, the memory is still written, but the reminder can repeat. When an agent session is launched from $HOME, AgentGuard treats that as "no active project" rather than passing home as a project hint. Explicit file paths under $HOME still pass through, so one long-lived session can work across many projects without collapsing context onto the home directory itself.

For Claude Code, Codex, and Muse, AgentGuard also turns on Hive Memory delivery tracking (hm hook ... --delivery). hm then skips memories the model can still see, for example when a session moves back to a project it already received context for. This is safe only because every way these hosts lose context is reported:

  • agent-hook-compact (PreCompact: Claude Code, Codex, and Muse) and SessionStart with source compact, clear, or resume start a new context epoch, and SessionStart re-injects. Muse compaction keeps session-start context and Muse prompt context never outlives its turn, so Muse keeps its epoch there.
  • A reset that fails leaves a marker so the next prompt resets first.
  • AgentGuard passes each host's context lifetime (Muse drops prompt context every turn) and size limit (Claude shows at most 10,000 characters of hook context; Codex previews context above 2,500 approximate tokens; Muse drops hook output over 16 KB). Codex's default threshold uses UTF-8 bytes divided by four, rounded up. Its complete tracked context is budgeted below 9,500 bytes, leaving 500 bytes of headroom, including any context before Hive Memory. Codex still preserves oversized output in its full saved artifact; the preview alone does not count as full memory delivery.
  • The size budget is exact: the host limit minus context already present, counted in the host's units (JSON-escaped response bytes on Muse), minus the tail recorded for that event. A tail is recorded only when context added after the memory block actually pushed a response over the limit; the last such overflow replaces it, capped at half the limit unless two overflows were consecutive. Compaction, clear, or resume forgets it, and so does a Muse project-change block.
  • When less than 2 KB would be left, or after three overflows (counted until the next compaction, clear, or resume), the hook runs untracked, which behaves exactly as without tracking; a rewritten context whose tail cannot be measured counts as three overflows.
  • hm counts a memory as delivered once it emits it within that limit. When the host will not show it after all, AgentGuard leaves a reset pending so the next prompt re-sends: _hook_finish drops memory context that would exceed the limit and records the overflowing tail. A failed tracked hm call, a response the hook could not write, or a prompt it blocks get the same treatment. A blocked Muse prompt is the exception: Muse prompt context never counts as delivered, so it has nothing to re-send. A pending reset is cleared once a tracked response re-sent memory context under it (even a header-only block when everything is already visible).
  • Accepted gap: when another plugin's hook blocks the prompt, or the host times out the hook after hm returned, those memories wait for the next reset (compaction, clear, resume). Untracked prompt recall has the same gap.

Subagents, nested sessions whose session variables point at a parent, other runtimes, and AGENTGUARD_HIVE_MEMORY_DELIVERY=0 stay untracked. So does an hm that predates the flags; AgentGuard falls back automatically.

Claude-specific extensions auto-name untitled sessions and run the daily claude-templates update refresh in the background.

Script Notes

wait-until polls exactly one condition and returns as soon as it holds:

wait-until [--timeout SECS] [--interval SECS] --file PATH
wait-until [...] --pattern REGEX --in FILE   # FILE may appear later
wait-until [...] --pid PID                   # process has exited
wait-until [...] --cmd 'COMMAND'             # bash -c COMMAND exits 0

--timeout defaults to 90 seconds, under Claude Code's default 120-second Bash tool timeout, and --interval to 2. Longer waits need an explicit --timeout and should run as a background tool call (run_in_background) so the agent's tool timeout cannot kill them first. It exits 0 when the condition holds, 124 on timeout with a one-line diagnostic of the last observed state, and 2 on usage errors. Each --cmd probe runs in its own process group, which is stopped (TERM, then KILL after a short grace period) when the probe finishes or its budget expires, so a hung probe cannot outlive the deadline by more than that; a probe started at the deadline still gets two seconds to finish. --pid decides existence from kill -0 and its error (no such process versus not permitted), so a hidepid /proc or a missing ps cannot hide an exit, and spots zombies through /proc on Linux or ps elsewhere; it never reports an exit it cannot confirm. Older Bash (macOS /bin/bash 3.2) measures the deadline in whole seconds and can end up to a second early.

claude-session-name is a PATH-visible helper used by agent-hook-session-end-claude. It can also name older transcripts in batch:

claude-session-name <transcript_path>

License

MIT. See LICENSE.

About

Safety hooks for AI coding agents: block destructive commands, guard secrets, audit every tool call

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages