Permissions & guardrails
This is the builder-facing reference for permission evaluation, delegated authority, and custom policy integration. For operator choices, configuration, and posture selection, see Permissions and posture.
Mecatl gates tool execution with two independent layers. Layer 1 is a rule-based permission engine that fires on every tool call before execution and resolves to allow / ask / deny. Layer 2 is an optional model-backed guardrail checker that inspects tool input and output on operator-configured matchers — it inspects content, where Layer 1 inspects the call.
The layers are separate by design: Layer 1 decides whether a call is permitted to run at all; Layer 2 inspects the data crossing the tool boundary once a call is permitted. Configure them independently. Layer 1 is always on (it ships with a safe default ruleset); Layer 2 is off until you give it a checker model.
Delegated authority
Delegated children carry an authority set. Authority is neither an approval prompt nor an ownership check: it records which capabilities reached this run. Permission policy answers whether a call may run now; ownership answers who may access the session; authority answers whether the parent delegated the capability at all.
A root session starts with the tools in its composed catalog. A child receives only what survives this calculation:
parent authority
∩ child runtime posture
∩ managed specialist ceiling, when eligible
∩ optional call-level narrowing
− one delegation hop
For example, a root with Read, Grep, and Write can delegate a read-only
reviewer that receives only Read and Grep. That reviewer can use those tools,
but it cannot regain Write or delegate another child after its hop is spent.
The child's set persists with its session. On resume, Mecatl checks that its
persisted set still fits the current parent set; a child created before the parent
was narrowed is refused rather than regaining the removed capability.
Agent definitions and authority ceilings
A definition's tools: allowlist scopes the specialist engine in every source,
but only a definition loaded from an explicit operator directory establishes a
durable authority ceiling. Start mecated with --agents-dir to use that managed
tier:
mecated serve --agents-dir /etc/mecatl/agents
For example, /etc/mecatl/agents/code-reviewer.md can contain:
---
name: code-reviewer
description: Reviews source code without changing it.
tools: [Read, Grep]
disallowedTools: [Write, Shell]
---
Review the requested code and return findings with file and line references.
When a parent delegates to this specialist, tools: [Read, Grep] is a ceiling:
it can remove capabilities from the parent, but it can never grant either tool if
the parent did not already hold it. Definitions discovered from conventional
project or user locations, or from a driver, still scope their specialist engine,
but do not establish an independent authority ceiling. This prevents a project
repository or remote definition source from becoming a new authority grant.
Derivation happens before Mecatl creates the child engine, workspace, runner, or worktree. If the requested child would exceed the parent, the request is refused without acquiring those runtime resources.
Choosing an evaluator
mecated selects the authority evaluator at startup:
mecated serve --authority-evaluator=local
mecated serve --authority-evaluator=noop
mecated serve --authority-evaluator=cedar --cedar-authority-policy=/etc/mecatl/authority.cedar
| Evaluator | Use it when | Behavior |
|---|---|---|
local (default) | You want delegated authority enforced without an external policy language. | Allows only exact capabilities in the carried set. |
noop (explicit) | A local or demo deployment deliberately disables authority enforcement. | Accepts well-formed authority requests. It is never a fallback for a missing evaluator. |
cedar (opt-in) | You need operator-owned rules over an already-authorized capability, such as a workspace path boundary. | Checks the carried set first, then lets Cedar add a denial. It cannot grant an omitted capability. |
A bound session with no configured evaluator fails its tool call closed. That is
different from selecting noop deliberately.
Cedar policy boundaries
Cedar loads one static policy file at startup. A missing or invalid policy prevents mecated from starting; it never falls back to a less restrictive evaluator. Keep the policy outside project-controlled directories.
A minimal policy that prevents reads below a protected subtree is:
permit(principal, action, resource);
forbid(principal, action, resource)
when {
resource.kind == "workspace_file" &&
resource.path like "/workspace/vendor/*"
};
Cedar receives the carried capability, requested operation, delegation depth,
non-secret session identity, and—for Read, Edit, and Write—the normalized
physical workspace target. It does not receive raw tool arguments, file contents,
credentials, or headers. A symlink inside the workspace cannot bypass a Cedar
path boundary because the evaluator sees the resolved target that filesystem access
will use.
Cedar requires verified session owner identity. Deployments that intentionally run
ownerless sessions should use local, or configure caller identity before selecting
Cedar.
MCP capabilities
MCP grants stay narrow. CallMcpWithQuery is evaluated against the concrete tool it
addresses, such as mcp__github__list_pull_requests, rather than receiving blanket
access to a server. MCP resource operations similarly spend a separate per-server
resource capability while preserving their operation, such as ReadMcpResource, for
policy evaluation. Granting one GitHub MCP tool therefore does not grant every GitHub
MCP tool.
Deployment checklist
Before enabling delegated authority:
- Choose
local,noop, orcedarintentionally. - Put authority-defining specialist definitions in an explicit
--agents-dir. - Treat
tools:as a ceiling, never a grant. - If using Cedar, keep its policy outside project-controlled paths and configure verified caller identity.
- Test the intended root → child → resume behavior in your deployment.
Authority failures are fail-closed. A denial tells the model that authority refused the call; an unavailable evaluator is reported separately to operators and is not treated as permission approval. See ADR 0234 for the boundary and constraints.
Layer 1 — the permission rule engine
Every tool call is evaluated against a merged set of rules. A rule is
{Scope, Tool, Pattern, Effect}: Effect is allow, ask, or deny; an empty
Tool matches any tool; an empty Pattern matches any arguments, otherwise it is
a shell-style glob over the canonicalized command/argument string.
Resolution: deny-dominant, then scope
A decision resolves in this order:
- Effect dominance:
deny→ask→allow. Adenyin any scope beats anaskorallowanywhere — a deny is absolute and final. Otherwise anaskbeats anallow. - Scope breaks same-effect ties (highest precedence first):
Managed > CLI > LocalProject > SharedProject > User > BuiltinDefault. - No matching rule →
ask— the safe default. The harness never silently allows an unconfigured call.
There is one narrow exception to "ask beats allow": a higher-scope configured
Allow may loosen only the built-in BuiltinDefault Ask floor (for example,
allowing Shell(go test:*) relaxes the built-in Shell ask). It can never suppress
a configured Ask, and it can never out-rank a deny in any scope.
A deny or ask carries a human-readable reason: surfaced to the model on a deny
(so it can adapt) and to the client on an ask.
The scope hierarchy
Scopes are where a rule comes from, highest precedence first:
| Scope | Source | Trust |
|---|---|---|
Managed | enterprise/admin floor | always honoured; nothing below overrides its deny |
CLI | each --permission-config <file> | fully trusted (the operator's own) |
LocalProject | <workspace>/.mecatl/settings.local.yaml (gitignored, personal) | trust-gated |
SharedProject | <workspace>/.mecatl/settings.yaml (checked-in, shared) | trust-gated |
User | $XDG_CONFIG_HOME/mecatl/settings.yaml | fully trusted (the operator's own) |
BuiltinDefault | the built-in floor (read-allow / mutate-ask) | n/a — lowest precedence |
Project files are re-resolved per session against each session's workspace
root, and the resolver revalidates its cache on the config files' mtime/size — so a
deny added mid-process takes effect on the next call, not at restart. Two sessions
running in different repos under the same server get different decisions for the
same tool call.
The default ruleset
The built-in floor allows read-only exploration and asks before anything that can mutate:
| Tool | Default effect |
|---|---|
Read, ListDir, Grep, Glob, WebFetch, WebSearch, Subagent | allow |
Shell, Edit, Write, Copy, Move, Remove, Team, SkillDraft | ask |
(The memory tools, the synthetic soul:apply action, and the read-only child
observability tools are also floor-scoped allows — pre-approved but overridable by
any higher-scope config.) Read-only exploration runs uninterrupted; anything that
can mutate the workspace pauses for approval. Team asks because it can spawn
mutating members, unlike the read-only Subagent explorer.
Effects: allow, ask, deny
allow— the call runs without prompting.ask— the call pauses and is surfaced to the client for an approval decision (see Thepermission.askflow). An approval can be allow-once (this call only) or allow-always (learned for the session — see below).deny— the call never runs; the model receives the deny reason and adapts.
Allow-once vs allow-always. When a client approves an ask, it chooses the
duration. Allow-once clears only the current call. Allow-always feeds the
permission policy's Learn path, which derives a per-session rule so the same call
is not re-asked. A learned allow is consulted at the lowest scope only — it can
never override a deny or a configured ask.
Compound Shell and substitution safety
For Shell, the evaluator splits a compound command line (&&, ||, ;, |, a
bare &, newlines, honouring quotes) and requires every sub-command to pass;
the worst outcome wins. So git status && rm -rf / inherits the deny/ask from
the rm segment even if git status alone would be allowed.
Any segment containing command/process substitution or subshell grouping ($(...),
backticks, <(...), (/{ grouping) — which could smuggle a hidden inner command
past the splitter — is floored at ask. An allow rule for the outer literal can
never silently approve a concealed command. (The substitution floor can be loosened
by posture — see below — but only for read-only inners.)
Plan mode
When a session is in plan mode, the evaluator gates before the rule engine:
Edit and Write are unconditionally denied, and any non-read-only Shell
command is denied. Read-only tools and read-only Shell fall through to the rules.
The deny reason tells the model to present a plan and exit plan mode first.
Getting out of plan mode. Once the model has a complete plan, it calls the
PresentPlan tool. That parks the run on a plan-approval ask — a distinct
gate from an ordinary permission ask, though it reuses the same ask machinery
described above. On an interactive client (mecatui shows a dedicated "Plan
ready for review" modal), you pick one of three outcomes: approve and run
(switches to default mode, so mutating tools still go through the ordinary
deny/ask/allow rules), approve with edits auto-accepted (switches to
acceptEdits mode), or iterate (stay in plan mode while the model revises and
re-presents).
In a headless deployment there's no human to review the plan, so by
default the ask is auto-denied and the model just keeps iterating. The opt-in
--plan-mode-auto-approve flag (operator-tier only, off by default) instead
auto-approves a parked plan ask. This is a deliberate autonomous-approval
capability for the operator, not a safety mechanism: the engine still requires
PresentPlan to reach this ask in the first place — the flag only decides who
resolves it once parked, human or auto-approve. See the gRPC API
reference and HTTP and SSE API
reference for wire behavior.
Configuring rules
.mecatl/settings.yaml (checked-in, shared), .mecatl/settings.local.yaml
(gitignored, personal), and the user-global file all share the same shape:
permissions:
allow:
- "Shell(go test:*)" # the "prefix:*" form, normalised to the glob "go test*"
- "Shell(go build*)" # native glob form
- "Read" # bare tool name = tool-wide
ask:
- "Shell(git push:*)"
deny:
- "Shell(rm:*)" # deny wins absolutely, in any scope — binds children too
subagent:
deny:
- "Shell(gh pr merge:*)" # tighten a child's Shell beyond the main rules
allow:
- "Shell(go vet:*)" # clears this from a child's substitution-floored ask
Each entry is a rule spec Tool(pattern) or a bare Tool. Config rules use glob
semantics; the prefix:* / prefix: form is normalised to a prefix* glob. The
permissions: subtree parses strictly — an unknown key (a typo like alow:) is
a loud parse error and the whole file is skipped (and logged), never silently
ignored; the subagent: subtree inside it parses just as strictly, on its own.
Importing Claude Code's permissions
If you already have a Claude Code .claude/settings.json in the repo (or your home
directory), --import-claude-permissions reads its permissions alongside your
.mecatl/ config instead of making you duplicate the rules. The import is
deliberately lossy — every lossy outcome is logged, and it never widens what
Claude Code itself would have allowed:
WebFetch(domain:x)in an allow list is demoted toask— a domain/substring match is too risky to auto-allow without you seeing it at least once.- A bare
WebSearchallow imports verbatim, no demotion (its payload is a query string, not an arbitrary fetch). - A
Read(~/...)pattern imports but stays inert — the~is left unexpanded, so it never matches the absolute path a tool actually resolves to. - Anything the importer can't parse is dropped, not guessed at.
deny/ask rules always import verbatim (tightening is never lossy). mecatui's
embedded server turns this on by default, alongside --permissions-conventional —
if you've used Claude Code in a repo before, mecatui picks up its rules with no
extra setup.
The posture ladder
A single operator tier — chosen at process start by whoever owns the blast radius —
sets how much the harness self-authorizes. Higher tiers grant more autonomy and
prompt less. Set it with --posture <strict|trusted|auto|yolo> (or the
operator-global posture: setting); --trust-project is an alias for trusted and
--yolo is an alias for yolo.
| posture | allow-all (no mutate-ask prompts) | child substitution floor | project trust | use it for |
|---|---|---|---|---|
strict (default, fail-closed) | off | gated | (your own --trust-project) | interactive / untrusted repos |
trusted | off | gated | on | a repo you trust, still want prompts |
auto | on (main + children) | gated (injection defence on) | on | the recommended unattended default |
yolo | on (main + children) | loosened (injection defence off) | on | a disposable, isolated, single-tenant sandbox |
auto is the recommended unattended default: allow-all for the main agent and its
children so a CI / container run never parks on a mutate-ask prompt, but the child
prompt-injection defence stays on (a subagent's $(...)/backtick command still
resolves through the child-ask model rather than auto-running). Only step up to
yolo — which loosens that child substitution floor too — where the harness
genuinely cannot cause durable harm.
Allow-all is not an evaluator bypass. It injects a single ScopeCLI allow-all
rule that loosens only the built-in mutate-ask floor. The governance invariants hold
at every tier including yolo:
- A
denyin any scope (includingManaged) still wins — deny-dominance is absolute. - Any deliberately configured
askstill asks. Allow-all never suppresses a configured ask, so a misconfigured ask can still block an unattended run (the startup warning says so). - Plan-mode hard-denies still fire first.
The posture bypasses the prompt, never a sandbox. The real boundary for
unattended agentic execution is OS-level isolation (container/microVM,
network-off-by-default, ephemeral filesystem). Enable an allow-all posture only
where the harness cannot cause durable harm, and only on single-tenant daemons (the
posture makes every session on that daemon allow-all). If an allow-all posture is
requested while running as root and no sandbox env var (MECATL_SANDBOX=1 or
IS_SANDBOX=1) is set, the process refuses to start.
Independently of posture, every agent-facing shell runs with the harness's
credentials scrubbed from its environment — even under auto/yolo, the model
cannot echo $OPENROUTER_API_KEY or cat /proc/self/environ to read a provider key.
Out-of-workspace filesystem access (the path-escape posture)
The FS tools (Read/Write/Edit) are rooted at the session workspace; a path that
resolves outside it used to be a dead end — the call failed with a path-escape error and
the model fell back to an opaque Shell cat /path, losing the FS tools' invariants and
audit shape. The posture now decides what an out-of-workspace escape does instead:
| Posture | Read escape | Write escape |
|---|---|---|
yolo | allow | allow |
auto | allow | ask |
strict / trusted | ask | ask |
- The consent model. Every ask is an ordinary Layer 1
permission.ask: the prompt names the exact path, and approval is allow-once only — approving one out-of-workspace call never learns a rule that pre-approves the next one. A configureddenyor configuredaskalways wins over the posture row (deny-dominance and the configured-Ask floor are untouched), and plan mode still hard-denies writes first. - Shell parity. At
auto/yoloa Shellcat /outsidealready reads the same bytes, so an un-asked read boundary on the FS tools was cosmetic; writes are never silent belowyolo. - Never relaxed, at any posture: paths under
/proc,/sys, or/devare a hard deny everywhere. An in-process Read of/proc/self/environwould expose the server's raw, unscrubbed environment — a channel the env-scrubbed Shell parity path does not provide — so the parity premise does not extend there. Child agents (subagents, team members, parallel branches) also never get the relax at any posture: only the main session's workspace carries it, and a child that shares the parent's workspace is handed a non-relaxed view of the same root. - Serving stays contained. An approved escape is served through the same
symlink-checked containment the workspace itself uses (a fresh
os.Rooton the target's parent directory) — the relax widens which paths may be served, never how. - Optional LLM gate at
auto. With a guardrail checker model configured, the operator-tierguardrails.escape: truesetting (user-globalsettings.yamlonly) routes eachauto-posture escape through the Layer 2 checker first: unsafe → deny; a checker error fails closed to the write-escape ask. Default is off — the plain table above. See ADR 0080 and the configuration reference.
Workspace trust
Trust decides whether a project's injected authority is admitted. It is a composition decision, not a permission scope, and it gates exactly the project-tier authority set:
- the project's permission ALLOW rules (auto-approval the repo grants itself);
- the project soul (
<workspace>/.mecatl/soul.md— a repo rewriting the agent's persona); - the project tier of agent definitions, slash commands, and skills under
<workspace>/.mecatl/*and<workspace>/.claude/*.
Trust is not a kill-switch. An untrusted repo is still a fully usable coding agent — it degrades to "ask the human" mode, never "do nothing" mode. These stay active on any repo, trusted or not: the built-in tools and the whole loop, the base system prompt, your own user-tier config, every deny/ask rule from any scope (they only tighten), and the permission prompt itself. So an untrusted repo cannot silently steer the model with an injected agent, command, skill, persona, or self-granted auto-approve — but you can still read, edit, and run-with-a-prompt in it from the first run.
Note the asymmetry: a project's deny and ask rules are always honoured (they only tighten); only its allow rules and soul are trust-gated. Trust is monotonic-positive — it only ever grants admission, never overrides a deny or a configured ask.
Configure trust three ways:
-
--trust-project— a per-invocation flag (thetrustedposture alias). -
trustedWorkspaces:— a list of absolute paths in the user-globalsettings.yaml, for CI / daemons / repeated work in a known-good checkout:# ~/.config/mecatl/settings.yaml (the operator's own, fully-trusted file)trustedWorkspaces:- /home/me/src/my-project- /home/me/work/known-good-repoThis is read-only — Mecatl only reads it, never writes it. Paths are compared on their cleaned, absolute, symlink-resolved form, so a moved or symlinked path cannot forge another workspace's trust.
-
The
mecatuifirst-encounter prompt — when you launch the embedded TUI in an untrusted workspace that carries a project authority set, it prompts once ([t]rust / [o]nce / [n]o, default no) before the TUI takes over.tremembers the decision in a machine-written registry (trust.yaml, separate from your human-authoredsettings.yaml).mecatednever prompts — it reads the registry declaratively.
A remembered trust is keyed to the project's identity anchor (its soul plus
project-tier agent/command/skill definitions — but not settings.yaml, which
changes every commit). If that anchor drifts after you trusted it, the workspace is
re-gated to untrusted (and mecatui re-prompts). Editing permission rules does not
re-prompt; changing the persona / agents / commands / skills does.
A corrupt or unparseable settings.yaml or trust.yaml always resolves to
untrusted — a broken config never grants trust.
Project-tier ingestion on headless roots (the opt-in design)
On a headless root (--headless), posture never raises TrustProject. Explicit
--trust-project, trustedWorkspaces:, or undrifted remembered trust admits BOTH repo steering and
the read-only child shell. Without any trust source, mecatequi --posture auto keeps allow-all
approvals but gets neither because .git is not vouched.
Layer 2 — model-backed guardrails
Guardrails inspect the data crossing the agent's tool boundary with a separate, tool-less checker model and enforce a verdict on the call. It is the dual-LLM quarantine pattern: a dedicated model judges tool content as data, never as instructions, so a compromised tool result or a model bent on exfiltration is caught by something the attacker cannot also prompt-inject in the same breath.
It catches two trust-boundary crossings:
- Outbound (
PreToolUse) — exfiltration. The model chose the arguments. A guardrail inspects the args before the call runs — a secret in an HTTP body, a credential in an MCP call,.envcontents addressed to an external service. - Inbound (
PostToolUse) — prompt injection. A tool result is attacker-influenced data — a fetched web page, a GitHub issue body, an MCP response. A guardrail inspects the result the model is about to read for injection-like content.
The checker is the same trust model as Layer 1 turned inward: where Layer 1 gates whether a call runs, guardrails inspect what the call carries.
Verdicts: block, sanitize, advisory
Each rule sets a mode:
block— enforce. APreToolUseblock is a real veto (the tool never runs). APostToolUseblock rewrites the result to a model-visible error rather than vetoing (see below).sanitize— enforce by rewriting the args (pre) or result (post) to the checker'ssanitized_content. A sanitized result carries a[guardrail: redacted unsafe content]marker so the model knows it was edited. Sanitize trusts the checker's output (a compromised checker could rewrite content), so use it only with a checker model you trust; a nil, oversized, or invalid rewrite falls back to a block.advisory— observe-only. A finding emits an operator-log diagnostic and a client-visible advisory notice on the tool card, but the call/result is byte-unchanged and the model sees nothing. Measure the false-positive rate, then promote a rule toblockorsanitize.
PostToolUse block rewrites, it does not veto
This is the key non-obvious point. By the time a PostToolUse hook fires, the tool
has already run — so an enforcing inbound block cannot un-run it. Instead the
guardrail rewrites the result to an error (is_error: true). The loop guarantees
the recorded history, the client event stream, and the model's view all show the
effective (rewritten) result — so the model sees the block, the client agrees,
and the raw injected result never reaches either. Only a PreToolUse block is a true
veto.
Recovering from a block: approve-once
A block verdict is not a permanent dead end. When the checker blocks a
PreToolUse call, the block is askable: on an interactive session the harness
pauses the run and surfaces it to the human through the same permission-ask flow
Layer 1 uses (see The permission.ask flow) — an ordinary
approval modal carrying the actual blocked call, not a slash command or a prompt
directive the human has to predict and pre-type. The human picks one of the same
three verdicts:
- Deny — the call never runs; the model receives the block reason and adapts.
- Allow once — the call runs, this time only.
- Allow & don't ask again — the call runs, and the harness arms a session-scoped
waiver: a later call matching the exact tool and the exact normalized command
(
Shell) or arguments (any other tool) skips the checker for the rest of the session. Matching is exact — never a substring, never a blanket per-tool bypass — so approving onegh pr mergecall never waves through an unrelated one. The waiver is in-memory only and does not survive a process restart.
A run parked on an askable guardrail block resumes exactly like any other pending approval, including across a process restart. In a headless deployment there is no human to ask, so an askable block simply resolves as a terminal block — the same fail-safe default as an unresolved Layer 1 ask.
Posture coupling. Under the yolo posture — the fully
gate-free tier — every guardrail rule is demoted to advisory (log and notify only;
never block or ask). strict, trusted, and auto all keep enforcing: under auto
the interactive approve-once ask is the intended behavior (the checker blocks, an
interactive human allows it once), so auto is deliberately excluded from the
demotion — only yolo trades the guardrail's enforcement away.
Configuring guardrails
Guardrails are off until you configure a checker model — configuring a model is the opt-in to spend (the only cost is the per-call checker LLM call). With a model and no explicit rule list, guardrails are on with the default block ruleset:
| Tool matcher | Phases | Mode |
|---|---|---|
WebSearch | pre + post | block |
WebFetch | post | block |
mcp__* (all MCP tools) | pre + post | block |
Shell | pre | block (read-only commands skip the checker) |
The other local tools (Read/Edit/Write/Grep/Glob) are deliberately not
matched — they have no outward reach, and Edit/Write are workspace mutations git
already covers as the rollback layer. Shell is matched, because the shell is an
agent's single largest blast radius: it can push, merge, delete, or exfiltrate, and a
guardrail that ignores it misses exactly that surface (the motivating incident was an
agent running gh pr merge --squash as a Shell call and merging its own PR
unattended, with guardrails never seeing it).
Inspecting every shell command would be an unacceptable latency/cost tax on the ls /
grep / git status traffic that dominates a session, so the default Shell rule
carries a read-only pre-filter: a command that is confidently read-only (the same
classifiers Layer 1's rule engine uses) skips the checker entirely — zero LLM calls.
Anything else — a mutating or outward command, an unrecognized verb, or a substitution
it can't prove read-only — falls through to inspection; ambiguity always fails toward
inspecting, never skipping.
The default Shell rule also swaps in a Shell-specific rubric in place of the
generic exfiltration prompt used for the network/MCP rules — the generic rubric's
"if uncertain, judge unsafe" false-positives badly on ordinary shell work (a write to
a sibling repo never leaves the machine, so it isn't exfiltration). The Shell rubric
instead judges a command safe unless it names one of five concrete danger
categories: (1) sending data off the machine to a network destination, especially
secrets; (2) fetching and executing remote code (curl … | sh); (3) an irreversible
action against a remote you may not control (force-push, push/merge, gh pr merge,
publishing a release, deleting a remote branch/repo); (4) a destructive, hard-to-
reverse local operation (recursive deletion, overwriting a disk device, mass
recursive chmod/chown); (5) a local-persistence write to a credential, SSH key,
shell-startup file, scheduler entry, or git hook — a write that never leaves the
machine but grants later off-machine access or persistent code execution. Ordinary
local writes (source, config, build output, notes — including to sibling repos),
builds, tests, local file moves/copies, and routine origin-remote git operations are
explicitly judged safe.
Set defaultMode: advisory to start the default set in observe-only mode and tune up
from there.
# ~/.config/mecatl/settings.yaml (user-global only — NOT a checked-in project file)
guardrails:
model: gpt-5-mini # configuring a model is the opt-in; default rules apply
minContentBytes: 16 # skip a short INBOUND (post) result; outbound (pre) args are always inspected
rules: # an explicit list REPLACES the default set
- match: "WebFetch" # inbound injection on fetched pages
phases: ["post"] # "pre" = outbound args, "post" = inbound result; omit = both
mode: block
- match: "mcp__*" # all MCP tools, both directions
mode: advisory # observe first, tune later
- match: "Shell" # outbound exfil in shell args
phases: ["pre"]
mode: sanitize # trusts the checker's rewrite — use only with a trusted checker
failClosed: true # a checker outage treats the content as UNSAFE (default is fail-OPEN)
A matcher keys on the tool name only (exact > prefix* > *, most-specific
wins); a tool with no matching rule is unchecked. A checker error/timeout
fails open by default (degrade to "no checker" with a WARN; a sustained outage
escalates to a one-time "checker DOWN" sticky WARN); set failClosed: true to treat
a checker error as unsafe instead. A checker saying safe always passes.
Unlike the headless-only ask reviewer, guardrails fire on the main loop regardless
of --headless.
The guardrails: config is read from the user-global settings.yaml and the CLI
only — never from a project-tier file. This inverts the usual tighten-only
project gate: a project repo disabling or weakening a security checker would be a
downgrade, so a project-tier guardrails: block is ignored with a WARN. The
subtree is parsed strictly, so a typo cannot silently disable a guardrail. Set the
model with --guardrails-model (or a bound guardrail model slot); force the whole
layer off with --guardrails=off.
The permission.ask flow
When Layer 1 resolves to ask, the call pauses and the harness surfaces a
permission.ask to the client — carrying the tool name, the (clamped, redacted)
arguments, and the human-readable reason. The client responds with a verdict:
- deny — the call never runs; the model receives the deny and adapts.
- allow-once — the call runs this time only.
- allow-always — the call runs and the policy learns a per-session rule so the same call is not re-asked. A learned allow lives at the lowest scope and can never override a deny or a configured ask.
A run that parks awaiting an approval can be approved later — even after a process restart, the harness re-enters the loop at the pending ask when the verdict arrives.
In mecatui the approval modal shows the args up front: the command renders in
bright text with a left accent bar so it reads distinct from the surrounding
metadata, long args wrap (a Shell ask decodes to the command text, never a
raw JSON blob) and scroll inside the card, and ctrl+t opens a full-screen
scrollable view of the whole arguments with the verdict buttons pinned at the
bottom.
In a headless deployment there is no human to ask. An unresolved ask is
auto-denied by default, with one optional step before that: the
--subagent-ask-reviewer (headless-only) inserts a tool-less, one-turn LLM reviewer
that can approve a child's ask for that call only — an allow is always allow-once,
never learned, and a deny leaves the child with the same clamped denial reason an
auto-deny would give it. It is fail-safe (any error keeps the call denied), never
delegated a configured ask, and deliberately a server flag rather than a config
key — granting an autonomous approval capability is an operator deployment decision,
not something a checked-in project file should switch on. A per-run breaker trips
after 3 consecutive non-allow outcomes (denies, errors, timeouts) — once open, later
asks in that run skip the reviewer and go straight to auto-deny; a single allow
resets the count.
Subagents and the permission model
Children (subagent explorers, team members, parallel branches) get their own scoped ruleset, distinct from the main engine's:
- Child engines default to allow-all at a built-in floor — everything runs except substitution-floored commands and anything a configured deny/ask gates.
- A top-level
denybinds children too (a deny only ever tightens, so it binds everywhere). Top-levelallow/askare main-only (children are already allow-all). - A
subagent:block in the config carries child-scoped rules:subagent: deny/subagent: asktighten a child command;subagent: allowclears a child's substitution-floored ask (and only when the hidden$(...)inners independently classify as read-only). - Under
autoandyoloposture, the allow-all rule is pushed to children too. The difference between the two tiers is the child substitution floor:autokeeps it gated (a child's$(...)resolves through the child-ask model — injection defence on);yololoosens it (a child's substitution auto-runs — defence off).
A project's subagent: allows are themselves trust-gated, exactly like its main
allows.
What's next?
- Hook system — the lifecycle phases the guardrail checker decorates, and how to write your own pre/post-tool and lifecycle hooks.
- PermissionPolicy extension point — implement the port to replace Layer 1's rule logic with your own.