npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.com path, 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, then wget <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 bare wget "$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, so gh pr mer''ge counts. 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, bash hash -p, and a store into commands, BASH_CMDS, functions, dis_functions or nameddirs (commands[y]=…, commands+=(…), set -A commands …, zstyle -e … 'commands[y]=…'), because the guard classifies by command name and would read y push as 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 be commands[y]; name-slots.ts and hooked-params.ts read these builtins from one table, WRITER_SPECS. Not seen: a command word only known at run time that is hash then ("$(printf ha%s sh)" y=/x), fully dynamic eval text, autoload from an fpath file and zmodload tricks, and a rebinding written into ~/.zshrc for a later session.
  • A builtin with a store option (print, printf, set, read, compgen, mapfile, wait, zparseopts, strftime, zstat, zsh stat, compadd: every WRITER_SPECS builtin 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's zparseopts, zformat, zstyle, zstat and stat skip one leading -- and read options after it, so stat -- "$f" is refused too; write stat ./"$f", since stat $o commands s with o=-LH stores into commands once zsh/stat is loaded. A run-time word in a name slot (read -- "$n", declare "$x", zsh read -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's aliases, galiases or saliases (or bash's BASH_ALIASES) by assignment, typeset -A, set -A, read -A, printf -v or ${aliases[y]::=git}, in the line, in eval and -c text, 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 read y push as an unknown program (TP-2705). Querying one (alias, alias g, alias -L, alias -m 'g*') and unalias pass. A command word the shell may expand into alias is read as alias: a brace expansion ({alias,y=echo}), a glob a file named like alias, a wrapper such as builtin, or a store builtin such as typeset or source would match, in any letter case (ali*, t?peset), a leading = or a tilde without a /. A word with a / is a path and never alias, 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 lone case pattern word is matched, never run, but only between in and esac (zsh's brace form case x { … } is read as commands). A word that varies where alias reads 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 plain NAME=VALUE form of a declaration is spared, as neither shell globs it: typeset a[1]=2 passes. 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 be alias, also after builtin, command, noglob, nocorrect, -, time or coproc, which run builtins: "$C" y=git and "$(printf al%s ias)" y=git are refused, as is "$C" -v 'aliases[y]' git. Accepted false denies, since alias itself takes the same words: "$MAKE" CC=gcc, "$PY" -m pip install x==1.2, "$DOCKER" run -e A=1; "$EDITOR" file and "$cmd" --version pass. Not seen: a run-time word behind an external runner (sudo "$C" y=git, env "$C" y=git; env $(…) A=1 pnpm must pass), a run-time word with only a glob, tilde or = operand, fully dynamic eval text, an alias written into ~/.zshrc for a later session, and hash NAME=path (see the rebinding bullet).
  • The mention rule is broad: any argument naming a secret path counts as a read, so echo ~/.npmrc and a gh pr create --body that names ~/.npmrc deny 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 log

No 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-1

The 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 the ctx port. It throws the shell's ParseError for a Bash command it cannot parse.
  • decide(actions, actor, table?) evaluates each action for each candidate class with authority's evaluate and 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 through realpathSync.native, so on a case-insensitive filesystem ~/.NPMRC resolves to the guarded ~/.npmrc. On darwin and Windows it also sets ctx.foldCase, so a command word reads lower-cased beside its written spelling and GIT push origin HEAD:main classifies as git push origin HEAD:main. On Linux, where GIT is 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:

  1. Create ~/.claude/hooks/authority-guard/, run npm init -y and npm i @titan-design/tool-guard@<version> there. Pinning keeps an upgrade deliberate and puts the guard inside a guarded config path.
  2. Run node ~/.claude/hooks/authority-guard/node_modules/@titan-design/tool-guard/dist/bin.js print-settings and paste its output as a second element of hooks.PreToolUse in ~/.claude/settings.json, after any existing entry.
  3. Check each other profile's settings.json with ls -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.
  4. From your own terminal, pipe a Read event for $HOME/.npmrc into <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. Throws ParseError on unterminated quoting, and on a zsh ~ or e flag (${~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 $HOME are expanded; anything computed stays dynamic. 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 as command, exec and sudo, and past zsh's precommand modifiers noglob, nocorrect and -.
  • parseGit and splitArgs read 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:

  1. Redirect targets are kept.
  2. Heredoc bodies are kept.
  3. $'...' strings are decoded.
  4. 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.