Remember "Always for this project" on a tool's consent card (#654)

"Always for this project" on a consent card is remembered in the project, and a tool allowed that way isn't asked about again (fixes #563). Replays #575.

Co-authored-by: Fábio Soares <fabiodev1123@gmail.com>
This commit is contained in:
BFlat
2026-11-03 20:40:55 +07:00
committed by GitHub
parent e9852f8c58
commit 0e79ffaaa5
5 changed files with 78 additions and 2 deletions
Generated
+1
View File
@@ -1320,6 +1320,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"tempfile",
]
[[package]]
+3
View File
@@ -16,3 +16,6 @@ craft-anything-platform = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
tempfile = "3"
+22 -2
View File
@@ -486,14 +486,34 @@ impl Server {
/// Ask the person (through the session's inbox) to allow one consent-gated call. `None` when
/// there is no inbox.
///
/// "Always for this project" is remembered in the project (the inbox's folder is the project's,
/// `allowed_actions` as `tool:<id>`), and a tool the person already always allowed isn't asked
/// about again (#563: it was asked every time, whatever was chosen).
fn ask_consent(&self, name: &str, why: &str, token: &CancelToken) -> Option<bool> {
let dir = self.engine.session().ext.get::<craft_anything_engine::inbox::InboxDir>()?;
let inbox = craft_anything_engine::inbox::Inbox::new(dir.0.clone());
let kind = format!("tool:{name}");
let mut project = dir.0.parent().and_then(|root| craft_anything_engine::workspace::Workspace::open(root).ok());
if project.as_ref().is_some_and(|p| p.manifest.allowed_actions.contains(&kind)) {
return Some(true);
}
let title = self.engine.registry().get(name).map(|s| s.title).unwrap_or(name);
let id =
inbox.post(craft_anything_engine::inbox::RequestKind::Approval, &format!("Allow: {title}"), why, Vec::new(), false, Some(name.to_string())).ok()?;
inbox.post(craft_anything_engine::inbox::RequestKind::Approval, &format!("Allow: {title}"), why, Vec::new(), false, Some(kind.clone())).ok()?;
let answer = inbox.wait(&id, Duration::from_secs(30 * 60), &|| token.is_cancelled());
Some(answer.and_then(|a| a.allow).unwrap_or(false))
let allowed = answer.as_ref().and_then(|a| a.allow).unwrap_or(false);
if allowed
&& answer.is_some_and(|a| a.always)
&& let Some(p) = project.as_mut()
{
let _ = p.set_person(|m| {
if !m.allowed_actions.contains(&kind) {
m.allowed_actions.push(kind);
}
});
}
Some(allowed)
}
fn success_body(&self, name: &str, out: &ToolOutput) -> Value {
+46
View File
@@ -315,3 +315,49 @@ fn claude_code_connections_get_a_budget_that_fits_its_limit() {
set.handle_request("initialize", &hello("claude-code")).unwrap();
assert_eq!(set.budget(), 500_000, "an explicit budget wins");
}
/// #563: "Always for this project" on a consent card was dropped, so the same tool was asked about
/// every time. It is remembered in the project, and a tool it was said for isn't asked again.
#[test]
fn always_for_this_project_on_a_consent_card_is_remembered() {
use craft_anything_engine::inbox::{Answer, Inbox, InboxDir};
use craft_anything_engine::workspace::{TargetRef, Workspace};
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::create(dir.path(), "t", "r", TargetRef::None).unwrap();
let engine = Arc::new(Engine::new(craft_anything_engine::testing::session()));
engine.session().ext.insert(Arc::new(InboxDir(ws.inbox_dir())));
let server = Server::new(engine, ServerOptions::default(), Box::new(Buf::default()));
let inbox = Inbox::new(ws.inbox_dir());
// Allowed once: this call goes through, and nothing is remembered.
let root = ws.inbox_dir();
let answerer = |always: bool| {
let inbox = Inbox::new(root.clone());
std::thread::spawn(move || {
for _ in 0..400 {
if let Some(r) = inbox.pending().first() {
inbox.answer(&r.id, &Answer { allow: Some(true), always, ..Default::default() }).unwrap();
return;
}
std::thread::sleep(Duration::from_millis(10));
}
})
};
let once = answerer(false);
assert_eq!(server.ask_consent("web_session_run", "why", &CancelToken::default()), Some(true));
once.join().unwrap();
assert!(Workspace::open(&ws.root).unwrap().manifest.allowed_actions.is_empty());
// Always: remembered as `tool:<id>`, and the next call isn't asked (the token is already
// cancelled, so asking would be refused, not waited on).
let always = answerer(true);
assert_eq!(server.ask_consent("web_session_run", "why", &CancelToken::default()), Some(true));
always.join().unwrap();
assert_eq!(Workspace::open(&ws.root).unwrap().manifest.allowed_actions, vec!["tool:web_session_run".to_string()]);
let cancelled = CancelToken::default();
cancelled.cancel();
assert_eq!(server.ask_consent("web_session_run", "why", &cancelled), Some(true), "not asked again");
assert!(inbox.pending().is_empty());
// Another tool is still asked about.
assert_eq!(server.ask_consent("browser_other", "why", &cancelled), Some(false));
}
+6
View File
@@ -188,6 +188,12 @@ every one into "isn't installed or isn't on your PATH": #95, #130 and #152 reins
had. When a spawn says not found, look the program up yourself; if it's there, name the file and
what it needs (`spawn_error_for`). Not run on Windows here.
**An answer with two parts, read as one.** An approval card has "Allow once" and "Always for this
project": the answer carries `allow` and `always`. The consent card our MCP server shows for a tool
that runs code read only `allow`, so "Always" was dropped and the same tool (a scripted web session)
was asked about every time (#563). When a card offers a choice, whoever reads the answer handles
every field of it, and a test answers it each way and asks again.
**A Mac app that couldn't find Claude Code, and offered a GitHub key instead.** An app opened from
the Dock gets the system's short `PATH`, not the shell's, so `claude` in `~/.local/bin` wasn't
found; "Built by" then fell back to "Claude Code on github.com-work", an `~/.ssh/config` alias