ai_memory/governance/mod.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// v0.7.0 Track K — Task K9: unified permission system.
5//
6// Replaces the v0.6.x ad-hoc governance gate with a single
7// composition pipeline:
8//
9// Rules (declarative `[permissions.rules]`)
10// +
11// Modes (`[permissions].mode` — K3, already wired)
12// +
13// Hooks (G1-G11 `HookDecision` returned by chain runs)
14// ↓
15// Decision { Allow, Deny(reason), Modify(delta), Ask(prompt) }
16//
17// Combining rule:
18//
19// 1. First Deny across any source wins.
20// 2. Otherwise: if any source returned Modify, Modify wins (the
21// composed delta from hooks; rules cannot Modify in K9 — they
22// only Allow / Deny / Ask).
23// 3. Otherwise: if any source returned Allow explicitly, Allow.
24// 4. Otherwise: Ask falls through to the active mode default —
25// Enforce promotes Ask to Deny, Advisory + Off promote to Allow.
26//
27// The pipeline is deny-first per the v0.7 epic K9 spec: ambiguity
28// goes to Ask rather than silently approving, but the mode default
29// for ambiguous cases under Advisory/Off is to allow (so existing
30// upgraders keep working). Operators who want strict-deny on Ask
31// must configure `[permissions] mode = "enforce"`.
32
33use serde::{Deserialize, Serialize};
34use std::sync::RwLock;
35
36use crate::config::{PermissionsMode, active_permissions_mode};
37use crate::hooks::decision::HookDecision;
38use crate::hooks::events::MemoryDelta;
39
40/// Tracing target for governance-gate log lines (#1558 tracing-target
41/// SSOT). Distinct from the `governance` Family taxonomy name and the
42/// `metadata.governance` key (`crate::META_KEY_GOVERNANCE`).
43pub(crate) const GOVERNANCE_TRACE_TARGET: &str = "governance";
44
45// v0.7.0 (issue #691) — substrate-level agent-action rules engine.
46// The K9 pipeline below gates substrate-INTERNAL ops (memory_store,
47// memory_link, ...). `agent_action` adds the parallel engine for
48// agent-EXTERNAL actions (Bash, FilesystemWrite, NetworkRequest,
49// ProcessSpawn, Custom). `rules_store` is the typed CRUD over the
50// `governance_rules` table.
51//
52// 7th-form closeout (issue #760): wired at the harness boundary
53// across the four enumerated wire-points (skill_export,
54// federation::sync, hooks::executor, llm) — see
55// `agent_action::module-docs` for the full table. Seed rules
56// R001-R004 land at `enabled=0` per migration
57// `0024_v07_governance_rules.sql`; the operator activates them via
58// `ai-memory governance install-defaults` (one-shot bulk enable)
59// or `ai-memory rules enable <id> --sign` (per-rule).
60pub mod agent_action;
61// v0.7.0 #697 — Ed25519-signed forensic audit log. Independent of the
62// file-based `audit.rs` chain (which logs memory-substrate ops);
63// `governance::audit` captures every governance DECISION (allow /
64// refuse / warn) into a daily-rotated, hash-chained, Ed25519-signed
65// `audit/forensic-<YYYY-MM-DD>.jsonl`. The `ai-memory audit verify
66// --since <ISO_DATE>` CLI walks the chain + signatures.
67pub mod audit;
68// v0.7.0 Policy-Engine Item 3 — deferred audit-log queue for
69// storage-hook refusals. Closes the cryptographic-log gap on the
70// `GOVERNANCE_PRE_WRITE` path that previously routed through
71// `check_agent_action_no_audit` (no chain-log emit) to avoid a
72// re-entrant `Connection` deadlock. See `deferred_audit.rs` for the
73// architecture.
74pub mod deferred_audit;
75// v0.7.0 #991 — per-instance cache for enabled-rule lists keyed by
76// `AgentAction::kind`. Owned by the Connection-bearing context
77// (HTTP `AppState`, MCP main loop, storage / wire-check hook
78// installers); never a global singleton. The per-instance design
79// closes the cross-connection poisoning hole that reverted #983 via
80// #990. See `rule_cache.rs` module docs for the full rationale.
81pub mod rule_cache;
82pub mod rules_store;
83
84// v0.7.0 (issue #691 fold-1) — universal AgentAction wire-point helper.
85// Same OnceLock-based hook pattern as `storage::GOVERNANCE_PRE_WRITE`,
86// but covering the four agent-EXTERNAL action variants (Bash,
87// FilesystemWrite, NetworkRequest, ProcessSpawn) — the storage hook
88// handles the substrate-INTERNAL Custom("memory_write") gate.
89//
90// The daemon `bootstrap_serve` installs ONE shared closure that
91// consults the same `governance_rules` table the storage hook reads,
92// then every wire-point in the daemon-side code paths (skill_export,
93// federation::sync, hooks::executor, llm) calls
94// `wire_check::check(&action)?` to consult it.
95pub mod wire_check;
96// #963 — typed governance refusal envelope. Currently exposed as a
97// self-contained module + unit-tested in isolation; the wire-in to
98// `GovernanceDecision::Deny` lands in the follow-up commit per the
99// per-issue end-to-end protocol (see issue #963 body).
100pub mod refusal;
101pub use refusal::GovernanceRefusal;
102
103// ---------------------------------------------------------------------------
104// Op tag — the five gated operations
105// ---------------------------------------------------------------------------
106
107/// The operation a permission check is gating. K9 wires the
108/// pipeline into five callsites: store, link, delete, archive,
109/// consolidate. v0.7.0 #628 H6 added a sixth — `memory_replay` —
110/// so cross-tenant transcript reads are gated by the same evaluator
111/// that already gates writes. The wire string is the canonical name
112/// surfaced in rule matchers (`op = "memory_store"` etc.).
113///
114/// # Disambiguation (issue #970)
115///
116/// `Op` is the **K9 permission-rule op discriminator**. It is
117/// related-but-distinct from [`crate::models::GovernedAction`], the
118/// **approval-queue discriminator**:
119///
120/// - `Op` wire strings: `memory_store` / `memory_link` /
121/// `memory_delete` / `memory_archive` / `memory_consolidate` /
122/// `memory_replay` (6 variants — every K9-gated tool).
123/// - `GovernedAction` wire strings: `store` / `delete` / `promote`
124/// / `reflect` (4 variants — substrate actions that can be queued
125/// for approval).
126///
127/// The two enums share the `delete` semantic surface but the rest
128/// is disjoint (`Op` covers `link`/`archive`/`consolidate`/`replay`
129/// which never queue; `GovernedAction` covers `promote`/`reflect`
130/// which do not need K9 op-gating). The wire strings are
131/// deliberately different so a config-file misuse is a typed loader
132/// error, not a silent fall-through. See
133/// `docs/internal/enum-proliferation-audit-970.md`.
134/// #1558 batch 5 wave 3 — `Op::MemoryArchive` wire name. A governance
135/// op identifier covering the 4-tool archive family
136/// (list/purge/restore/stats); NOT itself an MCP tool name (see the
137/// `Op::as_str` doc below), so it owns its spelling here. Also reused
138/// as the archive-event slug fired by the postgres archive handler.
139pub const OP_MEMORY_ARCHIVE: &str = "memory_archive";
140
141/// #1558 batch 5 wave 3 — cross-surface action labels passed to
142/// [`deny_message`] / `governance::audit::record_decision` / the AGE
143/// fallback warn path. One spelling per label; CLI, HTTP, and MCP
144/// surfaces reference these consts.
145pub mod action_labels {
146 /// Archive-purge admin action (CLI `archive purge`, HTTP
147 /// `DELETE /api/v1/archive`, MCP `memory_archive_purge`).
148 pub const ARCHIVE_PURGE: &str = "archive_purge";
149 /// Archive-restore action (HTTP `POST /api/v1/archive/{id}/restore`).
150 pub const ARCHIVE_RESTORE: &str = "archive_restore";
151 /// KG edge-invalidation action (MCP `memory_kg_invalidate` deny
152 /// label + the postgres AGE-fallback warn label).
153 pub const KG_INVALIDATE: &str = "kg_invalidate";
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum Op {
159 MemoryStore,
160 MemoryLink,
161 MemoryDelete,
162 MemoryArchive,
163 MemoryConsolidate,
164 /// v0.7.0 #628 H6 — `memory_replay` MCP tool (transcript read).
165 /// Gated so an agent cannot fetch verbatim transcript content
166 /// from a namespace they are not authorised to read.
167 MemoryReplay,
168}
169
170impl Op {
171 /// Wire name used in `[permissions.rules].op`. Stable across
172 /// versions.
173 ///
174 /// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): the
175 /// five variants whose wire string ALSO appears as an MCP tool
176 /// name reference the canonical `tool_names` const so the
177 /// governance-op spelling cannot drift from the dispatch table.
178 /// `MemoryArchive` is the lone exception: its wire string
179 /// `"memory_archive"` is a governance op identifier covering the
180 /// 4-tool archive family (list/purge/restore/stats); it is NOT
181 /// itself an MCP tool name and therefore stays as a raw literal.
182 #[must_use]
183 pub fn as_str(self) -> &'static str {
184 use crate::mcp::registry::tool_names as tn;
185 match self {
186 Op::MemoryStore => tn::MEMORY_STORE,
187 Op::MemoryLink => tn::MEMORY_LINK,
188 Op::MemoryDelete => tn::MEMORY_DELETE,
189 Op::MemoryArchive => OP_MEMORY_ARCHIVE,
190 Op::MemoryConsolidate => tn::MEMORY_CONSOLIDATE,
191 Op::MemoryReplay => tn::MEMORY_REPLAY,
192 }
193 }
194
195 /// Parse from the wire name. Used by rule loaders.
196 #[must_use]
197 pub fn from_str(s: &str) -> Option<Op> {
198 use crate::mcp::registry::tool_names as tn;
199 match s {
200 tn::MEMORY_STORE => Some(Op::MemoryStore),
201 tn::MEMORY_LINK => Some(Op::MemoryLink),
202 tn::MEMORY_DELETE => Some(Op::MemoryDelete),
203 OP_MEMORY_ARCHIVE => Some(Op::MemoryArchive),
204 tn::MEMORY_CONSOLIDATE => Some(Op::MemoryConsolidate),
205 tn::MEMORY_REPLAY => Some(Op::MemoryReplay),
206 _ => None,
207 }
208 }
209}
210
211// ---------------------------------------------------------------------------
212// Decision — the unified output of the pipeline
213// ---------------------------------------------------------------------------
214
215/// The four-shape outcome of [`Permissions::evaluate`]. Mirrors the
216/// G4 [`HookDecision`] vocabulary so callers wire one decision type
217/// into all five op paths regardless of which source produced the
218/// outcome.
219///
220/// `Modify` carries a [`MemoryDelta`] — the same payload type the
221/// hook chain composes. Rules in K9 cannot return Modify (only
222/// Allow / Deny / Ask); only hook chains can.
223///
224/// `Ask` carries the prompt text that should be surfaced to the
225/// operator (or queued in the K10 approval pipeline). The runtime
226/// promotion of Ask under [`PermissionsMode::Enforce`] turns this
227/// into Deny so callers don't accidentally approve under strict
228/// mode.
229///
230/// # Disambiguation (issue #970)
231///
232/// The codebase has five enums named `Decision`. They model
233/// different domain outputs and are NOT substitutable:
234///
235/// - [`Decision`] (this enum) — K9 four-shape pipeline output
236/// (rules + hooks + mode promotion combined).
237/// - [`RuleDecision`] — narrower three-shape TOML rule-row
238/// decision (no `Modify`; rules can't rewrite a payload).
239/// - [`crate::governance::agent_action::Decision`] — three-shape
240/// external-action engine output (`Allow` / `Refuse{rule_id,
241/// reason}` / `Warn{rule_id, reason}`); narrower again, with a
242/// structured refusal payload instead of a string.
243/// - [`crate::models::GovernanceDecision`] — three-shape substrate
244/// governance output (`Allow` / `Deny(GovernanceRefusal)` /
245/// `Pending(String)`); carries a typed refusal envelope.
246/// - [`crate::approvals::Decision`] — two-shape operator submission
247/// verdict (`Approve` / `Deny`) for the K10 transports.
248///
249/// Each enum's variant set is locked to its column / wire contract;
250/// see `docs/internal/enum-proliferation-audit-970.md`.
251// #969 — `PartialEq` derived. Pre-#969 hand-rolled because the
252// inner `MemoryDelta` of `Modify` was thought to lack a usable
253// equality; in fact `serde_json::Value` derives `Eq + PartialEq + Hash`
254// and `MemoryDelta` derives `PartialEq` (its `Option<f64>` blocks
255// `Eq` but not `PartialEq`).
256#[derive(Debug, Clone, PartialEq)]
257pub enum Decision {
258 /// Allow the operation to proceed unchanged.
259 Allow,
260 /// Deny the operation. `reason` surfaces in the API response and
261 /// the audit log.
262 Deny(String),
263 /// Allow the operation but apply `delta` first. Only produced by
264 /// hook chains in K9; rules cannot return Modify.
265 Modify(MemoryDelta),
266 /// Pause and prompt the operator. Mode default decides what to
267 /// do with this if no caller is wired into the K10 approval API
268 /// (Enforce → Deny, Advisory/Off → Allow).
269 Ask(String),
270}
271
272// ---------------------------------------------------------------------------
273// PermissionContext — input to evaluate
274// ---------------------------------------------------------------------------
275
276/// Every input the rule + hook + mode pipeline needs. Built by
277/// each op-path callsite (handlers / mcp.rs) and passed by value
278/// into [`Permissions::evaluate`].
279#[derive(Debug, Clone)]
280pub struct PermissionContext {
281 pub op: Op,
282 pub namespace: String,
283 pub agent_id: String,
284 /// JSON snapshot of the in-flight payload (memory, link target,
285 /// archive id, etc.). Surfaced to rule matchers for future
286 /// content-based rules; in K9 the matchers only consult
287 /// namespace + agent_id but the payload is part of the
288 /// signature so adding payload-aware rules later is wire-stable.
289 pub payload: serde_json::Value,
290}
291
292// ---------------------------------------------------------------------------
293// PermissionRule — the declarative `[permissions.rules]` shape
294// ---------------------------------------------------------------------------
295
296/// One row of `[[permissions.rules]]` from `config.toml`.
297///
298/// Wire format:
299///
300/// ```toml
301/// [[permissions.rules]]
302/// namespace_pattern = "secrets/*"
303/// op = "memory_store"
304/// agent_pattern = "ai:*"
305/// decision = "deny"
306/// reason = "ai agents may not write to secrets"
307/// ```
308///
309/// `namespace_pattern` and `agent_pattern` use a tiny glob
310/// vocabulary: `*` matches any run of non-`/` characters in the
311/// namespace, any run of any character in the agent id. `**`
312/// matches across `/` boundaries. An exact string is treated as a
313/// literal match.
314///
315/// `op` is required and matches the [`Op::as_str`] wire form. A
316/// missing `op` fails the loader.
317///
318/// Pattern specificity (longer literal-prefix wins) is the tie
319/// breaker when multiple rules match the same context — the rule
320/// whose `namespace_pattern` has the longest non-glob prefix takes
321/// precedence. Within equal namespace specificity, an exact
322/// `agent_pattern` (no `*`) beats a wildcard.
323#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
324pub struct PermissionRule {
325 pub namespace_pattern: String,
326 pub op: String,
327 #[serde(default = "default_agent_pattern")]
328 pub agent_pattern: String,
329 pub decision: RuleDecision,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub reason: Option<String>,
332}
333
334fn default_agent_pattern() -> String {
335 "*".to_string()
336}
337
338/// Wire-level rule outcome. Narrower than [`Decision`] because rules
339/// can't return `Modify` — only hook chains can. The `Ask` variant
340/// uses the rule's `reason` field as the prompt text.
341#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
342#[serde(rename_all = "lowercase")]
343pub enum RuleDecision {
344 Allow,
345 Deny,
346 Ask,
347}
348
349// ---------------------------------------------------------------------------
350// Pattern matching
351// ---------------------------------------------------------------------------
352
353/// Tiny glob: `**` matches across `/`, `*` matches a single
354/// `/`-delimited segment. Exact strings match literally. Empty
355/// pattern matches the empty string only.
356#[must_use]
357pub fn glob_matches(pattern: &str, value: &str) -> bool {
358 glob_inner(pattern.as_bytes(), value.as_bytes())
359}
360
361fn glob_inner(pat: &[u8], val: &[u8]) -> bool {
362 // Iterative backtracker — avoids unbounded recursion on a
363 // pathological pattern but keeps the implementation < 30 LOC.
364 let (mut p, mut v) = (0usize, 0usize);
365 let (mut star_p, mut star_v): (Option<usize>, usize) = (None, 0);
366 let mut star_double = false;
367 while v < val.len() {
368 if p < pat.len() {
369 // `**` greedy across '/'. `*` greedy within a segment.
370 if pat[p] == b'*' {
371 let double = p + 1 < pat.len() && pat[p + 1] == b'*';
372 star_p = Some(p);
373 star_double = double;
374 p += if double { 2 } else { 1 };
375 star_v = v;
376 continue;
377 }
378 if pat[p] == val[v] {
379 p += 1;
380 v += 1;
381 continue;
382 }
383 }
384 // Mismatch: reset to last star and advance value cursor.
385 if let Some(sp) = star_p {
386 // `*` may not consume a '/' — '**' may.
387 if !star_double && val[star_v] == b'/' {
388 return false;
389 }
390 star_v += 1;
391 // Walking past '/' under single-star also fails.
392 if !star_double && star_v <= val.len() && {
393 // Check: if a '/' lies between star_v-1 and star_v we
394 // already failed above; here we just reset cursors.
395 false
396 } {
397 return false;
398 }
399 p = sp + if star_double { 2 } else { 1 };
400 v = star_v;
401 continue;
402 }
403 return false;
404 }
405 // Trailing pattern must be all '*' / '**'.
406 while p < pat.len() && pat[p] == b'*' {
407 p += 1;
408 }
409 p == pat.len()
410}
411
412/// Specificity score for a glob. Higher = more specific. Used as
413/// the tie-breaker when multiple rules match the same context.
414/// Score is the length of the longest non-`*` prefix.
415#[must_use]
416pub fn pattern_specificity(pattern: &str) -> usize {
417 pattern.bytes().take_while(|b| *b != b'*').count()
418}
419
420// ---------------------------------------------------------------------------
421// Permissions — the public evaluator
422// ---------------------------------------------------------------------------
423
424/// The K9 unified evaluator. Rules + Mode + Hooks compose into a
425/// single [`Decision`]; deny-first; ask falls through to mode.
426///
427/// Stateless type — every input is a parameter. The active rules
428/// list is held in the process-wide [`active_permission_rules`]
429/// registry so callsites in `mcp.rs` / `handlers.rs` don't need to
430/// thread a config handle through every function.
431pub struct Permissions;
432
433impl Permissions {
434 /// Evaluate the full pipeline.
435 ///
436 /// `hook_decisions` is the (possibly empty) sequence of
437 /// decisions returned by hook chains for this op. Callers that
438 /// have not yet wired a hook chain into a particular op pass
439 /// `&[]`; the pipeline still works (rules + mode resolve the
440 /// decision).
441 #[must_use]
442 pub fn evaluate(ctx: &PermissionContext, hook_decisions: &[HookDecision]) -> Decision {
443 // Review #628 H10: K10's `remember=forever` writes a
444 // [`crate::approvals::SyntheticPermissionRule`] into a
445 // separate registry that the v0.7.0-ship evaluator did not
446 // consult — so an operator who clicked "remember" was
447 // re-prompted on every subsequent matching call. We promote
448 // each synthetic entry into a virtual [`PermissionRule`] and
449 // splice them onto the front of the rule list so the
450 // existing combiner sees them. The combiner is deny-first
451 // across all sources, which preserves the safety property
452 // that an explicit config Deny still beats an operator's
453 // `remember=forever`-Allow — and a synthetic Allow shadows a
454 // config-level Ask (the whole point of "remember").
455 let mut rules = synthetic_rules_as_permission_rules();
456 rules.extend(active_permission_rules());
457 Self::evaluate_with(ctx, hook_decisions, &rules, active_permissions_mode())
458 }
459
460 /// Same as [`Permissions::evaluate`] but takes the rule list and
461 /// mode as explicit parameters. Used by the K9 test matrix so
462 /// scenarios can exercise specific rule-set / mode combinations
463 /// without poking the process-wide registry.
464 ///
465 /// # H8 invariant — namespace cannot be elevated by `Modify`
466 ///
467 /// The pinned namespace for rule evaluation is taken from
468 /// `ctx.namespace` BEFORE any rule pass. If a hook returns
469 /// `Modify { namespace: <other_ns> }` the pipeline RE-EVALUATES
470 /// the entire rule set against the new namespace; if that
471 /// re-evaluation returns `Decision::Deny`, the modification is
472 /// rejected (the original `Deny` reason is surfaced — annotated
473 /// with the rejected escalation). This closes the v0.7.0 review
474 /// blocker H8 / #628 where a `Modify`-rewrite of `namespace`
475 /// could bypass a rule that targeted the destination namespace.
476 #[must_use]
477 pub fn evaluate_with(
478 ctx: &PermissionContext,
479 hook_decisions: &[HookDecision],
480 rules: &[PermissionRule],
481 mode: PermissionsMode,
482 ) -> Decision {
483 // Mode short-circuit: Off skips the whole pipeline. K3
484 // already documents Off as the freeze-thaw escape hatch.
485 if mode == PermissionsMode::Off {
486 return Decision::Allow;
487 }
488
489 // H8 — pin the namespace at entry. The original namespace
490 // is the only one that may participate in this evaluation;
491 // any hook that proposes a different namespace must survive
492 // a re-evaluation against the rules pinned to the *new*
493 // namespace below.
494 let pinned_ns = ctx.namespace.clone();
495
496 // Collect rule decisions matching this context.
497 let matched = matched_rules(ctx, rules);
498 let rule_outcomes: Vec<&PermissionRule> = matched;
499
500 // Pass 1: deny-first across all sources. Rules first
501 // (declarative intent should win against an over-permissive
502 // hook), then hooks.
503 for r in &rule_outcomes {
504 if matches!(r.decision, RuleDecision::Deny) {
505 return Decision::Deny(r.reason.clone().unwrap_or_else(|| {
506 format!(
507 "denied by permission rule (namespace_pattern={}, op={}, agent_pattern={})",
508 r.namespace_pattern, r.op, r.agent_pattern
509 )
510 }));
511 }
512 }
513 for h in hook_decisions {
514 if let HookDecision::Deny { reason, .. } = h {
515 return Decision::Deny(reason.clone());
516 }
517 }
518
519 // Pass 2: Modify wins next. Only hooks can produce Modify.
520 // Compose deltas from every Modify in chain order so
521 // multi-hook pipelines accumulate.
522 let mut composed: Option<MemoryDelta> = None;
523 for h in hook_decisions {
524 if let HookDecision::Modify(payload) = h {
525 let next = payload.delta.clone();
526 composed = Some(merge_delta(composed.take(), next));
527 }
528 }
529 if let Some(delta) = composed {
530 // H8 — if the composed delta rewrites `namespace` to a
531 // value other than the pinned one, RE-EVALUATE the rule
532 // pipeline against the new namespace. A Deny on the new
533 // namespace rejects the modification (the hook cannot
534 // launder a write into a denied namespace).
535 if let Some(new_ns) = delta.namespace.as_deref()
536 && new_ns != pinned_ns
537 {
538 let rebound_ctx = PermissionContext {
539 op: ctx.op,
540 namespace: new_ns.to_string(),
541 agent_id: ctx.agent_id.clone(),
542 payload: ctx.payload.clone(),
543 };
544 // Re-evaluate against rules ONLY (we already drained
545 // the hooks slice above; re-running them would either
546 // loop indefinitely or re-Modify the same delta).
547 // The hooks pass is empty here so the recursion
548 // terminates after a single rule pass.
549 let rebound = Self::evaluate_with(&rebound_ctx, &[], rules, mode);
550 match rebound {
551 Decision::Deny(reason) => {
552 return Decision::Deny(format!(
553 "namespace escalation rejected: hook proposed Modify into \
554 namespace {new_ns:?} (from pinned {pinned_ns:?}) which is denied: \
555 {reason}"
556 ));
557 }
558 // P2 (#628 agent-2 follow-up): an `Ask` decision on
559 // the rebound namespace must NOT silently elevate
560 // into a Modify. The hook is asking the operator to
561 // approve a cross-namespace write — surface the Ask
562 // up the stack so the same approval flow that
563 // governs an explicit cross-namespace store also
564 // governs hook-driven rebinds. Without this, a hook
565 // could launder a write into any namespace that
566 // lacks an explicit Allow rule (rules-opt-in
567 // design); the prior code silently accepted that.
568 Decision::Ask(reason) => {
569 return Decision::Ask(format!(
570 "namespace escalation requires approval: hook proposed Modify \
571 into namespace {new_ns:?} (from pinned {pinned_ns:?}); \
572 rebound rule prompts: {reason}"
573 ));
574 }
575 // Allow / Modify on the rebound namespace are fine —
576 // the hook's namespace rewrite is explicitly
577 // permitted by an Allow rule covering the new
578 // namespace, so the originally-pinned context's
579 // permission decision was the correct one.
580 Decision::Allow | Decision::Modify(_) => {}
581 }
582 }
583 return Decision::Modify(delta);
584 }
585
586 // Pass 3: explicit Allow from any source short-circuits Ask.
587 let any_rule_allow = rule_outcomes
588 .iter()
589 .any(|r| matches!(r.decision, RuleDecision::Allow));
590 let any_hook_allow = hook_decisions
591 .iter()
592 .any(|h| matches!(h, HookDecision::Allow));
593 if any_rule_allow || any_hook_allow {
594 return Decision::Allow;
595 }
596
597 // Pass 4: Ask falls through to mode default.
598 let any_rule_ask = rule_outcomes
599 .iter()
600 .find(|r| matches!(r.decision, RuleDecision::Ask));
601 let any_hook_ask = hook_decisions
602 .iter()
603 .find(|h| matches!(h, HookDecision::AskUser { .. }));
604 let prompt = if let Some(r) = any_rule_ask {
605 r.reason.clone().unwrap_or_else(|| {
606 format!(
607 "permission rule requests approval (namespace_pattern={}, op={})",
608 r.namespace_pattern, r.op
609 )
610 })
611 } else if let Some(HookDecision::AskUser { prompt, .. }) = any_hook_ask {
612 prompt.clone()
613 } else {
614 // No source spoke: fall back to the mode default
615 // outright (no Ask was raised).
616 return mode_default_for(mode, ctx);
617 };
618
619 match mode {
620 PermissionsMode::Enforce => Decision::Deny(format!(
621 "permission ask escalated to deny under enforce mode: {prompt}"
622 )),
623 PermissionsMode::Advisory | PermissionsMode::Off => Decision::Ask(prompt),
624 }
625 }
626}
627
628/// Mode default when no rule and no hook spoke. Enforce defaults
629/// to Allow (rules opt in to deny; the gate is opt-in everywhere
630/// else too); Advisory and Off both default to Allow. The unified
631/// surface mirrors the v0.6.x semantics: namespaces without an
632/// explicit policy are unaffected.
633fn mode_default_for(_mode: PermissionsMode, _ctx: &PermissionContext) -> Decision {
634 Decision::Allow
635}
636
637/// Walk `rules` and return the subset matching `ctx`, sorted by
638/// specificity descending (longest literal namespace prefix wins,
639/// then exact agent pattern beats wildcard).
640fn matched_rules<'a>(
641 ctx: &PermissionContext,
642 rules: &'a [PermissionRule],
643) -> Vec<&'a PermissionRule> {
644 let mut hits: Vec<&PermissionRule> = rules
645 .iter()
646 .filter(|r| {
647 r.op == ctx.op.as_str()
648 && glob_matches(&r.namespace_pattern, &ctx.namespace)
649 && glob_matches(&r.agent_pattern, &ctx.agent_id)
650 })
651 .collect();
652 hits.sort_by(|a, b| {
653 let sa = (
654 pattern_specificity(&a.namespace_pattern),
655 usize::from(!a.agent_pattern.contains('*')),
656 );
657 let sb = (
658 pattern_specificity(&b.namespace_pattern),
659 usize::from(!b.agent_pattern.contains('*')),
660 );
661 sb.cmp(&sa)
662 });
663 hits
664}
665
666/// Field-wise merge: `next` overrides `prior` field-by-field.
667fn merge_delta(prior: Option<MemoryDelta>, next: MemoryDelta) -> MemoryDelta {
668 let mut out = prior.unwrap_or_default();
669 if next.tier.is_some() {
670 out.tier = next.tier;
671 }
672 if next.namespace.is_some() {
673 out.namespace = next.namespace;
674 }
675 if next.title.is_some() {
676 out.title = next.title;
677 }
678 if next.content.is_some() {
679 out.content = next.content;
680 }
681 if next.tags.is_some() {
682 out.tags = next.tags;
683 }
684 if next.priority.is_some() {
685 out.priority = next.priority;
686 }
687 if next.confidence.is_some() {
688 out.confidence = next.confidence;
689 }
690 if next.source.is_some() {
691 out.source = next.source;
692 }
693 if next.expires_at.is_some() {
694 out.expires_at = next.expires_at;
695 }
696 if next.metadata.is_some() {
697 out.metadata = next.metadata;
698 }
699 out
700}
701
702// ---------------------------------------------------------------------------
703// Synthetic rule integration (review #628 H10)
704// ---------------------------------------------------------------------------
705
706/// Map a K10 `pending_actions.action_type` string onto a K9 [`Op`].
707///
708/// K10 records synthetic rules with the wire-level `action_type`
709/// (`"store"`, `"delete"`, `"promote"`) — the same shape the
710/// `pending_actions` table uses. K9 evaluates against an [`Op`]
711/// enum (`memory_store`, `memory_delete`, …). This adapter bridges
712/// the two so `remember=forever` rules become consultable by the
713/// store / delete pipeline without the rule loader having to know
714/// about K9 internals.
715fn op_matches_action_type(op: Op, action_type: &str) -> bool {
716 match (op, action_type) {
717 (Op::MemoryStore, "store")
718 | (Op::MemoryDelete, "delete")
719 | (Op::MemoryArchive, "archive" | "promote")
720 | (Op::MemoryConsolidate, crate::audit::OP_CONSOLIDATE)
721 | (Op::MemoryLink, "link") => true,
722 _ => false,
723 }
724}
725
726/// Promote every entry in
727/// [`crate::approvals::list_synthetic_rules`] into the equivalent
728/// [`PermissionRule`] shape so the K9 evaluator can consume them
729/// alongside the config-loaded rules. Empty agent_id is rendered as
730/// the wildcard `"*"`. Unknown decision verbs are dropped with a
731/// WARN — the K10 transports only ever write `"approve"` /
732/// `"deny"`, so this is defence-in-depth, not load-bearing.
733///
734/// Each synthetic entry yields one `PermissionRule` per K9 [`Op`]
735/// the `action_type` maps to (via [`op_matches_action_type`]).
736/// `pending_actions.action_type == "store"` produces a
737/// `memory_store` rule; `"delete"` produces `memory_delete`; etc.
738fn synthetic_rules_as_permission_rules() -> Vec<PermissionRule> {
739 let synth = crate::approvals::list_synthetic_rules();
740 let mut out: Vec<PermissionRule> = Vec::with_capacity(synth.len());
741 let ops = [
742 Op::MemoryStore,
743 Op::MemoryDelete,
744 Op::MemoryArchive,
745 Op::MemoryConsolidate,
746 Op::MemoryLink,
747 ];
748 for s in synth {
749 let decision = match s.decision.as_str() {
750 "approve" | "allow" => RuleDecision::Allow,
751 "deny" | "reject" => RuleDecision::Deny,
752 other => {
753 tracing::warn!(
754 "ignoring synthetic permission rule with unknown decision verb: {other:?}"
755 );
756 continue;
757 }
758 };
759 let agent_pattern = s.agent_id.clone().unwrap_or_else(|| "*".to_string());
760 for op in ops {
761 if !op_matches_action_type(op, &s.action_type) {
762 continue;
763 }
764 out.push(PermissionRule {
765 namespace_pattern: s.namespace.clone(),
766 op: op.as_str().to_string(),
767 agent_pattern: agent_pattern.clone(),
768 decision,
769 reason: Some(format!(
770 "remembered operator decision (recorded_at={})",
771 s.recorded_at
772 )),
773 });
774 }
775 }
776 out
777}
778
779// ---------------------------------------------------------------------------
780// v0.7.0 F8 — secure-by-default mode resolution
781// ---------------------------------------------------------------------------
782//
783// Round-2 evidence: a namespace with `metadata.governance.write=owner`
784// accepted writes from an unrelated agent_id because `permissions.mode`
785// was unconfigured and therefore fell back to the v0.7.0-ship default
786// of `advisory` — which logs but does not block. We flip the default
787// for unconfigured deployments to `enforce` so an upgrader who has
788// not yet authored a `[permissions]` block gets the secure posture
789// out of the box. Operators who wanted advisory must opt in
790// explicitly.
791//
792// The compiled `Default for PermissionsMode` in `config.rs` continues
793// to return `Advisory` because that default is also consumed by the
794// serde-deserialise of an empty `[permissions]` block — flipping it
795// there would silently change the meaning of `[permissions]` blocks
796// that lack `mode = ` while preserving every other field. Instead we
797// expose [`default_v07_secure_mode`] / [`resolve_v07_default_mode`]
798// here for the daemon's bootstrap path to consult at startup, and for
799// the migration-warning surface to detect the "config exists, mode
800// unset" upgrade case.
801
802/// The v0.7.0 secure-by-default permissions mode. Returns
803/// [`PermissionsMode::Enforce`].
804///
805/// Round-2 F8 — used by the daemon's bootstrap path (see
806/// [`crate::cli::serve_banner`]) to resolve the active mode when the
807/// operator's `config.toml` does not include a `[permissions]` block
808/// or omits the `mode = ` field within one.
809#[must_use]
810pub fn default_v07_secure_mode() -> PermissionsMode {
811 PermissionsMode::Enforce
812}
813
814/// Round-2 F8 — resolve the effective mode for an upgrading deployment.
815///
816/// `configured` is `Some(mode)` when the operator has explicitly set
817/// `[permissions].mode` in `config.toml`, and `None` when the field is
818/// absent (either the block is missing or only contains other fields).
819///
820/// Returns `(effective_mode, optional_migration_warning)`:
821/// - `effective_mode` is the configured value if present, otherwise the
822/// v0.7.0 secure default ([`PermissionsMode::Enforce`]).
823/// - `optional_migration_warning` is `Some(text)` when the operator's
824/// config did NOT explicitly set `mode` AND the resolved default is
825/// stricter than the v0.6.x posture (i.e., we flipped them from
826/// advisory to enforce). The warning is surfaced once at daemon
827/// startup so an upgrader notices the behaviour change and can opt
828/// back into advisory if their workflow depends on it.
829#[must_use]
830pub fn resolve_v07_default_mode(
831 configured: Option<PermissionsMode>,
832) -> (PermissionsMode, Option<String>) {
833 if let Some(mode) = configured {
834 return (mode, None);
835 }
836 let warning = "v0.7.0 default changed to enforce; set permissions.mode=advisory in config to \
837 opt out — see release notes."
838 .to_string();
839 (default_v07_secure_mode(), Some(warning))
840}
841
842/// Round-2 F8 — single-line startup-banner text describing the active
843/// permissions posture. Surfaced by the daemon's serve banner so an
844/// operator inspecting logs can see at a glance which mode is live.
845///
846/// Format: `"permissions: enforce"` / `"permissions: advisory"` /
847/// `"permissions: off"`.
848#[must_use]
849pub fn startup_banner_line(mode: PermissionsMode) -> String {
850 format!("permissions: {}", mode.as_str())
851}
852
853// ---------------------------------------------------------------------------
854// Process-wide rules registry
855// ---------------------------------------------------------------------------
856
857static ACTIVE_PERMISSION_RULES: RwLock<Vec<PermissionRule>> = RwLock::new(Vec::new());
858
859/// Replace the process-wide rules list. Called from `main` /
860/// daemon bootstrap with the loaded `[[permissions.rules]]`
861/// entries from `config.toml`. Tests call this to seed scenarios.
862pub fn set_active_permission_rules(rules: Vec<PermissionRule>) {
863 if let Ok(mut w) = ACTIVE_PERMISSION_RULES.write() {
864 *w = rules;
865 }
866}
867
868/// Snapshot of the current rules list. Cheap clone — the rules vec
869/// is small and the API contract is per-evaluate, not held across
870/// suspend points.
871#[must_use]
872pub fn active_permission_rules() -> Vec<PermissionRule> {
873 ACTIVE_PERMISSION_RULES
874 .read()
875 .map(|g| g.clone())
876 .unwrap_or_default()
877}
878
879/// Test-only: clear the registry. Mirrors the K3 reset helpers.
880#[doc(hidden)]
881pub fn clear_active_permission_rules_for_test() {
882 set_active_permission_rules(Vec::new());
883}
884
885/// #971 (2026-05-20) — single canonical builder for governance /
886/// permission-rule refusal messages.
887///
888/// **Why this exists.** Pre-#971 the same shape
889/// `format!("{verb} denied by {gate}: {reason}")` was inlined at
890/// ~20 sites across `mcp/tools/*`, `handlers/*`, and `cli/commands/*`.
891/// Each call site allocated through the `format!` macro's
892/// formatter-type-erasure path, and the message shape itself drifted
893/// in subtle ways (some sites missed the action verb, some used
894/// `"denied by governance:"`, others `"denied by permission rule:"`).
895///
896/// Centralising the construction here:
897///
898/// 1. **Eliminates the formatter machinery** — one direct
899/// `String::with_capacity` + four `push_str` calls per refusal,
900/// no monomorphised `Arguments` indirection.
901/// 2. **Fixes the shape in one place** — when #963 (typed
902/// `GovernanceRefusal`) lands, every caller flips at the helper
903/// boundary, not at 20 inline sites.
904/// 3. **Documents the wire contract** — the message format is part
905/// of the public refusal contract (clients grep for "denied by
906/// governance" / "denied by permission rule" to detect refusals);
907/// the helper's docs anchor that contract.
908///
909/// The wire shape is preserved byte-identical to the pre-#971
910/// `format!()` output so existing test expectations + client
911/// substring-matches remain valid.
912#[must_use]
913pub fn deny_message(action: &str, gate: DenyGate, reason: &str) -> String {
914 let gate_str = gate.as_str();
915 let mut out = String::with_capacity(action.len() + 12 + gate_str.len() + 2 + reason.len());
916 out.push_str(action);
917 out.push_str(" denied by ");
918 out.push_str(gate_str);
919 out.push_str(": ");
920 out.push_str(reason);
921 out
922}
923
924/// Which gate produced a refusal — controls the `"denied by X"`
925/// substring in the message built by [`deny_message`]. Closed set so
926/// the wire contract cannot drift via free-form string literals.
927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
928pub enum DenyGate {
929 /// K9 / namespace-standard rule refusal.
930 PermissionRule,
931 /// Substrate governance-level refusal (e.g. `enforce_governance`
932 /// returning `GovernanceDecision::Deny`).
933 Governance,
934}
935
936impl DenyGate {
937 #[must_use]
938 pub fn as_str(self) -> &'static str {
939 match self {
940 DenyGate::PermissionRule => "permission rule",
941 DenyGate::Governance => crate::models::field_names::GOVERNANCE,
942 }
943 }
944}
945
946// ---------------------------------------------------------------------------
947// Tests — unit-level coverage for the matcher + combiner.
948// The full pipeline is exercised by tests/k9_permission_pipeline.rs.
949// ---------------------------------------------------------------------------
950
951#[cfg(test)]
952mod deny_message_tests {
953 use super::*;
954
955 #[test]
956 fn governance_shape_byte_identical_to_pre_971_format() {
957 // #971 contract: the wire shape MUST match the pre-helper
958 // `format!("{verb} denied by governance: {reason}")` output so
959 // existing client substring-matches + test expectations remain
960 // valid.
961 let got = deny_message("store", DenyGate::Governance, "policy XYZ denies write");
962 assert_eq!(got, "store denied by governance: policy XYZ denies write");
963 }
964
965 #[test]
966 fn permission_rule_shape_byte_identical_to_pre_971_format() {
967 let got = deny_message("delete", DenyGate::PermissionRule, "rule R1 deny");
968 assert_eq!(got, "delete denied by permission rule: rule R1 deny");
969 }
970
971 #[test]
972 fn empty_reason_does_not_panic() {
973 let got = deny_message("archive", DenyGate::Governance, "");
974 assert_eq!(got, "archive denied by governance: ");
975 }
976
977 #[test]
978 fn long_action_verb_with_underscores() {
979 // entity_register / kg_invalidate / etc. — multi-word verbs.
980 let got = deny_message("kg_invalidate", DenyGate::PermissionRule, "K9");
981 assert_eq!(got, "kg_invalidate denied by permission rule: K9");
982 }
983
984 #[test]
985 fn gate_as_str_round_trips() {
986 assert_eq!(DenyGate::PermissionRule.as_str(), "permission rule");
987 assert_eq!(DenyGate::Governance.as_str(), "governance");
988 }
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994
995 fn ctx(op: Op, ns: &str, agent: &str) -> PermissionContext {
996 PermissionContext {
997 op,
998 namespace: ns.to_string(),
999 agent_id: agent.to_string(),
1000 payload: serde_json::Value::Null,
1001 }
1002 }
1003
1004 fn rule(ns_pat: &str, op: &str, agent_pat: &str, dec: RuleDecision) -> PermissionRule {
1005 PermissionRule {
1006 namespace_pattern: ns_pat.to_string(),
1007 op: op.to_string(),
1008 agent_pattern: agent_pat.to_string(),
1009 decision: dec,
1010 reason: Some(format!("test:{ns_pat}/{op}/{agent_pat}")),
1011 }
1012 }
1013
1014 #[test]
1015 fn glob_exact_match() {
1016 assert!(glob_matches("foo", "foo"));
1017 assert!(!glob_matches("foo", "bar"));
1018 assert!(glob_matches("", ""));
1019 }
1020
1021 #[test]
1022 fn glob_single_star_within_segment() {
1023 assert!(glob_matches("ai:*", "ai:claude"));
1024 assert!(glob_matches("ai:*", "ai:claude-1"));
1025 // single-star may not eat '/' — namespace segments preserved.
1026 assert!(!glob_matches("foo/*", "foo/bar/baz"));
1027 }
1028
1029 #[test]
1030 fn glob_double_star_across_segments() {
1031 assert!(glob_matches("foo/**", "foo/bar/baz"));
1032 assert!(glob_matches("**", "anything/at/all"));
1033 }
1034
1035 #[test]
1036 fn rule_deny_short_circuits_pipeline() {
1037 let r = rule("secrets/*", "memory_store", "ai:*", RuleDecision::Deny);
1038 let d = Permissions::evaluate_with(
1039 &ctx(Op::MemoryStore, "secrets/api", "ai:claude"),
1040 &[],
1041 &[r],
1042 PermissionsMode::Enforce,
1043 );
1044 assert!(matches!(d, Decision::Deny(_)));
1045 }
1046
1047 #[test]
1048 fn rule_allow_returns_allow() {
1049 let r = rule("public/*", "memory_store", "*", RuleDecision::Allow);
1050 let d = Permissions::evaluate_with(
1051 &ctx(Op::MemoryStore, "public/blog", "human:alice"),
1052 &[],
1053 &[r],
1054 PermissionsMode::Enforce,
1055 );
1056 assert_eq!(d, Decision::Allow);
1057 }
1058
1059 #[test]
1060 fn off_mode_short_circuits_to_allow() {
1061 let r = rule("**", "memory_store", "*", RuleDecision::Deny);
1062 let d = Permissions::evaluate_with(
1063 &ctx(Op::MemoryStore, "secrets/api", "ai:claude"),
1064 &[],
1065 &[r],
1066 PermissionsMode::Off,
1067 );
1068 assert_eq!(d, Decision::Allow);
1069 }
1070
1071 #[test]
1072 fn no_match_defaults_to_allow() {
1073 let r = rule("secrets/*", "memory_store", "*", RuleDecision::Deny);
1074 let d = Permissions::evaluate_with(
1075 &ctx(Op::MemoryStore, "public/blog", "human:alice"),
1076 &[],
1077 &[r],
1078 PermissionsMode::Enforce,
1079 );
1080 assert_eq!(d, Decision::Allow);
1081 }
1082
1083 #[test]
1084 fn op_as_str_round_trips() {
1085 for op in [
1086 Op::MemoryStore,
1087 Op::MemoryLink,
1088 Op::MemoryDelete,
1089 Op::MemoryArchive,
1090 Op::MemoryConsolidate,
1091 Op::MemoryReplay,
1092 ] {
1093 assert_eq!(Op::from_str(op.as_str()), Some(op));
1094 }
1095 }
1096
1097 #[test]
1098 fn specificity_orders_long_prefix_first() {
1099 assert!(pattern_specificity("secrets/api/v1") > pattern_specificity("secrets/*"));
1100 assert!(pattern_specificity("secrets/*") > pattern_specificity("**"));
1101 }
1102
1103 // ---- F8 secure-by-default --------------------------------------------
1104
1105 #[test]
1106 fn default_v07_secure_mode_is_enforce() {
1107 assert_eq!(default_v07_secure_mode(), PermissionsMode::Enforce);
1108 }
1109
1110 #[test]
1111 fn resolve_v07_default_mode_unconfigured_yields_enforce_with_warning() {
1112 let (mode, warning) = resolve_v07_default_mode(None);
1113 assert_eq!(mode, PermissionsMode::Enforce);
1114 let w = warning.expect("expected migration warning when mode is unconfigured");
1115 assert!(w.contains("v0.7.0 default changed to enforce"), "got: {w}");
1116 assert!(w.contains("permissions.mode=advisory"), "got: {w}");
1117 }
1118
1119 #[test]
1120 fn resolve_v07_default_mode_configured_passes_through() {
1121 let (mode, warning) = resolve_v07_default_mode(Some(PermissionsMode::Advisory));
1122 assert_eq!(mode, PermissionsMode::Advisory);
1123 assert!(warning.is_none(), "no warning when operator opted in");
1124
1125 let (mode, warning) = resolve_v07_default_mode(Some(PermissionsMode::Off));
1126 assert_eq!(mode, PermissionsMode::Off);
1127 assert!(warning.is_none());
1128
1129 let (mode, warning) = resolve_v07_default_mode(Some(PermissionsMode::Enforce));
1130 assert_eq!(mode, PermissionsMode::Enforce);
1131 assert!(warning.is_none());
1132 }
1133
1134 #[test]
1135 fn startup_banner_line_includes_mode_str() {
1136 assert_eq!(
1137 startup_banner_line(PermissionsMode::Enforce),
1138 "permissions: enforce"
1139 );
1140 assert_eq!(
1141 startup_banner_line(PermissionsMode::Advisory),
1142 "permissions: advisory"
1143 );
1144 assert_eq!(
1145 startup_banner_line(PermissionsMode::Off),
1146 "permissions: off"
1147 );
1148 }
1149
1150 #[test]
1151 fn op_from_str_unknown_returns_none() {
1152 assert!(Op::from_str("not_a_real_op").is_none());
1153 assert!(Op::from_str("").is_none());
1154 }
1155
1156 #[test]
1157 fn glob_matches_empty_pattern_only_matches_empty_value() {
1158 assert!(glob_matches("", ""));
1159 assert!(!glob_matches("", "anything"));
1160 }
1161
1162 #[test]
1163 fn glob_matches_pattern_longer_than_value_fails() {
1164 assert!(!glob_matches("longpattern", "x"));
1165 }
1166
1167 #[test]
1168 fn pattern_specificity_increasing_order() {
1169 let s1 = pattern_specificity("**");
1170 let s2 = pattern_specificity("foo/*");
1171 let s3 = pattern_specificity("foo/bar");
1172 let s4 = pattern_specificity("foo/bar/baz/qux/quux");
1173 assert!(s2 > s1);
1174 assert!(s3 > s2);
1175 assert!(s4 > s3);
1176 }
1177
1178 #[test]
1179 fn hook_deny_propagates_through_pipeline() {
1180 let hook_decisions = vec![HookDecision::Deny {
1181 reason: "hook said no".into(),
1182 code: 403,
1183 }];
1184 let d = Permissions::evaluate_with(
1185 &ctx(Op::MemoryStore, "public/blog", "ai:claude"),
1186 &hook_decisions,
1187 &[],
1188 PermissionsMode::Enforce,
1189 );
1190 match d {
1191 Decision::Deny(reason) => assert!(reason.contains("hook said no")),
1192 other => panic!("expected Deny, got {other:?}"),
1193 }
1194 }
1195
1196 #[test]
1197 fn hook_modify_composes_into_decision() {
1198 let delta = MemoryDelta {
1199 tags: Some(vec!["hooked".into()]),
1200 ..Default::default()
1201 };
1202 let hook_decisions = vec![HookDecision::Modify(
1203 crate::hooks::decision::ModifyPayload { delta },
1204 )];
1205 let d = Permissions::evaluate_with(
1206 &ctx(Op::MemoryStore, "public/blog", "ai:claude"),
1207 &hook_decisions,
1208 &[],
1209 PermissionsMode::Enforce,
1210 );
1211 match d {
1212 Decision::Modify(delta) => {
1213 assert_eq!(delta.tags.as_deref(), Some(&["hooked".to_string()][..]));
1214 }
1215 other => panic!("expected Modify, got {other:?}"),
1216 }
1217 }
1218
1219 #[test]
1220 fn hook_modify_namespace_escalation_rejected_when_destination_denied() {
1221 // Hook proposes to redirect into "secrets/api" — a denied ns.
1222 let delta = MemoryDelta {
1223 namespace: Some("secrets/api".into()),
1224 ..Default::default()
1225 };
1226 let hook_decisions = vec![HookDecision::Modify(
1227 crate::hooks::decision::ModifyPayload { delta },
1228 )];
1229 let r = rule("secrets/**", "memory_store", "*", RuleDecision::Deny);
1230 let d = Permissions::evaluate_with(
1231 &ctx(Op::MemoryStore, "public/blog", "ai:claude"),
1232 &hook_decisions,
1233 &[r],
1234 PermissionsMode::Enforce,
1235 );
1236 match d {
1237 Decision::Deny(reason) => {
1238 assert!(
1239 reason.contains("namespace escalation rejected"),
1240 "expected escalation rejection: {reason}"
1241 );
1242 }
1243 other => panic!("expected Deny, got {other:?}"),
1244 }
1245 }
1246
1247 #[test]
1248 fn hook_modify_namespace_change_to_allowed_passes() {
1249 let delta = MemoryDelta {
1250 namespace: Some("public/wiki".into()),
1251 ..Default::default()
1252 };
1253 let hook_decisions = vec![HookDecision::Modify(
1254 crate::hooks::decision::ModifyPayload { delta },
1255 )];
1256 let r = rule("public/**", "memory_store", "*", RuleDecision::Allow);
1257 let d = Permissions::evaluate_with(
1258 &ctx(Op::MemoryStore, "public/blog", "ai:claude"),
1259 &hook_decisions,
1260 &[r],
1261 PermissionsMode::Enforce,
1262 );
1263 match d {
1264 Decision::Modify(_) => {}
1265 other => panic!("expected Modify, got {other:?}"),
1266 }
1267 }
1268
1269 #[test]
1270 fn rule_allow_short_circuits_ask() {
1271 let allow = rule("public/*", "memory_store", "*", RuleDecision::Allow);
1272 let ask = rule("public/*", "memory_store", "*", RuleDecision::Ask);
1273 let d = Permissions::evaluate_with(
1274 &ctx(Op::MemoryStore, "public/blog", "ai:claude"),
1275 &[],
1276 &[allow, ask],
1277 PermissionsMode::Enforce,
1278 );
1279 assert_eq!(d, Decision::Allow);
1280 }
1281
1282 #[test]
1283 fn ask_under_enforce_mode_escalates_to_deny() {
1284 let r = rule("private/*", "memory_store", "*", RuleDecision::Ask);
1285 let d = Permissions::evaluate_with(
1286 &ctx(Op::MemoryStore, "private/journal", "ai:claude"),
1287 &[],
1288 &[r],
1289 PermissionsMode::Enforce,
1290 );
1291 match d {
1292 Decision::Deny(reason) => assert!(reason.contains("permission ask escalated")),
1293 other => panic!("expected Deny, got {other:?}"),
1294 }
1295 }
1296
1297 #[test]
1298 fn ask_under_advisory_mode_returns_ask() {
1299 let r = rule("private/*", "memory_store", "*", RuleDecision::Ask);
1300 let d = Permissions::evaluate_with(
1301 &ctx(Op::MemoryStore, "private/journal", "ai:claude"),
1302 &[],
1303 &[r],
1304 PermissionsMode::Advisory,
1305 );
1306 match d {
1307 Decision::Ask(_) => {}
1308 other => panic!("expected Ask, got {other:?}"),
1309 }
1310 }
1311
1312 #[test]
1313 fn hook_askuser_under_advisory_mode_surfaces_ask() {
1314 let hook_decisions = vec![HookDecision::AskUser {
1315 prompt: "Please confirm".into(),
1316 options: vec!["yes".into(), "no".into()],
1317 default: Some("no".into()),
1318 }];
1319 let d = Permissions::evaluate_with(
1320 &ctx(Op::MemoryStore, "public/blog", "ai:claude"),
1321 &hook_decisions,
1322 &[],
1323 PermissionsMode::Advisory,
1324 );
1325 match d {
1326 Decision::Ask(prompt) => assert!(prompt.contains("Please confirm")),
1327 other => panic!("expected Ask, got {other:?}"),
1328 }
1329 }
1330
1331 #[test]
1332 fn hook_allow_alone_short_circuits_to_allow() {
1333 let hook_decisions = vec![HookDecision::Allow];
1334 let d = Permissions::evaluate_with(
1335 &ctx(Op::MemoryStore, "public/blog", "ai:claude"),
1336 &hook_decisions,
1337 &[],
1338 PermissionsMode::Enforce,
1339 );
1340 assert_eq!(d, Decision::Allow);
1341 }
1342
1343 #[test]
1344 fn evaluate_public_facade_uses_active_rules() {
1345 clear_active_permission_rules_for_test();
1346 let d = Permissions::evaluate(&ctx(Op::MemoryStore, "anywhere", "ai:claude"), &[]);
1347 // No active rules and no hook decisions → mode default Allow.
1348 assert_eq!(d, Decision::Allow);
1349 }
1350
1351 #[test]
1352 fn set_and_active_permission_rules_round_trip() {
1353 clear_active_permission_rules_for_test();
1354 set_active_permission_rules(vec![rule(
1355 "secrets/*",
1356 "memory_store",
1357 "*",
1358 RuleDecision::Deny,
1359 )]);
1360 let rules = active_permission_rules();
1361 assert_eq!(rules.len(), 1);
1362 clear_active_permission_rules_for_test();
1363 assert!(active_permission_rules().is_empty());
1364 }
1365
1366 #[test]
1367 fn permissions_mode_serde_round_trip() {
1368 let modes = [
1369 PermissionsMode::Off,
1370 PermissionsMode::Advisory,
1371 PermissionsMode::Enforce,
1372 ];
1373 for m in modes {
1374 let json = serde_json::to_string(&m).unwrap();
1375 let back: PermissionsMode = serde_json::from_str(&json).unwrap();
1376 assert_eq!(m, back);
1377 }
1378 }
1379
1380 #[test]
1381 fn decision_partial_eq_distinct_variants() {
1382 let allow = Decision::Allow;
1383 let deny = Decision::Deny("x".into());
1384 let ask = Decision::Ask("?".into());
1385 let modify = Decision::Modify(MemoryDelta::default());
1386 assert_ne!(allow, deny);
1387 assert_ne!(allow, ask);
1388 assert_ne!(allow, modify);
1389 assert_ne!(deny, ask);
1390 }
1391
1392 #[test]
1393 fn glob_double_star_matches_zero_segments() {
1394 // "foo/**" should match "foo" itself per the standard glob extension.
1395 assert!(glob_matches("foo/**", "foo/anything"));
1396 }
1397
1398 #[test]
1399 fn evaluate_no_rules_no_hooks_returns_allow() {
1400 let d = Permissions::evaluate_with(
1401 &ctx(Op::MemoryStore, "anywhere", "ai:claude"),
1402 &[],
1403 &[],
1404 PermissionsMode::Enforce,
1405 );
1406 assert_eq!(d, Decision::Allow);
1407 }
1408
1409 // ------------------------------------------------------------------
1410 // Coverage-uplift block (2026-05-19): exercise the same-variant
1411 // arms of `impl PartialEq for Decision` (lines 176-185) and the
1412 // `default_agent_pattern` helper (line 252-254).
1413 // ------------------------------------------------------------------
1414
1415 #[test]
1416 fn decision_partial_eq_same_allow_arms_match() {
1417 // The (Allow, Allow) arm at line 176.
1418 let a = Decision::Allow;
1419 let b = Decision::Allow;
1420 assert_eq!(a, b);
1421 }
1422
1423 #[test]
1424 fn decision_partial_eq_same_deny_arms_match_by_reason() {
1425 // The (Deny, Deny) arm at line 177 compares the inner reason
1426 // string.
1427 let same = Decision::Deny("nope".into());
1428 let same_again = Decision::Deny("nope".into());
1429 assert_eq!(same, same_again);
1430 let different_reason = Decision::Deny("other".into());
1431 assert_ne!(same, different_reason);
1432 }
1433
1434 #[test]
1435 fn decision_partial_eq_same_modify_arms_compare_canonical_json() {
1436 // #969 — derived `PartialEq` (via `MemoryDelta: PartialEq`)
1437 // produces the same answers the previous hand-rolled
1438 // canonical-JSON comparison did. Test name preserved for
1439 // grep-history continuity; previously the rationale was
1440 // "MemoryDelta metadata Value is not Eq" — see #969 audit
1441 // doc for the corrected rationale.
1442 let a = Decision::Modify(MemoryDelta::default());
1443 let b = Decision::Modify(MemoryDelta::default());
1444 assert_eq!(a, b);
1445 let mut delta_with_meta = MemoryDelta::default();
1446 delta_with_meta.metadata = Some(serde_json::json!({"k":"v"}));
1447 let c = Decision::Modify(delta_with_meta);
1448 assert_ne!(a, c);
1449 }
1450
1451 #[test]
1452 fn decision_partial_eq_same_ask_arms_match_by_prompt() {
1453 // The (Ask, Ask) arm at line 184.
1454 let a = Decision::Ask("really?".into());
1455 let b = Decision::Ask("really?".into());
1456 assert_eq!(a, b);
1457 let c = Decision::Ask("hmm?".into());
1458 assert_ne!(a, c);
1459 }
1460
1461 #[test]
1462 fn permission_rule_default_agent_pattern_is_wildcard() {
1463 // Drives lines 252-254 (`default_agent_pattern`) via serde's
1464 // default-fill on the field. JSON without `agent_pattern`
1465 // should deserialise to "*".
1466 let json = r#"{
1467 "namespace_pattern": "*",
1468 "op": "memory_store",
1469 "decision": "allow"
1470 }"#;
1471 let rule: PermissionRule = serde_json::from_str(json).expect("deserialise");
1472 assert_eq!(rule.agent_pattern, "*");
1473 // Direct call also documents the contract.
1474 assert_eq!(default_agent_pattern(), "*");
1475 }
1476}