@soulguard/openclaw 0.1.4 → 0.2.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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "soulguard",
3
+ "name": "Soulguard",
4
+ "description": "Identity protection for AI agents",
5
+ "version": "0.1.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "properties": {},
9
+ "additionalProperties": false
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@soulguard/openclaw",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
8
- "build": "bun build src/index.ts --outdir dist --target node",
8
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --define \"SOULGUARD_VERSION='$(node -p \"require('./package.json').version\")'\" && cp openclaw.plugin.json dist/",
9
9
  "test": "bun test",
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@soulguard/core": "^0.1.3"
13
+ "@soulguard/core": "^2.0.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/bun": "latest",
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { MockSystemOps } from "@soulguard/core";
3
+ import type { SoulguardConfig } from "@soulguard/core";
4
+ import { getPendingChanges, buildPendingChangesContext } from "./context.js";
5
+
6
+ const WORKSPACE = "/test-workspace";
7
+
8
+ const baseConfig: SoulguardConfig = {
9
+ version: 1,
10
+ guardian: "soulguardian_agent",
11
+ files: {
12
+ "SOUL.md": "protect",
13
+ "AGENTS.md": "protect",
14
+ },
15
+ };
16
+
17
+ function setupOps(staged?: Record<string, string>) {
18
+ const ops = new MockSystemOps(WORKSPACE);
19
+ ops.addFile("SOUL.md", "soul content", {
20
+ owner: "soulguardian",
21
+ group: "soulguard",
22
+ mode: "444",
23
+ });
24
+ ops.addFile("AGENTS.md", "agents content", {
25
+ owner: "soulguardian",
26
+ group: "soulguard",
27
+ mode: "444",
28
+ });
29
+ if (staged) {
30
+ for (const [path, content] of Object.entries(staged)) {
31
+ ops.addFile(`.soulguard-staging/${path}`, content, {
32
+ owner: "agent",
33
+ group: "staff",
34
+ mode: "644",
35
+ });
36
+ }
37
+ }
38
+ return ops;
39
+ }
40
+
41
+ describe("getPendingChanges", () => {
42
+ it("returns empty when no staged changes exist", async () => {
43
+ const ops = setupOps();
44
+ const result = await getPendingChanges({ ops, config: baseConfig });
45
+ expect(result.files).toEqual([]);
46
+ });
47
+
48
+ it("detects a modified staged file", async () => {
49
+ const ops = setupOps({ "SOUL.md": "modified soul" });
50
+ const result = await getPendingChanges({ ops, config: baseConfig });
51
+ expect(result.files).toEqual(["SOUL.md"]);
52
+ });
53
+
54
+ it("detects multiple staged files", async () => {
55
+ const ops = setupOps({ "SOUL.md": "mod1", "AGENTS.md": "mod2" });
56
+ const result = await getPendingChanges({ ops, config: baseConfig });
57
+ expect(result.files.sort()).toEqual(["AGENTS.md", "SOUL.md"]);
58
+ });
59
+
60
+ it("ignores unchanged staged files (same content)", async () => {
61
+ const ops = setupOps({ "SOUL.md": "soul content" });
62
+ const result = await getPendingChanges({ ops, config: baseConfig });
63
+ expect(result.files).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe("buildPendingChangesContext", () => {
68
+ it("returns undefined when no pending changes", async () => {
69
+ const ops = setupOps();
70
+ const result = await buildPendingChangesContext({ ops, config: baseConfig });
71
+ expect(result).toBeUndefined();
72
+ });
73
+
74
+ it("returns context string when there are pending changes", async () => {
75
+ const ops = setupOps({ "SOUL.md": "modified" });
76
+ const result = await buildPendingChangesContext({ ops, config: baseConfig });
77
+ expect(result).toBeDefined();
78
+ expect(result).toContain("[Soulguard]");
79
+ expect(result).toContain("1 protected file(s)");
80
+ expect(result).toContain("SOUL.md");
81
+ expect(result).toContain("soulguard diff");
82
+ expect(result).toContain("soulguard reset");
83
+ });
84
+
85
+ it("lists multiple files", async () => {
86
+ const ops = setupOps({ "SOUL.md": "mod1", "AGENTS.md": "mod2" });
87
+ const result = await buildPendingChangesContext({ ops, config: baseConfig });
88
+ expect(result).toContain("2 protected file(s)");
89
+ expect(result).toContain("SOUL.md");
90
+ expect(result).toContain("AGENTS.md");
91
+ });
92
+ });
package/src/context.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * before_prompt_build context injection — injects a short note when
3
+ * there are pending staged changes so the agent knows about them
4
+ * without polluting context on normal turns.
5
+ */
6
+
7
+ import { StateTree, type BuildStateOptions } from "@soulguard/core";
8
+
9
+ // ── Types ──────────────────────────────────────────────────────────────
10
+
11
+ export type PendingChangesResult = {
12
+ /** File paths that have pending staged changes. */
13
+ files: string[];
14
+ };
15
+
16
+ // ── Core ───────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Get pending staged changes using the soulguard state tree.
20
+ */
21
+ export async function getPendingChanges(options: BuildStateOptions): Promise<PendingChangesResult> {
22
+ const treeResult = await StateTree.build(options);
23
+ if (!treeResult.ok) return { files: [] };
24
+ return { files: treeResult.value.changedFiles().map((f) => f.path) };
25
+ }
26
+
27
+ /**
28
+ * Build the context string to inject via before_prompt_build.
29
+ * Returns undefined if there are no pending changes (no context pollution).
30
+ */
31
+ export async function buildPendingChangesContext(
32
+ options: BuildStateOptions,
33
+ ): Promise<string | undefined> {
34
+ const { files } = await getPendingChanges(options);
35
+ if (files.length === 0) return undefined;
36
+
37
+ const fileList = files.join(", ");
38
+ return (
39
+ `[Soulguard] ${files.length} protected file(s) have pending staged changes: ${fileList}. ` +
40
+ `Use \`soulguard diff\` to review. Ask your owner to apply changes, ` +
41
+ `or use \`soulguard reset\` to discard them.`
42
+ );
43
+ }
package/src/guard.test.ts CHANGED
@@ -2,31 +2,34 @@ import { describe, expect, it } from "bun:test";
2
2
  import { guardToolCall, type GuardOptions } from "./guard.js";
3
3
 
4
4
  const defaultOpts: GuardOptions = {
5
- vaultFiles: ["SOUL.md", "IDENTITY.md"],
5
+ protectFiles: ["SOUL.md", "IDENTITY.md"],
6
+ stateDir: "/home/test/.openclaw",
6
7
  };
7
8
 
8
9
  describe("guardToolCall", () => {
9
- it("blocks Write to a vault file", () => {
10
+ it("blocks Write to a protected file", () => {
10
11
  const result = guardToolCall("Write", { file_path: "SOUL.md" }, defaultOpts);
11
12
  expect(result.blocked).toBe(true);
12
- expect(result.reason).toContain("vault-protected");
13
- expect(result.reason).toContain(".soulguard/staging/SOUL.md");
14
- expect(result.reason).toContain("reviewed and approved by the owner");
13
+ expect(result.reason).toContain("protected by soulguard");
14
+ expect(result.reason).toContain("soulguard stage SOUL.md");
15
+ expect(result.reason).toContain(".soulguard-staging/SOUL.md");
16
+ expect(result.reason).toContain("Your owner will review and apply");
15
17
  });
16
18
 
17
- it("blocks Edit to a vault file", () => {
19
+ it("blocks Edit to a protected file", () => {
18
20
  const result = guardToolCall("Edit", { path: "IDENTITY.md" }, defaultOpts);
19
21
  expect(result.blocked).toBe(true);
20
- expect(result.reason).toContain("vault-protected");
22
+ expect(result.reason).toContain("protected by soulguard");
23
+ expect(result.reason).toContain("soulguard stage IDENTITY.md");
21
24
  });
22
25
 
23
- it("allows Write to a non-vault file", () => {
26
+ it("allows Write to a non-protected file", () => {
24
27
  const result = guardToolCall("Write", { file_path: "README.md" }, defaultOpts);
25
28
  expect(result.blocked).toBe(false);
26
29
  });
27
30
 
28
- it("allows Write to staging copy of a vault file", () => {
29
- const result = guardToolCall("Write", { file_path: ".soulguard/staging/SOUL.md" }, defaultOpts);
31
+ it("allows Write to staging copy of a protected file", () => {
32
+ const result = guardToolCall("Write", { file_path: ".soulguard-staging/SOUL.md" }, defaultOpts);
30
33
  expect(result.blocked).toBe(false);
31
34
  });
32
35
 
@@ -35,9 +38,6 @@ describe("guardToolCall", () => {
35
38
  expect(result.blocked).toBe(false);
36
39
  });
37
40
 
38
- // TODO: re-enable when glob matching is delegated to @soulguard/core isVaulted() API
39
- // For 0.1, only exact matches are supported — globs are not evaluated.
40
-
41
41
  it("handles ./prefix in file paths", () => {
42
42
  const result = guardToolCall("Write", { path: "./SOUL.md" }, defaultOpts);
43
43
  expect(result.blocked).toBe(true);
@@ -53,6 +53,32 @@ describe("guardToolCall", () => {
53
53
  expect(result.blocked).toBe(true);
54
54
  });
55
55
 
56
- // TODO: glob pattern tests re-enable when delegated to @soulguard/core isVaulted() API
57
- // For 0.1, "*.md" in vaultFiles is treated as a literal string, not a glob.
56
+ it("includes the original path in the block reason", () => {
57
+ const result = guardToolCall("Write", { file_path: "./SOUL.md" }, defaultOpts);
58
+ expect(result.blocked).toBe(true);
59
+ expect(result.reason).toContain("./SOUL.md");
60
+ });
61
+
62
+ it("blocks writes to files inside a protected directory", () => {
63
+ const opts: GuardOptions = { protectFiles: ["skills"], stateDir: "/home/test/.openclaw" };
64
+ const result = guardToolCall("Write", { file_path: "skills/my-skill.md" }, opts);
65
+ expect(result.blocked).toBe(true);
66
+ expect(result.reason).toContain("skills/my-skill.md");
67
+ });
68
+
69
+ it("blocks Write with absolute path resolved against stateDir", () => {
70
+ const result = guardToolCall(
71
+ "Write",
72
+ { file_path: "/home/test/.openclaw/SOUL.md" },
73
+ defaultOpts,
74
+ );
75
+ expect(result.blocked).toBe(true);
76
+ expect(result.reason).toContain("protected by soulguard");
77
+ });
78
+
79
+ it("allows writes to files outside a protected directory", () => {
80
+ const opts: GuardOptions = { protectFiles: ["skills"], stateDir: "/home/test/.openclaw" };
81
+ const result = guardToolCall("Write", { file_path: "memory/notes.md" }, opts);
82
+ expect(result.blocked).toBe(false);
83
+ });
58
84
  });
package/src/guard.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  /**
2
- * before_tool_call guard — blocks writes to vault-protected files and
3
- * returns a helpful message pointing the agent to the staging workflow.
2
+ * before_tool_call guard — blocks writes to protected files and
3
+ * returns a helpful message guiding the agent to the staging workflow.
4
4
  */
5
5
 
6
- import { basename } from "node:path";
7
- import { isVaultedFile, normalizePath } from "@soulguard/core";
6
+ import path from "node:path";
7
+ import { isProtectedFile, isStagingPath, stagingPath } from "@soulguard/core";
8
8
 
9
9
  // ── Types ──────────────────────────────────────────────────────────────
10
10
 
11
11
  export type GuardOptions = {
12
- /** Vault file paths/patterns from soulguard.json */
13
- vaultFiles: string[];
12
+ /** Protected file paths/patterns from soulguard.json */
13
+ protectFiles: string[];
14
+ /** Absolute path to the OpenClaw state dir (e.g. ~/.openclaw/) for resolving absolute tool paths. */
15
+ stateDir: string;
14
16
  };
15
17
 
16
18
  export type GuardResult = {
@@ -20,15 +22,12 @@ export type GuardResult = {
20
22
 
21
23
  // ── Constants ──────────────────────────────────────────────────────────
22
24
 
23
- /** OpenClaw tool names that write files. */
24
- const WRITE_TOOLS = new Set(["Write", "Edit"]);
25
+ /** OpenClaw tool names that write files (lowercase — OpenClaw normalizes names). */
26
+ const WRITE_TOOLS = new Set(["write", "edit"]);
25
27
 
26
28
  /** Param keys that carry the target file path. */
27
29
  const PATH_KEYS = ["file_path", "path", "file"] as const;
28
30
 
29
- /** Staging directory — writes here are always allowed. */
30
- const STAGING_PREFIX = ".soulguard/staging/";
31
-
32
31
  // ── Main guard ─────────────────────────────────────────────────────────
33
32
 
34
33
  /**
@@ -41,8 +40,10 @@ export function guardToolCall(
41
40
  params: Record<string, unknown>,
42
41
  options: GuardOptions,
43
42
  ): GuardResult {
44
- // Only intercept file-writing tools
45
- if (!WRITE_TOOLS.has(toolName)) return { blocked: false };
43
+ // Only intercept file-writing tools (compare lowercase for robustness)
44
+ if (!WRITE_TOOLS.has(toolName.toLowerCase())) {
45
+ return { blocked: false };
46
+ }
46
47
 
47
48
  // Extract target path from params
48
49
  let targetPath: string | undefined;
@@ -54,21 +55,28 @@ export function guardToolCall(
54
55
  }
55
56
  }
56
57
 
58
+ // OpenClaw passes absolute paths (e.g. /Users/x/.openclaw/workspace/SOUL.md)
59
+ // but protectFiles uses relative paths (e.g. workspace/SOUL.md). Make relative.
60
+ if (targetPath && path.isAbsolute(targetPath)) {
61
+ targetPath = path.relative(options.stateDir, targetPath);
62
+ }
63
+
57
64
  if (!targetPath) return { blocked: false };
58
65
 
59
- // Never block writes to staging
60
- const norm = normalizePath(targetPath);
61
- if (norm.startsWith(STAGING_PREFIX)) return { blocked: false };
66
+ // Never block writes to staging files
67
+ if (isStagingPath(targetPath)) return { blocked: false };
62
68
 
63
- // Check against vault using core SDK
64
- if (!isVaultedFile(options.vaultFiles, targetPath)) return { blocked: false };
69
+ // Check against protect tier using core SDK
70
+ if (!isProtectedFile(options.protectFiles, targetPath)) return { blocked: false };
65
71
 
66
- const fileName = basename(targetPath);
67
72
  return {
68
73
  blocked: true,
69
- reason:
70
- `${fileName} is vault-protected by soulguard. ` +
71
- `To modify it, edit .soulguard/staging/${norm} instead. ` +
72
- `Your changes will be reviewed and approved by the owner.`,
74
+ reason: [
75
+ `${targetPath} is protected by soulguard.`,
76
+ `To propose changes, run \`soulguard stage ${targetPath}\` to create a working copy,`,
77
+ `then edit the staged file at ${stagingPath(targetPath)}.`,
78
+ `Run \`soulguard diff\` to review your changes.`,
79
+ `Your owner will review and apply the changes.`,
80
+ ].join(" "),
73
81
  };
74
82
  }
package/src/index.ts CHANGED
@@ -3,24 +3,26 @@
3
3
  *
4
4
  * Provides:
5
5
  * - Configuration templates (default, paranoid, relaxed)
6
- * - before_tool_call hooks to intercept writes to vault files
7
- * - Cron and extension directory gating
6
+ * - before_tool_call hook to intercept writes to protected files
7
+ * - Pending-changes context builder (for future before_prompt_build support)
8
8
  */
9
9
 
10
- export { templates, defaultTemplate, paranoidTemplate, relaxedTemplate } from "./templates.js";
10
+ export { templates } from "./templates.js";
11
11
  export type { TemplateName, Template } from "./templates.js";
12
12
 
13
13
  export { createSoulguardPlugin } from "./plugin.js";
14
14
  export type { SoulguardPluginOptions } from "./plugin.js";
15
15
 
16
16
  // Default export for OpenClaw plugin discovery
17
- // createSoulguardPlugin() returns { id, activate(api) } which matches OpenClaw's plugin shape
18
17
  import { createSoulguardPlugin } from "./plugin.js";
19
18
  export default createSoulguardPlugin();
20
19
 
21
20
  export { guardToolCall } from "./guard.js";
22
21
  export type { GuardOptions, GuardResult } from "./guard.js";
23
22
 
23
+ export { getPendingChanges, buildPendingChangesContext } from "./context.js";
24
+ export type { PendingChangesResult } from "./context.js";
25
+
24
26
  export type {
25
27
  OpenClawPluginDefinition,
26
28
  OpenClawPluginApi,
@@ -19,10 +19,12 @@ export type OpenClawPluginApi = {
19
19
  registerHook: (
20
20
  events: string | string[],
21
21
  handler: (...args: unknown[]) => unknown,
22
- opts?: { priority?: number },
22
+ opts?: { name?: string; description?: string; priority?: number },
23
23
  ) => void;
24
24
  registerTool: (tool: AgentTool, opts?: { optional?: boolean }) => void;
25
25
  config: Record<string, unknown>;
26
+ /** Plugin install directory, e.g. ~/.openclaw/extensions/soulguard/ */
27
+ rootDir?: string;
26
28
  runtime: { workspaceDir?: string };
27
29
  resolvePath?: (input: string) => string;
28
30
  logger?: { warn: (msg: string) => void; error: (msg: string) => void };
package/src/plugin.ts CHANGED
@@ -1,16 +1,12 @@
1
1
  /**
2
- * Soulguard OpenClaw plugin — protects vault files from direct writes.
2
+ * Soulguard OpenClaw plugin — protects files from direct writes.
3
3
  */
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
+ import os from "node:os";
6
7
  import { join } from "node:path";
7
- import { status, diff, parseConfig, NodeSystemOps, type SoulguardConfig } from "@soulguard/core";
8
+ import { parseConfig, protectPatterns } from "@soulguard/core";
8
9
 
9
- /** OpenClaw-specific default config — also vaults openclaw.json */
10
- const OPENCLAW_DEFAULT_CONFIG: SoulguardConfig = {
11
- vault: ["openclaw.json", "soulguard.json"],
12
- ledger: [],
13
- };
14
10
  import { guardToolCall } from "./guard.js";
15
11
  import type {
16
12
  BeforeToolCallEvent,
@@ -18,6 +14,11 @@ import type {
18
14
  OpenClawPluginDefinition,
19
15
  } from "./openclaw-types.js";
20
16
 
17
+ // Injected at build time via --define (see package.json build script)
18
+ declare const SOULGUARD_VERSION: string;
19
+ const PKG_VERSION: string =
20
+ typeof SOULGUARD_VERSION !== "undefined" ? SOULGUARD_VERSION : "0.0.0-dev";
21
+
21
22
  /** Shared plugin description (plugin.json keeps its own copy). */
22
23
  export const PLUGIN_DESCRIPTION = "Identity protection for AI agents";
23
24
 
@@ -34,123 +35,39 @@ export function createSoulguardPlugin(options?: SoulguardPluginOptions): OpenCla
34
35
  id: "soulguard",
35
36
  name: "Soulguard",
36
37
  description: PLUGIN_DESCRIPTION,
37
- version: "0.1.0",
38
+ version: PKG_VERSION,
38
39
 
39
40
  activate(api) {
40
- const workspaceDir = api.resolvePath?.(".") ?? api.runtime.workspaceDir ?? ".";
41
+ // Resolve OpenClaw state dir (~/.openclaw) where soulguard.json lives.
42
+ // Cannot use api.resolvePath — it resolves relative to process.cwd(), not the workspace.
43
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() ?? join(os.homedir(), ".openclaw");
41
44
  const configFile = options?.configPath ?? "soulguard.json";
42
- const configPath = api.resolvePath?.(configFile) ?? join(workspaceDir, configFile);
45
+ const configPath = join(stateDir, configFile);
43
46
 
44
- // Load config — fall back to OpenClaw defaults if missing
45
- let config: SoulguardConfig;
46
- let vaultFiles: string[];
47
+ // Load config
48
+ let protectFiles: string[];
47
49
  try {
48
50
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
49
- config = parseConfig(raw);
50
- vaultFiles = config.vault;
51
+ protectFiles = protectPatterns(parseConfig(raw));
51
52
  } catch {
52
- // No config file use OpenClaw defaults (includes openclaw.json)
53
- config = OPENCLAW_DEFAULT_CONFIG;
54
- vaultFiles = config.vault;
55
- api.logger?.warn("soulguard: no soulguard.json found — using OpenClaw defaults");
53
+ api.logger?.warn(`soulguard: no config found at ${configPath} plugin inactive`);
54
+ return;
56
55
  }
57
56
 
58
- if (vaultFiles.length === 0) return;
59
-
60
- // Helper to create ops for the workspace
61
- const createOps = () => new NodeSystemOps(workspaceDir);
62
-
63
- // Status tool
64
- api.registerTool(
65
- {
66
- name: "soulguard_status",
67
- description: "Check soulguard protection status of vault and ledger files",
68
- parameters: { type: "object", properties: {}, required: [] },
69
- async execute(_id, _params) {
70
- const ops = createOps();
71
- const result = await status({
72
- config,
73
- expectedVaultOwnership: { user: "soulguardian", group: "soulguard", mode: "444" },
74
- // TODO: ledger ownership should come from config, not hardcoded (agent user varies per init)
75
- expectedLedgerOwnership: { user: "agent", group: "staff", mode: "644" },
76
- ops,
77
- });
78
- if (!result.ok) {
79
- return { content: [{ type: "text" as const, text: "Status check failed" }] };
80
- }
81
- const lines: string[] = ["Soulguard Status:", ""];
82
- for (const f of [...result.value.vault, ...result.value.ledger]) {
83
- if (f.status === "ok") lines.push(` ✅ ${f.file.path}`);
84
- else if (f.status === "drifted")
85
- lines.push(` ⚠️ ${f.file.path} — ${f.issues.map((i) => i.kind).join(", ")}`);
86
- else if (f.status === "missing") lines.push(` ❌ ${f.path} — missing`);
87
- else if (f.status === "error") lines.push(` ❌ ${f.path} — error: ${f.error.kind}`);
88
- }
89
- if (result.value.issues.length === 0) lines.push("", "All files ok.");
90
- else lines.push("", `${result.value.issues.length} issue(s) found.`);
91
- return { content: [{ type: "text" as const, text: lines.join("\n") }] };
92
- },
93
- },
94
- { optional: true },
95
- );
96
-
97
- // Diff tool
98
- api.registerTool(
99
- {
100
- name: "soulguard_diff",
101
- description: "Show differences between vault files and their staging copies",
102
- parameters: {
103
- type: "object",
104
- properties: {
105
- files: {
106
- type: "array",
107
- items: { type: "string" },
108
- description: "Specific files to diff (default: all vault files)",
109
- },
110
- },
111
- required: [],
112
- },
113
- async execute(_id, params) {
114
- const ops = createOps();
115
- const files = Array.isArray(params.files) ? (params.files as string[]) : undefined;
116
- const result = await diff({ ops, config, files });
117
- if (!result.ok) {
118
- return {
119
- content: [{ type: "text" as const, text: `Diff failed: ${result.error.kind}` }],
120
- };
121
- }
122
- if (!result.value.hasChanges) {
123
- return {
124
- content: [
125
- { type: "text" as const, text: "No differences — staging matches vault." },
126
- ],
127
- };
128
- }
129
- const lines = result.value.files
130
- .filter((d) => d.status === "modified" && d.diff)
131
- .map((d) => `--- ${d.path}\n${d.diff}`);
132
- let text = lines.join("\n\n") || "No modified files.";
133
- if (result.value.approvalHash) {
134
- text += `\n\n────────────────────────────────────────\nApproval hash: ${result.value.approvalHash}\nTo approve: soulguard approve --hash ${result.value.approvalHash}`;
135
- }
136
- return { content: [{ type: "text" as const, text }] };
137
- },
138
- },
139
- { optional: true },
140
- );
57
+ if (protectFiles.length === 0) return;
141
58
 
142
- // Register the guard hook
143
- // Use registerHook (OpenClaw's actual API) with fallback to on()
144
- const hookFn = api.registerHook ?? api.on;
145
- hookFn("before_tool_call", (...args: unknown[]) => {
59
+ // ── Guard hook ─────────────────────────────────────────────────
60
+ // Block writes to protected files with a helpful message
61
+ // guiding the agent to use soulguard CLI commands for staging.
62
+ api.on("before_tool_call", (...args: unknown[]) => {
146
63
  const event = args[0];
147
- // Defense in depth — verify event shape before casting
148
64
  if (!event || typeof event !== "object" || !("toolName" in event)) {
149
65
  return undefined;
150
66
  }
151
67
  const e = event as BeforeToolCallEvent;
152
68
  const result = guardToolCall(e.toolName, e.params, {
153
- vaultFiles,
69
+ protectFiles,
70
+ stateDir,
154
71
  });
155
72
  if (result.blocked) {
156
73
  return { block: true, blockReason: result.reason } satisfies BeforeToolCallResult;
@@ -2,35 +2,31 @@ import { describe, expect, test } from "bun:test";
2
2
  import { ALL_KNOWN_PATHS, templates } from "./templates.js";
3
3
 
4
4
  describe("templates", () => {
5
- for (const [name, template] of Object.entries(templates)) {
6
- test(`${name}: accounts for all known paths`, () => {
7
- const allInTemplate = [...template.vault, ...template.ledger, ...template.unprotected];
8
- const sorted = (arr: string[]) => [...arr].sort();
5
+ const sorted = (arr: readonly string[]) => [...arr].sort();
9
6
 
10
- expect(sorted(allInTemplate)).toEqual(sorted([...ALL_KNOWN_PATHS]));
7
+ for (const [name, template] of Object.entries(templates)) {
8
+ test(`${name}: protect + watch + release = all known paths`, () => {
9
+ const all = [...template.protect, ...template.watch, ...template.release];
10
+ expect(sorted(all)).toEqual(sorted(ALL_KNOWN_PATHS));
11
11
  });
12
12
 
13
13
  test(`${name}: no path appears in multiple tiers`, () => {
14
- const vaultSet = new Set(template.vault);
15
- const ledgerSet = new Set(template.ledger);
16
- const unprotectedSet = new Set(template.unprotected);
14
+ const protectSet = new Set(template.protect);
15
+ const watchSet = new Set(template.watch);
16
+ const releaseSet = new Set(template.release);
17
17
 
18
- for (const path of template.vault) {
19
- expect(ledgerSet.has(path)).toBe(false);
20
- expect(unprotectedSet.has(path)).toBe(false);
18
+ for (const path of template.protect) {
19
+ expect(watchSet.has(path)).toBe(false);
20
+ expect(releaseSet.has(path)).toBe(false);
21
21
  }
22
- for (const path of template.ledger) {
23
- expect(vaultSet.has(path)).toBe(false);
24
- expect(unprotectedSet.has(path)).toBe(false);
22
+ for (const path of template.watch) {
23
+ expect(protectSet.has(path)).toBe(false);
24
+ expect(releaseSet.has(path)).toBe(false);
25
25
  }
26
- for (const path of template.unprotected) {
27
- expect(vaultSet.has(path)).toBe(false);
28
- expect(ledgerSet.has(path)).toBe(false);
26
+ for (const path of template.release) {
27
+ expect(protectSet.has(path)).toBe(false);
28
+ expect(watchSet.has(path)).toBe(false);
29
29
  }
30
30
  });
31
-
32
- test(`${name}: soulguard.json is always in vault`, () => {
33
- expect(template.vault).toContain("soulguard.json");
34
- });
35
31
  }
36
32
  });