@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.
@@ -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[];
@@ -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
- * Currently a stub prompt hooks always allow because the hook system
167
- * runs outside the query loop and has no access to a Provider instance.
168
- * Full implementation requires passing a Provider via HookContext so the
169
- * hook can call provider.complete() with the prompt text.
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(_promptText, _ctx) {
172
- return true;
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
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {