spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1<!--2KHAELOR3File: docs/PERMISSION_MODEL.md4Author: Simon-Pierre Boucher5Contact: contact@spboucher.ai6-->78# KHAELOR Permission Model — V1910> 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).11>12> 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).1314---1516## 1. Capability taxonomy1718Permissions are evaluated against **capabilities**, never tool names (CLAUDE.md §13). V1 capabilities:1920| Capability | Meaning | Subject (the string patterns match against) |21|---|---|---|22| `file.read` | Read file/directory content or metadata inside or outside the project | resolved absolute path |23| `file.write.project` | Create/modify/overwrite a file under the project root | resolved absolute path |24| `file.write.outsideProject` | Create/modify a file outside the project root | resolved absolute path |25| `process.execute` | Run a foreground shell command | the command text |26| `process.background` | Start (or adopt) a long-lived background process | the command text |27| `network.access` | Command whose primary purpose is network I/O (best-effort detection, §3.4) | the command text |28| `git.modify` | Git command that mutates repository state (§3.4) | the command text |2930Notes:3132- 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`.33- 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.34- `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/*`).35- Future capabilities (subagents, MCP, browser) extend this table; the rule format (§4) needs no change.3637### 1.1 `CapabilityRequest`3839Produced by each tool's `capabilities()` function (TOOL_PROTOCOL §1.2) before execution:4041```ts42/**43 * KHAELOR44 * File: src/permissions/types.ts (excerpt — normative shape)45 */4647export type Capability =48 | "file.read"49 | "file.write.project"50 | "file.write.outsideProject"51 | "process.execute"52 | "process.background"53 | "network.access"54 | "git.modify";5556export interface CapabilityRequest {57 capability: Capability;58 /** What rules' patterns match against: resolved path or command text. */59 subject: string;60 /** Human-readable line for the panel, e.g. `Run npm install`. */61 display: string;62 /**63 * Candidate "always allow" patterns, most specific first64 * (e.g. ["git push *"]). EMPTY for compound/obfuscated commands (§3.3)65 * and for anything the analyzer could not classify — the panel then66 * offers exact-subject persistence only, or none.67 */68 alwaysPatterns: string[];69 /** Shown in the panel: why this asks, what is unusual. */70 riskNotes: string[];71 /** UI extras: diff for writes, parsed command parts, cwd. Never model-facing. */72 metadata?: Record<string, unknown>;73}74```7576---7778## 2. Per-tool capability mapping7980The mapping function of each tool (`capabilities(input, ctx)`), normative:8182| Tool | Mapping |83|---|---|84| `read` | `file.read` — subject: resolved `file_path`. |85| `glob` | `file.read` — subject: resolved `path` (default cwd). |86| `grep` | `file.read` — subject: resolved `path` (default cwd). |87| `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. |88| `edit` | Identical to `write` (OpenCode folds `write` into `edit` policy-wise; KHAELOR folds both into the `file.write.*` capabilities). Metadata carries the diff. |89| `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). |90| `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). |9192Anything not expressible above is a design error: a new behavior needs either a new capability or an ADR amendment — never a bypass.9394---9596## 3. Bash command analysis (V1: conservative shell-word parsing)9798Per 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).99100### 3.1 Tokenization101102A small, dependency-free shell-word lexer:103104- Splits on whitespace; respects single quotes, double quotes, and backslash escapes.105- Recognizes operator tokens: `&&` `||` `;` `|` `&` `>` `>>` `<` `<<` `2>` `2>&1` and newlines.106- Flags **substitution markers** anywhere in the string: `$(`, backtick, `<(`, `>(`, `${`.107- Produces: `{ words: string[][], operators: string[], hasSubstitution: boolean }` — one `words` list per simple command in the pipeline/chain.108109### 3.2 Classification110111```112simple — exactly one command, no operators, no substitution113compound — ≥2 commands (&&, ||, ;, |, &) — each simple part analyzed individually114obfuscated— substitution present ($(), ``, ${}), OR quoted operator smuggling115 (an argument that itself lexes into operators for sh -c / bash -c /116 eval / xargs), OR lexer failure117```118119- **simple:** full analysis — arity suggestions (§3.3), network/git detection (§3.4), outside-project path checks (§3.5).120- **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.121- **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.`122123### 3.3 "Always allow" suggestion generation — and the refusal rule124125**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).126127For `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.128129```ts130// src/permissions/arity.ts (excerpt) — prefix word-counts per tool131const ARITY: Record<string, number | Record<string, number>> = {132 git: { "*": 2, config: 3, remote: 3, stash: 3, submodule: 3 },133 npm: { "*": 2, run: 3, exec: 3 },134 pnpm: { "*": 2, run: 3 }, yarn: { "*": 2, run: 3 },135 npx: 2, node: 2, python: 2, python3: 2, pip: 2, pip3: 2,136 cargo: 2, go: 2, make: 2, docker: { "*": 2, compose: 3 },137 kubectl: 2, gh: 3, brew: 2, ls: 1, cat: 1, mkdir: 1, touch: 1,138};139```140141Generation: 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`.142143### 3.4 Network and git detection (simple commands)144145- `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).146- `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).147148### 3.5 Outside-project filesystem checks (best-effort — ADR-9 ⚠ concern)149150For 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.151152---153154## 4. Policy: rules, precedence, evaluation155156### 4.1 Rule format157158```ts159export type PermissionAction = "allow" | "ask" | "deny";160161export interface PermissionRule {162 /** Capability pattern; wildcards allowed: "file.write.*", "*". */163 capability: string;164 /** Subject pattern; wildcards allowed: "git push *", "/Users/x/notes/*". Default "*". */165 pattern?: string;166 action: PermissionAction;167 /** Provenance, filled by the loader: "default" | "user" | "project" | "session". */168 source?: string;169}170```171172Wildcard 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.173174**Config file forms** (both accepted; CLAUDE.md §13 shows the shorthand):175176```jsonc177// .khaelor/config.json — "permissions" section178{179 "permissions": {180 // shorthand: capability → action181 "file.read": "allow",182 // nested: capability → { subject-pattern → action }, key order preserved183 "process.execute": {184 "git status": "allow",185 "git push *": "allow",186 "*": "ask"187 },188 // explicit ordered rules (appended after the shorthand expansion)189 "rules": [190 { "capability": "file.write.outsideProject", "pattern": "/Users/x/notes/*", "action": "allow" }191 ]192 }193}194```195196Normalization expands shorthand/nested forms into `PermissionRule[]` **in source key order** (the loader preserves JSON key order), then appends `rules`.197198### 4.2 Layering and the hardline floor199200The effective ruleset is plain array concatenation (OpenCode's `merge()`), later layers win by position:201202```203DEFAULTS (built-in, §4.5)204 ++ user rules (~/.khaelor/config.json → permissions)205 ++ project rules (.khaelor/config.json → permissions)206 ++ session grants (in-memory "allow once" bookkeeping; "always" grants are207 written to the project file and re-loaded, §6)208```209210**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):211212```213rm -rf / rm -rf /* rm -rf ~ rm -rf $HOME214mkfs* dd * of=/dev/* chmod -R 777 / chown -R * /215:(){ :|:& };: shutdown* reboot* halt*216git push * --force * (to a branch matching main|master, when detectable)217> /dev/sd*218```219220Hardline 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).221222### 4.3 Evaluation algorithm — last match wins223224The OpenCode-style ~4-line evaluator, exactly:225226```ts227export function evaluate(rules: PermissionRule[], req: CapabilityRequest): Decision {228 if (HARDLINE.some((h) => matchHardline(h, req))) return { action: "deny", rule: HARDLINE_RULE };229 const rule = rules.findLast(230 (r) => wildcard(r.capability, req.capability) && wildcard(r.pattern ?? "*", req.subject),231 );232 return { action: rule?.action ?? "ask", rule }; // unmatched default: ask233}234```235236Properties (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).237238### 4.4 Combining multiple requests per tool call239240A tool call producing requests `R1..Rn` is decided as:241242```243any deny → deny (the denied request named in the failure message)244else any ask → ask (ONE combined panel listing all asking requests)245else → allow246```247248For compound bash commands, §3.2's per-part union feeds this the same way. One tool call never produces more than one panel.249250### 4.5 Default policy shipped with V1251252Safe but not annoying (CLAUDE.md §13; defaults calibrated against OpenCode's — OPENCODE §8.1):253254```ts255export const DEFAULT_RULES: PermissionRule[] = [256 // reads: free, except secrets-shaped files257 { capability: "file.read", pattern: "*", action: "allow" },258 { capability: "file.read", pattern: "*.env", action: "ask" },259 { capability: "file.read", pattern: "*.env.*", action: "ask" },260 { capability: "file.read", pattern: "*.env.example", action: "allow" },261 { capability: "file.read", pattern: "*.pem", action: "ask" },262 { capability: "file.read", pattern: "*/.ssh/*", action: "ask" },263264 // writes: project free, outside asks265 { capability: "file.write.project", pattern: "*", action: "allow" },266 { capability: "file.write.outsideProject", pattern: "*", action: "ask" },267268 // commands: ask by default, with a read-only allowlist so common269 // inspection never prompts (the arity suggester grows this per project)270 { capability: "process.execute", pattern: "*", action: "ask" },271 { capability: "process.execute", pattern: "git status*", action: "allow" },272 { capability: "process.execute", pattern: "git diff*", action: "allow" },273 { capability: "process.execute", pattern: "git log*", action: "allow" },274 { capability: "process.execute", pattern: "git show*", action: "allow" },275 { capability: "process.execute", pattern: "git branch", action: "allow" },276 { capability: "process.execute", pattern: "ls*", action: "allow" },277 { capability: "process.execute", pattern: "pwd", action: "allow" },278 { capability: "process.execute", pattern: "which *", action: "allow" },279 { capability: "process.execute", pattern: "cat *", action: "allow" },280 { capability: "process.execute", pattern: "wc *", action: "allow" },281 { capability: "process.execute", pattern: "head *", action: "allow" },282 { capability: "process.execute", pattern: "tail *", action: "allow" },283284 // stdin to an already-approved background process: allowed285 { capability: "process.background", pattern: "stdin:*", action: "allow" },286 { capability: "process.background", pattern: "*", action: "ask" },287288 { capability: "network.access", pattern: "*", action: "ask" },289 { capability: "git.modify", pattern: "*", action: "ask" },290];291```292293The 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*.294295---296297## 5. The permission request flow298299### 5.1 Event flow (all durable — ADR-4, audit §8)300301```302Executor decodes tool_use303 → emit ToolRequested{callId, tool, input, requests: CapabilityRequest[]}304 → decision = combine(evaluate(rules, r) for r in requests) (§4.3–4.4)305306 allow → emit ToolApproved{callId, decisions} → execute()307 ask → emit PermissionRequested{callId, requests, decisions}308 → TUI panel (§6)309 → user grants → emit PermissionGranted{callId, scope: "once"|"always",310 persistedRule?} → ToolApproved → execute()311 → user denies → emit PermissionDenied{callId, feedback?}312 → ToolFailed (model-facing message, §5.4)313 deny → emit PermissionDenied{callId, byRule} → ToolFailed (§5.4) (no panel)314```315316### 5.2 Concurrency and timeout semantics317318- 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).319- **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.320- Non-interactive invocations (future `khaelor run`) resolve every `ask` as `deny` with message `KHAELOR is running non-interactively; interactive approval is unavailable.`321322### 5.3 Escalation inside execution323324`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.)325326### 5.4 Denial is steering, not a dead end (ADR-9)327328Model-facing `ToolFailed` content:329330- **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.`331- **User deny, no feedback:** `The user declined to allow: npm install. Continue without it, or propose an alternative.`332- **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.)333334---335336## 6. TUI panel and persistence337338### 6.1 Panel (CLAUDE.md §13 — inline, milliseconds, keyboard-native)339340```341╭─ KHAELOR requests permission ─────────────────────────────╮342│ Run process.execute │343│ npm install │344│ │345│ Working directory │346│ ~/dev/project │347│ │348│ ⚠ Installs packages (writes node_modules, lockfile) │349│ │350│ [ Enter ] Allow once │351│ [ A ] Always allow "npm install *" in this project │352│ [ Esc ] Deny [ Tab ] Details │353╰───────────────────────────────────────────────────────────╯354```355356Contents, 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.357358- **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".359- `[ 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.360- `[ Tab ]` expands details: every capability request in the combined decision, the matched rule + source for each, resolved absolute paths.361- `[ Esc ]` denies immediately; the panel then offers a single optional line: `Reason (Enter to skip): _` — feeding §5.4.362- 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).363364### 6.2 Persistence of "always allow" (fixing OpenCode's flaw — ADR-9)365366`A` appends, immediately and atomically, to the **project** config:367368```jsonc369// .khaelor/config.json (created if missing)370{371 "permissions": {372 "rules": [373 { "capability": "process.execute", "pattern": "npm install *", "action": "allow" }374 // appended entries keep chronological order → last-match-wins keeps newest decision authoritative375 ]376 }377}378```379380- 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).381- 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).382- **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).383- 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`.384- `/permissions` lists effective rules with source + match provenance, supports delete/reorder, and is the audit-friendly mirror of the config files.385386---387388## 7. Safety invariants3893901. **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.3912. **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.3923. **Hardline floor is unbypassable** — checked before rules, immune to config, matched on de-obfuscated text (§4.2).3934. **Fail closed.** Analyzer errors, lexer failures, unresolvable paths → treat as `obfuscated`/unknown → at least `ask`, never silent `allow` (OpenHands: analysis errors default HIGH).3945. **Deterministic rules are primary.** No LLM self-assessed risk as a gate in V1 (OpenHands NOT-COPY #10); risk notes are analyzer-derived facts.3956. **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).3967. **Session grants never outlive their scope.** "Allow once" covers exactly one `callId`. Nothing is silently widened.397398---399400## 8. Audit trail401402All 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:403404- `/permissions` and session replay can reconstruct *why* any action ran: which rule, from which file, granted by whom.405- Pending approvals survive restart (§5.2) because the request is in the log and the grant is absent.406- The event log is the compliance story: no permission decision is ever in-memory-only, even though "once" grants are never written to config.407408---409410## 9. Conformance checklist (tests to ship with `src/permissions/`)411412- [ ] Evaluator: last-match-wins, wildcard on both fields, unmatched → ask; property tests over rule orderings.413- [ ] Layer concatenation: defaults < user < project < session; shorthand/nested/rules normalization preserves source order.414- [ ] Hardline floor: fires on de-obfuscated variants (`rm -rf "/"`, `rm -rf $HOME`); unoverridable by allow rules.415- [ ] Lexer: quotes, escapes, operators, substitution flags; golden classification table (simple/compound/obfuscated).416- [ ] Suggestion generation: arity table goldens; **no `alwaysPatterns` for any compound/obfuscated command**.417- [ ] Per-tool mapping goldens, incl. path resolution tricks (`../`, symlinks) flipping project ↔ outsideProject.418- [ ] Combination rule: deny > ask > allow across multi-request calls; one panel per call.419- [ ] Persistence: `A` writes the exact rule to `.khaelor/config.json` atomically; reload honors it; malformed config fails loudly with in-memory fallback.420- [ ] Deny-shapes-tools: `file.write.*: deny` removes write/edit from the Anthropic tools array.421- [ ] Denial feedback reaches the model verbatim; audit events durable and replayable.422- [ ] Restart with pending `PermissionRequested` re-presents the panel.423424---425426*Author: Simon-Pierre Boucher · contact@spboucher.ai*427