Three layers, in order.
carlos's approval policy is a chain. Built-in allowlist first, then workspace trust, then a session-fallback prompt. The cross-frame detector intercepts ahead of the chain on writes. Every decision lands in an audit log.
The chain
Implemented in internal/agent/policy.go. Layers run in order; the first layer to decide wins. Decisions feed an optional AuditSink that the /permissions overlay reads back.
1. Built-in allowlist
Hardcoded read-only tools auto-approve without inspecting input. The configured-vault notes_* tools (notes_get, notes_search, notes_backlinks, notes_tagged, notes_neighbors, notes_recent, notes_resolve) plus read, grep, glob, ls, and five git_* inspection tools (git_status, git_diff, git_log, git_blame, git_show). Trust anchor is the configuration boundary set during onboarding.
2. Workspace trust
When the current working directory is in ~/.carlos/trusted-workspaces.json, a curated set of read-only bash verbs auto-approves: ls, pwd, cat, head, tail, wc, file, which, echo, and the read-only git subcommands (status, diff, log, show, blame, branch, ls-files, ls-tree, rev-parse, describe, remote, read-form config). Build/test/install tools (cargo, npm, yarn, pnpm, go test, go build, make, cmake, bazel, ninja) are explicitly excluded. Mutating filesystem verbs are excluded. Implementation lives in internal/workspace/bash.go:IsReadOnly.
3. Session fallback
The bubbletea TUI overlay prompts the user; y / N / Always. An Always answer caches as a session entry, so identical (tool, key inputs) pairs auto-approve for the rest of the session. Dies with the process.
Audit reasons
Every decision the chain emits is tagged with a stable reason. The /permissions overlay surfaces the current state plus the running audit log so an operator can answer "why did that happen?" without digging through the event store.
| reason | when |
|---|---|
ReasonBuiltinAllow |
Layer 1 hit. The tool is on the hardcoded read-only allowlist. |
ReasonWorkspaceAllow |
Layer 2 hit. cwd is in trusted-workspaces.json and the bash command parses as read-only. |
ReasonSessionAllow |
Layer 3, user said yes (or an Always cache entry served the call). |
ReasonSessionDeny |
Layer 3, user said no. |
ReasonCrossFrameAllow |
Cross-frame write detector, user approved. |
ReasonCrossFrameDeny |
Cross-frame write detector, user declined. |
Cross-frame detector phase F-12
Intercepts ahead of the layered chain on write and edit inputs whose path lands in a non-active frame's subtree. The matcher is separator-anchored prefix, so /work/foo does not collide with /work-extra/foo. These calls force the prompt path; there is no silent allow. The user's answer is recorded as ReasonCrossFrameAllow or ReasonCrossFrameDeny so the trail stays legible.
First-launch trust prompt
When the chat opens and the cwd contains a project marker (.git, go.mod, package.json, etc.) and the workspace is untrusted, a bordered overlay surfaces: y / n / esc. y persists via the same store the /trust slash uses; n or esc dismiss for the session. The overlay only shows once per session.
Identity hardening
Provider clients run every EventError through providers.ScrubModelName so "I am Gemini" or "I am Claude" reveals become "I am carlos". The cmd/carlos.scrubProviderName wrapper runs the same scrub at the stderr boundary, catching anything that escapes the event path. A regression test (internal/tui/chatglue/sysprompt_pinning_test.go) pins the system prompt against user-injection attempts; new providers added to the pantry inherit the same scrub by default.
Slashes
/permissions: opens the overlay. Current session-allow cache plus the running audit log./trust: marks the current cwd as trusted. Layer 2 lights up./untrust: removes the cwd fromtrusted-workspaces.json./trusts: lists every trusted workspace currently on disk.
Notes vs. Obsidian tools
Two parallel surfaces, one configured and one freeform. The configured surface is auto-approved; the freeform surface always prompts.
| tool family | behavior |
|---|---|
notes_* |
Configured vault, no vault: arg accepted. Auto-approved by layer 1 of the permission model. notes_write is hard-scoped to the active frame's vault_subtree. |
obsidian_* |
Arbitrary vault, requires vault: arg. Always prompts, regardless of trust state. |
Any shell metacharacter (|, >, <, &, ;, $, backtick, (, )) anywhere in the command disqualifies the whole call from the read-only allowlist. The carve-out is intentionally narrow; the risk of a clever pipeline doing unexpected work is too high to swallow for the convenience of a one-line approval.