From 17e62c0326b988c2fa40d9346de985f53850734c Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 21:20:56 +0000 Subject: [PATCH] =?UTF-8?q?safety:=20permission=20policy=20DSL=20=E2=80=94?= =?UTF-8?q?=20allow/confirm/deny=20rule=20lists=20(closes=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirm_cmd boolean was too coarse: true interrupts every harmless ls; false ungates everything. Most workflows want trust for read-only ops while still gating writes/network/sudo. New config: permissions = { allow = { "^ls%s", "^cat%s", "^git status" }, confirm = { "^rm%s", "^git push", "^docker%s", "^sudo%s" }, deny = { "^ssh%s+root@", "^curl%s+http[^s]" }, } Verdict order: deny > confirm > allow. First match in the chosen category wins. Unmatched defaults to "confirm". Patterns are Lua patterns (not regex) per PHASE0.md §3 — no compiled extensions. Verdict behavior in the interactive CMD: loop: - allow → run without prompt - deny → status line, skip - confirm → [y/N] prompt (same UX as legacy confirm_cmd=true) Backward compat: - permissions unset + confirm_cmd=true → always confirm - permissions unset + confirm_cmd=false → always allow - permissions set → policy table is authoritative Scope deliberately limited to the interactive AI-suggested CMD: gate. Norris autonomous mode keeps its own safety.is_destructive machinery (combining the two would double-gate or replace the LLM probe — both non-obvious behavioral changes that belong in their own issues). User-typed shell-routed lines (`router.classify → "shell"`) and :exec also bypass the policy by design — those are direct user intent. New introspection: :perms list — show the configured rule lists :perms check — report verdict + matching rule (debug) safety.classify_command is exported and unit-tested with 12 cases covering each category, priority order (deny > allow on overlap), and both fallback paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.lua | 11 +++++++++++ repl.lua | 48 ++++++++++++++++++++++++++++++++++++++++++++---- safety.lua | 31 +++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/config.lua b/config.lua index ae06aab..9d2567f 100644 --- a/config.lua +++ b/config.lua @@ -71,6 +71,17 @@ return { -- post_cmd = (os.getenv("HOME") or ".") .. "/.aish/hooks/post-cmd", -- }, + -- Issue #9: permission policy DSL for AI-suggested CMD: lines. When set, + -- supersedes shell.confirm_cmd. Patterns are Lua patterns (NOT regex) + -- per substrate invariant §3 (no compiled extensions). Priority order: + -- deny > confirm > allow; first match in the chosen category wins. + -- Unmatched commands default to "confirm". Probe with :perms check . + -- permissions = { + -- allow = { "^ls%s", "^cat%s", "^git status", "^git diff" }, + -- confirm = { "^rm%s", "^git push", "^docker%s", "^sudo%s" }, + -- deny = { "^ssh%s+root@", "^curl%s+http[^s]" }, + -- }, + -- Phase 2 (docs/PHASE2.md): MCP server registry + tool-call policy. -- The block is OFF by default — connect-at-startup happens only when -- `servers` is non-empty. Uncomment + adjust per your fleet. diff --git a/repl.lua b/repl.lua index 521c144..fb86c63 100644 --- a/repl.lua +++ b/repl.lua @@ -125,6 +125,8 @@ Meta commands: :plan on / :plan off set plan mode explicitly :safety patterns list active destructive-op patterns :safety check probe is_destructive against without running + :perms list show configured permission rules (allow/confirm/deny) + :perms check report which permission verdict would receive :remember shortcut: :memory add fact :memory list show active memory items (id, ts, kind, content) :memory add add a memory item (kind: fact | pref | context) @@ -755,12 +757,19 @@ function M.run(config) renderer.status(("PLAN: %s"):format(cmd)) ctx:append_exec_output(("[plan] would run: %s"):format(cmd)) else - local doit - if config.shell and config.shell.confirm_cmd then + -- Issue #9: permission policy DSL — verdict drives the gate. + -- Falls back to shell.confirm_cmd boolean when config.permissions + -- is unset (backward compat). + local verdict, rule = safety.classify_command(cmd, config) + local doit = false + if verdict == "allow" then + doit = true + elseif verdict == "deny" then + renderer.status(("denied by policy [%s]: %s") + :format(rule or "default", cmd)) + else -- "confirm" local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or "" doit = (ans:lower():sub(1, 1) == "y") - else - doit = true end if doit then run_shell(cmd) end end @@ -1293,6 +1302,37 @@ function M.run(config) renderer.status("usage: :safety {patterns|check}") end end, + perms = function(args) + local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") + if sub == "list" or sub == "" then + local p = config.permissions + if not p then + renderer.status(("(no permissions set; fallback: confirm_cmd=%s)") + :format(tostring(config.shell and config.shell.confirm_cmd or false))) + return + end + local function dump(label, rules) + if not rules or #rules == 0 then return end + io.write((" %s:\n"):format(label)) + for i, r in ipairs(rules) do + io.write((" %2d. %s\n"):format(i, r)) + end + end + renderer.status("permissions (deny > confirm > allow; default verdict: confirm):") + dump("deny", p.deny) + dump("confirm", p.confirm) + dump("allow", p.allow) + elseif sub == "check" then + local cmd = sub_args:match("^%s*(.-)%s*$") + if not cmd or cmd == "" then + renderer.status("usage: :perms check "); return + end + local v, rule = safety.classify_command(cmd, config) + renderer.status(("verdict=%s rule=%s"):format(v, rule or "(default)")) + else + renderer.status("usage: :perms {list|check}") + end + end, route = function(args) local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") config.routing = config.routing or {} diff --git a/safety.lua b/safety.lua index 158187d..15575e2 100644 --- a/safety.lua +++ b/safety.lua @@ -3,6 +3,8 @@ -- Phase 3: M.is_destructive (static pattern + LLM second-opinion gate for -- Norris autonomous mode) and M.norris_step (single-iteration -- planning loop). See docs/PHASE2.md §6 and docs/PHASE3.md §4 / §5. +-- Issue #9: M.classify_command (allow/confirm/deny rule list — interactive +-- CMD: gate, supersedes the confirm_cmd boolean when configured). local rl = require("ffi.readline") local json = require("dkjson") @@ -10,6 +12,35 @@ local broker = require("broker") local M = {} +-- ---------------------------------------------------------------- classify_command +-- Walk config.permissions (allow / confirm / deny rule lists) against `cmd` +-- in priority order: deny > confirm > allow. First match in the chosen +-- category wins. Returns the verdict string and the matching pattern (for +-- status messages); falls back to the legacy confirm_cmd boolean when no +-- permissions table is configured. Default verdict when permissions is set +-- but no rule matches is "confirm" — per the issue body. +-- verdict ∈ "allow" | "confirm" | "deny" +local function _match_any(cmd, rules) + if not rules then return nil end + for _, p in ipairs(rules) do + if cmd:find(p) then return p end + end + return nil +end +function M.classify_command(cmd, cfg) + local perms = cfg and cfg.permissions + if perms then + local mp = _match_any(cmd, perms.deny); if mp then return "deny", mp end + mp = _match_any(cmd, perms.confirm); if mp then return "confirm", mp end + mp = _match_any(cmd, perms.allow); if mp then return "allow", mp end + return "confirm", nil + end + if cfg and cfg.shell and cfg.shell.confirm_cmd then + return "confirm", nil + end + return "allow", nil +end + -- Render the call as `name({"path":"/tmp"})` for the confirm prompt. -- Truncate to keep one-line prompts. local function pretty_call(name, args)