mod bash;
mod command;
mod config;
mod paths;
mod policy;
mod review;
use crate::{
core::{
extensions::{
BeforeToolCallOutcome, Extension, ExtensionCommand, ExtensionHook, ExtensionMode,
ExtensionSetting, ExtensionTool, ExtensionUiRequest, ForkProjectInfo, SelectOption,
SubcommandDef, UiNotifyLevel, ask_pending, ask_prompt, request_ui, ui_ready,
},
provider::AgentMessage,
session_manager::Session,
tools::ToolError,
},
extensions::{EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_PERMISSIONS},
modes::interactive::handlers::register_slash_command,
utils::{
glyphs::DEF_ELLIPSIS,
paths::{absolutize, cwd, is_outside},
truncate::truncate_chars,
},
};
use config::Settings;
use futures_util::future::BoxFuture;
use policy::{Action, Decision, Direction, Policy};
use serde_json::Value;
use std::{
path::{Path, PathBuf},
sync::{
Arc, Mutex, MutexGuard,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
const EXT: &str = "permissions";
const CMD: &str = "permissions";
const SUBCOMMANDS: &[SubcommandDef] = &[
SubcommandDef {
name: "show",
description: "Print the effective policy, its sources and session grants",
},
SubcommandDef {
name: "settings",
description: "Open this extension's settings panel (reviewLog / yoloMode)",
},
SubcommandDef {
name: "allow",
description: "Grant a pattern for this session: /permissions allow <surface> <pattern>",
},
SubcommandDef {
name: "drop",
description: "Revoke a session grant: /permissions drop <surface> <pattern>",
},
SubcommandDef {
name: "allowlist",
description: "List session grants",
},
SubcommandDef {
name: "init",
description: "Write a conservative default config (add --force to overwrite)",
},
SubcommandDef {
name: "log",
description: "Show the last decisions from the review log: /permissions log [n]",
},
SubcommandDef {
name: "reset",
description: "Clear every session grant",
},
];
const ASK_ONCE: &str = "Approve once";
const ASK_SESSION: &str = "Approve for this session";
const ASK_DENY: &str = "Deny";
const ASK_TIMEOUT: Duration = std::time::Duration::from_secs(600);
const MAX_MESSAGE_VALUE_CHARS: usize = 300;
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static PERMISSIONS_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_PERMISSIONS,
make: || -> Arc<dyn Extension> { Arc::new(Permissions) },
};
static SESSION_CWD: Mutex<Option<PathBuf>> = Mutex::new(None);
static GRANTS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
static WARNED: AtomicBool = AtomicBool::new(false);
fn grants() -> MutexGuard<'static, Vec<(String, String)>> {
GRANTS.lock().unwrap_or_else(|e| e.into_inner())
}
fn remember_cwd(cwd: &str) {
let mut slot = SESSION_CWD.lock().unwrap_or_else(|e| e.into_inner());
*slot = Some(PathBuf::from(cwd));
}
fn current_cwd() -> PathBuf {
if let Some(cwd) = SESSION_CWD
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
return cwd;
}
cwd()
}
pub struct Permissions;
impl Extension for Permissions {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Centralized allow/ask/deny gates over tools, bash commands and file paths. Policy lives \
in agent_dir()/extensions/permissions/config.json (plus a project config when the project \
is trusted). 'ask' fails closed: the call is refused with the rule that wants \
confirmation, and /permissions allow <surface> <pattern> grants it for the session"
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "pi-permission-system".to_string(),
plugin_version: "41.0.0".to_string(),
url: "https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system"
.to_string(),
})
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: CMD.to_string(),
description: "Inspect and grant permissions: /permissions show|allow|drop|allowlist|init|log|reset"
.to_string(),
busy_safe: true,
subcommands: SUBCOMMANDS.to_vec(),
}]
}
fn on_registered(&self) {
register_slash_command(EXT, CMD, command::handle);
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::BeforeToolCallAsync]
}
fn on_enabled_changed(&self, enabled: bool) {
if enabled {
let settings = config::load(¤t_cwd());
surface_warnings(&settings);
} else {
grants().clear();
WARNED.store(false, Ordering::Relaxed);
}
}
fn on_session_start(&self, cwd: &str, _messages: &[AgentMessage], _session: Option<&Session>) {
remember_cwd(cwd);
}
fn on_session_switched(&self, _session_path: Option<&str>, _messages: &[AgentMessage]) {
WARNED.store(false, Ordering::Relaxed);
}
fn settings(&self) -> Vec<ExtensionSetting> {
let settings = config::load(¤t_cwd());
vec![
ExtensionSetting::cycle(
"reviewLog",
"permissionReviewLog",
"Write every decision to agent_dir()/extensions/permissions/logs/permissions-review.jsonl",
if settings.review_log { "on" } else { "off" },
["on", "off"],
),
ExtensionSetting::cycle(
"yoloMode",
"yoloMode",
"Treat 'ask' as 'allow' (explicitly gives up the confirmation semantics)",
if settings.yolo_mode { "on" } else { "off" },
["off", "on"],
),
]
}
fn apply_setting(&self, key: &str, value: &str) -> std::result::Result<(), String> {
let (field, on) = match key {
"reviewLog" => ("permissionReviewLog", value == "on"),
"yoloMode" => ("yoloMode", value == "on"),
other => return Err(format!("unknown setting: {other}")),
};
config::set_switch(field, on).map_err(|e| format!("write config failed: {e}"))
}
fn filter_tools(&self, selected: Vec<String>) -> Vec<String> {
let settings = config::load(¤t_cwd());
selected
.into_iter()
.filter(|tool| !settings.policy.surface_all_deny(tool))
.collect()
}
fn before_tool_call_async(
&self,
name: String,
args: Value,
_assistant_message: Option<AgentMessage>,
_context: Vec<AgentMessage>,
_all_args: Value,
) -> BoxFuture<'static, std::result::Result<BeforeToolCallOutcome, ToolError>> {
Box::pin(async move {
let cwd = current_cwd();
let settings = config::load(&cwd);
surface_warnings(&settings);
let outcome = evaluate(&settings.policy, &name, &args, &cwd);
let (action, approved) = settle_action(&name, &settings, &outcome).await;
let decision = Decision {
action,
..outcome.decision.clone()
};
review::record(&settings, &name, &decision, &outcome.value, approved);
match action {
Action::Allow => Ok(BeforeToolCallOutcome::allow()),
Action::Deny | Action::Ask => Ok(BeforeToolCallOutcome {
block: true,
reason: Some(denial_message(
&name,
&outcome,
approved,
settings.yolo_mode,
)),
terminate: false,
args: None,
}),
}
})
}
}
async fn settle_action(tool: &str, settings: &Settings, outcome: &Outcome) -> (Action, bool) {
let decision = &outcome.decision;
if decision.action != Action::Ask {
return (decision.action, false);
}
if settings.yolo_mode {
return (Action::Allow, false);
}
if grant_covers(decision) {
return (Action::Allow, true);
}
if !ui_ready() || ask_pending() {
return (Action::Deny, false);
}
let title = ask_title(tool, outcome);
let options = vec![
SelectOption::styled(ASK_ONCE, "success"),
SelectOption::styled(ASK_SESSION, "accent"),
SelectOption::styled(ASK_DENY, "error"),
];
match ask_prompt(title, options, Some(ASK_TIMEOUT))
.await
.as_deref()
{
Some(ASK_ONCE) => (Action::Allow, true),
Some(ASK_SESSION) => {
set_grant(&decision.surface, &decision.pattern, true);
(Action::Allow, true)
}
_ => (Action::Deny, false),
}
}
fn ask_title(tool: &str, outcome: &Outcome) -> String {
let d = &outcome.decision;
let value = truncate_chars(
&outcome.value,
MAX_MESSAGE_VALUE_CHARS,
Some(DEF_ELLIPSIS),
true,
)
.replace(['\n', '\r'], " ");
let what = if value.is_empty() {
tool.to_string()
} else {
format!("{tool}: {value}")
};
let mut title = format!("{what} — {} `{}`", d.surface, d.pattern);
if !outcome.note.is_empty() {
title.push_str(&format!(" ({})", outcome.note));
}
title
}
struct Outcome {
decision: Decision,
value: String,
note: String,
}
fn evaluate(policy: &Policy, tool: &str, args: &Value, cwd: &Path) -> Outcome {
let mut decision: Option<Decision> = None;
let mut value = String::new();
let mut note = String::new();
if is_shell_tool(tool) {
if let Some(command) = args.get("command").and_then(Value::as_str) {
value = command.to_string();
let scanned = bash::scan(command);
for cmd in scanned.all_commands() {
let mut texts = vec![cmd.text.clone()];
if let Some(abs) = cmd.absolute_text() {
texts.push(abs);
}
if let Some(inner) = bash::unwrap_transparent(&cmd.text)
&& inner != cmd.text
{
if let Some(abs) = absolutize_text(&inner, &cmd.cwd) {
texts.push(abs);
}
texts.push(inner);
}
if let Some(hit) = policy.bash_decision(&texts) {
decision = Some(match decision.take() {
Some(prev) => prev.tighten(Some(hit)),
None => hit,
});
if note.is_empty() {
note = cmd.origin.note().to_string();
}
}
for (path, dir) in cmd.path_targets() {
let forms = paths::canonical_forms(&path, &cmd.cwd);
decision = tighten_with(decision, policy.path_decision(&forms, dir));
if any_form_outside(&forms, &cmd.cwd) {
decision = tighten_with(decision, policy.external_decision(&forms, dir));
}
}
}
if let Some(evidence) = scanned.indirect.first() {
decision = tighten_with(
decision,
Some(Decision {
action: Action::Ask,
surface: "bash".to_string(),
pattern: wrapper_pattern(evidence),
reason: Some(format!("cannot statically verify: {evidence}")),
matched_as: None,
}),
);
}
if !scanned.complete {
decision = tighten_with(
decision,
Some(Decision {
action: Action::Ask,
surface: "bash".to_string(),
pattern: "*".to_string(),
reason: Some("command could not be fully parsed".to_string()),
matched_as: None,
}),
);
}
}
} else {
let dir = tool_direction(tool);
if let Some(path) = args.get("path").and_then(Value::as_str) {
value = path.to_string();
let forms = paths::canonical_forms(path, cwd);
decision = tighten_with(decision, policy.path_decision(&forms, dir));
if any_form_outside(&forms, cwd) {
decision = tighten_with(decision, policy.external_decision(&forms, dir));
}
decision = tighten_with(decision, policy.tool_decision(tool, &forms));
} else {
decision = tighten_with(decision, policy.tool_decision(tool, &["*".to_string()]));
}
}
let decision = decision.unwrap_or_else(|| Decision {
action: policy.universal.action,
surface: "*".to_string(),
pattern: "<fallback>".to_string(),
reason: policy.universal.reason.clone(),
matched_as: None,
});
Outcome {
decision,
value,
note,
}
}
fn tighten_with(prev: Option<Decision>, next: Option<Decision>) -> Option<Decision> {
match (prev, next) {
(None, None) => None,
(Some(p), None) => Some(p),
(None, Some(n)) => Some(n),
(Some(p), Some(n)) => Some(p.tighten(Some(n))),
}
}
fn absolutize_text(text: &str, cwd: &Path) -> Option<String> {
let mut changed = false;
let parts: Vec<String> = text
.split_whitespace()
.map(|word| {
if word.contains('/') && !word.contains(['$', '*', '?', '`']) {
let abs = absolutize(word, cwd).to_string_lossy().to_string();
if abs != word {
changed = true;
return abs;
}
}
word.to_string()
})
.collect();
changed.then(|| parts.join(" "))
}
fn any_form_outside(forms: &[String], cwd: &Path) -> bool {
forms.iter().any(|form| is_outside(Path::new(form), cwd))
}
fn is_shell_tool(tool: &str) -> bool {
matches!(tool, "bash" | "powershell")
}
fn tool_direction(tool: &str) -> Direction {
match tool {
"write" | "edit" => Direction::Write,
_ => Direction::Read,
}
}
fn wrapper_pattern(evidence: &str) -> String {
match evidence.split_whitespace().next() {
Some(head) => format!("{head} *"),
None => "*".to_string(),
}
}
fn grant_covers(decision: &Decision) -> bool {
let table = grants();
table.iter().any(|(surface, pattern)| {
(surface == "*" || surface == &decision.surface) && pattern == &decision.pattern
})
}
fn denial_message(tool: &str, outcome: &Outcome, approved: bool, yolo: bool) -> String {
let d = &outcome.decision;
let mut out = format!(
"Blocked by permissions: {tool} — {}({}) matched rule `{}`",
d.surface, d.action, d.pattern
);
if !outcome.note.is_empty() {
out.push_str(&format!(" [{}]", outcome.note));
}
out.push('.');
if let Some(reason) = &d.reason {
out.push_str(&format!(" Reason: {reason}."));
}
if let Some(matched) = &d.matched_as {
out.push_str(&format!(" Matched as `{matched}`."));
}
if !outcome.value.is_empty() {
out.push_str(&format!(
" Value: `{}`.",
truncate_chars(
&outcome.value,
MAX_MESSAGE_VALUE_CHARS,
Some(DEF_ELLIPSIS),
true
)
));
}
if d.action == Action::Ask {
if yolo {
out.push_str(
" yoloMode is on, so 'ask' would have been allowed — fix the rule to proceed.",
);
} else if approved {
out.push_str(" The session grant was not enough: this call is denied by another rule.");
} else {
let why = if ui_ready() {
"The approval panel was not answered (cancelled, timed out, or another prompt was \
already open), so 'ask' fails closed."
} else {
"There is no interactive prompt available in this run, so 'ask' fails closed."
};
out.push_str(&format!(
" {why} Grant it for this session with `/permissions allow {surface} \"{pattern}\"`, \
or change the rule in {cfg}.",
surface = d.surface,
pattern = d.pattern,
cfg = config::global_config_path().display()
));
}
} else {
out.push_str(" Change or remove that rule in the config to allow it.");
}
out
}
fn surface_warnings(settings: &Settings) {
if settings.warnings.is_empty() || WARNED.swap(true, Ordering::Relaxed) {
return;
}
let text = format!(
"permissions: {} config issue(s): {}",
settings.warnings.len(),
settings.warnings.join("; ")
);
request_ui(ExtensionUiRequest::Notify {
level: UiNotifyLevel::Warning,
text,
});
}
pub(super) fn session_cwd() -> PathBuf {
current_cwd()
}
pub(super) fn set_grant(surface: &str, pattern: &str, grant: bool) -> bool {
let mut table = grants();
let existed = table.iter().any(|(s, p)| s == surface && p == pattern);
if grant {
if existed {
return false;
}
table.push((surface.to_string(), pattern.to_string()));
true
} else {
table.retain(|(s, p)| !(s == surface && p == pattern));
existed
}
}
pub(super) fn clear_grants() -> usize {
let mut table = grants();
let n = table.len();
table.clear();
n
}
pub(super) fn list_grants() -> Vec<(String, String)> {
grants().clone()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn policy_with(config: Value) -> Policy {
config::parse_settings_for_tests(&config)
}
#[test]
fn subcommand_candidates_cover_the_handler() {
let names: Vec<&str> = SUBCOMMANDS.iter().map(|s| s.name).collect();
assert_eq!(
names,
vec![
"show",
"settings",
"allow",
"drop",
"allowlist",
"init",
"log",
"reset"
]
);
}
#[test]
fn denies_dotenv_through_file_tool() {
let policy = policy_with(json!({
"permission": {"*": "allow", "path": {"*": "allow", "*.env": "deny"}}
}));
let outcome = evaluate(
&policy,
"read",
&json!({"path": ".env"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Deny);
assert_eq!(outcome.decision.surface, "path");
}
#[test]
fn allows_env_example() {
let policy = policy_with(json!({
"permission": {"*": "allow", "path": {"*": "allow", "*.env": "deny", "*.env.example": "allow"}}
}));
let outcome = evaluate(
&policy,
"read",
&json!({"path": ".env.example"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Allow);
}
#[test]
fn per_tool_deny_survives_path_allow() {
let policy = policy_with(json!({
"permission": {"*": "allow", "path": {"*": "allow"}, "read": "deny"}
}));
let outcome = evaluate(
&policy,
"read",
&json!({"path": "src/a.rs"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Deny);
assert_eq!(outcome.decision.surface, "read");
}
#[test]
fn bash_chain_evaluates_every_command() {
let policy = policy_with(json!({
"permission": {"*": "allow", "bash": {"*": "allow", "rm -rf *": "deny"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "cd /tmp && rm -rf build"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Deny);
assert_eq!(outcome.decision.surface, "bash");
}
#[test]
fn bash_nested_substitution_is_denied() {
let policy = policy_with(json!({
"permission": {"*": "allow", "bash": {"*": "allow", "rm *": "deny"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "echo $(rm -rf x)"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Deny);
assert!(outcome.note.contains("command substitution"));
}
#[test]
fn indirect_wrapper_fails_closed_as_ask() {
let policy = policy_with(json!({
"permission": {"*": "allow", "bash": {"*": "allow"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "bash -c 'ls'"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Ask);
assert_eq!(outcome.decision.pattern, "bash *");
}
#[test]
fn unparsable_command_fails_closed() {
let policy = policy_with(json!({
"permission": {"*": "allow", "bash": {"*": "allow"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "echo 'unterminated"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Ask);
}
#[test]
fn external_directory_asks_outside_cwd() {
let policy = policy_with(json!({
"permission": {"*": "allow", "external_directory": "ask"}
}));
let outcome = evaluate(
&policy,
"read",
&json!({"path": "../outside.txt"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Ask);
assert_eq!(outcome.decision.surface, "external_directory");
}
#[test]
fn external_directory_allows_inside_cwd() {
let policy = policy_with(json!({
"permission": {"*": "allow", "external_directory": "ask"}
}));
let outcome = evaluate(
&policy,
"read",
&json!({"path": "src/a.rs"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Allow);
}
#[test]
fn universal_fallback_applies_when_nothing_matches() {
let policy = policy_with(json!({"permission": {"*": "deny"}}));
let outcome = evaluate(&policy, "some_tool", &json!({}), Path::new("/work/p"));
assert_eq!(outcome.decision.action, Action::Deny);
assert_eq!(outcome.decision.surface, "*");
}
#[test]
fn bash_redirect_target_is_gated_by_path_layer() {
let policy = policy_with(json!({
"permission": {"*": "allow", "path": {"*": "allow", "*.env": "deny"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "echo secret > .env"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Deny);
assert_eq!(outcome.decision.surface, "path");
}
#[test]
fn transparent_wrapper_unwraps_before_matching() {
let policy = policy_with(json!({
"permission": {"*": "allow", "bash": {"*": "allow", "npm publish": "deny"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "timeout 30 npm publish"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Deny);
}
#[test]
fn denial_message_offers_session_grant() {
let policy = policy_with(json!({
"permission": {"*": "allow", "bash": {"*": "allow", "sudo *": "ask"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "sudo ls"}),
Path::new("/work/p"),
);
assert_eq!(outcome.decision.action, Action::Ask);
let msg = denial_message("bash", &outcome, false, false);
assert!(msg.contains("/permissions allow bash \"sudo *\""), "{msg}");
}
#[test]
fn ask_title_leads_with_the_command_on_one_line() {
let policy = policy_with(json!({
"permission": {"*": "allow", "bash": {"*": "allow", "rm -rf *": "ask"}}
}));
let outcome = evaluate(
&policy,
"bash",
&json!({"command": "rm -rf build\nrm -rf dist"}),
Path::new("/work/p"),
);
let title = ask_title("bash", &outcome);
assert!(title.starts_with("bash: rm -rf build"), "{title}");
assert!(!title.contains('\n'), "标题必须是单行: {title:?}");
assert!(title.contains("rm -rf *"), "带上命中的规则: {title}");
}
}