Repository navigation
fix(permissions): fail closed when a settings file cannot be read or parsed - #4287
Conversation
…parsed A Claude Code settings file that exists but cannot be read or parsed adds a catch-all Ask rule and a warning instead of being skipped. Refs rtk-ai#4282.
📊 Automated PR Analysis
SummaryFixes rtk's permission rule loader so that a settings.json file which exists but fails to read or parse no longer counts as having no rules. Instead, it now prints a warning and injects a catch-all Ask rule, ensuring commands fail closed (deny/ask) rather than falling through to Default. The loader body was also extracted into a testable load_permission_rules_from(paths) function with accompanying unit tests. Review Checklist
Linked issues: #4282 Analyzed automatically by wshm · This is an automated analysis, not a human review. |
… parse failure The read-failure warning and the delegate exit code are checked end to end through `rtk rewrite` with `RTK_REWRITE_HOST=openclaw`, for a settings file that cannot be read and one that cannot be parsed. A unit test checks that deny rules from the other settings files still apply after an unparseable one, as they already did after an unreadable one. The new test helpers say why they would panic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KuSh
left a comment
There was a problem hiding this comment.
Round 1 — APPROVED, follow-ups filed
Claim: when a Claude Code settings file exists but cannot be read or parsed, load_permission_rules warns and adds a catch-all Ask, so a delegate that relaxes Default (OpenClaw) no longer turns that file's denied commands into allowed rewrites; deny rules from the other files still apply first.
Scope: accept (round 1, frozen)
Ran: rtk rewrite (no host and RTK_REWRITE_HOST=openclaw) and rtk hook claude, 15 settings-file states (valid, trailing comma, chmod 000, directory, empty, whitespace only, missing, non-UTF-8, BOM + valid JSON, null, // comment, dangling symlink, CLAUDE_CONFIG_DIR pointing at a file, project .claude that is a file, project settings.local.json at chmod 000) × 2 layouts (the file alone; the file next to a valid settings.local.json with deny: Bash(rm *) and allow: Bash(git status)) × 2 commands, so 180 rows per binary. Binaries: merge-base, develop e542dca0b, this head, and this head merged onto develop. Develop and merge-base give identical results. 38 rows change, all in states where a file exists but cannot be read or parsed, plus the two "not a directory" states below. Invariants: non-default targets (project-level and CLAUDE_CONFIG_DIR files), under the hook, split streams (warning on stderr, rewrite on stdout), every decision. Savings: no filter output changes.
Pins: 9 decisions, 9 mutated, 2 survived the suite: removing the read-failure warning, and stopping at an unparseable file so later files' deny rules are lost. Both are now killed by the fixup's tests.
Matrix: executed this round; pin check: executed this round
CI @e27b47d1c: all green (ubuntu, macos, windows). On the fixup head cd9dff3: all green (14 jobs, including macos, ubuntu and both windows targets).
Blocks merge (0)
Filed as follow-ups
- #4651 A settings file that cannot exist, or that holds only a BOM, fails closed. This is in this PR's own classification, but fixing it changes exit codes, so it is not a fixup. Two cases:
.claude/settings.jsonunder a.claudethat is a regular file, or under aCLAUDE_CONFIG_DIRthat points at a file, givesENOTDIR(Windows reportsNotFoundfor the same layout); and asettings.local.jsonholding onlyEF BB BFis not seen as empty, becausetrim()keeps U+FEFF. In both casesgit status, allowed by the other file, goes from exit 0 on develop to exit 3. That is the safe direction, which is why it does not hold this PR. - #4652 The Cursor, Gemini and Droid loaders (
read_json) still drop an unreadable or unparseable file's rules. Outside this PR's hunks, and the PR body already scopes them out. For Droid, a.factory/settings.jsonwithcommandDenylist: ["git push"]plus a trailing comma, or atchmod 000, letsgit push origin mainbe rewritten tortk git push origin main, where it should be left for Droid's own denylist. Cursor'sShell(git push)deny behaves the same way. - #4282 (comment added): a file that parses but has the wrong shape (
null, or"deny": "Bash(git push *)"as a string) still drops its rules silently. That code is outside this PR's hunks.
Checked and correct — no need to re-verify
- A deny rule from another settings file still wins over the fail-closed
Ask:rm -rf buildexits 2 in every broken state. - Valid, missing, empty, whitespace-only, BOM + valid JSON and dangling-symlink files give the same verdicts as develop.
rtk hook claudeproduces the same rewrite as before. The only change is that while a file is broken, an allow rule from another file no longer producespermissionDecision: allow.- Claude Code 2.1.303, run alone against a local API stand-in with a
Bash(canary *)deny insettings.json, does not apply that deny when the file has a trailing comma (in the object or in the array), a//comment or a/* */comment: the command runs. It does apply it with a leading BOM, and rtk reads that file too. So no tested file format is one Claude Code applies and rtk cannot read, and with this PR rtk asks in the cases where Claude Code itself drops the file. rtk discovercoverage is unchanged, because it treatsAsklikeDefault.- Every read failure names the file and the OS error (
EACCES,EISDIR, non-UTF-8,ENOTDIR). - The PR merges cleanly onto develop
e542dca0b. fmt and clippy are clean and the full suite passes on the merge.
Your questions
None asked.
Next
Fixup cd9dff3 pushed and code-reviewed. It adds openclaw_asks_when_a_settings_file_cannot_be_read_or_parsed, which checks through rtk rewrite with RTK_REWRITE_HOST=openclaw that an unreadable file and an unparseable file each give exit 3 and a warning, and test_unparseable_settings_file_keeps_other_files_deny_rules, which checks that an unparseable file does not mask deny rules from the other files. The new test helpers now use expect instead of unwrap. Approving.
Rounds: 1. Threads: 0 open, 0 resolved this round.
Summary
Refs #4282
load_permission_rulesnow prints a warning for both cases (the read failure was silent before) and adds a catch-allAskrule, so every command gets at leastAsk, the verdict nothing relaxes. Deny rules from the other settings files still apply first.load_permission_rules_from(paths)so it can be tested with real files.What changes in practice:
AskandDefaultboth exit 3 fromrtk rewrite, but a delegate that owns approval (RTK_REWRITE_HOST=openclaw, #3909) relaxesDefaultto an allowed rewrite. With a brokensettings.json, a command that file denies was rewritten and allowed there (exit 0); now it exits 3.Not in this PR: the hooks still call
rtk rewrite ... 2>/dev/null, so the warning is not shown in that path, and the Cursor / Gemini / Droid loaders (read_json) are unchanged.Test plan
test_valid_settings_file_rules_apply,test_unparseable_settings_file_fails_closed_to_ask,test_unreadable_settings_file_fails_closed_to_ask,test_unreadable_settings_file_keeps_other_files_deny_rules,test_missing_and_empty_settings_files_add_no_rules.rtk rewrite "git push origin main"on Linux (WSL Ubuntu 24.04),CLAUDE_CONFIG_DIRwith{"permissions":{"deny":["Bash(git push *)"]}}(unreadable = a directory in place ofsettings.json, since the shell runs as root andchmod 000does not block it):developdevelop, openclawcargo test --bin rtkon Linux: 3787 passed, 8 ignored.cargo fmt --all -- --checkandcargo clippy --all-targets(clean) on Windows (MSVC).Written with help from Claude Opus 5.5.