dirge-agent 0.12.6

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
//! The permission authorization engine — a two-stage Policy Decision
//! Point (PDP).
//!
//! A tool normalizes its intent into one [`AccessRequest`] (possibly
//! many [`Resource`]s) and calls [`Engine::authorize`], which returns
//! a [`Decision`] carrying the effect AND a full trace of which policy
//! decided and why. This replaces the old `enforce` + `check` /
//! `check_path` split (which had drifted into seven divergences) and
//! the per-tool ad-hoc gates.
//!
//! Evaluation, per resource:
//! 1. **Stage A (deciders, first-claim-wins):** the registered
//!    deciders are consulted in precedence order; the first to claim
//!    the resource sets its base [`Effect`]. Deciders may loosen, so
//!    Accept-mode coercion belongs here in the audited base layer.
//! 2. **Stage B (modifiers, monotone):** each applicable modifier may
//!    only tighten via [`Refined`]; the fold is [`Effect::meet`], so
//!    modifier order is irrelevant. Restrictive demotion and the loop
//!    guard live here.
//!
//! Per request: the resource effects fold via [`Effect::meet`]
//! (most-restrictive-wins), so a bash command's segments + write
//! targets yield ONE atomic decision and at most one prompt.
//!
//! All mutable state lives in [`PolicyCtx`]; `authorize` only reads it,
//! and [`Engine::commit`] is the sole writer (called after the user's
//! decision resolves). That single-writer rule is why the loop guard
//! has no count-before-vs-after ordering bug.

// Phase 1 builds the engine in isolation with no call sites yet; the
// chokepoint swap (Phase 2) consumes every item below. Remove this
// allow once `authorize`/`Engine`/the policy types are wired in.
#![allow(dead_code)]

mod build;
mod classify;
pub mod policies;
pub mod policy;
pub mod types;

pub use build::{classify_path, tool_operation};

// Re-export the classification helpers used across the permission
// module (`engine::pattern_for_tool`, `engine::is_path_tool_name`).
pub use classify::{is_path_tool_name, pattern_for_tool};

use policy::{Decider, Modifier, PolicyCtx};
use types::{AccessRequest, Decision, Effect, Operation, Resource, TraceEntry};

/// Whether Accept mode may coerce a base `Ask` to `Allow` for this
/// (op, resource): low-risk operations (not shell/mcp/network/agent)
/// on a non-external resource. "trust the agent in cwd" doesn't
/// generalize to external code execution or out-of-tree paths.
fn accept_eligible(op: Operation, resource: &Resource) -> bool {
    let high_risk = matches!(
        op,
        Operation::Execute
            | Operation::Mcp
            | Operation::Network
            | Operation::Agent
            | Operation::Plugin
    );
    let external_path = matches!(
        resource,
        Resource::Path {
            in_cwd: false,
            dev_null: false,
            ..
        }
    );
    !high_risk && !external_path
}

/// The registered-policy authorization engine. Holds the ordered
/// decider/modifier sets and the mutable [`PolicyCtx`].
pub struct Engine {
    deciders: Vec<Box<dyn Decider>>,
    modifiers: Vec<Box<dyn Modifier>>,
    ctx: PolicyCtx,
    /// Count of configured `Deny` rules (configured + external_directory),
    /// for the Yolo-mode "your deny rules are inert" startup warning.
    /// Set by `from_config`.
    pub(super) deny_rules: usize,
}

impl Engine {
    /// Construct an engine from an explicit, ordered policy set. The
    /// decider order IS the documented precedence. (The standard
    /// dirge policy set is assembled in a later phase; this keeps the
    /// core engine free of policy specifics and unit-testable with
    /// stub policies.)
    pub fn new(
        deciders: Vec<Box<dyn Decider>>,
        modifiers: Vec<Box<dyn Modifier>>,
        ctx: PolicyCtx,
    ) -> Self {
        Engine {
            deciders,
            modifiers,
            ctx,
            deny_rules: 0,
        }
    }

    /// Number of configured `Deny` rules — used to warn when Yolo mode
    /// renders them inert.
    pub fn deny_rule_count(&self) -> usize {
        self.deny_rules
    }

    /// Read-only access to the mutable context (for the UI's
    /// allowlist listing, `/why`, etc.).
    pub fn ctx(&self) -> &PolicyCtx {
        &self.ctx
    }

    pub fn ctx_mut(&mut self) -> &mut PolicyCtx {
        &mut self.ctx
    }

    /// Authorize a request. Pure read over `self` + `ctx`; no state is
    /// mutated (call [`Engine::commit`] after the decision resolves).
    pub fn authorize(&self, req: &AccessRequest) -> Decision {
        let mut trace: Vec<TraceEntry> = Vec::new();
        let mut resolved_paths = Vec::new();
        // Per resource: (effect, binding-trace-entry).
        let mut per_resource: Vec<(Effect, Option<TraceEntry>)> = Vec::new();

        for (ri, claim) in req.claims.iter().enumerate() {
            let op = claim.op;
            let resource = &claim.resource;
            if let Resource::Path { resolved, .. } = resource {
                resolved_paths.push(resolved.clone());
            }

            // ---- Stage A: deciders, first claim wins ----
            let mut base = Effect::Ask; // defensive default; DefaultActionPolicy normally claims
            let mut binding: Option<TraceEntry> = None;
            for d in &self.deciders {
                if !d.applies_to(op, resource) {
                    trace.push(TraceEntry {
                        policy: d.id(),
                        resource: ri,
                        effect: None,
                        why: "not applicable".to_string(),
                        applied: false,
                    });
                    continue;
                }
                match d.decide(req, op, resource, &self.ctx) {
                    Some(v) => {
                        let entry = TraceEntry {
                            policy: d.id(),
                            resource: ri,
                            effect: Some(v.effect),
                            why: v.why,
                            applied: true,
                        };
                        base = v.effect;
                        trace.push(entry.clone());
                        binding = Some(entry);
                        break; // first decisive claim wins
                    }
                    None => trace.push(TraceEntry {
                        policy: d.id(),
                        resource: ri,
                        effect: None,
                        why: "passed".to_string(),
                        applied: true,
                    }),
                }
            }

            // ---- Mode coercion (Accept): the one place a mode
            // LOOSENS. Accept turns a base `Ask` into `Allow` for
            // low-risk, in-tree operations, regardless of whether the
            // Ask came from a rule or the default. It lives here in the
            // base layer (not as a tighten-only Stage-B modifier) and
            // applies AFTER Stage A so it can relax a rule's Ask —
            // matching the legacy Accept semantics. Deny is never
            // touched (only `Ask` is coerced); high-risk ops
            // (Execute/Mcp/Network/Agent) and external paths are
            // excluded.
            if req.mode == crate::permission::SecurityMode::Accept
                && base == Effect::Ask
                && accept_eligible(op, resource)
            {
                base = Effect::Allow;
                let entry = TraceEntry {
                    policy: "accept-mode",
                    resource: ri,
                    effect: Some(Effect::Allow),
                    why: "accept mode coerced Ask→Allow".to_string(),
                    applied: true,
                };
                trace.push(entry.clone());
                binding = Some(entry);
            }

            // ---- Stage B: modifiers, monotone tighten ----
            let mut eff = base;
            for m in &self.modifiers {
                if !m.applies_to(op, resource) {
                    trace.push(TraceEntry {
                        policy: m.id(),
                        resource: ri,
                        effect: None,
                        why: "not applicable".to_string(),
                        applied: false,
                    });
                    continue;
                }
                let refined = m.refine(req, op, resource, eff, &self.ctx);
                if let Some(by) = refined.by {
                    let entry = TraceEntry {
                        policy: by,
                        resource: ri,
                        effect: Some(refined.effect()),
                        why: refined.why.clone(),
                        applied: true,
                    };
                    trace.push(entry.clone());
                    eff = refined.effect();
                    binding = Some(entry); // the tightening modifier now owns the outcome
                } else {
                    trace.push(TraceEntry {
                        policy: m.id(),
                        resource: ri,
                        effect: None,
                        why: "no change".to_string(),
                        applied: true,
                    });
                }
            }

            per_resource.push((eff, binding));
        }

        // ---- Per-request fold: most restrictive across resources ----
        let final_effect = per_resource
            .iter()
            .map(|(e, _)| *e)
            .fold(Effect::Allow, Effect::meet);

        // The deciding entry is the binding of the (first) resource
        // whose effect equals the final (most-restrictive) effect.
        let deciding = per_resource
            .into_iter()
            .find(|(e, _)| *e == final_effect)
            .and_then(|(_, b)| b);

        Decision {
            effect: final_effect,
            deciding,
            trace,
            resolved_paths,
        }
    }

    /// Record the request after its decision resolves. The sole state
    /// mutation point: bumps the loop-guard counter for prompted
    /// requests so a genuine retry loop can eventually be hard-denied.
    /// Allowed/denied requests don't accumulate retry pressure.
    pub fn commit(&mut self, req: &AccessRequest, decision: &Decision) {
        if decision.effect == Effect::Ask {
            // Bump once per distinct (op, resource) the request carries —
            // NOT once per claim. A bash request can hold duplicate claims
            // (e.g. the same path as both a mutation target and a redirect
            // target); double-counting them would let the loop guard
            // hard-deny before the real retry threshold.
            let mut seen = std::collections::HashSet::new();
            for claim in &req.claims {
                let key = claim.resource.match_key();
                if seen.insert((claim.op, key)) {
                    self.ctx.repeat.record(claim.op, key);
                }
            }
        }
    }

    /// Clear the loop-guard retry pressure for `req` — call this once the
    /// user has APPROVED its prompt. An approved-but-repeated action is a
    /// productive loop (the user keeps saying yes), so it must not trip
    /// the hard-deny; only prompts the user keeps DENYING accumulate. The
    /// dedup mirrors `commit` so the reset matches what was recorded.
    pub fn note_allowed(&mut self, req: &AccessRequest) {
        let mut seen = std::collections::HashSet::new();
        for claim in &req.claims {
            let key = claim.resource.match_key();
            if seen.insert((claim.op, key)) {
                self.ctx.repeat.reset(claim.op, key);
            }
        }
    }

    /// Register a session-scoped "allow always" grant (from the UI's
    /// AllowAlways reply).
    pub fn allow_always(&mut self, op: Operation, original: &str) {
        let pattern = if op == Operation::Execute || op == Operation::Mcp {
            crate::permission::pattern::Pattern::new_command(original)
        } else {
            crate::permission::pattern::Pattern::new(original)
        };
        self.ctx.allowlist.add(op, original, pattern);
    }
}

#[cfg(test)]
mod tests {
    use super::policy::*;
    use super::types::*;
    use super::*;
    use crate::permission::SecurityMode;
    use std::path::PathBuf;

    // ---- stub policies to exercise the algorithm in isolation ----

    struct AlwaysDecide(&'static str, Effect, bool /* applies */);
    impl Decider for AlwaysDecide {
        fn id(&self) -> &'static str {
            self.0
        }
        fn applies_to(&self, _: Operation, _: &Resource) -> bool {
            self.2
        }
        fn decide(
            &self,
            _: &AccessRequest,
            _: Operation,
            _: &Resource,
            _: &PolicyCtx,
        ) -> Option<Verdict> {
            Some(Verdict::new(self.1, "stub"))
        }
    }
    struct TightenTo(&'static str, Effect);
    impl Modifier for TightenTo {
        fn id(&self) -> &'static str {
            self.0
        }
        fn applies_to(&self, _: Operation, _: &Resource) -> bool {
            true
        }
        fn refine(
            &self,
            _: &AccessRequest,
            _: Operation,
            _: &Resource,
            cur: Effect,
            _: &PolicyCtx,
        ) -> Refined {
            Refined::tighten(cur, self.1, self.0, "stub tighten")
        }
    }

    fn req(resources: Vec<Resource>) -> AccessRequest {
        AccessRequest {
            tool: "test".to_string(),
            claims: resources
                .into_iter()
                .map(|r| Claim::new(Operation::Execute, r))
                .collect(),
            mode: SecurityMode::Standard,
            display_input: "test".to_string(),
        }
    }
    fn cmd(s: &str) -> Resource {
        Resource::Command {
            raw: s.to_string(),
            head: s.split_whitespace().next().unwrap_or("").to_string(),
        }
    }
    fn path(p: &str) -> Resource {
        Resource::Path {
            raw: p.to_string(),
            resolved: PathBuf::from(p),
            in_cwd: false,
            dev_null: false,
        }
    }

    #[test]
    fn first_decider_claim_wins() {
        let e = Engine::new(
            vec![
                Box::new(AlwaysDecide("a", Effect::Allow, true)),
                Box::new(AlwaysDecide("b", Effect::Deny, true)),
            ],
            vec![],
            PolicyCtx::default(),
        );
        let d = e.authorize(&req(vec![cmd("x")]));
        assert_eq!(d.effect, Effect::Allow);
        assert_eq!(d.deciding.unwrap().policy, "a");
    }

    #[test]
    fn non_applicable_decider_is_skipped() {
        let e = Engine::new(
            vec![
                Box::new(AlwaysDecide("skipme", Effect::Allow, false)),
                Box::new(AlwaysDecide("real", Effect::Deny, true)),
            ],
            vec![],
            PolicyCtx::default(),
        );
        let d = e.authorize(&req(vec![cmd("x")]));
        assert_eq!(d.effect, Effect::Deny);
        assert_eq!(d.deciding.unwrap().policy, "real");
        // skipped decider recorded as applied:false
        assert!(d.trace.iter().any(|t| t.policy == "skipme" && !t.applied));
    }

    #[test]
    fn modifier_tightens_but_cannot_loosen() {
        // base Allow, modifier tries to tighten to Ask -> Ask
        let e = Engine::new(
            vec![Box::new(AlwaysDecide("base", Effect::Allow, true))],
            vec![Box::new(TightenTo("tighten", Effect::Ask))],
            PolicyCtx::default(),
        );
        let d = e.authorize(&req(vec![cmd("x")]));
        assert_eq!(d.effect, Effect::Ask);
        assert_eq!(d.deciding.unwrap().policy, "tighten");

        // base Deny, modifier "tightens" to Ask -> stays Deny (loosening blocked)
        let e = Engine::new(
            vec![Box::new(AlwaysDecide("base", Effect::Deny, true))],
            vec![Box::new(TightenTo("tighten", Effect::Ask))],
            PolicyCtx::default(),
        );
        let d = e.authorize(&req(vec![cmd("x")]));
        assert_eq!(d.effect, Effect::Deny);
        assert_eq!(d.deciding.unwrap().policy, "base");
    }

    #[test]
    fn multi_resource_folds_most_restrictive() {
        // one Allow resource + one Deny resource -> Deny overall
        struct PerResource;
        impl Decider for PerResource {
            fn id(&self) -> &'static str {
                "perres"
            }
            fn applies_to(&self, _: Operation, _: &Resource) -> bool {
                true
            }
            fn decide(
                &self,
                _: &AccessRequest,
                _: Operation,
                r: &Resource,
                _: &PolicyCtx,
            ) -> Option<Verdict> {
                let eff = if r.match_key().contains("bad") {
                    Effect::Deny
                } else {
                    Effect::Allow
                };
                Some(Verdict::new(eff, "perres"))
            }
        }
        let e = Engine::new(vec![Box::new(PerResource)], vec![], PolicyCtx::default());
        let d = e.authorize(&req(vec![cmd("good"), cmd("bad"), path("/x")]));
        assert_eq!(d.effect, Effect::Deny);
        assert_eq!(d.resolved_paths, vec![PathBuf::from("/x")]);
    }

    #[test]
    fn commit_only_counts_prompted_requests() {
        let mut e = Engine::new(
            vec![Box::new(AlwaysDecide("ask", Effect::Ask, true))],
            vec![],
            PolicyCtx::default(),
        );
        let r = req(vec![cmd("loopy")]);
        assert_eq!(e.ctx().repeat.prior(Operation::Execute, "loopy"), 0);
        let d = e.authorize(&r);
        e.commit(&r, &d);
        assert_eq!(e.ctx().repeat.prior(Operation::Execute, "loopy"), 1);

        // an allowed request does not accumulate retry pressure
        let mut e2 = Engine::new(
            vec![Box::new(AlwaysDecide("allow", Effect::Allow, true))],
            vec![],
            PolicyCtx::default(),
        );
        let d2 = e2.authorize(&r);
        e2.commit(&r, &d2);
        assert_eq!(e2.ctx().repeat.prior(Operation::Execute, "loopy"), 0);
    }
}