apm-core 0.1.21

Core library for APM — a git-native project manager for parallel AI coding agents.
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
/// Integration tests for PathGuard and hook_config.
///
/// These tests cover the acceptance criteria for the filesystem path validator
/// by invoking the core library directly and, where the `apm` binary is
/// available, via subprocess.

use apm_core::config::IsolationConfig;
use apm_core::wrapper::path_guard::{PathGuard, canonicalize_lenient};
use apm_core::wrapper::hook_config::{write_hook_config, remove_hook_config};
use std::path::{Path, PathBuf};

// ---- helpers ----

fn make_guard(wt: &Path) -> PathGuard {
    PathGuard::new(wt, &[], &[]).unwrap()
}

fn make_guard_with_protected(wt: &Path, protected: &[PathBuf]) -> PathGuard {
    PathGuard::new(wt, &[], protected).unwrap()
}

fn make_guard_with_read_allow(wt: &Path, patterns: &[&str]) -> PathGuard {
    let patterns: Vec<String> = patterns.iter().map(|s| s.to_string()).collect();
    PathGuard::new(wt, &patterns, &[]).unwrap()
}

// ---- AC: path outside worktree is rejected ----

#[test]
fn ac_edit_outside_worktree_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);

    let outside = tmp.path().join("main-worktree").join("src").join("lib.rs");
    let err = guard.check_write(&outside).unwrap_err();
    assert!(
        err.contains("path outside ticket worktree"),
        "rejection message must contain 'path outside ticket worktree': {err}"
    );
}

// ---- AC: rejection message includes APM_TICKET_WORKTREE ----

#[test]
fn ac_rejection_message_includes_worktree() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);

    let outside = tmp.path().join("outside.txt");
    let err = guard.check_write(&outside).unwrap_err();
    assert!(
        err.contains("APM_TICKET_WORKTREE"),
        "rejection must include APM_TICKET_WORKTREE: {err}"
    );
}

// ---- AC: main-worktree file is unmodified after rejection ----

#[test]
fn ac_main_worktree_file_unmodified_after_rejection() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);

    let sentinel = tmp.path().join("sentinel.txt");
    std::fs::write(&sentinel, "original").unwrap();

    // check_write returns Err — the file should remain unchanged
    let result = guard.check_write(&sentinel);
    assert!(result.is_err());
    assert_eq!(std::fs::read_to_string(&sentinel).unwrap(), "original");
}

// ---- AC: Edit inside worktree succeeds ----

#[test]
fn ac_edit_inside_worktree_allowed() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);

    let inside = wt.join("src").join("main.rs");
    assert!(guard.check_write(&inside).is_ok());
}

// ---- AC: Write outside worktree rejected ----

#[test]
fn ac_write_outside_worktree_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);

    let outside = tmp.path().join("new_file.txt");
    let err = guard.check_write(&outside).unwrap_err();
    assert!(err.contains("path outside ticket worktree"));
}

// ---- AC: Bash echo redirect outside rejected, file unmodified ----

#[test]
fn ac_bash_redirect_outside_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);

    let target = tmp.path().join("outside.txt");
    std::fs::write(&target, "original").unwrap();

    let cmd = format!("echo foo > {}", target.display());
    let result = guard.check_bash(&cmd);
    assert!(result.is_err(), "bash redirect outside must be rejected");
    // File should still be unmodified (check_bash only validates, not writes)
    assert_eq!(std::fs::read_to_string(&target).unwrap(), "original");
}

// ---- AC: cat /etc/resolv.conf allowed (no write target detected) ----

#[test]
fn ac_bash_cat_resolv_conf_allowed() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);
    assert!(guard.check_bash("cat /etc/resolv.conf").is_ok());
}

// ---- AC: cat ~/.gitconfig allowed ----

#[test]
fn ac_bash_cat_gitconfig_allowed() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);
    assert!(guard.check_bash("cat ~/.gitconfig").is_ok());
}

// ---- AC: Bash with paths only inside worktree allowed ----

#[test]
fn ac_bash_inside_paths_allowed() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let guard = make_guard(&wt);

    let inside = wt.join("output.txt");
    let cmd = format!("echo hello > {}", inside.display());
    assert!(guard.check_bash(&cmd).is_ok());
}

// ---- AC: enforce_worktree_isolation = false / absent → no enforcement ----

#[test]
fn ac_isolation_config_default_is_false() {
    let config = IsolationConfig::default();
    assert!(!config.enforce_worktree_isolation);
}

#[test]
fn ac_isolation_config_parses_toml() {
    use apm_core::config::Config;
    let toml = r#"
[project]
name = "test"

[tickets]
dir = "tickets"

[isolation]
enforce_worktree_isolation = true
read_allow = ["/etc/resolv.conf", "~/.gitconfig"]
"#;
    let config: Config = toml::from_str(toml).unwrap();
    assert!(config.isolation.enforce_worktree_isolation);
    assert!(config.isolation.read_allow.contains(&"/etc/resolv.conf".to_string()));
}

#[test]
fn ac_isolation_config_absent_defaults_false() {
    use apm_core::config::Config;
    let toml = r#"
[project]
name = "test"

[tickets]
dir = "tickets"
"#;
    let config: Config = toml::from_str(toml).unwrap();
    assert!(!config.isolation.enforce_worktree_isolation);
}

// ---- AC: path resolution canonicalises .. before comparison ----

#[test]
fn ac_dotdot_escape_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    let sub = wt.join("subdir");
    std::fs::create_dir_all(&sub).unwrap();
    let guard = make_guard(&wt);

    // wt/subdir/../../etc/passwd — escapes to parent of wt
    let path = sub.join("..").join("..").join("etc").join("passwd");
    assert!(
        guard.check_write(&path).is_err(),
        "dotdot escape must be rejected"
    );
}

// ---- AC: path resolution follows symlinks ----

#[test]
fn ac_symlink_to_outside_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let outside = tmp.path().join("outside");
    std::fs::create_dir(&outside).unwrap();

    let link = wt.join("link");
    std::os::unix::fs::symlink(&outside, &link).unwrap();

    let guard = make_guard(&wt);
    let target = link.join("secret.txt");
    assert!(
        guard.check_write(&target).is_err(),
        "symlink resolving outside must be rejected"
    );
}

// ---- AC: APM_BIN write rejected regardless of path ----

#[test]
fn ac_apm_bin_write_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let apm_bin = tmp.path().join("usr").join("bin").join("apm");
    std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
    std::fs::write(&apm_bin, "binary").unwrap();

    let guard = make_guard_with_protected(&wt, &[apm_bin.clone()]);
    assert!(guard.check_write(&apm_bin).is_err());
}

// ---- AC: APM_SYSTEM_PROMPT_FILE / APM_USER_MESSAGE_FILE write rejected ----

#[test]
fn ac_system_prompt_file_write_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let sys_file = tmp.path().join("apm-sys-1234.txt");
    std::fs::write(&sys_file, "system prompt").unwrap();

    let guard = make_guard_with_protected(&wt, &[sys_file.clone()]);
    assert!(guard.check_write(&sys_file).is_err());
}

#[test]
fn ac_user_message_file_write_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();
    let msg_file = tmp.path().join("apm-msg-5678.txt");
    std::fs::write(&msg_file, "message").unwrap();

    let guard = make_guard_with_protected(&wt, &[msg_file.clone()]);
    assert!(guard.check_write(&msg_file).is_err());
}

// ---- AC: read_allow configurable; cat calls still allowed ----

#[test]
fn ac_read_allow_configurable_cat_still_allowed() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();

    // With custom read_allow patterns, cat calls remain allowed
    let guard = make_guard_with_read_allow(&wt, &["/custom/path/**", "/etc/resolv.conf"]);
    assert!(guard.check_bash("cat /custom/path/file.txt").is_ok());
    assert!(guard.check_bash("cat /etc/resolv.conf").is_ok());
}

// ---- AC: non-existent write target — intermediate components resolved ----

#[test]
fn ac_nonexistent_target_intermediate_symlinks_resolved() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    let sub = wt.join("subdir");
    std::fs::create_dir_all(&sub).unwrap();
    let guard = make_guard(&wt);

    // wt/subdir/../../etc/passwd — subdir exists but result escapes wt
    let path = sub.join("..").join("..").join("etc").join("passwd");
    assert!(
        guard.check_write(&path).is_err(),
        "intermediate-resolved path must be rejected if it escapes worktree"
    );
}

// ---- AC: APM_BIN inside worktree still rejected ----

#[test]
fn ac_apm_bin_inside_worktree_still_rejected() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    let bin_dir = wt.join("target").join("debug");
    std::fs::create_dir_all(&bin_dir).unwrap();
    let apm_bin = bin_dir.join("apm");
    std::fs::write(&apm_bin, "binary").unwrap();

    let guard = make_guard_with_protected(&wt, &[apm_bin.clone()]);
    // Even though apm_bin is inside the worktree, it must be rejected
    assert!(
        guard.check_write(&apm_bin).is_err(),
        "APM_BIN inside worktree must still be rejected"
    );
}

// ---- AC: canonicalize_lenient — existing ancestors resolved, non-existent appended ----

#[test]
fn canonicalize_lenient_existing_ancestors_with_nonexistent_leaf() {
    let tmp = tempfile::tempdir().unwrap();
    let wt = tmp.path().join("wt");
    std::fs::create_dir(&wt).unwrap();

    let path = wt.join("new_file_does_not_exist.txt");
    let result = canonicalize_lenient(&path);

    // Parent should be resolved (wt exists), leaf appended lexically
    let canon_wt = std::fs::canonicalize(&wt).unwrap();
    assert_eq!(result.parent().unwrap(), canon_wt);
    assert_eq!(result.file_name().unwrap().to_str().unwrap(), "new_file_does_not_exist.txt");
}

// ---- hook_config integration tests ----

#[test]
fn hook_config_write_and_remove_roundtrip() {
    let tmp = tempfile::tempdir().unwrap();
    write_hook_config(tmp.path(), "/usr/local/bin/apm").unwrap();

    let settings_path = tmp.path().join(".claude").join("settings.json");
    let content = std::fs::read_to_string(&settings_path).unwrap();
    assert!(content.contains("apm path-guard"));
    assert!(content.contains("Edit|Write|Bash"));

    remove_hook_config(tmp.path()).unwrap();
    let content_after = std::fs::read_to_string(&settings_path).unwrap();
    let v: serde_json::Value = serde_json::from_str(&content_after).unwrap();
    let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
    assert_eq!(arr.len(), 0);
}

// ---- manifest.enforce_worktree_isolation field ----

#[test]
fn manifest_enforce_worktree_isolation_defaults_false() {
    use apm_core::wrapper::custom::Manifest;

    let toml = "[wrapper]\n";
    #[derive(serde::Deserialize)]
    struct ManifestFile { wrapper: Manifest }
    let file: ManifestFile = toml::from_str(toml).unwrap();
    assert!(!file.wrapper.enforce_worktree_isolation);
}

#[test]
fn manifest_enforce_worktree_isolation_parses_true() {
    use apm_core::wrapper::custom::Manifest;

    let toml = "[wrapper]\nenforce_worktree_isolation = true\n";
    #[derive(serde::Deserialize)]
    struct ManifestFile { wrapper: Manifest }
    let file: ManifestFile = toml::from_str(toml).unwrap();
    assert!(file.wrapper.enforce_worktree_isolation);
}

#[test]
fn manifest_enforce_worktree_isolation_not_unknown_key() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
    std::fs::create_dir_all(&agent_dir).unwrap();
    std::fs::write(
        agent_dir.join("manifest.toml"),
        "[wrapper]\nenforce_worktree_isolation = true\n",
    )
    .unwrap();

    let unknown = apm_core::wrapper::custom::manifest_unknown_keys(root, "my-wrapper").unwrap();
    assert!(
        !unknown.contains(&"enforce_worktree_isolation".to_string()),
        "enforce_worktree_isolation should be a known key, not unknown: {unknown:?}"
    );
}