From 0b602aa25d9ef30cf51afb934a1e5907414a3b5b Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Sun, 5 Jul 2026 18:17:38 -0300 Subject: [PATCH] feat(approval-gate): auto-mode trust for the read-only coder surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped default rules gain structured, auto-scoped allows for the read-only coder functions (context / info / read-file / search / tree / list-folder), so an auto-mode coding session prompts on writes and exec instead of every read. Manual mode is unaffected — mode-scoped rules do not match there — and writes (update-file, apply-patch, create/delete/ move, worktree-*) plus shell::exec still gate in every mode. default_rule_values() is now the single source of truth for both the compiled default specs and the configuration schema default, and the rules schema items widen to oneOf string|structured so the shipped structured defaults pass configuration::register validation. --- approval-gate/src/config.rs | 43 ++++++--- .../src/permissions/default_rules.rs | 88 ++++++++++++++++++- approval-gate/src/permissions/mod.rs | 7 +- 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/approval-gate/src/config.rs b/approval-gate/src/config.rs index e315d2fec..4539de7cd 100644 --- a/approval-gate/src/config.rs +++ b/approval-gate/src/config.rs @@ -11,7 +11,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use crate::permissions::{default_rule_specs, parse_rules_from_config, Permissions, RuleSpec}; +use crate::permissions::{parse_rules_from_config, Permissions, RuleSpec}; use crate::types::PermissionMode; /// The shipped permission rules as a JSON array of shorthand strings — the @@ -20,13 +20,7 @@ use crate::types::PermissionMode; /// [`crate::permissions::default_rule_specs`] so the in-memory defaults and /// the console-editable list never drift. pub fn default_rules_value() -> Vec { - default_rule_specs() - .into_iter() - .filter_map(|r| match r { - RuleSpec::Shorthand(s) => Some(Value::String(s)), - _ => None, - }) - .collect() + crate::permissions::default_rules::default_rule_values() } fn default_rules() -> Vec { @@ -50,8 +44,27 @@ fn rules_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema "type": "array", "description": "Permission rules for the gate hook (first match wins). Each entry is a string or object.\n\nString shorthands: bare id/glob → allow; prefix ! → deny; no match → hold.\n\nStructured objects: { \"function\": \"shell::*\", \"action\": \"allow\" | \"deny\", \"modes\": [\"auto\"] } — optional modes scope the rule to manual, auto, or full (omit for all modes). Use modes: [\"auto\"] on allow rules to seed the auto-mode trust list.\n\nExamples:\n• \"state::get\" — allow reads\n• \"shell::*\" — allow any shell worker call\n• \"!approval::*\" — deny the approval decision plane (shipped default)\n• { \"function\": \"web::fetch\", \"action\": \"allow\", \"modes\": [\"auto\"] } — auto-mode trust", "items": { - "type": "string", - "description": "Function id or glob. Allow: \"web::fetch\", \"coder::*\". Deny: \"!configuration::*\", \"!router::chat\"." + "oneOf": [ + { + "type": "string", + "description": "Function id or glob. Allow: \"web::fetch\", \"coder::*\". Deny: \"!configuration::*\", \"!router::chat\"." + }, + { + "type": "object", + "description": "Structured rule; optional modes scope it to session permission modes (auto-scoped allows also seed the auto-mode trust list).", + "required": ["function", "action"], + "properties": { + "rule_id": { "type": "string" }, + "function": { "type": "string" }, + "action": { "type": "string", "enum": ["allow", "deny"] }, + "modes": { + "type": "array", + "items": { "type": "string", "enum": ["manual", "auto", "full"] } + }, + "args": { "type": "object" } + } + } + ] }, "default": default_rules_value(), })) @@ -198,7 +211,15 @@ mod tests { let cfg = WorkerConfig::default(); assert_eq!(cfg.default_mode, PermissionMode::Manual); assert!(!cfg.rules.is_empty()); - assert!(cfg.auto_allow_seed().is_empty()); + // The shipped defaults seed auto-mode trust for the read-only + // coder surface (and nothing else). + assert_eq!( + cfg.auto_allow_seed(), + crate::permissions::default_rules::READ_ONLY_CODER + .iter() + .map(|s| s.to_string()) + .collect::>() + ); assert_eq!(cfg.grant_reask_limit, 3); } diff --git a/approval-gate/src/permissions/default_rules.rs b/approval-gate/src/permissions/default_rules.rs index 190ed9b3d..934e11240 100644 --- a/approval-gate/src/permissions/default_rules.rs +++ b/approval-gate/src/permissions/default_rules.rs @@ -2,15 +2,52 @@ //! `approval-gate` configuration entry omits `rules` (or they fail to //! compile). First match wins; no match → **hold**. //! -//! The shipped defaults deny **only this worker's `approval::*` surface** -//! (the 12 registered functions — see `functions::catalog`). Every -//! other function_id holds until the operator adds custom `rules`. +//! The shipped defaults: +//! - deny **only this worker's `approval::*` surface** (the registered +//! functions — see `functions::catalog`); +//! - allow the READ-ONLY coder surface in **auto mode only** (context / +//! info / read-file / search / tree / list-folder), so an auto-mode +//! coding session prompts on writes and exec, not on every read. +//! These auto-scoped allows also feed `auto_allow_seed`. Manual mode +//! is unaffected — mode-scoped rules do not match there. +//! +//! Every other function_id holds until the operator adds custom `rules`. + +use serde_json::{json, Value}; + +/// The read-only coder functions trusted in auto mode by default. +pub const READ_ONLY_CODER: [&str; 6] = [ + "coder::context", + "coder::info", + "coder::read-file", + "coder::search", + "coder::tree", + "coder::list-folder", +]; /// Shorthand rule strings compiled into [`super::default_permissions`]. pub fn default_rule_strings() -> Vec<&'static str> { vec!["!approval::*"] } +/// The full default `rules` array as configuration JSON values: +/// shorthands plus the structured auto-scoped read-only coder allows. +pub fn default_rule_values() -> Vec { + let mut out: Vec = default_rule_strings() + .into_iter() + .map(|s| Value::String(s.to_string())) + .collect(); + for fid in READ_ONLY_CODER { + out.push(json!({ + "rule_id": format!("seed-auto-{}", fid.replace("::", "-")), + "function": fid, + "action": "allow", + "modes": ["auto"], + })); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -27,6 +64,51 @@ mod tests { assert_eq!(default_rule_strings(), ["!approval::*"]); } + #[test] + fn default_values_add_auto_scoped_read_only_coder_allows() { + let values = default_rule_values(); + assert_eq!(values.len(), 1 + READ_ONLY_CODER.len()); + for v in &values[1..] { + assert_eq!(v["action"], "allow"); + assert_eq!(v["modes"], json!(["auto"])); + } + } + + #[test] + fn read_only_coder_allows_in_auto_hold_in_manual() { + use crate::permissions::{parse_rules_from_config, Permissions}; + let p = Permissions::compile(&parse_rules_from_config(&Value::Array( + default_rule_values(), + ))) + .unwrap(); + for fid in READ_ONLY_CODER { + assert!( + matches!( + p.check(fid, &json!({}), PermissionMode::Auto), + Decision::Allow { .. } + ), + "{fid} should allow in auto" + ); + assert!( + matches!( + p.check(fid, &json!({}), PermissionMode::Manual), + Decision::NeedsApproval + ), + "{fid} should still hold in manual" + ); + } + // Writes and exec stay gated even in auto. + for fid in ["coder::update-file", "coder::apply-patch", "shell::exec"] { + assert!( + matches!( + p.check(fid, &json!({}), PermissionMode::Auto), + Decision::NeedsApproval + ), + "{fid} should hold in auto" + ); + } + } + #[test] fn default_rules_deny_every_registered_function() { let p = default_permissions(); diff --git a/approval-gate/src/permissions/mod.rs b/approval-gate/src/permissions/mod.rs index d0b84480a..00b869d4b 100644 --- a/approval-gate/src/permissions/mod.rs +++ b/approval-gate/src/permissions/mod.rs @@ -2,7 +2,7 @@ //! Rules live in the `approval-gate` configuration entry (`GateDefaults::rules`). mod compile; -mod default_rules; +pub mod default_rules; mod types; pub use compile::{ @@ -171,10 +171,7 @@ pub fn parse_rules_from_config(value: &Value) -> Vec { } pub fn default_rule_specs() -> Vec { - default_rules::default_rule_strings() - .into_iter() - .map(|s| RuleSpec::Shorthand(s.to_string())) - .collect() + parse_rules_from_config(&Value::Array(default_rules::default_rule_values())) } pub fn default_permissions() -> Permissions {