@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.
- package/README.md +127 -0
- package/dist/cli.js +8838 -0
- package/dist/index.js +6555 -0
- package/package.json +35 -0
- package/src/approve.test.ts +386 -0
- package/src/approve.ts +352 -0
- package/src/cli/approve-command.ts +107 -0
- package/src/cli/diff-command.test.ts +61 -0
- package/src/cli/diff-command.ts +81 -0
- package/src/cli/init-command.ts +83 -0
- package/src/cli/reset-command.ts +36 -0
- package/src/cli/status-command.test.ts +90 -0
- package/src/cli/status-command.ts +78 -0
- package/src/cli/sync-command.test.ts +67 -0
- package/src/cli/sync-command.ts +105 -0
- package/src/cli.ts +224 -0
- package/src/console-live.ts +32 -0
- package/src/console-mock.ts +48 -0
- package/src/console.ts +12 -0
- package/src/constants.ts +21 -0
- package/src/diff.test.ts +189 -0
- package/src/diff.ts +212 -0
- package/src/git.test.ts +180 -0
- package/src/git.ts +131 -0
- package/src/glob.test.ts +74 -0
- package/src/glob.ts +84 -0
- package/src/index.ts +100 -0
- package/src/init.test.ts +234 -0
- package/src/init.ts +317 -0
- package/src/policy.test.ts +123 -0
- package/src/policy.ts +100 -0
- package/src/reset.test.ts +68 -0
- package/src/reset.ts +63 -0
- package/src/result.ts +14 -0
- package/src/schema.test.ts +27 -0
- package/src/schema.ts +22 -0
- package/src/self-protection.test.ts +139 -0
- package/src/self-protection.ts +63 -0
- package/src/status.test.ts +241 -0
- package/src/status.ts +114 -0
- package/src/sync.test.ts +172 -0
- package/src/sync.ts +101 -0
- package/src/system-ops-mock.ts +243 -0
- package/src/system-ops-node.test.ts +183 -0
- package/src/system-ops-node.ts +499 -0
- package/src/system-ops.ts +109 -0
- package/src/types.ts +91 -0
- package/src/vault-check.test.ts +41 -0
- package/src/vault-check.ts +24 -0
- package/test-e2e/Dockerfile +29 -0
- package/test-e2e/cases/approve-with-hash/expected.txt +16 -0
- package/test-e2e/cases/approve-with-hash/test.sh +23 -0
- package/test-e2e/cases/diff-no-changes/expected.txt +5 -0
- package/test-e2e/cases/diff-no-changes/test.sh +7 -0
- package/test-e2e/cases/diff-no-staging/expected.txt +1 -0
- package/test-e2e/cases/diff-no-staging/test.sh +6 -0
- package/test-e2e/cases/diff-shows-changes/expected.txt +13 -0
- package/test-e2e/cases/diff-shows-changes/test.sh +12 -0
- package/test-e2e/cases/diff-vault-missing/expected.txt +6 -0
- package/test-e2e/cases/diff-vault-missing/test.sh +10 -0
- package/test-e2e/cases/glob-ledger-files/expected.txt +52 -0
- package/test-e2e/cases/glob-ledger-files/test.sh +28 -0
- package/test-e2e/cases/glob-vault-files/expected.txt +41 -0
- package/test-e2e/cases/glob-vault-files/test.sh +30 -0
- package/test-e2e/cases/init-blocked-by-agent/expected.txt +11 -0
- package/test-e2e/cases/init-blocked-by-agent/test.sh +15 -0
- package/test-e2e/cases/init-happy/expected.txt +18 -0
- package/test-e2e/cases/init-happy/test.sh +20 -0
- package/test-e2e/cases/init-idempotent/expected.txt +13 -0
- package/test-e2e/cases/init-idempotent/test.sh +14 -0
- package/test-e2e/cases/reset-staging/expected.txt +16 -0
- package/test-e2e/cases/reset-staging/test.sh +23 -0
- package/test-e2e/cases/self-protection-blocks-invalid/expected.txt +12 -0
- package/test-e2e/cases/self-protection-blocks-invalid/test.sh +25 -0
- package/test-e2e/cases/status-clean/expected.txt +9 -0
- package/test-e2e/cases/status-clean/test.sh +8 -0
- package/test-e2e/cases/status-drifted/expected.txt +9 -0
- package/test-e2e/cases/status-drifted/test.sh +11 -0
- package/test-e2e/cases/status-no-config/expected.txt +1 -0
- package/test-e2e/cases/status-no-config/test.sh +2 -0
- package/test-e2e/cases/sync-fix-drift/expected.txt +15 -0
- package/test-e2e/cases/sync-fix-drift/test.sh +14 -0
- package/test-e2e/cases/sync-no-sudo-clean/expected.txt +11 -0
- package/test-e2e/cases/sync-no-sudo-clean/test.sh +9 -0
- package/test-e2e/cases/sync-no-sudo-drift/expected.txt +7 -0
- package/test-e2e/cases/sync-no-sudo-drift/test.sh +10 -0
- package/test-e2e/cases/vault-remove-file/expected.txt +26 -0
- package/test-e2e/cases/vault-remove-file/test.sh +31 -0
- package/test-e2e/cases/vault-write-blocked/expected.txt +8 -0
- package/test-e2e/cases/vault-write-blocked/test.sh +14 -0
- package/test-e2e/run-tests.sh +76 -0
- package/test-integration/Dockerfile +17 -0
- package/test-integration/system-ops-node.integration.test.ts +170 -0
- package/tsconfig.json +8 -0
package/src/approve.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* soulguard approve — apply staging changes to vault files.
|
|
3
|
+
*
|
|
4
|
+
* Implicit proposal model: staging IS the proposal. At approval time:
|
|
5
|
+
* 1. Compute diff to find modified files
|
|
6
|
+
* 2. Copy modified staging files to protected .soulguard/pending/
|
|
7
|
+
* 3. Hash the frozen pending copies and verify against reviewer's hash
|
|
8
|
+
* 4. Backup current vault files
|
|
9
|
+
* 5. Apply from pending copies, re-protect vault files
|
|
10
|
+
* 6. Sync staging, cleanup
|
|
11
|
+
*
|
|
12
|
+
* The protected copy step eliminates timing attacks — once files are in
|
|
13
|
+
* .soulguard/pending/ (owned by soulguardian), the agent cannot modify them.
|
|
14
|
+
* The hash is verified against these frozen copies, not the live staging dir.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { SystemOperations } from "./system-ops.js";
|
|
18
|
+
import type { FileOwnership, SoulguardConfig, Result } from "./types.js";
|
|
19
|
+
import { diff, computeApprovalHash } from "./diff.js";
|
|
20
|
+
import type { FileDiff } from "./diff.js";
|
|
21
|
+
import { ok, err } from "./result.js";
|
|
22
|
+
import type { GitCommitResult } from "./git.js";
|
|
23
|
+
import { isGitEnabled, gitCommit, vaultCommitMessage } from "./git.js";
|
|
24
|
+
import type { Policy, ApprovalContext } from "./policy.js";
|
|
25
|
+
import { validatePolicies, evaluatePolicies } from "./policy.js";
|
|
26
|
+
import { validateSelfProtection } from "./self-protection.js";
|
|
27
|
+
|
|
28
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export type ApproveOptions = {
|
|
31
|
+
ops: SystemOperations;
|
|
32
|
+
config: SoulguardConfig;
|
|
33
|
+
/** SHA-256 approval hash — must match computed hash of frozen pending copies */
|
|
34
|
+
hash: string;
|
|
35
|
+
/** Expected vault ownership to restore after writing */
|
|
36
|
+
vaultOwnership: FileOwnership;
|
|
37
|
+
/** Ownership for staging copies after sync (agent-writable) */
|
|
38
|
+
stagingOwnership?: FileOwnership;
|
|
39
|
+
/** Named policy hooks — all evaluated before applying changes.
|
|
40
|
+
* Duplicate policy names are rejected with policy_name_collision error. */
|
|
41
|
+
policies?: Policy[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Errors from approve. */
|
|
45
|
+
export type ApprovalError =
|
|
46
|
+
| { kind: "no_changes" }
|
|
47
|
+
| { kind: "hash_mismatch"; message: string }
|
|
48
|
+
| { kind: "self_protection"; message: string }
|
|
49
|
+
| { kind: "policy_violation"; violations: Array<{ policy: string; message: string }> }
|
|
50
|
+
| { kind: "policy_name_collision"; duplicates: string[] }
|
|
51
|
+
| { kind: "apply_failed"; message: string }
|
|
52
|
+
| { kind: "diff_failed"; message: string };
|
|
53
|
+
|
|
54
|
+
export type ApproveResult = {
|
|
55
|
+
/** Files that were updated */
|
|
56
|
+
appliedFiles: string[];
|
|
57
|
+
/** Git commit result (undefined if git not enabled) */
|
|
58
|
+
gitResult?: GitCommitResult;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Approve and apply staging changes to vault.
|
|
63
|
+
*/
|
|
64
|
+
export async function approve(
|
|
65
|
+
options: ApproveOptions,
|
|
66
|
+
): Promise<Result<ApproveResult, ApprovalError>> {
|
|
67
|
+
const { ops, config, hash, vaultOwnership, policies } = options;
|
|
68
|
+
|
|
69
|
+
// ── Phase 0: Validate policy names (fail fast on collisions) ────────
|
|
70
|
+
if (policies && policies.length > 0) {
|
|
71
|
+
const validation = validatePolicies(policies);
|
|
72
|
+
if (!validation.ok) {
|
|
73
|
+
return err(validation.error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Phase 1: Compute diff to find changed files ────────────────────
|
|
78
|
+
const diffResult = await diff({ ops, config });
|
|
79
|
+
if (!diffResult.ok) {
|
|
80
|
+
return err({ kind: "diff_failed", message: diffResult.error.kind });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!diffResult.value.hasChanges) {
|
|
84
|
+
return err({ kind: "no_changes" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Files that need to be applied (modified, new, or deleted)
|
|
88
|
+
const changedFiles = diffResult.value.files.filter(
|
|
89
|
+
(f) => f.status === "modified" || f.status === "vault_missing" || f.status === "deleted",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// ── Phase 2: Copy staging to protected working dir ─────────────────
|
|
93
|
+
// Once in .soulguard/pending/ (owned by soulguardian), the agent cannot
|
|
94
|
+
// modify these files. This freezes the content before we verify the hash.
|
|
95
|
+
await ops.mkdir(".soulguard/pending");
|
|
96
|
+
for (const file of changedFiles.filter((f) => f.status !== "deleted")) {
|
|
97
|
+
const copyResult = await ops.copyFile(
|
|
98
|
+
`.soulguard/staging/${file.path}`,
|
|
99
|
+
`.soulguard/pending/${file.path}`,
|
|
100
|
+
);
|
|
101
|
+
if (!copyResult.ok) {
|
|
102
|
+
await cleanupPending(ops, changedFiles);
|
|
103
|
+
return err({ kind: "apply_failed", message: `Cannot copy staging/${file.path} to pending` });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Protect the pending dir so agent can't tamper during approval
|
|
108
|
+
// TODO: chown recursively (needs ops interface change — tracked upstack)
|
|
109
|
+
const chownPending = await ops.chown(".soulguard/pending", vaultOwnership);
|
|
110
|
+
if (!chownPending.ok) {
|
|
111
|
+
await cleanupPending(ops, changedFiles);
|
|
112
|
+
return err({ kind: "apply_failed", message: "Cannot protect pending directory" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Phase 3: Hash the frozen pending copies and verify ─────────────
|
|
116
|
+
// Compute the approval hash from the now-protected pending files.
|
|
117
|
+
// This is the authoritative check — if the agent modified staging between
|
|
118
|
+
// the diff and the copy, the pending hash won't match the reviewer's hash.
|
|
119
|
+
const pendingHash = await computePendingHash(ops, changedFiles);
|
|
120
|
+
if (!pendingHash.ok) {
|
|
121
|
+
await cleanupPending(ops, changedFiles);
|
|
122
|
+
return err({ kind: "apply_failed", message: pendingHash.error });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (pendingHash.value !== hash) {
|
|
126
|
+
await cleanupPending(ops, changedFiles);
|
|
127
|
+
return err({
|
|
128
|
+
kind: "hash_mismatch",
|
|
129
|
+
message: `Expected hash ${hash} but got hash ${pendingHash.value}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Phase 3a: Read all pending file contents (used by self-protection + policies) ──
|
|
134
|
+
const pendingContents = new Map<string, string>();
|
|
135
|
+
for (const file of changedFiles.filter((f) => f.status !== "deleted")) {
|
|
136
|
+
const content = await ops.readFile(`.soulguard/pending/${file.path}`);
|
|
137
|
+
if (!content.ok) {
|
|
138
|
+
await cleanupPending(ops, changedFiles);
|
|
139
|
+
return err({
|
|
140
|
+
kind: "apply_failed",
|
|
141
|
+
message: `Cannot read pending/${file.path}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
pendingContents.set(file.path, content.value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Phase 3b: Built-in self-protection (hardcoded, cannot be bypassed) ──
|
|
148
|
+
{
|
|
149
|
+
const selfCheck = validateSelfProtection(
|
|
150
|
+
pendingContents,
|
|
151
|
+
changedFiles.filter((f) => f.status === "deleted"),
|
|
152
|
+
);
|
|
153
|
+
if (!selfCheck.ok) {
|
|
154
|
+
await cleanupPending(ops, changedFiles);
|
|
155
|
+
return err(selfCheck.error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Phase 3c: Run user-provided policy hooks against frozen content ──
|
|
160
|
+
if (policies && policies.length > 0) {
|
|
161
|
+
const ctx: ApprovalContext = new Map();
|
|
162
|
+
for (const file of changedFiles) {
|
|
163
|
+
if (file.status === "deleted") {
|
|
164
|
+
const vaultContent = await ops.readFile(file.path);
|
|
165
|
+
ctx.set(file.path, {
|
|
166
|
+
final: "",
|
|
167
|
+
diff: `File deleted: ${file.path}`,
|
|
168
|
+
previous: vaultContent.ok ? vaultContent.value : "",
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
let previous = "";
|
|
172
|
+
if (file.status === "modified") {
|
|
173
|
+
const vaultContent = await ops.readFile(file.path);
|
|
174
|
+
if (vaultContent.ok) {
|
|
175
|
+
previous = vaultContent.value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
ctx.set(file.path, {
|
|
179
|
+
final: pendingContents.get(file.path)!,
|
|
180
|
+
diff: file.diff ?? "",
|
|
181
|
+
previous,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const policyResult = await evaluatePolicies(policies, ctx);
|
|
187
|
+
if (!policyResult.ok) {
|
|
188
|
+
await cleanupPending(ops, changedFiles);
|
|
189
|
+
return err(policyResult.error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Phase 4: Backup all affected vault files ───────────────────────
|
|
194
|
+
const backedUpFiles: string[] = [];
|
|
195
|
+
await ops.mkdir(".soulguard/backup");
|
|
196
|
+
for (const file of changedFiles) {
|
|
197
|
+
// Only backup files that exist in vault (skip new files)
|
|
198
|
+
if (file.status === "vault_missing") continue;
|
|
199
|
+
const backupResult = await ops.copyFile(file.path, `.soulguard/backup/${file.path}`);
|
|
200
|
+
if (!backupResult.ok) {
|
|
201
|
+
await cleanupBackup(ops, backedUpFiles);
|
|
202
|
+
await cleanupPending(ops, changedFiles);
|
|
203
|
+
return err({ kind: "apply_failed", message: `Backup of ${file.path} failed` });
|
|
204
|
+
}
|
|
205
|
+
backedUpFiles.push(file.path);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Phase 5: Apply changes ─────────────────────────────────────────
|
|
209
|
+
const appliedFiles: string[] = [];
|
|
210
|
+
|
|
211
|
+
for (const file of changedFiles) {
|
|
212
|
+
if (file.status === "deleted") {
|
|
213
|
+
// Delete vault file
|
|
214
|
+
const deleteResult = await ops.deleteFile(file.path);
|
|
215
|
+
if (!deleteResult.ok) {
|
|
216
|
+
await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
|
|
217
|
+
return err({
|
|
218
|
+
kind: "apply_failed",
|
|
219
|
+
message: `Cannot delete ${file.path}: ${deleteResult.error.kind}`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
// Write content from pending
|
|
224
|
+
const content = await ops.readFile(`.soulguard/pending/${file.path}`);
|
|
225
|
+
if (!content.ok) {
|
|
226
|
+
await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
|
|
227
|
+
return err({ kind: "apply_failed", message: `Cannot read pending/${file.path}` });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const writeResult = await ops.writeFile(file.path, content.value);
|
|
231
|
+
if (!writeResult.ok) {
|
|
232
|
+
await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
|
|
233
|
+
return err({
|
|
234
|
+
kind: "apply_failed",
|
|
235
|
+
message: `Cannot write ${file.path}: ${writeResult.error.kind}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Re-protect
|
|
240
|
+
const chownResult = await ops.chown(file.path, vaultOwnership);
|
|
241
|
+
if (!chownResult.ok) {
|
|
242
|
+
await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
|
|
243
|
+
return err({
|
|
244
|
+
kind: "apply_failed",
|
|
245
|
+
message: `Cannot chown ${file.path}: ${chownResult.error.kind}`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const chmodResult = await ops.chmod(file.path, vaultOwnership.mode);
|
|
250
|
+
if (!chmodResult.ok) {
|
|
251
|
+
await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
|
|
252
|
+
return err({
|
|
253
|
+
kind: "apply_failed",
|
|
254
|
+
message: `Cannot chmod ${file.path}: ${chmodResult.error.kind}`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
appliedFiles.push(file.path);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Phase 6: Sync staging copies + cleanup ─────────────────────────
|
|
262
|
+
for (const file of changedFiles.filter((f) => f.status !== "deleted")) {
|
|
263
|
+
const stagingPath = `.soulguard/staging/${file.path}`;
|
|
264
|
+
await ops.copyFile(file.path, stagingPath);
|
|
265
|
+
if (options.stagingOwnership) {
|
|
266
|
+
await ops.chown(stagingPath, options.stagingOwnership);
|
|
267
|
+
await ops.chmod(stagingPath, options.stagingOwnership.mode);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Deleted files: staging copy is already gone (that's how we detected the deletion)
|
|
271
|
+
|
|
272
|
+
// Clean up backup and pending
|
|
273
|
+
await cleanupBackup(ops, backedUpFiles);
|
|
274
|
+
await cleanupPending(ops, changedFiles);
|
|
275
|
+
|
|
276
|
+
// ── Git auto-commit (best-effort) ──────────────────────────────────
|
|
277
|
+
let gitResult: GitCommitResult | undefined;
|
|
278
|
+
if (await isGitEnabled(ops, config)) {
|
|
279
|
+
const message = vaultCommitMessage(appliedFiles);
|
|
280
|
+
const result = await gitCommit(ops, appliedFiles, message);
|
|
281
|
+
if (result.ok) {
|
|
282
|
+
gitResult = result.value;
|
|
283
|
+
}
|
|
284
|
+
// Git failures are swallowed — vault update already succeeded
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return ok({ appliedFiles, gitResult });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Compute approval hash from frozen pending copies.
|
|
292
|
+
* Reuses computeApprovalHash from diff.ts for a single hash algorithm.
|
|
293
|
+
*/
|
|
294
|
+
async function computePendingHash(
|
|
295
|
+
ops: SystemOperations,
|
|
296
|
+
changedFiles: FileDiff[],
|
|
297
|
+
): Promise<Result<string, string>> {
|
|
298
|
+
const withHashes: FileDiff[] = [];
|
|
299
|
+
for (const f of changedFiles) {
|
|
300
|
+
if (f.status === "deleted") {
|
|
301
|
+
// Deleted files pass through — they use protectedHash from the vault file
|
|
302
|
+
withHashes.push(f);
|
|
303
|
+
} else {
|
|
304
|
+
const fileHash = await ops.hashFile(`.soulguard/pending/${f.path}`);
|
|
305
|
+
if (!fileHash.ok) {
|
|
306
|
+
return err(`Cannot hash pending/${f.path}`);
|
|
307
|
+
}
|
|
308
|
+
withHashes.push({ ...f, stagedHash: fileHash.value });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return ok(computeApprovalHash(withHashes));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Clean up pending directory on early exit.
|
|
316
|
+
*/
|
|
317
|
+
async function cleanupPending(ops: SystemOperations, files: FileDiff[]): Promise<void> {
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
await ops.deleteFile(`.soulguard/pending/${file.path}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Clean up backup files.
|
|
325
|
+
*/
|
|
326
|
+
async function cleanupBackup(ops: SystemOperations, backedUpFiles: string[]): Promise<void> {
|
|
327
|
+
for (const filePath of backedUpFiles) {
|
|
328
|
+
await ops.deleteFile(`.soulguard/backup/${filePath}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Rollback: restore vault files from backups after a partial apply failure.
|
|
334
|
+
*/
|
|
335
|
+
async function rollback(
|
|
336
|
+
ops: SystemOperations,
|
|
337
|
+
changedFiles: FileDiff[],
|
|
338
|
+
appliedFiles: string[],
|
|
339
|
+
backedUpFiles: string[],
|
|
340
|
+
vaultOwnership: FileOwnership,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
for (const filePath of appliedFiles) {
|
|
343
|
+
const backupContent = await ops.readFile(`.soulguard/backup/${filePath}`);
|
|
344
|
+
if (backupContent.ok) {
|
|
345
|
+
await ops.writeFile(filePath, backupContent.value);
|
|
346
|
+
await ops.chown(filePath, vaultOwnership);
|
|
347
|
+
await ops.chmod(filePath, vaultOwnership.mode);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
await cleanupBackup(ops, backedUpFiles);
|
|
351
|
+
await cleanupPending(ops, changedFiles);
|
|
352
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApproveCommand — approve and apply staging changes to vault.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* - `--hash <hash>`: Non-interactive, verifies hash and applies.
|
|
6
|
+
* - No args: Interactive — shows diff, prompts for confirmation, then applies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ConsoleOutput } from "../console.js";
|
|
10
|
+
import type { ApproveOptions } from "../approve.js";
|
|
11
|
+
import { approve } from "../approve.js";
|
|
12
|
+
import { diff } from "../diff.js";
|
|
13
|
+
|
|
14
|
+
export type ApproveCommandOptions = Omit<ApproveOptions, "hash"> & {
|
|
15
|
+
/** Pre-computed hash for non-interactive mode */
|
|
16
|
+
hash?: string;
|
|
17
|
+
/** Prompt function for interactive mode (returns true if user confirms) */
|
|
18
|
+
prompt?: () => Promise<boolean>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class ApproveCommand {
|
|
22
|
+
constructor(
|
|
23
|
+
private opts: ApproveCommandOptions,
|
|
24
|
+
private out: ConsoleOutput,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
async execute(): Promise<number> {
|
|
28
|
+
let hash = this.opts.hash;
|
|
29
|
+
|
|
30
|
+
// Interactive mode: show diff, compute hash, prompt
|
|
31
|
+
if (!hash) {
|
|
32
|
+
const diffResult = await diff({ ops: this.opts.ops, config: this.opts.config });
|
|
33
|
+
if (!diffResult.ok) {
|
|
34
|
+
this.out.error(`Diff failed: ${diffResult.error.kind}`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
if (!diffResult.value.hasChanges) {
|
|
38
|
+
this.out.info("No changes to approve — staging matches vault.");
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Show diff
|
|
43
|
+
this.out.heading(`Soulguard Approve — ${this.opts.ops.workspace}`);
|
|
44
|
+
this.out.write("");
|
|
45
|
+
for (const file of diffResult.value.files) {
|
|
46
|
+
if (file.status === "modified" && file.diff) {
|
|
47
|
+
this.out.write(file.diff);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
this.out.write("");
|
|
51
|
+
this.out.info(`Approval hash: ${diffResult.value.approvalHash}`);
|
|
52
|
+
this.out.write("");
|
|
53
|
+
|
|
54
|
+
// Prompt
|
|
55
|
+
if (this.opts.prompt) {
|
|
56
|
+
const confirmed = await this.opts.prompt();
|
|
57
|
+
if (!confirmed) {
|
|
58
|
+
this.out.info("Cancelled.");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
hash = diffResult.value.approvalHash!;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = await approve({ ...this.opts, hash });
|
|
67
|
+
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
switch (result.error.kind) {
|
|
70
|
+
case "no_changes":
|
|
71
|
+
this.out.info("No changes to approve — staging matches vault.");
|
|
72
|
+
return 0;
|
|
73
|
+
case "hash_mismatch":
|
|
74
|
+
this.out.error(result.error.message);
|
|
75
|
+
this.out.info("Please run `soulguard diff` again and re-review.");
|
|
76
|
+
return 1;
|
|
77
|
+
case "self_protection":
|
|
78
|
+
this.out.error(`Self-protection: ${result.error.message}`);
|
|
79
|
+
return 1;
|
|
80
|
+
case "policy_violation":
|
|
81
|
+
this.out.error("Blocked by policy:");
|
|
82
|
+
for (const v of result.error.violations) {
|
|
83
|
+
this.out.error(` ✗ ${v.policy}: ${v.message}`);
|
|
84
|
+
}
|
|
85
|
+
return 1;
|
|
86
|
+
case "policy_name_collision":
|
|
87
|
+
this.out.error(`Duplicate policy names: ${result.error.duplicates.join(", ")}`);
|
|
88
|
+
return 1;
|
|
89
|
+
case "apply_failed":
|
|
90
|
+
this.out.error(`Apply failed: ${result.error.message}`);
|
|
91
|
+
return 1;
|
|
92
|
+
case "diff_failed":
|
|
93
|
+
this.out.error(`Diff failed: ${result.error.message}`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.out.write("");
|
|
99
|
+
this.out.success(`Approved ${result.value.appliedFiles.length} file(s):`);
|
|
100
|
+
for (const file of result.value.appliedFiles) {
|
|
101
|
+
this.out.success(` ✅ ${file}`);
|
|
102
|
+
}
|
|
103
|
+
this.out.write("");
|
|
104
|
+
this.out.info("Vault updated. Staging synced.");
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DiffCommand } from "./diff-command.js";
|
|
3
|
+
import { MockSystemOps } from "../system-ops-mock.js";
|
|
4
|
+
import { MockConsoleOutput } from "../console-mock.js";
|
|
5
|
+
import type { SoulguardConfig } from "../types.js";
|
|
6
|
+
|
|
7
|
+
const WORKSPACE = "/test/workspace";
|
|
8
|
+
|
|
9
|
+
function makeMock() {
|
|
10
|
+
return new MockSystemOps(WORKSPACE);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeConfig(vault: string[] = ["SOUL.md"]): SoulguardConfig {
|
|
14
|
+
return { vault, ledger: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("DiffCommand", () => {
|
|
18
|
+
test("no changes → exit 0, output contains 'No changes'", async () => {
|
|
19
|
+
const ops = makeMock();
|
|
20
|
+
ops.addFile(".soulguard/staging", "");
|
|
21
|
+
ops.addFile("SOUL.md", "# Soul");
|
|
22
|
+
ops.addFile(".soulguard/staging/SOUL.md", "# Soul");
|
|
23
|
+
|
|
24
|
+
const out = new MockConsoleOutput();
|
|
25
|
+
const cmd = new DiffCommand({ ops, config: makeConfig() }, out);
|
|
26
|
+
const exitCode = await cmd.execute();
|
|
27
|
+
|
|
28
|
+
expect(exitCode).toBe(0);
|
|
29
|
+
expect(out.hasText("No changes")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("modified file → exit 1, output contains diff", async () => {
|
|
33
|
+
const ops = makeMock();
|
|
34
|
+
ops.addFile(".soulguard/staging", "");
|
|
35
|
+
ops.addFile("SOUL.md", "# Soul\noriginal");
|
|
36
|
+
ops.addFile(".soulguard/staging/SOUL.md", "# Soul\nmodified");
|
|
37
|
+
|
|
38
|
+
const out = new MockConsoleOutput();
|
|
39
|
+
const cmd = new DiffCommand({ ops, config: makeConfig() }, out);
|
|
40
|
+
const exitCode = await cmd.execute();
|
|
41
|
+
|
|
42
|
+
expect(exitCode).toBe(1);
|
|
43
|
+
expect(out.hasText("📝 SOUL.md")).toBe(true);
|
|
44
|
+
expect(out.hasText("-original")).toBe(true);
|
|
45
|
+
expect(out.hasText("+modified")).toBe(true);
|
|
46
|
+
expect(out.hasText("1 file(s) changed")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("deleted file → exit 1, output contains deletion marker", async () => {
|
|
50
|
+
const ops = makeMock();
|
|
51
|
+
ops.addFile(".soulguard/staging", "");
|
|
52
|
+
ops.addFile("SOUL.md", "# Soul");
|
|
53
|
+
|
|
54
|
+
const out = new MockConsoleOutput();
|
|
55
|
+
const cmd = new DiffCommand({ ops, config: makeConfig() }, out);
|
|
56
|
+
const exitCode = await cmd.execute();
|
|
57
|
+
|
|
58
|
+
expect(exitCode).toBe(1);
|
|
59
|
+
expect(out.hasText("staged for deletion")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DiffCommand — pretty-prints the result of `diff()`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ConsoleOutput } from "../console.js";
|
|
6
|
+
import type { DiffOptions } from "../diff.js";
|
|
7
|
+
import { diff } from "../diff.js";
|
|
8
|
+
|
|
9
|
+
export class DiffCommand {
|
|
10
|
+
constructor(
|
|
11
|
+
private options: DiffOptions,
|
|
12
|
+
private out: ConsoleOutput,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
async execute(): Promise<number> {
|
|
16
|
+
const result = await diff(this.options);
|
|
17
|
+
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
switch (result.error.kind) {
|
|
20
|
+
case "no_staging":
|
|
21
|
+
this.out.error("No staging directory found. Run `soulguard init` first.");
|
|
22
|
+
return 1;
|
|
23
|
+
case "no_config":
|
|
24
|
+
this.out.error("No soulguard.json found.");
|
|
25
|
+
return 1;
|
|
26
|
+
case "read_failed":
|
|
27
|
+
this.out.error(`Failed to read ${result.error.path}: ${result.error.message}`);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { files, hasChanges } = result.value;
|
|
33
|
+
|
|
34
|
+
this.out.heading(`Soulguard Diff — ${this.options.ops.workspace}`);
|
|
35
|
+
this.out.write("");
|
|
36
|
+
|
|
37
|
+
let changeCount = 0;
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
switch (file.status) {
|
|
41
|
+
case "unchanged":
|
|
42
|
+
this.out.info(` ✅ ${file.path} (no changes)`);
|
|
43
|
+
break;
|
|
44
|
+
case "modified":
|
|
45
|
+
changeCount++;
|
|
46
|
+
this.out.warn(` 📝 ${file.path}`);
|
|
47
|
+
if (file.diff) {
|
|
48
|
+
for (const line of file.diff.split("\n")) {
|
|
49
|
+
this.out.write(` ${line}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
case "staging_missing":
|
|
54
|
+
changeCount++;
|
|
55
|
+
this.out.warn(` ⚠️ ${file.path} (no staging copy)`);
|
|
56
|
+
break;
|
|
57
|
+
case "vault_missing":
|
|
58
|
+
changeCount++;
|
|
59
|
+
this.out.warn(` ⚠️ ${file.path} (vault file missing — new file)`);
|
|
60
|
+
break;
|
|
61
|
+
case "deleted":
|
|
62
|
+
changeCount++;
|
|
63
|
+
this.out.warn(` 🗑️ ${file.path} (staged for deletion)`);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.out.write("");
|
|
69
|
+
if (hasChanges) {
|
|
70
|
+
this.out.info(`${changeCount} file(s) changed`);
|
|
71
|
+
if (result.value.approvalHash) {
|
|
72
|
+
this.out.info(`Approval hash: ${result.value.approvalHash}`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
this.out.info("No changes");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Exit 1 = differences found (matching `git diff` convention), not an error.
|
|
79
|
+
return hasChanges ? 1 : 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: soulguard init
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ConsoleOutput } from "../console.js";
|
|
6
|
+
import type { InitOptions, InitResult, InitError } from "../init.js";
|
|
7
|
+
import type { Result } from "../types.js";
|
|
8
|
+
import { init } from "../init.js";
|
|
9
|
+
|
|
10
|
+
export type InitCommandOptions = {
|
|
11
|
+
initOptions: InitOptions;
|
|
12
|
+
out: ConsoleOutput;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class InitCommand {
|
|
16
|
+
constructor(
|
|
17
|
+
private options: InitOptions,
|
|
18
|
+
private out: ConsoleOutput,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async execute(): Promise<number> {
|
|
22
|
+
const result: Result<InitResult, InitError> = await init(this.options);
|
|
23
|
+
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
const e = result.error;
|
|
26
|
+
switch (e.kind) {
|
|
27
|
+
case "not_root":
|
|
28
|
+
this.out.error("soulguard init requires root. Run with sudo.");
|
|
29
|
+
break;
|
|
30
|
+
case "group_creation_failed":
|
|
31
|
+
this.out.error(`Failed to create group: ${e.message}`);
|
|
32
|
+
break;
|
|
33
|
+
case "user_creation_failed":
|
|
34
|
+
this.out.error(`Failed to create user: ${e.message}`);
|
|
35
|
+
break;
|
|
36
|
+
case "config_write_failed":
|
|
37
|
+
this.out.error(`Failed to write config: ${e.message}`);
|
|
38
|
+
break;
|
|
39
|
+
case "sudoers_write_failed":
|
|
40
|
+
this.out.error(`Failed to write sudoers: ${e.message}`);
|
|
41
|
+
break;
|
|
42
|
+
case "staging_failed":
|
|
43
|
+
this.out.error(`Failed to create staging: ${e.message}`);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const r = result.value;
|
|
50
|
+
this.out.heading(`Soulguard Init — ${this.options.ops.workspace}`);
|
|
51
|
+
|
|
52
|
+
const steps: string[] = [];
|
|
53
|
+
if (r.groupCreated) steps.push("Created group: soulguard");
|
|
54
|
+
if (r.userCreated) steps.push("Created user: soulguardian");
|
|
55
|
+
if (r.configCreated) steps.push("Wrote soulguard.json");
|
|
56
|
+
if (r.sudoersCreated) steps.push("Wrote /etc/sudoers.d/soulguard");
|
|
57
|
+
if (r.stagingCreated) steps.push("Created .soulguard/staging/");
|
|
58
|
+
if (steps.length > 0) {
|
|
59
|
+
for (const step of steps) {
|
|
60
|
+
this.out.success(` ${step}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Report sync results
|
|
65
|
+
const fixed = r.syncResult.before.issues.length - r.syncResult.after.issues.length;
|
|
66
|
+
if (fixed > 0) {
|
|
67
|
+
this.out.success(` Synced ${fixed} vault file(s)`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const remaining = r.syncResult.after.issues.length;
|
|
71
|
+
if (remaining > 0) {
|
|
72
|
+
this.out.warn(` ${remaining} issue(s) remaining after sync`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (steps.length === 0 && fixed === 0) {
|
|
76
|
+
this.out.info("Already initialized — nothing to do.");
|
|
77
|
+
} else {
|
|
78
|
+
this.out.success("\nDone.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return remaining > 0 ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
}
|