Shoddy · Fettler

Reference

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.

On this page.

The three levels

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.

LevelOn whenWhat belongs to it
toolsalways. It needs no fileThe 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
guardrailsthe configuration has a guardrails sectionDeclared 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
screeningsthe configuration has a screenings sectionThe 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.

The configuration file's three sections

.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 }
  }
}

The verbs

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.

VerbWhat it answers
findpaths matching a glob, with size and modified time
searchmatching lines as records (path, line, column, text) for several patterns at once. A hit inside a document also carries the page it sits on
readcontent, 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
writethe file created or replaced
edita 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)
replaceone substitution everywhere it appears across a glob
new / mkdiran empty file; a directory
move / copy / deletethe operation, and what guarantee it gave
extractan archive unpacked into a declared tree, or refused whole, with nothing written
execthe executable bit, set or cleared
rootsthe 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 / runthe declared tasks; one of them, with its streams and exit code. Guardrails only
batchseveral operations sent as one. The script comes the same three ways as edit's
doctorone section per level. Whether this tool is wired into each client, and, for each level that is on, what goes round it
setupthe files for one level, written. The level is the first word. The one verb an assistant is not offered
servenothing. 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.json

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 level

The 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.

CommandWhat 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.

Worked twins

# 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.

What it can read

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.

TypeHow it comes back
Text, in any encoding a byte-order mark names, plus UTF-8lines, with the encoding, the line ending, whether the endings are mixed, and a hash
CSVas text, so a spreadsheet exported as CSV needs nothing special
Notebooks (.ipynb)cells, sources and outputs, rendered, not the JSON they are stored in
PDFtext, 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 .gzwhatever is underneath it: one file in a wrapper
Anything else binaryrefused, saying so

Searching inside one

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.

Unpacking one

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:tools
It reads archives and does not write them. Producing one is a build output, and building is what a declared task and 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.

The trees fettle finds

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.

TreeWhat it isCan
scratchthis session's scratch folder: <temp>/claude/<project>/<session>/scratchpad, or /tmp/claude-<uid>/... on macOS and Linuxlist read create update rename delete. Never execute
tool-resultswhere 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 folderlist read
memorythe client's memory folder, ~/.claude/projects/<project>/memorylist read create update rename delete
a companion's namethe --root folder of a companion serverread and write

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.

What it touches besides content

ThingWhat Fettler does
The executable bitpreserved 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 attributescarried across a staged write, or the answer names what could not be.
The Windows read-only attributeread and named in the refusal, never met as an obscure OS error. delete --force clears it explicitly.
Links and junctionsreported with their targets, never created. Creating one on Windows needs Developer Mode or elevation.
Modified timereported. 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 permissionsnothing. POSIX modes and Windows ACLs do not map onto one another, and an abstraction over them would be a falsehood told in a table.

Arguments it will not guess at

The rules a caller has to know

RuleWhat it is
Globa 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 matchinga 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.
Encodinga 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.
Orderingresults are ordered before any limit, so a truncated result is the same truncation twice, and says it was truncated.
Boundsevery 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 oncereads run concurrently. Changes are serialized against each other. run holds neither lock.

How a declared path is written

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 asWhat 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.

The boundary: what it can say

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 wantHow 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.

Where this pays. A single read-only flag is the most that assistant tooling usually offers, and it forces one choice for the whole tree: either the assistant cannot help, or it can delete your requirements. Every row above is a rule that flag cannot state.

Saying it at a depth

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:

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.

The seven permissions

VerbGrants
listappears in find and search. Absent means hidden. The path does not exist as far as anything can tell.
readcontent may be read
createa file or directory that is not there may be made
updatean existing file's content may be changed
renamemay be renamed, or moved within this scope or into a descendant of it
deletemay be removed, and moved out of this scope, which from here is the same thing
executea 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.

Verbs that do not name what they touch

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.

The defaults, which are the safe ones

SituationDefault
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 inlist read create update rename delete
Guardrails on: every other declared treelist read. Strictly read-only, and only a configuration file can widen it
execute, anywherenever 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 rootset 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.

Where a permission may come from

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.

SourceMay 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 folderanything. Writing one is a deliberate, reviewable act by a person, inside the tree it governs
The overlay, .fettler.local.json beside itanything. 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 PATHanything, because naming a file is the same deliberate act. It takes the project file's place
--root PATH on a command linelist read, and nothing else, with no override. The machine file still supplies the tools settings, and the client's folders are still opened
--no-configstops the search, and gives the tools level on the launch folder
No file foundthe 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.

Fettler never writes its own rules

This is the rule that makes every other rule hold. Without it, the permission model is decoration.

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:

FileRule
.fettler.jsonnever created, written, edited, renamed, moved or deleted through Fettler
.fettler.local.jsonas above
.mcp.jsonas above. It names the servers a client launches
.picker.jsonas above. It is the whole boundary of pick, the assistant's SQL tool, which has no write path of its own
.picker.local.jsonas above
.claude/settings.jsonas above. It names the tools a model may call at all
.claude/settings.local.jsonas above
.vscode/mcp.jsonas above
fettler/fettler.jsonas 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.

Why an assistant cannot override any of this

Worth stating plainly, because it is the whole point.

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.

Hidden is not secret

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.

Declared tasks

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.

The command grammar, in full

A task takes no arguments from its caller. 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 variant

cwd, 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.

Values the file supplies

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}" }
  }
}
The value in the file is the authorization. Fettler will not write its own configuration, so a branch name or a commit message in it is there because a person typed it. A task whose placeholder nobody declared refuses to run at all. The resting state is inert, and arming it is a deliberate edit by somebody who can edit that file.
That configuration is trusted input. Containment guards paths. It does not and cannot guard a command list, which is read from a file inside the tree Fettler was pointed at. Whoever can write that file chooses what a caller, a model included, can execute. Point Fettler at a tree you trust.

When it refuses

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.

LevelWhat is refusedExit
toolsA 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 too4
toolsA write, edit, rename, move or delete of one of the nine governed files11
toolsA write that would add a credential the file did not already carry12
toolsA flag no verb knows, a flag with no value, or a glob holding a brace or a bracket2
toolsAn archive member that would land outside the destination, or outside every tree4
toolsA configuration file that is not valid JSON, or does not hold an object2
toolsThe 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 sections2
toolsThe oldest shape, roots at the top2
toolsAny other key at the top. The message names the three sections2
toolsA key under tools other than generated, scratch, scratch-root and client-root2
toolsA declared tree with the same name as a companion server, on a different folder2
toolsfettle setup CLIENT, the old spelling with no level. The message shows the three new spellings2
guardrailsA guardrails section in the machine file2
guardrailsA guardrails section that declares no trees2
guardrailsA declared tree called scratch, tool-results or memory2
guardrailsA relative declared path written with a backslash2
guardrailsA task whose command line names a value replacements does not declare2
guardrailsA path in a scope with no list. It is refused in the same words as a path outside every tree4
guardrailsAn operation the tree or scope does not grant, including a recursive delete or copy over a scope inside it8
guardrailsA task in a tree or scope that does not grant execute8
guardrailsA task nobody declared. With guardrails off, run refuses every name this way, because there are no tasks3
guardrailsrun given anything beyond a task name and a timeout2
screeningsA screen inside a tree or scope entry. The message says it now goes under screenings2
screeningsA 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 declare2
screeningsA read whose answer would disclose a screened category, or a screen that could not run13

Real transcripts of the common ones follow.

A path outside every declared tree

$ fettle read ../secrets.txt
refused: the path is outside every declared tree. Run roots to see the trees
$ echo $LASTEXITCODE
4

Climbing 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.

Writing a file that says what the tool may do

$ 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
11

This 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.

A write that would put a secret on disk

$ 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
12

It 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.

This is a safety net, not a boundary, and the difference matters. Encode the value, split the string in two, or build it from parts, and every rule here is defeated, by accident as easily as on purpose. What it stops is the ordinary accident: pasting a real key into a config and writing it to disk. Anyone who believes their content has been scanned for secrets has been misled. The two detection tiers are deliberately narrow, so the refusals people do get are ones they believe: issued credentials whose shape their issuer fixed, and a secret-shaped name assigned a long, random-looking, whitespace-free value that is not a ${REFERENCE}.

A read that would disclose regulated data

$ 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
13

This 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.

Running a task in a tree that does not grant 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
8

The 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.

A task nobody declared

$ fettle run deploy
refused: no task called 'deploy'; .fettler.json declares: hello
$ echo $LASTEXITCODE
3

With 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

A flag with no value, and a flag no verb knows

$ 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 list

Both 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.

A configuration in the old flat shape

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.

A section or a key in the wrong place

Each of these is exit 2, and the configuration does not load. The message names the file.

A tree name fettle already uses

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.

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
2

Exit codes

An 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.

CodeOutcomeMeaning
0okit worked
1—an unexpected internal fault
2invalidthe request did not make sense: an unknown flag, a missing value, a malformed configuration
3not-foundthe path, or the task, is not there
4outside-rootthe path is outside every declared tree
5target-existsthe destination is occupied and no overwrite was asked for
6stalethe file changed since it was read. The hash passed as --expect no longer matches
7conflictambiguous or overlapping edits
8refusedFettler declined, for a stated reason
9deniedthe operating system declined: a sandbox, a restricted account, a read-only attribute, a file another process holds open
10timed-outa task ran past its timeout and was killed
11governedthe path names a file that says what this tool may do
12credentialthe write would have added a secret the file did not already carry
13screenedthe response would have disclosed regulated data out of a screened scope, or the screen that would have judged it could not run. See Screenings
8 and 9 are deliberately different. Refused means Fettler declined. Denied means the platform did, for an operation Fettler was willing to perform. Collapsing them sends a caller off to debug a boundary that is working.

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.

Where to say it so it is read

Ranked by how much each changes behaviour.

RankWhereWhy
1The MCP instructions string and the per-verb tool descriptionsRead at the moment a tool is chosen, ships with the tool, needs setup by nobody, reaches every client at once. Ours to write.
2The SessionStart hookAutomatic, and costs one line. Written by setup guardrails with --hooks.
3Global CLAUDE.mdWhere standing rules already live and demonstrably work.
4Repo CLAUDE.md and the grounding pageProject scope, and the canonical long form.
5permissions.denyEnforcement 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.

WhereTools paragraphGuardrails paragraph
The MCP instructionsalwaysone sentence, saying on or off for this launch
The instruction block in CLAUDE.md or copilot-instructions.mdin every blockin 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.

Instruction is the weak lever. Ranks 1 to 4 are worth doing, and none is sufficient. Rank 5 is the only one that does not depend on anything being read. What works best is removing the need. Fettler wants no working directory, because every path names its tree. And everything else that wanted one (a build, git, a script) has a form that names its location instead: git -C, an absolute path, fettle run.

Everything doctor can report, and how to clear it

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.

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

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 saysWhat it meansHow to clear it
on PATH: noNothing 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".
absentNo 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.
healthyRegistered, and the command launches.Nothing.
n/aThe 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.

The findings

LevelCheckWhat it foundHow to clear it
guardrails2.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.
guardrails2.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.
guardrails2.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.
guardrails2.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.
guardrails2.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.
tools2.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.
guardrails2.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.
guardrails2.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.
retired2.15No 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.
guardrails2.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.
guardrails2.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.
guardrails2.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.
guardrails2.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.
guardrails2.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.
guardrailsB.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.

What this defends against, and what it does not

The threat is inadvertent assistant action. It is not a determined person. Whoever runs Fettler can open a terminal and do anything they like. Nothing here tries to stop them, and a design that did would be both impossible and insulting. The measure of success is that a careless assistant cannot widen its own boundary, not that the boundary cannot be widened.

That bound matters, because it says how far each rule has to go.

This is stated because the alternative is scope creep. Every rule here could be hardened indefinitely against an attacker nobody is worried about.

Routes this tool does not close

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.

That is the whole of what Fettler is. The quick start puts it on a machine. Tools is what works with no file at all. Guardrails declares trees, scopes and tasks. Screenings turns on the disclosure screen.