Shoddy · Fettler
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.
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 want | How 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.
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:
cancelled sits inside backlog, so
cancelled wins there.create and
nothing else grants create and nothing else — not
even read.Paths resolve against the file, never against the caller. Read against a current directory, a relative path names a different tree from every folder you run it in. Read against the file's own directory, it names one tree from everywhere. That is what lets the file be checked in.
A tree, not a repository. A folder with no version control in
it is the ordinary case, not an exception. Nothing in the configuration,
the default names, or this page assumes a checkout, a remote, or a
.git.
A malformed configuration is refused, never fallen back from. A silent fallback usually widens the boundary.
| Verb | Grants |
|---|---|
list | appears in find and search. Absent means hidden — the path does not exist as far as anything can tell. |
read | content may be read |
create | a file or directory that is not there may be made |
update | an existing file's content may be changed |
rename | may be renamed, or moved within this scope or into a descendant of it |
delete | may be removed — and moved out of this scope, which from here is the same thing |
execute | a declared task may run with this as its working directory |
Move is two permissions, not one. Moving into a subfolder
needs rename on the source and create at the
destination. Moving out of a scope needs delete as
well, because a file that leaves is gone from here either way. That is
what makes "may reorganise, may not destroy" possible to say at all.
A move that replaces something needs delete at
the destination too, whatever the verb was called. Replacing a file
destroys it.
A recursive delete and a recursive copy reach paths nobody wrote down. That is how a protected scope gets taken anyway. So both are refused when any scope inside the target does not grant what the operation needs. The refusal names no path — saying where would tell a caller where a hidden scope is. That costs one bit of information. It buys a destructive operation refused out loud, instead of half-done in silence.
| Situation | Default |
|---|---|
| The tree the configuration file sits in | list read create update rename delete |
| Every other tree | list read — strictly read-only, overridable only in a configuration file |
execute, anywhere | never granted by default, in any tree, including the one the file sits in |
| No tree declared at all | refuse, 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.
This is the load-bearing rule, and it is the answer to
--root.
| Source | May grant |
|---|---|
A .fettler.json in the tree | anything. Writing one is a deliberate, reviewable act by a person, inside the tree it governs. |
--config PATH | anything, because naming a file is the same deliberate act. |
--root PATH on a command line | list 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.
The configuration file names the trees, what may be done in each, and the commands Fettler may run. Before this rule existed, Fettler could write that file and then act on what it had just written. Declaring a task and running it took two commands. Two things follow.
execute must not be a default, even on the tree the
configuration sits in. That tree is writable by definition, and the
configuration lives inside it. Write-by-default plus execute-by-default
is arbitrary code execution — the tool could be walked into
running anything. And it defeats every other permission at once, because
a scope protected from delete means nothing to a task that
can run del.
An explicit grant is not enough on its own, either. If the
declaration can be written by the thing the grant applies to, the grant
has no bound. Whoever holds execute also chooses what
executes.
Therefore, compiled in and not configurable at any permission level:
| File | Rule |
|---|---|
.fettler.json | never created, written, edited, renamed, moved or deleted through Fettler |
.fettler.local.json | as above |
.mcp.json | as above — it names the servers a client launches |
.claude/settings.json | as above — it names the tools a model may call at all |
.claude/settings.local.json | as above |
.vscode/mcp.json | as above |
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.
Worth stating plainly, because it is the whole point.
in, which selects among
trees already open.--root cannot grant write at all, so even a
careless launch cannot hand write access to anybody.So the answer to why does an MCP server need a root is that
it does not need --root. It needs a boundary, from a
source the model cannot write. A file in the tree is that
source.
A scope with no list is absent from find
and search, and absent from their counts and totals. A
direct path into it is refused in the same words as a path
outside every tree — including a path to a file that was never
there. The difference between hidden and absent cannot be probed.
This is not encryption, and it does not pretend to be. Somebody at a terminal can open the folder. What it stops is an assistant finding cancelled requirements by searching, and reading them back as current.
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.
| Verb | What it answers |
|---|---|
find | paths matching a glob, with size and modified time |
search | matching lines as records — path, line, column, text — for several patterns at once. A hit inside a document also carries the page it sits on |
read | content, encoding, line ending, trailing-newline state and a hash, for several paths at once — and notebooks, PDFs, images and archives. --tail N for the end of a log; --member NAME for one entry inside an archive |
write | the file created or replaced |
edit | a batch applied, or the first edit that failed |
replace | one substitution everywhere it appears across a glob |
new / mkdir | an empty file; a directory |
move / copy / delete | the operation, and what guarantee it gave |
extract | an archive unpacked into a declared tree — or refused whole, with nothing written |
exec | the executable bit, set or cleared |
roots | the declared trees, their paths, what may be done in each, and which one an unqualified path lands in |
tasks / run | the declared tasks; one of them, with its streams and exit code |
batch | several operations sent as one |
doctor | whether this tool is wired into each client, and what goes round it |
setup | the wiring, written — the one verb an assistant is not offered |
serve | nothing — it becomes the MCP front end on stdio |
$ fettle find "src/**/*.cs" --sort mtime --limit 3
src/Fettler/Glob.cs 4812 2026-08-14T09:22:07Z
src/Fettler/PathRoot.cs 6104 2026-08-13T17:40:11Z
src/Fettler/Edits.cs 11233 2026-08-12T08:05:52Z
3 files (77 excluded: bin, obj, .git - pass --include-generated)$ fettle roots
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# scripts/list-types.ps1
$out = fettle search "^public sealed" --glob "src/**/*.cs" --json
if ($LASTEXITCODE -ne 0) { throw "search failed: $out" }
$out# scripts/list-types.sh
out=$(fettle search '^public sealed' --glob 'src/**/*.cs' --json) || {
printf '%s\n' "$out" >&2; exit 1;
}
printf '%s\n' "$out"In machine-readable mode the complete result — failures
included — is on stdout, the normal output stream. That
matters on Windows. PowerShell 5.1 turns a native program's redirected
error output into NativeCommandError records, and sets
$? false even on exit code 0. Any design that needs
2>&1 to retrieve an answer is broken on the primary
development platform.
On a machine where the assistant's own reader has been denied, Fettler is the only reader. A type it cannot open is a file nobody can open.
| Type | How it comes back |
|---|---|
| Text, in any encoding a byte-order mark names, plus UTF-8 | lines, with the encoding, the line ending, whether the endings are mixed, and a hash |
| CSV | as text — so a spreadsheet exported as CSV needs nothing special |
Notebooks (.ipynb) | cells, sources and outputs, rendered — not the JSON they are stored in |
| text, page by page, each page announced so a citation can name one | |
Excel (.xlsx, .xlsm) | a row to a line, tab separated, one landmark per sheet; a cell holding a formula shows the formula with its last computed value beside it; dates come back as dates |
Word (.docx, .docm) | paragraphs and tables under their headings, and the headings are the landmarks — Word does not store where the pages break |
| Images (png, jpg, gif, webp) | mime type and dimensions from the header, without decoding a pixel; over MCP the bytes go as a native image part |
Archives (.zip, .tar, .tar.gz, .tgz) | the manifest — every member, its size, its date, and whether it carries the execute bit. --member NAME reads one entry out, decoded like any other file, without unpacking anything |
A lone .gz | whatever is underneath it — one file wearing a coat |
| Anything else binary | refused, saying so |
read stops at 2000 lines unless
--to says otherwise, and says how many are left.
find stops at a thousand, search at two
hundred.read stops at 40,000 characters, which
--to does not lift. Those two caps are in
different units on purpose. A line cap bounds how much of a file you
asked for; a character cap bounds how much of it survives the trip, and
lines vary in length by two orders of magnitude — so a 593-line
page, well under the line cap, was still large enough for a caller to
reject the answer whole. A rejected answer costs a turn and returns
nothing, which is worse than a short one, so the size is measured
here and the answer comes back short and says what is left. The budget
is spent across the whole call rather than per path, and at least one
line is always served — a minified file is a single enormous
line, and an empty answer is a dead end rather than a smaller
answer.--tail N is the end of the file, counted
backwards. Reaching the end of a log or a build transcript needs neither
its length nor any arithmetic. It cannot be combined with
--from/--to. The end of a file and a named
range are two different questions, and answering one of them by
precedence would be a confident wrong answer rather than an error.search is rendered and matched along with everything else,
and a hit inside one cites its page.
Searching inside one says what that costs.VLOOKUP is a search for how a sheet works and
would find nothing in the cached results, while a search for a customer
name would find nothing in the formulas. And dates are rendered as
dates — Excel stores the cell you see as 2026-03-15
as the number 46096, so rendering the number would leave a
workbook full of dates nobody can search for. That is the same
readable-but-unfindable fault that had search skipping
PDFs..xlsm and .docm are the same formats carrying
macros, read the same way, and nothing here executes anything.
.xlsb and .doc are not here: both are
binary formats wearing a familiar coat, and a reader that claimed them
would fail on every file.write,
edit and replace refuse a PDF, a workbook and a
Word document outright: what comes back for one is text pulled out of
it, with the sheets, the formulas, the tables and every piece of
structure left behind, so writing that text back would not edit the file
— it would replace it with a fraction of itself, still wearing the
name that says it is a document. An image, an archive and a lone
.gz are refused for the plainer reason that they are not
text at all. move, copy and
delete do not inspect content and are the way to shift one
of these; extract writes an archive member's own bytes and
is deliberately not caught.execute grant, so a loader running unnamed code in process
at full trust would be a hole straight through the boundary. A reader is
a class in this tree with tests beside it, never a file found at startup.
For a format Fettler does not ship, a declared converter task is the
honest form, and it already exists.Readable and unfindable is the trap this closes. A tool can
render a PDF for read and still let search try
to decode it as text, fail, and move on without counting it — 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.
--no-documents leaves them shut where the cost is
not worth paying. The count is still reported.extract ARCHIVE --into DIR writes; everything above only
reads. An archive is a list of paths somebody else chose. That is
exactly why the tool that already refuses a path outside a tree is the
right one to unpack with.
$ fettle read fettle-1.0.0-win-x64.zip
79605839 2026-08-16 16:08 - fettle.exe
2545 2026-08-16 14:09 - NOTICE
1133 2026-07-30 04:52 - LICENSE
3 members, 79609517 bytes uncompressed
$ fettle extract fettle-1.0.0-win-x64.zip --into tools
extracted 3 members (79609517 bytes) from work:fettle-1.0.0-win-x64.zip into work:toolsedit is all-or-nothing.../../etc/passwd so it lands outside the
unpack folder. Here that member resolves outside every tree and comes
back exit 4, exactly like a path somebody typed.out/../elsewhere.txt normalises to somewhere the boundary
is perfectly happy with. The tree check passes, and the file still lands
nowhere near where you said. Nothing is wrong with the path. What is
wrong is that it is not where you put it.fettle arriving out of a tar still able to run.--overwrite. An
existing file is a refusal before anything is written, not a surprise
halfway through.run are for. find and
search deliberately do not reach inside an archive either.
That would make members into paths, and then the boundary would have to
answer what a scope means inside a container. Read the manifest,
extract, search the tree — three honest steps beat one murky
one.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.
"one two" is a single argument.&&. The string is
split once, and the program is launched with the resulting list.run accepts a name and a timeout, and nothing else. An
extra word is refused, on the command line and over MCP alike,
rather than dropped. What runs is therefore exactly what the file says,
every time. A variant is a second declared task, or a value the file
declares — never something composed at the call.
$ fettle run build --release
refused: no such flag: --release - 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.
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}" }
}--message={note} and v{release-tag} are each
one argument.{ $_.Name }, {a:.b} and
{0} go on meaning what they meant. Write {{
for a literal brace where a name would otherwise be read.new {feature-branch} does not fail, it creates a branch
called {feature-branch}..fettler.local.json is gitignored and its replacements
merge over the checked-in ones name by name, so the project declares the
task while today's branch name stays out of the diff.tasks shows every
value, deliberately, so that what is listed is what runs. A token
belongs in the environment of the process a task launches.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.
$ fettle read ../secrets.txt
refused: the path is outside every declared tree; ask for the roots to see the boundary
$ echo $LASTEXITCODE
4Climbing 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.
$ 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
11This has its own outcome rather than a permission denial, because nothing is missing: no grant would have allowed it. Without the rule, the escape is two commands — write a task declaration, then run what you just wrote.
$ fettle write config.json --text-file staged.json --overwrite
refused: this write would add a credential to the file: line 14 (aws-access-key-id).
The secret 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
12It judges what the write ADDS, not what the file contains. A configuration that already holds a key stays editable. That is the whole difference between a rule people keep and a rule people switch off. Only a secret absent from the previous content stops anything, so reformatting, reordering, and editing the lines around one all still work.
The refusal never repeats what it found. A line number and a detector name, and nothing else. A message naming the secret would write it into the log, the transcript, and whatever ships those onward — 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.
${REFERENCE}.$ 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
13The 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.
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
8The tree holding the configuration is writable by definition. So
write-by-default plus execute-by-default would be arbitrary code
execution — and a scope protected from delete means
nothing to a task that can run del.
$ fettle run deploy
refused: no task called 'deploy'; .fettler.json declares: hello
$ echo $LASTEXITCODE
3$ 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 helpBoth 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.
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.
| Code | Outcome | Meaning |
|---|---|---|
| 0 | ok | it worked |
| 1 | — | an unexpected internal fault |
| 2 | invalid | the request did not make sense — an unknown flag, a missing value, a malformed configuration |
| 3 | not-found | the path, or the task, is not there |
| 4 | outside-root | the path is outside every declared tree |
| 5 | target-exists | the destination is occupied and no overwrite was asked for |
| 6 | stale | the file changed since it was read — the hash passed as --expect no longer matches |
| 7 | conflict | ambiguous or overlapping edits |
| 8 | refused | Fettler declined, for a stated reason |
| 9 | denied | the operating system declined — a sandbox, a restricted account, a read-only attribute, a file another process holds open |
| 10 | timed-out | a task outran its timeout and was killed |
| 11 | governed | the path names a file that says what this tool may do |
| 12 | credential | the write would have added a secret the file did not already carry |
| 13 | screened | the response would have disclosed regulated data out of a screened scope — or the screen that would have judged it could not run. See the disclosure screen |
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.
| Thing | What Fettler does |
|---|---|
| The executable bit | preserved by copy and move, reported by read and find, settable by exec. It is the one permission git tracks, because it is the only one that means the same thing on both platforms. |
| Attributes, alternate data streams, extended attributes | carried across a staged write, or the answer names what could not be. |
| The Windows read-only attribute | read and named in the refusal, never met as an obscure OS error. delete --force clears it explicitly. |
| Links and junctions | reported with their targets, never created — creating one on Windows needs Developer Mode or elevation. |
| Modified time | reported; preserved on copy only when asked. Both branches are explicit, because Windows carries the time across where POSIX cp gives a new one. |
| Everything else about permissions | nothing. POSIX modes and Windows ACLs do not map onto one another, and an abstraction over them would be a falsehood told in a table. |
--gob "*.cs" would eat the pattern, and the
search would then run against the whole tree with complete confidence. A
single dash is the same trap wearing one fewer character.
search PATTERN -i — the spelling every grep in the
world uses — would otherwise be read as a second pattern
and answer the union of two questions. -e is the only
short flag there is. A pattern or path that genuinely starts with a
hyphen goes after a bare --, or through
--pattern-file or --pattern-stdin.--NAME= for a
deliberately empty one.--pattern-file and --pattern-stdin take one
pattern per line; likewise --replace-file,
--with-file and --text-file. Text with quotes
and newlines never has to be escaped onto a command line.read
marks it (MIXED) beside the dominant ending. Nothing is
silently regularised.| Rule | What it is |
|---|---|
| Glob | a filename pattern: * stays within one path segment; ** matches any number of whole segments, including none; ? is one character and does not cross a separator. 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 matching | a dotfile and a Windows hidden file are ordinary files, matched normally and reported. |
| Regular expressions | .NET's dialect, which is neither PCRE nor ripgrep's, under a bound — non-backtracking where the pattern allows, an explicit timeout where it does not. |
| Encoding | a byte-order mark decides and is preserved; absent one, content that decodes as UTF-8 is UTF-8; anything else is binary and refused. No statistical guess, because a wrong guess corrupts a file on the way out. |
| Ordering | results are ordered before any limit, so a truncated result is the same truncation twice, and says it was truncated. |
| Bounds | every answer is bounded twice: 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 once | reads run concurrently; changes are serialized against each other; run holds neither lock. |
Relative, forward-slashed, no drive letter. There is no formal
standard for this. There is a convention, and it is git's. It is what
.gitignore, Docker, npm and VS Code's own task files use,
and it is the only spelling that means one thing on all three
platforms.
| Written as | What happens |
|---|---|
"path": "../shoddy-requirements" | correct, and means the same on every platform |
"path": "..\\shoddy-requirements" | refused. Windows accepts / in every API. But on macOS and Linux a backslash is an ordinary character in a name, not a separator — so this names a file with a backslash in it, and finds nothing |
"path": "C:/work/tree" | allowed; 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.
Ranked by how much each actually changes behaviour.
| Rank | Where | Why |
|---|---|---|
| 1 | The MCP instructions string and the per-verb tool descriptions | Read at the moment a tool is chosen, ships with the tool, needs setup by nobody, reaches every client at once. Ours to write. |
| 2 | The SessionStart hook | Automatic, and costs one line. |
| 3 | Global CLAUDE.md | Where standing rules already live and demonstrably work. |
| 4 | Repo CLAUDE.md and the grounding page | Project scope, and the canonical long form. |
| 5 | permissions.deny | Enforcement rather than instruction — the only one that does not rely on anything being read. |
git, a script — has a form that names its
location instead (git -C, an absolute path,
fettle run).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.
| What it says | What it means | How to clear it |
|---|---|---|
on PATH: no | Nothing on PATH resolves fettle, so every registration naming a bare fettle will fail to launch. | Put it on PATH and open a new terminal — or register an absolute path with fettle setup CLIENT --local --command "C:\tools\fettle\fettle.exe". |
absent | No 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. |
healthy | Registered, and the command launches. | Nothing. |
n/a | The 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. |
| Check | What it found | How 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 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; 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. |
That bound is load-bearing, because it says how far each rule has to go.
.fettler.json in a text editor.
That person is the author of the rules, not a threat to them.This is stated because the alternative is scope creep. Every rule here could be hardened indefinitely against an attacker nobody is worried about.