Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions approval-gate/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Value> {
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<Value> {
Expand All @@ -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(),
}))
Expand Down Expand Up @@ -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::<Vec<_>>()
);
assert_eq!(cfg.grant_reask_limit, 3);
}

Expand Down
88 changes: 85 additions & 3 deletions approval-gate/src/permissions/default_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> {
let mut out: Vec<Value> = 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::*;
Expand All @@ -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();
Expand Down
7 changes: 2 additions & 5 deletions approval-gate/src/permissions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -171,10 +171,7 @@ pub fn parse_rules_from_config(value: &Value) -> Vec<RuleSpec> {
}

pub fn default_rule_specs() -> Vec<RuleSpec> {
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 {
Expand Down
Loading