Languette: sharp hooks need strong guards.
A deterministic check against risky actions by coding agents.
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:
ask-first: a command the repo lists as costly, until you approve that one runrequire-well-formed: a Bash command that doesn't parse; the other guards skip itguard-disks:dd,mkfs,wipefs,shredonto disksguard-github-issues: a second GitHub issue create, transfer or delete in one human turn, or any inside a loopguard-host-availability: shutdown, reboot, fork bomb,systemctl stop sshdguard-infra:terraform destroy,kubectl deleteand other infrastructure destroys, until you approve that one runguard-permissions: recursivechmod,chown,chgrporchmod 777outside agent-owned orLANGUETTE_PERM_ALLOWdirectoriesguard-pipe-to-shell:curl u | sh,sh <(curl ..),eval "$(wget ..)"guard-private-terms: a term from your private list, posted to a public repo (off until given the list)guard-recursive-delete: a recursivermorfind -deleteoutside a generated or agent-owned directoryguard-scheduled-jobs:crontab -rguard-secrets: a pasted credentialguard-worktrees: opt-in; a branch switch in a$HOMEthat is a worktree, or a reach into another session's worktreeguard-bypass-hooks:--no-verifyon commit, push, merge, pull, rebase or am, andgit -c core.hooksPath=, until you approve that one runguard-bypass-labels: a session applying a label that waives a CI gate, such aschurn-okguard-bypass-ruleset: a push direct to main where pull requests are normally required (agents could potentially bypass using your credentials)guard-cross-session-send: prevents sending messages to another session with the option to ask first or deny outrightguard-git-stacked-base: deleting a remote branch an open PR is based on (GitHub silently closes the PR)guard-git-work-loss:add -A,commit -a,stash pop, force-push,reset --hardand other moves that throw work awayguard-protected-services:systemctl stop postgresql, for the services you listprose-budget-commit: agit commitwhose staged prose runs over the repo's word budgetsguard-databases(planned)guard-protected-paths(planned)
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 likedist: a directory with that name, anywhere below the top level of$HOMEor/. - 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
rmorfind -deleteis 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)
| File | Size | Uploaded | |
|---|---|---|---|
| languette-0.0.2.tar.gz | 174.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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