@soulguard/openclaw 0.2.1 → 0.2.3

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/dist/index.js CHANGED
@@ -23,10 +23,9 @@ var templates = {
23
23
  "workspace/HEARTBEAT.md",
24
24
  "workspace/BOOTSTRAP.md",
25
25
  "openclaw.json",
26
- "cron/",
27
26
  "extensions/"
28
27
  ],
29
- watch: ["workspace/MEMORY.md", "workspace/memory/", "workspace/skills/"],
28
+ watch: ["workspace/MEMORY.md", "workspace/memory/", "workspace/skills/", "cron/"],
30
29
  release: ["workspace/sessions/"]
31
30
  },
32
31
  paranoid: {
@@ -74,8 +73,9 @@ var templates = {
74
73
  };
75
74
  // src/plugin.ts
76
75
  import { readFileSync } from "node:fs";
76
+ import { access, chmod, copyFile, mkdir } from "node:fs/promises";
77
77
  import os from "node:os";
78
- import { join as join2 } from "node:path";
78
+ import { dirname, join as join2 } from "node:path";
79
79
 
80
80
  // ../core/src/util/result.ts
81
81
  function ok(value) {
@@ -4065,7 +4065,8 @@ var ownershipSchema = exports_external.object({
4065
4065
  mode: exports_external.string()
4066
4066
  });
4067
4067
  var daemonConfigSchema = exports_external.object({
4068
- channel: exports_external.string()
4068
+ channel: exports_external.string().optional(),
4069
+ syncIntervalSecs: exports_external.number().int().nonnegative().optional()
4069
4070
  }).passthrough();
4070
4071
  var soulguardConfigSchema = exports_external.object({
4071
4072
  version: exports_external.literal(1),
@@ -4733,40 +4734,57 @@ var WRITE_TOOLS = new Set(["write", "edit"]);
4733
4734
  var PATH_KEYS = ["file_path", "path", "file"];
4734
4735
  function guardToolCall(toolName, params, options) {
4735
4736
  if (!WRITE_TOOLS.has(toolName.toLowerCase())) {
4736
- return { blocked: false };
4737
+ return { action: "allow" };
4737
4738
  }
4738
- let targetPath;
4739
+ let pathKey;
4740
+ let rawPath;
4739
4741
  for (const key of PATH_KEYS) {
4740
4742
  const v = params[key];
4741
4743
  if (typeof v === "string" && v.length > 0) {
4742
- targetPath = v;
4744
+ pathKey = key;
4745
+ rawPath = v;
4743
4746
  break;
4744
4747
  }
4745
4748
  }
4746
- if (targetPath && path.isAbsolute(targetPath)) {
4747
- targetPath = path.relative(options.stateDir, targetPath);
4748
- }
4749
- if (!targetPath)
4750
- return { blocked: false };
4751
- if (isStagingPath(targetPath))
4752
- return { blocked: false };
4753
- if (!isProtectedFile(options.protectFiles, targetPath))
4754
- return { blocked: false };
4749
+ if (!pathKey || !rawPath)
4750
+ return { action: "allow" };
4751
+ const isAbsolute = path.isAbsolute(rawPath);
4752
+ const relativePath = isAbsolute ? path.relative(options.stateDir, rawPath) : rawPath;
4753
+ if (isStagingPath(relativePath))
4754
+ return { action: "allow" };
4755
+ if (!isProtectedFile(options.protectFiles, relativePath))
4756
+ return { action: "allow" };
4757
+ const relativeStaging = stagingPath(relativePath);
4758
+ const redirectedPath = isAbsolute ? path.join(options.stateDir, relativeStaging) : relativeStaging;
4755
4759
  return {
4756
- blocked: true,
4757
- reason: [
4758
- `${targetPath} is protected by soulguard.`,
4759
- `To propose changes, run \`soulguard stage ${targetPath}\` to create a working copy,`,
4760
- `then edit the staged file at ${stagingPath(targetPath)}.`,
4761
- `Run \`soulguard diff\` to review your changes.`,
4762
- `Your owner will review and apply the changes.`
4763
- ].join(" ")
4760
+ action: "redirect",
4761
+ pathKey,
4762
+ originalPath: relativePath,
4763
+ redirectedPath
4764
4764
  };
4765
4765
  }
4766
4766
 
4767
4767
  // src/plugin.ts
4768
- var PKG_VERSION = "0.2.1";
4768
+ var PKG_VERSION = "0.2.3";
4769
4769
  var PLUGIN_DESCRIPTION = "Identity protection for AI agents";
4770
+ var MAX_TRACKED_REDIRECTS = 1024;
4771
+ var redirectMap = new Map;
4772
+ async function ensureStagingCopy(originalAbsPath, stagingAbsPath) {
4773
+ try {
4774
+ await access(stagingAbsPath);
4775
+ return;
4776
+ } catch {}
4777
+ await mkdir(dirname(stagingAbsPath), { recursive: true });
4778
+ try {
4779
+ await copyFile(originalAbsPath, stagingAbsPath);
4780
+ await chmod(stagingAbsPath, 420);
4781
+ } catch {}
4782
+ }
4783
+ function buildRedirectWarning(info) {
4784
+ return `
4785
+
4786
+ [Soulguard] This edit was redirected to the staging copy at ${info.redirectedPath}. ` + `The original file ${info.originalPath} is protected. ` + "Run `soulguard diff` to review your changes. " + "Your owner will review and apply them.";
4787
+ }
4770
4788
  function createSoulguardPlugin(options) {
4771
4789
  return {
4772
4790
  id: "soulguard",
@@ -4787,7 +4805,7 @@ function createSoulguardPlugin(options) {
4787
4805
  }
4788
4806
  if (protectFiles.length === 0)
4789
4807
  return;
4790
- api.on("before_tool_call", (...args) => {
4808
+ api.on("before_tool_call", async (...args) => {
4791
4809
  const event = args[0];
4792
4810
  if (!event || typeof event !== "object" || !("toolName" in event)) {
4793
4811
  return;
@@ -4797,11 +4815,57 @@ function createSoulguardPlugin(options) {
4797
4815
  protectFiles,
4798
4816
  stateDir
4799
4817
  });
4800
- if (result.blocked) {
4818
+ if (result.action === "block") {
4801
4819
  return { block: true, blockReason: result.reason };
4802
4820
  }
4821
+ if (result.action === "redirect") {
4822
+ const originalAbsPath = join2(stateDir, result.originalPath);
4823
+ const stagingAbsPath = result.redirectedPath.startsWith("/") ? result.redirectedPath : join2(stateDir, result.redirectedPath);
4824
+ await ensureStagingCopy(originalAbsPath, stagingAbsPath);
4825
+ const toolCallId = e.toolCallId;
4826
+ if (toolCallId) {
4827
+ if (redirectMap.size >= MAX_TRACKED_REDIRECTS) {
4828
+ const oldest = redirectMap.keys().next().value;
4829
+ if (oldest)
4830
+ redirectMap.delete(oldest);
4831
+ }
4832
+ redirectMap.set(toolCallId, {
4833
+ originalPath: result.originalPath,
4834
+ redirectedPath: result.redirectedPath
4835
+ });
4836
+ }
4837
+ return {
4838
+ params: { [result.pathKey]: result.redirectedPath }
4839
+ };
4840
+ }
4803
4841
  return;
4804
4842
  });
4843
+ api.on("tool_result_persist", (...args) => {
4844
+ const event = args[0];
4845
+ if (!event?.toolCallId || !event.message)
4846
+ return;
4847
+ const info = redirectMap.get(event.toolCallId);
4848
+ if (!info)
4849
+ return;
4850
+ redirectMap.delete(event.toolCallId);
4851
+ const warning = buildRedirectWarning(info);
4852
+ const msg = event.message;
4853
+ const content = Array.isArray(msg.content) ? [...msg.content] : [];
4854
+ let lastText;
4855
+ for (let i = content.length - 1;i >= 0; i--) {
4856
+ const block = content[i];
4857
+ if (block && block.type === "text") {
4858
+ lastText = block;
4859
+ break;
4860
+ }
4861
+ }
4862
+ if (lastText && lastText.text != null) {
4863
+ lastText.text += warning;
4864
+ } else {
4865
+ content.push({ type: "text", text: warning.trimStart() });
4866
+ }
4867
+ return { message: { ...msg, content } };
4868
+ });
4805
4869
  }
4806
4870
  };
4807
4871
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulguard/openclaw",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/guard.test.ts CHANGED
@@ -7,78 +7,103 @@ const defaultOpts: GuardOptions = {
7
7
  };
8
8
 
9
9
  describe("guardToolCall", () => {
10
- it("blocks Write to a protected file", () => {
10
+ // ── Redirect (protected file writes) ──────────────────────────────
11
+
12
+ it("redirects Write to a protected file", () => {
11
13
  const result = guardToolCall("Write", { file_path: "SOUL.md" }, defaultOpts);
12
- expect(result.blocked).toBe(true);
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");
14
+ expect(result.action).toBe("redirect");
15
+ if (result.action !== "redirect") return;
16
+ expect(result.pathKey).toBe("file_path");
17
+ expect(result.originalPath).toBe("SOUL.md");
18
+ expect(result.redirectedPath).toBe(".soulguard-staging/SOUL.md");
17
19
  });
18
20
 
19
- it("blocks Edit to a protected file", () => {
21
+ it("redirects Edit to a protected file", () => {
20
22
  const result = guardToolCall("Edit", { path: "IDENTITY.md" }, defaultOpts);
21
- expect(result.blocked).toBe(true);
22
- expect(result.reason).toContain("protected by soulguard");
23
- expect(result.reason).toContain("soulguard stage IDENTITY.md");
24
- });
25
-
26
- it("allows Write to a non-protected file", () => {
27
- const result = guardToolCall("Write", { file_path: "README.md" }, defaultOpts);
28
- expect(result.blocked).toBe(false);
23
+ expect(result.action).toBe("redirect");
24
+ if (result.action !== "redirect") return;
25
+ expect(result.pathKey).toBe("path");
26
+ expect(result.originalPath).toBe("IDENTITY.md");
27
+ expect(result.redirectedPath).toBe(".soulguard-staging/IDENTITY.md");
29
28
  });
30
29
 
31
- it("allows Write to staging copy of a protected file", () => {
32
- const result = guardToolCall("Write", { file_path: ".soulguard-staging/SOUL.md" }, defaultOpts);
33
- expect(result.blocked).toBe(false);
30
+ it("redirects with the 'file' param key", () => {
31
+ const result = guardToolCall("Edit", { file: "SOUL.md" }, defaultOpts);
32
+ expect(result.action).toBe("redirect");
33
+ if (result.action !== "redirect") return;
34
+ expect(result.pathKey).toBe("file");
34
35
  });
35
36
 
36
- it("allows non-write tools (e.g. Read)", () => {
37
- const result = guardToolCall("Read", { file_path: "SOUL.md" }, defaultOpts);
38
- expect(result.blocked).toBe(false);
37
+ it("redirects writes to files inside a protected directory", () => {
38
+ const opts: GuardOptions = { protectFiles: ["skills"], stateDir: "/home/test/.openclaw" };
39
+ const result = guardToolCall("Write", { file_path: "skills/my-skill.md" }, opts);
40
+ expect(result.action).toBe("redirect");
41
+ if (result.action !== "redirect") return;
42
+ expect(result.originalPath).toBe("skills/my-skill.md");
43
+ expect(result.redirectedPath).toBe(".soulguard-staging/skills/my-skill.md");
39
44
  });
40
45
 
41
46
  it("handles ./prefix in file paths", () => {
42
47
  const result = guardToolCall("Write", { path: "./SOUL.md" }, defaultOpts);
43
- expect(result.blocked).toBe(true);
48
+ expect(result.action).toBe("redirect");
49
+ if (result.action !== "redirect") return;
50
+ expect(result.pathKey).toBe("path");
44
51
  });
45
52
 
46
- it("allows when no path param is present", () => {
47
- const result = guardToolCall("Write", { content: "hello" }, defaultOpts);
48
- expect(result.blocked).toBe(false);
53
+ // ── Absolute path handling ────────────────────────────────────────
54
+
55
+ it("redirects absolute path to absolute staging path", () => {
56
+ const result = guardToolCall(
57
+ "Write",
58
+ { file_path: "/home/test/.openclaw/SOUL.md" },
59
+ defaultOpts,
60
+ );
61
+ expect(result.action).toBe("redirect");
62
+ if (result.action !== "redirect") return;
63
+ expect(result.originalPath).toBe("SOUL.md");
64
+ expect(result.redirectedPath).toBe("/home/test/.openclaw/.soulguard-staging/SOUL.md");
49
65
  });
50
66
 
51
- it("checks file param key as well", () => {
52
- const result = guardToolCall("Edit", { file: "SOUL.md" }, defaultOpts);
53
- expect(result.blocked).toBe(true);
67
+ it("redirects absolute path inside protected directory", () => {
68
+ const opts: GuardOptions = { protectFiles: ["skills"], stateDir: "/home/test/.openclaw" };
69
+ const result = guardToolCall(
70
+ "Edit",
71
+ { file_path: "/home/test/.openclaw/skills/my-skill.md" },
72
+ opts,
73
+ );
74
+ expect(result.action).toBe("redirect");
75
+ if (result.action !== "redirect") return;
76
+ expect(result.originalPath).toBe("skills/my-skill.md");
77
+ expect(result.redirectedPath).toBe(
78
+ "/home/test/.openclaw/.soulguard-staging/skills/my-skill.md",
79
+ );
54
80
  });
55
81
 
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");
82
+ // ── Allow (non-protected / non-write / staging) ───────────────────
83
+
84
+ it("allows Write to a non-protected file", () => {
85
+ const result = guardToolCall("Write", { file_path: "README.md" }, defaultOpts);
86
+ expect(result.action).toBe("allow");
60
87
  });
61
88
 
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");
89
+ it("allows Write to staging copy of a protected file", () => {
90
+ const result = guardToolCall("Write", { file_path: ".soulguard-staging/SOUL.md" }, defaultOpts);
91
+ expect(result.action).toBe("allow");
67
92
  });
68
93
 
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");
94
+ it("allows non-write tools (e.g. Read)", () => {
95
+ const result = guardToolCall("Read", { file_path: "SOUL.md" }, defaultOpts);
96
+ expect(result.action).toBe("allow");
97
+ });
98
+
99
+ it("allows when no path param is present", () => {
100
+ const result = guardToolCall("Write", { content: "hello" }, defaultOpts);
101
+ expect(result.action).toBe("allow");
77
102
  });
78
103
 
79
104
  it("allows writes to files outside a protected directory", () => {
80
105
  const opts: GuardOptions = { protectFiles: ["skills"], stateDir: "/home/test/.openclaw" };
81
106
  const result = guardToolCall("Write", { file_path: "memory/notes.md" }, opts);
82
- expect(result.blocked).toBe(false);
107
+ expect(result.action).toBe("allow");
83
108
  });
84
109
  });
package/src/guard.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * before_tool_call guard — blocks writes to protected files and
3
- * returns a helpful message guiding the agent to the staging workflow.
2
+ * before_tool_call guard — redirects writes to protected files into the
3
+ * staging tree so agents can propose changes without direct access.
4
4
  */
5
5
 
6
6
  import path from "node:path";
@@ -15,10 +15,10 @@ export type GuardOptions = {
15
15
  stateDir: string;
16
16
  };
17
17
 
18
- export type GuardResult = {
19
- blocked: boolean;
20
- reason?: string;
21
- };
18
+ export type GuardResult =
19
+ | { action: "allow" }
20
+ | { action: "block"; reason: string }
21
+ | { action: "redirect"; pathKey: string; originalPath: string; redirectedPath: string };
22
22
 
23
23
  // ── Constants ──────────────────────────────────────────────────────────
24
24
 
@@ -31,9 +31,11 @@ const PATH_KEYS = ["file_path", "path", "file"] as const;
31
31
  // ── Main guard ─────────────────────────────────────────────────────────
32
32
 
33
33
  /**
34
- * Evaluate whether a tool call should be blocked.
34
+ * Evaluate whether a tool call should be redirected, blocked, or allowed.
35
35
  *
36
- * Returns `{ blocked: false }` to allow, or `{ blocked: true, reason }` to block.
36
+ * For writes to protected files the guard returns a `redirect` result that
37
+ * tells the caller which param key to rewrite and the staging-tree path to
38
+ * redirect to.
37
39
  */
38
40
  export function guardToolCall(
39
41
  toolName: string,
@@ -42,41 +44,45 @@ export function guardToolCall(
42
44
  ): GuardResult {
43
45
  // Only intercept file-writing tools (compare lowercase for robustness)
44
46
  if (!WRITE_TOOLS.has(toolName.toLowerCase())) {
45
- return { blocked: false };
47
+ return { action: "allow" };
46
48
  }
47
49
 
48
- // Extract target path from params
49
- let targetPath: string | undefined;
50
+ // Find which param key carries the path
51
+ let pathKey: string | undefined;
52
+ let rawPath: string | undefined;
50
53
  for (const key of PATH_KEYS) {
51
54
  const v = params[key];
52
55
  if (typeof v === "string" && v.length > 0) {
53
- targetPath = v;
56
+ pathKey = key;
57
+ rawPath = v;
54
58
  break;
55
59
  }
56
60
  }
57
61
 
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
- }
62
+ if (!pathKey || !rawPath) return { action: "allow" };
63
63
 
64
- if (!targetPath) return { blocked: false };
64
+ // Resolve to relative for protect-check. OpenClaw passes absolute paths
65
+ // (e.g. /Users/x/.openclaw/workspace/SOUL.md) but protectFiles uses
66
+ // relative paths (e.g. workspace/SOUL.md).
67
+ const isAbsolute = path.isAbsolute(rawPath);
68
+ const relativePath = isAbsolute ? path.relative(options.stateDir, rawPath) : rawPath;
65
69
 
66
- // Never block writes to staging files
67
- if (isStagingPath(targetPath)) return { blocked: false };
70
+ // Never intercept writes to staging files
71
+ if (isStagingPath(relativePath)) return { action: "allow" };
68
72
 
69
73
  // Check against protect tier using core SDK
70
- if (!isProtectedFile(options.protectFiles, targetPath)) return { blocked: false };
74
+ if (!isProtectedFile(options.protectFiles, relativePath)) return { action: "allow" };
75
+
76
+ // Compute the staging redirect path, preserving absolute/relative format
77
+ const relativeStaging = stagingPath(relativePath);
78
+ const redirectedPath = isAbsolute
79
+ ? path.join(options.stateDir, relativeStaging)
80
+ : relativeStaging;
71
81
 
72
82
  return {
73
- blocked: true,
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(" "),
83
+ action: "redirect",
84
+ pathKey,
85
+ originalPath: relativePath,
86
+ redirectedPath,
81
87
  };
82
88
  }
package/src/index.ts CHANGED
@@ -28,6 +28,9 @@ export type {
28
28
  OpenClawPluginApi,
29
29
  AgentTool,
30
30
  AgentToolResult,
31
+ AgentMessage,
31
32
  BeforeToolCallEvent,
32
33
  BeforeToolCallResult,
34
+ ToolResultPersistEvent,
35
+ ToolResultPersistResult,
33
36
  } from "./openclaw-types.js";
@@ -41,12 +41,45 @@ export type AgentToolResult = {
41
41
  content: Array<{ type: "text"; text: string }>;
42
42
  };
43
43
 
44
+ // ── before_tool_call hook ─────────────────────────────────────────────
45
+
44
46
  export type BeforeToolCallEvent = {
45
47
  toolName: string;
46
48
  params: Record<string, unknown>;
49
+ runId?: string;
50
+ toolCallId?: string;
47
51
  };
48
52
 
49
53
  export type BeforeToolCallResult = {
54
+ params?: Record<string, unknown>;
50
55
  block?: boolean;
51
56
  blockReason?: string;
52
57
  };
58
+
59
+ // ── tool_result_persist hook ──────────────────────────────────────────
60
+
61
+ export type AgentMessage = {
62
+ role: string;
63
+ toolCallId?: string;
64
+ isError?: boolean;
65
+ content: Array<{ type: string; text?: string }>;
66
+ [key: string]: unknown;
67
+ };
68
+
69
+ export type ToolResultPersistEvent = {
70
+ toolName?: string;
71
+ toolCallId?: string;
72
+ message: AgentMessage;
73
+ isSynthetic?: boolean;
74
+ };
75
+
76
+ export type ToolResultPersistContext = {
77
+ agentId?: string;
78
+ sessionKey?: string;
79
+ toolName?: string;
80
+ toolCallId?: string;
81
+ };
82
+
83
+ export type ToolResultPersistResult = {
84
+ message?: AgentMessage;
85
+ };
package/src/plugin.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Soulguard OpenClaw plugin — protects files from direct writes.
2
+ * Soulguard OpenClaw plugin — redirects writes to protected files into the
3
+ * staging tree so agents can propose changes without direct access.
3
4
  */
4
5
 
5
6
  import { readFileSync } from "node:fs";
7
+ import { access, chmod, copyFile, mkdir } from "node:fs/promises";
6
8
  import os from "node:os";
7
- import { join } from "node:path";
9
+ import { dirname, join } from "node:path";
8
10
  import { parseConfig, protectPatterns } from "@soulguard/core";
9
11
 
10
12
  import { guardToolCall } from "./guard.js";
@@ -12,6 +14,8 @@ import type {
12
14
  BeforeToolCallEvent,
13
15
  BeforeToolCallResult,
14
16
  OpenClawPluginDefinition,
17
+ ToolResultPersistEvent,
18
+ ToolResultPersistResult,
15
19
  } from "./openclaw-types.js";
16
20
 
17
21
  // Injected at build time via --define (see package.json build script)
@@ -27,6 +31,57 @@ export type SoulguardPluginOptions = {
27
31
  configPath?: string;
28
32
  };
29
33
 
34
+ // ── Redirect correlation state ────────────────────────────────────────
35
+
36
+ const MAX_TRACKED_REDIRECTS = 1024;
37
+
38
+ type RedirectInfo = {
39
+ originalPath: string;
40
+ redirectedPath: string;
41
+ };
42
+
43
+ const redirectMap = new Map<string, RedirectInfo>();
44
+
45
+ // ── Helpers ───────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Ensure a staging copy exists, creating it from the original if needed.
49
+ * Uses raw fs operations — no SystemOperations required.
50
+ */
51
+ async function ensureStagingCopy(originalAbsPath: string, stagingAbsPath: string): Promise<void> {
52
+ try {
53
+ await access(stagingAbsPath);
54
+ return; // already exists
55
+ } catch {
56
+ // doesn't exist yet — create it
57
+ }
58
+
59
+ await mkdir(dirname(stagingAbsPath), { recursive: true });
60
+
61
+ try {
62
+ await copyFile(originalAbsPath, stagingAbsPath);
63
+ // copyFile preserves the source mode (444 for protected files).
64
+ // The agent owns the new copy but needs write permission.
65
+ await chmod(stagingAbsPath, 0o644);
66
+ } catch {
67
+ // Original doesn't exist (agent creating a new file) — let the Write tool create it
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Build the warning message appended to tool results for redirected writes.
73
+ */
74
+ function buildRedirectWarning(info: RedirectInfo): string {
75
+ return (
76
+ `\n\n[Soulguard] This edit was redirected to the staging copy at ${info.redirectedPath}. ` +
77
+ `The original file ${info.originalPath} is protected. ` +
78
+ "Run `soulguard diff` to review your changes. " +
79
+ "Your owner will review and apply them."
80
+ );
81
+ }
82
+
83
+ // ── Plugin factory ────────────────────────────────────────────────────
84
+
30
85
  /**
31
86
  * Create the soulguard OpenClaw plugin definition.
32
87
  */
@@ -57,9 +112,8 @@ export function createSoulguardPlugin(options?: SoulguardPluginOptions): OpenCla
57
112
  if (protectFiles.length === 0) return;
58
113
 
59
114
  // ── 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[]) => {
115
+ // Redirect writes to protected files into the staging tree.
116
+ api.on("before_tool_call", async (...args: unknown[]) => {
63
117
  const event = args[0];
64
118
  if (!event || typeof event !== "object" || !("toolName" in event)) {
65
119
  return undefined;
@@ -69,11 +123,77 @@ export function createSoulguardPlugin(options?: SoulguardPluginOptions): OpenCla
69
123
  protectFiles,
70
124
  stateDir,
71
125
  });
72
- if (result.blocked) {
126
+
127
+ if (result.action === "block") {
73
128
  return { block: true, blockReason: result.reason } satisfies BeforeToolCallResult;
74
129
  }
130
+
131
+ if (result.action === "redirect") {
132
+ // Auto-create the staging copy from the original
133
+ const originalAbsPath = join(stateDir, result.originalPath);
134
+ const stagingAbsPath = result.redirectedPath.startsWith("/")
135
+ ? result.redirectedPath
136
+ : join(stateDir, result.redirectedPath);
137
+
138
+ await ensureStagingCopy(originalAbsPath, stagingAbsPath);
139
+
140
+ // Store redirect info for the tool_result_persist hook
141
+ const toolCallId = e.toolCallId;
142
+ if (toolCallId) {
143
+ if (redirectMap.size >= MAX_TRACKED_REDIRECTS) {
144
+ const oldest = redirectMap.keys().next().value;
145
+ if (oldest) redirectMap.delete(oldest);
146
+ }
147
+ redirectMap.set(toolCallId, {
148
+ originalPath: result.originalPath,
149
+ redirectedPath: result.redirectedPath,
150
+ });
151
+ }
152
+
153
+ // Rewrite the path param to point to the staging copy
154
+ return {
155
+ params: { [result.pathKey]: result.redirectedPath },
156
+ } satisfies BeforeToolCallResult;
157
+ }
158
+
75
159
  return undefined;
76
160
  });
161
+
162
+ // ── Result annotation hook ─────────────────────────────────────
163
+ // Append a warning to the tool result when a write was redirected.
164
+ // This hook is synchronous — no async allowed.
165
+ api.on("tool_result_persist", (...args: unknown[]) => {
166
+ const event = args[0] as ToolResultPersistEvent | undefined;
167
+ if (!event?.toolCallId || !event.message) return undefined;
168
+
169
+ const info = redirectMap.get(event.toolCallId);
170
+ if (!info) return undefined;
171
+
172
+ // Consume — one-shot per tool call
173
+ redirectMap.delete(event.toolCallId);
174
+
175
+ const warning = buildRedirectWarning(info);
176
+ const msg = event.message;
177
+
178
+ // Clone content and append the warning
179
+ type ContentBlock = { type: string; text?: string };
180
+ const content: ContentBlock[] = Array.isArray(msg.content) ? [...msg.content] : [];
181
+ let lastText: ContentBlock | undefined;
182
+ for (let i = content.length - 1; i >= 0; i--) {
183
+ const block = content[i];
184
+ if (block && block.type === "text") {
185
+ lastText = block;
186
+ break;
187
+ }
188
+ }
189
+ if (lastText && lastText.text != null) {
190
+ lastText.text += warning;
191
+ } else {
192
+ content.push({ type: "text", text: warning.trimStart() });
193
+ }
194
+
195
+ return { message: { ...msg, content } } satisfies ToolResultPersistResult;
196
+ });
77
197
  },
78
198
  };
79
199
  }
package/src/templates.ts CHANGED
@@ -51,10 +51,9 @@ export const templates: Record<TemplateName, Template> = {
51
51
  "workspace/HEARTBEAT.md",
52
52
  "workspace/BOOTSTRAP.md",
53
53
  "openclaw.json",
54
- "cron/",
55
54
  "extensions/",
56
55
  ],
57
- watch: ["workspace/MEMORY.md", "workspace/memory/", "workspace/skills/"],
56
+ watch: ["workspace/MEMORY.md", "workspace/memory/", "workspace/skills/", "cron/"],
58
57
  release: ["workspace/sessions/"],
59
58
  },
60
59