@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/src/init.ts ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * soulguard init — one-time workspace setup.
3
+ *
4
+ * Creates system user/group, writes config, syncs vault files,
5
+ * creates staging copies, generates scoped sudoers.
6
+ *
7
+ * Idempotent: skips already-completed steps, reports what was done.
8
+ * Requires root (sudo).
9
+ */
10
+
11
+ import type { SyncResult } from "./sync.js";
12
+ import type { SystemOperations } from "./system-ops.js";
13
+ import type { SoulguardConfig, SystemIdentity, FileOwnership, Result, IOError } from "./types.js";
14
+ import { ok, err } from "./result.js";
15
+ import { sync } from "./sync.js";
16
+ import { DEFAULT_CONFIG } from "./constants.js";
17
+ import { resolvePatterns } from "./glob.js";
18
+
19
+ /** Result of `soulguard init` — idempotent, booleans report what was done */
20
+ export type InitResult = {
21
+ /** Whether the system user was created (false if it already existed) */
22
+ userCreated: boolean;
23
+ /** Whether the system group was created (false if it already existed) */
24
+ groupCreated: boolean;
25
+ /** Whether soulguard.json was written (false if it already existed) */
26
+ configCreated: boolean;
27
+ /** Whether the sudoers file was written */
28
+ sudoersCreated: boolean;
29
+ /** Whether staging directory was created */
30
+ stagingCreated: boolean;
31
+ /** Whether git was initialized (false if already existed or git disabled) */
32
+ gitInitialized: boolean;
33
+ /** Whether .gitignore was updated with staging entry */
34
+ gitignoreUpdated: boolean;
35
+ /** Sync result from the initial sync after setup */
36
+ syncResult: SyncResult;
37
+ };
38
+
39
+ /** Errors specific to init */
40
+ export type InitError =
41
+ | { kind: "not_root"; message: string }
42
+ | { kind: "user_creation_failed"; message: string }
43
+ | { kind: "group_creation_failed"; message: string }
44
+ | { kind: "config_write_failed"; message: string }
45
+ | { kind: "sudoers_write_failed"; message: string }
46
+ | { kind: "staging_failed"; message: string }
47
+ | { kind: "git_failed"; message: string };
48
+
49
+ /** Write content to an absolute path (outside workspace). Used for sudoers. */
50
+ export type AbsoluteWriter = (path: string, content: string) => Promise<Result<void, IOError>>;
51
+
52
+ /** Check if an absolute path exists (outside workspace). */
53
+ export type AbsoluteExists = (path: string) => Promise<boolean>;
54
+
55
+ export type InitOptions = {
56
+ ops: SystemOperations;
57
+ identity: SystemIdentity;
58
+ config?: SoulguardConfig;
59
+ /** Agent's OS username (for sudoers and staging ownership) */
60
+ agentUser: string;
61
+ /** Writer for files outside the workspace (sudoers). Keeps SystemOperations clean. */
62
+ writeAbsolute: AbsoluteWriter;
63
+ /** Check if absolute path exists. Used for sudoers idempotency. */
64
+ existsAbsolute: AbsoluteExists;
65
+ /** Path to write sudoers file (default: /etc/sudoers.d/soulguard) */
66
+ sudoersPath?: string;
67
+ /** @internal Skip root check (for testing only) */
68
+ _skipRootCheck?: boolean;
69
+ };
70
+
71
+ /** Generate scoped sudoers content */
72
+ export function generateSudoers(agentUser: string, soulguardBin: string): string {
73
+ const cmds = ["sync", "stage", "status", "diff"].map((cmd) => `${soulguardBin} ${cmd} *`);
74
+ return `# Soulguard — scoped sudo for agent user\n${agentUser} ALL=(root) NOPASSWD: ${cmds.join(", ")}\n`;
75
+ }
76
+
77
+ const DEFAULT_SUDOERS_PATH = "/etc/sudoers.d/soulguard";
78
+ const SOULGUARD_BIN = "/usr/local/bin/soulguard";
79
+
80
+ export async function init(options: InitOptions): Promise<Result<InitResult, InitError>> {
81
+ const {
82
+ ops,
83
+ identity,
84
+ config: configOption,
85
+ agentUser,
86
+ writeAbsolute,
87
+ existsAbsolute,
88
+ sudoersPath = DEFAULT_SUDOERS_PATH,
89
+ } = options;
90
+ const config = configOption ?? DEFAULT_CONFIG;
91
+
92
+ // ── 0. Check root ─────────────────────────────────────────────────────
93
+ if (
94
+ !options._skipRootCheck &&
95
+ typeof process !== "undefined" &&
96
+ typeof process.getuid === "function" &&
97
+ process.getuid() !== 0
98
+ ) {
99
+ return err({ kind: "not_root", message: "soulguard init requires root. Run with sudo." });
100
+ }
101
+
102
+ // ── 1. Create group ──────────────────────────────────────────────────
103
+ let groupCreated = false;
104
+ const groupExists = await ops.groupExists(identity.group);
105
+ if (!groupExists.ok) {
106
+ return err({
107
+ kind: "group_creation_failed",
108
+ message: `check failed: ${groupExists.error.message}`,
109
+ });
110
+ }
111
+ if (!groupExists.value) {
112
+ const result = await ops.createGroup(identity.group);
113
+ if (!result.ok) {
114
+ return err({ kind: "group_creation_failed", message: result.error.message });
115
+ }
116
+ groupCreated = true;
117
+ }
118
+
119
+ // ── 2. Create user ───────────────────────────────────────────────────
120
+ let userCreated = false;
121
+ const userExists = await ops.userExists(identity.user);
122
+ if (!userExists.ok) {
123
+ return err({
124
+ kind: "user_creation_failed",
125
+ message: `check failed: ${userExists.error.message}`,
126
+ });
127
+ }
128
+ if (!userExists.value) {
129
+ const result = await ops.createUser(identity.user, identity.group);
130
+ if (!result.ok) {
131
+ return err({ kind: "user_creation_failed", message: result.error.message });
132
+ }
133
+ userCreated = true;
134
+ }
135
+
136
+ // ── 3. Write config ──────────────────────────────────────────────────
137
+ let configCreated = false;
138
+ const configExists = await ops.exists("soulguard.json");
139
+ if (!configExists.ok) {
140
+ return err({ kind: "config_write_failed", message: configExists.error.message });
141
+ }
142
+ if (!configExists.value) {
143
+ const content = JSON.stringify(config, null, 2) + "\n";
144
+ const result = await ops.writeFile("soulguard.json", content);
145
+ if (!result.ok) {
146
+ return err({ kind: "config_write_failed", message: `write failed: ${result.error.kind}` });
147
+ }
148
+ configCreated = true;
149
+ }
150
+
151
+ // ── 4. Sync vault files ──────────────────────────────────────────────
152
+ const vaultOwnership: FileOwnership = {
153
+ user: identity.user,
154
+ group: identity.group,
155
+ mode: "444",
156
+ };
157
+ const ledgerOwnership: FileOwnership = {
158
+ user: agentUser,
159
+ group: identity.group,
160
+ mode: "644",
161
+ };
162
+
163
+ const syncResult = await sync({
164
+ config,
165
+ expectedVaultOwnership: vaultOwnership,
166
+ expectedLedgerOwnership: ledgerOwnership,
167
+ ops,
168
+ });
169
+ if (!syncResult.ok) {
170
+ // sync currently never errors at the Result level, but handle it
171
+ return err({ kind: "config_write_failed", message: "sync failed unexpectedly" });
172
+ }
173
+
174
+ // ── 5. Create staging ────────────────────────────────────────────────
175
+ // Always (re)create staging — idempotent, self-healing on partial state
176
+ const stagingCreated = true;
177
+ const mkdirResult = await ops.mkdir(".soulguard/staging");
178
+ if (!mkdirResult.ok) {
179
+ return err({ kind: "staging_failed", message: `mkdir failed: ${mkdirResult.error.kind}` });
180
+ }
181
+ // .soulguard/ owned by soulguardian — agent CANNOT create/delete files here.
182
+ // Only staging/ is agent-writable.
183
+ const chownSg = await ops.chown(".soulguard", { user: identity.user, group: identity.group });
184
+ if (!chownSg.ok) {
185
+ return err({
186
+ kind: "staging_failed",
187
+ message: `chown .soulguard failed: ${chownSg.error.kind}`,
188
+ });
189
+ }
190
+ const chmodSg = await ops.chmod(".soulguard", "755");
191
+ if (!chmodSg.ok) {
192
+ return err({
193
+ kind: "staging_failed",
194
+ message: `chmod .soulguard failed: ${chmodSg.error.kind}`,
195
+ });
196
+ }
197
+ const chownStaging = await ops.chown(".soulguard/staging", {
198
+ user: agentUser,
199
+ group: identity.group,
200
+ });
201
+ if (!chownStaging.ok) {
202
+ return err({
203
+ kind: "staging_failed",
204
+ message: `chown staging failed: ${chownStaging.error.kind}`,
205
+ });
206
+ }
207
+ const chmodStaging = await ops.chmod(".soulguard/staging", "755");
208
+ if (!chmodStaging.ok) {
209
+ return err({
210
+ kind: "staging_failed",
211
+ message: `chmod staging failed: ${chmodStaging.error.kind}`,
212
+ });
213
+ }
214
+ // Copy vault files to staging (resolve globs first)
215
+ const vaultGlob = await resolvePatterns(ops, config.vault);
216
+ if (!vaultGlob.ok) {
217
+ return err({ kind: "staging_failed", message: `glob failed: ${vaultGlob.error.message}` });
218
+ }
219
+ const vaultFiles = vaultGlob.value;
220
+ for (const vaultFile of vaultFiles) {
221
+ const fileExists = await ops.exists(vaultFile);
222
+ if (fileExists.ok && fileExists.value) {
223
+ const copyResult = await ops.copyFile(vaultFile, `.soulguard/staging/${vaultFile}`);
224
+ if (!copyResult.ok) {
225
+ return err({
226
+ kind: "staging_failed",
227
+ message: `copy ${vaultFile} failed: ${copyResult.error.kind}`,
228
+ });
229
+ }
230
+ // Make staging copy agent-writable
231
+ const chownFile = await ops.chown(`.soulguard/staging/${vaultFile}`, {
232
+ user: agentUser,
233
+ group: identity.group,
234
+ });
235
+ if (!chownFile.ok) {
236
+ return err({
237
+ kind: "staging_failed",
238
+ message: `chown staging/${vaultFile} failed: ${chownFile.error.kind}`,
239
+ });
240
+ }
241
+ const chmodFile = await ops.chmod(`.soulguard/staging/${vaultFile}`, "644");
242
+ if (!chmodFile.ok) {
243
+ return err({
244
+ kind: "staging_failed",
245
+ message: `chmod staging/${vaultFile} failed: ${chmodFile.error.kind}`,
246
+ });
247
+ }
248
+ }
249
+ }
250
+
251
+ // ── 6. Git integration ────────────────────────────────────────────────
252
+ let gitInitialized = false;
253
+ let gitignoreUpdated = false;
254
+ if (config.git !== false) {
255
+ // Check if .git exists
256
+ const gitExists = await ops.exists(".git");
257
+ if (gitExists.ok && !gitExists.value) {
258
+ const gitResult = await ops.exec("git", ["init"]);
259
+ if (!gitResult.ok) {
260
+ return err({ kind: "git_failed", message: gitResult.error.message });
261
+ }
262
+ gitInitialized = true;
263
+ }
264
+
265
+ // Ensure .gitignore contains .soulguard/ (staging, pending, backup are all internal)
266
+ const soulguardEntry = ".soulguard/";
267
+ const gitignoreExists = await ops.exists(".gitignore");
268
+ if (gitignoreExists.ok && gitignoreExists.value) {
269
+ const content = await ops.readFile(".gitignore");
270
+ if (content.ok) {
271
+ const lines = content.value.split("\n");
272
+ if (!lines.some((line) => line.trim() === soulguardEntry)) {
273
+ const newContent = content.value.endsWith("\n")
274
+ ? content.value + soulguardEntry + "\n"
275
+ : content.value + "\n" + soulguardEntry + "\n";
276
+ const writeResult = await ops.writeFile(".gitignore", newContent);
277
+ if (!writeResult.ok) {
278
+ return err({
279
+ kind: "git_failed",
280
+ message: `write .gitignore: ${writeResult.error.kind}`,
281
+ });
282
+ }
283
+ gitignoreUpdated = true;
284
+ }
285
+ }
286
+ } else {
287
+ const writeResult = await ops.writeFile(".gitignore", soulguardEntry + "\n");
288
+ if (!writeResult.ok) {
289
+ return err({ kind: "git_failed", message: `create .gitignore: ${writeResult.error.kind}` });
290
+ }
291
+ gitignoreUpdated = true;
292
+ }
293
+ }
294
+
295
+ // ── 7. Write sudoers ─ ─────────────────────────────────────────────────
296
+ let sudoersCreated = false;
297
+ const sudoersAlreadyExists = await existsAbsolute(sudoersPath);
298
+ if (!sudoersAlreadyExists) {
299
+ const sudoersContent = generateSudoers(agentUser, SOULGUARD_BIN);
300
+ const sudoersResult = await writeAbsolute(sudoersPath, sudoersContent);
301
+ if (!sudoersResult.ok) {
302
+ return err({ kind: "sudoers_write_failed", message: sudoersResult.error.message });
303
+ }
304
+ sudoersCreated = true;
305
+ }
306
+
307
+ return ok({
308
+ userCreated,
309
+ groupCreated,
310
+ configCreated,
311
+ sudoersCreated,
312
+ stagingCreated,
313
+ gitInitialized,
314
+ gitignoreUpdated,
315
+ syncResult: syncResult.value,
316
+ });
317
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validatePolicies, evaluatePolicies } from "./policy.js";
3
+ import type { Policy, ApprovalContext } from "./policy.js";
4
+ import { ok, err } from "./result.js";
5
+
6
+ function makeCtx(files: Record<string, string>): ApprovalContext {
7
+ const ctx: ApprovalContext = new Map();
8
+ for (const [path, content] of Object.entries(files)) {
9
+ ctx.set(path, { final: content, diff: "", previous: "" });
10
+ }
11
+ return ctx;
12
+ }
13
+
14
+ describe("validatePolicies", () => {
15
+ test("accepts unique names", () => {
16
+ const result = validatePolicies([
17
+ { name: "policy-a", check: () => ok(undefined) },
18
+ { name: "policy-b", check: () => ok(undefined) },
19
+ ]);
20
+ expect(result.ok).toBe(true);
21
+ });
22
+
23
+ test("rejects duplicate names", () => {
24
+ const result = validatePolicies([
25
+ { name: "policy-a", check: () => ok(undefined) },
26
+ { name: "policy-a", check: () => ok(undefined) },
27
+ ]);
28
+ expect(result.ok).toBe(false);
29
+ if (result.ok) return;
30
+ expect(result.error.kind).toBe("policy_name_collision");
31
+ expect(result.error.duplicates).toEqual(["policy-a"]);
32
+ });
33
+
34
+ test("reports all duplicates", () => {
35
+ const result = validatePolicies([
36
+ { name: "a", check: () => ok(undefined) },
37
+ { name: "b", check: () => ok(undefined) },
38
+ { name: "a", check: () => ok(undefined) },
39
+ { name: "b", check: () => ok(undefined) },
40
+ ]);
41
+ expect(result.ok).toBe(false);
42
+ if (result.ok) return;
43
+ expect(result.error.duplicates).toEqual(["a", "b"]);
44
+ });
45
+
46
+ test("accepts empty array", () => {
47
+ const result = validatePolicies([]);
48
+ expect(result.ok).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe("evaluatePolicies", () => {
53
+ test("passes when all policies pass", async () => {
54
+ const policies: Policy[] = [
55
+ { name: "always-ok", check: () => ok(undefined) },
56
+ { name: "also-ok", check: () => ok(undefined) },
57
+ ];
58
+ const result = await evaluatePolicies(policies, makeCtx({ "SOUL.md": "content" }));
59
+ expect(result.ok).toBe(true);
60
+ });
61
+
62
+ test("fails with violation when policy rejects", async () => {
63
+ const policies: Policy[] = [{ name: "strict", check: () => err("not allowed") }];
64
+ const result = await evaluatePolicies(policies, makeCtx({ "SOUL.md": "content" }));
65
+ expect(result.ok).toBe(false);
66
+ if (result.ok) return;
67
+ expect(result.error.violations).toHaveLength(1);
68
+ expect(result.error.violations[0]!.policy).toBe("strict");
69
+ expect(result.error.violations[0]!.message).toBe("not allowed");
70
+ });
71
+
72
+ test("evaluates ALL policies (no short-circuit)", async () => {
73
+ const policies: Policy[] = [
74
+ { name: "first", check: () => err("fail 1") },
75
+ { name: "second", check: () => err("fail 2") },
76
+ { name: "third", check: () => ok(undefined) },
77
+ ];
78
+ const result = await evaluatePolicies(policies, makeCtx({ "SOUL.md": "content" }));
79
+ expect(result.ok).toBe(false);
80
+ if (result.ok) return;
81
+ expect(result.error.violations).toHaveLength(2);
82
+ expect(result.error.violations[0]!.policy).toBe("first");
83
+ expect(result.error.violations[1]!.policy).toBe("second");
84
+ });
85
+
86
+ test("supports async policy checks", async () => {
87
+ const policies: Policy[] = [
88
+ {
89
+ name: "async-check",
90
+ check: async (ctx) => {
91
+ const soul = ctx.get("SOUL.md");
92
+ if (soul?.final.includes("evil")) return err("evil content");
93
+ return ok(undefined);
94
+ },
95
+ },
96
+ ];
97
+ const passResult = await evaluatePolicies(policies, makeCtx({ "SOUL.md": "good content" }));
98
+ expect(passResult.ok).toBe(true);
99
+
100
+ const failResult = await evaluatePolicies(policies, makeCtx({ "SOUL.md": "evil content" }));
101
+ expect(failResult.ok).toBe(false);
102
+ });
103
+
104
+ test("receives correct context", async () => {
105
+ let captured: ApprovalContext | undefined;
106
+ const policies: Policy[] = [
107
+ {
108
+ name: "capture",
109
+ check: (ctx) => {
110
+ captured = ctx;
111
+ return ok(undefined);
112
+ },
113
+ },
114
+ ];
115
+ const ctx = new Map([
116
+ ["SOUL.md", { final: "new soul", diff: "some diff", previous: "old soul" }],
117
+ ]);
118
+ await evaluatePolicies(policies, ctx);
119
+ expect(captured).toBeDefined();
120
+ expect(captured!.get("SOUL.md")!.final).toBe("new soul");
121
+ expect(captured!.get("SOUL.md")!.previous).toBe("old soul");
122
+ });
123
+ });
package/src/policy.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Policy hooks for soulguard approve.
3
+ *
4
+ * Policies are named check functions that run before vault changes are applied.
5
+ * Each receives an ApprovalContext (map of file path → { final, diff, previous })
6
+ * and returns ok() to allow or err() to block.
7
+ *
8
+ * Multiple policies can be registered. All are evaluated — if any fail,
9
+ * the approval is blocked and all violations are reported.
10
+ */
11
+
12
+ import type { Result } from "./result.js";
13
+ import { ok, err } from "./result.js";
14
+
15
+ // ── Types ──────────────────────────────────────────────────────────────
16
+
17
+ /** Context passed to policy check functions. */
18
+ export type ApprovalContext = Map<
19
+ string,
20
+ {
21
+ /** Content that would be applied (staging content) */
22
+ final: string;
23
+ /** Unified diff string (vault → staging) */
24
+ diff: string;
25
+ /** Current vault content (empty string for new files) */
26
+ previous: string;
27
+ }
28
+ >;
29
+
30
+ /** A single policy violation. */
31
+ export type PolicyViolation = {
32
+ policy: string;
33
+ message: string;
34
+ };
35
+
36
+ /** Error returned when one or more policies block approval. */
37
+ export type PolicyError = {
38
+ kind: "policy_violation";
39
+ violations: PolicyViolation[];
40
+ };
41
+
42
+ /** A named policy check function. */
43
+ export type Policy = {
44
+ /** Unique name for this policy (e.g. "chelae-plugin-required") */
45
+ name: string;
46
+ /** Check function — return ok() to allow, err(message) to block. */
47
+ check: (ctx: ApprovalContext) => Result<void, string> | Promise<Result<void, string>>;
48
+ };
49
+
50
+ // ── Validation ─────────────────────────────────────────────────────────
51
+
52
+ /** Error when policies have duplicate names. */
53
+ export type PolicyCollisionError = {
54
+ kind: "policy_name_collision";
55
+ duplicates: string[];
56
+ };
57
+
58
+ /**
59
+ * Validate that all policy names are unique.
60
+ * Returns the duplicate names if any collisions exist.
61
+ */
62
+ export function validatePolicies(policies: Policy[]): Result<void, PolicyCollisionError> {
63
+ const seen = new Set<string>();
64
+ const duplicates: string[] = [];
65
+ for (const p of policies) {
66
+ if (seen.has(p.name)) {
67
+ duplicates.push(p.name);
68
+ }
69
+ seen.add(p.name);
70
+ }
71
+ if (duplicates.length > 0) {
72
+ return err({ kind: "policy_name_collision", duplicates });
73
+ }
74
+ return ok(undefined);
75
+ }
76
+
77
+ // ── Evaluation ─────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Run all policies against an approval context.
81
+ * All policies are evaluated (not short-circuited) so all violations are reported.
82
+ */
83
+ export async function evaluatePolicies(
84
+ policies: Policy[],
85
+ ctx: ApprovalContext,
86
+ ): Promise<Result<void, PolicyError>> {
87
+ const violations: PolicyViolation[] = [];
88
+
89
+ for (const policy of policies) {
90
+ const result = await policy.check(ctx);
91
+ if (!result.ok) {
92
+ violations.push({ policy: policy.name, message: result.error });
93
+ }
94
+ }
95
+
96
+ if (violations.length > 0) {
97
+ return err({ kind: "policy_violation", violations });
98
+ }
99
+ return ok(undefined);
100
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MockSystemOps } from "./system-ops-mock.js";
3
+ import { reset } from "./reset.js";
4
+ import { diff } from "./diff.js";
5
+ import type { SoulguardConfig } from "./types.js";
6
+
7
+ const config: SoulguardConfig = { vault: ["SOUL.md"], ledger: [] };
8
+
9
+ function setup() {
10
+ const ops = new MockSystemOps("/workspace");
11
+ ops.addFile("SOUL.md", "original soul", {
12
+ owner: "soulguardian",
13
+ group: "soulguard",
14
+ mode: "444",
15
+ });
16
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
17
+ ops.addFile(".soulguard/staging/SOUL.md", "modified soul", {
18
+ owner: "agent",
19
+ group: "soulguard",
20
+ mode: "644",
21
+ });
22
+ return ops;
23
+ }
24
+
25
+ describe("reset (implicit proposals)", () => {
26
+ test("resets staging to match vault", async () => {
27
+ const ops = setup();
28
+
29
+ const result = await reset({ ops, config });
30
+ expect(result.ok).toBe(true);
31
+ if (!result.ok) return;
32
+ expect(result.value.resetFiles).toEqual(["SOUL.md"]);
33
+
34
+ // Staging should now match vault
35
+ const diffResult = await diff({ ops, config });
36
+ expect(diffResult.ok).toBe(true);
37
+ if (diffResult.ok) expect(diffResult.value.hasChanges).toBe(false);
38
+ });
39
+
40
+ test("returns empty resetFiles when staging matches vault", async () => {
41
+ const ops = new MockSystemOps("/workspace");
42
+ ops.addFile("SOUL.md", "same", { owner: "soulguardian", group: "soulguard", mode: "444" });
43
+ ops.addFile(".soulguard/staging", "", { owner: "root", group: "root", mode: "755" });
44
+ ops.addFile(".soulguard/staging/SOUL.md", "same", {
45
+ owner: "agent",
46
+ group: "soulguard",
47
+ mode: "644",
48
+ });
49
+
50
+ const result = await reset({ ops, config });
51
+ expect(result.ok).toBe(true);
52
+ if (!result.ok) return;
53
+ expect(result.value.resetFiles).toEqual([]);
54
+ });
55
+
56
+ test("applies staging ownership after reset", async () => {
57
+ const ops = setup();
58
+ const stagingOwnership = { user: "agent", group: "soulguard", mode: "644" };
59
+
60
+ const result = await reset({ ops, config, stagingOwnership });
61
+ expect(result.ok).toBe(true);
62
+
63
+ // Check staging content matches vault
64
+ const stagingContent = await ops.readFile(".soulguard/staging/SOUL.md");
65
+ expect(stagingContent.ok).toBe(true);
66
+ if (stagingContent.ok) expect(stagingContent.value).toBe("original soul");
67
+ });
68
+ });
package/src/reset.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * soulguard reset — reset staging copies to match vault originals.
3
+ *
4
+ * With implicit proposals, reset simply resets staging to match vault.
5
+ * Staging IS the proposal — resetting it discards all pending changes.
6
+ */
7
+
8
+ import type { SystemOperations } from "./system-ops.js";
9
+ import type { FileOwnership, SoulguardConfig, Result } from "./types.js";
10
+ import { diff } from "./diff.js";
11
+ import { ok, err } from "./result.js";
12
+
13
+ export type ResetOptions = {
14
+ ops: SystemOperations;
15
+ config: SoulguardConfig;
16
+ /** Ownership to apply to reset staging files (agent-writable) */
17
+ stagingOwnership?: FileOwnership;
18
+ };
19
+
20
+ export type ResetResult = {
21
+ /** Files whose staging copies were reset */
22
+ resetFiles: string[];
23
+ };
24
+
25
+ export type ResetError = { kind: "reset_failed"; message: string };
26
+
27
+ /**
28
+ * Reset staging changes to match vault.
29
+ */
30
+ export async function reset(options: ResetOptions): Promise<Result<ResetResult, ResetError>> {
31
+ const { ops, config, stagingOwnership } = options;
32
+
33
+ // Compute current diff to find what needs resetting
34
+ const diffResult = await diff({ ops, config });
35
+ if (!diffResult.ok) {
36
+ return err({ kind: "reset_failed", message: `Diff failed: ${diffResult.error.kind}` });
37
+ }
38
+
39
+ if (!diffResult.value.hasChanges) {
40
+ return ok({ resetFiles: [] });
41
+ }
42
+
43
+ // Reset staging copies to match vault originals
44
+ // Handles modified (overwrite) and deleted (recreate staging copy)
45
+ const resetFiles: string[] = [];
46
+ const resettableFiles = diffResult.value.files.filter(
47
+ (f) => f.status === "modified" || f.status === "deleted",
48
+ );
49
+
50
+ for (const file of resettableFiles) {
51
+ const stagingPath = `.soulguard/staging/${file.path}`;
52
+ const copyResult = await ops.copyFile(file.path, stagingPath);
53
+ if (copyResult.ok) {
54
+ if (stagingOwnership) {
55
+ await ops.chown(stagingPath, stagingOwnership);
56
+ await ops.chmod(stagingPath, stagingOwnership.mode);
57
+ }
58
+ resetFiles.push(file.path);
59
+ }
60
+ }
61
+
62
+ return ok({ resetFiles });
63
+ }
package/src/result.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Generic Result pattern for explicit error handling.
3
+ * Domain-specific error types live with their domain (system-ops, status, etc.).
4
+ */
5
+
6
+ export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
7
+
8
+ export function ok<T>(value: T): Result<T, never> {
9
+ return { ok: true, value };
10
+ }
11
+
12
+ export function err<E>(error: E): Result<never, E> {
13
+ return { ok: false, error };
14
+ }