SPB Git

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%
27.4 KB

# 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<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 / readno request (pure observation of KHAELOR-owned state — read-only short-circuit, OpenHands §6). writeprocess.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>&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

text
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<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 maingit push *; npm run devnpm 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:

text
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):

text
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:

text
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)

text
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)

text
╭─ 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 devnpm 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 asks, 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