Shoddy · Fettler

Reference

The boundary in full, the seven permissions, every verb, every refusal, every rule a caller has to know, 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 boundary · The seven permissions · The defaults · Where a permission may come from · It never writes its own rules · Why an assistant cannot override it · The verbs · What it can read · Declared tasks · When it refuses · Exit codes · What it touches besides content · Arguments it will not guess at · The rules a caller has to know · Where to say it so it is read · Everything doctor can report · What this defends against

The boundary — what it can actually say

Nothing outside a declared tree exists. Not writable, not readable, not listable, not knowable. A path outside every tree is refused with exit 4 — and refused identically whether or not something is actually 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.

Inside a tree, everything that may be done is stated, never assumed. The first question worth settling 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"] — and 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 section 4 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. Here is a real configuration, shown to read rather than to copy — writing your own is the first step of setting up a project:

{
  "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 worth knowing 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 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 — 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
The tree the configuration file sits inlist read create update rename delete
Every other treelist read — strictly read-only, overridable only in a configuration file
execute, anywherenever granted by default, in any tree, including the one the file sits in
No tree declared at allrefuse, and say how to declare one. There is no implicit current directory — with the one exception below.

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.

The exception is setup and doctor, and it is exactly two verbs wide. Both configure a machine rather than work in a tree. Neither resolves a path through the boundary, and both reach only a fixed, compiled-in list of well-known configuration paths. With nothing declared, they fall back to the current directory — read-only. That cannot widen anything: read-only is all it grants, and no verb that touches a tree is reachable that way. Without the exception there was no way in at all. setup carries the scaffolder for .fettler.json, so the file it writes was the file required to run it. And doctor refused to diagnose the commonest broken state there is. A configuration that is present and malformed still refuses, for both verbs, because falling back from a broken boundary is how a boundary silently widens.

Why there is no implicit current directory otherwise. The obvious convenience — with no flag and no configuration, the folder you happen to be in becomes a tree — is exactly the "searching around" this boundary exists to stop, and it fails in the one direction a containment mistake must never fail in. A current directory usually sits above the tree somebody meant, so that fallback would widen the boundary silently, at exactly the moment nobody had stated one.

Where a permission may come from

This is the load-bearing rule, and it is the answer to --root.

SourceMay grant
A .fettler.json in the treeanything. Writing one is a deliberate, reviewable act by a person, inside the tree it governs.
--config PATHanything, because naming a file is the same deliberate act.
--root PATH on a command linelist read, and nothing else, with no override.

--root stays because the MCP server has to be launched somehow, and because ad-hoc reading is genuinely useful. It can no longer hand anybody write access. To write to a 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
.claude/settings.jsonas above — it names the tools a model may call at all
.claude/settings.local.jsonas above
.vscode/mcp.jsonas above

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

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 all of them — by construction, 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 simply not in its way.

The six names cover the trees and the tasks alike, since a task is declared by the same file that declares the trees.

The names are matched at any depth, so a configuration in a subdirectory is protected too. And 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 — these are the rules this tool works under, and it does not edit them — 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 may create a configuration where none exists. Changing one 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.

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.

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
writethe file created or replaced
edita batch applied, or the first edit that failed
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 declared trees, their paths, what may be done in each, and which one an unqualified path lands in
tasks / runthe declared tasks; one of them, with its streams and exit code
batchseveral operations sent as one
doctorwhether this tool is wired into each client, and what goes round it
setupthe wiring, written — 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
work          /work/shoddy  (default)
              can: list read create update rename delete
requirements  /work/shoddy-requirements
              can: list read
              backlog  can: list read create update rename
              backlog/cancelled  can: nothing

declared by /work/shoddy/.fettler.json

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 wearing a coat
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 — leaving a tree full of documents answering every search with a confident nothing. That 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 honest steps beat one murky one.

Declared tasks

run executes only what the configuration declares, and never composes a shell command. Declaring one is a step of setting up a project. 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 - try: fettle help
$ fettle run build release
refused: run takes a task name and nothing else; 'release' is extra. A task is
declared whole and takes no arguments - declare a second task for the second
thing you want run.

cwd — the folder the task runs in — is optional, 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:

"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 very 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

Real transcripts. Each prints its whole answer on stdout, the normal output stream, so no redirection is needed to read a failure. The full code table is below.

A path outside every declared tree

$ fettle read ../secrets.txt
refused: the path is outside every declared tree; ask for the roots to see the boundary
$ echo $LASTEXITCODE
4

Climbing out with .., an absolute path elsewhere, and a link leaving the tree all land here. 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 one of the files that tell this tool what it may do,
and it does not edit those. Change it with an editor.
The files are: .fettler.json, .fettler.local.json (.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 itself is deliberately not quoted back, because a refusal that named it
would put it in the log and the transcript. Use a reference such as ${ENV_VAR}
instead, or pass the override 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 — 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 — the same reasoning that keeps setup out of a model's hands.

This is a safety net, not a boundary, and the difference is not a quibble. 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. What was found is
deliberately not quoted back, because a refusal that named it would put it in the
log and the transcript, which is the disclosure being refused. Read a narrower
range, or take the screen off this scope if the content is not what it looks like.
$ echo $LASTEXITCODE
13

The mirror of the credential refusal above — that one is a write going in, this one a payload coming out. It only happens in a scope whose configuration asks for it. Section 14 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 grant execute.
Add "execute" to that tree's or scope's "can" in .fettler.json.
It is never granted by 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

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

$ fettle edit f.txt --insert-after 1 --text
refused: --text needs a value, and nothing followed it - write --NAME= for a deliberately empty one
$ fettle find --gob
refused: no such flag: --gob - try: fettle help

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.

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
1an 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 outran 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 the disclosure screen
8 and 9 are deliberately different. Refused means Fettler declined. Denied means the platform did, for an operation Fettler was perfectly 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. --hook always exits 0.

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. Case-insensitive on every platform, so a pattern behaves identically everywhere.
Exclusions.git, bin, obj, artifacts and node_modules, by name, with the count reported; --include-generated turns it off. .gitignore is not honoured — 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: by count — 2000 lines for read, 1000 files for find, 200 hits for search — and by 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; 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 a gitignored .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 and to a task's cwd.

Where to say it so it is read

Ranked by how much each actually 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.
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.
Instruction is the weak lever. Ranks 1–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 now that trees are declared. 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's report is two halves. The wiring is one row per client per level, ending in a verdict. The findings are numbered, and each one is a way round the boundary. A finding marked ! is serious; one marked - is worth knowing. Running it is step 5 of setting up a project.

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

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 CLIENT --local --command "C:\tools\fettle\fettle.exe".
absentNo server called fettler is registered in that file.fettle setup CLIENT --local (or --global). Run it with --dry-run first.
broken (2.12)A registration is there, but its command does not resolve — checked the way the client would check it, through PATH and PATHEXT.Fix PATH, or re-run setup 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 level — Claude Desktop has no project scope, so its repo row is always this.Nothing. Never a failure.

The findings

CheckWhat it foundHow to clear it
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 — the shell section explains why echo x > file defeats any list that could be written. The rule worth adopting: stop pre-approving shell commands that write.
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 CLIENT --local to add the matching deny. Leaving both is finding B.17.
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, so denying Bash and leaving it open denies the word and not the thing. BashOutput and TaskOutput are readers: they start nothing and write nothing, but they hand back the output of work already done, and those bytes reached the model without passing the tree boundary, the secret scan or the disclosure screen.fettle setup claude-code --local writes all eleven denies at the repo level by default. 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.
2.9 -An additionalDirectories entry naming somewhere outside every declared tree. When two boundaries disagree, the wider one is what applies, and the narrower one is decoration.Pick one. Either drop the entry from the client's settings, or add that directory to .fettler.json as a tree with the permissions you actually mean.
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. Guidance is the weak lever and the deny list is the strong one — but its absence guarantees habit wins. Where to say it has wording.
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 silently 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.
2.16 -A task fills a declared value into a string it hands to a shell to interpret — pwsh -Command, bash -c. Filling a value into a word makes it 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 — "stage": {"run": "git add -A"} and "commit": {"run": "git commit -m {commit-note}"} — or call a script with the value as a parameter. Either way the value stays an argument the whole way down and never meets a parser. Warned rather than refused: which flags of which programs mean "interpret this" is an allowlist of shells this tool does not keep.
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. setup never writes a personal settings file.
2.14 !chat.tools.autoApprove is on. It approves arbitrary tools without asking, and reinstates every route the boundary removes.Turn it off in the client's settings. Nothing else survives it being on.
2.15 -An assistant scratch directory outside every declared tree. Nothing here granted it — the harness did — so work staged there is invisible to this boundary. And once the built-in Write is denied, nothing can write there at all.Declare the per-project folder inside it as a tree in .fettler.local.json, which is gitignored. Declare the project folder, not its parent — the parent holds every project's scratch. See step 2.
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 says anything about what it may reach, and it reaches whatever its own process reaches.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>__*. Unlike 2.8 this reads the servers actually registered on the machine in front of it, so a server installed tomorrow is reported tomorrow.
2.18 -The configuration names a tool that is not in the inventory this fettle was built with. Check 2.8 therefore said nothing about it either way — not that it is safe. The client keeps growing new tools, and a closed list that has fallen behind reports exactly as clean as one that has not, which is why the inventory's date 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__…) are 2.17's business and are never reported here.
B.17 !A rule in both allow and deny. Which wins is the client's to decide, and cannot be determined from here. A narrowed shell allow beside the shell deny is exemptallow: 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; see deny versus allow.
2.x !A configuration 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 configuration that cannot be read is not treated as absent, because "absent" and "broken" want different answers.

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 is load-bearing, 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.

That is the whole of what Fettler is. Three commands put it on a machine and turn it on in a project: the quick start. The reference behind them is installing it for the per-machine wiring and the exit codes, and Fettler in a project for declaring trees, scopes and tasks.