feat(settings): rebuild destructive-command cover in autoMode, scope the classifier environment

`permissions.ask` gates nothing under `defaultMode: auto` (LRN-146,
verified live), so the ten rules that left the static tiers had no cover
left: rsync / kill -9 / killall / pkill out of deny, and python3 -c /
python -c / xargs / sed / cp / mv out of ask.

autoMode.soft_deny (7 rules) takes over what an explicit instruction
should be able to clear: writes outside the working directory,
rsync --delete, SIGKILL and kill-by-name, in-place edits spanning more
than one file, directory moves, and inline interpreters or xargs that
delete or write outside the cwd. Intent clears a soft block for the
current turn only, stated as a rule since no setting expresses it.

autoMode.hard_deny (3 rules) takes the classes no command pattern can
express: secret exfiltration, production deployment, and disarming the
guardrails. Adding a restriction stays allowed, removing one does not.

permissions.deny gains ten .env reader rules (sed awk cut tr sort uniq
diff od xxd strings). Six of those tools sat in permissions.allow, so
reading a .env through them triggered nothing.

autoMode.environment named another project, its FTP deploy target and its
customer data, inside the file link.sh:21 symlinks to
~/.claude/settings.json, where it reached every repo and contradicted
this one's Gitea remote. Rewritten machine-generic; the project facts
moved to that project's gitignored .claude/settings.local.json. All three
lists now open with "$defaults", which the original omitted, so the
built-in classifier entries are inherited rather than replaced.

doctor.sh check_automode backstops both defects. SETTINGS.md documents
the block and a tier-choice table. README no longer claims the ask tier
makes every mcp__magic__* call require a live confirmation.
This commit is contained in:
Bastien Chanot
2026-09-15 19:44:24 +02:00
parent 3228acabfc
commit 3b0167c6cb
5 changed files with 226 additions and 15 deletions
+57
View File
@@ -215,6 +215,61 @@ echo ""
# ────────────────────────────────────────────────────────────
# 5. Permissions check
# ────────────────────────────────────────────────────────────
# Under defaultMode auto the classifier reads `autoMode`, so a block scoped
# to ONE project feeds every other project false facts, and a list without
# "$defaults" silently drops the built-in rules. Neither is visible from the
# deny count. Emits TAG|message lines for the caller to dispatch.
inspect_automode() {
REPO="$REPO" python3 - "$SETTINGS" <<'PY'
import json, os, re, sys
settings = json.load(open(sys.argv[1]))
mode = settings.get("permissions", {}).get("defaultMode")
block = settings.get("autoMode") or {}
if mode != "auto":
sys.exit(print("INFO|defaultMode is %s, autoMode not consulted" % mode))
if not block:
sys.exit(print("WARN|defaultMode is auto but no autoMode block set"))
sections = [k for k in ("allow", "soft_deny", "hard_deny", "environment")
if k in block]
bare = [k for k in sections if "$defaults" not in block[k]]
if bare:
print('WARN|autoMode.%s replaces the built-in entries (no "$defaults")'
% ", ".join(bare))
else:
print('PASS|autoMode: %s inherit "$defaults"' % ", ".join(sections))
repo, home = os.environ["REPO"], os.path.expanduser("~")
foreign = {q for entry in block.get("environment", [])
for q in re.findall(r"`(/[^`]+)`", entry)
if (p := q.rstrip("/")).startswith(home) and p != repo
and os.path.isdir(os.path.join(p, ".git"))}
if foreign:
print("WARN|autoMode.environment names another repo (%s); this file is "
"user-scope and reaches every project" % ", ".join(sorted(foreign)))
else:
print("PASS|autoMode.environment is not scoped to a foreign repo")
PY
}
check_automode() {
local out tag msg
if ! out=$(inspect_automode 2>/dev/null); then
warn "Could not inspect the autoMode block"
return
fi
while IFS='|' read -r tag msg; do
case "$tag" in
PASS) pass "$msg" ;;
WARN) warn "$msg" ;;
INFO) info "$msg" ;;
esac
done <<< "$out"
}
echo "── Permissions ──"
SETTINGS="$HOME/.claude/settings.json"
@@ -251,6 +306,8 @@ print(len(json.load(sys.stdin).get('permissions',{}).get('deny',[])))
warn "Deny rules: $DENY_COUNT (committed: $EXPECTED_DENY) — live settings diverge from last commit"
fi
fi
check_automode
else
fail "$HOME/.claude/settings.json not found"
fi