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