# KHAELOR Permission Model — V1 > Phase 1 design document. Binding inputs: CLAUDE.md §13 (Permission System), ADR-9 (permissions). Reference evidence: OPENCODE_ANALYSIS §8 (evaluator, arity suggestions, deny-shapes-tools), OPENHANDS_ANALYSIS §6 (persisted pending approvals, rejection-as-feedback), HERMES (hardline floor, "silence is not consent", operator guard). > > This document is the implementation contract for `src/permissions/`. Powerful but not annoying: the default policy keeps ordinary project work friction-free while keeping destructive or unusually broad actions visible (CLAUDE.md §13). --- ## 1. Capability taxonomy Permissions are evaluated against **capabilities**, never tool names (CLAUDE.md §13). V1 capabilities: | Capability | Meaning | Subject (the string patterns match against) | |---|---|---| | `file.read` | Read file/directory content or metadata inside or outside the project | resolved absolute path | | `file.write.project` | Create/modify/overwrite a file under the project root | resolved absolute path | | `file.write.outsideProject` | Create/modify a file outside the project root | resolved absolute path | | `process.execute` | Run a foreground shell command | the command text | | `process.background` | Start (or adopt) a long-lived background process | the command text | | `network.access` | Command whose primary purpose is network I/O (best-effort detection, §3.4) | the command text | | `git.modify` | Git command that mutates repository state (§3.4) | the command text | Notes: - The project root is `workspace.cwd()` resolved at session start. Path subjects are fully resolved (symlinks, `..`) **before** classification — a write to `./x/../../etc/hosts` is `file.write.outsideProject`. - One tool call may map to **multiple** capability requests (a `bash` call can carry `process.execute` + `network.access` + `git.modify`). Combination rule (§4.4): `deny` beats `ask` beats `allow`; every request must resolve `allow` for silent approval. - `filesystem.outsideProject` from CLAUDE.md §13 is realized as the `*.outsideProject` variants plus best-effort bash detection (§3.4); reads outside the project stay under `file.read` (pattern rules can still gate them, e.g. `file.read` on `/etc/*`). - Future capabilities (subagents, MCP, browser) extend this table; the rule format (§4) needs no change. ### 1.1 `CapabilityRequest` Produced by each tool's `capabilities()` function (TOOL_PROTOCOL §1.2) before execution: ```ts /** * KHAELOR * File: src/permissions/types.ts (excerpt — normative shape) */ export type Capability = | "file.read" | "file.write.project" | "file.write.outsideProject" | "process.execute" | "process.background" | "network.access" | "git.modify"; export interface CapabilityRequest { capability: Capability; /** What rules' patterns match against: resolved path or command text. */ subject: string; /** Human-readable line for the panel, e.g. `Run npm install`. */ display: string; /** * Candidate "always allow" patterns, most specific first * (e.g. ["git push *"]). EMPTY for compound/obfuscated commands (§3.3) * and for anything the analyzer could not classify — the panel then * offers exact-subject persistence only, or none. */ alwaysPatterns: string[]; /** Shown in the panel: why this asks, what is unusual. */ riskNotes: string[]; /** UI extras: diff for writes, parsed command parts, cwd. Never model-facing. */ metadata?: Record; } ``` --- ## 2. Per-tool capability mapping The mapping function of each tool (`capabilities(input, ctx)`), normative: | Tool | Mapping | |---|---| | `read` | `file.read` — subject: resolved `file_path`. | | `glob` | `file.read` — subject: resolved `path` (default cwd). | | `grep` | `file.read` — subject: resolved `path` (default cwd). | | `write` | Resolve `file_path` → under project root ? `file.write.project` : `file.write.outsideProject`. Metadata carries the unified diff (TOOL_PROTOCOL §3.2) so the panel shows what would change. | | `edit` | Identical to `write` (OpenCode folds `write` into `edit` policy-wise; KHAELOR folds both into the `file.write.*` capabilities). Metadata carries the diff. | | `bash` | `process.execute` (subject: full command text) **plus** derived requests from command analysis (§3): `network.access`, `git.modify`, `file.write.outsideProject` (filesystem verbs with resolved outside-project arguments — best-effort, ADR-9 concern). | | `process` | `action: "start"` → `process.background` (subject: command) plus the same derived analysis as `bash`. `list` / `read` → **no request** (pure observation of KHAELOR-owned state — read-only short-circuit, OpenHands §6). `write` → `process.background` with subject `stdin:` (sending input to a process the user already approved starting; default rule allows it when the start was allowed, §4.5). `stop` → no request (stopping our own process is always safe). | Anything not expressible above is a design error: a new behavior needs either a new capability or an ADR amendment — never a bypass. --- ## 3. Bash command analysis (V1: conservative shell-word parsing) Per ADR-9: **no tree-sitter in V1**; tree-sitter is the planned upgrade, not a maybe. V1 analysis must therefore be honest about its limits: it may *under-generalize* (fall back to exact-command approval) but must never *over-generalize* (suggest a broad "always allow" it cannot justify). ### 3.1 Tokenization A small, dependency-free shell-word lexer: - Splits on whitespace; respects single quotes, double quotes, and backslash escapes. - Recognizes operator tokens: `&&` `||` `;` `|` `&` `>` `>>` `<` `<<` `2>` `2>&1` and newlines. - Flags **substitution markers** anywhere in the string: `$(`, backtick, `<(`, `>(`, `${`. - Produces: `{ words: string[][], operators: string[], hasSubstitution: boolean }` — one `words` list per simple command in the pipeline/chain. ### 3.2 Classification ``` simple — exactly one command, no operators, no substitution compound — ≥2 commands (&&, ||, ;, |, &) — each simple part analyzed individually obfuscated— substitution present ($(), ``, ${}), OR quoted operator smuggling (an argument that itself lexes into operators for sh -c / bash -c / eval / xargs), OR lexer failure ``` - **simple:** full analysis — arity suggestions (§3.3), network/git detection (§3.4), outside-project path checks (§3.5). - **compound:** every part is analyzed; the derived capability set is the **union** over parts (one `curl` in a pipeline makes the whole command carry `network.access`). Evaluation may still auto-allow a compound command **only** if *every* part matches an `allow` rule and none matches `ask`/`deny` — otherwise one `ask` for the whole command. - **obfuscated:** derived analysis is skipped as unreliable; the command carries `process.execute` (+ `network.access` conservatively when net-tool names appear anywhere in the raw text) and always at least `ask` unless an **exact-subject** rule allows it. Risk note: `Command uses substitution — KHAELOR cannot verify what it will run.` ### 3.3 "Always allow" suggestion generation — and the refusal rule **Refusal rule (Hermes' hardline-floor concept applied to suggestions):** `alwaysPatterns` is **empty** for any `compound` or `obfuscated` command. The panel then offers only "allow once" — never a persistable generalization for something the analyzer could not fully read. Exact-command persistence for compound commands is also refused in V1 (an exact string containing `&&` is still a standing grant for a multi-step effect; revisit with tree-sitter). For `simple` commands, suggestions come from an **arity dictionary** (OpenCode §8.2 — "the difference between a permission system users tolerate and one they like"): how many leading words form a meaningful prefix for common tools. ```ts // src/permissions/arity.ts (excerpt) — prefix word-counts per tool const ARITY: Record> = { git: { "*": 2, config: 3, remote: 3, stash: 3, submodule: 3 }, npm: { "*": 2, run: 3, exec: 3 }, pnpm: { "*": 2, run: 3 }, yarn: { "*": 2, run: 3 }, npx: 2, node: 2, python: 2, python3: 2, pip: 2, pip3: 2, cargo: 2, go: 2, make: 2, docker: { "*": 2, compose: 3 }, kubectl: 2, gh: 3, brew: 2, ls: 1, cat: 1, mkdir: 1, touch: 1, }; ``` Generation: take the first `arity` words of the command, append ` *` if arguments were elided. `git push origin main` → `git push *`; `npm run dev` → `npm run dev` (arity 3, exact); unknown command `./scripts/build.sh --prod` → arity default 1 → suggest `./scripts/build.sh *` **only if** the word resolves inside the project; otherwise exact command only. Panel copy: `[ A ] Always allow "git push *" in this project`. ### 3.4 Network and git detection (simple commands) - `network.access` when the command word ∈ `{curl, wget, nc, ncat, netcat, ssh, scp, sftp, ftp, telnet, ping, dig, nslookup, rsync-with-remote-arg}`. Package managers (`npm install`, `pip install`, `cargo add`, `brew install`) stay `process.execute` — network is incidental and gating them separately would be pure annoyance; their arity suggestions handle policy. Documented as best-effort: this is a UX signal for the `ask` panel, **not** a security boundary (a denied `network.access` cannot stop a novel binary from opening a socket — see §7 honesty note). - `git.modify` when word 0 is `git` and word 1 ∈ `{commit, push, reset, rebase, merge, revert, cherry-pick, checkout, switch, restore, clean, stash, tag, branch(-d/-D/-m), remote(add/remove/set-url), am, apply, filter-branch, gc, reflog(delete/expire), config, rm, mv}`. Read-only git (`status, diff, log, show, blame, branch` listing, `ls-files`, `rev-parse`, `describe`, `fetch --dry-run`) remains plain `process.execute` and sits in the default allowlist (§4.5). ### 3.5 Outside-project filesystem checks (best-effort — ADR-9 ⚠ concern) For simple commands whose word 0 is a filesystem verb (`rm, cp, mv, mkdir, rmdir, touch, chmod, chown, ln, dd, tee, truncate, install`), non-flag arguments are resolved against the effective cwd; any resolving outside the project root adds `file.write.outsideProject` with that path as subject. Explicitly best-effort on complex commands (recorded concern in ADR-9); the hardline floor (§4.2) backstops the worst cases, and tree-sitter is the planned upgrade. --- ## 4. Policy: rules, precedence, evaluation ### 4.1 Rule format ```ts export type PermissionAction = "allow" | "ask" | "deny"; export interface PermissionRule { /** Capability pattern; wildcards allowed: "file.write.*", "*". */ capability: string; /** Subject pattern; wildcards allowed: "git push *", "/Users/x/notes/*". Default "*". */ pattern?: string; action: PermissionAction; /** Provenance, filled by the loader: "default" | "user" | "project" | "session". */ source?: string; } ``` Wildcard matching: `*` matches any run of characters (including `/` in paths); matching is case-sensitive; a pattern without `*` must match the subject exactly. Command subjects are matched with collapsed whitespace. **Config file forms** (both accepted; CLAUDE.md §13 shows the shorthand): ```jsonc // .khaelor/config.json — "permissions" section { "permissions": { // shorthand: capability → action "file.read": "allow", // nested: capability → { subject-pattern → action }, key order preserved "process.execute": { "git status": "allow", "git push *": "allow", "*": "ask" }, // explicit ordered rules (appended after the shorthand expansion) "rules": [ { "capability": "file.write.outsideProject", "pattern": "/Users/x/notes/*", "action": "allow" } ] } } ``` Normalization expands shorthand/nested forms into `PermissionRule[]` **in source key order** (the loader preserves JSON key order), then appends `rules`. ### 4.2 Layering and the hardline floor The effective ruleset is plain array concatenation (OpenCode's `merge()`), later layers win by position: ``` DEFAULTS (built-in, §4.5) ++ user rules (~/.khaelor/config.json → permissions) ++ project rules (.khaelor/config.json → permissions) ++ session grants (in-memory "allow once" bookkeeping; "always" grants are written to the project file and re-loaded, §6) ``` **Beneath** the rule system sits the **hardline deny floor** (Hermes §6.5): a small, built-in, non-configurable pattern list that no rule can override. Checked against a *de-obfuscated* rendering of the command (quotes stripped, whitespace collapsed, `$HOME`/`~` expanded): ``` rm -rf / rm -rf /* rm -rf ~ rm -rf $HOME mkfs* dd * of=/dev/* chmod -R 777 / chown -R * / :(){ :|:& };: shutdown* reboot* halt* git push * --force * (to a branch matching main|master, when detectable) > /dev/sd* ``` Hardline hits return `deny` with `riskNotes: ["Blocked by KHAELOR's built-in safety floor — this cannot be allowed by configuration."]`. The list ships short and explicit; growing it requires an ADR note. It is a floor against catastrophe, not the primary defense (Hermes' regex-armory posture is rejected — HERMES NOT-COPY #9). ### 4.3 Evaluation algorithm — last match wins The OpenCode-style ~4-line evaluator, exactly: ```ts export function evaluate(rules: PermissionRule[], req: CapabilityRequest): Decision { if (HARDLINE.some((h) => matchHardline(h, req))) return { action: "deny", rule: HARDLINE_RULE }; const rule = rules.findLast( (r) => wildcard(r.capability, req.capability) && wildcard(r.pattern ?? "*", req.subject), ); return { action: rule?.action ?? "ask", rule }; // unmatched default: ask } ``` Properties (all tested): last matching rule wins; both fields must match; users control precedence purely by rule order within a file and by file layer; the fallback for a capability no rule mentions is `ask` (safe default — though the shipped defaults §4.5 mention every V1 capability). `Decision` carries the matched rule + `source` for provenance display and audit (§8). ### 4.4 Combining multiple requests per tool call A tool call producing requests `R1..Rn` is decided as: ``` any deny → deny (the denied request named in the failure message) else any ask → ask (ONE combined panel listing all asking requests) else → allow ``` For compound bash commands, §3.2's per-part union feeds this the same way. One tool call never produces more than one panel. ### 4.5 Default policy shipped with V1 Safe but not annoying (CLAUDE.md §13; defaults calibrated against OpenCode's — OPENCODE §8.1): ```ts export const DEFAULT_RULES: PermissionRule[] = [ // reads: free, except secrets-shaped files { capability: "file.read", pattern: "*", action: "allow" }, { capability: "file.read", pattern: "*.env", action: "ask" }, { capability: "file.read", pattern: "*.env.*", action: "ask" }, { capability: "file.read", pattern: "*.env.example", action: "allow" }, { capability: "file.read", pattern: "*.pem", action: "ask" }, { capability: "file.read", pattern: "*/.ssh/*", action: "ask" }, // writes: project free, outside asks { capability: "file.write.project", pattern: "*", action: "allow" }, { capability: "file.write.outsideProject", pattern: "*", action: "ask" }, // commands: ask by default, with a read-only allowlist so common // inspection never prompts (the arity suggester grows this per project) { capability: "process.execute", pattern: "*", action: "ask" }, { capability: "process.execute", pattern: "git status*", action: "allow" }, { capability: "process.execute", pattern: "git diff*", action: "allow" }, { capability: "process.execute", pattern: "git log*", action: "allow" }, { capability: "process.execute", pattern: "git show*", action: "allow" }, { capability: "process.execute", pattern: "git branch", action: "allow" }, { capability: "process.execute", pattern: "ls*", action: "allow" }, { capability: "process.execute", pattern: "pwd", action: "allow" }, { capability: "process.execute", pattern: "which *", action: "allow" }, { capability: "process.execute", pattern: "cat *", action: "allow" }, { capability: "process.execute", pattern: "wc *", action: "allow" }, { capability: "process.execute", pattern: "head *", action: "allow" }, { capability: "process.execute", pattern: "tail *", action: "allow" }, // stdin to an already-approved background process: allowed { capability: "process.background", pattern: "stdin:*", action: "allow" }, { capability: "process.background", pattern: "*", action: "ask" }, { capability: "network.access", pattern: "*", action: "ask" }, { capability: "git.modify", pattern: "*", action: "ask" }, ]; ``` The allowlisted read-only commands still pass through §3 analysis — `cat * > file` is compound and asks. First-run UX teaches the loop once: approve `npm test` with `A` and it never asks again *in this project*. --- ## 5. The permission request flow ### 5.1 Event flow (all durable — ADR-4, audit §8) ``` Executor decodes tool_use → emit ToolRequested{callId, tool, input, requests: CapabilityRequest[]} → decision = combine(evaluate(rules, r) for r in requests) (§4.3–4.4) allow → emit ToolApproved{callId, decisions} → execute() ask → emit PermissionRequested{callId, requests, decisions} → TUI panel (§6) → user grants → emit PermissionGranted{callId, scope: "once"|"always", persistedRule?} → ToolApproved → execute() → user denies → emit PermissionDenied{callId, feedback?} → ToolFailed (model-facing message, §5.4) deny → emit PermissionDenied{callId, byRule} → ToolFailed (§5.4) (no panel) ``` ### 5.2 Concurrency and timeout semantics - The agent turn parks on a pending request (`Deferred`, OpenCode §8.3). Parallel tool calls queue their panels; **granting "always" auto-resolves other pending requests that now evaluate to `allow`; denying one denies all pending requests of the same turn** (OpenCode behavior, adopted). - **Silence is not consent** (Hermes): there is no auto-approval timeout. A pending request idles until answered; the status line shows `● Waiting for permission`. Because `PermissionRequested` is durable and the approval is just the granted event, a pending approval **survives restart** (OpenHands' persisted-unexecuted-action trick): on resume, the panel reappears. - Non-interactive invocations (future `khaelor run`) resolve every `ask` as `deny` with message `KHAELOR is running non-interactively; interactive approval is unavailable.` ### 5.3 Escalation inside execution `bash`'s timeout-redirect (TOOL_PROTOCOL §7.2) turns a foreground command into a background process. No second prompt: the original `process.execute` grant covers the adoption; the adoption is announced in the result and the process appears in `/processes`. (Rationale: the user approved *this command*; whether it takes 90 s or 900 s does not change what it does.) ### 5.4 Denial is steering, not a dead end (ADR-9) Model-facing `ToolFailed` content: - **Policy deny:** `Permission denied by policy: process.execute for "rm -rf build" is denied in this project (rule: process.execute / "rm -rf *", source: project). Do not retry this command or attempt an equivalent workaround. Choose a different approach, or ask the user to adjust permissions.` - **User deny, no feedback:** `The user declined to allow: npm install. Continue without it, or propose an alternative.` - **User deny with feedback** (§6 panel offers an optional one-line reason): `The user declined to allow: npm install — reason: "use pnpm in this repo". Adapt your approach accordingly.` (OpenCode's `CorrectedError` — rejection becomes course correction.) --- ## 6. TUI panel and persistence ### 6.1 Panel (CLAUDE.md §13 — inline, milliseconds, keyboard-native) ``` ╭─ KHAELOR requests permission ─────────────────────────────╮ │ Run process.execute │ │ npm install │ │ │ │ Working directory │ │ ~/dev/project │ │ │ │ ⚠ Installs packages (writes node_modules, lockfile) │ │ │ │ [ Enter ] Allow once │ │ [ A ] Always allow "npm install *" in this project │ │ [ Esc ] Deny [ Tab ] Details │ ╰───────────────────────────────────────────────────────────╯ ``` Contents, top to bottom: **verb + capability badge** (right-aligned; `Run`/`Write`/`Edit`/`Start process`/`Read`); the **subject** (command text, or path); **working directory**; **risk notes** from `CapabilityRequest.riskNotes` (compound-command warning, outside-project path, hardline-adjacent notes) — only when present; keys. - **Metadata-driven bodies** (OpenCode §8.3): `write`/`edit` requests render the actual unified diff (scrollable within the panel); bash shows the command with operator tokens visually marked for compound commands; `process start` notes "keeps running in the background". - `[ A ]` appears **only when `alwaysPatterns` is non-empty** (§3.3 refusal rule) and shows the exact pattern it will persist. Multiple candidate patterns → `A` cycles specificity (`npm run dev` ↔ `npm run *`), current choice always visible. - `[ Tab ]` expands details: every capability request in the combined decision, the matched rule + source for each, resolved absolute paths. - `[ Esc ]` denies immediately; the panel then offers a single optional line: `Reason (Enter to skip): _` — feeding §5.4. - Never a bare `Allow? y/n`. The composer is disabled while a panel is open; the panel is fully keyboard-driven and monochrome-safe (symbols, not color alone — CLAUDE.md §19). ### 6.2 Persistence of "always allow" (fixing OpenCode's flaw — ADR-9) `A` appends, immediately and atomically, to the **project** config: ```jsonc // .khaelor/config.json (created if missing) { "permissions": { "rules": [ { "capability": "process.execute", "pattern": "npm install *", "action": "allow" } // appended entries keep chronological order → last-match-wins keeps newest decision authoritative ] } } ``` - Written via read-modify-write with comment/key-order preservation and an atomic rename; a malformed config file fails the write loudly (the grant still applies in-memory for the session; the user is told the file could not be updated). - Grants survive restarts by construction — they are ordinary rules on the next load (OpenCode v1's in-memory-only `always` is the named flaw this fixes). - **User-level rules** live in `~/.khaelor/config.json` with identical syntax; users edit them by hand or via `/permissions`. The panel itself only writes project scope in V1 (a per-user grant from a transient prompt is too broad a default; `/permissions` offers promotion). - Precedence recap (§4.2): defaults < user < project < session — project grants therefore override user-level `ask`s, and either can be overridden by a later project `deny`. - `/permissions` lists effective rules with source + match provenance, supports delete/reorder, and is the audit-friendly mirror of the config files. --- ## 7. Safety invariants 1. **No bypass path.** Tools execute only via the Executor, which requires a recorded `ToolApproved` for the exact `callId`. `ToolDefinition.execute` is not reachable otherwise; tools cannot spawn processes or touch files except through `Workspace`/`ProcessManager` (ADR-13 lint guard), which are handed out only inside approved execution contexts. 2. **Deny shapes the tool list** (OpenCode §8.3 `Permission.disabled`). If a capability that is a tool's *sole* possible mapping is denied for `*` (e.g. `file.write.*: deny`), the registry removes `write`/`edit` from the tools array sent to the model (`ToolRegistry.available(policy)`, TOOL_PROTOCOL §1.2) — the model never wastes a turn requesting the impossible. Partial denies (pattern-scoped) keep the tool visible. 3. **Hardline floor is unbypassable** — checked before rules, immune to config, matched on de-obfuscated text (§4.2). 4. **Fail closed.** Analyzer errors, lexer failures, unresolvable paths → treat as `obfuscated`/unknown → at least `ask`, never silent `allow` (OpenHands: analysis errors default HIGH). 5. **Deterministic rules are primary.** No LLM self-assessed risk as a gate in V1 (OpenHands NOT-COPY #10); risk notes are analyzer-derived facts. 6. **Honesty note (recorded limitation).** V1 permission enforcement gates *what the agent asks to do*, not what a running binary does — `network.access` detection is a UX signal, not an egress firewall, and §3.5's outside-project detection is best-effort on complex commands (ADR-9 ⚠). The mitigations are the operator-refusal rule, the `ask` defaults, and the hardline floor; the structural upgrade is tree-sitter parsing (planned, post-V1). 7. **Session grants never outlive their scope.** "Allow once" covers exactly one `callId`. Nothing is silently widened. --- ## 8. Audit trail All of `ToolRequested`, `ToolApproved`, `PermissionRequested`, `PermissionGranted`, `PermissionDenied`, `ToolFailed` are **durable** events in the session JSONL (ADR-3/ADR-4), each carrying: `callId`, the capability requests, the matched rule and its `source`, scope of grants (`once`/`always` + persisted pattern), and denial feedback. Consequences: - `/permissions` and session replay can reconstruct *why* any action ran: which rule, from which file, granted by whom. - Pending approvals survive restart (§5.2) because the request is in the log and the grant is absent. - The event log is the compliance story: no permission decision is ever in-memory-only, even though "once" grants are never written to config. --- ## 9. Conformance checklist (tests to ship with `src/permissions/`) - [ ] Evaluator: last-match-wins, wildcard on both fields, unmatched → ask; property tests over rule orderings. - [ ] Layer concatenation: defaults < user < project < session; shorthand/nested/rules normalization preserves source order. - [ ] Hardline floor: fires on de-obfuscated variants (`rm -rf "/"`, `rm -rf $HOME`); unoverridable by allow rules. - [ ] Lexer: quotes, escapes, operators, substitution flags; golden classification table (simple/compound/obfuscated). - [ ] Suggestion generation: arity table goldens; **no `alwaysPatterns` for any compound/obfuscated command**. - [ ] Per-tool mapping goldens, incl. path resolution tricks (`../`, symlinks) flipping project ↔ outsideProject. - [ ] Combination rule: deny > ask > allow across multi-request calls; one panel per call. - [ ] Persistence: `A` writes the exact rule to `.khaelor/config.json` atomically; reload honors it; malformed config fails loudly with in-memory fallback. - [ ] Deny-shapes-tools: `file.write.*: deny` removes write/edit from the Anthropic tools array. - [ ] Denial feedback reaches the model verbatim; audit events durable and replayable. - [ ] Restart with pending `PermissionRequested` re-presents the panel. --- *Author: Simon-Pierre Boucher · contact@spboucher.ai*