@zhijiewang/openharness 2.29.0 → 2.30.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.
@@ -2,6 +2,8 @@
2
2
  * Tool execution during LLM streaming — concurrent tool execution
3
3
  * with permission checks and queue management.
4
4
  */
5
+ import { getAffectedFiles } from "../harness/checkpoints.js";
6
+ import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
5
7
  import { findToolByName } from "../Tool.js";
6
8
  import { checkPermission } from "../types/permissions.js";
7
9
  const MAX_CONCURRENCY = 10;
@@ -54,23 +56,69 @@ export class StreamingToolExecutor {
54
56
  tracked.status = "completed";
55
57
  return;
56
58
  }
59
+ const argsPreview = JSON.stringify(tracked.toolCall.arguments).slice(0, 1000);
57
60
  // Permission check
58
61
  const perm = checkPermission(this.permissionMode, tool.riskLevel, tool.isReadOnly(tracked.toolCall.arguments), tool.name, tracked.toolCall.arguments);
59
- if (!perm.allowed && perm.reason === "needs-approval" && this.askUser) {
60
- const { formatToolArgs } = await import("../utils/tool-summary.js");
61
- const description = formatToolArgs(tool.name, tracked.toolCall.arguments);
62
- const allowed = await this.askUser(tool.name, description, tool.riskLevel);
63
- if (!allowed) {
64
- tracked.result = { output: "Permission denied.", isError: true };
62
+ if (!perm.allowed) {
63
+ if (perm.reason === "needs-approval") {
64
+ // Hook: permissionRequest — give configured hooks first say. If they
65
+ // explicitly allow/deny, that wins; otherwise fall through to the
66
+ // interactive prompt or to a fail-closed deny in headless mode.
67
+ const hookOutcome = await emitHookWithOutcome("permissionRequest", {
68
+ toolName: tool.name,
69
+ toolArgs: argsPreview,
70
+ toolInputJson: JSON.stringify(tracked.toolCall.arguments).slice(0, 1000),
71
+ permissionMode: this.permissionMode,
72
+ permissionAction: "ask",
73
+ });
74
+ const denyAndEmit = (source, reason, output) => {
75
+ emitHook("permissionDenied", {
76
+ toolName: tool.name,
77
+ toolArgs: argsPreview,
78
+ permissionMode: this.permissionMode,
79
+ denySource: source,
80
+ denyReason: reason,
81
+ });
82
+ tracked.result = { output, isError: true };
83
+ tracked.status = "completed";
84
+ };
85
+ if (hookOutcome.permissionDecision === "allow") {
86
+ // Hook granted — proceed.
87
+ }
88
+ else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
89
+ const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
90
+ denyAndEmit("hook", hookOutcome.reason ?? "hook denied", `Permission denied by hook${reason}`);
91
+ return;
92
+ }
93
+ else if (this.askUser) {
94
+ const { formatToolArgs } = await import("../utils/tool-summary.js");
95
+ const description = formatToolArgs(tool.name, tracked.toolCall.arguments);
96
+ const allowed = await this.askUser(tool.name, description, tool.riskLevel);
97
+ if (!allowed) {
98
+ denyAndEmit("user", "user declined", "Permission denied by user.");
99
+ return;
100
+ }
101
+ }
102
+ else {
103
+ // Headless mode with no hook decision and no interactive prompt.
104
+ denyAndEmit("headless", "no hook decision and no interactive prompt available", "Permission denied: needs-approval (no interactive prompt available; configure a permissionRequest hook to gate this tool)");
105
+ return;
106
+ }
107
+ }
108
+ else {
109
+ // Auto-mode policy block (deny / acceptEdits / etc) — symmetric event.
110
+ emitHook("permissionDenied", {
111
+ toolName: tool.name,
112
+ toolArgs: argsPreview,
113
+ permissionMode: this.permissionMode,
114
+ denySource: "policy",
115
+ denyReason: perm.reason,
116
+ });
117
+ tracked.result = { output: `Denied: ${perm.reason}`, isError: true };
65
118
  tracked.status = "completed";
66
119
  return;
67
120
  }
68
121
  }
69
- else if (!perm.allowed) {
70
- tracked.result = { output: `Denied: ${perm.reason}`, isError: true };
71
- tracked.status = "completed";
72
- return;
73
- }
74
122
  // Validate input
75
123
  const parsed = tool.inputSchema.safeParse(tracked.toolCall.arguments);
76
124
  if (!parsed.success) {
@@ -84,6 +132,17 @@ export class StreamingToolExecutor {
84
132
  tracked.status = "completed";
85
133
  return;
86
134
  }
135
+ // Hook: preToolUse — last gate before execution. A hook that returns
136
+ // false (exit code 1 / { allowed: false }) blocks the call.
137
+ const preAllowed = emitHook("preToolUse", {
138
+ toolName: tool.name,
139
+ toolArgs: argsPreview,
140
+ });
141
+ if (!preAllowed) {
142
+ tracked.result = { output: "Blocked by preToolUse hook.", isError: true };
143
+ tracked.status = "completed";
144
+ return;
145
+ }
87
146
  // Execute with per-call context (streaming output chunks + abort signal)
88
147
  const callId = tracked.toolCall.id;
89
148
  const callContext = {
@@ -94,8 +153,11 @@ export class StreamingToolExecutor {
94
153
  this.outputChunks.push({ callId: id, chunk });
95
154
  },
96
155
  };
156
+ const toolSpanId = callContext.tracer?.startSpan(`tool:${tool.name}`, { riskLevel: tool.riskLevel }, callContext.parentSpanId);
97
157
  try {
98
158
  tracked.result = await tool.call(parsed.data, callContext);
159
+ if (toolSpanId)
160
+ callContext.tracer?.endSpan(toolSpanId, tracked.result.isError ? "error" : "ok");
99
161
  // Verification loop: auto-run lint/typecheck after file-modifying tools
100
162
  if (tracked.result && !tracked.result.isError && ["Edit", "Write", "MultiEdit"].includes(tool.name)) {
101
163
  try {
@@ -132,6 +194,35 @@ export class StreamingToolExecutor {
132
194
  output: `Error: ${err instanceof Error ? err.message : String(err)}`,
133
195
  isError: true,
134
196
  };
197
+ if (toolSpanId)
198
+ callContext.tracer?.endSpan(toolSpanId, "error", { error: tracked.result.output });
199
+ }
200
+ // Hook: postToolUse / postToolUseFailure (mutually exclusive — strict CC parity)
201
+ if (tracked.result) {
202
+ const outputPreview = tracked.result.output.slice(0, 1000);
203
+ if (tracked.result.isError) {
204
+ emitHook("postToolUseFailure", {
205
+ toolName: tool.name,
206
+ toolArgs: argsPreview,
207
+ toolOutput: outputPreview,
208
+ toolError: "ReportedError",
209
+ errorMessage: outputPreview,
210
+ });
211
+ }
212
+ else {
213
+ emitHook("postToolUse", {
214
+ toolName: tool.name,
215
+ toolArgs: argsPreview,
216
+ toolOutput: outputPreview,
217
+ });
218
+ // Emit fileChanged hook for file-modifying tools
219
+ if (["Edit", "Write", "MultiEdit"].includes(tool.name)) {
220
+ const filePaths = getAffectedFiles(tool.name, parsed.data);
221
+ for (const fp of filePaths) {
222
+ emitHook("fileChanged", { filePath: fp, toolName: tool.name });
223
+ }
224
+ }
225
+ }
135
226
  }
136
227
  tracked.status = "completed";
137
228
  this.processQueue(); // Process next queued tools
@@ -6,13 +6,13 @@ declare const createSchema: z.ZodObject<{
6
6
  schedule: z.ZodString;
7
7
  prompt: z.ZodString;
8
8
  }, "strip", z.ZodTypeAny, {
9
- action: "create";
10
9
  name: string;
10
+ action: "create";
11
11
  prompt: string;
12
12
  schedule: string;
13
13
  }, {
14
- action: "create";
15
14
  name: string;
15
+ action: "create";
16
16
  prompt: string;
17
17
  schedule: string;
18
18
  }>;
@@ -6,8 +6,8 @@ declare const inputSchema: z.ZodObject<{
6
6
  line: z.ZodOptional<z.ZodNumber>;
7
7
  character: z.ZodOptional<z.ZodNumber>;
8
8
  }, "strip", z.ZodTypeAny, {
9
- action: "diagnostics" | "definition" | "references" | "hover";
10
9
  file_path: string;
10
+ action: "diagnostics" | "definition" | "references" | "hover";
11
11
  line?: number | undefined;
12
12
  character?: number | undefined;
13
13
  }, {
@@ -17,9 +17,9 @@ declare const inputSchema: z.ZodObject<{
17
17
  "-n": z.ZodOptional<z.ZodBoolean>;
18
18
  }, "strip", z.ZodTypeAny, {
19
19
  pattern: string;
20
+ path?: string | undefined;
20
21
  type?: string | undefined;
21
22
  "-i"?: boolean | undefined;
22
- path?: string | undefined;
23
23
  context?: number | undefined;
24
24
  glob?: string | undefined;
25
25
  offset?: number | undefined;
@@ -32,9 +32,9 @@ declare const inputSchema: z.ZodObject<{
32
32
  "-n"?: boolean | undefined;
33
33
  }, {
34
34
  pattern: string;
35
+ path?: string | undefined;
35
36
  type?: string | undefined;
36
37
  "-i"?: boolean | undefined;
37
- path?: string | undefined;
38
38
  context?: number | undefined;
39
39
  glob?: string | undefined;
40
40
  offset?: number | undefined;
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { z } from "zod";
3
3
  const inputSchema = z.object({
4
4
  command: z.string().describe("PowerShell command to execute"),
@@ -21,7 +21,16 @@ export const PowerShellTool = {
21
21
  }
22
22
  const timeout = input.timeout ?? 120_000;
23
23
  try {
24
- const output = execSync(`powershell.exe -NoProfile -NonInteractive -Command "${input.command.replace(/"/g, '\\"')}"`, { encoding: "utf-8", timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true });
24
+ // execFileSync(file, args[]) spawns powershell.exe directly without a
25
+ // cmd.exe wrapper, so cmd.exe metachars (& | < > ^ %VAR%) are inert.
26
+ // The user's command is passed as a single -Command arg; PowerShell
27
+ // parses it as PowerShell, not as a doubly-parsed shell string.
28
+ const output = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", input.command], {
29
+ encoding: "utf-8",
30
+ timeout,
31
+ maxBuffer: 10 * 1024 * 1024,
32
+ windowsHide: true,
33
+ });
25
34
  return { output: output.trim(), isError: false };
26
35
  }
27
36
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.29.0",
3
+ "version": "2.30.1",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {