huskies: merge 1218 story Remembered/sticky permission approvals so constrained agents stop re-prompting for the same action class

This commit is contained in:
Huskies Agent
2026-07-18 13:58:35 +00:00
parent fbaf5bf959
commit 7d3de2bb44
21 changed files with 313 additions and 214 deletions
+1
View File
@@ -141,6 +141,7 @@ pub(super) fn call_sync(
permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
-72
View File
@@ -1,72 +0,0 @@
//! Diagnostics I/O — the ONLY place in `service::diagnostics/` that may perform side effects.
//!
//! Side effects here include: reading and writing `.claude/settings.json` via `std::fs`.
//! Pure permission-rule logic (pattern derivation, wildcard domination checks) lives in
//! `permission.rs`.
use serde_json::{Value, json};
use std::fs;
use std::path::Path;
/// Add a permission rule to `.claude/settings.json` in the project root.
///
/// Does nothing if the rule already exists (exact match) or is already covered
/// by a wildcard pattern in the allow list. Creates the file and any missing
/// parent directories if they do not yet exist.
///
/// # Errors
/// Returns `Err(String)` if the directory cannot be created, the file cannot be
/// read or written, or the JSON cannot be parsed or serialised.
pub fn add_permission_rule(project_root: &Path, rule: &str) -> Result<(), String> {
let claude_dir = project_root.join(".claude");
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;
let settings_path = claude_dir.join("settings.json");
let mut settings: Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings.json: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))?
} else {
json!({ "permissions": { "allow": [] } })
};
let allow_arr = settings
.pointer_mut("/permissions/allow")
.and_then(|v| v.as_array_mut());
let allow = match allow_arr {
Some(arr) => arr,
None => {
settings
.as_object_mut()
.unwrap()
.entry("permissions")
.or_insert(json!({ "allow": [] }));
settings
.pointer_mut("/permissions/allow")
.unwrap()
.as_array_mut()
.unwrap()
}
};
let rule_value = Value::String(rule.to_string());
// Exact duplicate check.
if allow.contains(&rule_value) {
return Ok(());
}
// Wildcard-coverage check: if "mcp__huskies__*" exists, skip more-specific rules.
if super::permission::is_dominated_by_wildcard(rule, allow) {
return Ok(());
}
allow.push(rule_value);
let pretty =
serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?;
Ok(())
}
+4 -7
View File
@@ -3,20 +3,17 @@
//! Extracted from `http/mcp/diagnostics.rs` following the conventions in
//! `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (filesystem reads/writes)
//! - `permission.rs` — pure permission-rule generation and wildcard checks
//!
//! Permission rules are remembered in-memory, per requesting-agent session
//! (`service::permission_router::RememberedPermissions`, story 1218) rather
//! than written to disk, so there is no side-effectful I/O submodule here.
/// Side-effectful diagnostics I/O — log reads, CRDT dumps, filesystem writes.
pub mod io;
/// Pure permission-rule generation and wildcard matching.
pub mod permission;
#[allow(unused_imports)]
pub use io::add_permission_rule;
#[allow(unused_imports)]
pub use permission::generate_permission_rule;
#[allow(unused_imports)]
pub use permission::is_dominated_by_wildcard;
// ── Error type ────────────────────────────────────────────────────────────────
@@ -22,21 +22,6 @@ pub fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
}
}
/// Return `true` if `rule` is already covered by an existing wildcard in `allow_list`.
///
/// For example, if `allow_list` contains `"mcp__huskies__*"`, then the more
/// specific rule `"mcp__huskies__create_story"` is already covered.
pub fn is_dominated_by_wildcard(rule: &str, allow_list: &[Value]) -> bool {
allow_list.iter().any(|existing| {
if let Some(pat) = existing.as_str()
&& let Some(prefix) = pat.strip_suffix('*')
{
return rule.starts_with(prefix);
}
false
})
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -79,27 +64,4 @@ mod tests {
let rule = generate_permission_rule("mcp__huskies__create_story", &json!({"name": "foo"}));
assert_eq!(rule, "mcp__huskies__create_story");
}
#[test]
fn is_dominated_by_exact_wildcard() {
let allow = vec![json!("mcp__huskies__*")];
assert!(is_dominated_by_wildcard(
"mcp__huskies__create_story",
&allow
));
}
#[test]
fn is_not_dominated_by_different_prefix() {
let allow = vec![json!("mcp__other__*")];
assert!(!is_dominated_by_wildcard(
"mcp__huskies__create_story",
&allow
));
}
#[test]
fn is_not_dominated_when_list_is_empty() {
assert!(!is_dominated_by_wildcard("Edit", &[]));
}
}
+1
View File
@@ -741,6 +741,7 @@ pub fn spawn_gateway_bot(
.as_ref()
.map(|c| c.permission_timeout_secs)
.unwrap_or(120),
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
chat_dispatcher: std::sync::Arc::new(crate::chat::dispatcher::ChatDispatcher::new(
bot_cfg
.as_ref()
+84 -1
View File
@@ -20,7 +20,7 @@
//! part of the server.
use crate::http::context::{PermissionDecision, PermissionForward};
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -241,6 +241,57 @@ impl PendingPermReplies {
}
}
// ── Remembered permissions ───────────────────────────────────────────────
/// In-memory "don't ask again for this tool + target-pattern" store, scoped
/// per requesting agent session (story 1218).
///
/// Keyed by session id (see `http::mcp::session`) — for a per-story coding
/// agent this is the story id, stable across a `--resume` of the same story
/// since the worktree (and its `.mcp.json` header) doesn't change. Rules
/// remembered here are visible only within the session that recorded them,
/// so one agent's "don't ask again" never widens what another agent or
/// story is auto-approved for.
///
/// Deliberately **not** persisted to disk: this store lives only as long as
/// the server process does. Restarting the server clears every remembered
/// rule (agents will be prompted again once), which is an explicit tradeoff
/// — durability across restarts would require writing into each worktree's
/// own `.claude/settings.json`, which adds a second persistence path for a
/// case (server restarts mid-story) rare enough not to justify the added
/// complexity.
pub struct RememberedPermissions {
inner: StdMutex<HashMap<String, HashSet<String>>>,
}
impl RememberedPermissions {
/// Create an empty store.
pub fn new() -> Arc<Self> {
Arc::new(Self {
inner: StdMutex::new(HashMap::new()),
})
}
/// Record that `rule` is approved for `session_id` going forward.
pub fn remember(&self, session_id: &str, rule: &str) {
self.inner
.lock()
.unwrap()
.entry(session_id.to_string())
.or_default()
.insert(rule.to_string());
}
/// `true` if `rule` was previously remembered for `session_id`.
pub fn is_remembered(&self, session_id: &str, rule: &str) -> bool {
self.inner
.lock()
.unwrap()
.get(session_id)
.is_some_and(|rules| rules.contains(rule))
}
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -394,4 +445,36 @@ mod tests {
let pending = PendingPermReplies::new();
assert!(pending.resolve_oldest("no-such-room").await.is_none());
}
// ── RememberedPermissions ───────────────────────────────────────
#[test]
fn remembered_permissions_starts_empty() {
let store = RememberedPermissions::new();
assert!(!store.is_remembered("1218", "Bash(git *)"));
}
#[test]
fn remembered_permissions_recalls_after_remember() {
let store = RememberedPermissions::new();
store.remember("1218", "Bash(git *)");
assert!(store.is_remembered("1218", "Bash(git *)"));
}
#[test]
fn remembered_permissions_scoped_per_session() {
let store = RememberedPermissions::new();
store.remember("1218", "Bash(git *)");
assert!(
!store.is_remembered("1216", "Bash(git *)"),
"a rule remembered for one story must not apply to another"
);
}
#[test]
fn remembered_permissions_scoped_per_rule() {
let store = RememberedPermissions::new();
store.remember("1218", "Bash(git *)");
assert!(!store.is_remembered("1218", "Write"));
}
}