pi-code 1.0.61 → 1.0.63

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.
@@ -18,6 +18,8 @@ import * as path from 'node:path'
18
18
 
19
19
  import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
20
20
 
21
+ import { CLAUDE_TOOL_MAP } from './claude-tool-names.js'
22
+
21
23
  /** The pi file tools a Claude path rule can govern. */
22
24
  export type PathRuleTool = 'read' | 'edit' | 'write'
23
25
 
@@ -27,6 +29,12 @@ export interface ParsedCommand {
27
29
  allowedTools?: string[]
28
30
  /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
29
31
  bashRules?: string[]
32
+ /** Claude `WebFetch(domain:...)` specifiers, on the same unscoped-wins rule. */
33
+ domainRules?: string[]
34
+ /** Claude `Agent(...)`/`Task(...)` agent names, on the same unscoped-wins rule. */
35
+ agentRules?: string[]
36
+ /** Claude `Skill(...)` name patterns, on the same unscoped-wins rule. */
37
+ skillRules?: string[]
30
38
  /** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
31
39
  pathRules?: Partial<Record<PathRuleTool, string[]>>
32
40
  /** Names from the `arguments:` frontmatter list, mapped to positions in order. */
@@ -58,34 +66,6 @@ export interface DiscoveredCommand {
58
66
  plugin?: { root: string; dataDir: string; userConfig?: Record<string, string> }
59
67
  }
60
68
 
61
- /** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
62
- * `find`. Lowercasing alone left `glob` in the list, and since pi has no tool by
63
- * that name the grant was silently dropped when the list was intersected with the
64
- * active tools. Shared with the subagent's own frontmatter parsing. */
65
- const CLAUDE_TOOL_MAP: Record<string, string> = {
66
- read: 'read',
67
- write: 'write',
68
- edit: 'edit',
69
- bash: 'bash',
70
- grep: 'grep',
71
- glob: 'find',
72
- ls: 'ls',
73
- // Claude's names for the tools this package registers itself. Without these a
74
- // perfectly ordinary `allowed-tools: WebFetch, WebSearch` matched no pi tool and
75
- // the intersection left the turn with nothing.
76
- webfetch: 'web_fetch',
77
- websearch: 'web_search',
78
- todowrite: 'todo',
79
- todoread: 'todo',
80
- task: 'subagent',
81
- askuserquestion: 'question',
82
- exitplanmode: 'plan_mode_complete',
83
- // Claude's name for the tool this package registers so the model can run user slash
84
- // commands; without it `allowed-tools: SlashCommand` matched nothing and the grant
85
- // could neither keep nor drop the tool.
86
- slashcommand: 'slash_command',
87
- }
88
-
89
69
  /**
90
70
  * The pi tool name for one grant entry, scope and all: `Bash(git add:*)` is `bash`.
91
71
  * Keeping the scope in the name matched nothing when the list was intersected with
@@ -135,6 +115,14 @@ export interface ToolGrants {
135
115
  /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped:
136
116
  * an unscoped `Bash` entry is the wider grant and wins over its scoped siblings. */
137
117
  bashRules?: string[]
118
+ /** Claude `WebFetch(domain:host)` specifiers, same unscoped-wins rule as bash.
119
+ * Dropping them granted unrestricted web_fetch to a command that asked for one host. */
120
+ domainRules?: string[]
121
+ /** Claude `Agent(AgentName)` names from `Agent(...)` or the legacy `Task(...)`,
122
+ * same unscoped-wins rule. Dropping them granted the whole subagent tool. */
123
+ agentRules?: string[]
124
+ /** Claude `Skill(name)` / `Skill(name *)` specifiers, same unscoped-wins rule. */
125
+ skillRules?: string[]
138
126
  /** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
139
127
  * Edit scopes govern writes too, as Claude documents; Write scopes are honored
140
128
  * rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
@@ -150,18 +138,32 @@ const PATH_RULE_TOOLS: Record<string, Array<PathRuleTool>> = {
150
138
  write: ['write'],
151
139
  }
152
140
 
141
+ /** pi tools whose Claude specifier is a scalar argument scope rather than a path
142
+ * rule: `Bash(cmd)`, `WebFetch(domain:host)` and `Agent(AgentName)`. One list, so a
143
+ * tool cannot be granted a scope here and quietly miss the unscoped-wins rule. */
144
+ const ARG_RULE_TOOLS = ['bash', 'web_fetch', 'subagent', 'slash_command'] as const
145
+ type ArgRuleTool = (typeof ARG_RULE_TOOLS)[number]
146
+
147
+ const isArgRuleTool = (name: string): name is ArgRuleTool => (ARG_RULE_TOOLS as readonly string[]).includes(name)
148
+
153
149
  /** The tools, scopes, and path rules accumulated while scanning one grant list. */
154
150
  interface GrantAccumulator {
155
151
  tools: string[]
156
152
  scopedEntries: string[]
157
- bashRules: string[]
158
- bashUnscoped: boolean
153
+ argScopes: Record<ArgRuleTool, string[]>
154
+ argUnscoped: Set<ArgRuleTool>
159
155
  pathScopes: Record<PathRuleTool, string[]>
160
156
  pathUnscoped: Set<PathRuleTool>
161
157
  }
162
158
 
163
159
  function createGrantAccumulator(): GrantAccumulator {
164
- return { tools: [], scopedEntries: [], bashRules: [], bashUnscoped: false, pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
160
+ return { tools: [], scopedEntries: [], argScopes: { bash: [], web_fetch: [], subagent: [], slash_command: [] }, argUnscoped: new Set(), pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
161
+ }
162
+
163
+ /** The scopes for one argument-ruled tool, or undefined when it has none or when an
164
+ * unscoped grant for it makes it wide. The wider grant wins, as it does for bash. */
165
+ function argRules(acc: GrantAccumulator, tool: ArgRuleTool): string[] | undefined {
166
+ return !acc.argUnscoped.has(tool) && acc.argScopes[tool].length > 0 ? acc.argScopes[tool] : undefined
165
167
  }
166
168
 
167
169
  /** Coerce a raw grant value to its string entries: a YAML list stays a list, a
@@ -186,7 +188,7 @@ function addGrantEntry(acc: GrantAccumulator, item: string): void {
186
188
  if (!acc.tools.includes(name)) acc.tools.push(name)
187
189
  const open = entry.indexOf('(')
188
190
  if (open === -1) {
189
- if (name === 'bash') acc.bashUnscoped = true
191
+ if (isArgRuleTool(name)) acc.argUnscoped.add(name)
190
192
  for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathUnscoped.add(tool)
191
193
  return
192
194
  }
@@ -195,7 +197,7 @@ function addGrantEntry(acc: GrantAccumulator, item: string): void {
195
197
  // An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
196
198
  // the unscoped grant it explicitly is not: it is recorded so the tool stays
197
199
  // restricted, and the matchers treat an empty rule as matching no input.
198
- if (name === 'bash') acc.bashRules.push(scope)
200
+ if (isArgRuleTool(name)) acc.argScopes[name].push(scope)
199
201
  for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathScopes[tool].push(scope)
200
202
  }
201
203
 
@@ -217,10 +219,14 @@ function buildPathRules(acc: GrantAccumulator): ToolGrants['pathRules'] {
217
219
  *
218
220
  * Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
219
221
  * pi's active-tool list is per tool, with no argument dimension, so the base tool is
220
- * granted and the scope is kept: commands.ts enforces bash scopes at tool_call time,
221
- * and the subagent's frontmatter parsing rejects a scoped grant it cannot express.
222
- * A scope on any other tool is dropped, which widens that grant; bash is the one
223
- * whose widening reaches everything, so it is the one enforced.
222
+ * granted and the scope is kept for commands.ts to enforce at tool_call time. Every
223
+ * specifier Claude documents for an allow rule is kept: `Bash(cmd)`, the Read/Edit
224
+ * path rules, `WebFetch(domain:host)` and `Agent(AgentName)`. The subagent's own
225
+ * frontmatter parsing still rejects a scoped grant outright, since it has no
226
+ * call-time seam to enforce one in.
227
+ *
228
+ * Claude's `Tool(param:value)` form is not among them: the permissions reference
229
+ * confines it to deny and ask rules, and `allowed-tools` is an allow surface.
224
230
  */
225
231
  export function parseToolGrants(raw: unknown): ToolGrants | undefined {
226
232
  const items = coerceGrantItems(raw)
@@ -230,7 +236,10 @@ export function parseToolGrants(raw: unknown): ToolGrants | undefined {
230
236
  return {
231
237
  tools: acc.tools,
232
238
  scopedEntries: acc.scopedEntries,
233
- bashRules: !acc.bashUnscoped && acc.bashRules.length > 0 ? acc.bashRules : undefined,
239
+ bashRules: argRules(acc, 'bash'),
240
+ domainRules: argRules(acc, 'web_fetch'),
241
+ agentRules: argRules(acc, 'subagent'),
242
+ skillRules: argRules(acc, 'slash_command'),
234
243
  pathRules: buildPathRules(acc),
235
244
  }
236
245
  }
@@ -293,6 +302,9 @@ export function parseCommandFile(content: string): ParsedCommand {
293
302
  argumentHint: hint(frontmatter['argument-hint']) || undefined,
294
303
  allowedTools: grants?.tools,
295
304
  bashRules: grants?.bashRules,
305
+ domainRules: grants?.domainRules,
306
+ agentRules: grants?.agentRules,
307
+ skillRules: grants?.skillRules,
296
308
  pathRules: grants?.pathRules,
297
309
  argumentNames: parseArgumentNames(frontmatter.arguments),
298
310
  // A scope on a disallow entry only denies more than asked, so the drop is safe.
@@ -18,6 +18,7 @@ import * as path from 'node:path'
18
18
  import { getAgentDir } from '@earendil-works/pi-coding-agent'
19
19
  import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
20
20
  import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
21
+ import { errorMessage } from './values.js'
21
22
 
22
23
  interface StoredAuth {
23
24
  client?: OAuthClientInformationMixed
@@ -71,6 +72,14 @@ function storeFileFor(serverName: string, endpoint?: string): string {
71
72
  return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
72
73
  }
73
74
 
75
+ /** MCP_OAUTH_CALLBACK_PORT parsed, or undefined when unset or not a plain integer. */
76
+ function envCallbackPort(): number | undefined {
77
+ const raw = process.env.MCP_OAUTH_CALLBACK_PORT
78
+ if (raw === undefined || raw.trim() === '') return undefined
79
+ const parsed = Number(raw)
80
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
81
+ }
82
+
74
83
  export class FileOAuthProvider implements OAuthClientProvider {
75
84
  private readonly storePath: string
76
85
  private readonly data: StoredAuth
@@ -105,10 +114,20 @@ export class FileOAuthProvider implements OAuthClientProvider {
105
114
  fs.writeFileSync(this.storePath, JSON.stringify(this.data), { mode: 0o600 })
106
115
  }
107
116
 
117
+ /** The configured callbackPort alone, absent when only a remembered port exists.
118
+ * The caller needs the two apart: a configured port is a hard requirement. Falls
119
+ * back to MCP_OAUTH_CALLBACK_PORT, Claude's "alternative to --callback-port when
120
+ * adding an MCP server with pre-configured credentials"; pi-code has no `mcp add`
121
+ * command, so the env var applies as a default for any server naming no port of
122
+ * its own rather than only ones added that way. */
123
+ configuredRedirectPort(): number | undefined {
124
+ return this.oauth?.callbackPort ?? envCallbackPort()
125
+ }
126
+
108
127
  /** The configured callbackPort (Claude: for pre-registered redirect URIs), else
109
128
  * the port a prior login registered, so a re-login can bind the same one. */
110
129
  savedRedirectPort(): number | undefined {
111
- return this.oauth?.callbackPort ?? this.data.redirectPort
130
+ return this.oauth?.callbackPort ?? envCallbackPort() ?? this.data.redirectPort
112
131
  }
113
132
 
114
133
  /** Record the loopback port the callback server actually bound; the redirect
@@ -193,13 +212,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
193
212
  /** A one-shot loopback listener for the authorization redirect. Loopback redirect
194
213
  * URIs are the RFC 8252 pattern for native apps. A preferred port (from a prior
195
214
  * login) is tried first so a re-login keeps the registered redirect_uri; if it is
196
- * taken, an ephemeral port is used. */
197
- export async function startCallbackServer(preferredPort?: number): Promise<{ server: http.Server; port: number }> {
215
+ * taken, an ephemeral port is used.
216
+ *
217
+ * `portRequired` marks the port as configured rather than remembered. A configured
218
+ * `oauth.callbackPort` names the redirect_uri the IdP has registered, so quietly
219
+ * binding a different one sends the user to an opaque redirect_uri mismatch at the
220
+ * IdP; the bind failure is reported here instead, where it can name the real cause. */
221
+ export async function startCallbackServer(preferredPort?: number, portRequired = false): Promise<{ server: http.Server; port: number }> {
198
222
  const server = http.createServer()
199
223
  const listen = (port: number, host: string): Promise<void> => new Promise((resolve, reject) => server.listen(port, host, resolve).once('error', reject))
200
224
  try {
201
225
  await listen(preferredPort ?? 0, '127.0.0.1')
202
- } catch {
226
+ } catch (error) {
227
+ if (portRequired) throw new Error(`oauth.callbackPort ${preferredPort} is in use, so the registered redirect URI cannot be served: free that port or change oauth.callbackPort (${errorMessage(error)})`)
203
228
  await listen(0, '127.0.0.1')
204
229
  }
205
230
  const port = (server.address() as { port: number }).port
@@ -96,10 +96,29 @@ function bracketEnd(pattern: string, start: number): number {
96
96
 
97
97
  /** A bracket expression body as a regex character class, escaping regex-relevant
98
98
  * characters while keeping `-` ranges; a leading `!` (or `^`) negates. */
99
- function bracketClass(body: string): string {
99
+ function bracketClass(body: string): string | null {
100
100
  const negated = body.startsWith('!') || body.startsWith('^')
101
101
  const members = (negated ? body.slice(1) : body).replace(/[\\\]^]/g, (ch) => `\\${ch}`)
102
- return `[${negated ? '^' : ''}${members}]`
102
+ const source = `[${negated ? '^' : ''}${members}]`
103
+ // A range whose endpoints descend, `["- ]` for instance, is not a character class
104
+ // JavaScript will build: RegExp throws "Range out of order in character class". The
105
+ // `-` cannot simply be escaped, since `[a-z]` is the whole point of the syntax, so the
106
+ // class is validated by construction and an unbuildable one is treated exactly as an
107
+ // unterminated `[` already is: the pattern is invalid and matches nothing. Without
108
+ // this the throw escaped compileGlobs, which does not catch, and took the permission
109
+ // check that called it with it.
110
+ return isBuildableClass(source) ? source : null
111
+ }
112
+
113
+ /** Whether JavaScript will build this character class. Asking RegExp is the only
114
+ * faithful test: the invalid forms are its rules, not ones worth re-deriving here. */
115
+ function isBuildableClass(source: string): boolean {
116
+ try {
117
+ new RegExp(source)
118
+ return true
119
+ } catch {
120
+ return false
121
+ }
103
122
  }
104
123
 
105
124
  /** A `*` run starting at `i`: a double star followed by a slash spans whole
@@ -114,39 +133,33 @@ function translateStar(pattern: string, i: number): { source: string; next: numb
114
133
  return { source: '[^/]*', next: i + 1 }
115
134
  }
116
135
 
136
+ /** The regex source for the construct at `i` and the index after it, or null when the
137
+ * pattern is invalid there and so matches nothing. */
138
+ function translateAt(pattern: string, i: number): { source: string; next: number } | null {
139
+ const ch = pattern[i]
140
+ // Claude: to match a literal bracket, escape it; the escape consumes both chars.
141
+ if (ch === '\\' && (pattern[i + 1] === '[' || pattern[i + 1] === ']')) return { source: escapeRegExp(pattern[i + 1]), next: i + 2 }
142
+ // Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be read
143
+ // as one, or a body that is not a buildable class, makes the pattern invalid.
144
+ if (ch === '[') {
145
+ const end = bracketEnd(pattern, i)
146
+ if (end === -1) return null
147
+ const cls = bracketClass(pattern.slice(i + 1, end))
148
+ return cls === null ? null : { source: cls, next: end + 1 }
149
+ }
150
+ if (ch === '*') return translateStar(pattern, i)
151
+ if (ch === '?') return { source: '[^/]', next: i + 1 }
152
+ return { source: escapeRegExp(ch), next: i + 1 }
153
+ }
154
+
117
155
  function translateGlob(pattern: string): string | null {
118
156
  let out = ''
119
157
  let i = 0
120
158
  while (i < pattern.length) {
121
- const ch = pattern[i]
122
- // Claude: to match a literal bracket, escape it; the escape consumes both chars.
123
- if (ch === '\\' && (pattern[i + 1] === '[' || pattern[i + 1] === ']')) {
124
- out += escapeRegExp(pattern[i + 1])
125
- i += 2
126
- continue
127
- }
128
- // Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be
129
- // read as one makes the pattern invalid, matching nothing.
130
- if (ch === '[') {
131
- const end = bracketEnd(pattern, i)
132
- if (end === -1) return null
133
- out += bracketClass(pattern.slice(i + 1, end))
134
- i = end + 1
135
- continue
136
- }
137
- if (ch === '*') {
138
- const star = translateStar(pattern, i)
139
- out += star.source
140
- i = star.next
141
- continue
142
- }
143
- if (ch === '?') {
144
- out += '[^/]'
145
- i += 1
146
- continue
147
- }
148
- out += escapeRegExp(ch)
149
- i += 1
159
+ const step = translateAt(pattern, i)
160
+ if (step === null) return null
161
+ out += step.source
162
+ i = step.next
150
163
  }
151
164
  return out
152
165
  }
@@ -243,6 +256,23 @@ const toPosix = (target: string): string => {
243
256
  return withSlashes.replace(/^\/?[A-Za-z]:\//, '/')
244
257
  }
245
258
 
259
+ /**
260
+ * Drop a drive from a RESOLVED rule so it can meet a target that toPosix has already
261
+ * stripped. Claude documents `//path` as an absolute path from the filesystem root,
262
+ * and on Windows its own example names the drive as the first segment (`//c/` then a
263
+ * recursive glob), where `c` is the drive; that resolved to `/c/...` while every target
264
+ * resolved to `/...`, so the rule could never match anything.
265
+ *
266
+ * Windows only, and that is the whole point of the flag: on POSIX `/c/foo` is an
267
+ * ordinary absolute path and stripping its first segment would widen the rule to
268
+ * everything under the root. Exported and platform-parameterized rather than reading
269
+ * process.platform inline, so both branches are assertable from either host.
270
+ */
271
+ export function stripRuleDrive(rule: string, windows: boolean): string {
272
+ if (!windows) return rule
273
+ return rule.replace(/^([A-Za-z]):\//, '/').replace(/^\/[A-Za-z]\//, '/')
274
+ }
275
+
246
276
  export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
247
277
  const target = toPosix(path.resolve(anchors.cwd, filePath))
248
278
  return rules.some((rule) => {
@@ -250,7 +280,7 @@ export function matchesPathRules(filePath: string, rules: string[], anchors: Pat
250
280
  // An empty specifier (`Read()`) matches nothing, so the tool stays blocked
251
281
  // rather than falling open, mirroring `Bash()`.
252
282
  if (trimmed === '') return false
253
- const resolved = toPosix(resolveRule(trimmed, anchors))
283
+ const resolved = stripRuleDrive(toPosix(resolveRule(trimmed, anchors)), path.sep === '\\')
254
284
  return new RegExp(`^${globToRegExpSource(resolved)}$`).test(target)
255
285
  })
256
286
  }
@@ -215,6 +215,17 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
215
215
  return plugins
216
216
  }
217
217
 
218
+ /** The plugins a managed `enabledPlugins` entry force-enables. Claude exempts their
219
+ * hooks from `allowManagedHooksOnly`: an administrator who turned a plugin on meant its
220
+ * hooks to run. Keys are matched the way pluginEnabled matches them, by the qualified
221
+ * `name@marketplace` or the bare directory name, so the two cannot drift apart. */
222
+ export function managedForceEnabled(plugins: InstalledPlugin[]): InstalledPlugin[] {
223
+ const managedEntry = readManagedSettings().enabledPlugins
224
+ if (managedEntry === null || typeof managedEntry !== 'object') return []
225
+ const entries = managedEntry as Record<string, unknown>
226
+ return plugins.filter((plugin) => Object.entries(entries).some(([key, value]) => value === true && (key === plugin.name || key.startsWith(`${plugin.name}@`))))
227
+ }
228
+
218
229
  /** The plugin's effective enablement per Claude's precedence: a managed
219
230
  * enabledPlugins entry force-enables or blocks, then the user's setting, then the
220
231
  * manifest's defaultEnabled, which defaults to true ("starts in an enabled state
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Claude's argument scopes for the two tools whose specifier is neither a bash
3
+ * command nor a file path: `WebFetch(domain:host)` and `Agent(AgentName)`.
4
+ *
5
+ * pi's active-tool set has no argument dimension, so commands.ts grants the base
6
+ * tool and checks each call here, the way it already does for `Bash(...)` scopes.
7
+ * Both matchers fail closed: a scope this module cannot interpret matches nothing
8
+ * rather than reading as the unscoped grant the author did not write.
9
+ *
10
+ * Claude's parameter form `Tool(param:value)` is deliberately absent. The
11
+ * permissions reference restricts it to deny and ask rules ("An allow rule for one
12
+ * parameter value wouldn't establish that the call is safe overall, so allow rules
13
+ * continue to use each tool's own specifier syntax"), and every scope reaching this
14
+ * module comes from an allow surface: a command's `allowed-tools`.
15
+ */
16
+
17
+ /** Regex specials except `*`, which carries the rule's own wildcard meaning. */
18
+ const escapeExceptStar = (text: string): string => text.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`)
19
+
20
+ /** A hostname pattern segment: `*` matches any text that does not cross a dot. */
21
+ const hostPattern = (pattern: string): string => escapeExceptStar(pattern).replaceAll('*', '[^.]*')
22
+
23
+ /** Lowercased, with the trailing `.` the reference strips from both sides removed. */
24
+ const canonicalHost = (host: string): string => host.trim().toLowerCase().replace(/\.$/, '')
25
+
26
+ /** The hostname of a fetch target, or '' when the url does not parse. */
27
+ function hostnameOf(url: string): string {
28
+ try {
29
+ return canonicalHost(new URL(url).hostname)
30
+ } catch {
31
+ return ''
32
+ }
33
+ }
34
+
35
+ /** The host pattern of one `domain:` scope, or undefined for any other spelling.
36
+ * WebFetch has exactly one documented specifier syntax, so an unrecognized scope is
37
+ * not a wider grant, it is a rule that matches nothing. */
38
+ function domainPattern(rule: string): string | undefined {
39
+ const trimmed = rule.trim()
40
+ const colon = trimmed.indexOf(':')
41
+ if (colon === -1 || trimmed.slice(0, colon).trim().toLowerCase() !== 'domain') return undefined
42
+ const pattern = canonicalHost(trimmed.slice(colon + 1))
43
+ return pattern === '' ? undefined : pattern
44
+ }
45
+
46
+ function matchesDomainRule(host: string, rule: string): boolean {
47
+ const pattern = domainPattern(rule)
48
+ if (pattern === undefined) return false
49
+ if (pattern === '*') return true
50
+ // A leading `*.` is the one wildcard that crosses dots: it stands for one or more
51
+ // whole labels, so it covers `a.b.example.com` while leaving the apex unmatched.
52
+ if (pattern.startsWith('*.')) return new RegExp(String.raw`^(?:[^.]+\.)+${hostPattern(pattern.slice(2))}$`).test(host)
53
+ return new RegExp(`^${hostPattern(pattern)}$`).test(host)
54
+ }
55
+
56
+ /**
57
+ * Claude: "WebFetch rules use a `domain:` prefix and match against the hostname of
58
+ * the requested URL. Matching is case-insensitive, supports `*` wildcards, and strips
59
+ * a trailing `.` from both the rule and the hostname."
60
+ */
61
+ export function matchesDomainRules(url: string, rules: string[]): boolean {
62
+ const host = hostnameOf(url)
63
+ if (host === '') return false
64
+ return rules.some((rule) => matchesDomainRule(host, rule))
65
+ }
66
+
67
+ /**
68
+ * Claude: "Permission syntax: `Skill(name)` for exact match, `Skill(name *)` for
69
+ * prefix match with any arguments."
70
+ *
71
+ * The invocation is the skill name and its arguments as one string, the shape pi's
72
+ * `slash_command` tool takes; a leading `/` is optional there, so it is stripped
73
+ * before matching. Only the two documented forms are interpreted. A rule spelled any
74
+ * other way, `commit:*` included, matches nothing rather than widening the grant.
75
+ */
76
+ export function matchesSkillRules(invocation: string, rules: string[]): boolean {
77
+ const call = invocation.trim().replace(/^\//, '').trim()
78
+ if (call === '') return false
79
+ return rules.some((raw) => {
80
+ const rule = raw.trim()
81
+ if (rule === '') return false
82
+ if (!rule.endsWith(' *')) return rule === call
83
+ // The space before the trailing `*` is part of the rule, so `review-pr *` covers
84
+ // `review-pr` and `review-pr 123` but never the longer name `review-pretend`.
85
+ const prefix = rule.slice(0, -2).trimEnd()
86
+ return prefix !== '' && (call === prefix || call.startsWith(`${prefix} `))
87
+ })
88
+ }
89
+
90
+ /** The `agent` of one call or task entry, when it has one. */
91
+ const agentNameOf = (value: unknown): string | undefined => {
92
+ const record = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
93
+ return typeof record?.agent === 'string' ? record.agent : undefined
94
+ }
95
+
96
+ /** Every agent name one subagent call names, across all three modes: `agent` for
97
+ * single, and the `agent` of each entry in `tasks` (parallel) or `chain` (sequential).
98
+ * One collector, so a rule checked against single mode cannot be quietly skipped for
99
+ * the two modes that carry their names in an array. */
100
+ export function agentNamesIn(input: unknown): string[] {
101
+ const raw = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
102
+ const listed = ['tasks', 'chain'].flatMap((key) => (Array.isArray(raw[key]) ? (raw[key] as unknown[]) : []))
103
+ return [input, ...listed].map(agentNameOf).filter((name): name is string => name !== undefined)
104
+ }
105
+
106
+ /**
107
+ * Claude: "Use `Agent(AgentName)` rules to control which subagents Claude can use."
108
+ *
109
+ * Every agent the call names must match, not just the first. A subagent call carries
110
+ * names in `agent`, `tasks[].agent` and `chain[].agent`; gating one field would let
111
+ * parallel or chain mode route around the rule. A call naming no agent cannot be
112
+ * checked against the scope, so it fails closed too.
113
+ */
114
+ export function matchesAgentRules(names: string[], rules: string[]): boolean {
115
+ if (names.length === 0) return false
116
+ return names.every((name) => rules.some((rule) => rule.trim() !== '' && rule.trim() === name.trim()))
117
+ }
@@ -37,11 +37,22 @@ const isFile = (file: string): boolean => {
37
37
  * Windows spellings on win32, where powershell.exe ships with the OS. */
38
38
  const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
39
39
 
40
- /** First PowerShell binary found on PATH, or undefined when none is installed. */
41
- export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
42
- const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
40
+ /** First PowerShell binary found on PATH, or undefined when none is installed.
41
+ *
42
+ * A PATH entry that is the launch directory, or project tooling below it, is skipped
43
+ * for the same reason resolveGitBash skips one: a repository that ships `pwsh.exe`
44
+ * must not become the shell its own hooks and spans run through. Go made this the
45
+ * default in 1.19 (`os/exec` refuses a program resolved "relative to the current
46
+ * directory", returning ErrDot), and Windows offers NoDefaultCurrentDirectoryInExePath
47
+ * for it; node honors neither (nodejs/node#46264), so the check belongs here. */
48
+ export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd()): string | undefined {
49
+ // `env.Path` as well as `env.PATH`, matching resolveGitBash: process.env is
50
+ // case-insensitive on Windows, but an env object handed in by a caller or a test is
51
+ // whatever spelling it was built with, and the two resolvers must read it alike.
52
+ const dirs = (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)
43
53
  for (const candidate of powershellCandidates(platform)) {
44
54
  for (const dir of dirs) {
55
+ if (isProjectTooling(dir, cwd)) continue
45
56
  const full = path.join(dir, candidate)
46
57
  try {
47
58
  fs.accessSync(full, fs.constants.X_OK)
@@ -111,11 +122,11 @@ const powershellShell = (file: string): ResolvedShell => ({
111
122
  */
112
123
  export function resolveShell(preferred: string | undefined, platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): ResolvedShell | undefined {
113
124
  if (preferred === 'powershell') {
114
- const powershell = resolvePowershellBinary(platform, env)
125
+ const powershell = resolvePowershellBinary(platform, env, cwd)
115
126
  if (powershell) return powershellShell(powershell)
116
127
  }
117
128
  const bash = bashBinary(platform, env, cwd, installRoots)
118
129
  if (bash) return bashShell(bash)
119
- const powershell = resolvePowershellBinary(platform, env)
130
+ const powershell = resolvePowershellBinary(platform, env, cwd)
120
131
  return powershell ? powershellShell(powershell) : undefined
121
132
  }
@@ -52,6 +52,13 @@ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
52
52
  // (204/205/304) and for status 0. That throw fires here, off the Promise
53
53
  // executor, so without this guard it escapes as an uncaughtException and pi
54
54
  // exits. Give those statuses a null body; reject anything else that throws.
55
+ //
56
+ // The catch below has no test and cannot get one through this function: the
57
+ // only two throw sources are the null-body statuses, which the line under this
58
+ // comment handles, and a status outside 200-599, which never reaches this
59
+ // callback at all (node routes 1xx to the `information` event and rejects a
60
+ // malformed status line in the parser). It stays as depth, not dead code, but
61
+ // do not chase its coverage with a test that reaches it some other way.
55
62
  const body = NULL_BODY_STATUSES.has(status) ? null : (Readable.toWeb(res) as ReadableStream<Uint8Array>)
56
63
  resolve(new Response(body, { status, headers }))
57
64
  } catch (err) {
@@ -27,6 +27,8 @@ export interface StdioServerConfig {
27
27
  baseName?: string
28
28
  /** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
29
29
  pluginRoot?: string
30
+ /** ${CLAUDE_PLUGIN_DATA} for a plugin's server, exported alongside the root. */
31
+ pluginDataDir?: string
30
32
  /** Loaded from the project scope, whose helpers run credential-stripped. */
31
33
  projectScope?: boolean
32
34
  }
@@ -52,6 +54,8 @@ export interface HttpServerConfig {
52
54
  baseName?: string
53
55
  /** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
54
56
  pluginRoot?: string
57
+ /** ${CLAUDE_PLUGIN_DATA} for a plugin's server, exported alongside the root. */
58
+ pluginDataDir?: string
55
59
  /** Loaded from the project scope, whose helpers run credential-stripped. */
56
60
  projectScope?: boolean
57
61
  }
@@ -225,7 +229,7 @@ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: strin
225
229
  // plugin:<plugin-name>:<server-name>", which is what an mcp_tool hook names and what
226
230
  // keeps a same-named user server from replacing a plugin's. The tool alias keeps its
227
231
  // own flat spelling, mcp__plugin_<plugin>_<server>__<tool>.
228
- if (substituted) servers[`plugin:${plugin.name}:${name}`] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, baseName: name, pluginRoot: plugin.root }
232
+ if (substituted) servers[`plugin:${plugin.name}:${name}`] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, baseName: name, pluginRoot: plugin.root, pluginDataDir: plugin.dataDir }
229
233
  }
230
234
  }
231
235
  return servers