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/hostsisfile.write.outsideProject. - One tool call may map to multiple capability requests (a
bashcall can carryprocess.execute+network.access+git.modify). Combination rule (§4.4):denybeatsaskbeatsallow; every request must resolveallowfor silent approval. filesystem.outsideProjectfrom CLAUDE.md §13 is realized as the*.outsideProjectvariants plus best-effort bash detection (§3.4); reads outside the project stay underfile.read(pattern rules can still gate them, e.g.file.readon/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:
/**
* 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<string, unknown>;
}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:<command of target process> (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>&1and newlines. - Flags substitution markers anywhere in the string:
$(, backtick,<(,>(,${. - Produces:
{ words: string[][], operators: string[], hasSubstitution: boolean }— onewordslist 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
curlin a pipeline makes the whole command carrynetwork.access). Evaluation may still auto-allow a compound command only if every part matches anallowrule and none matchesask/deny— otherwise oneaskfor the whole command. - obfuscated: derived analysis is skipped as unreliable; the command carries
process.execute(+network.accessconservatively when net-tool names appear anywhere in the raw text) and always at leastaskunless 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.
// src/permissions/arity.ts (excerpt) — prefix word-counts per tool
const ARITY: Record<string, number | Record<string, number>> = {
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.accesswhen 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) stayprocess.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 theaskpanel, not a security boundary (a deniednetwork.accesscannot stop a novel binary from opening a socket — see §7 honesty note).git.modifywhen word 0 isgitand 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, branchlisting,ls-files,rev-parse,describe,fetch --dry-run) remains plainprocess.executeand 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
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):
// .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:
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 → allowFor 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):
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 toallow; 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. BecausePermissionRequestedis 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 everyaskasdenywith messageKHAELOR 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'sCorrectedError— 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/editrequests render the actual unified diff (scrollable within the panel); bash shows the command with operator tokens visually marked for compound commands;process startnotes "keeps running in the background". [ A ]appears only whenalwaysPatternsis non-empty (§3.3 refusal rule) and shows the exact pattern it will persist. Multiple candidate patterns →Acycles 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:
// .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
alwaysis the named flaw this fixes). - User-level rules live in
~/.khaelor/config.jsonwith 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;/permissionsoffers promotion). - Precedence recap (§4.2): defaults < user < project < session — project grants therefore override user-level
asks, and either can be overridden by a later projectdeny. /permissionslists effective rules with source + match provenance, supports delete/reorder, and is the audit-friendly mirror of the config files.
7. Safety invariants
- No bypass path. Tools execute only via the Executor, which requires a recorded
ToolApprovedfor the exactcallId.ToolDefinition.executeis not reachable otherwise; tools cannot spawn processes or touch files except throughWorkspace/ProcessManager(ADR-13 lint guard), which are handed out only inside approved execution contexts. - 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 removeswrite/editfrom 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. - Hardline floor is unbypassable — checked before rules, immune to config, matched on de-obfuscated text (§4.2).
- Fail closed. Analyzer errors, lexer failures, unresolvable paths → treat as
obfuscated/unknown → at leastask, never silentallow(OpenHands: analysis errors default HIGH). - Deterministic rules are primary. No LLM self-assessed risk as a gate in V1 (OpenHands NOT-COPY #10); risk notes are analyzer-derived facts.
- Honesty note (recorded limitation). V1 permission enforcement gates what the agent asks to do, not what a running binary does —
network.accessdetection 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, theaskdefaults, and the hardline floor; the structural upgrade is tree-sitter parsing (planned, post-V1). - 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:
/permissionsand 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
alwaysPatternsfor 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:
Awrites the exact rule to.khaelor/config.jsonatomically; reload honors it; malformed config fails loudly with in-memory fallback. - Deny-shapes-tools:
file.write.*: denyremoves write/edit from the Anthropic tools array. - Denial feedback reaches the model verbatim; audit events durable and replayable.
- Restart with pending
PermissionRequestedre-presents the panel.
Author: Simon-Pierre Boucher · contact@spboucher.ai