Shoddy · Fettler
Two pictures — the disk a developer actually has, and the three boundaries that make it look that way.
This page states no rule of its own. It draws the whole arrangement twice. The first picture is the disk — the directories a developer really has, which of them the assistant may enter, and every way off the machine. The second is the mechanism that produces that result. One answers what can it reach; the other answers how is that held, and it is worth seeing the answer before the machinery.
No command produced this arrangement. Every line of it was
written by hand into two short files at the top of the project — a
checked-in .fettler.json and a gitignored
.fettler.local.json beside it. Both are printed in full
directly under the map, so every block can be read back to the line that
put it there. fettle setup claude-code --local writes the
client’s deny list and the registration and nothing else: it never
declares a tree, a scope or a task, and it could not have drawn any of
this.
The pages after this one build it piece by piece: the quick start turns it on, installing it is the machine-level reference, in a project is the reference behind the two files below, and screening is the one feature with a page of its own.
requirements\completed is invisible while its sibling is
writable. The files that define the boundary are sealed inside the very tree
that grants everything else. And the two exits open from one room only, because
that is the single place permitted to run anything at all. Then there is
Dropbox — a way off the machine that no task opens and nothing here
can close, which is why sealing that folder is not about privacy but about
shutting a route that would bypass every other line on the map.And here is what drew it. Two files, sitting at the top of the project. Read them beside the map: every lit block above is a line below, and every dark one is dark because no line mentions it.
// .fettler.json - checked in, travels with the project
{
"trees": {
"work": {
"path": ".",
"can": ["list", "read", "create", "update", "rename", "delete", "execute"]
}
},
"tasks": {
"build": { "run": "pwsh -File build.ps1 build", "cwd": "." },
"test": { "run": "pwsh -File build.ps1 test", "cwd": "." },
"feature": { "run": "pwsh -File scripts/shoddy-branch.ps1 feature hebdenbridge", "cwd": "." },
"ship": { "run": "pwsh -File scripts/shoddy-branch.ps1 ship -Yes", "cwd": "." }
}
}// .fettler.local.json - gitignored, this machine's own trees
{
"trees": {
"scratch": {
"path": "C:/Users/foster/AppData/Local/Temp/claude/c--github-shoddy",
"can": ["list", "read", "create", "update", "rename", "delete"]
},
"requirements": {
"path": "../shoddy-planning/requirements",
"can": [],
"scopes": {
"backlog": { "can": ["list", "read", "create", "update", "rename"] },
"inprogress": { "can": ["list", "read", "create", "update", "rename"] },
"cancelled": { "can": [] },
"completed": { "can": [] }
}
}
}
}Correlating the two — including the three things on the map that no line in either file could have put there:
| On the map | What says so |
|---|---|
c:\github\shoddy, and the two exits below it | the work tree — the only can list ending in execute, so the only room on the machine a task may run in |
github.com and nuget.org, and no other address | the four declared tasks: feature and ship run git, build and test restore packages. Nothing names a third host, so there is no third route |
| Scratch is writable, but nothing may be executed there | the scratch tree’s can, which stops one word short of execute |
shoddy-planning grants nothing at its root | "can": [] on the requirements tree |
backlog and inprogress writable, yet nothing there can be deleted | those two scopes’ can, which omits delete |
completed and cancelled sealed, and not even listed | "can": [] on each — a scope replaces what its tree grants rather than adding to it, so an empty one grants nothing at all |
shoddy-devilsdust, Documents, .ssh, AppData\Roaming, the whole of C:\ | no line at all. Out of bounds is the default; these two files are the list of exceptions to it, and whatever is absent stays dark |
The sealed pocket inside shoddy | no line, and no line could. The six governed names are refused by every write path at every permission level — the tool boundary lists them |
Dropbox syncing out to dropbox.com | neither file’s doing. That door belongs to another program, which is precisely why the folder it watches is one of the ones left dark |
And here is how it is held. The same arrangement again, drawn as the three boundaries that produce it: what the assistant is, the one process between it and the disk, and the filesystem as the declared trees present it.
Monitor
is a shell, running what it is handed in the same environment Bash
does, so denying Bash and leaving it open would deny 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. The server takes its
boundary from .fettler.json at launch, and refuses to write any file that
governs it or the model. And the filesystem exists only as the declared trees
— everything else answers exit 4, whether or not it is there. The diagram
fits the page; Full screen shows it at reading size.permissions.deny enforces.
The eleven built-in tools are denied by name, and the Fettler tools have
different names — denying Read does not deny
mcp__fettler__read. That routes the assistant rather than
disarming it. The exclusion
list is the reference.serve learns its trees from .fettler.json at
launch. No tool schema carries a root or config flag. And the six files
that govern the tool and the model alike are refused by every write
path — the rule that makes every
other rule hold.backlog may be reorganised but never destroyed, a hidden
scope no search can surface, and the scratch overlay. Outside them
there is nothing, and it is refused identically whether or not it
exists.Two levels of configuration reach the model, and the diagram shows both. The deny list — the settings entry that blocks the assistant's built-in file, shell and output tools — appears at each level.
C:\Users\you\.claude) —
the global CLAUDE.md carries the standing instruction, and
~/.claude.json registers the server for every project. The
machine-wide deny list is written only when asked for out loud
(--global --deny)..mcp.json launches the
server, and .claude/settings.json holds the deny list.
Both are governed: Fettler refuses to write them at every
permission level.echo x > file
turns any command into a writer.
The shell section has the
argument in full.The deny list fettle setup claude-code --local writes, verbatim:
{
"permissions": {
"deny": ["Read", "Write", "Edit", "NotebookEdit", "Grep", "Glob",
"Bash", "PowerShell", "Monitor",
"BashOutput", "TaskOutput"]
}
}The registration it writes, and nothing more:
{
"mcpServers": {
"fettler": { "command": "fettle", "args": ["serve"] }
}
}And the opt-in session hook
(--hooks), which runs doctor at the start of
each session — the moment a diagnostic will actually be read:
{
"hooks": {
"SessionStart": [
{ "hooks": [
{ "type": "command", "command": "\"C:\\tools\\fettle\\fettle.exe\" doctor --hook", "timeout": 10 }
] }
]
}
}The server learns its trees once, at launch, from files it can never write.
--root on a
command line can only ever grant list read..fettler.json,
.fettler.local.json, .mcp.json,
.claude/settings.json,
.claude/settings.local.json,
.vscode/mcp.json.setup is the one verb a model is never offered.
doctor is offered, because a model diagnosing its own
wiring beats asking somebody to open a terminal for it..fettler.json
in an ordinary editor. That is the design, not a hole.
What this defends against draws the
bound.The two files that say all of this are
printed under the map at the top of the page: the
checked-in .fettler.json naming the project tree and its
tasks, and the gitignored overlay adding this machine’s
scratch tree and its
scoped planning tree. Neither can
be written by the thing it governs — which is what a refusal looks
like when the boundary is asked to cross itself:
$ 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.
$ echo $LASTEXITCODE
11
$ fettle read ../secrets.txt
refused: the path is outside every declared tree; ask for the roots to
see the boundary
$ echo $LASTEXITCODE
4Seven permission words, combined per tree and per folder, say everything the diagram colours. The seven permissions defines each in full.
| Verb | Grants |
|---|---|
list | appears in find and search — absent means hidden, not merely unreadable |
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 | renamed or moved within this scope, or into a descendant |
delete | removed — and moved out of this scope, which from here is the same thing |
execute | a declared task may run here — never granted by default, anywhere |
Prove it, don’t trust it. The wiring being drawn correctly and the boundary holding are two different claims, and only the second one matters. Ask the assistant to try each of these, and watch what comes back. Verifying the guardrails is the same checklist where the setup steps live:
| Ask it to… | A correct setup… |
|---|---|
read C:\Windows\win.ini | refuses — outside every tree, exit 4, identically whether or not the file exists |
write .fettler.json | refuses — governed, exit 11, whatever the tree grants |
write .claude/settings.json | refuses — governed, exit 11: a model does not edit the file naming its tools |
| write a file holding an AWS key | refuses — credential, exit 12, naming the line but never the secret |
run a task with no execute grant | refuses — exit 8, naming the missing grant |
| edit with the built-in editor | is denied by the client — and reaches for Fettler instead |
| stage a file in its scratch directory | writes — because the overlay declared that tree; if this fails, that was the skipped step |