safety: permission policy DSL — allow/confirm/deny rule lists (closes #9)
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 <cmd> — 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) <noreply@anthropic.com>
This commit is contained in:
+11
@@ -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 <cmd>.
|
||||
-- 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.
|
||||
|
||||
@@ -125,6 +125,8 @@ Meta commands:
|
||||
:plan on / :plan off set plan mode explicitly
|
||||
:safety patterns list active destructive-op patterns
|
||||
:safety check <cmd> probe is_destructive against <cmd> without running
|
||||
:perms list show configured permission rules (allow/confirm/deny)
|
||||
:perms check <cmd> report which permission verdict <cmd> would receive
|
||||
:remember <text> shortcut: :memory add fact <text>
|
||||
:memory list show active memory items (id, ts, kind, content)
|
||||
:memory add <kind> <t> 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 <cmd>"); 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 {}
|
||||
|
||||
+31
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user