pi-code 1.0.60 → 1.0.62

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
@@ -105,6 +106,12 @@ export class FileOAuthProvider implements OAuthClientProvider {
105
106
  fs.writeFileSync(this.storePath, JSON.stringify(this.data), { mode: 0o600 })
106
107
  }
107
108
 
109
+ /** The configured callbackPort alone, absent when only a remembered port exists.
110
+ * The caller needs the two apart: a configured port is a hard requirement. */
111
+ configuredRedirectPort(): number | undefined {
112
+ return this.oauth?.callbackPort
113
+ }
114
+
108
115
  /** The configured callbackPort (Claude: for pre-registered redirect URIs), else
109
116
  * the port a prior login registered, so a re-login can bind the same one. */
110
117
  savedRedirectPort(): number | undefined {
@@ -193,13 +200,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
193
200
  /** A one-shot loopback listener for the authorization redirect. Loopback redirect
194
201
  * URIs are the RFC 8252 pattern for native apps. A preferred port (from a prior
195
202
  * 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 }> {
203
+ * taken, an ephemeral port is used.
204
+ *
205
+ * `portRequired` marks the port as configured rather than remembered. A configured
206
+ * `oauth.callbackPort` names the redirect_uri the IdP has registered, so quietly
207
+ * binding a different one sends the user to an opaque redirect_uri mismatch at the
208
+ * IdP; the bind failure is reported here instead, where it can name the real cause. */
209
+ export async function startCallbackServer(preferredPort?: number, portRequired = false): Promise<{ server: http.Server; port: number }> {
198
210
  const server = http.createServer()
199
211
  const listen = (port: number, host: string): Promise<void> => new Promise((resolve, reject) => server.listen(port, host, resolve).once('error', reject))
200
212
  try {
201
213
  await listen(preferredPort ?? 0, '127.0.0.1')
202
- } catch {
214
+ } catch (error) {
215
+ 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
216
  await listen(0, '127.0.0.1')
204
217
  }
205
218
  const port = (server.address() as { port: number }).port
@@ -188,7 +188,6 @@ function resolveRule(rule: string, anchors: PathAnchors): string {
188
188
  * root-relative path. */
189
189
  export interface CompiledGlob {
190
190
  regex: RegExp
191
- matchesBasename: boolean
192
191
  }
193
192
 
194
193
  let globsCompiled = 0
@@ -219,7 +218,7 @@ export function compileGlobs(globs: string[]): CompiledGlob[] {
219
218
  // would compile to `^docs/$` and match nothing.
220
219
  if (glob.endsWith('/')) glob += '**'
221
220
  globsCompiled += 1
222
- compiled.push({ regex: new RegExp(`^${globToRegExpSource(glob)}$`), matchesBasename: !glob.includes('/') })
221
+ compiled.push({ regex: new RegExp(`^${globToRegExpSource(glob)}$`) })
223
222
  }
224
223
  return compiled
225
224
  }
@@ -229,8 +228,7 @@ export function compileGlobs(globs: string[]): CompiledGlob[] {
229
228
  export function matchesCompiledGlobs(relPath: string, globs: CompiledGlob[]): boolean {
230
229
  globsEvaluated += 1
231
230
  const posix = relPath.split(path.sep).join('/')
232
- const base = posix.split('/').pop() ?? posix
233
- return globs.some((glob) => glob.regex.test(glob.matchesBasename ? base : posix))
231
+ return globs.some((glob) => glob.regex.test(posix))
234
232
  }
235
233
 
236
234
  /** Whether the accessed file matches at least one rule. No rules means no match:
@@ -245,6 +243,23 @@ const toPosix = (target: string): string => {
245
243
  return withSlashes.replace(/^\/?[A-Za-z]:\//, '/')
246
244
  }
247
245
 
246
+ /**
247
+ * Drop a drive from a RESOLVED rule so it can meet a target that toPosix has already
248
+ * stripped. Claude documents `//path` as an absolute path from the filesystem root,
249
+ * and on Windows its own example names the drive as the first segment (`//c/` then a
250
+ * recursive glob), where `c` is the drive; that resolved to `/c/...` while every target
251
+ * resolved to `/...`, so the rule could never match anything.
252
+ *
253
+ * Windows only, and that is the whole point of the flag: on POSIX `/c/foo` is an
254
+ * ordinary absolute path and stripping its first segment would widen the rule to
255
+ * everything under the root. Exported and platform-parameterized rather than reading
256
+ * process.platform inline, so both branches are assertable from either host.
257
+ */
258
+ export function stripRuleDrive(rule: string, windows: boolean): string {
259
+ if (!windows) return rule
260
+ return rule.replace(/^([A-Za-z]):\//, '/').replace(/^\/[A-Za-z]\//, '/')
261
+ }
262
+
248
263
  export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
249
264
  const target = toPosix(path.resolve(anchors.cwd, filePath))
250
265
  return rules.some((rule) => {
@@ -252,7 +267,7 @@ export function matchesPathRules(filePath: string, rules: string[], anchors: Pat
252
267
  // An empty specifier (`Read()`) matches nothing, so the tool stays blocked
253
268
  // rather than falling open, mirroring `Bash()`.
254
269
  if (trimmed === '') return false
255
- const resolved = toPosix(resolveRule(trimmed, anchors))
270
+ const resolved = stripRuleDrive(toPosix(resolveRule(trimmed, anchors)), path.sep === '\\')
256
271
  return new RegExp(`^${globToRegExpSource(resolved)}$`).test(target)
257
272
  })
258
273
  }
@@ -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
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import * as fs from 'node:fs'
21
+ import * as os from 'node:os'
21
22
  import * as path from 'node:path'
22
23
  import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
23
24
 
@@ -46,11 +47,15 @@ const CLAUDE_SHAPED = [
46
47
  * The walk matters: agent discovery already searches upward, so starting pi in a
47
48
  * subdirectory of a repository whose `.claude/agents` sits at the root found those
48
49
  * agents while a cwd-only check reported nothing to gate, and the short-circuit
49
- * approved the project without ever asking. The bound is the repository root, so a
50
- * directory outside any repository never inherits a parent's config. */
51
- export function hasClaudeShapedConfig(cwd: string): boolean {
50
+ * approved the project without ever asking. The bound is the repository root and the home
51
+ * directory, because `~/.claude` is the user's own configuration: a directory under
52
+ * home that is in no repository would otherwise walk up into it and report the user's
53
+ * own settings as a project waiting to be approved. */
54
+ export function hasClaudeShapedConfig(cwd: string, home: string = os.homedir()): boolean {
52
55
  let currentDir = cwd
53
56
  while (true) {
57
+ // The home check comes first: at home itself the .claude found is the user's own.
58
+ if (currentDir === home) return false
54
59
  if (CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(currentDir, entry)))) return true
55
60
  if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return false
56
61
  const parentDir = path.dirname(currentDir)
@@ -13,18 +13,21 @@
13
13
  import * as fs from 'node:fs'
14
14
  import * as path from 'node:path'
15
15
 
16
- /** Project root markers ending the walk. `.git` is a file in worktrees and submodules. */
17
- export const ROOT_MARKERS = ['.git', 'package.json']
16
+ /** The project root marker. `.git` is a file in worktrees and submodules, a directory
17
+ * in an ordinary clone.
18
+ *
19
+ * `package.json` used to count too, which made every package of a monorepo its own
20
+ * project: its own memory directory, its own settings.local.json, its own
21
+ * CLAUDE_PROJECT_DIR, its own trust decision. Claude's project is the repository, and
22
+ * a repository can add a package.json wherever it likes, so a marker it controls was
23
+ * also a marker it could move. */
24
+ export const ROOT_MARKERS = ['.git']
18
25
 
19
- /** Project root at or above `from`, or undefined when no marker is found. */
26
+ /** Project root at or above `from`, or undefined outside a repository. */
20
27
  export function repoRoot(from: string): string | undefined {
21
- let currentDir = from
22
- while (true) {
23
- if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
24
- const parentDir = path.dirname(currentDir)
25
- if (parentDir === currentDir) return undefined
26
- currentDir = parentDir
27
- }
28
+ const root = gitRoot(from)
29
+ if (root === undefined) return undefined
30
+ return mainCheckout(root)
28
31
  }
29
32
 
30
33
  /** The git checkout at or above `from`, or undefined outside one.
@@ -45,6 +48,38 @@ export function gitRoot(from: string): string | undefined {
45
48
  }
46
49
  }
47
50
 
51
+ /** The main checkout for a git directory.
52
+ *
53
+ * A worktree carries a `.git` FILE holding `gitdir: <main>/.git/worktrees/<name>`, so
54
+ * resolving it gives the checkout the repository's state actually belongs to. Claude
55
+ * reads settings.local.json from "the file at the main checkout's root" and shares one
56
+ * auto memory directory across "all worktrees and subdirectories within the same repo",
57
+ * so a worktree is not its own project. An unreadable or unexpected `.git` file leaves
58
+ * the directory as its own root, which is the safe direction. */
59
+ function mainCheckout(root: string): string {
60
+ const dotGit = path.join(root, '.git')
61
+ let pointer: string
62
+ try {
63
+ if (!fs.statSync(dotGit).isFile()) return root
64
+ pointer = fs.readFileSync(dotGit, 'utf-8')
65
+ } catch {
66
+ return root
67
+ }
68
+ // Parsed rather than matched: the file is one `gitdir: <path>` line, and a regex
69
+ // over an arbitrary-length path is a backtracking cost for nothing.
70
+ const [firstLine = ''] = pointer.split('\n')
71
+ const prefix = 'gitdir:'
72
+ if (!firstLine.startsWith(prefix)) return root
73
+ const target = firstLine.slice(prefix.length).trim()
74
+ if (!target) return root
75
+ // <main>/.git/worktrees/<name> -> <main>
76
+ const worktreeDir = path.resolve(root, target)
77
+ const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`
78
+ const cut = worktreeDir.lastIndexOf(marker)
79
+ if (cut === -1) return root
80
+ return worktreeDir.slice(0, cut)
81
+ }
82
+
48
83
  function statOf(target: string): fs.Stats | null {
49
84
  try {
50
85
  return fs.statSync(target)
@@ -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
+ }
@@ -13,19 +13,46 @@ import { claudeConfigDir } from './config-dir.js'
13
13
  import { repoRoot } from './project-root.js'
14
14
  import { isRecord } from './values.js'
15
15
 
16
+ /** Whether every path given exists and belongs to the user running this process.
17
+ * A path that is absent is not someone else's, so it does not disqualify the root. */
18
+ function ownedByUser(paths: string[]): boolean {
19
+ const uid = process.getuid?.()
20
+ if (uid === undefined) return true
21
+ return paths.every((target) => {
22
+ try {
23
+ return fs.statSync(target).uid === uid
24
+ } catch {
25
+ return true
26
+ }
27
+ })
28
+ }
29
+
30
+ /** Where `settings.local.json` lives, per Claude's four exceptions: it sits at the
31
+ * repository root, except outside a repository, when that root is the home directory,
32
+ * on Windows, or when the root or its `.git` or `.claude` entry belongs to someone
33
+ * else. In each of those it stays beside `.claude/settings.json` in the working
34
+ * directory instead. In a worktree the root is the main checkout, which repoRoot
35
+ * resolves. */
36
+ function localSettingsDir(cwd: string, home: string, platform: NodeJS.Platform, owned: (paths: string[]) => boolean): string {
37
+ const root = repoRoot(cwd)
38
+ if (root === undefined || root === home) return cwd
39
+ if (platform === 'win32') return cwd
40
+ if (!owned([root, path.join(root, '.git'), path.join(root, '.claude')])) return cwd
41
+ return root
42
+ }
43
+
16
44
  /** The user settings.json, then (only when `includeProject`) the project files by
17
45
  * Claude's placement rules: the shared `.claude/settings.json` is read from the
18
46
  * session's primary working directory (never an ancestor; "to use a file committed
19
47
  * at the repository root, start Claude Code there"), while `settings.local.json`
20
- * lives at the repository root, falling back to the primary directory outside a
21
- * repository or when the root is the home directory. A legacy local file at the
22
- * primary directory is still read, with the root's values winning. Later files win. */
23
- export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean): string[] {
48
+ * lives at the repository root, subject to the exceptions in localSettingsDir. A
49
+ * legacy local file at the primary directory is still read, with the root's values
50
+ * winning. Later files win. */
51
+ export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean, platform: NodeJS.Platform = process.platform, owned: (paths: string[]) => boolean = ownedByUser): string[] {
24
52
  const files = [path.join(claudeConfigDir(home), 'settings.json')]
25
53
  if (!includeProject) return files
26
54
  files.push(path.join(cwd, '.claude', 'settings.json'))
27
- const root = repoRoot(cwd)
28
- const localDir = root !== undefined && root !== home ? root : cwd
55
+ const localDir = localSettingsDir(cwd, home, platform, owned)
29
56
  if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
30
57
  files.push(path.join(localDir, '.claude', 'settings.local.json'))
31
58
  return files
@@ -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
@@ -85,6 +85,10 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
85
85
 
86
86
  export default async function mcpExtension(pi: ExtensionAPI) {
87
87
  const clients = new Map<string, Client>()
88
+ // The session's OAuth UI seams, captured at session_start. The reconnect paths below
89
+ // run outside that handler, and passing undefined there made an INTERACTIVE session
90
+ // report the headless "cannot log in" advice on a re-auth it could actually perform.
91
+ let sessionAuthUi: AuthUi | undefined
88
92
  const status = new Map<string, { state: string; tools: number }>()
89
93
  // Config per server name, kept for call-time timeout tuning: the idle tier follows
90
94
  // the transport kind, and a declared per-server timeout governs the wall budget.
@@ -178,14 +182,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
178
182
  /** Claude's mid-session reconnect for a dropped remote server: five attempts with
179
183
  * a delay doubling from one second. connectServers redoes the full bring-up
180
184
  * (tools, prompts, subscriptions, a fresh onclose) and its duplicate guard skips
181
- * out if another path already reconnected the name. Runs without authUi: a server
182
- * that now needs a login ends failed, and after the fifth failure the last
183
- * attempt's failed status stands, with a session restart as the manual retry. */
185
+ * out if another path already reconnected the name. Uses the session's authUi, so a
186
+ * server that now needs a login can prompt for it in an interactive session; headless
187
+ * still ends failed, and after the fifth failure the last attempt's failed status
188
+ * stands, with a session restart as the manual retry. */
184
189
  async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
185
190
  for (let attempt = 0; attempt < 5; attempt++) {
186
191
  await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
187
192
  if (shuttingDown || clients.has(name)) return
188
- await connectServers({ [name]: config }, undefined, true)
193
+ await connectServers({ [name]: config }, sessionAuthUi, true)
189
194
  if (clients.has(name)) return
190
195
  }
191
196
  }
@@ -200,7 +205,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
200
205
  clients.delete(name)
201
206
  await withTimeout(old.close(), 3000, 'close').catch(() => {})
202
207
  }
203
- await connectServers({ [name]: config }, undefined, true)
208
+ await connectServers({ [name]: config }, sessionAuthUi, true)
204
209
  }
205
210
 
206
211
  /** A tool call with the auth retry: on a 401/403 rejection, reconnect once and
@@ -576,6 +581,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
576
581
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
577
582
  sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
578
583
  const authUi = authUiFor(ctx)
584
+ sessionAuthUi = authUi
579
585
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
580
586
  // merge from managed settings plus the trust-gated settings chain, as Claude
581
587
  // documents (a repo's file counts only once the project is approved).