@soulguard/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +127 -0
  2. package/dist/cli.js +8838 -0
  3. package/dist/index.js +6555 -0
  4. package/package.json +35 -0
  5. package/src/approve.test.ts +386 -0
  6. package/src/approve.ts +352 -0
  7. package/src/cli/approve-command.ts +107 -0
  8. package/src/cli/diff-command.test.ts +61 -0
  9. package/src/cli/diff-command.ts +81 -0
  10. package/src/cli/init-command.ts +83 -0
  11. package/src/cli/reset-command.ts +36 -0
  12. package/src/cli/status-command.test.ts +90 -0
  13. package/src/cli/status-command.ts +78 -0
  14. package/src/cli/sync-command.test.ts +67 -0
  15. package/src/cli/sync-command.ts +105 -0
  16. package/src/cli.ts +224 -0
  17. package/src/console-live.ts +32 -0
  18. package/src/console-mock.ts +48 -0
  19. package/src/console.ts +12 -0
  20. package/src/constants.ts +21 -0
  21. package/src/diff.test.ts +189 -0
  22. package/src/diff.ts +212 -0
  23. package/src/git.test.ts +180 -0
  24. package/src/git.ts +131 -0
  25. package/src/glob.test.ts +74 -0
  26. package/src/glob.ts +84 -0
  27. package/src/index.ts +100 -0
  28. package/src/init.test.ts +234 -0
  29. package/src/init.ts +317 -0
  30. package/src/policy.test.ts +123 -0
  31. package/src/policy.ts +100 -0
  32. package/src/reset.test.ts +68 -0
  33. package/src/reset.ts +63 -0
  34. package/src/result.ts +14 -0
  35. package/src/schema.test.ts +27 -0
  36. package/src/schema.ts +22 -0
  37. package/src/self-protection.test.ts +139 -0
  38. package/src/self-protection.ts +63 -0
  39. package/src/status.test.ts +241 -0
  40. package/src/status.ts +114 -0
  41. package/src/sync.test.ts +172 -0
  42. package/src/sync.ts +101 -0
  43. package/src/system-ops-mock.ts +243 -0
  44. package/src/system-ops-node.test.ts +183 -0
  45. package/src/system-ops-node.ts +499 -0
  46. package/src/system-ops.ts +109 -0
  47. package/src/types.ts +91 -0
  48. package/src/vault-check.test.ts +41 -0
  49. package/src/vault-check.ts +24 -0
  50. package/test-e2e/Dockerfile +29 -0
  51. package/test-e2e/cases/approve-with-hash/expected.txt +16 -0
  52. package/test-e2e/cases/approve-with-hash/test.sh +23 -0
  53. package/test-e2e/cases/diff-no-changes/expected.txt +5 -0
  54. package/test-e2e/cases/diff-no-changes/test.sh +7 -0
  55. package/test-e2e/cases/diff-no-staging/expected.txt +1 -0
  56. package/test-e2e/cases/diff-no-staging/test.sh +6 -0
  57. package/test-e2e/cases/diff-shows-changes/expected.txt +13 -0
  58. package/test-e2e/cases/diff-shows-changes/test.sh +12 -0
  59. package/test-e2e/cases/diff-vault-missing/expected.txt +6 -0
  60. package/test-e2e/cases/diff-vault-missing/test.sh +10 -0
  61. package/test-e2e/cases/glob-ledger-files/expected.txt +52 -0
  62. package/test-e2e/cases/glob-ledger-files/test.sh +28 -0
  63. package/test-e2e/cases/glob-vault-files/expected.txt +41 -0
  64. package/test-e2e/cases/glob-vault-files/test.sh +30 -0
  65. package/test-e2e/cases/init-blocked-by-agent/expected.txt +11 -0
  66. package/test-e2e/cases/init-blocked-by-agent/test.sh +15 -0
  67. package/test-e2e/cases/init-happy/expected.txt +18 -0
  68. package/test-e2e/cases/init-happy/test.sh +20 -0
  69. package/test-e2e/cases/init-idempotent/expected.txt +13 -0
  70. package/test-e2e/cases/init-idempotent/test.sh +14 -0
  71. package/test-e2e/cases/reset-staging/expected.txt +16 -0
  72. package/test-e2e/cases/reset-staging/test.sh +23 -0
  73. package/test-e2e/cases/self-protection-blocks-invalid/expected.txt +12 -0
  74. package/test-e2e/cases/self-protection-blocks-invalid/test.sh +25 -0
  75. package/test-e2e/cases/status-clean/expected.txt +9 -0
  76. package/test-e2e/cases/status-clean/test.sh +8 -0
  77. package/test-e2e/cases/status-drifted/expected.txt +9 -0
  78. package/test-e2e/cases/status-drifted/test.sh +11 -0
  79. package/test-e2e/cases/status-no-config/expected.txt +1 -0
  80. package/test-e2e/cases/status-no-config/test.sh +2 -0
  81. package/test-e2e/cases/sync-fix-drift/expected.txt +15 -0
  82. package/test-e2e/cases/sync-fix-drift/test.sh +14 -0
  83. package/test-e2e/cases/sync-no-sudo-clean/expected.txt +11 -0
  84. package/test-e2e/cases/sync-no-sudo-clean/test.sh +9 -0
  85. package/test-e2e/cases/sync-no-sudo-drift/expected.txt +7 -0
  86. package/test-e2e/cases/sync-no-sudo-drift/test.sh +10 -0
  87. package/test-e2e/cases/vault-remove-file/expected.txt +26 -0
  88. package/test-e2e/cases/vault-remove-file/test.sh +31 -0
  89. package/test-e2e/cases/vault-write-blocked/expected.txt +8 -0
  90. package/test-e2e/cases/vault-write-blocked/test.sh +14 -0
  91. package/test-e2e/run-tests.sh +76 -0
  92. package/test-integration/Dockerfile +17 -0
  93. package/test-integration/system-ops-node.integration.test.ts +170 -0
  94. package/tsconfig.json +8 -0
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@soulguard/core",
3
+ "version": "0.1.0",
4
+ "bin": {
5
+ "soulguard": "dist/cli.js"
6
+ },
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "bun build src/index.ts src/cli.ts --outdir dist --target node",
18
+ "test": "bun test src/",
19
+ "test:integration": "docker build -f test-integration/Dockerfile -t soulguard-test ../.. && docker run --rm soulguard-test",
20
+ "test:e2e": "docker build -f test-e2e/Dockerfile -t soulguard-e2e ../.. && bash test-e2e/run-tests.sh",
21
+ "test:e2e:update": "docker build -f test-e2e/Dockerfile -t soulguard-e2e ../.. && bash test-e2e/run-tests.sh --update",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "commander": "^13.0.0",
26
+ "diff": "^8.0.3",
27
+ "picocolors": "^1.1.1",
28
+ "zod": "^3.24.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bun": "latest",
32
+ "@types/diff": "^8.0.0",
33
+ "typescript": "^5.7.0"
34
+ }
35
+ }
@@ -0,0 +1,386 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MockSystemOps } from "./system-ops-mock.js";
3
+ import { diff } from "./diff.js";
4
+ import { approve } from "./approve.js";
5
+ import type { SoulguardConfig, FileOwnership } from "./types.js";
6
+ import type { Policy } from "./policy.js";
7
+ import { ok, err } from "./result.js";
8
+
9
+ const config: SoulguardConfig = { vault: ["SOUL.md"], ledger: [] };
10
+ const multiConfig: SoulguardConfig = { vault: ["SOUL.md", "AGENTS.md"], ledger: [] };
11
+ const vaultOwnership: FileOwnership = { user: "soulguardian", group: "soulguard", mode: "444" };
12
+
13
+ function setup() {
14
+ const ops = new MockSystemOps("/workspace");
15
+ ops.addFile("SOUL.md", "original soul", {
16
+ owner: "soulguardian",
17
+ group: "soulguard",
18
+ mode: "444",
19
+ });
20
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
21
+ ops.addFile(".soulguard/staging/SOUL.md", "modified soul", {
22
+ owner: "agent",
23
+ group: "soulguard",
24
+ mode: "644",
25
+ });
26
+ return ops;
27
+ }
28
+
29
+ /** Helper to compute hash from current staging diff */
30
+ async function getApprovalHash(ops: MockSystemOps, cfg: SoulguardConfig): Promise<string> {
31
+ const result = await diff({ ops, config: cfg });
32
+ if (!result.ok) throw new Error("diff failed");
33
+ return result.value.approvalHash!;
34
+ }
35
+
36
+ describe("approve (implicit proposals)", () => {
37
+ test("applies changes when hash matches", async () => {
38
+ const ops = setup();
39
+ const hash = await getApprovalHash(ops, config);
40
+
41
+ const result = await approve({ ops, config, hash, vaultOwnership });
42
+ expect(result.ok).toBe(true);
43
+ if (!result.ok) return;
44
+ expect(result.value.appliedFiles).toEqual(["SOUL.md"]);
45
+
46
+ // Vault file should have new content
47
+ const content = await ops.readFile("SOUL.md");
48
+ expect(content.ok).toBe(true);
49
+ if (content.ok) expect(content.value).toBe("modified soul");
50
+ });
51
+
52
+ test("rejects when no changes exist", async () => {
53
+ const ops = new MockSystemOps("/workspace");
54
+ ops.addFile("SOUL.md", "same", { owner: "soulguardian", group: "soulguard", mode: "444" });
55
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
56
+ ops.addFile(".soulguard/staging/SOUL.md", "same", {
57
+ owner: "agent",
58
+ group: "soulguard",
59
+ mode: "644",
60
+ });
61
+
62
+ const result = await approve({ ops, config, hash: "anyhash", vaultOwnership });
63
+ expect(result.ok).toBe(false);
64
+ if (result.ok) return;
65
+ expect(result.error.kind).toBe("no_changes");
66
+ });
67
+
68
+ test("rejects hash mismatch (staging changed since review)", async () => {
69
+ const ops = setup();
70
+ const hash = await getApprovalHash(ops, config);
71
+
72
+ // Agent sneaks in a change after hash was computed
73
+ ops.addFile(".soulguard/staging/SOUL.md", "sneaky different content", {
74
+ owner: "agent",
75
+ group: "soulguard",
76
+ mode: "644",
77
+ });
78
+
79
+ const result = await approve({ ops, config, hash, vaultOwnership });
80
+ expect(result.ok).toBe(false);
81
+ if (result.ok) return;
82
+ expect(result.error.kind).toBe("hash_mismatch");
83
+ });
84
+
85
+ test("rolls back on partial apply failure", async () => {
86
+ const ops = new MockSystemOps("/workspace");
87
+ ops.addFile("SOUL.md", "original soul", {
88
+ owner: "soulguardian",
89
+ group: "soulguard",
90
+ mode: "444",
91
+ });
92
+ ops.addFile("AGENTS.md", "original agents", {
93
+ owner: "soulguardian",
94
+ group: "soulguard",
95
+ mode: "444",
96
+ });
97
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
98
+ ops.addFile(".soulguard/staging/SOUL.md", "modified soul", {
99
+ owner: "agent",
100
+ group: "soulguard",
101
+ mode: "644",
102
+ });
103
+ ops.addFile(".soulguard/staging/AGENTS.md", "modified agents", {
104
+ owner: "agent",
105
+ group: "soulguard",
106
+ mode: "644",
107
+ });
108
+
109
+ const hash = await getApprovalHash(ops, multiConfig);
110
+
111
+ // Inject failure: make chown fail on AGENTS.md
112
+ const originalChown = ops.chown.bind(ops);
113
+ ops.chown = async (path, owner) => {
114
+ if (path === "AGENTS.md") {
115
+ return err({ kind: "permission_denied" as const, path, operation: "chown" });
116
+ }
117
+ return originalChown(path, owner);
118
+ };
119
+
120
+ const result = await approve({ ops, config: multiConfig, hash, vaultOwnership });
121
+ expect(result.ok).toBe(false);
122
+ if (result.ok) return;
123
+ expect(result.error.kind).toBe("apply_failed");
124
+
125
+ // SOUL.md should be rolled back to original
126
+ const soulContent = await ops.readFile("SOUL.md");
127
+ expect(soulContent.ok).toBe(true);
128
+ if (soulContent.ok) expect(soulContent.value).toBe("original soul");
129
+ });
130
+
131
+ test("syncs staging after successful approve", async () => {
132
+ const ops = setup();
133
+ const hash = await getApprovalHash(ops, config);
134
+ const stagingOwnership = { user: "agent", group: "soulguard", mode: "644" };
135
+
136
+ const result = await approve({ ops, config, hash, vaultOwnership, stagingOwnership });
137
+ expect(result.ok).toBe(true);
138
+
139
+ // Staging should now match vault (no diff)
140
+ const diffResult = await diff({ ops, config });
141
+ expect(diffResult.ok).toBe(true);
142
+ if (diffResult.ok) expect(diffResult.value.hasChanges).toBe(false);
143
+ });
144
+
145
+ test("blocks on policy violation", async () => {
146
+ const ops = setup();
147
+ const hash = await getApprovalHash(ops, config);
148
+ const policies: Policy[] = [{ name: "block-all", check: () => err("blocked by policy") }];
149
+
150
+ const result = await approve({ ops, config, hash, vaultOwnership, policies });
151
+ expect(result.ok).toBe(false);
152
+ if (result.ok) return;
153
+ expect(result.error.kind).toBe("policy_violation");
154
+ if (result.error.kind === "policy_violation") {
155
+ expect(result.error.violations).toHaveLength(1);
156
+ expect(result.error.violations[0]!.policy).toBe("block-all");
157
+ }
158
+
159
+ // Vault should be unchanged
160
+ const content = await ops.readFile("SOUL.md");
161
+ if (content.ok) expect(content.value).toBe("original soul");
162
+ });
163
+
164
+ test("passes with allowing policy", async () => {
165
+ const ops = setup();
166
+ const hash = await getApprovalHash(ops, config);
167
+ const policies: Policy[] = [{ name: "allow-all", check: () => ok(undefined) }];
168
+
169
+ const result = await approve({ ops, config, hash, vaultOwnership, policies });
170
+ expect(result.ok).toBe(true);
171
+ });
172
+
173
+ test("rejects duplicate policy names", async () => {
174
+ const ops = setup();
175
+ const hash = await getApprovalHash(ops, config);
176
+ const policies: Policy[] = [
177
+ { name: "dupe", check: () => ok(undefined) },
178
+ { name: "dupe", check: () => ok(undefined) },
179
+ ];
180
+
181
+ const result = await approve({ ops, config, hash, vaultOwnership, policies });
182
+ expect(result.ok).toBe(false);
183
+ if (result.ok) return;
184
+ expect(result.error.kind).toBe("policy_name_collision");
185
+ if (result.error.kind === "policy_name_collision") {
186
+ expect(result.error.duplicates).toEqual(["dupe"]);
187
+ }
188
+ });
189
+
190
+ test("policy receives frozen pending content", async () => {
191
+ const ops = setup();
192
+ const hash = await getApprovalHash(ops, config);
193
+ let capturedFinal: string | undefined;
194
+ const policies: Policy[] = [
195
+ {
196
+ name: "capture",
197
+ check: (ctx) => {
198
+ capturedFinal = ctx.get("SOUL.md")?.final;
199
+ return ok(undefined);
200
+ },
201
+ },
202
+ ];
203
+
204
+ await approve({ ops, config, hash, vaultOwnership, policies });
205
+ expect(capturedFinal).toBe("modified soul");
206
+ });
207
+
208
+ test("cleans up pending directory after approve", async () => {
209
+ const ops = setup();
210
+ const hash = await getApprovalHash(ops, config);
211
+
212
+ const result = await approve({ ops, config, hash, vaultOwnership });
213
+ expect(result.ok).toBe(true);
214
+
215
+ // Pending files should be cleaned up
216
+ const pendingExists = await ops.exists(".soulguard/pending/SOUL.md");
217
+ expect(pendingExists.ok).toBe(true);
218
+ if (pendingExists.ok) expect(pendingExists.value).toBe(false);
219
+ });
220
+
221
+ test("auto-commits vault changes when git enabled", async () => {
222
+ const ops = setup();
223
+ ops.addFile(".git", ""); // git repo exists
224
+ ops.execFailOnCall.set("git diff --cached --quiet", new Set([1]));
225
+
226
+ const gitConfig: SoulguardConfig = { ...config, git: true };
227
+ const hash = await getApprovalHash(ops, gitConfig);
228
+ const result = await approve({ ops, config: gitConfig, hash, vaultOwnership });
229
+ expect(result.ok).toBe(true);
230
+ if (!result.ok) return;
231
+
232
+ expect(result.value.gitResult).toBeDefined();
233
+ expect(result.value.gitResult!.committed).toBe(true);
234
+ if (result.value.gitResult!.committed) {
235
+ expect(result.value.gitResult!.message).toContain("SOUL.md");
236
+ }
237
+ });
238
+
239
+ test("skips git commit when git disabled", async () => {
240
+ const ops = setup();
241
+ ops.addFile(".git", "");
242
+
243
+ const gitConfig: SoulguardConfig = { ...config, git: false };
244
+ const hash = await getApprovalHash(ops, gitConfig);
245
+ const result = await approve({ ops, config: gitConfig, hash, vaultOwnership });
246
+ expect(result.ok).toBe(true);
247
+ if (!result.ok) return;
248
+
249
+ expect(result.value.gitResult).toBeUndefined();
250
+ });
251
+
252
+ test("deletes vault file when staging copy is removed", async () => {
253
+ const ops = new MockSystemOps("/workspace");
254
+ ops.addFile("SOUL.md", "original soul", {
255
+ owner: "soulguardian",
256
+ group: "soulguard",
257
+ mode: "444",
258
+ });
259
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
260
+ // No staging/SOUL.md — agent deleted it
261
+
262
+ const hash = await getApprovalHash(ops, config);
263
+ const result = await approve({ ops, config, hash, vaultOwnership });
264
+
265
+ expect(result.ok).toBe(true);
266
+ if (!result.ok) return;
267
+ expect(result.value.appliedFiles).toEqual(["SOUL.md"]);
268
+
269
+ // Vault file should be gone
270
+ const exists = await ops.exists("SOUL.md");
271
+ expect(exists.ok).toBe(true);
272
+ if (exists.ok) expect(exists.value).toBe(false);
273
+ });
274
+
275
+ test("blocks deletion of soulguard.json via self-protection", async () => {
276
+ const sgConfig: SoulguardConfig = { vault: ["soulguard.json"], ledger: [] };
277
+ const ops = new MockSystemOps("/workspace");
278
+ ops.addFile("soulguard.json", '{"vault":["soulguard.json"],"ledger":[]}', {
279
+ owner: "soulguardian",
280
+ group: "soulguard",
281
+ mode: "444",
282
+ });
283
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
284
+ // No staging/soulguard.json — agent trying to delete config
285
+
286
+ const hash = await getApprovalHash(ops, sgConfig);
287
+ const result = await approve({ ops, config: sgConfig, hash, vaultOwnership });
288
+
289
+ expect(result.ok).toBe(false);
290
+ if (result.ok) return;
291
+ expect(result.error.kind).toBe("self_protection");
292
+ });
293
+
294
+ test("deletion + modification in same approve", async () => {
295
+ const ops = new MockSystemOps("/workspace");
296
+ ops.addFile("SOUL.md", "original soul", {
297
+ owner: "soulguardian",
298
+ group: "soulguard",
299
+ mode: "444",
300
+ });
301
+ ops.addFile("AGENTS.md", "original agents", {
302
+ owner: "soulguardian",
303
+ group: "soulguard",
304
+ mode: "444",
305
+ });
306
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
307
+ // SOUL.md deleted from staging, AGENTS.md modified
308
+ ops.addFile(".soulguard/staging/AGENTS.md", "modified agents", {
309
+ owner: "agent",
310
+ group: "soulguard",
311
+ mode: "644",
312
+ });
313
+
314
+ const hash = await getApprovalHash(ops, multiConfig);
315
+ const result = await approve({ ops, config: multiConfig, hash, vaultOwnership });
316
+
317
+ expect(result.ok).toBe(true);
318
+ if (!result.ok) return;
319
+ expect(result.value.appliedFiles).toContain("SOUL.md");
320
+ expect(result.value.appliedFiles).toContain("AGENTS.md");
321
+
322
+ // SOUL.md deleted, AGENTS.md updated
323
+ const soulExists = await ops.exists("SOUL.md");
324
+ expect(soulExists.ok && soulExists.value).toBe(false);
325
+ const agentsContent = await ops.readFile("AGENTS.md");
326
+ expect(agentsContent.ok && agentsContent.value).toBe("modified agents");
327
+ });
328
+
329
+ test("rollback restores deleted files when subsequent deletion fails", async () => {
330
+ const twoDeleteConfig: SoulguardConfig = { vault: ["SOUL.md", "AGENTS.md"], ledger: [] };
331
+ const ops = new MockSystemOps("/workspace");
332
+ ops.addFile("SOUL.md", "original soul", {
333
+ owner: "soulguardian",
334
+ group: "soulguard",
335
+ mode: "444",
336
+ });
337
+ ops.addFile("AGENTS.md", "original agents", {
338
+ owner: "soulguardian",
339
+ group: "soulguard",
340
+ mode: "444",
341
+ });
342
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
343
+ // Both files deleted from staging
344
+
345
+ const hash = await getApprovalHash(ops, twoDeleteConfig);
346
+
347
+ // Make AGENTS.md deletion fail (SOUL.md deletes first alphabetically)
348
+ ops.failingDeletes.add("AGENTS.md");
349
+
350
+ const result = await approve({ ops, config: twoDeleteConfig, hash, vaultOwnership });
351
+
352
+ expect(result.ok).toBe(false);
353
+ if (result.ok) return;
354
+ expect(result.error.kind).toBe("apply_failed");
355
+
356
+ // SOUL.md should be restored from backup
357
+ const soulContent = await ops.readFile("SOUL.md");
358
+ expect(soulContent.ok).toBe(true);
359
+ if (soulContent.ok) expect(soulContent.value).toBe("original soul");
360
+ });
361
+
362
+ test("deleted file with git commits deletion", async () => {
363
+ const ops = new MockSystemOps("/workspace");
364
+ ops.addFile(".git", "");
365
+ ops.addFile("SOUL.md", "original soul", {
366
+ owner: "soulguardian",
367
+ group: "soulguard",
368
+ mode: "444",
369
+ });
370
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
371
+ // No staging/SOUL.md — agent deleted it
372
+ ops.execFailOnCall.set("git diff --cached --quiet", new Set([1]));
373
+
374
+ const gitConfig: SoulguardConfig = { ...config, git: true };
375
+ const hash = await getApprovalHash(ops, gitConfig);
376
+ const result = await approve({ ops, config: gitConfig, hash, vaultOwnership });
377
+
378
+ expect(result.ok).toBe(true);
379
+ if (!result.ok) return;
380
+ expect(result.value.gitResult).toBeDefined();
381
+ expect(result.value.gitResult!.committed).toBe(true);
382
+ // git add should have been called with SOUL.md (stages the deletion)
383
+ const execOps = ops.ops.filter((op) => op.kind === "exec");
384
+ expect(execOps.some((op) => op.kind === "exec" && op.args.includes("SOUL.md"))).toBe(true);
385
+ });
386
+ });