Repository navigation
feat(cli): add [cli] sandbox and approval_policy config defaults - #52
Conversation
…ions The first revision of locez#52 added a dedicated `[permissions] fully_trusted` key. Replace it with a generic `[cli] default_options` setting so the same mechanism also selects the sandbox mode, instead of growing one config key per root flag. `[cli] default_options` accepts a whitespace-separated string or an array of root `merry` options and applies them to every invocation as if they were written before the subcommand. Supported options are --with-sandbox, --no-sandbox, --inner-sandbox, and --fully-trusted. Validation mirrors clap: unsupported entries, repeated options, blank array entries, and more than one sandbox mode are config errors reported at startup for every command. Explicit command-line flags take precedence: a sandbox mode on the command line replaces the configured one, because the three modes are mutually exclusive. --fully-trusted has no negating flag, so a configured default enables it for every session. Cli::apply_default_options mutates the parsed Cli before the sandbox bootstrap decision, so main, the outer re-exec, and cli_route all observe the same effective flags; the sandboxed child re-derives them from the same config. The `[permissions] fully_trusted` key and MerryConfig::fully_trusted are removed. examples/config.toml and README document the new table. Verification: cargo fmt --all -- --check; cargo clippy -p merry-cli --all-targets (only the pre-existing nonminimal_bool warning in tui/projector/tool_output.rs); cargo test -p merry-cli: 603 passed, 5 failed, all five the known host-only sandbox tests (nested bwrap namespace and "mount destination must be absolute") that fail identically on origin/main. Manual smoke with a temporary XDG_CONFIG_HOME confirmed the validation messages and that a configured --with-sandbox makes `merry debug` attempt the outer re-exec. Refs: locez#52 Claude-Session: https://claude.ai/code/session_01XGvvrrRLJvE1KuFSbFBx84
Follow-up on locez#52: the previous revision accepted raw `--flag` strings in `[cli] default_options` and re-parsed them against an allowlist of root flag names. Replace that with four typed values that map directly to modes, so config never carries command-line syntax. `default_options` now takes one value or an array of `with-sandbox`, `no-sandbox`, `inner-sandbox`, and `fully-trusted`. `CliDefaultOption` is a serde enum, so an unknown value (including a raw `--no-sandbox`) is a parse error that lists the accepted names. `CliDefaultOptions` resolves the list to an optional ProcessExecutionMode plus a fully_trusted bool; listing more than one sandbox mode or repeating a value is a config error. Cli::apply_default_options sets the matching flag fields from the resolved mode, with command-line flags still taking precedence and fully-trusted applying to every session. The flag-name allowlist (`cli::DefaultOption`) and its round-trip test are removed. examples/config.toml and README describe the four values. Verification: cargo fmt --all -- --check; cargo clippy -p merry-cli --all-targets (only the pre-existing nonminimal_bool warning in tui/projector/tool_output.rs); cargo test -p merry-cli: 601 passed, 5 failed, all five the known host-only sandbox tests that fail identically on origin/main. Manual smoke with a temporary XDG_CONFIG_HOME confirmed the unknown-variant message for a raw flag, the sandbox-mode conflict error, that a configured with-sandbox makes `merry debug` attempt the outer re-exec, and that inner-sandbox + fully-trusted runs normally. Refs: locez#52 Claude-Session: https://claude.ai/code/session_01XGvvrrRLJvE1KuFSbFBx84
Users who always want a particular sandbox mode or fully trusted review previously had to pass --with-sandbox/--no-sandbox/--inner-sandbox or --fully-trusted on every TUI or `merry run` invocation. Add `[cli] default_options` to config.toml as the persistent equivalent. `default_options` takes one value or an array of four typed values that map to modes: with-sandbox (outer+inner bubblewrap), no-sandbox (unrestricted host), inner-sandbox (inner action sandbox only), and fully-trusted (skip model/host permission review). `CliDefaultOption` is a serde enum, so an unknown value, including a raw `--no-sandbox`, is a parse error that lists the accepted names. `CliDefaultOptions` resolves the list to an optional ProcessExecutionMode plus a fully_trusted bool; listing more than one sandbox mode or repeating a value is a config error reported at startup for every command. Cli::apply_default_options sets the matching flag fields right after config validation and before the sandbox bootstrap decision, so main, the outer re-exec, and cli_route all observe the same effective flags; the sandboxed child re-derives them from the same config. Explicit command-line flags take precedence: a sandbox mode on the command line replaces the configured one because the three modes are mutually exclusive. fully-trusted has no negating flag, so a configured default enables it for every session. Fully trusted review still only skips model and host permission review. Access ceilings, deny_paths, review_paths masking, and network = false are unaffected. The key lives in the user-trusted XDG config only. examples/config.toml documents the four values (commented, nothing preselected; the example-config test asserts this) and README explains precedence. Verification: cargo fmt --all -- --check; cargo clippy -p merry-cli --all-targets (only the pre-existing nonminimal_bool warning in tui/projector/tool_output.rs); cargo test -p merry-cli: 601 passed, 5 failed, all five the known host-only sandbox tests (nested bwrap namespace and "mount destination must be absolute") that fail identically on origin/main. Manual smoke with a temporary XDG_CONFIG_HOME confirmed the unknown-variant message for a raw flag, the sandbox-mode conflict error, that a configured with-sandbox makes `merry debug` attempt the outer re-exec, and that inner-sandbox + fully-trusted runs normally. Claude-Session: https://claude.ai/code/session_01XGvvrrRLJvE1KuFSbFBx84
0e1c74a to
19669aa
Compare
Follow-up on locez#52: the combined `default_options` list mixed two independent axes, the sandbox mode and the review mode, so it needed conflict and repeat validation and an array for the common case of setting both. Split it into one scalar, typed key per axis. `[cli] sandbox` takes "with-sandbox", "no-sandbox", or "inner-sandbox", named after the root flag each stands in for, and maps to ProcessExecutionMode through a serde enum; any other value, including a raw `--no-sandbox`, is a parse error listing the accepted names. `[cli] fully_trusted` is a bool matching `--fully-trusted`. The `[cli]` table still rejects unknown keys, so the old `default_options` key fails to parse with a message naming the two supported keys. MerryConfig::cli_defaults() is now infallible and returns CliDefaults; Cli::apply_defaults sets the matching flag fields, with command-line sandbox flags still taking precedence and fully_trusted applying to every session. examples/config.toml and README describe both keys. Verification: cargo fmt --all -- --check; cargo clippy -p merry-cli --all-targets (only the pre-existing nonminimal_bool warning in tui/projector/tool_output.rs); cargo test -p merry-cli: 600 passed, 5 failed, all five the known host-only sandbox tests that fail identically on origin/main. Manual smoke with a temporary XDG_CONFIG_HOME confirmed the unknown-variant message for a raw flag, the unknown-field message for the old default_options key, that sandbox = "with-sandbox" makes `merry debug` attempt the outer re-exec, and that inner-sandbox plus fully_trusted = true runs normally. Refs: locez#52 Claude-Session: https://claude.ai/code/session_01KdtdhgkdDC8Scm7hprRdzC
`[cli] fully_trusted` and `--fully-trusted` were hard to read: a bool named after trust said nothing about who reviews commands otherwise. Replace both with `approval_policy` / `--approval-policy <POLICY>`, one value per reviewer: - auto (default): derive the reviewer from the sandbox mode as before; `[permissions] no_sandbox_review` now applies only here - model: model-backed review without a human fallback - human: the TUI dialog or `merry run` prompt decides - model-then-human: model first, human when the model cannot decide - trusted: no approval round (the old fully trusted mode) - none: reject every permission request without asking `none` needs a runtime mode that neither approves nor consults a source, so add `PermissionReviewMode::DenyAll`; it denies in `review_permission_request` and never reuses an existing grant. The coding layer replaces `CodingTrustMode` with `CodingApprovalPolicy` and dispatches on it in `CodingPermissionPolicy::for_process_boundary`; explicit reviewer policies ignore the boundary, `auto` keeps the previous derivation. The `[cli]` invariants carry over: `approval_policy = "trusted"` still requires `sandbox = "no-sandbox"` at load time, and a configured `trusted` still yields to `--with-sandbox` / `--inner-sandbox` on the command line. Every other configured policy applies under any sandbox mode, and an explicit flag always wins. Claude-Session: https://claude.ai/code/session_016kj3E1CjQtLCASyAW8WKzm
… to model_then_human
Rename the approval policy values so each one says who reviews, using the
same snake_case spelling in config and on the command line:
- none no approval needed; configured actions run unreviewed
(was "trusted")
- deny reject every permission request (was "none")
- model_only model review only (was "model")
- model_then_human model first, human when the model denies or cannot decide
(was "model-then-human")
- human_only human review only (was "human")
Drop the "auto" value together with `[permissions] no_sandbox_review`: the
default is now `model_then_human` under every sandbox mode instead of a
reviewer derived from the sandbox boundary, so the derivation and its
no-sandbox knob have nothing left to decide. `CodingPermissionPolicy::
for_process_boundary` becomes `for_approval_policy`, and `CodingProcessBoundary`
and `NoSandboxReviewMode` are removed from the coding API.
`model_then_human` now also escalates a model denial to the host instead of
treating it as final: the model is a first pass under that policy, and the
person at the terminal makes the final call whenever the model does not
approve. The host prompt shows the model's rationale as the fallback reason.
`model_only` keeps a model denial final.
The `none` policy keeps the old `trusted` invariants: it requires
`sandbox = "no-sandbox"` in config and a configured `none` applies only
while the effective mode is unrestricted. `examples/config.toml` now spells
out `approval_policy = "model_then_human"` as the shipped default.
Claude-Session: https://claude.ai/code/session_016kj3E1CjQtLCASyAW8WKzm
Generate bash, zsh, fish, elvish, and powershell completion scripts from the same clap definition that parses `merry` arguments, so subcommands, flags, and value lists such as `--approval-policy` stay in sync without a hand-kept list. `main` answers the subcommand right after parsing, before loading `config.toml` or touching the sandbox, so the script prints even when Merry is not configured yet or the current config fails to load. The route keeps a matching arm so it stays total if that short-circuit ever moves. Claude-Session: https://claude.ai/code/session_016kj3E1CjQtLCASyAW8WKzm
| /// | ||
| /// Explicit command-line flags win. The three sandbox modes are mutually | ||
| /// exclusive, so a mode given on the command line keeps a configured | ||
| /// `sandbox` from applying. A configured `approval_policy = "none"` |
There was a problem hiding this comment.
这里不对吧, approval_policy 不是不与 sandbox 强相关么? approval 的语义不是在自己的授权范围内申请上限么?
|
审查版本: 整体分层方向合理,但建议修复下面两处问题后再合并。
Locez: 这里直接按照2个参数互相独立
Locez: run 这里看看是否可以解决,如果不可以,感觉显示拒绝就好了 建议在宿主侧建立专用审批通道,或在外层 re-exec 前打开并受控传递审批所需的文件描述符,保留 职责与代码复用
文件大小与可维护性 本次修改的 Rust 文件均低于仓库的 1000 行约束,建议按职责拆分。
另一个非阻断改进点是使用明确类型表示人工回退原因。permission_execution.rs:411–415 把模型拒绝装进 Locez:这个处理一下 验证结果
上述测试通过并不能覆盖前两项发现:现有配置测试固化了当前耦合规则,PTY 审批组合则缺少对应集成覆盖。 |
…al for sandboxed run -, type the host fallback reason Review follow-up for PR locez#52. approval_policy and sandbox are now independent dimensions. `[cli]` config no longer rejects `approval_policy = "none"` without `sandbox = "no-sandbox"`, `MerryConfig::cli_defaults` is infallible, and `Cli::apply_defaults` applies a configured policy under every sandbox mode; a flag on one dimension replaces only that dimension's default. Config and flags resolve every 5x3 combination identically (new matrix tests in cli/tests.rs and config/tests/cli.rs). A sandboxed `merry run -` can now complete human approval. The outer bubblewrap child runs under `--new-session`, so it cannot open `/dev/tty`, and a `-` task has already consumed stdin. When such a run uses `model_then_human` or `human_only`, the parent resolves its controlling terminal's device node (tty_nr from /proc/self/stat) before the re-exec and `--dev-bind`s only that node at /dev/merry-review-tty; the headless reviewer opens it with O_NOCTTY after /dev/tty fails. `--new-session` is kept, no descriptor is inherited, and inner action sandboxes mount their own /dev, so tool processes never see the node. A real bubblewrap+PTY test proves /dev/tty stays closed while the answer arrives through the bound device. Runs with no terminal at all still deny each request, now with a notice naming the alternatives. The host fallback reason is a typed `HostFallbackReason` (`ModelDenied(PermissionAdmissionReview)`, `ReviewFailed`, `ReviewModelUnavailable`) instead of a string, so the TUI and headless prompts say "AI review denied: <rationale>" for a model denial and keep "AI review unavailable" for actual failures; the original review metadata travels with the request. Also moves the cli.rs unit tests to cli/tests.rs and the coding approval-policy types to merry-coding/src/runtime/permission_policy.rs. Claude-Session: https://claude.ai/code/session_016kj3E1CjQtLCASyAW8WKzm
|
Addressed in 1. approval_policy 与 sandbox 完全独立。 删除了 2. 外层沙箱中的 3. 人工回退原因改为明确类型。 新增 文件拆分: PR 描述已更新。 |
Brings in locez#51 (fail closed when bubblewrap cannot nest). The only conflict was in `crates/merry-cli/src/main.rs`, where both sides inserted a block between the bubblewrap availability check and `sandbox::maybe_reexec`. Both are kept: the nested-sandbox probe runs first, so a host that cannot nest stops startup before anything else, and the review terminal is resolved after it, right before the re-exec that needs it. Claude-Session: https://claude.ai/code/session_016kj3E1CjQtLCASyAW8WKzm
|
复审版本: 上次要求的配置解耦、类型化回退原因和模块拆分已落实。整体分层方向合理,建议补齐下面两处问题后合并。
职责、复用和 Rust 实践的复查结果:
本次修改的 36 个 Rust 文件均低于 1000 行;关键模块的规模与职责基本匹配:
没有发现需要为了减少几行重复而新增跨层抽象的情况。后续拆分应围绕终端资源管理、审核合同、channel 通信这些真实职责展开。 验证:
|
…e review terminal Second-round review follow-up for PR locez#52. Terminal answers for a stdin task were read through tokio::fs::File, whose reads run on the blocking pool. Cancelling the reviewer while an answer was outstanding dropped the future but not the read, so dropping the runtime in main waited for a keystroke that never came. The terminal is now opened O_NONBLOCK | O_NOCTTY on a private file description and read through tokio's reactor (AsyncFd) by a dedicated adapter in headless_review/terminal.rs; an unanswered read is a pending await that cancellation simply drops. A hung-up pseudo-terminal (EIO after the master closes) reads as end of input, which the reviewer already turns into a denial. New tests cover typed lines followed by hangup, a pending read dropped by cancellation, and the full lifecycle on a real PTY: request outstanding, reviewer cancelled, request denied, runtime dropped within a time bound. Against the previous blocking read that last test fails at the shutdown assertion. The Linux-only pieces are gated: /proc and device-number lookup in sandbox/review_terminal.rs are cfg(target_os = "linux") with a None branch elsewhere, the terminal adapter and its libc flags are cfg(unix) with a None branch elsewhere, the bound-device path is only tried on Linux, and the PTY/bubblewrap tests (which need the Linux-only rustix dev-dependency) are cfg(all(test, target_os = "linux")). Claude-Session: https://claude.ai/code/session_016kj3E1CjQtLCASyAW8WKzm
|
Both points addressed in 1. 终端读取改为可取消的非阻塞读取。 新增专用适配器 新增真实 PTY 测试 2. 平台边界。 跨平台编译说明:本机为 Windows/macOS target 跑 本地:fmt / clippy(仅既有 |
Summary
Adds a
[cli]table toconfig.tomlwith two defaults applied to everymerryinvocation, so a preferred sandbox mode or approval policy does not need to be passed as a flag each time. The approval side replaces the earlierfully_trustedbool (and--fully-trusted) with anapproval_policywhose values name who reviews a command, adds adenypolicy, and makesmodel_then_humanthe default under every sandbox mode.noneFullyTrustedfully_trusted = true)denyDenyAll(new)model_onlyRequired[models.approval_review]model; a model denial is finalmodel_then_human(default)ModelThenHostFallbackmerry runprompt makes the final callhuman_onlyHostDecisionOnlymerry runpromptsandboxtakeswith-sandbox(outer+inner, the built-in default for TUI andrun),no-sandbox, orinner-sandbox, named after the root flag each stands in for. Any other value, including a raw--no-sandbox, is a parse error that lists the accepted names.approval_policyand--approval-policy <POLICY>share oneApprovalPolicytype (crates/merry-cli/src/coding/approval.rs, clapValueEnum+ serde, snake_case).--helplists the five values with a one-line description each.fully_trusted/--fully-trustedno longer parse.sandboxandapproval_policyare independent. The sandbox sets the execution boundary; the approval policy sets who reviews requests inside it. Every one of the 5×3 combinations is accepted in config and on the command line and resolves identically either way.Cli::apply_defaultsmerges each dimension on its own: a sandbox flag replaces only the configuredsandbox,--approval-policyreplaces only the configuredapproval_policy, and neither touches the other (so a configurednonesurvives--with-sandbox, and--no-sandbox --approval-policy human_onlyworks withapproval_policy = "none"in config).MerryConfig::cli_defaults()is infallible; the CLI no longer derives or constrains one dimension from the other.PermissionReviewMode::DenyAll, denied directly inreview_permission_requestand never satisfied by an existing grant.ModelThenHostFallbackescalates a model denial (including an "approve" the model was not confident enough in) to the host instead of treating it as final;Requiredkeeps a model denial final.PermissionAdmissionContext/PermissionReviewRequestcarryOption<HostFallbackReason>(ModelDenied(PermissionAdmissionReview),ReviewFailed { message },ReviewModelUnavailable) instead of areview_failurestring. The TUI dialog and the headless prompt printAI review denied: <rationale>for a model denial andAI review unavailable: …only for real failures; the original review metadata (source, risk, user authorization, rationale) travels with the request.CodingTrustModeis replaced byCodingApprovalPolicy(defaultModelThenHuman);CodingPermissionPolicy::for_process_boundarybecomesfor_approval_policy(policy, host_source). These types now live inmerry-coding/src/runtime/permission_policy.rs. Host-reviewed policies still require a host admission source and fail with a typed error otherwise.[permissions] no_sandbox_review,NoSandboxReviewMode, andCodingProcessBoundary. They only existed to derive a reviewer from the sandbox boundary, which the fixed default replaces; a config that still setsno_sandbox_reviewfails to parse with an unknown-field error.examples/config.tomldocuments both keys;sandboxstays commented andapproval_policy = "model_then_human"is spelled out as the shipped default (the example-config test asserts both). README has the reviewer table and the independence rule.Human approval for a sandboxed
merry run -The outer bubblewrap child runs under
--new-session, so it cannot open/dev/tty(ENXIO), and a-task has already consumed stdin. Previously a--with-sandbox … run -undermodel_then_human/human_onlydenied every request unanswered.Now, only for that combination (
Cli::hands_off_review_terminal: outer sandbox +run -+ a policy that may ask a person), the parent resolves its controlling terminal's device node before the re-exec (tty_nrfrom/proc/self/stat, matched against/dev/ptsand/devbyst_rdev) and adds--dev-bind <that node> /dev/merry-review-ttyto the bwrap plan. The headless reviewer tries/dev/ttyfirst and then that path, opened withO_NOCTTY. Properties:--new-sessionis unchanged;/dev/ttystays unopenable inside, so the TIOCSTI/job-control isolation it provides is kept. The bound node is not the child's controlling terminal.unsafe, and a bind needs nopre_exec/from_raw_fd), so there is nothing to leak to child processes.--dev /dev, so tool processes never see/dev/merry-review-tty.--inner-sandbox/--no-sandboxruns keep using/dev/tty; policies that never ask a person get no bind at all.--approval-policy model_only/deny).O_NONBLOCK | O_NOCTTYon a private file description and read through Tokio's reactor (AsyncFd) by a dedicated adapter,headless_review/terminal.rs. An unanswered prompt is a pending await that cancellation drops; nothing sits on the blocking pool, soHeadlessPermissionReviewTask::finish()followed by dropping the runtime returns promptly. A hung-up pseudo-terminal (EIO after the master closes) reads as end of input, which the reviewer turns into a denial. The operator's shell is unaffected because the non-blocking flag never touches an inherited description./proc/self/statand device-number lookup arecfg(target_os = "linux")with aNonebranch elsewhere; the terminal adapter and itslibcflags arecfg(unix)with aNonebranch elsewhere; the bound-device path is only tried on Linux; the PTY and bubblewrap tests (which use the Linux-onlyrustixdev-dependency) arecfg(all(test, target_os = "linux")).Tests: a real bubblewrap + pseudo-terminal integration test (
bound_terminal_answers_inside_a_new_session_sandbox_without_dev_tty) spawnsbwrap --unshare-user --unshare-pid --new-session … --dev-bind <pts> /dev/merry-review-tty, confirms/dev/ttyis closed inside, writesyesto the pty master, and readsanswer=yesback through the bound device.cancelling_an_unanswered_terminal_review_denies_it_and_lets_the_runtime_stopruns the reviewer against a real PTY with a request outstanding and nobody answering, cancels it, asserts the request is denied, and asserts the runtime drops within a time bound (against the previous blocking read it fails at that assertion). The adapter tests cover typed lines followed by hangup and a pending read dropped by cancellation. Further tests covertty_nrparsing, device lookup by number against a fresh pty, the plan (bind present only when handed off, after--dev /dev,--new-sessionintact, never/dev/tty), the terminal path fallback order, and the CLI predicate across all policies and modes.Shell completions
merry completions <SHELL>(bash, zsh, fish, elvish, powershell) prints a completion script generated byclap_completefrom the same clap definition that parses the flags, so--approval-policyand its five values, the sandbox flags, and every subcommand complete without a hand-kept list.mainanswers it right after parsing, before loadingconfig.tomlor touching the sandbox, so it works before Merry is configured or when the current config fails to load. README shows the install lines for zsh, bash, and fish.Behavior notes
modelunderwith-sandbox, model-then-host underinner-sandbox, host-only underno-sandbox). Now every mode defaults tomodel_then_human; sethuman_onlyto keep the oldno-sandboxbehaviour,model_onlyto keep the oldwith-sandboxbehaviour.nonenordenychanges any access ceiling.deny_paths,review_pathsmasking, andnetwork = falseapply exactly as before.sandbox = "with-sandbox"also applies tomerry debug, exactly as--with-sandboxwould.[cli] fully_trustedand[permissions] no_sandbox_revieware now unknown-field parse errors. There is no deprecation alias in this revision.cli.rsunit tests moved tocli/tests.rs(cli.rsis now ~230 lines); approval-policy types moved out ofmerry-coding/src/runtime.rsintoruntime/permission_policy.rs.Verification
cargo fmt --allcargo check --workspace --all-targetsandcargo clippy --workspace --all-targets --all-features -- -D warnings(only the pre-existingnonminimal_boollint intui/projector/tool_output.rs, untouched by this PR)cargo test --workspace --no-fail-fast: every crate passes except the bubblewrap execution tests inmerry-processandmerry-clithat fail on this host withmount destination must be absolute(identical on the baseline).merry-runtime728 passed,merry-coding37 passed,merry-cli615 passed / 5 known host-only failures.cargo check -p merry-cliforx86_64-pc-windows-gnuandaarch64-apple-darwincannot complete on this host on the baseline either: it stops in the build scripts ofringandonig_sys(no cross C toolchain) before reaching CLI code. Platform gating was verified by inspection of everystd::os::unix,libc,AsyncFd,/proc,rustix, and bubblewrap use in the touched files.config_and_flags_resolve_every_sandbox_and_approval_policy_combination_alike,cli_accepts_every_sandbox_and_approval_policy_combination, and the runtime assertions that the host receivesHostFallbackReason::ModelDeniedwith the model's rationale andReviewModelUnavailablewhen no review model is configured.XDG_CONFIG_HOME=/nonexistent merry completions zshprints a#compdef merryscript listing--approval-policywith all five values.Earlier revisions: https://claude.ai/code/session_01KdtdhgkdDC8Scm7hprRdzC
https://claude.ai/code/session_016kj3E1CjQtLCASyAW8WKzm