Shoddy · Fettler

In a Project

Six steps, in the order they depend on each other. Declare the boundary, wire the client, prove the refusals.

This page assumes fettle is already on the machine and registered. That is installing it, done once. What follows is per project, and the numbering is not decoration: each step needs the one before it.

Write .fettler.json first, before you run anything. It is the file that declares your trees, and until it exists this project has no boundary. The server learns its trees at launch, from the .fettler.json in the folder it starts in — and declaring nothing is a refusal, not a fallback to the current directory. setup --local will create the file if none is there, and it is the only thing that does. But what it writes is the bare minimum, {"trees":{"work":{"path":"."}}}, and it never revises one afterwards. A scaffolded file nobody read is still the boundary you are running under. Write it yourself, and you know what it says. setup and doctor are the two verbs that run without one; everything else refuses until it exists.
# 1. write .fettler.json in an editor          <- your boundary; nothing below writes it
# 2. add .fettler.local.json for the scratch   <- gitignored, this machine's own
# 3. add "tasks" if anything needs running
fettle doctor                                  # what is wired, and what goes round it
fettle setup claude-code --local --dry-run     # what would change
fettle setup claude-code --local --hooks       # change it
fettle doctor                                  # confirm, and see what is left by hand

The last step matters. setup clears the findings whose fix is an addition. Every finding whose fix is a removal — 2.6, 2.7, 2.11, 2.14, B.17 — is still there afterwards, and still yours to do.

1. Declare your trees

A tree is a path and a set of permissions. More than one may be declared. A folder of code, a sibling folder, and a scratch directory is the ordinary case, not the exotic one. A file at the top of the project says it once, so nobody types a boundary onto every command.

Put it at the top of the project, named .fettler.json, and check it in. The smallest useful one is a single tree:

{
  "trees": {
    "work": {
      "path": ".",
      "can": ["list", "read", "create", "update", "rename", "delete"]
    }
  }
}

A fuller one, with a second tree kept read-only and a folder inside it hidden altogether:

{
  "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": [] }
      }
    }
  }
}

Check what you wrote by asking for it back:

$ 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

The personal overlay

A gitignored .fettler.local.json beside it is merged over the top. Your machine's own trees need not be typed on every command, and need not be inflicted on everybody else's checkout.

A name in both files means the overlay's version wins entirely — not field by field. Nobody reading two files side by side could say what a field-level merge produced without running it.

2. Declare the scratch tree

Do this before the deny list lands, not after. An assistant stages work — a long replacement, a generated file, a diff it is about to apply — in a scratch directory the harness gives it, well outside your source tree. Deny the built-in Write and that route shuts. The scratch is outside every declared tree, so Fettler will not write there either. Both routes closed — and it surfaces at the first staging write, not while you are running setup.

It goes in the gitignored overlay, because the path is this machine's and nobody else's:

// .fettler.local.json  - beside .fettler.json, gitignored
{
  "trees": {
    "scratch": {
      "path": "C:/Users/you/AppData/Local/Temp/claude/<project-folder>",
      "can": ["list", "read", "create", "update", "rename", "delete"]
    }
  }
}

Why setup cannot do steps 1 and 2 for you

.fettler.json and .fettler.local.json are the files that say what Fettler may do, and Fettler refuses to write those at every permission level. setup is bound by the same rule. If it could declare a tree, it could grant itself any tree, and the refusal that makes every other rule hold would be decoration. It may create a starter .fettler.json where none exists; it never changes one. So it names the fix, and a person applies it — the same shape as findings 2.6, 2.7, 2.11 and B.17, which setup also reports and does not clear.

3. Declare your tasks

Only if something in this project needs running. run executes only what the configuration declares, and never composes a shell command. It also needs the tree to grant execute, which is never a default:

{
  "trees": {
    "work": { "path": ".", "can": ["list", "read", "create", "update", "rename", "delete", "execute"] }
  },
  "replacements": {
    "feature-branch": "hebdenbridge"
  },
  "tasks": {
    "build":   { "run": "pwsh -File shoddy-build.ps1" },
    "gate":    { "run": "node scripts/gate/driver.mjs gate --resume", "cwd": "." },
    "sign":    { "run": "signtool sign /f \"C:\\keys\\my cert.pfx\" bin/app.exe" },
    "feature": { "run": "pwsh -File scripts/shoddy-feature.ps1 new {feature-branch}" }
  }
}
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.

3a. Screen what leaves a tree, if it holds regulated data

Skip this unless a tree holds health, consumer-report or education records. It is off everywhere until a tree asks for it, and asking is one line in the file you have just written:

{
  "trees": {
    "records": {
      "path": "../records",
      "can": ["list", "read"],
      "screen": ["identifiers"]
    }
  }
}

identifiers is the tier that works with nothing installed — identifiers with a fixed shape or an explicit label. The other three categories, clinical, legal and scientific, screen nothing at all until you install a model, and fettle roots says so in as many words rather than letting the name imply otherwise.

It is a safety net and not a boundary. A clean verdict is evidence of absence, never a certificate of it. Screening is the whole procedure — what each category covers, how to prove a refusal actually refuses, and what it deliberately does not do. The models is the other half, for the three categories that need one.

4. Run fettle setup

fettle setup <client> | --all [--global | --local] [--dry-run] [--force] [--hooks] [--deny] [--command PATH]

  clients: claude-code · claude-desktop · vscode-copilot · github-copilot

It is a command rather than a .ps1 and a .sh, so there is one implementation instead of two that must be kept in step by hand.

Every flag, and what it decides

FlagWhat it does
<client>One of claude-code, claude-desktop, vscode-copilot, github-copilot.
--allEvery client this machine has. Ones that are not installed are skipped rather than created.
--localThe repository level: .mcp.json, .vscode/mcp.json, .claude/settings.json. Reviewable in a diff, affects this tree only. The deny list is written here by default — asking for setup in a project is asking for Fettler to be the route in it.
--globalThe machine level, covered on installing it. The registration is written; the deny list is only offered, and needs --deny as well.
--denyWrite the deny list at the global level too. It is a separate flag because changing how the assistant may work in every project is not a side effect of configuring one.
--hooksAdd the SessionStart hook that runs doctor. Claude Code only, opt-in, and added once however often you re-run. It is independent of whether the deny list needed changing, so asking for it on a project that is already denied still installs it. What it writes.
--command PATHRegister an absolute path instead of a bare fettle. This is the answer when the binary is deliberately not on PATH.
--dry-runPrint the identical report and write nothing. Run this first.
--forceOverwrite an existing fettler entry rather than leaving it alone. Needed when a registration is broken and has to be replaced.

It merges into JSON and never clobbers. These files hold other people's servers, other people's permission grants, and a hook array that is already doing something. Setup reads the file, adds or updates Fettler's own key, and preserves everything else. Before its first write it makes a backup at <file>.fettler-backup and says where that went. --dry-run prints what it would do and touches nothing.

What it will and will not correct

Re-running changes nothing the second time — for the JSON and for the marker-delimited instruction block alike. A registration that already says something different needs --force, so setup never quietly reshapes somebody else's choice.

5. Run fettle doctor

Two jobs in one verb: is Fettler wired into each client, and is anything on this machine letting the assistant go round it.

fettle doctor [--json] [--quiet] [--hook] [--client NAME]
$ fettle doctor
fettle 1.0.0  C:\tools\fettle\fettle.exe
on PATH: no - every registration naming a bare 'fettle' will fail to launch
tool inventory drawn 2026-08-20; check 2.8 says nothing about a tool added to the client since

claude-code     global  absent    no server called 'fettler' is registered here
                        C:\Users\you\.claude.json
claude-code     repo    broken    the registration launches 'fettle', which resolves to
                                  nothing on this machine - and fettle is not on PATH at all
                        C:\work\tree\.mcp.json
claude-desktop  repo    n/a       this client has no project scope, so there is nothing
                                  to configure here

what could let the assistant go round the boundary:
  ! 2.7  the built-in tools Fettler replaces are explicitly allowed: Read(//c/work/**).
         Read in particular defeats containment, the read permission and hidden scopes at once
      C:\Users\you\.claude\settings.json
  - 2.8  nothing denies the built-in Read, Write, Edit, NotebookEdit, Grep, Glob, Bash,
         PowerShell, Monitor, BashOutput, TaskOutput, so Fettler is an option rather than
         the route; `fettle setup claude-code --local` writes the denies
      C:\work\tree\.claude\settings.local.json
  - 2.17 1 other MCP server is registered here and the deny list cannot narrow it: its
         tools are not called Read, so nothing setup writes says anything about what they
         may reach. It is: sparky. Deny mcp__<name>__* for any that should not be a route
      C:\Users\you\.claude.json

The 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 — ! is serious, - is worth knowing. Everything doctor can report, and how to clear it gives a row per check: what it found, and the edit that clears it. setup adds and never deletes, so any finding whose fix is "remove" is yours to make in an editor.

The four verdicts

VerdictMeansExit
n/aThe client is not installed, or has nothing at this level. Claude Desktop has no project scope at all, so its repo level is always this.never a failure
absentThe client is here; Fettler is not registered with it.warning
brokenRegistered and wrong: the command resolves to nothing, the configuration will not parse, a tree does not exist.failure
healthyRegistered, and the command it names actually resolves.

6. Verify the guardrails

The wiring being right and the boundary holding are two different claims, and only the second one matters. Ask the assistant to do each of these, and watch what comes back.

Ask the assistant to…What a correct setup does
list the tools it hasfettler appears, and doctor is among the verbs
ask for the rootsthe trees come back with what may be done in each, not only where they are — and they are the trees you declared in step 1
read a fileit uses the Fettler tool, and the answer carries an encoding, a line ending and a hash
read /etc/passwd or C:\Windows\win.inirefused as outside every declared tree, exit 4 — and refused identically whether or not the file is there
write .fettler.jsonrefused as governed, exit 11, whatever the tree grants
write .claude/settings.jsonrefused as governed too — a model does not edit the file naming the tools it may call
write a file containing an AWS key or a PEM private-key headerrefused as credential, exit 12, naming the line and the detector but never the secret
run a task in a tree with no executerefused, exit 8, naming the grant that is missing
edit a file with the built-in editorrefused by the deny list, and it reaches for Fettler instead
stage something in its scratch directoryit writes — because step 2 declared that tree. If this one fails, that is the step that was skipped
A changed .fettler.json needs the server restarted. The MCP server learns its trees at launch and does not re-read them, so a tree you added mid-session is not there yet. This is the same property that stops a model widening its own boundary, met from the other side.