carlos

home · docs · permissions

concepts · permissions

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

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.
bash classifier

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.