@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 +91 -26
- package/package.json +1 -1
- package/src/guard.test.ts +71 -46
- package/src/guard.ts +35 -29
- package/src/index.ts +3 -0
- package/src/openclaw-types.ts +33 -0
- package/src/plugin.ts +126 -6
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.
|
|
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 {
|
|
4737
|
+
return { action: "allow" };
|
|
4736
4738
|
}
|
|
4737
|
-
let
|
|
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
|
-
|
|
4744
|
+
pathKey = key;
|
|
4745
|
+
rawPath = v;
|
|
4742
4746
|
break;
|
|
4743
4747
|
}
|
|
4744
4748
|
}
|
|
4745
|
-
if (
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
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
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
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.
|
|
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.
|
|
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
package/src/guard.test.ts
CHANGED
|
@@ -7,78 +7,103 @@ const defaultOpts: GuardOptions = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
describe("guardToolCall", () => {
|
|
10
|
-
|
|
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.
|
|
13
|
-
|
|
14
|
-
expect(result.
|
|
15
|
-
expect(result.
|
|
16
|
-
expect(result.
|
|
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("
|
|
21
|
+
it("redirects Edit to a protected file", () => {
|
|
20
22
|
const result = guardToolCall("Edit", { path: "IDENTITY.md" }, defaultOpts);
|
|
21
|
-
expect(result.
|
|
22
|
-
|
|
23
|
-
expect(result.
|
|
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("
|
|
32
|
-
const result = guardToolCall("
|
|
33
|
-
expect(result.
|
|
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("
|
|
37
|
-
const
|
|
38
|
-
|
|
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.
|
|
48
|
+
expect(result.action).toBe("redirect");
|
|
49
|
+
if (result.action !== "redirect") return;
|
|
50
|
+
expect(result.pathKey).toBe("path");
|
|
44
51
|
});
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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("
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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("
|
|
63
|
-
const
|
|
64
|
-
|
|
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("
|
|
70
|
-
const result = guardToolCall(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
expect(result.
|
|
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.
|
|
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 —
|
|
3
|
-
*
|
|
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
|
-
|
|
20
|
-
reason
|
|
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
|
-
*
|
|
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 {
|
|
47
|
+
return { action: "allow" };
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
//
|
|
49
|
-
let
|
|
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
|
-
|
|
56
|
+
pathKey = key;
|
|
57
|
+
rawPath = v;
|
|
54
58
|
break;
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
if (isStagingPath(
|
|
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,
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
package/src/openclaw-types.ts
CHANGED
|
@@ -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 —
|
|
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
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
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
|
}
|