@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,10 @@
1
+ # Agent can't fix drift without sudo (no init = no scoped sudoers)
2
+ echo '{"vault": ["SOUL.md"], "ledger": []}' > soulguard.json
3
+ echo '# My Soul' > SOUL.md
4
+
5
+ # Make workspace readable by agent (no init, no sudoers)
6
+ chmod 755 .
7
+ chmod o+r soulguard.json SOUL.md
8
+
9
+ # Agent syncs drifted workspace without sudo — chown should fail
10
+ su - agent -c "soulguard sync $(pwd)"
@@ -0,0 +1,26 @@
1
+ Soulguard Init — /workspace
2
+ Created group: soulguard
3
+ Created user: soulguardian
4
+ Wrote /etc/sudoers.d/soulguard
5
+ Created .soulguard/staging/
6
+ Synced 3 vault file(s)
7
+
8
+ Done.
9
+ DIFF:
10
+ Soulguard Diff — /workspace
11
+
12
+ 🗑️ BOOTSTRAP.md (staged for deletion)
13
+ ✅ SOUL.md (no changes)
14
+ ✅ soulguard.json (no changes)
15
+
16
+ 1 file(s) changed
17
+ Approval hash: 24d6aaec960a3d74c84690b786336bdcce7c35585e353be867f6f0606093618e
18
+
19
+ Approved 1 file(s):
20
+ ✅ BOOTSTRAP.md
21
+
22
+ Vault updated. Staging synced.
23
+ BOOTSTRAP EXISTS:
24
+ no
25
+ SOUL EXISTS:
26
+ yes
@@ -0,0 +1,31 @@
1
+ # Vault file deletion: agent deletes a file from staging, owner approves.
2
+ # Tests the full lifecycle when a vault-protected file is deleted through staging.
3
+
4
+ # Setup: two vault files
5
+ cat > soulguard.json <<'EOF'
6
+ {"vault":["SOUL.md","BOOTSTRAP.md","soulguard.json"],"ledger":[]}
7
+ EOF
8
+ echo '# My Soul' > SOUL.md
9
+ echo '# Bootstrap' > BOOTSTRAP.md
10
+
11
+ # Owner runs init
12
+ soulguard init . --agent-user agent
13
+
14
+ # Agent deletes BOOTSTRAP.md from staging (done with it)
15
+ su - agent -c "rm $(pwd)/.soulguard/staging/BOOTSTRAP.md"
16
+
17
+ # Diff should show deletion
18
+ echo "DIFF:"
19
+ NO_COLOR=1 soulguard diff . 2>&1
20
+
21
+ # Get hash and approve the deletion
22
+ HASH=$(NO_COLOR=1 soulguard diff . 2>&1 | grep 'Approval hash:' | awk '{print $NF}')
23
+ soulguard approve . --hash "$HASH"
24
+
25
+ # BOOTSTRAP.md should be gone from disk
26
+ echo "BOOTSTRAP EXISTS:"
27
+ test -f BOOTSTRAP.md && echo "yes" || echo "no"
28
+
29
+ # SOUL.md should still exist
30
+ echo "SOUL EXISTS:"
31
+ test -f SOUL.md && echo "yes" || echo "no"
@@ -0,0 +1,8 @@
1
+ Soulguard Sync — /workspace
2
+
3
+ Nothing to fix — all files ok.
4
+ -bash: line 1: /workspace/SOUL.md: Permission denied
5
+ WRITE BLOCKED (GOOD)
6
+ chown: changing ownership of '/workspace/SOUL.md': Operation not permitted
7
+ CHOWN BLOCKED (GOOD)
8
+ # My Soul
@@ -0,0 +1,14 @@
1
+ # Setup: create and protect a vault file (as owner/root)
2
+ echo '{"vault": ["SOUL.md"], "ledger": []}' > soulguard.json
3
+ echo '# My Soul' > SOUL.md
4
+ soulguard init . --agent-user agent > /dev/null 2>&1
5
+ soulguard sync .
6
+
7
+ # Attack 1: Agent tries to write to the vaulted file (should fail)
8
+ su - agent -c "(echo hacked > $(pwd)/SOUL.md) 2>&1" && echo "WRITE SUCCEEDED (BAD)" || echo "WRITE BLOCKED (GOOD)"
9
+
10
+ # Attack 2: Agent tries to chown the file back (should fail without root)
11
+ su - agent -c "chown agent:agent $(pwd)/SOUL.md 2>&1" && echo "CHOWN SUCCEEDED (BAD)" || echo "CHOWN BLOCKED (GOOD)"
12
+
13
+ # Verify file is still intact
14
+ cat SOUL.md
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # E2E snapshot test runner for soulguard CLI.
3
+ #
4
+ # Runs each test case in a separate Docker container for full isolation.
5
+ # No shared state between tests — each gets a fresh filesystem.
6
+ #
7
+ # Each test case is a directory under cases/ containing:
8
+ # test.sh — shell script to run (executed as `agent` user)
9
+ # expected.txt — exact expected stdout+stderr output
10
+ #
11
+ # Usage:
12
+ # ./run-tests.sh # run all tests, diff against snapshots
13
+ # ./run-tests.sh --update # regenerate all snapshots
14
+
15
+ set -euo pipefail
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
+ CASES_DIR="$SCRIPT_DIR/cases"
19
+ IMAGE="soulguard-e2e"
20
+ UPDATE="${1:-}"
21
+ PASS=0
22
+ FAIL=0
23
+
24
+ for case_dir in "$CASES_DIR"/*/; do
25
+ test_name="$(basename "$case_dir")"
26
+ test_script="$case_dir/test.sh"
27
+ expected_file="$case_dir/expected.txt"
28
+
29
+ if [ ! -f "$test_script" ]; then
30
+ echo "SKIP: $test_name (no test.sh)"
31
+ continue
32
+ fi
33
+
34
+ # Run test in a fresh container — complete isolation
35
+ actual=$(docker run --rm "$IMAGE" bash -c "
36
+ workspace=\$(mktemp -d /tmp/soulguard-e2e-XXXX)
37
+ chmod 755 \"\$workspace\"
38
+ cd \"\$workspace\"
39
+ NO_COLOR=1 bash /app/packages/core/test-e2e/cases/$test_name/test.sh 2>&1 | \
40
+ sed \"s|\$workspace|/workspace|g\" | \
41
+ sed 's|/app/packages/core/test-e2e/cases/$test_name/test.sh|test.sh|g'
42
+ ") || true
43
+
44
+ if [ "$UPDATE" = "--update" ]; then
45
+ echo "$actual" > "$expected_file"
46
+ echo "UPDATED: $test_name"
47
+ continue
48
+ fi
49
+
50
+ if [ ! -f "$expected_file" ]; then
51
+ echo "FAIL: $test_name (no expected.txt — run with --update)"
52
+ echo "--- actual output ---"
53
+ echo "$actual"
54
+ echo "---"
55
+ FAIL=$((FAIL + 1))
56
+ continue
57
+ fi
58
+
59
+ expected=$(cat "$expected_file")
60
+
61
+ if [ "$actual" = "$expected" ]; then
62
+ echo "PASS: $test_name"
63
+ PASS=$((PASS + 1))
64
+ else
65
+ echo "FAIL: $test_name"
66
+ diff --color=auto <(echo "$expected") <(echo "$actual") || true
67
+ FAIL=$((FAIL + 1))
68
+ fi
69
+ done
70
+
71
+ echo ""
72
+ echo "$PASS passed, $FAIL failed"
73
+
74
+ if [ "$FAIL" -gt 0 ]; then
75
+ exit 1
76
+ fi
@@ -0,0 +1,17 @@
1
+ FROM oven/bun:latest
2
+
3
+ # Create test users that mirror the soulguard design
4
+ RUN groupadd soulguard && \
5
+ useradd -r -g soulguard soulguardian && \
6
+ useradd -g staff agent 2>/dev/null || useradd agent
7
+
8
+ WORKDIR /app
9
+
10
+ # Copy full monorepo (respects .dockerignore)
11
+ COPY . .
12
+
13
+ # Install deps
14
+ RUN bun install --frozen-lockfile
15
+
16
+ # Run integration tests as root (needed for chown)
17
+ CMD ["bun", "test", "packages/core/test-integration/"]
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Integration tests for NodeSystemOps — requires root + test users.
3
+ *
4
+ * Run via Docker:
5
+ * docker build -f packages/core/test-integration/Dockerfile -t soulguard-test .
6
+ * docker run --rm soulguard-test
7
+ *
8
+ * These tests exercise chown (which requires root) and verify the full
9
+ * ownership pipeline: create file → chown to soulguardian → verify stat.
10
+ */
11
+
12
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
13
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { NodeSystemOps } from "../src/system-ops-node.js";
17
+
18
+ let workspace: string;
19
+ let ops: NodeSystemOps;
20
+
21
+ beforeEach(async () => {
22
+ workspace = await mkdtemp(join(tmpdir(), "soulguard-integ-"));
23
+ ops = new NodeSystemOps(workspace);
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await rm(workspace, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("NodeSystemOps integration (root + test users)", () => {
31
+ // ── chown ───────────────────────────────────────────────────────────
32
+
33
+ describe("chown", () => {
34
+ test("transfers file to soulguardian:soulguard", async () => {
35
+ await writeFile(join(workspace, "SOUL.md"), "# Soul");
36
+
37
+ const result = await ops.chown("SOUL.md", {
38
+ user: "soulguardian",
39
+ group: "soulguard",
40
+ });
41
+ expect(result.ok).toBe(true);
42
+
43
+ const stat = await ops.stat("SOUL.md");
44
+ expect(stat.ok).toBe(true);
45
+ if (!stat.ok) return;
46
+ expect(stat.value.ownership.user).toBe("soulguardian");
47
+ expect(stat.value.ownership.group).toBe("soulguard");
48
+ });
49
+
50
+ test("transfers file back to agent user", async () => {
51
+ await writeFile(join(workspace, "notes.md"), "# Notes");
52
+
53
+ // First transfer to soulguardian
54
+ await ops.chown("notes.md", {
55
+ user: "soulguardian",
56
+ group: "soulguard",
57
+ });
58
+
59
+ // Then release back to agent
60
+ const result = await ops.chown("notes.md", {
61
+ user: "agent",
62
+ group: "soulguard",
63
+ });
64
+ expect(result.ok).toBe(true);
65
+
66
+ const stat = await ops.stat("notes.md");
67
+ expect(stat.ok).toBe(true);
68
+ if (!stat.ok) return;
69
+ expect(stat.value.ownership.user).toBe("agent");
70
+ });
71
+
72
+ test("returns error for nonexistent file", async () => {
73
+ const result = await ops.chown("nope.md", {
74
+ user: "soulguardian",
75
+ group: "soulguard",
76
+ });
77
+ expect(result.ok).toBe(false);
78
+ });
79
+
80
+ test("returns error for nonexistent user", async () => {
81
+ await writeFile(join(workspace, "test.md"), "hello");
82
+
83
+ const result = await ops.chown("test.md", {
84
+ user: "nonexistent_user_xyz",
85
+ group: "soulguard",
86
+ });
87
+ expect(result.ok).toBe(false);
88
+ });
89
+ });
90
+
91
+ // ── Full vault workflow ─────────────────────────────────────────────
92
+
93
+ describe("vault workflow", () => {
94
+ test("protect → verify → release cycle", async () => {
95
+ await writeFile(join(workspace, "SOUL.md"), "# My Soul");
96
+
97
+ // 1. Protect: chown + chmod
98
+ const chownResult = await ops.chown("SOUL.md", {
99
+ user: "soulguardian",
100
+ group: "soulguard",
101
+ });
102
+ expect(chownResult.ok).toBe(true);
103
+
104
+ const chmodResult = await ops.chmod("SOUL.md", "444");
105
+ expect(chmodResult.ok).toBe(true);
106
+
107
+ // 2. Verify: stat shows correct ownership + mode
108
+ const stat1 = await ops.stat("SOUL.md");
109
+ expect(stat1.ok).toBe(true);
110
+ if (!stat1.ok) return;
111
+ expect(stat1.value.ownership.user).toBe("soulguardian");
112
+ expect(stat1.value.ownership.group).toBe("soulguard");
113
+ expect(stat1.value.ownership.mode).toBe("444");
114
+
115
+ // 3. Content still readable
116
+ const content = await ops.readFile("SOUL.md");
117
+ expect(content.ok).toBe(true);
118
+ if (!content.ok) return;
119
+ expect(content.value).toBe("# My Soul");
120
+
121
+ // 4. Hash still works
122
+ const hash = await ops.hashFile("SOUL.md");
123
+ expect(hash.ok).toBe(true);
124
+ if (!hash.ok) return;
125
+ expect(hash.value).toMatch(/^[a-f0-9]{64}$/);
126
+
127
+ // 5. Release: chown back + chmod
128
+ const releaseChown = await ops.chown("SOUL.md", {
129
+ user: "agent",
130
+ group: "soulguard",
131
+ });
132
+ expect(releaseChown.ok).toBe(true);
133
+
134
+ const releaseChmod = await ops.chmod("SOUL.md", "644");
135
+ expect(releaseChmod.ok).toBe(true);
136
+
137
+ // 6. Verify released state
138
+ const stat2 = await ops.stat("SOUL.md");
139
+ expect(stat2.ok).toBe(true);
140
+ if (!stat2.ok) return;
141
+ expect(stat2.value.ownership.user).toBe("agent");
142
+ expect(stat2.value.ownership.mode).toBe("644");
143
+ });
144
+ });
145
+
146
+ // ── userExists / groupExists with real users ────────────────────────
147
+
148
+ describe("user/group checks with test users", () => {
149
+ test("soulguardian user exists", async () => {
150
+ const result = await ops.userExists("soulguardian");
151
+ expect(result.ok).toBe(true);
152
+ if (!result.ok) return;
153
+ expect(result.value).toBe(true);
154
+ });
155
+
156
+ test("soulguard group exists", async () => {
157
+ const result = await ops.groupExists("soulguard");
158
+ expect(result.ok).toBe(true);
159
+ if (!result.ok) return;
160
+ expect(result.value).toBe(true);
161
+ });
162
+
163
+ test("agent user exists", async () => {
164
+ const result = await ops.userExists("agent");
165
+ expect(result.ok).toBe(true);
166
+ if (!result.ok) return;
167
+ expect(result.value).toBe(true);
168
+ });
169
+ });
170
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src"]
8
+ }