Skip to main content

Languette: sharp hooks need strong guards.

A deterministic check against risky actions by coding agents.

Jacques Callot, Drill with halberds (NGV 32320, public domain)

Before a coding agent runs a shell command, a languette guard reads it and answers: allow, ask, or deny.

Nothing external decides: no web services, nothing cloud-based, and no ML or AI models. The critical execution path is deterministic, and uses "pure functions" where possible.

Who is this for?

You! ...assuming you are a real human being. A big AI vendor's safeguards are its product decision, changed on a whim. We don't have to trust them blindly. Languette is a guard you can read, test, and understand for yourself: trust, but verify. Any vendor that wants to adopt it is welcome to, under the terms of the AGPL.

What's with the name?

Languette is French for "little tongue". On a halberd the languette (or 'langet') is the strip of iron that runs down the shaft from the head, so a stray blow can't cut through the pole. These guards are that strip: protecting your work, strengthening your tools, and holding back specific agentic hazards that could ruin your day.

It's also a play on words: Shell is a little language, and languette listens for the few words in a shell command that can do damage. When it hears one, it tells the errant agent "hold your tongue!"

"The wise speak only of what they know, Gríma son of Gálmód. A witless worm have you become. Therefore be silent, and keep your forked tongue behind your teeth. I have not passed through fire and death to bandy crooked words with a serving-man till the lightning falls." — Gandalf, in J.R.R. Tolkien's The Two Towers, Book 3, Chapter 6

Install

The guards run as PreToolUse hooks in Claude Code today, on shell commands, gh and GitHub MCP calls, and messages to other sessions; other agent hosts are planned (#5).

Claude Code

/plugin marketplace add mark-brannan/languette#release
/plugin install languette@languette

It needs python3, standard library only. shfmt (the parser it trusts most) and gh (for the two guards that ask GitHub) are optional.

macOS (planned: #102)

brew install mark-brannan/tap/languette
languette install && languette doctor

Linux and WSL (planned: #102)

sudo apt install python3 pipx shfmt gh
pipx install languette
languette install && languette doctor

Windows without WSL

Languette protects Claude Code inside WSL: run wsl --install, then the Claude Code block above. An agent running natively in PowerShell is not protected.

Devcontainer, Codespace and CI (planned: #102)

One line in devcontainer.json installs languette before the agent starts:

"postCreateCommand": "pipx install languette"

Without the plugin system or the installer, see Installing by hand.

The guards

Each guard checks for one kind of hazard:

The promise

When a guard can't tell what a command it covers will do, it denies and says why. When no rule covers a command, it says nothing.

A variable where a path should be, a brace expansion, a quote it can't resolve: each is a deny that names what the guard saw. The second half is a limit, not a bug. A guard reads shell text; it does not open scripts or read other languages. These are real verdicts, generated from the guards' scenarios for a command run in ~/project, and CI fails if the table drifts:

Command Verdict Why
rm -rf build DENY not an allowlisted generated dir
rm -rf node_modules allow allowlisted
rm -rf "$DIR" DENY unresolvable word; loud
rm -rf dist{,2} DENY brace expansion unresolved; loud
sh -c "rm -rf build" DENY nested text scanned
find build -delete DENY rule covers find -delete
python3 -c "shutil.rmtree('build')" allow no rule; silent (known gap)
./cleanup.sh allow no rule; silent (known gap)

With shfmt 3.6 or later on PATH, the require-well-formed guard denies a command that doesn't parse. Without shfmt, it falls back to bash -n, then to its own lexer. In a replay of 101,671 agent commands, about 1 in 3,000 didn't parse, and each would have broken. Bash runs a broken command in part, the lines before the error or prose in backticks as a command; the deny stops all of it, so the agent looks again. A command over 64 KB, or nested too deeply to parse quickly, is denied unread.

Ask first

A repo lists its expensive commands in .languette/ask-first.json:

{"commands": [{"id": "e2e",
  "match": [{"cmd": "npm", "args": ["run", "e2e"]},
            {"cmd": "node", "script": "scripts/e2e.mjs"}],
  "cost": "> 40 minutes, using all CPU cores on a typical desktop",
  "approve_label": "Run e2e"}]}

When the agent tries a matching command, the guard denies it and tells the agent to ask you first: the exact command, why now, the cost, any cheaper form the entry lists under "cheaper", and a button labelled with approve_label. Click it and the next matching command runs. One click is one run: a command that runs it twice needs two, and one in a loop or xargs is denied whatever you approved. Only your click counts, never what the agent wrote in the question.

Matching sees through wrappers: timeout 3h npm run e2e, sh -c "...", pnpm exec node ./scripts/e2e.mjs and yarn e2e all count, while grep, git commit -m, cat and pkill -f that merely name the script do not. A run hidden inside a script (./ci.sh) or behind a variable ($CMD) is not seen.

The guard reads the nearest .languette/ask-first.json at or above the command's working directory, stopping at the repo root, else the one in $CLAUDE_PROJECT_DIR. With no file, it stays silent. A file that doesn't parse, or that gives two commands the same approve_label, denies every command until it is fixed, because the guard can no longer tell what the repo meant. Spent approvals are kept beside the session transcript, in <transcript>.languette-ask.

Configuration

Every guard is on by default except guard-worktrees, which is opt-in. Turn one off (or that one on) with /plugin configure languette@languette, or at install, by its name with underscores:

claude plugin install languette@languette --config guard_recursive_delete=false

A guard is skipped only when its setting is exactly false. Unset, empty or anything else runs it, so a misconfiguration cannot open the gate. guard_worktrees is the reverse: it runs only when its setting is exactly true, so a misconfiguration leaves it off. Its two controls, guard_worktrees_checkout_home and guard_worktrees_foreign, are each on unless set to false.

One setting per guard-git-work-loss rule

guard_git_work_loss=false skips all five rules. To keep four, turn off one:

Setting Rule it switches off
guard_git_work_loss_blanket_staging add -A, add ., commit -a
guard_git_work_loss_stash stash pop, stash clear, a bare stash drop
guard_git_work_loss_force_push a bare force push; a force push to or delete of main
guard_git_work_loss_discard reset --hard, checkout ., restore ., clean -f
guard_git_work_loss_branch_delete branch -D, branch --delete --force

Allowing more for guard-recursive-delete

Built in: the generated directories node_modules, dist, coverage and .pio, and the agent's own places, /tmp, ~/.local/state/claude-tmpdir and ~/.claude/worktrees. To allow more, set LANGUETTE_RM_ALLOW to a colon-separated list. It adds to the built-in lists and never replaces them; unset or empty changes nothing.

export LANGUETTE_RM_ALLOW=build:.next:/srv/agent-area
  • A bare name (build) is treated like dist: a directory with that name, anywhere below the top level of $HOME or /.
  • An absolute path (/srv/agent-area) is treated like the scratchpad: a place the agent owns.
  • Entries use letters, digits and . _ @ + - only. A path may not contain a . or .. segment, and may not be / or $HOME.
  • If the value doesn't parse (an empty entry, a glob, a space, a relative path with a slash), every Bash call carries a warning, and only a recursive rm or find -delete is denied. A typo can't stop unrelated work, and it can't open the gate either.

Settings for guard-private-terms

Setting Holds Example
private_terms_file a text file of terms, one per line, matched case-insensitively ~/.config/private-terms
private_repos repos whose posts are never scanned, comma-separated you/notes,you/scratch

Without a terms file the guard is off.

bypass_labels lists the labels guard-bypass-labels keeps for humans, comma-separated; empty means churn-ok,mixed-loops-ok.

record_decisions, off by default, keeps every verdict, with its command and secrets masked, in $XDG_STATE_HOME/languette/decisions.jsonl, readable only by you.

How Claude Code passes plugin settings to a hook (measured)

Claude Code hands each key to the hook as CLAUDE_PLUGIN_OPTION_<KEY>. Measured on Claude Code 2.1.258, with a throwaway plugin loaded by --plugin-dir and options supplied through pluginConfigs:

Setting Env in the hook
key flag_off, boolean false CLAUDE_PLUGIN_OPTION_FLAG_OFF=false
key flag_on, boolean true CLAUDE_PLUGIN_OPTION_FLAG_ON=true
key camelKey, boolean false CLAUDE_PLUGIN_OPTION_CAMELKEY=false
multiple string ["a b","c,d"] CLAUDE_PLUGIN_OPTION_LIST_VALS=a b,c,d
multiple string [] CLAUDE_PLUGIN_OPTION_LIST_VALS=
key unset, though the manifest gives a default variable absent

The name is the key upper-cased, underscores kept and camelCase not split. Booleans arrive as the words true and false. A list is joined with a bare comma and no escaping, so an item holding a comma can't be recovered. A string "false" looks the same as a boolean false. A default did not reach the environment under --plugin-dir; the installed-plugin path was not measured, which is why the guards treat unset as on.

Strong guards

A guard is a pure function of the command and the facts it asked for; it asks no model and runs nothing, and the runner fetches the facts (the design). The verdict is deny with the reason, ask, a warning, or nothing (allow). What a guard asks for beyond the command, it declares:

Guard Reads
guard-git-work-loss nothing: a pure function of the command
require-well-formed a shell parser; nothing runs the command
guard-recursive-delete the filesystem, and LANGUETTE_RM_ALLOW
ask-first the repo's list, the session transcript, the approvals spent
guard-bypass-hooks the transcript, and the approvals spent
guard-secrets the repo's .languette/secrets.json, when it has one
guard-infra the transcript, the approvals spent
guard-git-stacked-base GitHub, through gh
guard-bypass-ruleset git, for where a push lands, and GitHub's rules for the default branch, through gh, cached an hour
guard-github-issues the payload's session_id, the transcript, and a door file in $TMPDIR
guard-private-terms the terms file, the files a post reads, and the checkout's git remote
guard-bypass-labels the bypass_labels setting, and a file gh api --input names
guard-cross-session-send the payload's session_id and permission_mode, a record per session in $TMPDIR, and the agent-team config
guard-worktrees $HOME and what git rev-parse --show-toplevel resolves to; git, for where each path lands, and a record per session in $TMPDIR
prose-budget-commit the staged diff and, for a commit that reaches past the index, the named working-tree files, through prose-budget

A guard that cannot decide denies and says what it saw. A guard that crashes is a deny naming the guard; the runner holds that rule, so no guard has to.

The contract is written as "gherkin" scenarios, in the words a person uses to state the rule. They're still completely deterministic tests, just easy for non-technical folks to read.

Scenario: a target the guard cannot resolve is denied on sight
  Given the working directory is "$HOME/project"
  When the agent runs `rm -rf "$DIR"`
  Then the guard denies, naming "variable or command substitution"

Installing by hand

Clone the repo and add one entry per guard in hooks/hooks.json to settings.json, shaped like the one below. Replace ${CLAUDE_PLUGIN_ROOT} with the path to your clone, since it is unset outside the plugin, and keep the wrapper. The wrapper is what makes a missing or crashing guard a deny: without it, a missing script is a non-blocking error to Claude Code, and every command goes through. One entry:

{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command",
  "command": "h=\"$HOME/languette/languette/run.py\"; { [ -f \"$h\" ] && python3 -I \"$h\" --guard guard-recursive-delete; } || printf '%s\\n' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"languette/run.py (guard-recursive-delete) is missing or crashed, or python3 is not on PATH. This is a gate and fails closed.\"}}'"}]}]}}

Working on it

git clone https://github.com/mark-brannan/languette && cd languette
sudo apt install shfmt shellcheck
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
python3 -m pytest                       # every scenario
python3 tests/readme_table.py           # regenerate the table
shellcheck --severity=warning tests/stubs/* tests/*.test.sh   # as CI runs it

Running the tests needs pytest, pytest-bdd and PyHamcrest; the hooks do not.

The same run checks the shape of hooks/hooks.json, because claude plugin validate --strict passes a malformed one. The headless smoke test, which installs the plugin in a scratch project and confirms a recursive rm is really blocked, stays manual: it needs a model call and a login.

features/ holds the contract as scenarios: a command in, tokens or a verdict out. The guards began as shell scripts copied from mark-brannan/dotfiles; they run in Python now, and this repo is where they are maintained.

License

Markdown files are CC BY-SA 4.0; everything else is AGPL-3.0-or-later, except where a file carries its own notice. Copyright 2026 Mark Brannan. Credit "Mark Brannan" and link this repository.

Metadata

Release files for languette 0.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for languette 0.0.2
File Size Uploaded
languette-0.0.2.tar.gz 174.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for languette 0.0.2
File Interpreter ABI Platform
languette-0.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 341.5 kB

Release files / languette-0.0.2.tar.gz

Download URL languette-0.0.2.tar.gz
Size 174.7 kB
Tags Source
SHA-256 checksum
How to use checksums
946c356c6073d262f881fe9ae48c1b855f0509d29d44eb620a971a803d29549a
BLAKE2b-256 checksum
How to use checksums
a95de93bc38dcd1d0e0813bffaf1974e920e656978a0aa485803ced6afce3590
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.

Transparency log

Release files / languette-0.0.2-py3-none-any.whl

Download URL languette-0.0.2-py3-none-any.whl
Size 166.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1165361371134e4b209731d1171f4cdcdb8b55168d7a234ecadc699be0cd62db
BLAKE2b-256 checksum
How to use checksums
18289f37d173c53a8824e0ab7d0e6e26da474a9d4e3a4ce7d136424dad5ee32b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.0.2 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page