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
+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"));
}
}