@zhijiewang/openharness 2.9.0 → 2.10.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/harness/config.d.ts
CHANGED
|
@@ -16,6 +16,19 @@ export type HookDef = {
|
|
|
16
16
|
prompt?: string;
|
|
17
17
|
match?: string;
|
|
18
18
|
timeout?: number;
|
|
19
|
+
/**
|
|
20
|
+
* When true (and this hook has a `command`), OH sends a JSON envelope
|
|
21
|
+
* `{event, ...context}` on stdin and parses a JSON response from stdout.
|
|
22
|
+
* Response shape (Claude Code compatible):
|
|
23
|
+
* { "decision": "allow" | "deny",
|
|
24
|
+
* "reason"?: string,
|
|
25
|
+
* "hookSpecificOutput"?: {...} }
|
|
26
|
+
*
|
|
27
|
+
* When false (default), OH passes context via `OH_EVENT` / `OH_TOOL_NAME`
|
|
28
|
+
* env vars and gates on the command's exit code (0 = allow). The env-var
|
|
29
|
+
* mode remains the default for backward compatibility.
|
|
30
|
+
*/
|
|
31
|
+
jsonIO?: boolean;
|
|
19
32
|
};
|
|
20
33
|
export type HooksConfig = {
|
|
21
34
|
sessionStart?: HookDef[];
|
package/dist/harness/hooks.js
CHANGED
|
@@ -141,6 +141,86 @@ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
|
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Run a JSON-mode command hook (Claude Code convention).
|
|
146
|
+
*
|
|
147
|
+
* Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
|
|
148
|
+
* `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
|
|
149
|
+
*
|
|
150
|
+
* Gating logic:
|
|
151
|
+
* - `decision: "deny"` → blocks (returns false).
|
|
152
|
+
* - `decision: "allow"` or omitted decision → allow (returns true).
|
|
153
|
+
* - Non-zero exit code → block.
|
|
154
|
+
* - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
|
|
155
|
+
* - Timeout or spawn error → block.
|
|
156
|
+
*/
|
|
157
|
+
function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const proc = spawn(command, {
|
|
160
|
+
shell: true,
|
|
161
|
+
timeout: timeoutMs,
|
|
162
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
163
|
+
env,
|
|
164
|
+
});
|
|
165
|
+
let settled = false;
|
|
166
|
+
let stdoutBuf = "";
|
|
167
|
+
const timer = setTimeout(() => {
|
|
168
|
+
if (!settled) {
|
|
169
|
+
settled = true;
|
|
170
|
+
proc.kill();
|
|
171
|
+
resolve(false);
|
|
172
|
+
}
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
proc.stdout?.on("data", (chunk) => {
|
|
175
|
+
stdoutBuf += chunk.toString();
|
|
176
|
+
});
|
|
177
|
+
// Write the event + context JSON envelope to stdin then close it so the
|
|
178
|
+
// hook knows there's no more input coming.
|
|
179
|
+
try {
|
|
180
|
+
const payload = JSON.stringify({ event, ...ctx });
|
|
181
|
+
proc.stdin?.end(payload);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* stdin already closed — ignore */
|
|
185
|
+
}
|
|
186
|
+
proc.on("close", (code) => {
|
|
187
|
+
if (settled)
|
|
188
|
+
return;
|
|
189
|
+
settled = true;
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
// Non-zero exit is always a block, regardless of stdout.
|
|
192
|
+
if ((code ?? 1) !== 0) {
|
|
193
|
+
resolve(false);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Empty stdout → treat exit code as the signal (allow for exit 0).
|
|
197
|
+
if (!stdoutBuf.trim()) {
|
|
198
|
+
resolve(true);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(stdoutBuf);
|
|
203
|
+
if (parsed.decision === "deny") {
|
|
204
|
+
resolve(false);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
resolve(true); // "allow" or omitted → allow
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Malformed JSON with a zero exit — fail closed conservatively.
|
|
212
|
+
resolve(false);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
proc.on("error", () => {
|
|
216
|
+
if (!settled) {
|
|
217
|
+
settled = true;
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
resolve(false);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
144
224
|
/** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
|
|
145
225
|
async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
|
|
146
226
|
try {
|
|
@@ -161,15 +241,63 @@ async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
|
|
|
161
241
|
}
|
|
162
242
|
}
|
|
163
243
|
/**
|
|
164
|
-
* Run a prompt hook. Uses LLM to make a yes/no decision.
|
|
244
|
+
* Run a prompt hook. Uses an LLM to make a yes/no allow/deny decision.
|
|
245
|
+
*
|
|
246
|
+
* The hook's `prompt:` field is the question posed to the model along with
|
|
247
|
+
* the event context. The response is parsed case-insensitively: responses
|
|
248
|
+
* starting with YES / ALLOW / TRUE / PASS / APPROVE allow; anything else
|
|
249
|
+
* (including explicit NO, DENY, errors, timeouts, empty) blocks.
|
|
165
250
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
251
|
+
* Fail-closed semantics: if the provider isn't reachable or the response
|
|
252
|
+
* can't be parsed, the hook denies. This matches command hooks (non-zero
|
|
253
|
+
* exit = deny) and HTTP hooks (network error = deny).
|
|
254
|
+
*
|
|
255
|
+
* Provider selection: reads `.oh/config.yaml` to get the configured provider
|
|
256
|
+
* and model. A separate provider instance is created per call — no caching,
|
|
257
|
+
* since hooks are rare and cold-start cost is negligible compared to the
|
|
258
|
+
* LLM call itself.
|
|
170
259
|
*/
|
|
171
|
-
async function runPromptHook(
|
|
172
|
-
|
|
260
|
+
async function runPromptHook(promptText, ctx, timeoutMs = 10_000) {
|
|
261
|
+
try {
|
|
262
|
+
const cfg = readOhConfig();
|
|
263
|
+
if (!cfg)
|
|
264
|
+
return false; // no config → no provider → fail closed
|
|
265
|
+
const { createProvider } = (await import("../providers/index.js"));
|
|
266
|
+
const modelArg = cfg.model ? `${cfg.provider}/${cfg.model}` : cfg.provider;
|
|
267
|
+
const overrides = {};
|
|
268
|
+
if (cfg.apiKey)
|
|
269
|
+
overrides.apiKey = cfg.apiKey;
|
|
270
|
+
if (cfg.baseUrl)
|
|
271
|
+
overrides.baseUrl = cfg.baseUrl;
|
|
272
|
+
const { provider, model } = await createProvider(modelArg, overrides);
|
|
273
|
+
const systemPrompt = "You are a policy gate. Read the question and the event context. Answer with a single word: YES to allow, NO to deny. Do not explain unless asked.";
|
|
274
|
+
const userContent = [
|
|
275
|
+
`Question: ${promptText}`,
|
|
276
|
+
"",
|
|
277
|
+
"Event context:",
|
|
278
|
+
JSON.stringify({ event: ctx }, null, 2),
|
|
279
|
+
"",
|
|
280
|
+
"Answer (YES or NO):",
|
|
281
|
+
].join("\n");
|
|
282
|
+
const { createUserMessage } = (await import("../types/message.js"));
|
|
283
|
+
const messages = [createUserMessage(userContent)];
|
|
284
|
+
// Race the completion against a hard timeout so a hung provider doesn't
|
|
285
|
+
// block the agent loop indefinitely.
|
|
286
|
+
const completion = await Promise.race([
|
|
287
|
+
provider.complete(messages, systemPrompt, undefined, model),
|
|
288
|
+
new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
|
|
289
|
+
]);
|
|
290
|
+
if (!completion)
|
|
291
|
+
return false; // timeout → deny
|
|
292
|
+
const text = (completion.content ?? "").trim().toUpperCase();
|
|
293
|
+
if (!text)
|
|
294
|
+
return false;
|
|
295
|
+
// Accept multiple allow synonyms; default to deny on anything else.
|
|
296
|
+
return /^(YES|ALLOW|TRUE|PASS|APPROVE)\b/.test(text);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return false; // any error path → deny
|
|
300
|
+
}
|
|
173
301
|
}
|
|
174
302
|
// ── Hook Execution ──
|
|
175
303
|
/** Execute a single hook definition. Returns true if allowed. */
|
|
@@ -177,6 +305,12 @@ async function executeHookDef(def, event, ctx) {
|
|
|
177
305
|
const timeout = def.timeout ?? 10_000;
|
|
178
306
|
if (def.command) {
|
|
179
307
|
const env = buildEnv(event, ctx);
|
|
308
|
+
// JSON-mode (Claude Code convention): send `{event, ...ctx}` on stdin,
|
|
309
|
+
// parse `{decision}` from stdout. Env-var mode (legacy default): gate on
|
|
310
|
+
// exit code.
|
|
311
|
+
if (def.jsonIO) {
|
|
312
|
+
return runJsonIoHookAsync(def.command, env, event, ctx, timeout);
|
|
313
|
+
}
|
|
180
314
|
const code = await runCommandHookAsync(def.command, env, timeout);
|
|
181
315
|
return code === 0;
|
|
182
316
|
}
|
|
@@ -206,14 +340,31 @@ export function emitHook(event, ctx = {}) {
|
|
|
206
340
|
if (!matchesHook(def, ctx))
|
|
207
341
|
continue;
|
|
208
342
|
if (def.command) {
|
|
343
|
+
const input = def.jsonIO ? JSON.stringify({ event, ...ctx }) : undefined;
|
|
209
344
|
const result = spawnSync(def.command, {
|
|
210
345
|
shell: true,
|
|
211
346
|
timeout: def.timeout ?? 10_000,
|
|
212
347
|
stdio: "pipe",
|
|
213
348
|
env,
|
|
349
|
+
input,
|
|
214
350
|
});
|
|
215
351
|
if (result.status !== 0 || result.error)
|
|
216
352
|
return false;
|
|
353
|
+
// JSON mode: parse stdout for {decision: "deny"} → block. Allow on empty
|
|
354
|
+
// stdout (exit-code already gated above). Malformed JSON fails closed.
|
|
355
|
+
if (def.jsonIO) {
|
|
356
|
+
const out = result.stdout?.toString() ?? "";
|
|
357
|
+
if (out.trim()) {
|
|
358
|
+
try {
|
|
359
|
+
const parsed = JSON.parse(out);
|
|
360
|
+
if (parsed.decision === "deny")
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
217
368
|
}
|
|
218
369
|
// HTTP and prompt hooks for preToolUse are handled in emitHookAsync
|
|
219
370
|
}
|
|
@@ -12,16 +12,16 @@ declare const inputSchema: z.ZodObject<{
|
|
|
12
12
|
action: "search" | "save" | "list";
|
|
13
13
|
content?: string | undefined;
|
|
14
14
|
type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
|
|
15
|
-
description?: string | undefined;
|
|
16
15
|
name?: string | undefined;
|
|
16
|
+
description?: string | undefined;
|
|
17
17
|
global?: boolean | undefined;
|
|
18
18
|
query?: string | undefined;
|
|
19
19
|
}, {
|
|
20
20
|
action: "search" | "save" | "list";
|
|
21
21
|
content?: string | undefined;
|
|
22
22
|
type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
|
|
23
|
-
description?: string | undefined;
|
|
24
23
|
name?: string | undefined;
|
|
24
|
+
description?: string | undefined;
|
|
25
25
|
global?: boolean | undefined;
|
|
26
26
|
query?: string | undefined;
|
|
27
27
|
}>;
|