huskies: merge 1218 story Remembered/sticky permission approvals so constrained agents stop re-prompting for the same action class
This commit is contained in:
@@ -18,8 +18,12 @@ pub enum PermissionDecision {
|
||||
Deny,
|
||||
/// One-time approval.
|
||||
Approve,
|
||||
/// Approve and persist the rule to `.claude/settings.json` so Claude Code's
|
||||
/// built-in permission system handles future checks without prompting.
|
||||
/// Approve, and remember `(tool, target-pattern)` for the rest of the
|
||||
/// requesting agent's session (story 1218) — subsequent matching
|
||||
/// requests auto-approve without forwarding to chat. Scoped in-memory to
|
||||
/// the session that made the request (see
|
||||
/// `service::permission_router::RememberedPermissions`); never persisted
|
||||
/// to disk and never shared with another agent or story.
|
||||
AlwaysAllow,
|
||||
}
|
||||
|
||||
@@ -125,6 +129,7 @@ impl AppContext {
|
||||
permission_registry,
|
||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
status: agents.status_broadcaster(),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::http::context::AppContext;
|
||||
use crate::service::diagnostics::{add_permission_rule, generate_permission_rule};
|
||||
use crate::http::mcp::session;
|
||||
use crate::service::diagnostics::generate_permission_rule;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
|
||||
@@ -29,6 +30,23 @@ pub(crate) async fn tool_prompt_permission(
|
||||
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
|
||||
}
|
||||
|
||||
let session_id = session::current();
|
||||
let rule = generate_permission_rule(&tool_name, &tool_input);
|
||||
|
||||
// Auto-approve without forwarding when this exact (tool, target-pattern)
|
||||
// was already remembered for this agent's session (story 1218). Logged
|
||||
// here for auditability since it bypasses the chat approval dialog.
|
||||
if ctx
|
||||
.services
|
||||
.remembered_permissions
|
||||
.is_remembered(&session_id, &rule)
|
||||
{
|
||||
crate::slog!(
|
||||
"[permission] Auto-approved '{tool_name}' (remembered rule '{rule}' for session '{session_id}')"
|
||||
);
|
||||
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
|
||||
}
|
||||
|
||||
// Auto-deny immediately if no responder is currently registered to
|
||||
// receive forwarded permission requests. The Matrix bot's
|
||||
// permission_listener task, sled uplinks, and per-message chat transports
|
||||
@@ -83,15 +101,14 @@ pub(crate) async fn tool_prompt_permission(
|
||||
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
|
||||
|
||||
if decision == PermissionDecision::AlwaysAllow {
|
||||
// Persist the rule so Claude Code won't prompt again for this tool.
|
||||
if let Some(root) = ctx.state.project_root.lock().unwrap().clone() {
|
||||
let rule = generate_permission_rule(&tool_name, &tool_input);
|
||||
if let Err(e) = add_permission_rule(&root, &rule) {
|
||||
slog_warn!("[permission] Failed to write always-allow rule: {e}");
|
||||
} else {
|
||||
slog!("[permission] Added always-allow rule: {rule}");
|
||||
}
|
||||
}
|
||||
// Remember for the rest of this agent's session (story 1218) — never
|
||||
// written to disk, never visible to another session's requests.
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.remember(&session_id, &rule);
|
||||
slog!(
|
||||
"[permission] Remembered rule '{rule}' for session '{session_id}' — future matches auto-approve without prompting"
|
||||
);
|
||||
}
|
||||
|
||||
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
|
||||
@@ -238,91 +255,86 @@ mod tests {
|
||||
assert_eq!(rule, "mcp__huskies__create_story");
|
||||
}
|
||||
|
||||
// ── Settings.json writing tests ──────────────────────────────
|
||||
// ── Remembered ("don't ask again this session") tests (story 1218) ──
|
||||
|
||||
#[test]
|
||||
fn add_rule_creates_settings_file_when_missing() {
|
||||
#[tokio::test]
|
||||
async fn remembered_rule_auto_approves_without_forwarding() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
}
|
||||
// Pre-remember the rule for the default session (no X-Huskies-Session
|
||||
// header scope installed in this test) — no responder is registered,
|
||||
// so if the request were forwarded it would auto-deny instead.
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.remember(&session::current(), "Bash(git *)");
|
||||
|
||||
#[test]
|
||||
fn add_rule_does_not_duplicate_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
|
||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
let count = allow.iter().filter(|v| v == &&json!("Edit")).count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_skips_when_wildcard_already_covers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["mcp__huskies__*"]}}"#,
|
||||
let result = tool_prompt_permission(
|
||||
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
.await
|
||||
.expect("remembered rule must short-circuit before the no-responder auto-deny");
|
||||
|
||||
add_permission_rule(tmp.path(), "mcp__huskies__create_story").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert_eq!(allow.len(), 1);
|
||||
assert_eq!(allow[0], "mcp__huskies__*");
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["behavior"], "allow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_appends_to_existing_rules() {
|
||||
#[tokio::test]
|
||||
async fn always_allow_decision_remembers_rule_for_session_not_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Edit"]}}"#,
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let (guard, mut rx) = ctx.services.permission_registry.register();
|
||||
tokio::spawn(async move {
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward
|
||||
.response_tx
|
||||
.send(crate::http::context::PermissionDecision::AlwaysAllow);
|
||||
}
|
||||
drop(guard);
|
||||
});
|
||||
|
||||
tool_prompt_permission(
|
||||
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
.await
|
||||
.expect("always-allow must succeed");
|
||||
|
||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert_eq!(allow.len(), 2);
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
assert!(allow.contains(&json!("Write")));
|
||||
assert!(
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.is_remembered(&session::current(), "Bash(git *)"),
|
||||
"AlwaysAllow must remember the rule in-memory for this session"
|
||||
);
|
||||
assert!(
|
||||
!tmp.path().join(".claude/settings.json").exists(),
|
||||
"AlwaysAllow must not write to disk (story 1218: session-scoped only)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_preserves_other_settings_fields() {
|
||||
#[tokio::test]
|
||||
async fn remembered_rule_does_not_cross_sessions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Edit"]},"enabledMcpjsonServers":["huskies"]}"#,
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.remember("story-a", "Bash(git *)");
|
||||
|
||||
// Current (default) session never remembered this rule, and no
|
||||
// responder is registered, so it must fall through to auto-deny.
|
||||
let result = tool_prompt_permission(
|
||||
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let servers = settings["enabledMcpjsonServers"].as_array().unwrap();
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0], "huskies");
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(
|
||||
parsed["behavior"], "deny",
|
||||
"a rule remembered for a different session must not auto-approve this one"
|
||||
);
|
||||
}
|
||||
|
||||
// ── move_story tool tests ─────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,10 @@ pub mod merge_tools;
|
||||
pub mod progress;
|
||||
/// MCP tools for QA request, approve, and reject workflows.
|
||||
pub mod qa_tools;
|
||||
/// Task-local session identifier for the requesting MCP client, read from
|
||||
/// the `X-Huskies-Session` header so `tool_prompt_permission` can scope
|
||||
/// remembered approvals per requesting agent.
|
||||
pub mod session;
|
||||
/// MCP tools for running shell commands and test suites.
|
||||
pub mod shell_tools;
|
||||
/// MCP tools for pipeline status, story todos, and triage dump.
|
||||
@@ -71,6 +75,11 @@ pub async fn mcp_get_handler() -> Response {
|
||||
/// `tools/call`, and `notifications/*`.
|
||||
#[handler]
|
||||
pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppContext>>) -> Response {
|
||||
let session_id = req
|
||||
.header("x-huskies-session")
|
||||
.unwrap_or(session::DEFAULT_SESSION)
|
||||
.to_string();
|
||||
|
||||
let content_type = req.header("content-type").unwrap_or("");
|
||||
if !content_type.is_empty() && !content_type.contains("application/json") {
|
||||
return json_response(JsonRpcResponse::error(
|
||||
@@ -125,7 +134,7 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
|
||||
.and_then(|m| m.get("progressToken"))
|
||||
.cloned();
|
||||
if let (true, Some(token)) = (accepts_sse, progress_token) {
|
||||
return sse_tools_call(rpc.id, rpc.params, token, Arc::clone(&ctx)).await;
|
||||
return sse_tools_call(rpc.id, rpc.params, token, Arc::clone(&ctx), session_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +143,11 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
|
||||
"tools/list" => {
|
||||
JsonRpcResponse::success(rpc.id, json!({ "tools": tools_list::list_tools() }))
|
||||
}
|
||||
"tools/call" => handle_tools_call(rpc.id, &rpc.params, &ctx).await,
|
||||
"tools/call" => {
|
||||
session::SESSION_ID
|
||||
.scope(session_id, handle_tools_call(rpc.id, &rpc.params, &ctx))
|
||||
.await
|
||||
}
|
||||
_ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)),
|
||||
};
|
||||
|
||||
@@ -152,6 +165,7 @@ async fn sse_tools_call(
|
||||
params: Value,
|
||||
progress_token: Value,
|
||||
ctx: Arc<AppContext>,
|
||||
session_id: String,
|
||||
) -> Response {
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
@@ -174,9 +188,13 @@ async fn sse_tools_call(
|
||||
// its final state to the CRDT even on client disconnect).
|
||||
let dispatch_ctx = Arc::clone(&ctx);
|
||||
let dispatch_handle = tokio::spawn(async move {
|
||||
progress::EMITTER
|
||||
.scope(emitter, async move {
|
||||
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
|
||||
session::SESSION_ID
|
||||
.scope(session_id, async move {
|
||||
progress::EMITTER
|
||||
.scope(emitter, async move {
|
||||
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Task-local session identifier for the requesting MCP client.
|
||||
//!
|
||||
//! Threaded ambiently through tool dispatch (same pattern as
|
||||
//! [`super::progress::EMITTER`]) so a deeply-nested handler — currently only
|
||||
//! `tool_prompt_permission` — can scope decisions per requesting agent
|
||||
//! without adding a parameter to `dispatch_tool_call` and every one of its
|
||||
//! ~40 match arms.
|
||||
//!
|
||||
//! The HTTP MCP handler installs the scope before dispatching a `tools/call`
|
||||
//! request, populated from the `X-Huskies-Session` header that per-story
|
||||
//! worktrees embed in their `.mcp.json` (see `worktree::write_mcp_json`).
|
||||
//! Callers with no header (the main interactive chat CLI, API-based runtimes
|
||||
//! that invoke `dispatch_tool_call` directly) fall back to a fixed
|
||||
//! `"default"` key — there is only ever one such session per server process,
|
||||
//! so no cross-story leakage results from sharing that bucket.
|
||||
|
||||
/// Session key used when no `X-Huskies-Session` header was present.
|
||||
pub const DEFAULT_SESSION: &str = "default";
|
||||
|
||||
tokio::task_local! {
|
||||
/// Set by the MCP HTTP handler before dispatching a `tools/call` request.
|
||||
/// Unset in tests and in non-HTTP dispatch paths, where [`current`] falls
|
||||
/// back to [`DEFAULT_SESSION`].
|
||||
pub static SESSION_ID: String;
|
||||
}
|
||||
|
||||
/// Return the current request's session id, or [`DEFAULT_SESSION`] if no
|
||||
/// scope is installed.
|
||||
pub fn current() -> String {
|
||||
SESSION_ID
|
||||
.try_with(Clone::clone)
|
||||
.unwrap_or_else(|_| DEFAULT_SESSION.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_falls_back_to_default_without_scope() {
|
||||
assert_eq!(current(), DEFAULT_SESSION);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_reads_installed_scope() {
|
||||
let value = SESSION_ID
|
||||
.scope("1218".to_string(), async { current() })
|
||||
.await;
|
||||
assert_eq!(value, "1218");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user