ironclaw 0.22.0

Secure personal AI assistant that protects your data and expands its capabilities on the fly
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
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
#![cfg(feature = "libsql")]
//! Integration tests for multi-scope workspace reads using file-backed libSQL.
//!
//! Guards the PR2 contract: workspaces can read from multiple user scopes
//! while writes remain isolated to the primary scope.

use std::sync::Arc;

use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::workspace::Workspace;

async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
    let dir = tempfile::tempdir().expect("create temp dir");
    let db_path = dir.path().join("test.db");
    let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
    backend.run_migrations().await.expect("run migrations");
    let db: Arc<dyn Database> = Arc::new(backend);
    (db, dir)
}

#[tokio::test]
async fn read_across_scopes() {
    let (db, _dir) = setup().await;

    // Write docs as the "shared" user
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("docs/team-standup.md", "Team standup notes from Monday")
        .await
        .expect("shared write failed");

    // Alice's workspace with "shared" as an additional read scope
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);

    // Alice can read shared docs
    let doc = ws_alice
        .read("docs/team-standup.md")
        .await
        .expect("cross-scope read failed");
    assert_eq!(doc.content, "Team standup notes from Monday");
}

#[tokio::test]
async fn write_stays_in_primary_scope() {
    let (db, _dir) = setup().await;

    // Alice has "shared" as a read scope
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);

    // Alice writes a personal note
    ws_alice
        .write("notes/personal.md", "Alice's private note")
        .await
        .expect("alice write failed");

    // The "shared" workspace should NOT see Alice's note
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    let result = ws_shared.read("notes/personal.md").await;
    assert!(result.is_err(), "Shared scope should not see Alice's note");
}

#[tokio::test]
async fn list_paths_merges_across_scopes() {
    let (db, _dir) = setup().await;

    // Write as alice
    let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
    ws_alice_plain
        .write("notes/personal.md", "My notes")
        .await
        .expect("alice write failed");

    // Write as shared
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("docs/shared-doc.md", "Shared document")
        .await
        .expect("shared write failed");

    // Alice with multi-scope should see both
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);

    let all_paths = ws_alice.list_all().await.expect("list_all failed");
    assert!(
        all_paths.contains(&"notes/personal.md".to_string()),
        "Should contain alice's note: {:?}",
        all_paths
    );
    assert!(
        all_paths.contains(&"docs/shared-doc.md".to_string()),
        "Should contain shared doc: {:?}",
        all_paths
    );
}

#[tokio::test]
async fn list_directory_merges_across_scopes() {
    let (db, _dir) = setup().await;

    // Alice writes to docs/
    let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
    ws_alice_plain
        .write("docs/alice-doc.md", "Alice's doc")
        .await
        .expect("alice write failed");

    // Shared writes to docs/
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("docs/shared-doc.md", "Shared doc")
        .await
        .expect("shared write failed");

    // Alice with multi-scope lists docs/
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);

    let entries = ws_alice.list("docs").await.expect("list failed");
    let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
    assert!(
        paths.contains(&"docs/alice-doc.md"),
        "Should contain alice's doc: {:?}",
        paths
    );
    assert!(
        paths.contains(&"docs/shared-doc.md"),
        "Should contain shared doc: {:?}",
        paths
    );
}

#[tokio::test]
async fn search_spans_scopes() {
    let (db, _dir) = setup().await;

    // Write searchable content in shared scope
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write(
            "docs/architecture.md",
            "The microservice architecture uses gRPC for inter-service communication",
        )
        .await
        .expect("shared write failed");

    // Write searchable content in alice scope
    let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
    ws_alice_plain
        .write("notes/ideas.md", "Consider switching to GraphQL federation")
        .await
        .expect("alice write failed");

    // Alice with multi-scope searches
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);

    // Search for content in the shared scope
    let results = ws_alice
        .search("microservice architecture gRPC", 10)
        .await
        .expect("search failed");
    assert!(!results.is_empty(), "Should find results from shared scope");
}

#[tokio::test]
async fn read_priority_primary_first() {
    let (db, _dir) = setup().await;

    // Write same path in both scopes
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("config/settings.md", "Shared settings v1")
        .await
        .expect("shared write failed");

    let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
    ws_alice_plain
        .write("config/settings.md", "Alice's settings override")
        .await
        .expect("alice write failed");

    // Alice with multi-scope should get her own version (primary scope wins)
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);

    let doc = ws_alice
        .read("config/settings.md")
        .await
        .expect("read failed");
    assert_eq!(
        doc.content, "Alice's settings override",
        "Primary scope should take priority"
    );
}

#[tokio::test]
async fn exists_spans_scopes() {
    let (db, _dir) = setup().await;

    // Write a doc as "shared"
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("docs/shared-only.md", "Shared content")
        .await
        .expect("shared write failed");

    // Alice without multi-scope should NOT see it
    let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
    assert!(
        !ws_alice_plain
            .exists("docs/shared-only.md")
            .await
            .expect("exists failed"),
        "Alice without multi-scope should not see shared doc"
    );

    // Alice with multi-scope should see it
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);
    assert!(
        ws_alice
            .exists("docs/shared-only.md")
            .await
            .expect("exists failed"),
        "Alice with multi-scope should see shared doc"
    );
}

#[tokio::test]
async fn append_stays_in_primary_scope() {
    let (db, _dir) = setup().await;

    // Write a document as "shared"
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("notes/log.md", "shared original content")
        .await
        .expect("shared write failed");

    // Alice has "shared" as a read scope and appends to the same path
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);
    ws_alice
        .append("notes/log.md", "alice appended line")
        .await
        .expect("alice append failed");

    // Shared document must be unchanged (write isolation)
    let shared_doc = ws_shared
        .read("notes/log.md")
        .await
        .expect("shared read failed");
    assert_eq!(
        shared_doc.content, "shared original content",
        "Append must not modify the secondary scope's document"
    );

    // Alice should have her own copy with the appended content
    let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
    let alice_doc = ws_alice_plain
        .read("notes/log.md")
        .await
        .expect("alice read failed");
    assert_eq!(
        alice_doc.content, "alice appended line",
        "Append should create a new document in alice's scope"
    );
}

#[tokio::test]
async fn append_memory_stays_in_primary_scope() {
    let (db, _dir) = setup().await;

    // Write MEMORY.md as "shared"
    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("MEMORY.md", "shared memory baseline")
        .await
        .expect("shared write failed");

    // Alice has "shared" as a read scope and appends a memory entry
    let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string()]);
    ws_alice
        .append_memory("alice remembers this")
        .await
        .expect("alice append_memory failed");

    // Shared MEMORY.md must be unchanged
    let shared_doc = ws_shared
        .read("MEMORY.md")
        .await
        .expect("shared read failed");
    assert_eq!(
        shared_doc.content, "shared memory baseline",
        "append_memory must not modify the secondary scope's document"
    );

    // Alice should have her own MEMORY.md
    let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
    let alice_doc = ws_alice_plain
        .read("MEMORY.md")
        .await
        .expect("alice read failed");
    assert_eq!(
        alice_doc.content, "alice remembers this",
        "append_memory should create in alice's scope"
    );
}

// ==================== Identity isolation tests ====================

#[tokio::test]
async fn identity_files_not_readable_from_secondary_scope() {
    let (db, _dir) = setup().await;

    let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
    ws_other
        .write("IDENTITY.md", "I am the other user")
        .await
        .expect("write failed");
    ws_other
        .write("SOUL.md", "Other user soul overlay")
        .await
        .expect("write failed");
    ws_other
        .write("USER.md", "Other user profile")
        .await
        .expect("write failed");
    ws_other
        .write("AGENTS.md", "Other user agent config")
        .await
        .expect("write failed");

    let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
        .with_additional_read_scopes(vec!["other-user".to_string()]);

    for path in &["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md"] {
        let result = ws_primary.read(path).await;
        assert!(
            result.is_err(),
            "Primary should NOT read other user's {} via secondary scope",
            path
        );
    }
}

#[tokio::test]
async fn identity_files_not_in_search_from_secondary_scope() {
    let (db, _dir) = setup().await;

    let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
    ws_other
        .write("SOUL.md", "Other user loves xylophone music passionately")
        .await
        .expect("write failed");
    ws_other
        .write(
            "notes/music.md",
            "Other user played xylophone at the concert",
        )
        .await
        .expect("write failed");

    let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
        .with_additional_read_scopes(vec!["other-user".to_string()]);

    let results = ws_primary
        .search("xylophone", 10)
        .await
        .expect("search failed");
    let has_concert = results.iter().any(|r| r.content.contains("concert"));
    assert!(
        has_concert,
        "Should find non-identity content from secondary scope"
    );
    let has_soul = results.iter().any(|r| r.content.contains("passionately"));
    assert!(
        !has_soul,
        "SOUL.md content from secondary scope should not appear in search results"
    );
}

#[tokio::test]
async fn identity_files_not_in_list_from_secondary_scope() {
    let (db, _dir) = setup().await;

    let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
    ws_other
        .write("IDENTITY.md", "I am the other user")
        .await
        .expect("write failed");
    ws_other
        .write("notes/shared-note.md", "A shared note")
        .await
        .expect("write failed");

    let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
        .with_additional_read_scopes(vec!["other-user".to_string()]);

    let paths = ws_primary.list_all().await.expect("list failed");
    assert!(
        !paths.contains(&"IDENTITY.md".to_string()),
        "IDENTITY.md from secondary scope should not appear"
    );
    assert!(
        paths.contains(&"notes/shared-note.md".to_string()),
        "Non-identity files should be listed"
    );
}

#[tokio::test]
async fn empty_read_scopes_reads_primary_only() {
    let (db, _dir) = setup().await;

    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("docs/note.md", "Shared note")
        .await
        .expect("write failed");

    let ws_primary =
        Workspace::new_with_db("primary", Arc::clone(&db)).with_additional_read_scopes(vec![]);

    let result = ws_primary.read("docs/note.md").await;
    assert!(
        result.is_err(),
        "Empty read scopes should not grant cross-scope access"
    );
}

#[tokio::test]
async fn duplicate_read_scopes_handled() {
    let (db, _dir) = setup().await;

    let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
    ws_shared
        .write("docs/note.md", "One note")
        .await
        .expect("write failed");

    let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
        .with_additional_read_scopes(vec!["shared".to_string(), "shared".to_string()]);

    let doc = ws_primary.read("docs/note.md").await.expect("read failed");
    assert_eq!(doc.content, "One note");
}