ai-memory 0.7.0

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
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
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! v0.7.0 issue #863 — `ai-memory governance check-action` CLI
//! subcommand. Shell-side parity surface for the MCP tool
//! `memory_check_agent_action`. Operators can dry-run any substrate
//! agent-action rule (R001-R004 plus any operator-added rule) from
//! a terminal without driving JSON-RPC over stdio.
//!
//! ## Wire shape
//!
//! ```text
//! ai-memory governance check-action \
//!     --kind <bash|filesystem_write|network_request|process_spawn|custom> \
//!     [--command <str>]   (bash)
//!     [--path <str>]      (filesystem_write)
//!     [--host <str>]      (network_request)
//!     [--binary <str>]    (process_spawn)
//!     [--custom-kind <str>] (custom)
//!     [--agent-id <str>]
//!     [--json]
//! ```
//!
//! Defaults `agent_id` to the same `anonymous:mcp` sentinel the MCP
//! handler uses so the audit trail is symmetric across surfaces.
//!
//! ## Output
//!
//! - Human (default): one line per outcome
//!   `Allow` / `Refuse: <rule_id> — <reason>` / `Warn: <rule_id> — <reason>`.
//! - `--json`: the wire envelope from
//!   [`crate::mcp::tools::check_agent_action::run_check`] unchanged
//!   (`{"decision":{...},"kind":"...","agent_id":"..."}`).
//!
//! ## DRY contract
//!
//! Every per-kind validation and the rule-engine call route through
//! [`crate::mcp::tools::check_agent_action::build_action`] and
//! [`crate::mcp::tools::check_agent_action::run_check`]. No business
//! logic lives here — this module is a clap arg-parser plus an output
//! formatter.

use anyhow::{Context, Result};
use clap::Args;
use serde_json::Value;

use crate::cli::CliOutput;
use crate::mcp::tools::check_agent_action::{
    DEFAULT_AGENT_ID, build_action as build_agent_action, run_check,
};

/// CLI args for `ai-memory governance check-action`. The per-kind
/// fields are optional at the clap layer; the substrate shared helper
/// validates which fields are required for the supplied `--kind`.
#[derive(Args, Debug, Clone)]
pub struct CheckActionArgs {
    /// AgentAction kind — one of `bash`, `filesystem_write`,
    /// `network_request`, `process_spawn`, `custom`. Mirrors the
    /// `governance_rules.kind` enum.
    #[arg(long, value_name = "KIND")]
    pub kind: String,

    /// Shell command (required when `--kind bash`).
    #[arg(long, value_name = "COMMAND")]
    pub command: Option<String>,

    /// Filesystem path (required when `--kind filesystem_write`).
    #[arg(long, value_name = "PATH")]
    pub path: Option<String>,

    /// Host (required when `--kind network_request`).
    #[arg(long, value_name = "HOST")]
    pub host: Option<String>,

    /// Resolved binary name (required when `--kind process_spawn`).
    #[arg(long, value_name = "BINARY")]
    pub binary: Option<String>,

    /// Inner custom kind (required when `--kind custom`).
    #[arg(long = "custom-kind", value_name = "KIND")]
    pub custom_kind: Option<String>,

    /// Optional agent id stamped into the audit row. Defaults to the
    /// same `anonymous:mcp` sentinel the MCP handler uses.
    #[arg(long = "agent-id", value_name = "ID")]
    pub agent_id: Option<String>,

    /// Emit the raw JSON envelope (same shape as the MCP tool) instead
    /// of the human-readable verdict line.
    #[arg(long)]
    pub json: bool,
}

impl CheckActionArgs {
    /// Convert the CLI arg-bag into the JSON object shape the shared
    /// [`build_agent_action`] helper expects.
    fn to_arguments(&self) -> Value {
        let mut obj = serde_json::Map::new();
        obj.insert("kind".to_string(), Value::String(self.kind.clone()));
        if let Some(v) = &self.command {
            obj.insert("command".to_string(), Value::String(v.clone()));
        }
        if let Some(v) = &self.path {
            obj.insert("path".to_string(), Value::String(v.clone()));
        }
        if let Some(v) = &self.host {
            obj.insert("host".to_string(), Value::String(v.clone()));
        }
        if let Some(v) = &self.binary {
            obj.insert("binary".to_string(), Value::String(v.clone()));
        }
        if let Some(v) = &self.custom_kind {
            obj.insert(
                crate::models::field_names::CUSTOM_KIND.to_string(),
                Value::String(v.clone()),
            );
        }
        Value::Object(obj)
    }
}

/// Dispatch entry called from the daemon-runtime `GovernanceAction`
/// match arm.
///
/// # Errors
///
/// - The rules DB at `db_path` cannot be opened.
/// - The shared `build_action` rejects the supplied kind / fields
///   (e.g. `--kind filesystem_write` without `--path`).
/// - The shared `run_check` call returns an error (rules-table SQL
///   failure, audit emit failure).
/// - `--json` mode and `serde_json` cannot serialise the envelope
///   (in practice never happens with the shapes used here).
pub fn run(
    db_path: &std::path::Path,
    args: &CheckActionArgs,
    out: &mut CliOutput<'_>,
) -> Result<()> {
    let conn = rusqlite::Connection::open(db_path)
        .with_context(|| format!("governance check-action: open db at {}", db_path.display()))?;

    let arguments = args.to_arguments();
    let action = build_agent_action(&args.kind, &arguments)
        .map_err(|e| anyhow::anyhow!("governance check-action: {e}"))?;
    let agent_id = args
        .agent_id
        .clone()
        .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string());

    let envelope = run_check(&conn, &agent_id, &args.kind, &action)
        .map_err(|e| anyhow::anyhow!("governance check-action: {e}"))?;

    if args.json {
        // v0.7.0 #1103 — structured Deny envelope. When the resolved
        // decision is `refuse`, lift `rule_id` + `reason` to the top
        // level and tag the payload with `error: "GOVERNANCE_REFUSED"`
        // (uppercase, matching the HTTP error-envelope tag for
        // governance refusals so MCP / HTTP / CLI surfaces are
        // byte-equal under JSON parsing). Allow / warn keep emitting
        // the unchanged envelope so existing consumers don't break.
        let decision_obj = envelope.get("decision").cloned().unwrap_or(Value::Null);
        let verdict = decision_obj
            .get("decision")
            .and_then(Value::as_str)
            .unwrap_or("unknown");
        let payload = if verdict == "refuse" {
            let rule_id = decision_obj
                .get("rule_id")
                .and_then(Value::as_str)
                .unwrap_or("");
            let reason = decision_obj
                .get("reason")
                .and_then(Value::as_str)
                .unwrap_or("");
            serde_json::json!({
                "error": crate::errors::error_codes::GOVERNANCE_REFUSED,
                "decision": "deny",
                "rule_id": rule_id,
                "reason": reason,
                "kind": args.kind,
                "agent_id": agent_id,
            })
        } else {
            envelope.clone()
        };
        writeln!(
            out.stdout,
            "{}",
            serde_json::to_string(&payload)
                .context("governance check-action: serialise JSON envelope")?
        )?;
        return Ok(());
    }

    // Human-readable verdict line. The `decision` sub-object follows the
    // serde-tagged shape from `governance::agent_action::Decision`:
    // {"decision": "allow"} / {"decision": "refuse", "rule_id": "...", "reason": "..."}
    let decision = envelope.get("decision").cloned().unwrap_or(Value::Null);
    let verdict = decision
        .get("decision")
        .and_then(Value::as_str)
        .unwrap_or("unknown");
    match verdict {
        "allow" => writeln!(out.stdout, "Allow")?,
        "refuse" => {
            let rule_id = decision
                .get("rule_id")
                .and_then(Value::as_str)
                .unwrap_or("?");
            let reason = decision
                .get("reason")
                .and_then(Value::as_str)
                .unwrap_or("?");
            writeln!(out.stdout, "Refuse: {rule_id}{reason}")?;
        }
        "warn" => {
            let rule_id = decision
                .get("rule_id")
                .and_then(Value::as_str)
                .unwrap_or("?");
            let reason = decision
                .get("reason")
                .and_then(Value::as_str)
                .unwrap_or("?");
            writeln!(out.stdout, "Warn: {rule_id}{reason}")?;
        }
        other => writeln!(out.stdout, "Unknown verdict: {other}")?,
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use rusqlite::params;
    use tempfile::NamedTempFile;

    fn seed_rules_db() -> NamedTempFile {
        let tmp = NamedTempFile::new().unwrap();
        let conn = rusqlite::Connection::open(tmp.path()).unwrap();
        conn.execute_batch(
            "CREATE TABLE governance_rules (
                 id TEXT PRIMARY KEY,
                 kind TEXT NOT NULL,
                 matcher TEXT NOT NULL,
                 severity TEXT NOT NULL,
                 reason TEXT NOT NULL,
                 namespace TEXT NOT NULL DEFAULT '_global',
                 created_by TEXT NOT NULL,
                 created_at INTEGER NOT NULL,
                 enabled INTEGER NOT NULL DEFAULT 1,
                 signature BLOB,
                 attest_level TEXT NOT NULL DEFAULT 'unsigned'
             );
             CREATE TABLE signed_events (
                 id TEXT PRIMARY KEY,
                 agent_id TEXT NOT NULL,
                 event_type TEXT NOT NULL,
                 payload_hash BLOB NOT NULL,
                 signature BLOB,
                 attest_level TEXT NOT NULL DEFAULT 'unsigned',
                 timestamp TEXT NOT NULL,
                 prev_hash BLOB,
                 sequence INTEGER
             );",
        )
        .unwrap();
        // Seed R001-style filesystem_write rule (enabled = 1, no signature
        // required because tests force_no_operator_pubkey_for_test below).
        conn.execute(
            "INSERT INTO governance_rules (id, kind, matcher, severity, reason, \
             namespace, created_by, created_at, enabled, signature, attest_level) \
             VALUES (?1, ?2, ?3, 'refuse', ?4, '_global', 'test', 0, 1, NULL, 'unsigned')",
            params![
                "R001",
                "filesystem_write",
                r#"{"glob":"/tmp/**"}"#,
                "no /tmp writes",
            ],
        )
        .unwrap();
        tmp
    }

    #[test]
    fn refuses_filesystem_write_to_tmp() {
        // v0.7.0 #1043 (Agent-6 #6) — acquire the forensic-sink
        // lock alongside the pubkey guard. `run()` → `run_check`
        // → `check_agent_action` → `emit_forensic_decision` →
        // `record_decision` writes to the process-wide
        // `governance::audit::SINK`. Without the lock, a sibling
        // test in the same lib-test binary that called
        // `audit::init` with its own tempdir could bleed decisions
        // into our tempdir (or vice-versa). Today's lib-test
        // scheduler keeps this benign on macOS/Linux, but the
        // explicit lock makes the isolation invariant load-bearing.
        let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
        let tmp = seed_rules_db();
        let mut so = Vec::<u8>::new();
        let mut se = Vec::<u8>::new();
        let mut out = CliOutput::from_std(&mut so, &mut se);
        let args = CheckActionArgs {
            kind: "filesystem_write".into(),
            command: None,
            path: Some("/tmp/foo.txt".into()),
            host: None,
            binary: None,
            custom_kind: None,
            agent_id: None,
            json: true,
        };
        run(tmp.path(), &args, &mut out).unwrap();
        let stdout = String::from_utf8(so).unwrap();
        let v: Value = serde_json::from_str(stdout.trim()).unwrap();
        // v0.7.0 #1103 — structured Deny envelope. Pre-#1103 the JSON
        // shape was the raw `run_check` envelope (`decision.decision`,
        // `decision.rule_id`). Post-#1103 a refuse verdict is lifted
        // to the top-level `GOVERNANCE_REFUSED` envelope so MCP / HTTP /
        // CLI agree on the uppercase tag + flat-fielded shape.
        assert_eq!(
            v["error"], "GOVERNANCE_REFUSED",
            "#1103: refuse verdict must surface `error=GOVERNANCE_REFUSED`"
        );
        assert_eq!(
            v["decision"], "deny",
            "#1103: refuse verdict must surface `decision=deny` (CLI/HTTP parity tag)"
        );
        assert_eq!(
            v["rule_id"], "R001",
            "#1103: refuse verdict must surface flat `rule_id` at the top level"
        );
        assert_eq!(
            v["kind"], "filesystem_write",
            "#1103: refuse envelope echoes the action kind"
        );
        assert!(
            v["reason"].as_str().unwrap_or("").contains("/tmp"),
            "#1103: refuse envelope carries the substrate `reason` string"
        );
    }

    /// v0.7.0 #1103 — Allow / warn verdicts keep the legacy envelope
    /// shape so existing callers that parse `decision.decision` aren't
    /// broken. Only the refuse path lifts to the `GOVERNANCE_REFUSED`
    /// tag. Pins the back-compat half of the #1103 fix.
    #[test]
    fn allow_verdict_keeps_legacy_envelope_1103() {
        let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
        let tmp = seed_rules_db();
        let mut so = Vec::<u8>::new();
        let mut se = Vec::<u8>::new();
        let mut out = CliOutput::from_std(&mut so, &mut se);
        let args = CheckActionArgs {
            kind: "filesystem_write".into(),
            command: None,
            // Path outside /tmp — substrate rule allows.
            path: Some("/home/user/ok.txt".into()),
            host: None,
            binary: None,
            custom_kind: None,
            agent_id: None,
            json: true,
        };
        run(tmp.path(), &args, &mut out).unwrap();
        let stdout = String::from_utf8(so).unwrap();
        let v: Value = serde_json::from_str(stdout.trim()).unwrap();
        // Allow path keeps the raw envelope shape.
        assert_eq!(v["decision"]["decision"], "allow");
        assert!(
            v.get("error").is_none(),
            "#1103: allow verdicts must NOT carry the GOVERNANCE_REFUSED tag"
        );
    }

    #[test]
    fn allows_filesystem_write_outside_tmp() {
        // v0.7.0 #1043 — see comment on
        // `refuses_filesystem_write_to_tmp` for the lock rationale.
        let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
        let _no_pubkey = crate::governance::rules_store::force_no_operator_pubkey_for_test();
        let tmp = seed_rules_db();
        let mut so = Vec::<u8>::new();
        let mut se = Vec::<u8>::new();
        let mut out = CliOutput::from_std(&mut so, &mut se);
        let args = CheckActionArgs {
            kind: "filesystem_write".into(),
            command: None,
            path: Some("/home/user/ok.txt".into()),
            host: None,
            binary: None,
            custom_kind: None,
            agent_id: None,
            json: false,
        };
        run(tmp.path(), &args, &mut out).unwrap();
        let stdout = String::from_utf8(so).unwrap();
        assert!(stdout.trim() == "Allow", "got: {stdout}");
    }

    #[test]
    fn missing_required_field_errors() {
        // v0.7.0 #1043 — even error-path tests acquire the lock so
        // a future change that starts emitting forensic rows on the
        // missing-field error path inherits the isolation.
        let _audit_lock = crate::governance::audit::forensic_sink_test_lock().lock();
        let tmp = seed_rules_db();
        let mut so = Vec::<u8>::new();
        let mut se = Vec::<u8>::new();
        let mut out = CliOutput::from_std(&mut so, &mut se);
        let args = CheckActionArgs {
            kind: "filesystem_write".into(),
            command: None,
            path: None,
            host: None,
            binary: None,
            custom_kind: None,
            agent_id: None,
            json: false,
        };
        let err = run(tmp.path(), &args, &mut out).unwrap_err();
        assert!(err.to_string().contains("path"), "got: {err}");
    }
}