@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 +91 -27
- 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/src/templates.ts +1 -2
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 {
|
|
4737
|
+
return { action: "allow" };
|
|
4737
4738
|
}
|
|
4738
|
-
let
|
|
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
|
-
|
|
4744
|
+
pathKey = key;
|
|
4745
|
+
rawPath = v;
|
|
4743
4746
|
break;
|
|
4744
4747
|
}
|
|
4745
4748
|
}
|
|
4746
|
-
if (
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
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
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
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.
|
|
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.
|
|
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
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
|
}
|
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
|
|