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.
- package/README.md +2 -0
- package/extensions/commands.ts +45 -11
- package/extensions/context-imports.ts +43 -21
- package/extensions/env-settings.ts +19 -1
- package/extensions/git-checkpoint.ts +4 -0
- package/extensions/hooks/claude-tools.ts +9 -7
- package/extensions/hooks/config.ts +20 -3
- package/extensions/hooks/index.ts +14 -3
- package/extensions/hooks/matcher.ts +36 -8
- package/extensions/hooks/runners.ts +45 -2
- package/extensions/internal/claude-tool-names.ts +70 -0
- package/extensions/internal/command-file.ts +50 -38
- package/extensions/internal/mcp-oauth.ts +29 -4
- package/extensions/internal/path-rules.ts +62 -32
- package/extensions/internal/plugins.ts +11 -0
- package/extensions/internal/scope-rules.ts +117 -0
- package/extensions/internal/shell-resolve.ts +16 -5
- package/extensions/internal/web-transport.ts +7 -0
- package/extensions/mcp/config.ts +5 -1
- package/extensions/mcp/index.ts +51 -22
- package/extensions/mcp/oauth-flow.ts +1 -1
- package/extensions/mcp/transport.ts +29 -3
- package/extensions/question.ts +147 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +26 -3
- package/extensions/status-line.ts +4 -2
- package/extensions/subagent/child.ts +17 -2
- package/extensions/subagent/index.ts +4 -2
- package/extensions/subagent/run.ts +32 -7
- package/extensions/thinking.ts +14 -8
- package/extensions/web.ts +74 -21
- package/package.json +1 -1
|
@@ -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
|
-
|
|
158
|
-
|
|
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: [],
|
|
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
|
|
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
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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) {
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -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
|