@soulguard/openclaw 0.2.2 → 0.3.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/dist/index.js CHANGED
@@ -73,8 +73,9 @@ var templates = {
73
73
  };
74
74
  // src/plugin.ts
75
75
  import { readFileSync } from "node:fs";
76
+ import { access, chmod, copyFile, mkdir } from "node:fs/promises";
76
77
  import os from "node:os";
77
- import { join as join2 } from "node:path";
78
+ import { dirname, join as join2 } from "node:path";
78
79
 
79
80
  // ../core/src/util/result.ts
80
81
  function ok(value) {
@@ -4064,7 +4065,8 @@ var ownershipSchema = exports_external.object({
4064
4065
  mode: exports_external.string()
4065
4066
  });
4066
4067
  var daemonConfigSchema = exports_external.object({
4067
- channel: exports_external.string()
4068
+ channel: exports_external.string().optional(),
4069
+ syncIntervalSecs: exports_external.number().int().nonnegative().optional()
4068
4070
  }).passthrough();
4069
4071
  var soulguardConfigSchema = exports_external.object({
4070
4072
  version: exports_external.literal(1),
@@ -4447,7 +4449,7 @@ class StateTree {
4447
4449
  return this.flatFiles().filter((f) => f.status !== "unchanged");
4448
4450
  }
4449
4451
  stagedFiles() {
4450
- return this.flatFiles().filter((f) => f.stagedHash !== null || f.status === "deleted");
4452
+ return this.flatFiles().filter((f) => f.status !== "unchanged");
4451
4453
  }
4452
4454
  driftedEntities() {
4453
4455
  return collectDrifts(this.entities, this.protectOwnership);
@@ -4732,40 +4734,57 @@ var WRITE_TOOLS = new Set(["write", "edit"]);
4732
4734
  var PATH_KEYS = ["file_path", "path", "file"];
4733
4735
  function guardToolCall(toolName, params, options) {
4734
4736
  if (!WRITE_TOOLS.has(toolName.toLowerCase())) {
4735
- return { blocked: false };
4737
+ return { action: "allow" };
4736
4738
  }
4737
- let targetPath;
4739
+ let pathKey;
4740
+ let rawPath;
4738
4741
  for (const key of PATH_KEYS) {
4739
4742
  const v = params[key];
4740
4743
  if (typeof v === "string" && v.length > 0) {
4741
- targetPath = v;
4744
+ pathKey = key;
4745
+ rawPath = v;
4742
4746
  break;
4743
4747
  }
4744
4748
  }
4745
- if (targetPath && path.isAbsolute(targetPath)) {
4746
- targetPath = path.relative(options.stateDir, targetPath);
4747
- }
4748
- if (!targetPath)
4749
- return { blocked: false };
4750
- if (isStagingPath(targetPath))
4751
- return { blocked: false };
4752
- if (!isProtectedFile(options.protectFiles, targetPath))
4753
- 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;
4754
4759
  return {
4755
- blocked: true,
4756
- reason: [
4757
- `${targetPath} is protected by soulguard.`,
4758
- `To propose changes, run \`soulguard stage ${targetPath}\` to create a working copy,`,
4759
- `then edit the staged file at ${stagingPath(targetPath)}.`,
4760
- `Run \`soulguard diff\` to review your changes.`,
4761
- `Your owner will review and apply the changes.`
4762
- ].join(" ")
4760
+ action: "redirect",
4761
+ pathKey,
4762
+ originalPath: relativePath,
4763
+ redirectedPath
4763
4764
  };
4764
4765
  }
4765
4766
 
4766
4767
  // src/plugin.ts
4767
- var PKG_VERSION = "0.2.2";
4768
+ var PKG_VERSION = "0.3.0";
4768
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
+ }
4769
4788
  function createSoulguardPlugin(options) {
4770
4789
  return {
4771
4790
  id: "soulguard",
@@ -4786,7 +4805,7 @@ function createSoulguardPlugin(options) {
4786
4805
  }
4787
4806
  if (protectFiles.length === 0)
4788
4807
  return;
4789
- api.on("before_tool_call", (...args) => {
4808
+ api.on("before_tool_call", async (...args) => {
4790
4809
  const event = args[0];
4791
4810
  if (!event || typeof event !== "object" || !("toolName" in event)) {
4792
4811
  return;
@@ -4796,11 +4815,57 @@ function createSoulguardPlugin(options) {
4796
4815
  protectFiles,
4797
4816
  stateDir
4798
4817
  });
4799
- if (result.blocked) {
4818
+ if (result.action === "block") {
4800
4819
  return { block: true, blockReason: result.reason };
4801
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
+ }
4802
4841
  return;
4803
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
+ });
4804
4869
  }
4805
4870
  };
4806
4871
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulguard/openclaw",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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
  }