@oh-my-pi/pi-coding-agent 15.4.3 → 15.5.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +75 -5
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/auth-broker-cli.d.ts +1 -1
  4. package/dist/types/commands/launch.d.ts +8 -0
  5. package/dist/types/config/settings-schema.d.ts +42 -1
  6. package/dist/types/edit/index.d.ts +2 -0
  7. package/dist/types/extensibility/custom-tools/types.d.ts +8 -2
  8. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  9. package/dist/types/lsp/index.d.ts +9 -1
  10. package/dist/types/mcp/client.d.ts +2 -1
  11. package/dist/types/mcp/oauth-discovery.d.ts +4 -3
  12. package/dist/types/mcp/timeout.d.ts +9 -0
  13. package/dist/types/mcp/types.d.ts +1 -1
  14. package/dist/types/sdk.d.ts +2 -0
  15. package/dist/types/session/streaming-output.d.ts +1 -1
  16. package/dist/types/task/index.d.ts +2 -0
  17. package/dist/types/task/types.d.ts +4 -0
  18. package/dist/types/tools/approval.d.ts +46 -0
  19. package/dist/types/tools/ask.d.ts +1 -0
  20. package/dist/types/tools/ast-edit.d.ts +2 -0
  21. package/dist/types/tools/ast-grep.d.ts +1 -0
  22. package/dist/types/tools/bash.d.ts +11 -1
  23. package/dist/types/tools/browser.d.ts +2 -0
  24. package/dist/types/tools/calculator.d.ts +1 -0
  25. package/dist/types/tools/checkpoint.d.ts +2 -0
  26. package/dist/types/tools/debug.d.ts +9 -1
  27. package/dist/types/tools/eval.d.ts +2 -0
  28. package/dist/types/tools/find.d.ts +10 -0
  29. package/dist/types/tools/gh.d.ts +2 -1
  30. package/dist/types/tools/hindsight-recall.d.ts +1 -0
  31. package/dist/types/tools/hindsight-reflect.d.ts +1 -0
  32. package/dist/types/tools/hindsight-retain.d.ts +1 -0
  33. package/dist/types/tools/inspect-image.d.ts +1 -0
  34. package/dist/types/tools/irc.d.ts +1 -0
  35. package/dist/types/tools/job.d.ts +1 -0
  36. package/dist/types/tools/read.d.ts +1 -0
  37. package/dist/types/tools/recipe/index.d.ts +1 -0
  38. package/dist/types/tools/render-mermaid.d.ts +1 -0
  39. package/dist/types/tools/resolve.d.ts +1 -0
  40. package/dist/types/tools/search-tool-bm25.d.ts +1 -0
  41. package/dist/types/tools/search.d.ts +1 -0
  42. package/dist/types/tools/ssh.d.ts +2 -0
  43. package/dist/types/tools/todo-write.d.ts +1 -0
  44. package/dist/types/tools/write.d.ts +2 -0
  45. package/dist/types/tools/yield.d.ts +1 -0
  46. package/dist/types/web/search/index.d.ts +1 -0
  47. package/package.json +7 -7
  48. package/src/cli/args.ts +14 -0
  49. package/src/cli/auth-broker-cli.ts +171 -22
  50. package/src/commands/auth-broker.ts +3 -0
  51. package/src/commands/launch.ts +16 -0
  52. package/src/config/mcp-schema.json +2 -2
  53. package/src/config/model-registry.ts +19 -4
  54. package/src/config/settings-schema.ts +59 -1
  55. package/src/config/settings.ts +2 -1
  56. package/src/dap/session.ts +35 -2
  57. package/src/discovery/builtin.ts +2 -2
  58. package/src/discovery/mcp-json.ts +1 -1
  59. package/src/edit/index.ts +26 -0
  60. package/src/edit/modes/patch.ts +1 -1
  61. package/src/edit/streaming.ts +12 -2
  62. package/src/exec/bash-executor.ts +6 -2
  63. package/src/extensibility/custom-commands/bundled/review/index.ts +18 -14
  64. package/src/extensibility/custom-tools/types.ts +16 -2
  65. package/src/extensibility/extensions/wrapper.ts +36 -1
  66. package/src/extensibility/hooks/types.ts +8 -1
  67. package/src/hashline/apply.ts +47 -2
  68. package/src/internal-urls/docs-index.generated.ts +8 -7
  69. package/src/lsp/edits.ts +82 -29
  70. package/src/lsp/index.ts +38 -1
  71. package/src/lsp/utils.ts +1 -1
  72. package/src/main.ts +6 -0
  73. package/src/mcp/client.ts +8 -6
  74. package/src/mcp/oauth-discovery.ts +120 -32
  75. package/src/mcp/oauth-flow.ts +34 -6
  76. package/src/mcp/timeout.ts +59 -0
  77. package/src/mcp/transports/http.ts +42 -44
  78. package/src/mcp/transports/stdio.ts +8 -5
  79. package/src/mcp/types.ts +1 -1
  80. package/src/modes/components/hook-editor.ts +11 -3
  81. package/src/modes/components/mcp-add-wizard.ts +6 -2
  82. package/src/modes/components/model-selector.ts +33 -11
  83. package/src/modes/controllers/command-controller.ts +6 -4
  84. package/src/modes/controllers/mcp-command-controller.ts +8 -4
  85. package/src/prompts/review-custom-request.md +22 -0
  86. package/src/prompts/review-headless-request.md +16 -0
  87. package/src/prompts/review-request.md +2 -3
  88. package/src/prompts/system/project-prompt.md +4 -0
  89. package/src/prompts/tools/debug.md +1 -0
  90. package/src/prompts/tools/find.md +4 -2
  91. package/src/prompts/tools/hashline.md +1 -0
  92. package/src/sdk.ts +47 -73
  93. package/src/session/agent-session.ts +93 -27
  94. package/src/session/streaming-output.ts +1 -1
  95. package/src/slash-commands/helpers/usage-report.ts +3 -1
  96. package/src/task/executor.ts +11 -0
  97. package/src/task/index.ts +19 -0
  98. package/src/task/render.ts +12 -2
  99. package/src/task/types.ts +4 -0
  100. package/src/tools/approval.ts +185 -0
  101. package/src/tools/ask.ts +1 -0
  102. package/src/tools/ast-edit.ts +25 -1
  103. package/src/tools/ast-grep.ts +1 -0
  104. package/src/tools/bash.ts +69 -1
  105. package/src/tools/browser/tab-supervisor.ts +1 -1
  106. package/src/tools/browser.ts +15 -0
  107. package/src/tools/calculator.ts +1 -0
  108. package/src/tools/checkpoint.ts +2 -0
  109. package/src/tools/debug.ts +38 -0
  110. package/src/tools/eval.ts +15 -0
  111. package/src/tools/find.ts +17 -8
  112. package/src/tools/gh.ts +21 -1
  113. package/src/tools/hindsight-recall.ts +1 -0
  114. package/src/tools/hindsight-reflect.ts +1 -0
  115. package/src/tools/hindsight-retain.ts +1 -0
  116. package/src/tools/image-gen.ts +1 -0
  117. package/src/tools/inspect-image.ts +1 -0
  118. package/src/tools/irc.ts +1 -0
  119. package/src/tools/job.ts +1 -0
  120. package/src/tools/path-utils.ts +14 -1
  121. package/src/tools/read.ts +1 -0
  122. package/src/tools/recipe/index.ts +1 -0
  123. package/src/tools/render-mermaid.ts +1 -0
  124. package/src/tools/report-tool-issue.ts +1 -0
  125. package/src/tools/resolve.ts +1 -0
  126. package/src/tools/review.ts +1 -0
  127. package/src/tools/search-tool-bm25.ts +1 -0
  128. package/src/tools/search.ts +1 -0
  129. package/src/tools/ssh.ts +8 -0
  130. package/src/tools/todo-write.ts +1 -0
  131. package/src/tools/write.ts +12 -1
  132. package/src/tools/yield.ts +1 -0
  133. package/src/web/search/index.ts +2 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Tool approval resolution.
3
+ *
4
+ * Approval policy is declared by each tool. This module only knows how to:
5
+ * - normalize user `tools.approval.<tool>: allow | deny | prompt` overrides,
6
+ * - compare a tool capability tier against the active approval mode,
7
+ * - format the generic approval prompt body.
8
+ */
9
+ import type { AgentTool, ToolApprovalDecision, ToolTier } from "@oh-my-pi/pi-agent-core";
10
+
11
+ export type { ToolApproval, ToolApprovalDecision, ToolTier } from "@oh-my-pi/pi-agent-core";
12
+
13
+ export type ApprovalPolicy = "allow" | "deny" | "prompt";
14
+ export type ApprovalMode = "always-ask" | "write" | "yolo";
15
+
16
+ type ApprovalSubject = Pick<AgentTool, "name" | "approval" | "formatApprovalDetails">;
17
+
18
+ export interface ResolvedApproval {
19
+ policy: ApprovalPolicy;
20
+ tier: ToolTier;
21
+ reason?: string;
22
+ override: boolean;
23
+ }
24
+
25
+ const POLICY_VALUES: ReadonlySet<ApprovalPolicy> = new Set(["allow", "deny", "prompt"]);
26
+ const TIER_VALUES: ReadonlySet<ToolTier> = new Set(["read", "write", "exec"]);
27
+
28
+ const TIER_RANK: Record<ToolTier, number> = {
29
+ read: 0,
30
+ write: 1,
31
+ exec: 2,
32
+ };
33
+
34
+ const APPROVAL_MODE_MAX_TIER: Record<ApprovalMode, ToolTier> = {
35
+ "always-ask": "read",
36
+ write: "write",
37
+ yolo: "exec",
38
+ };
39
+
40
+ const DEFAULT_PROMPT_TRUNCATE_CHARS = 2000;
41
+
42
+ /** Best-effort conversion of an arbitrary user-supplied value to a policy. */
43
+ function normalizePolicy(value: unknown): ApprovalPolicy | undefined {
44
+ if (typeof value !== "string") return undefined;
45
+ const lowered = value.trim().toLowerCase();
46
+ return POLICY_VALUES.has(lowered as ApprovalPolicy) ? (lowered as ApprovalPolicy) : undefined;
47
+ }
48
+
49
+ function isToolTier(value: unknown): value is ToolTier {
50
+ return typeof value === "string" && TIER_VALUES.has(value as ToolTier);
51
+ }
52
+
53
+ function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> {
54
+ if (isToolTier(value)) {
55
+ return { tier: value, override: false };
56
+ }
57
+
58
+ if (value && typeof value === "object" && !Array.isArray(value)) {
59
+ const record = value as Record<string, unknown>;
60
+ const tier = isToolTier(record.tier) ? record.tier : "exec";
61
+ const reason = typeof record.reason === "string" && record.reason.length > 0 ? record.reason : undefined;
62
+ return {
63
+ tier,
64
+ override: record.override === true,
65
+ ...(reason ? { reason } : {}),
66
+ };
67
+ }
68
+
69
+ return { tier: "exec", override: false };
70
+ }
71
+
72
+ function getToolDecision(tool: ApprovalSubject, args: unknown): Omit<ResolvedApproval, "policy"> {
73
+ const approval = tool.approval;
74
+ const decision: ToolApprovalDecision | undefined = typeof approval === "function" ? approval(args) : approval;
75
+ return normalizeDecision(decision);
76
+ }
77
+
78
+ function modeApprovesTier(mode: ApprovalMode, tier: ToolTier): boolean {
79
+ return TIER_RANK[tier] <= TIER_RANK[APPROVAL_MODE_MAX_TIER[mode]];
80
+ }
81
+
82
+ /**
83
+ * Resolve approval policy for a tool call.
84
+ *
85
+ * Resolution order:
86
+ * 1. Tool `approval(args)` decision, defaulting to tier "exec" when omitted.
87
+ * 2. User per-tool override, if set and valid.
88
+ * 3. Active mode tier comparison.
89
+ *
90
+ * Tool decisions with `override: true` force a prompt in every mode unless the
91
+ * user explicitly denies the tool; deny remains the strongest policy.
92
+ */
93
+ export function resolveApproval(
94
+ tool: ApprovalSubject,
95
+ args: unknown,
96
+ mode: ApprovalMode,
97
+ userConfig: Record<string, unknown> = {},
98
+ ): ResolvedApproval {
99
+ const decision = getToolDecision(tool, args);
100
+ const userPolicy = Object.hasOwn(userConfig, tool.name) ? normalizePolicy(userConfig[tool.name]) : undefined;
101
+
102
+ if (decision.override) {
103
+ if (userPolicy === "deny") {
104
+ return { policy: "deny", tier: decision.tier, override: true };
105
+ }
106
+ return {
107
+ policy: "prompt",
108
+ tier: decision.tier,
109
+ override: true,
110
+ ...(decision.reason ? { reason: decision.reason } : {}),
111
+ };
112
+ }
113
+
114
+ if (userPolicy) {
115
+ return { policy: userPolicy, tier: decision.tier, override: false };
116
+ }
117
+
118
+ if (modeApprovesTier(mode, decision.tier)) {
119
+ return { policy: "allow", tier: decision.tier, override: false };
120
+ }
121
+
122
+ return {
123
+ policy: "prompt",
124
+ tier: decision.tier,
125
+ override: false,
126
+ ...(decision.reason ? { reason: decision.reason } : {}),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Check if a tool call requires user approval.
132
+ *
133
+ * @throws Error if policy is 'deny'
134
+ * @returns Object with required flag and optional reason for the prompt
135
+ */
136
+ export function requiresApproval(
137
+ tool: ApprovalSubject,
138
+ args: unknown,
139
+ mode: ApprovalMode,
140
+ userConfig: Record<string, unknown> = {},
141
+ ): { required: boolean; reason?: string } {
142
+ const { policy, reason } = resolveApproval(tool, args, mode, userConfig);
143
+
144
+ if (policy === "deny") {
145
+ throw new Error(
146
+ `Tool "${tool.name}" is blocked by user policy.\n` +
147
+ `To allow: remove "tools.approval.${tool.name}: deny" from config.`,
148
+ );
149
+ }
150
+
151
+ if (policy === "prompt") return { required: true, reason };
152
+ return { required: false };
153
+ }
154
+
155
+ export function truncateForPrompt(value: string, maxChars = DEFAULT_PROMPT_TRUNCATE_CHARS): string {
156
+ if (value.length <= maxChars) return value;
157
+ const omitted = value.length - maxChars;
158
+ return `${value.slice(0, maxChars)}… (${omitted} chars truncated)`;
159
+ }
160
+
161
+ /**
162
+ * Format the approval prompt body shown to the user.
163
+ */
164
+ export function formatApprovalPrompt(tool: ApprovalSubject, args: unknown, reason?: string): string {
165
+ const lines = [`Allow tool: ${tool.name}`];
166
+
167
+ if (tool.name.startsWith("mcp__") && tool.approval === undefined) {
168
+ lines.push("Origin: MCP server tool");
169
+ }
170
+
171
+ if (reason) {
172
+ lines.push(`Reason: ${reason}`);
173
+ }
174
+
175
+ const details = tool.formatApprovalDetails?.(args);
176
+ if (typeof details === "string") {
177
+ if (details.length > 0) lines.push(details);
178
+ } else if (Array.isArray(details)) {
179
+ for (const detail of details) {
180
+ if (detail.length > 0) lines.push(detail);
181
+ }
182
+ }
183
+
184
+ return lines.join("\n");
185
+ }
package/src/tools/ask.ts CHANGED
@@ -378,6 +378,7 @@ type AskParams = AskToolInput;
378
378
  */
379
379
  export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
380
380
  readonly name = "ask";
381
+ readonly approval = "read" as const;
381
382
  readonly label = "Ask";
382
383
  readonly summary = "Ask the user a clarifying question";
383
384
  readonly description: string;
@@ -12,10 +12,11 @@ import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text
12
12
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
13
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
14
  import type { ToolSession } from ".";
15
+ import { truncateForPrompt } from "./approval";
15
16
  import { createFileRecorder, formatResultPath } from "./file-recorder";
16
17
  import { formatGroupedFiles } from "./grouped-file-output";
17
18
  import type { OutputMeta } from "./output-meta";
18
- import { resolveToolSearchScope } from "./path-utils";
19
+ import { isInternalUrlPath, resolveToolSearchScope } from "./path-utils";
19
20
  import {
20
21
  appendParseErrorsBulletList,
21
22
  capParseErrors,
@@ -162,6 +163,29 @@ export interface AstEditToolDetails {
162
163
 
163
164
  export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
164
165
  readonly name = "ast_edit";
166
+ readonly approval = (args: unknown) => {
167
+ const paths = Array.isArray((args as Partial<z.infer<typeof astEditSchema>>).paths)
168
+ ? ((args as Partial<z.infer<typeof astEditSchema>>).paths as string[])
169
+ : [];
170
+ return paths.length > 0 && paths.every(path => isInternalUrlPath(path)) ? "read" : "write";
171
+ };
172
+ readonly formatApprovalDetails = (args: unknown): string[] => {
173
+ const params = args as Partial<z.infer<typeof astEditSchema>>;
174
+ const lines: string[] = [];
175
+ const ops = Array.isArray(params.ops) ? params.ops : [];
176
+ const firstOp = ops[0];
177
+ if (firstOp) {
178
+ lines.push(`Pattern: ${truncateForPrompt(firstOp.pat)}`);
179
+ lines.push(`Replacement: ${truncateForPrompt(firstOp.out)}`);
180
+ if (ops.length > 1) {
181
+ lines.push(`+${ops.length - 1} more op${ops.length === 2 ? "" : "s"}`);
182
+ }
183
+ }
184
+ if (Array.isArray(params.paths) && params.paths.length > 0) {
185
+ lines.push(`Paths: ${truncateForPrompt(params.paths.join(", "))}`);
186
+ }
187
+ return lines;
188
+ };
165
189
  readonly label = "AST Edit";
166
190
  readonly summary = "Perform AST-aware code edits (structural refactoring)";
167
191
  readonly description: string;
@@ -122,6 +122,7 @@ export interface AstGrepToolDetails {
122
122
 
123
123
  export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
124
124
  readonly name = "ast_grep";
125
+ readonly approval = "read" as const;
125
126
  readonly label = "AST Grep";
126
127
  readonly summary = "Search code with AST patterns (structural grep)";
127
128
  readonly description: string;
package/src/tools/bash.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import * as fs from "node:fs";
2
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import type {
3
+ AgentTool,
4
+ AgentToolContext,
5
+ AgentToolResult,
6
+ AgentToolUpdateCallback,
7
+ ToolApprovalDecision,
8
+ } from "@oh-my-pi/pi-agent-core";
3
9
  import type { Component } from "@oh-my-pi/pi-tui";
4
10
  import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui";
5
11
  import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
@@ -17,6 +23,7 @@ import { renderStatusLine } from "../tui";
17
23
  import { CachedOutputBlock } from "../tui/output-block";
18
24
  import { getSixelLineMask } from "../utils/sixel";
19
25
  import type { ToolSession } from ".";
26
+ import { truncateForPrompt } from "./approval";
20
27
  import { applyBashFixups } from "./bash-command-fixup";
21
28
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
22
29
  import { checkBashInterception } from "./bash-interceptor";
@@ -34,6 +41,54 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
34
41
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
42
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
36
43
 
44
+ /**
45
+ * Bash patterns that force an approval prompt even in yolo mode.
46
+ *
47
+ * Kept intentionally tight — the cost of a false positive is one extra prompt;
48
+ * the cost of a false negative is data loss or a compromised host. New patterns
49
+ * should target shapes that are virtually never legitimate in automation.
50
+ */
51
+ export const CRITICAL_BASH_PATTERNS = [
52
+ // Recursive destruction.
53
+ /\brm\s+-[a-z]*[rRfF][a-z]*\s+\//i, // rm -rf /, rm -fr /, rm -r /, rm -f /…
54
+ /\bsudo\s+rm\b/i, // any `sudo rm`.
55
+ /\bchmod\s+-R\s+[0-7]+\s+\//i, // `chmod -R 777 /`.
56
+ /\bchmod\s+-R\s+[ugoa+\-=rwxXst,]+\s+\//, // `chmod -R u+x /`, `chmod -R u+rwx,o+w /etc` (symbolic mode, root target).
57
+ /\bchown\s+-R\s+\S+\s+\//i, // `chown -R user /`.
58
+
59
+ // Fork bomb (a few common spacings).
60
+ /:\(\)\s*\{\s*:\s*\|\s*:/i,
61
+
62
+ // Disk / filesystem destruction.
63
+ />\s*\/dev\/sd[a-z]/i, // write to disk device.
64
+ /\bmkfs(\.|\b)/i, // format filesystem.
65
+ /\bdd\s+if=.+of=\/dev\//i, // dd to a device.
66
+ /\bshred\s+\/dev\//i,
67
+ /\bcryptsetup\b/i,
68
+
69
+ // System-config destruction.
70
+ />\s*\/etc\/(?:passwd|shadow|sudoers)\b/i,
71
+ /\btee\s+(?:-a\s+)?\/etc\/(?:passwd|shadow|sudoers)\b/i, // `tee /etc/passwd`, `tee -a /etc/sudoers`.
72
+
73
+ // Remote-fetch-then-execute (curl/wget piped to a shell or process-subbed).
74
+ /\b(?:curl|wget|fetch)\b[^|]*\|\s*(?:bash|sh|zsh|fish)\b/i,
75
+ // Process-sub variants — `bash <(curl …)`, `source <(curl …)`, `. <(curl …)`. `.` and `source` are
76
+ // anchored to a command boundary so `find . -name` and similar don't false-positive.
77
+ /(?:^|[\s;&|(])(?:bash|sh|zsh|source|\.)\s+<\(\s*(?:curl|wget|fetch)\b/i,
78
+ // `eval "$(curl …)"` / `eval $(curl …)` / `eval \`curl …\``.
79
+ /\beval\s+["'`]?\$\(\s*(?:curl|wget|fetch)\b|\beval\s+`\s*(?:curl|wget|fetch)\b/i,
80
+
81
+ // Process/host control.
82
+ /\bkill\s+-9\s+1\b/, // kill PID 1.
83
+ // Process/host control — must sit at command position so `npm run reboot-tests`
84
+ // or `echo 'shutdown the queue'` don't false-positive.
85
+ /(?:^|[\s;&|(])(?:shutdown|poweroff|reboot|halt)(?:\s|$|[;|&])/i,
86
+ /(?:^|[\s;&|(])init\s+0\b/i,
87
+
88
+ // Network-shell exfil.
89
+ /\bnc\b[^|;]*\s-[a-zA-Z]*[ec][a-zA-Z]*\s/i, // `nc -e` / `nc -c`.
90
+ ] as const;
91
+
37
92
  async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
38
93
  try {
39
94
  const alloc = await session.allocateOutputArtifact?.("bash-original");
@@ -224,6 +279,19 @@ function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutS
224
279
  */
225
280
  export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
226
281
  readonly name = "bash";
282
+ readonly approval = (args: unknown): ToolApprovalDecision => {
283
+ const rawCommand = (args as Partial<BashToolInput>).command;
284
+ const command = typeof rawCommand === "string" ? rawCommand : "";
285
+ if (command !== "" && CRITICAL_BASH_PATTERNS.some(pattern => pattern.test(command))) {
286
+ return { tier: "exec", override: true, reason: "Critical pattern detected" };
287
+ }
288
+ return "exec";
289
+ };
290
+ readonly formatApprovalDetails = (args: unknown): string[] => {
291
+ const rawCommand = (args as Partial<BashToolInput>).command;
292
+ const command = typeof rawCommand === "string" ? rawCommand : "(missing)";
293
+ return [`Command: ${truncateForPrompt(command)}`];
294
+ };
227
295
  readonly label = "Bash";
228
296
  readonly loadMode = "essential";
229
297
  readonly description: string;
@@ -105,7 +105,7 @@ export async function acquireTab(
105
105
  await runInTabWithSnapshot(
106
106
  name,
107
107
  {
108
- code: `await tab.goto(${JSON.stringify(opts.url)}, { waitUntil: ${JSON.stringify(opts.waitUntil ?? "networkidle2")} });`,
108
+ code: `await tab.goto(${JSON.stringify(opts.url)}, { waitUntil: ${JSON.stringify(opts.waitUntil ?? "load")} });`,
109
109
  timeoutMs: opts.timeoutMs,
110
110
  signal: opts.signal,
111
111
  },
@@ -3,6 +3,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import * as z from "zod/v4";
4
4
  import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
5
5
  import type { ToolSession } from "../sdk";
6
+ import { truncateForPrompt } from "./approval";
6
7
  import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
7
8
  import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
8
9
  import { acquireTab, dropHeadlessTabs, getTab, releaseAllTabs, releaseTab, runInTab } from "./browser/tab-supervisor";
@@ -87,6 +88,20 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
87
88
  */
88
89
  export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
89
90
  readonly name = "browser";
91
+ readonly approval = "exec" as const;
92
+ readonly formatApprovalDetails = (args: unknown): string[] => {
93
+ const params = args as Partial<BrowserParams>;
94
+ const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
95
+ const tabName = typeof params.name === "string" ? params.name : DEFAULT_TAB_NAME;
96
+ lines.push(`Tab: ${truncateForPrompt(tabName)}`);
97
+ if (typeof params.url === "string" && params.url.length > 0) {
98
+ lines.push(`URL: ${truncateForPrompt(params.url)}`);
99
+ }
100
+ if (typeof params.code === "string" && params.code.length > 0) {
101
+ lines.push(`Code:\n${truncateForPrompt(params.code)}`);
102
+ }
103
+ return lines;
104
+ };
90
105
  readonly label = "Browser";
91
106
  readonly loadMode = "discoverable";
92
107
  readonly summary = "Control a headless browser to navigate and interact with web pages";
@@ -396,6 +396,7 @@ type CalculatorParams = z.infer<typeof calculatorSchema>;
396
396
  */
397
397
  export class CalculatorTool implements AgentTool<typeof calculatorSchema, CalculatorToolDetails> {
398
398
  readonly name = "calc";
399
+ readonly approval = "read" as const;
399
400
  readonly label = "Calc";
400
401
  readonly summary = "Evaluate a mathematical expression";
401
402
  readonly loadMode = "discoverable";
@@ -48,6 +48,7 @@ function isTopLevelSession(session: ToolSession): boolean {
48
48
 
49
49
  export class CheckpointTool implements AgentTool<typeof checkpointSchema, CheckpointToolDetails> {
50
50
  readonly name = "checkpoint";
51
+ readonly approval = "read" as const;
51
52
  readonly label = "Checkpoint";
52
53
  readonly summary = "Create a git-based checkpoint to save and restore session state";
53
54
  readonly description: string;
@@ -93,6 +94,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
93
94
 
94
95
  export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDetails> {
95
96
  readonly name = "rewind";
97
+ readonly approval = "read" as const;
96
98
  readonly label = "Rewind";
97
99
  readonly summary = "Rewind to a previously created checkpoint";
98
100
  readonly description: string;
@@ -5,6 +5,7 @@ import type {
5
5
  AgentToolResult,
6
6
  AgentToolUpdateCallback,
7
7
  RenderResultOptions,
8
+ ToolApprovalDecision,
8
9
  } from "@oh-my-pi/pi-agent-core";
9
10
  import { type Component, Text } from "@oh-my-pi/pi-tui";
10
11
  import { isEnoent, prompt } from "@oh-my-pi/pi-utils";
@@ -37,6 +38,7 @@ import debugDescription from "../prompts/tools/debug.md" with { type: "text" };
37
38
  import { renderStatusLine } from "../tui";
38
39
  import { CachedOutputBlock } from "../tui/output-block";
39
40
  import type { ToolSession } from ".";
41
+ import { truncateForPrompt } from "./approval";
40
42
  import type { OutputMeta } from "./output-meta";
41
43
  import { formatPathRelativeToCwd, resolveToCwd } from "./path-utils";
42
44
  import {
@@ -51,6 +53,23 @@ import { ToolError } from "./tool-errors";
51
53
  import { toolResult } from "./tool-result";
52
54
  import { clampTimeout } from "./tool-timeouts";
53
55
 
56
+ /**
57
+ * DAP debug actions that only read program state (no mutation, no execution).
58
+ * Execution-side actions (`launch`, `attach`, `continue`, `step_*`, `pause`,
59
+ * `evaluate`, breakpoint mutations, memory writes) are exec-tier.
60
+ */
61
+ export const DEBUG_READONLY_ACTIONS: ReadonlySet<string> = new Set([
62
+ "output",
63
+ "threads",
64
+ "stack_trace",
65
+ "scopes",
66
+ "variables",
67
+ "disassemble",
68
+ "read_memory",
69
+ "loaded_sources",
70
+ "modules",
71
+ "sessions",
72
+ ]);
54
73
  const debugSchema = z.object({
55
74
  action: z.enum([
56
75
  "launch",
@@ -609,6 +628,19 @@ export const debugToolRenderer = {
609
628
 
610
629
  export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails> {
611
630
  readonly name = "debug";
631
+ readonly approval = (args: unknown): ToolApprovalDecision => {
632
+ const rawAction = (args as Partial<DebugParams>).action;
633
+ const action = typeof rawAction === "string" ? rawAction.toLowerCase() : "";
634
+ return DEBUG_READONLY_ACTIONS.has(action) ? "read" : "exec";
635
+ };
636
+ readonly formatApprovalDetails = (args: unknown): string[] => {
637
+ const params = args as Partial<DebugParams>;
638
+ const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
639
+ if (typeof params.program === "string" && params.program.length > 0) {
640
+ lines.push(`Program: ${truncateForPrompt(params.program)}`);
641
+ }
642
+ return lines;
643
+ };
612
644
  readonly label = "Debug";
613
645
  readonly summary = "Debug a running process with DAP (debugger adapter protocol)";
614
646
  readonly description: string;
@@ -647,6 +679,9 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
647
679
  await validateLaunchProgram(program, commandCwd);
648
680
  const adapter = selectLaunchAdapter(program, commandCwd, params.adapter);
649
681
  if (!adapter) {
682
+ if (params.adapter === "debugpy") {
683
+ throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
684
+ }
650
685
  throw new ToolError(
651
686
  `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
652
687
  );
@@ -667,6 +702,9 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
667
702
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
668
703
  const adapter = selectAttachAdapter(commandCwd, params.adapter, params.port);
669
704
  if (!adapter) {
705
+ if (params.adapter === "debugpy") {
706
+ throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
707
+ }
670
708
  throw new ToolError(
671
709
  `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
672
710
  );
package/src/tools/eval.ts CHANGED
@@ -15,6 +15,7 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
15
15
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
16
16
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
17
17
  import { resolveEvalBackends, type ToolSession } from ".";
18
+ import { truncateForPrompt } from "./approval";
18
19
  import {
19
20
  formatStyledTruncationWarning,
20
21
  resolveOutputMaxColumns,
@@ -202,6 +203,20 @@ async function resolveBackend(session: ToolSession, language: EvalLanguage): Pro
202
203
 
203
204
  export class EvalTool implements AgentTool<typeof evalSchema> {
204
205
  readonly name = "eval";
206
+ readonly approval = "exec" as const;
207
+ readonly formatApprovalDetails = (args: unknown): string[] => {
208
+ const params = args as Partial<EvalToolParams>;
209
+ const cells = Array.isArray(params.cells) ? params.cells : [];
210
+ const firstCell = cells[0] as Partial<EvalCellInput> | undefined;
211
+ if (!firstCell) return [];
212
+ const language = typeof firstCell.language === "string" ? firstCell.language : "(missing)";
213
+ const code = typeof firstCell.code === "string" ? firstCell.code : "";
214
+ const lines = [`Language: ${language}`, `Code:\n${truncateForPrompt(code)}`];
215
+ if (cells.length > 1) {
216
+ lines.push(`+${cells.length - 1} more cell${cells.length === 2 ? "" : "s"}`);
217
+ }
218
+ return lines;
219
+ };
205
220
  readonly summary = "Execute Python or JavaScript code in an in-process eval backend";
206
221
  readonly loadMode = "discoverable";
207
222
  readonly label = "Eval";
package/src/tools/find.ts CHANGED
@@ -59,11 +59,15 @@ const MAX_GLOB_TIMEOUT_MS = 60_000;
59
59
  * Commas inside brace expansion (`{a,b}`) are legitimate glob syntax and
60
60
  * must pass through.
61
61
  */
62
- function validateFindPathInputs(paths: readonly string[]): void {
62
+ export function validateFindPathInputs(paths: readonly string[]): void {
63
63
  for (const entry of paths) {
64
64
  let braceDepth = 0;
65
65
  for (let i = 0; i < entry.length; i++) {
66
66
  const ch = entry.charCodeAt(i);
67
+ if (ch === 0x5c /* \ */ && i + 1 < entry.length) {
68
+ i++;
69
+ continue;
70
+ }
67
71
  if (ch === 0x7b /* { */) braceDepth++;
68
72
  else if (ch === 0x7d /* } */) {
69
73
  if (braceDepth > 0) braceDepth--;
@@ -115,6 +119,7 @@ export interface FindToolOptions {
115
119
 
116
120
  export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
117
121
  readonly name = "find";
122
+ readonly approval = "read" as const;
118
123
  readonly summary = "Find files and directories matching a glob pattern";
119
124
  readonly loadMode = "discoverable";
120
125
  readonly label = "Find";
@@ -304,6 +309,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
304
309
 
305
310
  let matches: natives.GlobMatch[];
306
311
  const onUpdateMatches: string[] = [];
312
+ const onUpdateMtimes: number[] = [];
307
313
  const updateIntervalMs = 200;
308
314
  let lastUpdate = 0;
309
315
  const emitUpdate = () => {
@@ -323,9 +329,10 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
323
329
  });
324
330
  };
325
331
  const onMatch = (err: Error | null, match: natives.GlobMatch | null) => {
326
- if (err || signal?.aborted || !match?.path) return;
332
+ if (err || combinedSignal.aborted || !match?.path) return;
327
333
  const relativePath = formatMatchPath(match.path, match.fileType);
328
334
  onUpdateMatches.push(relativePath);
335
+ onUpdateMtimes.push(match.mtime ?? 0);
329
336
  emitUpdate();
330
337
  };
331
338
 
@@ -335,7 +342,6 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
335
342
  {
336
343
  pattern: globPattern,
337
344
  path: searchPath,
338
- fileType: natives.FileType.File,
339
345
  hidden: includeHidden,
340
346
  maxResults: effectiveLimit,
341
347
  sortByMtime: true,
@@ -371,15 +377,18 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
371
377
  // instead of throwing — empty results after a multi-second wait force the
372
378
  // caller to retry blind, which is the worst possible outcome.
373
379
  const seen = new Set<string>();
374
- const partial: string[] = [];
375
- for (const entry of onUpdateMatches) {
380
+ const partial: Array<{ p: string; m: number }> = [];
381
+ for (let i = 0; i < onUpdateMatches.length; i++) {
382
+ const entry = onUpdateMatches[i];
376
383
  if (seen.has(entry)) continue;
377
384
  seen.add(entry);
378
- partial.push(entry);
385
+ partial.push({ p: entry, m: onUpdateMtimes[i] ?? 0 });
379
386
  }
387
+ partial.sort((a, b) => b.m - a.m);
388
+ const sortedPaths = partial.map(e => e.p);
380
389
  const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
381
- const notice = `find timed out after ${seconds}s; returning ${partial.length} partial matches — increase timeout or narrow pattern`;
382
- return buildResult(partial, { notice, forceTruncated: true });
390
+ const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — increase timeout or narrow pattern`;
391
+ return buildResult(sortedPaths, { notice, forceTruncated: true });
383
392
  }
384
393
 
385
394
  const relativized: string[] = [];
package/src/tools/gh.ts CHANGED
@@ -2,7 +2,13 @@ import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { scheduler } from "node:timers/promises";
5
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
+ import type {
6
+ AgentTool,
7
+ AgentToolContext,
8
+ AgentToolResult,
9
+ AgentToolUpdateCallback,
10
+ ToolApprovalDecision,
11
+ } from "@oh-my-pi/pi-agent-core";
6
12
 
7
13
  import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
14
  import * as z from "zod/v4";
@@ -196,6 +202,15 @@ const RUN_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/
196
202
  const RUN_SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
197
203
  const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure"]);
198
204
  const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]);
205
+ const GITHUB_READONLY_OPS: ReadonlySet<string> = new Set([
206
+ "repo_view",
207
+ "search_issues",
208
+ "search_prs",
209
+ "search_code",
210
+ "search_commits",
211
+ "search_repos",
212
+ "run_watch",
213
+ ]);
199
214
 
200
215
  const githubSchema = z
201
216
  .object({
@@ -2344,6 +2359,11 @@ function buildTextResult(
2344
2359
 
2345
2360
  export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails> {
2346
2361
  readonly name = "github";
2362
+ readonly approval = (args: unknown): ToolApprovalDecision => {
2363
+ const rawOp = (args as Partial<GithubInput>).op;
2364
+ const op = typeof rawOp === "string" ? rawOp : "";
2365
+ return GITHUB_READONLY_OPS.has(op) ? "read" : "exec";
2366
+ };
2347
2367
  readonly summary = "Interact with GitHub issues, pull requests, and repositories";
2348
2368
  readonly loadMode = "discoverable";
2349
2369
  readonly label = "GitHub";
@@ -13,6 +13,7 @@ export type HindsightRecallParams = z.infer<typeof hindsightRecallSchema>;
13
13
 
14
14
  export class HindsightRecallTool implements AgentTool<typeof hindsightRecallSchema> {
15
15
  readonly name = "recall";
16
+ readonly approval = "read" as const;
16
17
  readonly label = "Recall";
17
18
  readonly description = recallDescription;
18
19
  readonly parameters = hindsightRecallSchema;
@@ -14,6 +14,7 @@ export type HindsightReflectParams = z.infer<typeof hindsightReflectSchema>;
14
14
 
15
15
  export class HindsightReflectTool implements AgentTool<typeof hindsightReflectSchema> {
16
16
  readonly name = "reflect";
17
+ readonly approval = "read" as const;
17
18
  readonly label = "Reflect";
18
19
  readonly description = reflectDescription;
19
20
  readonly parameters = hindsightReflectSchema;
@@ -18,6 +18,7 @@ const hindsightRetainSchema = z.object({
18
18
  export type HindsightRetainParams = z.infer<typeof hindsightRetainSchema>;
19
19
  export class HindsightRetainTool implements AgentTool<typeof hindsightRetainSchema> {
20
20
  readonly name = "retain";
21
+ readonly approval = "read" as const;
21
22
  readonly label = "Retain";
22
23
  readonly description = retainDescription;
23
24
  readonly parameters = hindsightRetainSchema;
@@ -901,6 +901,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
901
901
  name: "generate_image",
902
902
  label: "GenerateImage",
903
903
  strict: false,
904
+ approval: "write",
904
905
  description: prompt.render(imageGenDescription),
905
906
  parameters: imageGenSchema,
906
907
  async execute(_toolCallId, params, _onUpdate, ctx, signal) {