Shoddy · Fettler
The three levels, the boundary in full, the seven permissions, every verb, every refusal, every finding, and what this defends against. For whoever has to be sure.
Note Nothing here is needed to use Fettler. The quick start gets it working. This page is the argument behind it, for anybody who has to satisfy themselves the boundary holds.
Fettler does three kinds of work, called levels: tools, guardrails and screenings. The tools level is always on and needs no file. Each of the other two is on when the configuration file has a section of that name. Tools, Guardrails and Screenings each have a page.
| Level | On when | What belongs to it |
|---|---|---|
| tools | always. It needs no file | The verbs find, search, read, write, edit, replace, new, mkdir, move, copy, delete, extract, exec, batch, roots and doctor. The caps on every answer, the hashes, the typed reads, and the generated-folder exclusions. The client's folders (scratch, tool-results, memory) and companion roots. The credential check on writes. The refusal to write a governed file |
| guardrails | the configuration has a guardrails section | Declared trees, can, scopes and hidden folders. Tasks, replacements, run and execute. The client's deny list, the instruction block and the hook that setup guardrails writes. doctor's numbered findings, except 2.11 |
| screenings | the configuration has a screenings section | The disclosure screen: the patterns fettle runs itself (tier one), burler (the separate program that runs the screening models), the models directory, and exit 13 |
Why three. The tools are what no other tool offers: the batch, the hash, the caps and the document readers. Guardrails help on some projects and get in the way on others, so a project turns them on. Screenings are for the few projects that hold regulated data.
.fettler.json holds any of tools, guardrails and screenings, and nothing else at the top. An unknown key at the top is refused, and the message names the three. Here is a file with all three:
{
"tools": {
"generated": ["bin", "obj", ".git", "artifacts", "node_modules"],
"scratch": "path/to/a/folder",
"scratch-root": "path",
"client-root": "path"
},
"guardrails": {
"trees": {
"work": { "path": ".", "can": ["list", "read", "create", "update", "rename", "delete", "execute"],
"scopes": { "secrets": { "can": [] }, "docs": { "can": ["list", "read"] },
"records": { "can": ["list", "read"] }, "public": { "can": ["list", "read"] } } },
"shoddy": { "path": "../shoddy" }
},
"tasks": { "build": { "run": "pwsh -NoProfile -File build.ps1", "cwd": "." } },
"replacements": { "feature-branch": "halifax" }
},
"screenings": {
"models": "../screening-models",
"screen": { "work": ["identifiers"], "work/records": true, "work/public": false }
}
}tools is optional. generated replaces the folder names that find and search skip. scratch names a folder to open as the scratch tree by hand. scratch-root names the folder that holds every project's scratch folders. client-root names the folder that holds the client's per-project folders (by default ~/.claude/projects). Nothing else goes here. The caps, the hashes and the readers cannot be configured.guardrails holds trees, tasks and replacements, with the meanings this page gives them. The tree holding the file gets everything but execute. Every other tree gets list read. A guardrails section with no trees is refused.screenings holds models, the directory of screening models, and screen. Each screen key is a tree name, or a tree name, a slash and a scope path. Each value is true, false or a list of category words. The tree must be declared, or be work when guardrails are off. A scope named in a key must be declared under its tree. In the example, work/records is screened for every category, and work/public takes the screen off inside a screened tree..fettler.local.json, may hold any section. It merges over .fettler.json section by section. Inside a section, an entry named in both files is the overlay's entirely.scratch, tool-results or memory, because fettle opens those folders itself.trees, tasks, replacements or models at the top, or a screen inside a tree, is refused. The message shows the file's own entries moved under the new sections.The CLI verb and the MCP tool name are the same word, and both front ends reach the operations through one dispatcher. That makes "this capability is for a model but not a script" impossible to write, rather than merely forbidden.
Every verb except tasks and run belongs to the tools level and works with no configuration file. tasks and run belong to guardrails. fettle help opens with a here: line naming the levels that are on where it was run. It then lists the verbs grouped by level, tools first, and ends with the exit codes.
| Verb | What it answers |
|---|---|
find | paths matching a glob, with size and modified time |
search | matching lines as records (path, line, column, text) for several patterns at once. A hit inside a document also carries the page it sits on |
read | content, encoding, line ending, trailing-newline state and a hash, for several paths at once, and notebooks, PDFs, images and archives. --tail N for the end of a log; --member NAME for one entry inside an archive; --numbered for the text with line numbers |
write | the file created or replaced |
edit | a batch applied, or the first edit that failed. The script comes inline, from --script FILE, or from a file in a tree named by --script-path (script_path over MCP) |
replace | one substitution everywhere it appears across a glob |
new / mkdir | an empty file; a directory |
move / copy / delete | the operation, and what guarantee it gave |
extract | an archive unpacked into a declared tree, or refused whole, with nothing written |
exec | the executable bit, set or cleared |
roots | the levels that are on, then the open trees: their paths, what may be done in each, which one an unqualified path lands in, and the branch of a declared tree that is a checkout. The trees fettle opened itself are listed last, under found by fettle, each with how it was found |
tasks / run | the declared tasks; one of them, with its streams and exit code. Guardrails only |
batch | several operations sent as one. The script comes the same three ways as edit's |
doctor | one section per level. Whether this tool is wired into each client, and, for each level that is on, what goes round it |
setup | the files for one level, written. The level is the first word. The one verb an assistant is not offered |
serve | nothing. It becomes the MCP front end on stdio |
$ fettle find "src/**/*.cs" --sort mtime --limit 3
src/Fettler/Glob.cs 4812 2026-08-14T09:22:07Z
src/Fettler/PathRoot.cs 6104 2026-08-13T17:40:11Z
src/Fettler/Edits.cs 11233 2026-08-12T08:05:52Z
3 files (77 excluded: bin, obj, .git - pass --include-generated)$ fettle roots
levels
tools on, always
guardrails on, from C:\github\app\.fettler.json
screenings off
trees
work C:\github\app (default)
can: list read create update rename delete execute
branch: main (local)
secrets can: nothing
screens
work identifiers
found by fettle
scratch C:\Users\you\AppData\Local\Temp\claude\c--github-app\1f0c...\scratchpad
can: list read create update rename delete
found: this session's folder, named by the environment variable NAME
tool-results C:\Users\you\.claude\projects\c--github-app\1f0c...\tool-results
can: list read
found: in the client's folder for this project, in this session's folder; ...
memory C:\Users\you\.claude\projects\c--github-app\memory
can: list read create update rename delete
found: in the client's folder for this project
declared by C:\github\app\.fettler.jsonoff. At the tools level, the trees part shows only work, the launch folder.screens heading appears only when something is screened or a models directory is named.--json answer gains a levels object, such as {"tools":{"on":true,"from":[]},"guardrails":{"on":false,"from":[]},...}, and a found field on each tree fettle opened itself.The branch line is local. It is printed for declared trees only, never for a tree fettle found itself. For a tree whose top, or a folder above it, holds .git, roots prints branch: NAME (local), or detached at HASH (local) when HEAD holds a commit. When the .git is above the tree, the next line names that folder as the repository. The line says what this machine has checked out. It does not say what a remote holds, and only a fetch answers that, so do not take it as proof that a branch is current. Only .git/HEAD is read, and the one gitdir: pointer a worktree or a submodule uses. No ref, index, object or configuration file is opened. A .git that cannot be read answers as no .git. The --json answer carries branch, detached and repository, and over MCP the branch is read at every call, so a switch made in a terminal shows on the next one.
A path inside a script is a tree path. In an edit script, replaceFile, withFile and textFile name a file whose text the script uses. That file is read through the boundary with read, relative to the default tree or written as tree:path, on both front ends. A file outside every tree is refused as outside, and the batch does not run. The refusal names the edit and the field. A script named by --script-path follows the same rule. --script FILE on the command line is a caller's own file, read directly, as --text-file is. A large script goes in a file in a tree, so the call that names it is one short line.
setup, by levelThe level is the first word, then the client. The clients are claude-code, claude-desktop, vscode-copilot and github-copilot. --local writes in this project and --global writes for every project.
| Command | What it writes |
|---|---|
fettle setup tools CLIENT [--local or --global] | the registration, and nothing else. It writes no .fettler.json |
fettle setup guardrails CLIENT [--local or --global] [--hooks] [--deny] | the deny list (written at --local, only offered at --global unless --deny is given), the instruction block, the hook with --hooks, and a starter guardrails section, {"trees":{"work":{"path":"."}}}, when the project file has none. It never changes a guardrails section that exists, and it never registers |
fettle setup screenings CLIENT [--local] | a starter screenings section screening the default tree, when the file has none. It also checks that burler is beside fettle, and checks the models directory |
--dry-run, --force, --command PATH and --all keep their meanings. fettle setup CLIENT, the old spelling, is refused with the new spellings shown.
# scripts/list-types.ps1
$out = fettle search "^public sealed" --glob "src/**/*.cs" --json
if ($LASTEXITCODE -ne 0) { throw "search failed: $out" }
$out# scripts/list-types.sh
out=$(fettle search '^public sealed' --glob 'src/**/*.cs' --json) || {
printf '%s\n' "$out" >&2; exit 1;
}
printf '%s\n' "$out"In machine-readable mode the complete result, failures included, is on stdout, the normal output stream. That matters on Windows. PowerShell 5.1 turns a native program's redirected error output into NativeCommandError records, and sets $? false even on exit code 0. Any design that needs 2>&1 to retrieve an answer is broken on the primary development platform.
On a machine where the assistant's own reader has been denied, Fettler is the only reader. A type it cannot open is a file nobody can open.
| Type | How it comes back |
|---|---|
| Text, in any encoding a byte-order mark names, plus UTF-8 | lines, with the encoding, the line ending, whether the endings are mixed, and a hash |
| CSV | as text, so a spreadsheet exported as CSV needs nothing special |
Notebooks (.ipynb) | cells, sources and outputs, rendered, not the JSON they are stored in |
| text, page by page, each page announced so a citation can name one | |
Excel (.xlsx, .xlsm) | a row to a line, tab separated, one landmark per sheet. A cell holding a formula shows the formula with its last computed value beside it. Dates come back as dates |
Word (.docx, .docm) | paragraphs and tables under their headings, and the headings are the landmarks. Word does not store where the pages break |
| Images (png, jpg, gif, webp) | mime type and dimensions from the header, without decoding a pixel. Over MCP the bytes go as a native image part |
Archives (.zip, .tar, .tar.gz, .tgz) | the manifest: every member, its size, its date, and whether it carries the execute bit. --member NAME reads one entry out, decoded like any other file, without unpacking anything |
A lone .gz | whatever is underneath it: one file in a wrapper |
| Anything else binary | refused, saying so |
read stops at 2000 lines unless --to says otherwise, and says how many are left. find stops at a thousand, search at two hundred.read also stops at 40,000 characters, which --to does not lift. Those two caps are in different units on purpose. A line cap bounds how much of a file you asked for. A character cap bounds how much of it survives the trip, and lines vary in length by two orders of magnitude. A 593-line page, well under the line cap, was still large enough for a caller to reject the answer whole. A rejected answer costs a turn and returns nothing, which is worse than a short one. So the size is measured here, and the answer comes back short and says what is left. The budget is spent across the whole call rather than per path, and at least one line is always served. A minified file is a single enormous line, and an empty answer is a dead end rather than a smaller answer.--numbered puts the line numbers in the text, in the form the terminal prints: N | text, with N right-aligned in five columns. The prefix counts toward the 40,000-character cap, so a numbered read of a large file stops earlier. The hash is still the file's, and an edit may present it as expect. The numbered text is a rendering, not the file's content, so leave the prefix out of an edit. A rendered document is numbered by rendered line.--tail N is the end of the file, counted backwards. Reaching the end of a log or a build transcript needs neither its length nor any arithmetic. It cannot be combined with --from/--to. The end of a file and a named range are two different questions, and answering one of them by precedence would be a confident wrong answer rather than an error.search is rendered and matched along with everything else, and a hit inside one cites its page. Searching inside one says what that costs.VLOOKUP is a search for how a sheet works and would find nothing in the cached results. A search for a customer name would find nothing in the formulas. And dates are rendered as dates. Excel stores the cell you see as 2026-03-15 as the number 46096, so rendering the number would leave a workbook full of dates nobody can search for. That is the same readable-but-unfindable fault that had search skipping PDFs..xlsm and .docm are the same formats carrying macros, read the same way, and nothing here executes anything. .xlsb and .doc are not here. Both are binary formats under a familiar extension, and a reader that claimed them would fail on every file.write, edit and replace refuse a PDF, a workbook and a Word document outright. What comes back for one is text pulled out of it, with the sheets, the formulas, the tables and every piece of structure left behind. Writing that text back would not edit the file. It would replace it with a fraction of itself, still under the name that says it is a document. An image, an archive and a lone .gz are refused for the plainer reason that they are not text at all. The verbs move, copy and delete do not inspect content and are the way to shift one of these. The extract verb writes an archive member's own bytes and is deliberately not caught.execute grant, so a loader running unnamed code in process at full trust would be a hole straight through the boundary. A reader is a class in this tree with tests beside it, never a file found at startup. For a format Fettler does not ship, a declared converter task is the right form, and it already exists.Readable and unfindable is the trap this closes. A tool can render a PDF for read and still let search try to decode it as text, fail, and move on without counting it. That leaves a tree full of documents answering every search with a confident nothing. It is the failure this whole tool exists to prevent, and it would be sitting inside the tool.
--no-documents leaves them shut where the cost is not worth paying. The count is still reported.extract ARCHIVE --into DIR writes. Everything above only reads. An archive is a list of paths somebody else chose. That is exactly why the tool that already refuses a path outside a tree is the right one to unpack with.
$ fettle read fettle-1.0.0-win-x64.zip
79605839 2026-08-16 16:08 - fettle.exe
2545 2026-08-16 14:09 - NOTICE
1133 2026-07-30 04:52 - LICENSE
3 members, 79609517 bytes uncompressed
$ fettle extract fettle-1.0.0-win-x64.zip --into tools
extracted 3 members (79609517 bytes) from work:fettle-1.0.0-win-x64.zip into work:toolsedit is all-or-nothing.../../etc/passwd so it lands outside the unpack folder. Here that member resolves outside every tree and comes back exit 4, exactly like a path somebody typed.out/../elsewhere.txt normalises to somewhere the boundary is happy with. The tree check passes, and the file still lands nowhere near where you said. Nothing is wrong with the path. What is wrong is that it is not where you put it.fettle arriving out of a tar still able to run.--overwrite. An existing file is a refusal before anything is written, not a surprise halfway through.run are for. find and search deliberately do not reach inside an archive either. That would make members into paths, and then the boundary would have to answer what a scope means inside a container. Read the manifest, extract, search the tree. Three plain steps beat one murky one.At every level, fettle opens some trees with no declaration. They are folders the model already reaches through the client, or through another server. Opening them lets Fettler's own tools work there.
| Tree | What it is | Can |
|---|---|---|
scratch | this session's scratch folder: <temp>/claude/<project>/<session>/scratchpad, or /tmp/claude-<uid>/... on macOS and Linux | list read create update rename delete. Never execute |
tool-results | where the client puts a large tool answer and asks the model to read it back: ~/.claude/projects/<project>/<session>/tool-results, or directly under the project folder | list read |
memory | the client's memory folder, ~/.claude/projects/<project>/memory | list read create update rename delete |
| a companion's name | the --root folder of a companion server | read and write |
<project> is the launch path with separators and the drive colon replaced by hyphens. c:\github\app becomes c--github-app.roots says which route it used.tools, scratch names a folder to open as scratch by hand, scratch-root names the folder that holds every project's scratch folders, and client-root names the folder that holds the client's per-project folders (by default ~/.claude/projects).tree:path whenever more than one tree is open, which is now almost always.A companion is another MCP server whose registration passes --root PATH. sparky is one. fettle opens that folder as a tree named for the server, read and write, because the model already reaches it through the companion. So fettle can write a file there, and the companion can load it in the same turn. A declared tree with the same name on the same folder stands. The same name on a different folder is refused. With guardrails on, a companion's root inside a declared tree is finding 2.19.
| Thing | What Fettler does |
|---|---|
| The executable bit | preserved by copy and move, reported by read and find, settable by exec. It is the one permission git tracks, because it is the only one that means the same thing on both platforms. |
| Attributes, alternate data streams, extended attributes | carried across a staged write, or the answer names what could not be. |
| The Windows read-only attribute | read and named in the refusal, never met as an obscure OS error. delete --force clears it explicitly. |
| Links and junctions | reported with their targets, never created. Creating one on Windows needs Developer Mode or elevation. |
| Modified time | reported. Preserved on copy only when asked. Both branches are explicit, because Windows carries the time across where POSIX cp gives a new one. |
| Everything else about permissions | nothing. POSIX modes and Windows ACLs do not map onto one another, and an abstraction over them would be a falsehood told in a table. |
--gob "*.cs" would eat the pattern, and the search would then run against the whole tree with complete confidence. A single dash is the same trap with one fewer character. Take search PATTERN -i, the spelling every grep in the world uses. It would otherwise be read as a second pattern and answer the union of two questions. There is one short flag, -e. A pattern or path that starts with a hyphen goes after a bare --, or through --pattern-file or --pattern-stdin.--NAME= for a deliberately empty one.--pattern-file and --pattern-stdin take one pattern per line. So do --replace-file, --with-file and --text-file. Text with quotes and newlines never has to be escaped onto a command line.read marks it (MIXED) beside the dominant ending. Nothing is silently regularised.| Rule | What it is |
|---|---|
| Glob | a filename pattern. * stays within one path segment. ** matches any number of whole segments, including none. ? is one character and does not cross a separator. Every other character is literal. Case-insensitive on every platform, so a pattern behaves the same everywhere. A glob holding {, }, [ or ], or starting with !, is refused with exit 2, in a pattern and in --exclude. Other dialects give those characters a meaning, and here they would match only themselves. For an alternation, run two searches. For a bracket, put ? or * in its place. A file with one of those characters in its name is still reached by path, and by find with ? in the character's place. A search or replace whose glob matched no file says so. |
| Exclusions | .git, bin, obj, artifacts and node_modules, by name, with the count reported. A generated list under tools in the configuration replaces those names. --include-generated turns the exclusion off for one call. .gitignore is not honoured, because implementing its rules subtly wrong hides files while appearing to work. |
| Attributes never decide matching | a dotfile and a Windows hidden file are ordinary files, matched normally and reported. |
| Regular expressions | .NET's dialect, which is neither PCRE nor ripgrep's, under a bound: non-backtracking where the pattern allows, an explicit timeout where it does not. |
| Encoding | a byte-order mark decides and is preserved. Absent one, content that decodes as UTF-8 is UTF-8. Anything else is binary and refused. No statistical guess, because a wrong guess corrupts a file on the way out. |
| Ordering | results are ordered before any limit, so a truncated result is the same truncation twice, and says it was truncated. |
| Bounds | every answer is bounded twice. The first bound is a count: 2000 lines for read, 1000 files for find, 200 hits for search. The second is 40,000 characters of text, because one line or one hit can be a thousand characters and a count is a poor proxy for a size. Naming an explicit range lifts the first and never the second. Either way the answer says it was cut, and read says which of the two did it. |
| What runs at once | reads run concurrently. Changes are serialized against each other. run holds neither lock. |
Relative, forward-slashed, no drive letter. There is no formal standard for this. There is a convention, and it is git's. It is what .gitignore, Docker, npm and VS Code's own task files use, and it is the only spelling that means one thing on all three platforms.
| Written as | What happens |
|---|---|
"path": "../shoddy-requirements" | correct, and means the same on every platform |
"path": "..\\shoddy-requirements" | refused. Windows accepts / in every API. But on macOS and Linux a backslash is an ordinary character in a name, not a separator, so this names a file with a backslash in it, and finds nothing |
"path": "C:/work/tree" | allowed. It is absolute, so it names one machine either way |
"path": "C:\\work\\tree" | allowed. An absolute path cannot travel whatever its separators are, so a Windows path in an uncommitted .fettler.local.json is a person describing their own machine |
Refused rather than converted, because converting would make a file legitimately called a\b unreachable on the platforms where that is a legal name. The same rule applies to a scope key, to a task's cwd, to a screen key, and to the folders named under tools (scratch, scratch-root, client-root) and under screenings (models). Each of those paths resolves against the folder of the file that names it.
Nothing outside an open tree exists. A tree is a folder fettle has opened, with a name and a list of what may be done in it. At the tools level the open trees are the launch folder, as work, and the trees fettle finds. With guardrails on, they are the declared trees and the trees fettle finds. Outside them, nothing is writable, readable, listable or knowable. A path outside every tree is refused with exit 4, and refused in the same words whether or not something is there, so the refusal itself cannot be used to probe a disk. There is no current directory to change, no path that climbs out, and no shell to reach past it with.
The rest of this section is the guardrails level. Inside a declared tree, everything that may be done is stated, never assumed. The first question to settle is not how this works. It is whether it can express the rule you already have in your head. Most people arrive with one of these.
| The rule you want | How it is said |
|---|---|
| Look, don't touch. Read my source; change nothing. | "can": ["list", "read"]. It is the default for every tree the configuration file does not sit in, so you get it by saying nothing. |
| Work here, not there. Change the code, leave the requirements alone. | Two trees. The code tree grants writes. The other keeps the read-only default. |
| May add and revise, may never destroy. | create update rename without delete. This is the rule people usually want, and the one most tooling cannot express at all. |
| Not for you. This folder does not exist as far as the assistant is concerned. | "can": []. With no list it never appears in a search, and guessing the path does not help. |
| Run the build; run nothing else. | execute, plus tasks declared by name. There is no shell, so there is no anything else to reach for. |
All of that, but only under src/. | A scope: the same words, stated at a depth inside a tree. |
Every word in that table is one of seven, and the next section defines them all. They combine freely, per tree and per folder. That is what makes "may reorganise, may not destroy" a rule you can state, rather than a thing you hope for.
A tree is a path and a set of permissions. A scope states permissions somewhere inside one. Trees and scopes are declared in the guardrails section. Here is a real one, shown to read rather than to copy. Guardrails shows how to write your own:
{
"guardrails": {
"trees": {
"work": {
"path": ".",
"can": ["list", "read", "create", "update", "rename", "delete"]
},
"requirements": {
"path": "../shoddy-requirements",
"can": ["list", "read"],
"scopes": {
"backlog": { "can": ["list", "read", "create", "update", "rename"] },
"backlog/cancelled": { "can": [] }
}
}
}
}
}That reads: work may be changed but nothing in it may be run. The requirements tree is readable throughout. Its backlog folder may be added to, changed, and reorganised, but never destroyed. And backlog/cancelled is not there at all, as far as anything can tell.
Two rules decide what a given file gets, and they are the two to know before you write one of these:
cancelled sits inside backlog, so cancelled wins there.create and nothing else grants create and nothing else, not even read.Paths resolve against the file, never against the caller. Read against a current directory, a relative path names a different tree from every folder you run it in. Read against the file's own directory, it names one tree from everywhere. That is what lets the file be checked in.
A tree, not a repository. A folder with no version control in it is the ordinary case, not an exception. Nothing in the configuration, the default names, or this page assumes a checkout, a remote, or a .git. A folder that is also a checkout has its branch named by roots.
A malformed configuration is refused, never fallen back from. A silent fallback usually widens the boundary.
| Verb | Grants |
|---|---|
list | appears in find and search. Absent means hidden. The path does not exist as far as anything can tell. |
read | content may be read |
create | a file or directory that is not there may be made |
update | an existing file's content may be changed |
rename | may be renamed, or moved within this scope or into a descendant of it |
delete | may be removed, and moved out of this scope, which from here is the same thing |
execute | a declared task may run with this as its working directory |
Move is two permissions, not one. Moving into a subfolder needs rename on the source and create at the destination. Moving out of a scope needs delete as well, because a file that leaves is gone from here either way. That is what makes "may reorganise, may not destroy" possible to say at all.
A move that replaces something needs delete at the destination too, whatever the verb was called. Replacing a file destroys it.
A recursive delete and a recursive copy reach paths nobody wrote down. That is how a protected scope gets taken anyway. So both are refused when any scope inside the target does not grant what the operation needs. The refusal names no path, because saying where would tell a caller where a hidden scope is. That costs one bit of information. It buys a destructive operation refused out loud, instead of half-done in silence.
| Situation | Default |
|---|---|
| No configuration file (the tools level) | the launch folder, the folder fettle starts in, is one tree called work, granted list read create update rename delete. A path that climbs out of it is refused as outside every tree |
| Guardrails on: the tree the configuration file sits in | list read create update rename delete |
| Guardrails on: every other declared tree | list read. Strictly read-only, and only a configuration file can widen it |
execute, anywhere | never granted by default, in any tree, including the launch folder and the one the file sits in |
| The client's folders and a companion's root | set by fettle, not by a file. The trees fettle finds lists each one |
The default is decided by where the tree is, not by how its path was spelled. "." and an absolute path to the same folder mean the same thing.
A malformed file is a refusal, never a fallback to the tools level. Falling back from a broken boundary is how a boundary silently widens.
Why the launch folder, and not a folder above it. Before 2.1.0, a folder with no file was refused. That changed on purpose, and it is the one widening in 2.1.0. The launch folder is the narrowest honest tree. It is the folder the client was started in, which is the folder the work is in. The old objection was that a current directory often sits above the tree somebody meant. The upward search answers it. The search for a .fettler.json runs first, so a file in a folder above is always found and honoured.
This is the central rule, and it is the answer to --root. fettle reads up to three files, in this order, and a later file wins section by section.
| Source | May grant |
|---|---|
The machine file: %APPDATA%\fettler\fettler.json on Windows, ~/.config/fettler/fettler.json elsewhere (or $XDG_CONFIG_HOME/fettler/fettler.json) | tools and screenings only, such as the models directory. A guardrails section in it refuses the load. A tree, a task or an execute grant there would apply to every project, and no project reviewed it |
The project file, the first .fettler.json found at or above the launch folder | anything. Writing one is a deliberate, reviewable act by a person, inside the tree it governs |
The overlay, .fettler.local.json beside it | anything. It merges over the project file section by section. Inside a section, an entry named in both files is the overlay's entirely. A lone .fettler.local.json with no .fettler.json beside it is read as the project file |
--config PATH | anything, because naming a file is the same deliberate act. It takes the project file's place |
--root PATH on a command line | list read, and nothing else, with no override. The machine file still supplies the tools settings, and the client's folders are still opened |
--no-config | stops the search, and gives the tools level on the launch folder |
| No file found | the tools level on the launch folder |
The machine file is not ~/.fettler.json. The upward search would find a file of that name and take the whole home folder as the tree. roots and doctor name the machine file when it supplied something.
serve looks again on every call while it has no file. Writing a guardrails section into a new .fettler.json binds on the next call, with no restart. Once a file is found, that file is the one read again before every call. fettle never searches upward again from there, because a fresh search could find a broader file above.
--root stays because the MCP server has to be launched somehow, and because ad-hoc reading is useful. It can no longer hand anybody write access. To write to a declared tree, you put a file in it.
The single exception, and the only one: setup may write outside every tree, to a fixed, compiled-in list of client-configuration paths, and nowhere else. It is the same closed list doctor reads. Nothing else in the tool may write outside a tree that granted it.
The configuration file names the trees, what may be done in each, and the commands Fettler may run. Before this rule existed, Fettler could write that file and then act on what it had just written. Declaring a task and running it took two commands. Two things follow.
execute must not be a default, even on the tree the configuration sits in. That tree is writable by definition, and the configuration lives inside it. Write-by-default plus execute-by-default is arbitrary code execution. The tool could be walked into running anything. And it defeats every other permission at once, because a scope protected from delete means nothing to a task that can run del.
An explicit grant is not enough on its own, either. If the declaration can be written by the thing the grant applies to, the grant has no bound. Whoever holds execute also chooses what executes.
Therefore, compiled in and not configurable at any permission level:
| File | Rule |
|---|---|
.fettler.json | never created, written, edited, renamed, moved or deleted through Fettler |
.fettler.local.json | as above |
.mcp.json | as above. It names the servers a client launches |
.picker.json | as above. It is the whole boundary of pick, the assistant's SQL tool, which has no write path of its own |
.picker.local.json | as above |
.claude/settings.json | as above. It names the tools a model may call at all |
.claude/settings.local.json | as above |
.vscode/mcp.json | as above |
fettler/fettler.json | as above. It is the machine file, which can turn screenings on in every project and says where the client's folders are |
That is nine files. The ninth is matched on its last two path segments, like the client's files below it in the table.
The second group is the same argument, arrived at later. Fettler's own declaration was protected from the start. Meanwhile .claude/settings.json, the file naming which tools a model may use at all, stayed an ordinary writable file. No boundary had to be breached to reach it. A project's assistant configuration lives in the project, so the file governing the assistant and a file the assistant may write were the same file. A model that can edit its own deny list has no deny list. Being asked to edit it is no safeguard. An instruction can arrive from a prompt, from a file just read, or from a misreading of either.
pick's files are the same argument once more. The .picker pair is the whole boundary of the assistant's SQL tool. pick itself has no write path at all and re-reads the pair before every call. A fettle that could write them would hand the assistant that boundary through the side door, and the very next pick call would obey the edit.
The client's files and the machine file are matched on the last two path segments, not on the file name alone. A rule refusing every settings.json would be unusable, and also wrong. .vscode/settings.json is a person's editor configuration and has no say in what an assistant may do. CLAUDE.md is deliberately not on the list either. It persuades rather than permits, and an assistant maintaining a project's instructions is ordinary work.
setup still writes the ones it needs, by construction and not by exemption. It uses plain file IO and resolves no path through the boundary, so a guard that lives on the path-resolving route every tool verb takes is not in its way.
The names cover all three sections alike, since the trees, the tasks and the screens are declared in the same file.
The names are matched at any depth, so a configuration in a subdirectory is protected too. They are matched by name, so .fettler.json.bak and .fettler.json.md are ordinary files. Reading them is fine and stays fine. A caller asking what the rules are is the opposite of the problem.
The refusal has its own outcome and its own wording, Fettler does not write those files, and its own exit code, 11. It is deliberately not a permission denial, because nothing is missing. No grant would have allowed it, and a caller sent looking for one is being sent somewhere there is nothing to find. setup guardrails and setup screenings may add a starter section where the file has none. Changing a section that exists is a person's job, with an ordinary editor.
Worth stating plainly, because it is the whole point.
.fettler.json and its overlay. serve reads them again before every call, so a person's edit binds on the next call. The model cannot write any of them.in, which selects among trees already open.--root cannot grant write at all, so even a careless launch cannot hand write access to anybody.So the answer to why does an MCP server need a root is that it does not need --root. It needs a boundary, from a source the model cannot write. A file in the tree is that source.
A scope with no list is absent from find and search, and absent from their counts and totals. A direct path into it is refused in the same words as a path outside every tree, including a path to a file that was never there. The difference between hidden and absent cannot be probed.
This is not encryption, and it does not pretend to be. Somebody at a terminal can open the folder. What it stops is an assistant finding cancelled requirements by searching, and reading them back as current.
Tasks belong to the guardrails level. They are declared in tasks under guardrails, and with guardrails off there are none. run executes only what the configuration declares, and never composes a shell command. Guardrails shows how to declare one. What a declaration means is here.
"one two" is a single argument.&&. The string is split once, and the program is launched with the resulting list.run accepts a name and a timeout, and nothing else. An extra word is refused, on the command line and over MCP alike, rather than dropped. What runs is therefore exactly what the file says, every time. A variant is a second declared task, or a value the file declares, never something composed at the call.
$ fettle run build --release
refused: no such flag: --release. Run fettle help for the list
$ fettle run build release
refused: run takes only a task name; 'release' is extra. A task takes no arguments.
Declare a second task for a variantcwd, the folder the task runs in, is optional. It defaults to the top of the default tree, and is checked for containment like any other path.
Declaring a second task answers test versus machines. It cannot answer the branch to cut or the note to commit under, which are strings invented fresh each time. A command line may name a value the configuration declares:
"guardrails": {
"trees": { "work": { "path": ".", "can": ["list", "read", "create", "update", "rename", "delete", "execute"] } },
"replacements": {
"feature-branch": "hebdenbridge"
},
"tasks": {
"feature": { "run": "pwsh -File scripts/shoddy-branch.ps1 feature {feature-branch}" }
}
}--message={note} and v{release-tag} are each one argument.{ $_.Name }, {a:.b} and {0} go on meaning what they meant. Write {{ for a literal brace where a name would otherwise be read.new {feature-branch} does not fail. It creates a branch called {feature-branch}..fettler.local.json is kept out of source control. Its guardrails section merges over the checked-in one, and its replacements replace the checked-in ones name by name. The project declares the task while today's branch name stays out of source control.tasks shows every value, deliberately, so that what is listed is what runs. A token belongs in the environment of the process a task launches.Every refusal belongs to one level. A refusal can happen only when its level is on, except for the configuration refusals, which happen while the file is read. Each refusal prints its whole answer on stdout, the normal output stream, so no redirection is needed to read a failure. The exit codes are below.
| Level | What is refused | Exit |
|---|---|---|
| tools | A path outside every open tree. A path that climbs out of the launch folder, or into an older session's folder or a transcript beside the client's folders, lands here too | 4 |
| tools | A write, edit, rename, move or delete of one of the nine governed files | 11 |
| tools | A write that would add a credential the file did not already carry | 12 |
| tools | A flag no verb knows, a flag with no value, or a glob holding a brace or a bracket | 2 |
| tools | An archive member that would land outside the destination, or outside every tree | 4 |
| tools | A configuration file that is not valid JSON, or does not hold an object | 2 |
| tools | The flat shape of the last release: trees, tasks, replacements or models at the top. The message shows the file's own entries rewritten under the three sections | 2 |
| tools | The oldest shape, roots at the top | 2 |
| tools | Any other key at the top. The message names the three sections | 2 |
| tools | A key under tools other than generated, scratch, scratch-root and client-root | 2 |
| tools | A declared tree with the same name as a companion server, on a different folder | 2 |
| tools | fettle setup CLIENT, the old spelling with no level. The message shows the three new spellings | 2 |
| guardrails | A guardrails section in the machine file | 2 |
| guardrails | A guardrails section that declares no trees | 2 |
| guardrails | A declared tree called scratch, tool-results or memory | 2 |
| guardrails | A relative declared path written with a backslash | 2 |
| guardrails | A task whose command line names a value replacements does not declare | 2 |
| guardrails | A path in a scope with no list. It is refused in the same words as a path outside every tree | 4 |
| guardrails | An operation the tree or scope does not grant, including a recursive delete or copy over a scope inside it | 8 |
| guardrails | A task in a tree or scope that does not grant execute | 8 |
| guardrails | A task nobody declared. With guardrails off, run refuses every name this way, because there are no tasks | 3 |
| guardrails | run given anything beyond a task name and a timeout | 2 |
| screenings | A screen inside a tree or scope entry. The message says it now goes under screenings | 2 |
| screenings | A screen key that names no tree, a tree that is not declared (with guardrails off, anything but work), or a scope the tree does not declare | 2 |
| screenings | A read whose answer would disclose a screened category, or a screen that could not run | 13 |
Real transcripts of the common ones follow.
$ fettle read ../secrets.txt
refused: the path is outside every declared tree. Run roots to see the trees
$ echo $LASTEXITCODE
4Climbing out with .., an absolute path elsewhere, and a link leaving the tree all land here. So does a path into an older session's scratch folder, or into the session transcripts beside the client's folders. All of them answer the same whether or not the file exists.
$ fettle write .fettler.json --text "{}" --overwrite
refused: .fettler.json is a file that says what this tool and the assistant may do.
Fettler does not write those files. Change it with an editor. The files are:
.fettler.json, .fettler.local.json, .mcp.json, .picker.json, .picker.local.json,
.claude/settings.json, .claude/settings.local.json, .vscode/mcp.json,
fettler/fettler.json (C:\github\app\.fettler.json)
$ echo $LASTEXITCODE
11This has its own outcome rather than a permission denial, because nothing is missing. No grant would have allowed it. Without the rule, the escape is two commands: write a task declaration, then run what you just wrote.
$ fettle write config.json --text-file staged.json --overwrite
refused: this write would add a credential to the file: line 14 (aws-access-key-id).
The secret is not quoted, because the refusal would then disclose it. Use a reference
such as ${ENV_VAR} instead, or pass --allow-credential on the command line if this is
not a credential.
$ echo $LASTEXITCODE
12It judges what the write adds, not what the file contains. A configuration that already holds a key stays editable. That is the whole difference between a rule people keep and a rule people switch off. Only a secret absent from the previous content stops anything, so reformatting, reordering, and editing the lines around one all still work.
The refusal never repeats what it found. A line number and a detector name, and nothing else. A message naming the secret would write it into the log, the transcript, and whatever ships those onward, which is a worse outcome than the write it was refusing.
--allow-credential is offered by the command line and by nothing else. No MCP tool carries it, and a test asserts none ever will. Deciding that a random-looking string is not a secret is a judgement about your own file, and a person makes it. That is the same reasoning that keeps setup out of a model's hands.
${REFERENCE}.$ fettle read ward-round.docx
refused: this response would disclose regulated data: 2 in identifiers. The matches are
not quoted, because the refusal would then disclose them. Read a narrower range, or
take the screen off this scope if the content is not regulated.
$ echo $LASTEXITCODE
13This is the mirror of the credential refusal above. That one is a write going in. This one is a payload coming out. It happens only when the screenings level is on, in a tree or scope the screen names. Screenings is the whole of it.
execute$ fettle run hello
refused: 'hello' would run in a tree that does not allow execute. Add "execute" to
that tree's or scope's "can" under "guardrails" in .fettler.json. execute is never
a default.
$ echo $LASTEXITCODE
8The tree holding the configuration is writable by definition. So write-by-default plus execute-by-default would be arbitrary code execution, and a scope protected from delete means nothing to a task that can run del.
$ fettle run deploy
refused: no task called 'deploy'; .fettler.json declares: hello
$ echo $LASTEXITCODE
3With guardrails off there are no tasks, and every name gets this answer:
$ fettle run build
refused: no task is declared, because tasks belong to the guardrails level and it is off.
Write a .fettler.json with a "guardrails" section holding "trees" and "tasks"
$ echo $LASTEXITCODE
3$ fettle edit f.txt --insert-after 1 --text
refused: --text needs a value. Write --NAME= to pass an empty one
$ fettle find --gob
refused: no such flag: --gob. Run fettle help for the listBoth answer 2. Ignoring either would produce a wrong answer rather than a failure. A flag with no value would swallow the next word as its value and write the word true into the file.
Before 2.1.0, trees, tasks, replacements and models sat at the top of the file, and a screen sat inside a tree. That shape is refused with exit 2, not read. A file that means one thing to the old version and another to this one has a boundary nobody can state. Take this file:
{
"trees": { "work": { "path": ".", "screen": true } },
"tasks": { "build": { "run": "dotnet build" } }
}The refusal names the first old key it found and the file. It ends with the file's own entries moved under the new sections, so the fix is to paste them:
has "trees" at the top, which is the shape this version no longer reads. Rewrite the
file with three sections, one per level. Your entries, rewritten:
{"guardrails":{"trees":{"work":{"path":"."}},"tasks":{"build":{"run":"dotnet build"}}},
"screenings":{"screen":{"work":true}}}The message is printed on one line. It is wrapped here to fit the page. trees, tasks and replacements move under guardrails. models moves under screenings. Each screen leaves its tree or scope and goes under screenings, keyed by the tree name, or the tree name, a slash and the scope path.
Each of these is exit 2, and the configuration does not load. The message names the file.
has "NAME" at the top. The top holds three sections, one per level: tools, guardrails, screenings.guardrails section in the machine file says: is the machine file, and it has a "guardrails" section. A tree, a task or an execute grant here would apply to every project on the machine, and no project reviewed it. Move the section to a project's .fettler.json.screen inside a tree entry says: tree 'work' has a "screen". A screen now goes under "screenings", keyed by the tree or scope: {"screenings":{"screen":{"work":true}}}. A screen inside a scope gets the same message with the key work/SCOPE.screen key naming no tree, such as "/records", says: has a "screen" key that names no tree. Write the tree name first. A key naming a tree that is not declared lists the trees there are.guardrails section with no trees says: has a "guardrails" section that declares no trees. Add a "trees" object to it, or remove the section to use the launch folder as the tree.fettle opens some trees itself (see the trees fettle finds), and a declared tree may not take their names. Both of these are exit 2.
scratch, tool-results or memory says: declares a tree called 'scratch'. fettle gives that name to the client's scratch folder and finds the folder itself. Remove the tree, or give it another name.tree 'sparky' is declared on FOLDER, and fettle also opens OTHER under that name: HOW. Give the declared tree another name. A declared tree of the same name on the same folder is allowed. It is the same pairing written by hand.setup with the client first$ fettle setup claude-code --local
refused: setup takes the level first, then the client. Run: fettle setup tools claude-code,
fettle setup guardrails claude-code or fettle setup screenings claude-code
$ echo $LASTEXITCODE
2An exit code is the number a program hands back when it finishes. 0 means success. Callers chain on exit codes because PowerShell 5.1 has no &&. The machine-readable answer carries the same word as an outcome field.
| Code | Outcome | Meaning |
|---|---|---|
| 0 | ok | it worked |
| 1 | — | an unexpected internal fault |
| 2 | invalid | the request did not make sense: an unknown flag, a missing value, a malformed configuration |
| 3 | not-found | the path, or the task, is not there |
| 4 | outside-root | the path is outside every declared tree |
| 5 | target-exists | the destination is occupied and no overwrite was asked for |
| 6 | stale | the file changed since it was read. The hash passed as --expect no longer matches |
| 7 | conflict | ambiguous or overlapping edits |
| 8 | refused | Fettler declined, for a stated reason |
| 9 | denied | the operating system declined: a sandbox, a restricted account, a read-only attribute, a file another process holds open |
| 10 | timed-out | a task ran past its timeout and was killed |
| 11 | governed | the path names a file that says what this tool may do |
| 12 | credential | the write would have added a secret the file did not already carry |
| 13 | screened | the response would have disclosed regulated data out of a screened scope, or the screen that would have judged it could not run. See Screenings |
doctor is the exception, because it reports rather than operates: 0 healthy or not applicable, 1 warnings only, 2 anything broken (a broken registration, or finding 2.19). --hook always exits 0, and says nothing about a level that is off.
Ranked by how much each changes behaviour.
| Rank | Where | Why |
|---|---|---|
| 1 | The MCP instructions string and the per-verb tool descriptions | Read at the moment a tool is chosen, ships with the tool, needs setup by nobody, reaches every client at once. Ours to write. |
| 2 | The SessionStart hook | Automatic, and costs one line. Written by setup guardrails with --hooks. |
| 3 | Global CLAUDE.md | Where standing rules already live and demonstrably work. |
| 4 | Repo CLAUDE.md and the grounding page | Project scope, and the canonical long form. |
| 5 | permissions.deny | Enforcement rather than instruction. The only one that does not rely on anything being read. Written by setup guardrails. |
What each says depends on the levels that are on. The MCP instructions open with the tools. Then one sentence says whether guardrails are on for this launch, and one says whether screenings are on.
| Where | Tools paragraph | Guardrails paragraph |
|---|---|---|
The MCP instructions | always | one sentence, saying on or off for this launch |
The instruction block in CLAUDE.md or copilot-instructions.md | in every block | in the block setup guardrails writes. It opens "Guardrails are on. The built-in Read, Write, Edit, NotebookEdit, Grep and Glob are denied." |
setup tools writes no instruction block, because it writes only the registration. The guardrails paragraph says the built-ins are denied, which is true only once setup guardrails has written the deny list.
git, a script) has a form that names its location instead: git -C, an absolute path, fettle run.fettle doctor prints the version and the PATH line, then one section per level. Each section opens with the level's name and whether it is on. A level that is off is one line. A finding is reported only when its level is on. With guardrails off, nothing in the guardrails rows below can appear.
fettle, the models directory, and the model installed for each category.A finding marked ! is serious. One marked - is worth knowing. doctor exits 0 when healthy, 1 for warnings only, and 2 when something is broken: a broken registration, or finding 2.19.
Note setup adds and never deletes. So anything below whose fix is "remove" is a person's edit, in an editor. That is deliberate. A tool that quietly removed your configuration would be doing the very thing this design objects to.
The wiring belongs to the tools level. It is one row per client, for the global registration and the one in this project, ending in a verdict.
| What it says | What it means | How to clear it |
|---|---|---|
on PATH: no | Nothing on PATH resolves fettle, so every registration naming a bare fettle will fail to launch. | Put it on PATH and open a new terminal. Or register an absolute path with fettle setup tools CLIENT --local --command "C:\tools\fettle\fettle.exe". |
absent | No server called fettler is registered in that file. | fettle setup tools CLIENT --local (or --global). Run it with --dry-run first. |
broken (2.12) | A registration is there, but its command does not resolve. It is checked the way the client would check it, through PATH and PATHEXT. | Fix PATH, or run setup tools again with --command naming the binary. Add --force to overwrite the existing entry. |
healthy | Registered, and the command launches. | Nothing. |
n/a | The client is not installed, or has nothing at this reach. Claude Desktop has no project registration, so its row for this project is always this. | Nothing. Never a failure. |
In doctor --json, each row's "reach" is "global" or "repo". Before 2.1.0 that key was called level.
| Level | Check | What it found | How to clear it |
|---|---|---|---|
| guardrails | 2.6 ! | An allow entry pre-approving a shell command that writes files: sed -i, cp, rm, Set-Content, a redirect. Each runs without a prompt, and each is a complete route round the boundary. | Remove those allow entries. No tool can do this for you, and no blocklist can replace it. echo x > file defeats any list that could be written. The rule to adopt: stop pre-approving shell commands that write. Guardrails explains the deny list. |
| guardrails | 2.7 ! | An allow on a built-in tool Fettler replaces. Read in particular defeats containment, the read permission, and hidden scopes at once. | Remove the allow, then fettle setup guardrails CLIENT --local to add the matching deny. Leaving both is finding B.17. |
| guardrails | 2.8 - | Nothing denies one of the eleven: the six built-in editors and readers; the shells Bash, PowerShell and Monitor; and BashOutput and TaskOutput. Each undenied name is a route round the boundary. A shell is the widest of them. node x.js writes anywhere, and it is not a writing command. Monitor is a shell. It runs the command it is handed in the same environment Bash does. BashOutput and TaskOutput are readers. They hand back the output of work already done, and those bytes reach the model without passing the tree boundary, the secret scan or the disclosure screen. The deny list is judged across the client's files in this project, the way the client merges them. A deny in .claude/settings.json holds whatever .claude/settings.local.json says, so the finding names .claude/settings.json. | fettle setup guardrails CLIENT --local writes all eleven denies in this project, and clears the finding. Then allow back the commands this project needs. For every project at once, --global --deny, said out loud, because changing how the assistant works everywhere is not a side effect. |
| guardrails | 2.9 - | An additionalDirectories entry naming somewhere outside every declared tree. When two boundaries disagree, the wider one is what applies. | Pick one. Either drop the entry from the client's settings, or add that directory as a tree under guardrails, with the permissions you mean. |
| guardrails | 2.10 - | An instructions file (CLAUDE.md, copilot-instructions.md) that never mentions Fettler. Nothing tells the assistant to prefer it over what it already reaches for. | Add a line to it, or run fettle setup guardrails CLIENT --local, which writes the instruction block. Where to say it has the wording. |
| tools | 2.11 ! | Two project entries naming one directory in different spellings: c:/work/tree and C:/work/tree. Permissions and approvals attach to whichever spelling was resolved that day, and do not apply to the other. | Merge them by hand in ~/.claude.json, keeping the settings you want and deleting the duplicate key. Nothing can guess which of the two you meant. |
| guardrails | 2.13 - | A settings.local.json is present. It is personal and uncommitted, so anything granted there never appears in a review. | Read it, and move anything that should apply to the project into the committed settings.json. |
| guardrails | 2.14 ! | chat.tools.autoApprove is on. It approves any tool without asking, and puts back every route the boundary removes. | Turn it off in the client's settings. Nothing else holds while it is on. |
| retired | 2.15 | No longer reported. It said a scratch folder was outside every tree. fettle now opens this session's scratch folder itself, as the tree scratch. | Nothing to clear. The number is not reused. |
| guardrails | 2.16 - | A task fills a declared value into a string it hands to a shell to interpret: pwsh -Command, bash -c. A filled value is one argument whatever is in it, and that guarantee stops at the process boundary. Here the argument is itself a script the shell parses, so a quote or a semicolon in the value changes what runs. | Take the shell out of it. Declare the steps as separate tasks, such as "stage": {"run": "git add -A"} and "commit": {"run": "git commit -m {commit-note}"}, or call a script with the value as a parameter. Warned rather than refused, because which flags of which programs mean "interpret this" is a list of shells this tool does not keep. |
| guardrails | 2.17 - | Another MCP server is registered beside Fettler. The deny list closes names, and that server's tools are not called Read. So nothing setup writes limits what it may reach. | Decide, one server at a time. Most of them are wanted, and this is reported as a fact rather than a fault. For any that should not be a route, deny mcp__<name>__*. A server installed tomorrow is reported tomorrow. |
| guardrails | 2.18 - | A policy file names a tool that is not in the list this fettle was built with. Check 2.8 therefore said nothing about it either way, and that is not the same as safe. The client keeps adding tools. That is why the date of the list is printed with every report. | Decide by hand whether it reads files, writes them, or runs a shell, and deny it if it does. A newer fettle may already know it, so upgrading is the other half of the fix. Rules naming another server's tools (mcp__…) belong to 2.17. |
| guardrails | 2.19 ! | A companion server's --root is inside a declared tree, inside a screened tree or scope, or in a folder that a governed file switches. The companion reads and writes every file there with none of fettle's rules. This finding makes doctor exit 2. | Move the companion's --root to a folder outside every tree. Do not deny the companion, because that removes what it is for. A root that is exactly a declared tree of the same name is a pairing, and is reported as a fact. |
| guardrails | 2.x ! | A policy file that is there and could not be read: malformed JSON, or a permission error. The message is the parser's own. | Fix the file. A file that cannot be read is not treated as absent, because "absent" and "broken" want different answers. |
| guardrails | B.17 ! | A rule in both allow and deny. Which wins is the client's to decide, and cannot be known from here. A narrowed shell allow beside the shell deny is exempt. allow: Bash(git status:*) with deny: Bash is the shape this tool asks for, not a conflict. | Remove it from one of the two lists, whichever you meant. Do not rely on precedence. |
With guardrails off, a companion's root is one line of fact in the tools section and never a finding. No declared tree states a rule the companion could ignore.
That bound matters, because it says how far each rule has to go.
.fettler.json in a text editor. That person is the author of the rules, not a threat to them.This is stated because the alternative is scope creep. Every rule here could be hardened indefinitely against an attacker nobody is worried about.
Each item below is a way for data to reach the model, or leave it, that does not pass through a Fettler tool. Each is stated so that nobody mistakes it for something Fettler covers.
fettle setup guardrails CLIENT --local writes the deny list, and doctor's findings report the routes that are still open.--root. If it has commands that open a network connection, the model can hand it text it read through Fettler, and the companion can send that text anywhere. That route does not pass through a file. It is not a boundary this tool claims.