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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Approval cards can now remember eligible safe shell and file-write approvals
as exact `allow` rules scoped to the current repository. Remembered shell
commands use complete-command matching, validated file and patch paths remain
workspace-relative, and dangerous, critical, or repo-law-held requests stay
ineligible and continue to require review.

## [0.9.1] - 2026-07-24

Expand Down
12 changes: 9 additions & 3 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,16 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc

# Typed permission rules live in a sibling `permissions.toml` file, not in
# config.toml. Each `[[rules]]` entry accepts `tool`, optional `command`
# or `path`, and an `action` field: `"deny"`, `"ask"` (default), or `"allow"`.
# or `path`, optional absolute `workspace`, optional `command_exact = true`,
# and an `action` field: `"deny"`, `"ask"` (default), or `"allow"`.
# Deny always wins over ask, which wins over allow. Globs, broad directory
# rules, and a rule editor/deleter UI are future work.
#
# In supported approval cards, press `S` to approve once and save exact rules
# (currently saved as ask-only; deny/allow UI save is future work):
# In supported approval cards, press `S` to allow once and save an exact ask
# rule. Eligible safe calls also offer `P` / "Always allow this exact rule in
# this repo", which saves an exact `allow` rule scoped to the current absolute
# workspace. Dangerous/critical calls and repo-law prompts cannot save allow
# grants. The UI still does not save deny rules or provide editing/deletion:
# exec_shell -> exact approved command string
# write_file -> exact workspace-relative target path
# edit_file -> exact workspace-relative target path
Expand Down Expand Up @@ -219,6 +223,8 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
# [[rules]]
# tool = "exec_shell"
# command = "git status"
# command_exact = true
# workspace = "/absolute/path/to/project"
# action = "allow"
#
# # Path-based deny
Expand Down
113 changes: 101 additions & 12 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
pub use codewhale_execpolicy::ToolAskRule;
use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
use codewhale_execpolicy::{ExecPolicyEngine, PermissionAction, Ruleset};
use codewhale_secrets::SecretSource;
pub use codewhale_secrets::Secrets;
pub use external_credentials::{
Expand Down Expand Up @@ -360,8 +360,8 @@ pub struct ProvidersToml {
/// Sibling `permissions.toml` schema.
///
/// Each rule is a typed condition that can deny, allow, or ask before a tool
/// invocation. UI actions that persist deny/allow rules are future work; the
/// approval card still saves ask rules.
/// invocation. The approval card persists ask rules and narrowly scoped,
/// exact allow grants; deny rules remain manually authored.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PermissionsToml {
Expand All @@ -377,7 +377,6 @@ impl PermissionsToml {

#[must_use]
pub fn ruleset(&self) -> Ruleset {
use codewhale_execpolicy::PermissionAction;
let mut denied = Vec::new();
let mut trusted = Vec::new();
let mut ask_rules = Vec::new();
Expand All @@ -387,7 +386,10 @@ impl PermissionsToml {
PermissionAction::Deny => {
// Command-based deny rules are promoted to denied_prefixes
// so they are caught by execpolicy's deny-always-wins check.
if let Some(cmd) = &rule.command {
if let Some(cmd) = &rule.command
&& !rule.command_exact
&& rule.workspace.is_none()
{
denied.push(cmd.clone());
}
// Always keep in ask_rules for path-based and tool-only matching.
Expand All @@ -397,7 +399,10 @@ impl PermissionsToml {
// Command-based allow rules are promoted to trusted_prefixes
// for arity-aware matching. Path-only allow rules are
// handled through ask_rules (they skip the approval prompt).
if let Some(cmd) = &rule.command {
if let Some(cmd) = &rule.command
&& !rule.command_exact
&& rule.workspace.is_none()
{
trusted.push(cmd.clone());
}
// Keep in ask_rules so path-only allow rules also work.
Expand Down Expand Up @@ -3684,9 +3689,62 @@ impl ConfigStore {
/// are ignored, and the in-memory permissions snapshot is refreshed after
/// a successful write.
pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
self.append_permission_rules(rules, PermissionAction::Ask)
}

/// Atomically append exact, repo-scoped allow rules to the sibling
/// `permissions.toml` file.
///
/// The caller is responsible for deciding which tool calls are eligible;
/// this boundary rejects broad or incorrectly typed records so a UI bug
/// cannot persist an unscoped allow grant.
pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
for rule in rules {
if rule.action != PermissionAction::Allow {
bail!("append_allow_rules only accepts action = \"allow\"");
}
let Some(workspace) = rule
.workspace
.as_deref()
.and_then(codewhale_execpolicy::normalize_workspace_scope)
else {
bail!("persistent allow rules must be scoped to a workspace");
};
if rule.command.is_some() && !rule.command_exact {
bail!("persistent command allow rules must use exact matching");
}
if rule.command.is_none() && rule.path.is_none() {
bail!("persistent allow rules must match an exact command or path");
}
if let Some(command) = rule.command.as_deref()
&& command.trim().is_empty()
{
bail!("persistent command allow rules must not be empty");
}
if let Some(path) = rule.path.as_deref()
&& codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
.is_none_or(|path| path.is_empty())
{
bail!("persistent path allow rules must stay within the workspace");
}
}
self.append_permission_rules(rules, PermissionAction::Allow)
}

fn append_permission_rules(
&mut self,
rules: &[ToolAskRule],
expected_action: PermissionAction,
) -> Result<usize> {
if rules.is_empty() {
return Ok(0);
}
if rules.iter().any(|rule| rule.action != expected_action) {
bail!(
"permission rule action does not match requested {:?} persistence",
expected_action
);
}

let path = checked_permissions_path_for_config_path(&self.path)?;
let raw = if checked_path_exists(&path)? {
Expand Down Expand Up @@ -3727,7 +3785,7 @@ impl ConfigStore {
if permissions.rules.contains(rule) {
continue;
}
append_ask_rule(rules_item, rule)?;
append_permission_rule(rules_item, rule)?;
permissions.rules.push(rule.clone());
added += 1;
}
Expand Down Expand Up @@ -4350,44 +4408,75 @@ fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
})
}

fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
match item {
toml_edit::Item::ArrayOfTables(rules) => {
rules.push(ask_rule_table(rule));
rules.push(permission_rule_table(rule));
Ok(())
}
toml_edit::Item::Value(value) => {
let Some(rules) = value.as_array_mut() else {
bail!("`rules` in permissions.toml must be an array");
};
rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
rule,
)));
Ok(())
}
_ => bail!("`rules` in permissions.toml must be an array"),
}
}

fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
let mut table = toml_edit::Table::new();
table["tool"] = toml_edit::value(rule.tool.clone());
if let Some(command) = rule.command.as_deref() {
table["command"] = toml_edit::value(command);
}
if rule.command_exact {
table["command_exact"] = toml_edit::value(true);
}
if let Some(path) = rule.path.as_deref() {
table["path"] = toml_edit::value(path);
}
if let Some(workspace) = rule.workspace.as_deref() {
table["workspace"] = toml_edit::value(workspace);
}
if rule.action != PermissionAction::Ask {
table["action"] = toml_edit::value(match rule.action {
PermissionAction::Allow => "allow",
PermissionAction::Ask => "ask",
PermissionAction::Deny => "deny",
});
}
table
}

fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
let mut table = toml_edit::InlineTable::new();
table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
if let Some(command) = rule.command.as_deref() {
table.insert("command", toml_edit::Value::from(command));
}
if rule.command_exact {
table.insert("command_exact", toml_edit::Value::from(true));
}
if let Some(path) = rule.path.as_deref() {
table.insert("path", toml_edit::Value::from(path));
}
if let Some(workspace) = rule.workspace.as_deref() {
table.insert("workspace", toml_edit::Value::from(workspace));
}
if rule.action != PermissionAction::Ask {
table.insert(
"action",
toml_edit::Value::from(match rule.action {
PermissionAction::Allow => "allow",
PermissionAction::Ask => "ask",
PermissionAction::Deny => "deny",
}),
);
}
table
}

Expand Down
117 changes: 117 additions & 0 deletions crates/config/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ fn permissions_ruleset_populates_denied_and_trusted_prefixes() {
assert!(!ruleset.denied_prefixes.contains(&"cargo test".to_string()));
}

#[test]
fn exact_workspace_command_allow_is_not_promoted_to_global_prefix() {
let rule =
ToolAskRule::exec_shell("cargo test").into_exact_workspace_allow("/workspace/project");
let permissions = PermissionsToml {
rules: vec![rule.clone()],
};

let ruleset = permissions.ruleset();

assert!(
ruleset.trusted_prefixes.is_empty(),
"an exact repo grant must not become a global trusted prefix"
);
assert_eq!(ruleset.ask_rules, vec![rule]);
}

#[test]
fn permissions_ruleset_deny_without_command_stays_in_ask_rules() {
// Tool-only deny (no command) can't be promoted to denied_prefixes.
Expand Down Expand Up @@ -695,6 +712,106 @@ fn config_store_appends_ask_rule_to_inline_rules_array() {
);
}

#[test]
fn config_store_appends_exact_workspace_allow_rules() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join(CONFIG_FILE_NAME);
let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
fs::write(&permissions_path, "# remembered grants stay user-owned\n")
.expect("write permissions");
let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
let rule = ToolAskRule::exec_shell("cargo test")
.into_exact_workspace_allow(dir.path().to_string_lossy());

assert_eq!(
store
.append_allow_rules(&[rule.clone(), rule.clone()])
.expect("append allow rule"),
1
);

let body = fs::read_to_string(&permissions_path).expect("read permissions");
assert!(body.contains("# remembered grants stay user-owned"));
assert!(body.contains("action = \"allow\""));
assert!(body.contains("command_exact = true"));
// Windows paths may make toml_edit choose a different valid quoting style
// than Rust's Debug output. Check the rendered field shape here and its
// exact value through the typed parse below.
assert!(body.lines().any(|line| line.starts_with("workspace = ")));
let parsed: PermissionsToml = toml::from_str(&body).expect("parse permissions");
assert_eq!(parsed.rules, vec![rule.clone()]);

let exact = store
.exec_policy_engine()
.check(codewhale_execpolicy::ExecPolicyContext {
command: "cargo test",
cwd: dir.path().to_string_lossy().as_ref(),
tool: Some("exec_shell"),
path: None,
ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest,
sandbox_mode: Some("workspace-write"),
})
.expect("check exact grant");
assert_eq!(
exact.matched_action,
Some(codewhale_execpolicy::PermissionAction::Allow)
);
assert!(!exact.requires_approval);

let extra_args = store
.exec_policy_engine()
.check(codewhale_execpolicy::ExecPolicyContext {
command: "cargo test --workspace",
cwd: dir.path().to_string_lossy().as_ref(),
tool: Some("exec_shell"),
path: None,
ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest,
sandbox_mode: Some("workspace-write"),
})
.expect("check extra args");
assert!(extra_args.requires_approval);

let reloaded = ConfigStore::load(Some(config_path)).expect("reload config store");
assert_eq!(reloaded.permissions().rules, vec![rule]);
}

#[test]
fn config_store_rejects_broad_or_unscoped_allow_rules() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join(CONFIG_FILE_NAME);
let mut store = ConfigStore::load(Some(config_path)).expect("load config store");

let mut unscoped = ToolAskRule::exec_shell("cargo test");
unscoped.action = codewhale_execpolicy::PermissionAction::Allow;
assert!(
store
.append_allow_rules(&[unscoped])
.expect_err("unscoped allow must fail")
.to_string()
.contains("scoped to a workspace")
);

let mut prefix = ToolAskRule::exec_shell("cargo test");
prefix.action = codewhale_execpolicy::PermissionAction::Allow;
prefix.workspace = Some(dir.path().to_string_lossy().into_owned());
assert!(
store
.append_allow_rules(&[prefix])
.expect_err("prefix allow must fail")
.to_string()
.contains("exact matching")
);

assert!(
store
.append_allow_rules(&[ToolAskRule::new("exec_shell")
.into_exact_workspace_allow(dir.path().to_string_lossy())])
.expect_err("tool-wide allow must fail")
.to_string()
.contains("exact command or path")
);
}

#[test]
fn config_store_does_not_overwrite_invalid_permissions_file() {
let dir = tempfile::tempdir().expect("tempdir");
Expand Down
Loading
Loading