@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
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseConfig } from "./schema.js";
3
+
4
+ describe("soulguardConfigSchema", () => {
5
+ test("parses valid config", () => {
6
+ const config = parseConfig({
7
+ vault: ["SOUL.md", "AGENTS.md"],
8
+ ledger: ["memory/**"],
9
+ });
10
+ expect(config.vault).toEqual(["SOUL.md", "AGENTS.md"]);
11
+ expect(config.ledger).toEqual(["memory/**"]);
12
+ });
13
+
14
+ test("rejects missing vault", () => {
15
+ expect(() => parseConfig({ ledger: [] })).toThrow();
16
+ });
17
+
18
+ test("rejects missing ledger", () => {
19
+ expect(() => parseConfig({ vault: [] })).toThrow();
20
+ });
21
+
22
+ test("accepts empty arrays", () => {
23
+ const config = parseConfig({ vault: [], ledger: [] });
24
+ expect(config.vault).toEqual([]);
25
+ expect(config.ledger).toEqual([]);
26
+ });
27
+ });
package/src/schema.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Soulguard config schema — runtime validation via Zod.
3
+ *
4
+ * The canonical type is SoulguardConfig in types.ts.
5
+ * This schema validates against it at compile time via z.ZodType<T>.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import type { SoulguardConfig } from "./types.js";
10
+
11
+ export const soulguardConfigSchema: z.ZodType<SoulguardConfig> = z.object({
12
+ vault: z.array(z.string()),
13
+ ledger: z.array(z.string()),
14
+ git: z.boolean().optional(),
15
+ });
16
+
17
+ /**
18
+ * Parse and validate a soulguard.json config object.
19
+ */
20
+ export function parseConfig(raw: unknown): SoulguardConfig {
21
+ return soulguardConfigSchema.parse(raw);
22
+ }
@@ -0,0 +1,139 @@
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
+
7
+ const vaultOwnership: FileOwnership = { user: "soulguardian", group: "soulguard", mode: "444" };
8
+
9
+ async function getApprovalHash(ops: MockSystemOps, config: SoulguardConfig): Promise<string> {
10
+ const result = await diff({ ops, config });
11
+ if (!result.ok) throw new Error("diff failed");
12
+ return result.value.approvalHash!;
13
+ }
14
+
15
+ describe("self-protection", () => {
16
+ test("blocks invalid JSON in soulguard.json", async () => {
17
+ const config: SoulguardConfig = { vault: ["soulguard.json"], ledger: [] };
18
+ const ops = new MockSystemOps("/workspace");
19
+ ops.addFile("soulguard.json", '{"vault":["soulguard.json"],"ledger":[]}', {
20
+ owner: "soulguardian",
21
+ group: "soulguard",
22
+ mode: "444",
23
+ });
24
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
25
+ ops.addFile(".soulguard/staging/soulguard.json", "not valid json {{{", {
26
+ owner: "agent",
27
+ group: "soulguard",
28
+ mode: "644",
29
+ });
30
+
31
+ const hash = await getApprovalHash(ops, config);
32
+ const result = await approve({ ops, config, hash, vaultOwnership });
33
+ expect(result.ok).toBe(false);
34
+ if (result.ok) return;
35
+ expect(result.error.kind).toBe("self_protection");
36
+ if (result.error.kind === "self_protection") {
37
+ expect(result.error.message).toContain("not be valid JSON");
38
+ }
39
+ });
40
+
41
+ test("blocks invalid schema in soulguard.json", async () => {
42
+ const config: SoulguardConfig = { vault: ["soulguard.json"], ledger: [] };
43
+ const ops = new MockSystemOps("/workspace");
44
+ ops.addFile("soulguard.json", '{"vault":["soulguard.json"],"ledger":[]}', {
45
+ owner: "soulguardian",
46
+ group: "soulguard",
47
+ mode: "444",
48
+ });
49
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
50
+ // Missing ledger field
51
+ ops.addFile(".soulguard/staging/soulguard.json", '{"vault":["soulguard.json"]}', {
52
+ owner: "agent",
53
+ group: "soulguard",
54
+ mode: "644",
55
+ });
56
+
57
+ const hash = await getApprovalHash(ops, config);
58
+ const result = await approve({ ops, config, hash, vaultOwnership });
59
+ expect(result.ok).toBe(false);
60
+ if (result.ok) return;
61
+ expect(result.error.kind).toBe("self_protection");
62
+ if (result.error.kind === "self_protection") {
63
+ expect(result.error.message).toContain("invalid after this change");
64
+ }
65
+ });
66
+
67
+ test("allows valid soulguard.json changes", async () => {
68
+ const config: SoulguardConfig = { vault: ["soulguard.json"], ledger: [] };
69
+ const ops = new MockSystemOps("/workspace");
70
+ ops.addFile("soulguard.json", '{"vault":["soulguard.json"],"ledger":[]}', {
71
+ owner: "soulguardian",
72
+ group: "soulguard",
73
+ mode: "444",
74
+ });
75
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
76
+ ops.addFile(
77
+ ".soulguard/staging/soulguard.json",
78
+ '{"vault":["soulguard.json","SOUL.md"],"ledger":["memory/**"]}',
79
+ { owner: "agent", group: "soulguard", mode: "644" },
80
+ );
81
+
82
+ const hash = await getApprovalHash(ops, config);
83
+ const result = await approve({ ops, config, hash, vaultOwnership });
84
+ expect(result.ok).toBe(true);
85
+ });
86
+
87
+ test("does not run when soulguard.json is not being changed", async () => {
88
+ const config: SoulguardConfig = { vault: ["SOUL.md", "soulguard.json"], ledger: [] };
89
+ const ops = new MockSystemOps("/workspace");
90
+ ops.addFile("SOUL.md", "original", {
91
+ owner: "soulguardian",
92
+ group: "soulguard",
93
+ mode: "444",
94
+ });
95
+ ops.addFile("soulguard.json", '{"vault":["SOUL.md","soulguard.json"],"ledger":[]}', {
96
+ owner: "soulguardian",
97
+ group: "soulguard",
98
+ mode: "444",
99
+ });
100
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
101
+ ops.addFile(".soulguard/staging/SOUL.md", "modified", {
102
+ owner: "agent",
103
+ group: "soulguard",
104
+ mode: "644",
105
+ });
106
+ ops.addFile(
107
+ ".soulguard/staging/soulguard.json",
108
+ '{"vault":["SOUL.md","soulguard.json"],"ledger":[]}',
109
+ { owner: "agent", group: "soulguard", mode: "644" },
110
+ );
111
+
112
+ const hash = await getApprovalHash(ops, config);
113
+ const result = await approve({ ops, config, hash, vaultOwnership });
114
+ expect(result.ok).toBe(true);
115
+ });
116
+
117
+ test("self-protection cannot be bypassed with empty policies", async () => {
118
+ const config: SoulguardConfig = { vault: ["soulguard.json"], ledger: [] };
119
+ const ops = new MockSystemOps("/workspace");
120
+ ops.addFile("soulguard.json", '{"vault":["soulguard.json"],"ledger":[]}', {
121
+ owner: "soulguardian",
122
+ group: "soulguard",
123
+ mode: "444",
124
+ });
125
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
126
+ ops.addFile(".soulguard/staging/soulguard.json", "not json", {
127
+ owner: "agent",
128
+ group: "soulguard",
129
+ mode: "644",
130
+ });
131
+
132
+ const hash = await getApprovalHash(ops, config);
133
+ // Explicitly pass empty policies — self-protection still runs
134
+ const result = await approve({ ops, config, hash, vaultOwnership, policies: [] });
135
+ expect(result.ok).toBe(false);
136
+ if (result.ok) return;
137
+ expect(result.error.kind).toBe("self_protection");
138
+ });
139
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Built-in self-protection for soulguard approve.
3
+ *
4
+ * These checks are HARDCODED and run on every approval — they cannot be
5
+ * bypassed by passing an empty policies array. This prevents soulguard
6
+ * from being bricked through its own approval flow.
7
+ *
8
+ * Current checks:
9
+ * - soulguard.json cannot be deleted
10
+ * - If soulguard.json is being changed, the new content must be valid config
11
+ */
12
+
13
+ import type { Result } from "./types.js";
14
+ import type { ApprovalError } from "./approve.js";
15
+ import type { FileDiff } from "./diff.js";
16
+ import { soulguardConfigSchema } from "./schema.js";
17
+ import { ok, err } from "./result.js";
18
+
19
+ /**
20
+ * Validate built-in self-protection rules against pending changes.
21
+ * Takes a map of path → content for content files, and a list of deleted files.
22
+ * Returns an ApprovalError if any check fails.
23
+ */
24
+ export function validateSelfProtection(
25
+ pendingContents: Map<string, string>,
26
+ deletedFiles: FileDiff[] = [],
27
+ ): Result<void, ApprovalError> {
28
+ // Block deletion of soulguard.json — config must always exist
29
+ if (deletedFiles.some((f) => f.path === "soulguard.json")) {
30
+ return err({
31
+ kind: "self_protection",
32
+ message: "Cannot delete soulguard.json — it is required for soulguard to function",
33
+ });
34
+ }
35
+
36
+ const sgContent = pendingContents.get("soulguard.json");
37
+ if (sgContent === undefined) {
38
+ return ok(undefined);
39
+ }
40
+
41
+ // Parse as JSON
42
+ let parsed: unknown;
43
+ try {
44
+ parsed = JSON.parse(sgContent);
45
+ } catch {
46
+ return err({
47
+ kind: "self_protection",
48
+ message: "soulguard.json would not be valid JSON after this change",
49
+ });
50
+ }
51
+
52
+ // Validate against config schema
53
+ const result = soulguardConfigSchema.safeParse(parsed);
54
+ if (!result.success) {
55
+ const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
56
+ return err({
57
+ kind: "self_protection",
58
+ message: `soulguard.json would be invalid after this change: ${issues}`,
59
+ });
60
+ }
61
+
62
+ return ok(undefined);
63
+ }
@@ -0,0 +1,241 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { status } from "./status.js";
3
+ import { MockSystemOps } from "./system-ops-mock.js";
4
+ import type { FileStatus } from "./status.js";
5
+ import { formatIssue } from "./types.js";
6
+ import type { DriftIssue } from "./types.js";
7
+
8
+ const WORKSPACE = "/test/workspace";
9
+ const VAULT_OWNERSHIP = { user: "soulguardian", group: "soulguard", mode: "444" };
10
+ const LEDGER_OWNERSHIP = { user: "aster", group: "staff", mode: "644" };
11
+
12
+ function makeMock() {
13
+ const ops = new MockSystemOps(WORKSPACE);
14
+ ops.addUser(VAULT_OWNERSHIP.user);
15
+ ops.addGroup(VAULT_OWNERSHIP.group);
16
+ return ops;
17
+ }
18
+
19
+ function opts(config: { vault: string[]; ledger: string[] }, ops: MockSystemOps) {
20
+ return {
21
+ config,
22
+ expectedVaultOwnership: VAULT_OWNERSHIP,
23
+ expectedLedgerOwnership: LEDGER_OWNERSHIP,
24
+ ops,
25
+ };
26
+ }
27
+
28
+ describe("status", () => {
29
+ test("reports ok when vault file is correct", async () => {
30
+ const ops = makeMock();
31
+ ops.addFile("SOUL.md", "# Soul", {
32
+ owner: VAULT_OWNERSHIP.user,
33
+ group: VAULT_OWNERSHIP.group,
34
+ mode: "444",
35
+ });
36
+
37
+ const result = await status(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
38
+
39
+ expect(result.ok).toBe(true);
40
+ if (!result.ok) return;
41
+
42
+ expect(result.value.vault).toHaveLength(1);
43
+ expect(result.value.vault[0]!.status).toBe("ok");
44
+ expect(result.value.issues).toHaveLength(0);
45
+ });
46
+
47
+ test("reports drifted with semantic issues when vault file has wrong owner", async () => {
48
+ const ops = makeMock();
49
+ ops.addFile("SOUL.md", "# Soul", {
50
+ owner: "agent",
51
+ group: VAULT_OWNERSHIP.group,
52
+ mode: "444",
53
+ });
54
+
55
+ const result = await status(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
56
+
57
+ expect(result.ok).toBe(true);
58
+ if (!result.ok) return;
59
+
60
+ const file = result.value.vault[0]! as FileStatus & { status: "drifted" };
61
+ expect(file.status).toBe("drifted");
62
+ expect(file.issues).toContainEqual({
63
+ kind: "wrong_owner",
64
+ expected: VAULT_OWNERSHIP.user,
65
+ actual: "agent",
66
+ });
67
+ expect(result.value.issues).toHaveLength(1);
68
+ });
69
+
70
+ test("reports drifted when vault file has wrong mode", async () => {
71
+ const ops = makeMock();
72
+ ops.addFile("SOUL.md", "# Soul", {
73
+ owner: VAULT_OWNERSHIP.user,
74
+ group: VAULT_OWNERSHIP.group,
75
+ mode: "644",
76
+ });
77
+
78
+ const result = await status(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
79
+
80
+ expect(result.ok).toBe(true);
81
+ if (!result.ok) return;
82
+
83
+ const file = result.value.vault[0]! as FileStatus & { status: "drifted" };
84
+ expect(file.status).toBe("drifted");
85
+ expect(file.issues).toContainEqual({
86
+ kind: "wrong_mode",
87
+ expected: "444",
88
+ actual: "644",
89
+ });
90
+ });
91
+
92
+ test("reports missing vault files", async () => {
93
+ const ops = makeMock();
94
+
95
+ const result = await status(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
96
+
97
+ expect(result.ok).toBe(true);
98
+ if (!result.ok) return;
99
+
100
+ expect(result.value.vault[0]!.status).toBe("missing");
101
+ expect(result.value.issues).toHaveLength(1);
102
+ });
103
+
104
+ test("includes hashes in FileInfo for ok files", async () => {
105
+ const ops = makeMock();
106
+ ops.addFile("SOUL.md", "# Soul", {
107
+ owner: VAULT_OWNERSHIP.user,
108
+ group: VAULT_OWNERSHIP.group,
109
+ mode: "444",
110
+ });
111
+
112
+ const result = await status(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
113
+
114
+ expect(result.ok).toBe(true);
115
+ if (!result.ok) return;
116
+
117
+ const file = result.value.vault[0]! as FileStatus & { status: "ok" };
118
+ expect(file.file.hash).toMatch(/^[a-f0-9]{64}$/);
119
+ });
120
+
121
+ test("resolves glob patterns to matching files", async () => {
122
+ const ops = makeMock();
123
+ ops.addFile("memory/day1.md", "notes", {
124
+ owner: VAULT_OWNERSHIP.user,
125
+ group: VAULT_OWNERSHIP.group,
126
+ mode: VAULT_OWNERSHIP.mode,
127
+ });
128
+ ops.addFile("skills/python.md", "skill", {
129
+ owner: LEDGER_OWNERSHIP.user,
130
+ group: LEDGER_OWNERSHIP.group,
131
+ mode: LEDGER_OWNERSHIP.mode,
132
+ });
133
+
134
+ const result = await status(opts({ vault: ["memory/**"], ledger: ["skills/**"] }, ops));
135
+
136
+ expect(result.ok).toBe(true);
137
+ if (!result.ok) return;
138
+
139
+ expect(result.value.vault).toHaveLength(1);
140
+ expect(result.value.vault[0]!.status).toBe("ok");
141
+ expect(result.value.ledger).toHaveLength(1);
142
+ expect(result.value.ledger[0]!.status).toBe("ok");
143
+ expect(result.value.issues).toHaveLength(0);
144
+ });
145
+
146
+ test("glob with no matches returns empty", async () => {
147
+ const ops = makeMock();
148
+
149
+ const result = await status(opts({ vault: ["memory/**"], ledger: ["skills/**"] }, ops));
150
+
151
+ expect(result.ok).toBe(true);
152
+ if (!result.ok) return;
153
+
154
+ expect(result.value.vault).toHaveLength(0);
155
+ expect(result.value.ledger).toHaveLength(0);
156
+ });
157
+
158
+ test("reports multiple semantic issues on same file", async () => {
159
+ const ops = makeMock();
160
+ ops.addFile("SOUL.md", "# Soul", {
161
+ owner: "agent",
162
+ group: "staff",
163
+ mode: "777",
164
+ });
165
+
166
+ const result = await status(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
167
+
168
+ expect(result.ok).toBe(true);
169
+ if (!result.ok) return;
170
+
171
+ const file = result.value.vault[0]! as FileStatus & { status: "drifted" };
172
+ expect(file.issues).toHaveLength(3);
173
+ expect(file.issues.map((i) => i.kind)).toEqual(["wrong_owner", "wrong_group", "wrong_mode"]);
174
+ });
175
+
176
+ test("issues array contains all problems from both tiers", async () => {
177
+ const ops = makeMock();
178
+ ops.addFile("SOUL.md", "# Soul", {
179
+ owner: "agent",
180
+ group: VAULT_OWNERSHIP.group,
181
+ mode: "444",
182
+ });
183
+
184
+ const result = await status(opts({ vault: ["SOUL.md"], ledger: ["notes.md"] }, ops));
185
+
186
+ expect(result.ok).toBe(true);
187
+ if (!result.ok) return;
188
+
189
+ expect(result.value.issues).toHaveLength(2);
190
+ });
191
+
192
+ test("ledger ok files include FileInfo", async () => {
193
+ const ops = makeMock();
194
+ ops.addFile("notes.md", "# Notes", {
195
+ owner: LEDGER_OWNERSHIP.user,
196
+ group: LEDGER_OWNERSHIP.group,
197
+ mode: "644",
198
+ });
199
+
200
+ const result = await status(opts({ vault: [], ledger: ["notes.md"] }, ops));
201
+
202
+ expect(result.ok).toBe(true);
203
+ if (!result.ok) return;
204
+
205
+ const file = result.value.ledger[0]! as FileStatus & { status: "ok" };
206
+ expect(file.status).toBe("ok");
207
+ expect(file.file.hash).toMatch(/^[a-f0-9]{64}$/);
208
+ expect(file.file.ownership.user).toBe(LEDGER_OWNERSHIP.user);
209
+ });
210
+
211
+ test("ledger file owned by guardian reports drift", async () => {
212
+ const ops = makeMock();
213
+ ops.addFile("notes.md", "# Notes", {
214
+ owner: VAULT_OWNERSHIP.user,
215
+ group: VAULT_OWNERSHIP.group,
216
+ mode: "444",
217
+ });
218
+
219
+ const result = await status(opts({ vault: [], ledger: ["notes.md"] }, ops));
220
+
221
+ expect(result.ok).toBe(true);
222
+ if (!result.ok) return;
223
+
224
+ const file = result.value.ledger[0]! as FileStatus & { status: "drifted" };
225
+ expect(file.status).toBe("drifted");
226
+ expect(file.issues).toContainEqual({
227
+ kind: "wrong_owner",
228
+ expected: LEDGER_OWNERSHIP.user,
229
+ actual: VAULT_OWNERSHIP.user,
230
+ });
231
+ });
232
+
233
+ test("formatIssue produces readable strings", () => {
234
+ const issues: DriftIssue[] = [
235
+ { kind: "wrong_owner", expected: "soulguardian", actual: "agent" },
236
+ { kind: "wrong_mode", expected: "444", actual: "644" },
237
+ ];
238
+ expect(formatIssue(issues[0]!)).toBe("owner is agent, expected soulguardian");
239
+ expect(formatIssue(issues[1]!)).toBe("mode is 644, expected 444");
240
+ });
241
+ });
package/src/status.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * soulguard status — report the current protection state of a workspace.
3
+ */
4
+
5
+ import type {
6
+ SoulguardConfig,
7
+ FileInfo,
8
+ FileOwnership,
9
+ Tier,
10
+ DriftIssue,
11
+ FileSystemError,
12
+ IOError,
13
+ Result,
14
+ } from "./types.js";
15
+ import { ok } from "./result.js";
16
+ import type { SystemOperations } from "./system-ops.js";
17
+ import { getFileInfo } from "./system-ops.js";
18
+ import { resolvePatterns } from "./glob.js";
19
+
20
+ // ── File status ────────────────────────────────────────────────────────
21
+
22
+ export type FileStatus =
23
+ | { tier: Tier; status: "ok"; file: FileInfo }
24
+ | { tier: Tier; status: "drifted"; file: FileInfo; issues: DriftIssue[] }
25
+ | { tier: Tier; status: "missing"; path: string }
26
+ | { tier: Tier; status: "error"; path: string; error: FileSystemError };
27
+
28
+ export type StatusResult = {
29
+ vault: FileStatus[];
30
+ ledger: FileStatus[];
31
+ /** All non-ok statuses from both tiers, for convenience */
32
+ issues: FileStatus[];
33
+ };
34
+
35
+ export type StatusOptions = {
36
+ config: SoulguardConfig;
37
+ /** Expected ownership for vault files (e.g. soulguardian:soulguard 444) */
38
+ expectedVaultOwnership: FileOwnership;
39
+ /** Expected ownership for ledger files (e.g. agent:staff 644) */
40
+ expectedLedgerOwnership: FileOwnership;
41
+ ops: SystemOperations;
42
+ };
43
+
44
+ /**
45
+ * Check the protection status of all configured files.
46
+ */
47
+ export async function status(options: StatusOptions): Promise<Result<StatusResult, IOError>> {
48
+ const { config, expectedVaultOwnership, expectedLedgerOwnership, ops } = options;
49
+
50
+ // Resolve glob patterns to concrete file paths
51
+ const [vaultResult, ledgerResult] = await Promise.all([
52
+ resolvePatterns(ops, config.vault),
53
+ resolvePatterns(ops, config.ledger),
54
+ ]);
55
+ if (!vaultResult.ok) return vaultResult;
56
+ if (!ledgerResult.ok) return ledgerResult;
57
+ const vaultPaths = vaultResult.value;
58
+ const ledgerPaths = ledgerResult.value;
59
+
60
+ const [vault, ledger] = await Promise.all([
61
+ Promise.all(vaultPaths.map((path) => checkPath(path, "vault", expectedVaultOwnership, ops))),
62
+ Promise.all(ledgerPaths.map((path) => checkPath(path, "ledger", expectedLedgerOwnership, ops))),
63
+ ]);
64
+
65
+ const issues = [...vault, ...ledger].filter((f) => f.status !== "ok");
66
+
67
+ return ok({ vault, ledger, issues });
68
+ }
69
+
70
+ async function checkPath(
71
+ filePath: string,
72
+ tier: Tier,
73
+ expectedOwnership: FileOwnership,
74
+ ops: SystemOperations,
75
+ ): Promise<FileStatus> {
76
+ const infoResult = await getFileInfo(filePath, ops);
77
+
78
+ if (!infoResult.ok) {
79
+ if (infoResult.error.kind === "not_found") {
80
+ return { tier, status: "missing", path: filePath };
81
+ }
82
+ return { tier, status: "error", path: filePath, error: infoResult.error };
83
+ }
84
+
85
+ const file = infoResult.value;
86
+ const issues: DriftIssue[] = [];
87
+
88
+ if (file.ownership.user !== expectedOwnership.user) {
89
+ issues.push({
90
+ kind: "wrong_owner",
91
+ expected: expectedOwnership.user,
92
+ actual: file.ownership.user,
93
+ });
94
+ }
95
+ if (file.ownership.group !== expectedOwnership.group) {
96
+ issues.push({
97
+ kind: "wrong_group",
98
+ expected: expectedOwnership.group,
99
+ actual: file.ownership.group,
100
+ });
101
+ }
102
+ if (file.ownership.mode !== expectedOwnership.mode) {
103
+ issues.push({
104
+ kind: "wrong_mode",
105
+ expected: expectedOwnership.mode,
106
+ actual: file.ownership.mode,
107
+ });
108
+ }
109
+
110
+ if (issues.length === 0) {
111
+ return { tier, status: "ok", file };
112
+ }
113
+ return { tier, status: "drifted", file, issues };
114
+ }