Skip to main content

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}