@groeponline/pi-wishcraft 0.23.3 → 0.24.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/CHANGELOG.md +11 -0
- package/README.md +29 -0
- package/docs/configuration.md +2 -0
- package/package.json +1 -1
- package/src/extension/hooks/hooks-runner.ts +10 -2
- package/src/extension/hooks/index.ts +52 -23
- package/src/extension/hooks/policy-config.ts +98 -0
- package/src/extension/hooks/policy-engine.ts +80 -0
- package/src/extension/session/activate.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.24.0] - 2026-08-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Declarative policy engine (`wishcraft.policy`): in-process deny/inject rules in global settings, evaluated before command hooks. No process spawn. `policyEnabled: false` is the kill-switch.
|
|
9
|
+
|
|
10
|
+
## [0.23.4] - 2026-08-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Command hooks, session context inject, and `/repairs` run: `setupHooks` is registered on activation.
|
|
14
|
+
- Hook runner ignores EPIPE when a command exits before reading stdin.
|
|
15
|
+
|
|
5
16
|
## [0.23.3] - 2026-08-20
|
|
6
17
|
|
|
7
18
|
### Fixed
|
package/README.md
CHANGED
|
@@ -171,6 +171,35 @@ PY
|
|
|
171
171
|
|
|
172
172
|
Repairs run on custom/extension tools only, before hooks: drop null optionals, parse JSON-string arrays before wrapping, turn `{}` into `[]` on array keys, wrap bare strings, alias `filePath` / `absolutePath` / `target_file` to `path`, unwrap degenerate markdown auto-links. Core tools (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) are never rewritten. `/repairs` prints the counters.
|
|
173
173
|
|
|
174
|
+
## Policy
|
|
175
|
+
|
|
176
|
+
Declarative deny/inject rules in the **global** agent settings file. No shell commands — pure in-process regex. Evaluated before command hooks. `wishcraft.policyEnabled: false` disables policy without deleting rules.
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"wishcraft": {
|
|
181
|
+
"policy": [
|
|
182
|
+
{
|
|
183
|
+
"action": "deny",
|
|
184
|
+
"tool": "bash",
|
|
185
|
+
"match": "sudo\\s+rm",
|
|
186
|
+
"reason": "destructive sudo rm"
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"action": "inject",
|
|
190
|
+
"tool": "read",
|
|
191
|
+
"pathMatch": "\\.env",
|
|
192
|
+
"context": "Do not leak secrets from .env files into the conversation."
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**deny** — regex on tool input (`bash` uses `command`; other tools use JSON-serialized input). First match wins; the tool call is blocked with `reason`.
|
|
200
|
+
|
|
201
|
+
**inject** — regex on file path after a matching tool completes; context is appended to the tool result (same shape as postToolUse hook `additionalContext`).
|
|
202
|
+
|
|
174
203
|
## Limits
|
|
175
204
|
|
|
176
205
|
- No mouse on the live footer. Pi core owns that surface.
|
package/docs/configuration.md
CHANGED
|
@@ -218,6 +218,8 @@ Set `powerline.costAlert` to a USD threshold to get a single warning notificatio
|
|
|
218
218
|
|
|
219
219
|
Command hooks live under `wishcraft.hooks` in the **global** agent settings file. `wishcraft.hooksEnabled: false` is the kill-switch. See the README Hooks section for three copy-paste examples (bash-guard, write-audit, SessionStart git-status).
|
|
220
220
|
|
|
221
|
+
Declarative policy rules (`wishcraft.policy`) live in the same global file. They run in-process before command hooks: **deny** blocks a tool call when input matches a regex; **inject** appends context after a matching read/write path. `wishcraft.policyEnabled: false` disables policy without deleting rules. See the README Policy section for two copy-paste examples.
|
|
222
|
+
|
|
221
223
|
Tool-input repairs apply to custom/extension tools only (`wishcraft.repairsEnabled`, default on). `/repairs` prints the counters.
|
|
222
224
|
|
|
223
225
|
## Token budget
|
package/package.json
CHANGED
|
@@ -110,12 +110,20 @@ export function runHookCommand(
|
|
|
110
110
|
finish(null);
|
|
111
111
|
});
|
|
112
112
|
child.on("close", (code) => finish(code));
|
|
113
|
+
// Hooks that exit before reading stdin (typical `exit 2` deny scripts)
|
|
114
|
+
// close the pipe; ignore EPIPE so the harness still records the exit code.
|
|
115
|
+
child.stdin?.on("error", (error: NodeJS.ErrnoException) => {
|
|
116
|
+
if (error.code === "EPIPE") return;
|
|
117
|
+
stderr += String(error);
|
|
118
|
+
});
|
|
113
119
|
|
|
114
120
|
try {
|
|
115
121
|
child.stdin?.write(JSON.stringify(payload));
|
|
116
122
|
child.stdin?.end();
|
|
117
|
-
} catch {
|
|
118
|
-
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if ((error as NodeJS.ErrnoException).code !== "EPIPE") {
|
|
125
|
+
stderr += String(error);
|
|
126
|
+
}
|
|
119
127
|
}
|
|
120
128
|
});
|
|
121
129
|
}
|
|
@@ -27,6 +27,14 @@ import {
|
|
|
27
27
|
runHookCommand,
|
|
28
28
|
type HookPayload,
|
|
29
29
|
} from "./hooks-runner.ts";
|
|
30
|
+
import {
|
|
31
|
+
parsePolicySettings,
|
|
32
|
+
type PolicyRule,
|
|
33
|
+
} from "./policy-config.ts";
|
|
34
|
+
import {
|
|
35
|
+
evalPostToolUsePolicy,
|
|
36
|
+
evalPreToolUsePolicy,
|
|
37
|
+
} from "./policy-engine.ts";
|
|
30
38
|
import {
|
|
31
39
|
recordRepairs,
|
|
32
40
|
repairToolInput,
|
|
@@ -34,6 +42,8 @@ import {
|
|
|
34
42
|
|
|
35
43
|
let hooksSettings: WishcraftHooksSettings = {};
|
|
36
44
|
let hooksEnabled = false;
|
|
45
|
+
let policyRules: PolicyRule[] = [];
|
|
46
|
+
let policyEnabled = false;
|
|
37
47
|
let repairsEnabled = true;
|
|
38
48
|
let pendingSessionContext: string | null = null;
|
|
39
49
|
|
|
@@ -46,6 +56,9 @@ function refreshSettings(cwd: string): void {
|
|
|
46
56
|
const parsed = parseHooksSettings(globalWishcraft);
|
|
47
57
|
hooksSettings = parsed.hooks;
|
|
48
58
|
hooksEnabled = parsed.enabled && hasAnyHook(parsed.hooks);
|
|
59
|
+
const policy = parsePolicySettings(globalWishcraft);
|
|
60
|
+
policyRules = policy.rules;
|
|
61
|
+
policyEnabled = policy.enabled;
|
|
49
62
|
repairsEnabled =
|
|
50
63
|
!merged ||
|
|
51
64
|
typeof merged !== "object" ||
|
|
@@ -74,11 +87,15 @@ function basePayload(
|
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
/** Registreer de hooks + repairs op de pi extension API. */
|
|
77
|
-
export function setupHooks(
|
|
78
|
-
|
|
90
|
+
export function setupHooks(
|
|
91
|
+
pi: ExtensionAPI,
|
|
92
|
+
rt: RuntimeState,
|
|
93
|
+
cwd: string = process.cwd(),
|
|
94
|
+
): void {
|
|
95
|
+
refreshSettings(rt.currentCtx?.cwd ?? cwd);
|
|
79
96
|
|
|
80
97
|
pi.on("session_start", async () => {
|
|
81
|
-
refreshSettings(cwd);
|
|
98
|
+
refreshSettings(rt.currentCtx?.cwd ?? process.cwd());
|
|
82
99
|
if (!hooksEnabled) return;
|
|
83
100
|
const cmds = commandsFor(hooksSettings, "sessionStart");
|
|
84
101
|
if (cmds.length === 0) return;
|
|
@@ -121,6 +138,12 @@ export function setupHooks(pi: ExtensionAPI, rt: RuntimeState, cwd: string): voi
|
|
|
121
138
|
const result = repairToolInput(toolName, event.input);
|
|
122
139
|
if (result.repairs.length > 0) recordRepairs(result);
|
|
123
140
|
}
|
|
141
|
+
if (policyEnabled) {
|
|
142
|
+
const policyVerdict = evalPreToolUsePolicy(policyRules, toolName, event.input);
|
|
143
|
+
if (policyVerdict.block) {
|
|
144
|
+
return { block: true, reason: policyVerdict.reason };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
124
147
|
if (!hooksEnabled) return;
|
|
125
148
|
const cmds = commandsFor(hooksSettings, "preToolUse", toolName);
|
|
126
149
|
if (cmds.length === 0) return;
|
|
@@ -143,27 +166,33 @@ export function setupHooks(pi: ExtensionAPI, rt: RuntimeState, cwd: string): voi
|
|
|
143
166
|
});
|
|
144
167
|
|
|
145
168
|
pi.on("tool_result", async (event: any, ctx: any) => {
|
|
146
|
-
if (!hooksEnabled) return;
|
|
147
|
-
const cmds = commandsFor(hooksSettings, "postToolUse", event.toolName);
|
|
148
|
-
if (cmds.length === 0) return;
|
|
149
|
-
const payload = basePayload("postToolUse", ctx);
|
|
150
|
-
payload.tool_use_id = event.toolCallId;
|
|
151
|
-
payload.tool_name = event.toolName;
|
|
152
|
-
payload.tool_input = event.input;
|
|
153
|
-
payload.tool_response =
|
|
154
|
-
typeof event.content === "string"
|
|
155
|
-
? event.content
|
|
156
|
-
: Array.isArray(event.content)
|
|
157
|
-
? event.content.map((c: any) => c.text ?? "").join("\n")
|
|
158
|
-
: "";
|
|
159
|
-
// parallel: één crashende hook annuleert de rest niet
|
|
160
|
-
const outs = await Promise.all(cmds.map((c) => runHookCommand(c, payload)));
|
|
161
169
|
let extra = "";
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
if (policyEnabled) {
|
|
171
|
+
const inject = evalPostToolUsePolicy(policyRules, event.toolName, event.input);
|
|
172
|
+
if (inject) extra = inject.additionalContext;
|
|
173
|
+
}
|
|
174
|
+
if (hooksEnabled) {
|
|
175
|
+
const cmds = commandsFor(hooksSettings, "postToolUse", event.toolName);
|
|
176
|
+
if (cmds.length > 0) {
|
|
177
|
+
const payload = basePayload("postToolUse", ctx);
|
|
178
|
+
payload.tool_use_id = event.toolCallId;
|
|
179
|
+
payload.tool_name = event.toolName;
|
|
180
|
+
payload.tool_input = event.input;
|
|
181
|
+
payload.tool_response =
|
|
182
|
+
typeof event.content === "string"
|
|
183
|
+
? event.content
|
|
184
|
+
: Array.isArray(event.content)
|
|
185
|
+
? event.content.map((c: any) => c.text ?? "").join("\n")
|
|
186
|
+
: "";
|
|
187
|
+
// parallel: één crashende hook annuleert de rest niet
|
|
188
|
+
const outs = await Promise.all(cmds.map((c) => runHookCommand(c, payload)));
|
|
189
|
+
for (const out of outs) {
|
|
190
|
+
const add = out.parsed?.hookSpecificOutput?.additionalContext;
|
|
191
|
+
if (add) extra += (extra ? "\n" : "") + add;
|
|
192
|
+
if (out.parsed?.systemMessage && ctx?.ui?.notify) {
|
|
193
|
+
ctx.ui.notify(out.parsed.systemMessage, "info");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
167
196
|
}
|
|
168
197
|
}
|
|
169
198
|
if (extra && Array.isArray(event.content)) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy-config.ts
|
|
3
|
+
* ---------------------------------------------------------------------------
|
|
4
|
+
* Declarative policy rules (deny / inject) from global wishcraft settings.
|
|
5
|
+
* No process spawn — pure regex evaluation in-process.
|
|
6
|
+
*
|
|
7
|
+
* "wishcraft": {
|
|
8
|
+
* "policyEnabled": true,
|
|
9
|
+
* "policy": [
|
|
10
|
+
* { "action": "deny", "tool": "bash", "match": "sudo\\s+rm", "reason": "…" },
|
|
11
|
+
* { "action": "inject", "tool": "read", "pathMatch": "\\.env", "context": "…" }
|
|
12
|
+
* ]
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* policyEnabled defaults to true when policy is non-empty; explicit false
|
|
16
|
+
* is the kill-switch. Malformed rules are dropped (no throw).
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface DenyPolicyRule {
|
|
21
|
+
action: "deny";
|
|
22
|
+
tool: string;
|
|
23
|
+
match: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface InjectPolicyRule {
|
|
28
|
+
action: "inject";
|
|
29
|
+
tool: string;
|
|
30
|
+
pathMatch: string;
|
|
31
|
+
context: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PolicyRule = DenyPolicyRule | InjectPolicyRule;
|
|
35
|
+
|
|
36
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
37
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validRegex(pattern: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
new RegExp(pattern);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseDenyRule(v: unknown): DenyPolicyRule | null {
|
|
50
|
+
if (!isRecord(v) || v.action !== "deny") return null;
|
|
51
|
+
if (typeof v.tool !== "string" || !v.tool.trim()) return null;
|
|
52
|
+
if (typeof v.match !== "string" || !v.match.trim()) return null;
|
|
53
|
+
if (typeof v.reason !== "string" || !v.reason.trim()) return null;
|
|
54
|
+
if (!validRegex(v.match)) return null;
|
|
55
|
+
return {
|
|
56
|
+
action: "deny",
|
|
57
|
+
tool: v.tool,
|
|
58
|
+
match: v.match,
|
|
59
|
+
reason: v.reason,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseInjectRule(v: unknown): InjectPolicyRule | null {
|
|
64
|
+
if (!isRecord(v) || v.action !== "inject") return null;
|
|
65
|
+
if (typeof v.tool !== "string" || !v.tool.trim()) return null;
|
|
66
|
+
if (typeof v.pathMatch !== "string" || !v.pathMatch.trim()) return null;
|
|
67
|
+
if (typeof v.context !== "string" || !v.context.trim()) return null;
|
|
68
|
+
if (!validRegex(v.pathMatch)) return null;
|
|
69
|
+
return {
|
|
70
|
+
action: "inject",
|
|
71
|
+
tool: v.tool,
|
|
72
|
+
pathMatch: v.pathMatch,
|
|
73
|
+
context: v.context,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parsePolicyRule(v: unknown): PolicyRule | null {
|
|
78
|
+
if (!isRecord(v)) return null;
|
|
79
|
+
if (v.action === "deny") return parseDenyRule(v);
|
|
80
|
+
if (v.action === "inject") return parseInjectRule(v);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse wishcraft policy settings. Invalid rules are dropped. */
|
|
85
|
+
export function parsePolicySettings(wishcraftSettings: unknown): {
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
rules: PolicyRule[];
|
|
88
|
+
} {
|
|
89
|
+
if (!isRecord(wishcraftSettings)) return { enabled: false, rules: [] };
|
|
90
|
+
const raw = wishcraftSettings.policy;
|
|
91
|
+
if (!Array.isArray(raw)) return { enabled: false, rules: [] };
|
|
92
|
+
const rules = raw
|
|
93
|
+
.map(parsePolicyRule)
|
|
94
|
+
.filter((r): r is PolicyRule => r !== null);
|
|
95
|
+
if (rules.length === 0) return { enabled: false, rules: [] };
|
|
96
|
+
const enabled = wishcraftSettings.policyEnabled !== false;
|
|
97
|
+
return { enabled, rules };
|
|
98
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy-engine.ts
|
|
3
|
+
* ---------------------------------------------------------------------------
|
|
4
|
+
* Pure in-process policy evaluation (deny before tool use, inject after).
|
|
5
|
+
* ---------------------------------------------------------------------------
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PolicyRule } from "./policy-config.ts";
|
|
9
|
+
|
|
10
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
11
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Text to match deny rules against (bash command or serialized input). */
|
|
15
|
+
export function toolInputText(toolName: string, input: unknown): string {
|
|
16
|
+
if (toolName === "bash" && isRecord(input) && typeof input.command === "string") {
|
|
17
|
+
return input.command;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return JSON.stringify(input ?? {});
|
|
21
|
+
} catch {
|
|
22
|
+
return String(input ?? "");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Path from tool input (read/write/edit and common aliases). */
|
|
27
|
+
export function toolPath(input: unknown): string | null {
|
|
28
|
+
if (!isRecord(input)) return null;
|
|
29
|
+
for (const key of ["path", "filePath", "absolutePath", "target_file", "file_path"]) {
|
|
30
|
+
const v = input[key];
|
|
31
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type PreToolUsePolicyVerdict =
|
|
37
|
+
| { block: true; reason: string }
|
|
38
|
+
| { block: false };
|
|
39
|
+
|
|
40
|
+
/** First matching deny rule wins. */
|
|
41
|
+
export function evalPreToolUsePolicy(
|
|
42
|
+
rules: PolicyRule[],
|
|
43
|
+
toolName: string,
|
|
44
|
+
input: unknown,
|
|
45
|
+
): PreToolUsePolicyVerdict {
|
|
46
|
+
const text = toolInputText(toolName, input);
|
|
47
|
+
for (const rule of rules) {
|
|
48
|
+
if (rule.action !== "deny" || rule.tool !== toolName) continue;
|
|
49
|
+
try {
|
|
50
|
+
if (new RegExp(rule.match).test(text)) {
|
|
51
|
+
return { block: true, reason: rule.reason };
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// invalid regex at runtime — skip
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { block: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** All matching inject rules contribute context (in order). */
|
|
61
|
+
export function evalPostToolUsePolicy(
|
|
62
|
+
rules: PolicyRule[],
|
|
63
|
+
toolName: string,
|
|
64
|
+
input: unknown,
|
|
65
|
+
): { additionalContext: string } | null {
|
|
66
|
+
const path = toolPath(input);
|
|
67
|
+
if (path === null) return null;
|
|
68
|
+
let extra = "";
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
if (rule.action !== "inject" || rule.tool !== toolName) continue;
|
|
71
|
+
try {
|
|
72
|
+
if (new RegExp(rule.pathMatch).test(path)) {
|
|
73
|
+
extra += (extra ? "\n" : "") + rule.context;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// invalid regex at runtime — skip
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return extra ? { additionalContext: extra } : null;
|
|
80
|
+
}
|
|
@@ -6,6 +6,7 @@ import { registerCustomSegments } from "../../segments/index.ts";
|
|
|
6
6
|
import { readSettings } from "../settings/settings-io.ts";
|
|
7
7
|
import { registerSessionLifecycle } from "./session-lifecycle.ts";
|
|
8
8
|
import { registerCommands } from "../commands/commands.ts";
|
|
9
|
+
import { setupHooks } from "../hooks/index.ts";
|
|
9
10
|
import { setupInlineInvocation } from "../skills/inline-invocation.ts";
|
|
10
11
|
import {
|
|
11
12
|
config,
|
|
@@ -26,4 +27,5 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
26
27
|
registerSessionLifecycle(pi, rt);
|
|
27
28
|
registerCommands(pi, rt);
|
|
28
29
|
setupInlineInvocation(pi, rt);
|
|
30
|
+
setupHooks(pi, rt, process.cwd());
|
|
29
31
|
}
|