Managed Permissions

FlyDocs keeps a small, owned region of .claude/settings.json in step with the tool surface: the standing rules, the agent default mode, and whatever your organisation adds on top.

FlyDocs keeps a small, owned region of .claude/settings.json in step with the tool surface: the standing rules that let an agent call FlyDocs tools without an approval prompt, plus whatever your organisation adds on top. This guide is for whoever sets that policy — what the modes do, what each field controls, what FlyDocs claims ownership of, and how a v1 install is upgraded.

For what a developer sees during setup, see Setting up the FlyDocs tool surface.

What is managed

Two rules, regardless of how many skills, scripts or repos exist:

{
  "permissions": {
    "allow": ["mcp__flydocs__*", "Bash(flydocs run:*)"]
  }
}

That is the entire default surface. It is two rules rather than dozens because both sides of the tool surface are addressed by identifier — an MCP tool name and an operation ID — instead of by file path. A rule that names a path has to change whenever the layout does; these do not.

Everything else in the file belongs to you, and FlyDocs leaves it byte for byte as it found it.


The ownership record

Alongside permissions, the writer maintains a top-level key:

{
  "flydocsManaged": {
    "version": 2,
    "mode": "append",
    "allow": ["mcp__flydocs__*", "Bash(flydocs run:*)"],
    "deny": []
  }
}

This record is the boundary of the managed region. It exists because ownership has to be data:

  • Claude Code rejects a settings file containing // comments as malformed — and then silently ignores the file. A comment-delimited block would have quietly disabled your entire settings file, hooks included, with no error anywhere.
  • Sentinel rule strings fail differently: entries in permissions.allow are parsed as permission rules, and an invalid one is dropped with a warning. A marker the client is entitled to delete is not a marker.

The record is also what makes removal possible at all. Without it, rules could only ever be added — a merge can union two lists, but it cannot know which of the resulting entries were once FlyDocs' and are now obsolete.

It deliberately carries no timestamp. A timestamp would make every sync produce a diff, which is the opposite of the idempotence the writer promises.


Modes

Set through the portal; delivered to each repo in .flydocs/config.json under permissionPolicy, alongside the other server-driven blocks.

Mode Writes standing rules + extraAllow / deny Retires rules Removes drifted managed rules
off no no no
append yes yes no
managed yes yes yes

Both writing modes deliver the whole policy — the two standing rules plus the org's extraAllow and deny. The mode controls ownership strictness: append only ever adds and never removes a rule on the strength of the ownership record, so a rule that later leaves the policy stays in the file until someone retires it. managed additionally removes drifted managed rules — entries the record says FlyDocs wrote but the policy no longer wants. Rules named in retire are withdrawn in both writing modes.

append is the default, and is what a CLI too old to understand the field does anyway. An unrecognised mode value falls back to append rather than to off: off looks like the cautious choice and is the worse one, because the standing rules are exactly what stops the agent asking for approval on every FlyDocs operation. Silently not writing them degrades into permission churn that nobody traces back to a typo in a config field.

off writes nothing and removes nothing. It is a full opt-out, not a "remove FlyDocs' rules" switch — an org that turns the policy off keeps whatever is already in its settings files.


Policy fields

{
  "permissionPolicy": {
    "mode": "managed",
    "target": "project",
    "defaultMode": "acceptEdits",
    "extraAllow": ["Bash(pnpm test:*)"],
    "deny": ["Bash(git push --force:*)"],
    "retire": ["Bash(python3 tools/legacy.py:*)"],
  },
}
Field Type Effect
mode off | append | managed see above
target project | userGlobal <repo>/.claude/settings.json, or ~/.claude/settings.json
defaultMode acceptEdits | default see The agent default mode
extraAllow string[] org allow rules, appended to the two standing ones
deny string[] org deny rules, written to permissions.deny
retire string[] rules removed from the file, whoever wrote them

Entries that are not usable rule strings — non-strings, empty strings, anything containing a newline — are dropped, with a warning naming the field and the count. Duplicates are collapsed.

target: userGlobal puts the block in the user's home settings instead of the repo's, which is the right choice for an org that wants one grant per machine rather than one per checkout. It applies to the whole block; there is no per-rule targeting.

retire and what it may remove

retire is the escape hatch for rules that are no longer wanted — including rules FlyDocs itself once shipped. The CLI seeds it with the seven path-shaped rules the template used to carry (Bash(python3 .claude/skills/…/*.py:*) and its variants); your list is merged with that seed.

Three properties keep it safe:

  • Exact string match only. A rule is removed if it is character-for- character one of the retired strings. A similar rule you wrote is untouched. A glob would have to guess.
  • deny is not seeded from the CLI's list. Those strings shipped as allow rules; the same string sitting in deny is something a human typed on purpose, and withdrawing a grant is not licence to withdraw a prohibition. Your own retire entries do apply to both lists — an org that names a rule is naming it wherever it appears.
  • Retiring a permission is not removing a capability. Withdrawing Bash(python3 …/issues.py:*) does not stop the dispatcher scripts working; it withdraws a standing grant that Bash(flydocs run:*) now covers. In Claude Code, the auto-approve hook approves dispatcher commands on its own, so the fallback path keeps running unprompted.

The agent default mode

Claude Code's permissions.defaultMode decides what happens when an agent wants to do something no rule covers. FlyDocs distributes it as part of the same policy, with two values and no others:

Value What the machine does
acceptEdits File edits by agents are auto-accepted. Shell commands and every other tool still follow the permission allowlist.
default Claude Code prompts on edits. Written into the settings file, and claimed, exactly like acceptEdits.
unset Nothing is distributed, and a value FlyDocs previously wrote is withdrawn. Each machine's own default applies.

Nothing is distributed unless a workspace names a value. A workspace that has never set the field distributes nothing, and Claude Code's own default applies on every machine.

That reverses what FLY-1357 originally shipped, which was the opposite: acceptEdits for every workspace including those that predated the field, so that "agent work stops stalling on edit prompts without an admin having to find a setting first". Two facts made that harmful:

  • Claude Code resolves project settings over user settings. On Pro, Max and Team the built-in starting mode is auto. A defaultMode distributed into each repo's .claude/settings.json therefore pins every developer below the mode their own settings ask for — the convergence took capability away rather than adding it.
  • .claude/ is a protected path. Writes to it are never auto-approved in acceptEdits, and no allow rule or PreToolUse hook can suppress that prompt. The prompts the feature existed to remove were the ones it could not remove.

So the field is now opt-in. Set it to acceptEdits in portal settings → Permissions if you mean it; leave it unset otherwise. Clearing it withdraws the key and FlyDocs' record of it on the next flydocs update, and the CLI says which value went:

Updated FlyDocs permission block in … — retired distributed defaultMode
"acceptEdits" — Claude Code's default now applies

default is not the off switch. It used to be: FLY-1357 treated it as "withdraw", reasoning that default and no key at all look the same to the agent. The same finding that reversed the convergence falsifies that too — a paid plan starts in auto, and a project-level defaultMode outranks it, so default in the file forces prompting where absence would not. It is a stricter posture, and an org that wants it gets it written and claimed like any other value. Clearing the field is the off switch.

Retiring never deletes a value FlyDocs did not distribute. If the key has been edited by hand since FlyDocs wrote it, the withdrawal takes back only the ownership record and leaves the line:

Updated FlyDocs permission block in … — withdrew the FlyDocs defaultMode record
and left your permissions.defaultMode "plan" in place (FlyDocs had distributed
"acceptEdits")

While a workspace is mid-upgrade

The change lives in the CLI, so a workspace is briefly of two minds:

  • A machine on an older CLI keeps converging on acceptEdits for a workspace that has never named a value, and will keep rewriting the key on every sync until that machine updates. The two CLIs will fight over the file in exactly that window — the newer one retiring, the older one restoring.
  • A repo that already carries a distributed value keeps it until an updated CLI runs there. Nothing reaches out to clean it up; the retirement happens on the next flydocs update or flydocs doctor --tools --fix from a CLI that knows about it.
  • A workspace that chooses default sees the same fight from the other side. To an older CLI default is the opt-out, so it withdraws the key on every sync; a newer one writes it, because default now distributes. The file flips back and forth until every machine has updated. Nothing is lost either way — the ownership record is respected by both — but the setting does not hold until the last CLI is current.

acceptEdits is the one value both CLI generations agree about, so an org that wants the previous behaviour on purpose can set it now and be understood everywhere.

Claude Code itself accepts six values — acceptEdits, auto, bypassPermissions, default, dontAsk, plan (verified against 2.1.240, which validates the key and names the set). FlyDocs distributes two. The rest are refused by the server's validator and again by the CLI's reader, so a hand-edited config cannot widen what gets written. They waive the allowlist rather than one class of decision inside it, and an org-wide switch is the last place that call should be made. Anyone who wants one can still set it in their own settings.local.json, which FlyDocs never touches.

auto is refused for a second, separate reason. Claude Code honours it only from ~/.claude/settings.json or --permission-mode auto. An auto in a project-level settings file is ignored, and it suppresses the user-level default — so writing it would leave every session below auto rather than at it. The CLI refuses it with that reason by name, treats it as unset, and never writes it. flydocs doctor reports an auto it finds in any project-level settings file (.claude/settings.json or .claude/settings.local.json) as its own check, permissions.auto-noop, whoever wrote it — a separate check rather than a state of permissions.default-mode, so that reporting it never interferes with repairing the mode itself.

Its own ownership record

The value is claimed by a second top-level key, separate from flydocsManaged:

{
  "permissions": { "defaultMode": "acceptEdits" },
  "flydocsManagedDefaultMode": {
    "version": 1,
    "workspaceId": "ws_…",
    "value": "acceptEdits"
  }
}

Separate, because flydocsManaged is rewritten wholesale on every sync by whichever CLI runs. A version that predates this feature would drop the claim while leaving the value behind, and the next modern CLI would then read its own value as a user's and refuse to touch it. A second key is invisible to every older CLI: they neither write it nor delete it.

The record carries the workspace id, so two workspaces sharing one ~/.claude/settings.json cannot rewrite each other's value on every sync. A record with no id predates the stamp and reads as ours.

What it will not do

  • Overwrite a defaultMode it did not write. If the key is in the file and no record claims it, FlyDocs writes nothing and flydocs doctor reports permissions.default-mode as a conflict, naming the value. That holds even when the value is the one we would have written — a value someone set by hand is theirs, and adopting it would mean deleting it the day the workspace opts out. Remove the line yourself to hand the key over.
  • Remove a defaultMode it did not write. Turning the workspace off withdraws only what the record claims.
  • Write anything when mode is off. off means FlyDocs does not write to that file, and that includes this key.
  • Decide anything without a policy to read. When no config resolves — a directory FlyDocs is not installed in, or a workspace root none of whose children is installed yet — it neither writes nor withdraws. A multi-repo workspace root with an installed child is not that case: it reads the workspace's policy through the child (see At a multi-repo workspace root).

off freezes, clearing the field retires

The two off-switches do different things, and the difference is the same one FLY-927 established for the rules:

Setting Effect on a value already on disk
defaultMode cleared Withdrawn on the next sync, along with our record (FLY-1437).
defaultMode: default Replaced by default — it is a distributed value, not an opt-out (FLY-1437).
mode: off Left exactly where it is. Nothing is written or removed.

off is a full opt-out from the writer, not an instruction to strip what earlier runs wrote — the same reason it does not remove the standing rules. That means a workspace that switches from append to off leaves permissions.defaultMode in force on every machine that already has it, with nothing maintaining it.

That state is real, so flydocs doctor reports it: permissions.default-mode warns with state: stale and names the frozen value. It is not fixable — --fix runs the writer, and the writer does nothing at all in off mode. Remove the key and the flydocsManagedDefaultMode record by hand, or clear the workspace's defaultMode instead of turning the policy off — that retires it properly.

Changing it by hand

A permissions.defaultMode our record claims is ours, so a local edit to it is reverted on the next sync. The CLI says so rather than doing it quietly — the change line names the value it replaced:

Updated FlyDocs permission block in … — replacing hand-edited
permissions.defaultMode "plan" with workspace-distributed "acceptEdits"

flydocs doctor reports the same thing before it happens: pending, naming the value in the file rather than claiming the file "does not carry it".

To hold a different mode on one machine, put it in .claude/settings.local.json. FlyDocs never touches that file.


The v1 → v2 upgrade

v1 was the comment-delimited form:

"allow": [
  // ── BEGIN flydocs-managed v1 ──
  "mcp__flydocs__*",
  // ── END flydocs-managed v1 ──
]

When the writer finds it, three things happen in one atomic write: the marker comments are deleted (repairing a file Claude Code was refusing to load), the rules between them are adopted as managed, and the v2 flydocsManaged record is written.

The upgrade is content-addressed, not version-stamped — any file containing those markers is upgraded, whoever wrote it, including a settings file where someone pasted the block in by hand.

Going the other way: if a file carries a newer block version than the CLI knows about, the CLI says so and reconciles anyway. The rules are still just rules; only the record is rewritten in the older shape.


When the block is written

Command Writes the block
flydocs init yes, after the template settings merge (FLY-1311); at a workspace root as well
flydocs update yes, on both the cloud and local tiers; at a workspace root, after the children
flydocs sync (deprecated) yes; at a workspace root, after the children
flydocs doctor --tools --fix yes, by running the same writer

The write is idempotent by construction: the plan is computed from the file's own bytes, so a run that changes nothing writes nothing — no backup, no rename, no mtime churn for a file watcher to react to.

At a multi-repo workspace root

The workspace root gets a block of its own, in its own .claude/settings.json (or ~/.claude/settings.json when the target is userGlobal) — the same rules, org additions, MCP server approval and default mode a child gets (FLY-1619).

  • Why it needs one. Agents run at the root: it is where the editor is opened and where the shared .claude/ tree lives. Without the block, flydocs run and the flydocs MCP tools prompt there even though every child pre-approves them.
  • Where the policy comes from. The root has no .flydocs/config.json — config lives in each child. The policy is workspace-scoped, though: the server composes one permissionPolicy per workspace, and every child's config carries the same copy, stamped with the same workspaceId. So the root reads it through a child — the first installed child by name whose config names the root's workspace. That is the child flydocs doctor reads at a root, so the check and the write agree.
  • On init. No child is installed yet when the root is written, so init uses the served config/generate payload every child is about to be written from.
  • On update and sync. The root is rewritten after the children, from a child whose sync succeeded in the same run. A child whose sync failed may still hold an older policy, so it is never read here, even when it sorts first. That is how a portal policy change reaches the root on the same run that brings it to the children.
  • When there is nothing to read — no installed child, or none that synced — the step is skipped silently. The workspace's policy is unknown then and may be off, so writing the default would be a guess, and a warning would name a file the root was never meant to have. A child config that does not parse is reported, naming that file.

Before any write, the previous file is copied to .claude/.flydocs-backup/, under a timestamped name. The directory is gitignored. A copy is only taken when a write actually happens, so it grows one file per real policy change and nothing else.


Inspecting and verifying

flydocs doctor --tools          # permissions.settings, .block, .default-mode
flydocs doctor --json           # all three, as structured data, for CI
flydocs doctor --tools --fix    # write the block, then re-check

permissions.block fails when the block is absent or out of date, and attaches a preview — the writer's own dry run, showing the exact lines that would move. Nothing is written to produce it.

permissions.default-mode reports the default mode separately, because the two fail independently and are fixed differently:

State Meaning Fixable
not-managed nothing is distributed; Claude Code's own default applies —
distributed the value is on disk and current — informational —
pending the value is missing, or ours was hand-edited here yes
stale the workspace distributes none; our value is still there yes
stale the key was edited since we wrote it; the record goes, retained value stays yes
stale mode: off froze a value an earlier run wrote (frozen) no
conflict a defaultMode FlyDocs did not write is in the file, and we wanted to write one no
unmanaged a developer-set defaultMode in project settings; we distribute none no
unknown no config resolves here, or the file does not parse —

not-managed is the resting state of a workspace that has not chosen, and it passes: distributing nothing is the correct answer, not a gap.

conflict and unmanaged are never fixable. Each would mean overwriting or deleting a value a human set, which is the one thing this writer exists not to do. The two fixable stale rows differ in what the fix does: the first takes our own value back and names it, the second withdraws only the record and says so, leaving the edited line where it is (data.retained, with data.claimed naming what our record had written — null when the record named no value).

permissions.auto-noop is a separate check, not a state here. It warns when any project-level settings file sets defaultMode: "auto", whoever wrote it, and it is never fixable. It has its own id precisely so that reporting something nobody can repair cannot sit on the id whose other states --fix must repair.

permissions.settings fails on its own when the file does not parse. That failure comes first and is not repairable by FlyDocs: a settings file with a syntax error is already disabling your hooks and permissions, and it is the only copy of whatever was being edited.


What is never touched

  • Any rule FlyDocs did not write and has not been asked to retire.
  • A permissions.defaultMode FlyDocs did not write — in either direction.
  • Any other key in the settings file — hooks, environment, model settings.
  • A settings file that does not parse.
  • The user's file when target is project, or the project's when it is userGlobal. One target, one file.

Next