pi-code 1.0.61 → 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.
- package/README.md +2 -0
- package/extensions/commands.ts +45 -11
- package/extensions/env-settings.ts +19 -1
- 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 +36 -2
- package/extensions/internal/claude-tool-names.ts +70 -0
- package/extensions/internal/command-file.ts +50 -38
- package/extensions/internal/mcp-oauth.ts +16 -3
- package/extensions/internal/path-rules.ts +18 -1
- package/extensions/internal/plugins.ts +11 -0
- package/extensions/internal/scope-rules.ts +117 -0
- package/extensions/internal/web-transport.ts +7 -0
- package/extensions/mcp/config.ts +5 -1
- package/extensions/mcp/index.ts +11 -5
- package/extensions/mcp/oauth-flow.ts +1 -1
- package/extensions/mcp/transport.ts +19 -3
- package/extensions/skills.ts +20 -2
- package/extensions/status-line.ts +4 -2
- package/extensions/subagent/index.ts +4 -2
- package/extensions/thinking.ts +14 -8
- package/extensions/web.ts +74 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/
|
|
|
60
60
|
|
|
61
61
|
pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
|
|
62
62
|
|
|
63
|
+
Trust is the other place pi-code is deliberately stricter. Claude states that "a `claude -p` run never shows the trust dialog" and loads the project's hooks, MCP servers, agents, commands, skills and rules anyway. pi-code refuses instead: with no stored decision and no UI to ask, a headless run in a project you have not already trusted loads none of them. A repository would otherwise get to run its own hooks and MCP servers in any CI job that checks it out, with nobody present to decline.
|
|
64
|
+
|
|
63
65
|
`CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base. Setting `CLAUDE_CONFIG_DIR` relocates the entire home config scope (settings, commands, agents, skills, plugins, output styles, memory, and the user `CLAUDE.md`); a project's own `.claude/` is a separate scope and is unaffected.
|
|
64
66
|
|
|
65
67
|
[`extensions/internal/`](extensions/internal) holds the shared modules pi's loader must not treat as extensions (each file's header says what it owns); only `internal/` keeps them out of pi's extension scan.
|
package/extensions/commands.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
|
|
11
11
|
* session model for the command's run via `pi.setModel`, restored on agent_end).
|
|
12
12
|
* `shell: powershell` runs a command's injected spans through PowerShell when a
|
|
13
|
-
* pwsh binary is installed, falling back to
|
|
14
|
-
* without one.
|
|
13
|
+
* pwsh binary is installed, falling back to the default shell path so the command
|
|
14
|
+
* still works without one: /bin/sh on POSIX, Git Bash on Windows.
|
|
15
15
|
*
|
|
16
16
|
* Commands are also exposed to the model through a `slash_command` tool
|
|
17
17
|
* (Claude's SlashCommand tool), listing every discovered command whose file
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* A project command body is repository-controlled text that can run shell
|
|
34
34
|
* commands and read files, so project commands load only once the project is
|
|
35
35
|
* approved. That closes the "skills / commands are not trust-gated" limitation
|
|
36
|
-
* for commands; skills
|
|
36
|
+
* for commands; skills.ts gates project skills on the same approval.
|
|
37
37
|
*
|
|
38
38
|
* Docs: https://code.claude.com/docs/en/slash-commands.md
|
|
39
39
|
*/
|
|
@@ -54,6 +54,7 @@ import { matchesPathRules } from './internal/path-rules.js'
|
|
|
54
54
|
import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
55
55
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
56
56
|
import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
57
|
+
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from './internal/scope-rules.js'
|
|
57
58
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
58
59
|
import { createTurnOverride } from './internal/turn-override.js'
|
|
59
60
|
import { errorMessage, isDirectory } from './internal/values.js'
|
|
@@ -85,6 +86,15 @@ function substitutePathRule(rule: string, vars: Record<string, string | undefine
|
|
|
85
86
|
return rule.trimStart().startsWith('${CLAUDE_') && path.isAbsolute(substituted) ? `/${substituted}` : substituted
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/** One scalar argument scope checked against one call: undefined when the tool is
|
|
90
|
+
* unscoped for this run or the call matches, otherwise a block naming the rules in
|
|
91
|
+
* force and the value that failed them. */
|
|
92
|
+
function scopeVerdict(rules: string[] | undefined, tool: string, subjectLabel: string, subject: string, matches: (rules: string[]) => boolean): { block: true; reason: string } | undefined {
|
|
93
|
+
if (!rules) return undefined
|
|
94
|
+
if (matches(rules)) return undefined
|
|
95
|
+
return { block: true, reason: `allowed-tools: ${tool} is scoped for this command.\nAllowed: ${rules.join(', ')}\n${subjectLabel}: ${subject}` }
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
/** The path rules that survive an allowed-tools intersection: only the tools the
|
|
89
99
|
* grant kept get their scopes, each rule ${CLAUDE_*}-substituted like the body. */
|
|
90
100
|
function scopedPathRules(pathRules: Partial<Record<PathRuleTool, string[]>>, granted: string[], vars: Record<string, string | undefined>): Partial<Record<PathRuleTool, string[]>> {
|
|
@@ -310,6 +320,12 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
310
320
|
let pendingRestore: string[] | undefined
|
|
311
321
|
/** `Bash(...)` scopes enforced while that run lasts; lifted with the restriction. */
|
|
312
322
|
let pendingBashRules: string[] | undefined
|
|
323
|
+
/** `WebFetch(domain:...)` scopes enforced the same way. */
|
|
324
|
+
let pendingDomainRules: string[] | undefined
|
|
325
|
+
/** `Agent(...)`/`Task(...)` agent-name scopes enforced the same way. */
|
|
326
|
+
let pendingAgentRules: string[] | undefined
|
|
327
|
+
/** `Skill(...)` name-pattern scopes enforced the same way. */
|
|
328
|
+
let pendingSkillRules: string[] | undefined
|
|
313
329
|
/** Read/Edit path scopes enforced the same way, per pi file tool. */
|
|
314
330
|
let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
|
|
315
331
|
/** The session model to restore after a command's `model:` override drove its run,
|
|
@@ -342,6 +358,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
342
358
|
// continuation remains, which is the grant's true clearing point.
|
|
343
359
|
pi.on('agent_settled', async () => {
|
|
344
360
|
pendingBashRules = undefined
|
|
361
|
+
pendingDomainRules = undefined
|
|
362
|
+
pendingAgentRules = undefined
|
|
363
|
+
pendingSkillRules = undefined
|
|
345
364
|
pendingPathRules = undefined
|
|
346
365
|
modelOverride.settle()
|
|
347
366
|
effortOverride.settle()
|
|
@@ -354,18 +373,27 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
354
373
|
// The active-tool set has no argument dimension, so a scoped grant hands the turn
|
|
355
374
|
// the whole tool; the scope is enforced here instead, when the call arrives. Same
|
|
356
375
|
// steering-not-sandbox caveat as plan mode's guard.
|
|
376
|
+
//
|
|
377
|
+
// PAIRED WITH hooks/matcher.ts's matchesToolPattern, which dispatches the same tools
|
|
378
|
+
// to the same matchers for hook `if` filters. Deliberately not merged: bash differs
|
|
379
|
+
// (here every segment of a compound command must match a rule; there the filter is
|
|
380
|
+
// best effort and errs toward running the hook). A new scoped tool must be added in
|
|
381
|
+
// both; the shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
|
|
357
382
|
pi.on('tool_call', async (event, ctx) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
383
|
+
const input = event.input as Record<string, unknown>
|
|
384
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
385
|
+
// The four scalar-scope tools share one shape: rules in force, the part of the call
|
|
386
|
+
// they judge, and the sentence naming both when it blocks. One helper keeps the arms
|
|
387
|
+
// from drifting; only the subject and the matcher differ.
|
|
388
|
+
if (event.toolName === 'bash') return scopeVerdict(pendingBashRules, 'bash', 'Command', text(input.command), (rules) => matchesBashRules(text(input.command), rules))
|
|
389
|
+
if (event.toolName === 'web_fetch') return scopeVerdict(pendingDomainRules, 'web_fetch', 'Url', text(input.url), (rules) => matchesDomainRules(text(input.url), rules))
|
|
390
|
+
if (event.toolName === 'subagent') {
|
|
391
|
+
const names = agentNamesIn(input)
|
|
392
|
+
return scopeVerdict(pendingAgentRules, 'subagent', 'Agents', names.join(', '), (rules) => matchesAgentRules(names, rules))
|
|
365
393
|
}
|
|
394
|
+
if (event.toolName === 'slash_command') return scopeVerdict(pendingSkillRules, 'slash_command', 'Command', text(input.command), (rules) => matchesSkillRules(text(input.command), rules))
|
|
366
395
|
const rules = pendingPathRules?.[event.toolName as PathRuleTool]
|
|
367
396
|
if (!rules) return
|
|
368
|
-
const input = event.input as Record<string, unknown>
|
|
369
397
|
const filePath = typeof input.path === 'string' ? input.path : ''
|
|
370
398
|
const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
|
|
371
399
|
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
@@ -403,6 +431,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
403
431
|
// the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
|
|
404
432
|
// script by its real path, as the skills docs show.
|
|
405
433
|
pendingBashRules = granted.includes('bash') ? parsed.bashRules?.map((rule) => substituteVars(rule, vars)) : undefined
|
|
434
|
+
pendingDomainRules = granted.includes('web_fetch') ? parsed.domainRules : undefined
|
|
435
|
+
pendingAgentRules = granted.includes('subagent') ? parsed.agentRules : undefined
|
|
436
|
+
pendingSkillRules = granted.includes('slash_command') ? parsed.skillRules : undefined
|
|
406
437
|
pendingPathRules = parsed.pathRules ? scopedPathRules(parsed.pathRules, granted, vars) : undefined
|
|
407
438
|
}
|
|
408
439
|
|
|
@@ -482,6 +513,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
482
513
|
// no setActiveTools/setModel/setThinkingLevel, since the new session owns its own state.
|
|
483
514
|
pendingRestore = undefined
|
|
484
515
|
pendingBashRules = undefined
|
|
516
|
+
pendingDomainRules = undefined
|
|
517
|
+
pendingAgentRules = undefined
|
|
518
|
+
pendingSkillRules = undefined
|
|
485
519
|
pendingPathRules = undefined
|
|
486
520
|
modelOverride.reset()
|
|
487
521
|
effortOverride.reset()
|
|
@@ -40,6 +40,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
|
|
|
40
40
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
41
41
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
42
42
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
43
|
+
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
43
44
|
import { isRecord } from './internal/values.js'
|
|
44
45
|
|
|
45
46
|
/** The `env` object of one settings scope, coerced to string values. A string is kept
|
|
@@ -145,6 +146,8 @@ function projectEnv(cwd: string, home: string): Record<string, string> {
|
|
|
145
146
|
|
|
146
147
|
export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
147
148
|
const owned = new Map<string, string | undefined>()
|
|
149
|
+
/** Stops the watcher of the previous session, as the hooks extension does. */
|
|
150
|
+
let disposeWatch: () => void = () => {}
|
|
148
151
|
|
|
149
152
|
const apply = (home: string, project: Record<string, string>): void => {
|
|
150
153
|
applyEnvSettings(mergeEnvScopes(envFromSettings(readManagedSettings()), userEnv(home), project), process.env, owned)
|
|
@@ -155,6 +158,21 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
|
155
158
|
apply(os.homedir(), {})
|
|
156
159
|
|
|
157
160
|
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
158
|
-
|
|
161
|
+
const home = os.homedir()
|
|
162
|
+
const approved = isProjectApprovedSilently(ctx)
|
|
163
|
+
const reapply = (): void => apply(home, approved ? projectEnv(ctx.cwd, home) : {})
|
|
164
|
+
reapply()
|
|
165
|
+
// Claude: "Claude Code watches your settings files and reloads them when they change,
|
|
166
|
+
// so it applies most edits to the running session without a restart." `env` is not
|
|
167
|
+
// one of the restart-only keys (model, effortLevel/modelSettings, outputStyle), so an
|
|
168
|
+
// edit has to reach process.env now rather than at the next session. applyEnvSettings
|
|
169
|
+
// tracks what it owns, so a key removed from the file is restored, not left behind.
|
|
170
|
+
disposeWatch()
|
|
171
|
+
disposeWatch = watchSettingsFiles(claudeSettingsChain(ctx.cwd, home, approved), reapply)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
pi.on('session_shutdown', async () => {
|
|
175
|
+
disposeWatch()
|
|
176
|
+
disposeWatch = () => {}
|
|
159
177
|
})
|
|
160
178
|
}
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* node_modules/@earendil-works/pi-coding-agent/dist/core/tools and Claude's hooks
|
|
11
11
|
* reference (per-tool input tables, PostToolUse response shapes).
|
|
12
12
|
*
|
|
13
|
+
* NAMES and SHAPES cover different sets on purpose. Names come from
|
|
14
|
+
* internal/claude-tool-names.ts and cover every pi tool the tools reference has a
|
|
15
|
+
* counterpart for, because a hook matcher written in Claude's vocabulary has to fire.
|
|
16
|
+
* Shapes are translated only for the file and shell tools below, because those are
|
|
17
|
+
* the only tools the hooks reference gives a per-tool input table for; for the rest
|
|
18
|
+
* there is no documented Claude shape to conform to, so the pi input passes through
|
|
19
|
+
* unchanged and a hook reads the fields pi's own schema defines.
|
|
20
|
+
*
|
|
13
21
|
* Translation choices the shapes force, each documented on its function:
|
|
14
22
|
* - pi's multi-entry `edits[]` maps to Claude's single Edit with the first entry in
|
|
15
23
|
* `old_string`/`new_string` and the full array carried alongside as `edits`.
|
|
@@ -21,13 +29,7 @@
|
|
|
21
29
|
import * as os from 'node:os'
|
|
22
30
|
import * as path from 'node:path'
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
* the alias bus instead; pi tools with no Claude counterpart (ls) stay untranslated. */
|
|
26
|
-
const CLAUDE_NAMES: Record<string, string> = { bash: 'Bash', edit: 'Edit', write: 'Write', read: 'Read', grep: 'Grep', find: 'Glob' }
|
|
27
|
-
|
|
28
|
-
export function claudeToolName(piName: string): string | undefined {
|
|
29
|
-
return CLAUDE_NAMES[piName]
|
|
30
|
-
}
|
|
32
|
+
export { claudeToolName } from '../internal/claude-tool-names.js'
|
|
31
33
|
|
|
32
34
|
/** Claude file-tool paths are always absolute with `~` expanded before hooks run,
|
|
33
35
|
* so a path guard cannot be bypassed by a relative or `~` spelling of the same path. */
|
|
@@ -33,6 +33,11 @@ export interface HookCommand {
|
|
|
33
33
|
/** Claude's permission-rule filter (`"Bash(git *)"`, `"Edit(*.ts)"`): evaluated only
|
|
34
34
|
* on tool events; on any other event a hook carrying `if` never runs. */
|
|
35
35
|
if?: string
|
|
36
|
+
/** The declaring plugin's paths, exported to the hook process. Claude: "All three
|
|
37
|
+
* are exported as environment variables to hook processes and to MCP and LSP server
|
|
38
|
+
* subprocesses", so a script can read them instead of relying on inline substitution. */
|
|
39
|
+
pluginRoot?: string
|
|
40
|
+
pluginDataDir?: string
|
|
36
41
|
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
37
42
|
url?: string
|
|
38
43
|
headers?: Record<string, string>
|
|
@@ -181,7 +186,18 @@ function stampOrigin(entries: HookMatcher[], origin: string): void {
|
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
|
|
189
|
+
/** Stamp the declaring plugin's paths onto every hook it contributes, so the runner
|
|
190
|
+
* can export them without re-deriving which plugin a hook came from. */
|
|
191
|
+
function stampPluginPaths(entries: HookMatcher[], plugin: { root: string; dataDir: string }): void {
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
for (const hook of entry.hooks ?? []) {
|
|
194
|
+
hook.pluginRoot = plugin.root
|
|
195
|
+
hook.pluginDataDir = plugin.dataDir
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>, origin?: string, plugin?: { root: string; dataDir: string }): void {
|
|
185
201
|
let parsed: { hooks?: HooksConfig }
|
|
186
202
|
try {
|
|
187
203
|
parsed = JSON.parse(raw)
|
|
@@ -200,6 +216,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
200
216
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
201
217
|
if (usable.length === 0) continue
|
|
202
218
|
if (origin !== undefined) stampOrigin(usable, origin)
|
|
219
|
+
if (plugin !== undefined) stampPluginPaths(usable, plugin)
|
|
203
220
|
config[event] = [...(config[event] ?? []), ...usable]
|
|
204
221
|
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
205
222
|
// viewer's source attribution without touching the entries themselves.
|
|
@@ -261,13 +278,13 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
|
|
|
261
278
|
// silently registering nothing.
|
|
262
279
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
263
280
|
const inlineSource = `${plugin.name} (plugin.json)`
|
|
264
|
-
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}
|
|
281
|
+
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}`, plugin)
|
|
265
282
|
continue
|
|
266
283
|
}
|
|
267
284
|
const file = pluginComponentPath(plugin, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
268
285
|
if (file === undefined) continue
|
|
269
286
|
try {
|
|
270
|
-
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}
|
|
287
|
+
mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`, plugin)
|
|
271
288
|
} catch {
|
|
272
289
|
// a plugin without hooks contributes nothing
|
|
273
290
|
}
|
|
@@ -102,7 +102,7 @@ import { readManagedSettings } from '../internal/managed-settings.js'
|
|
|
102
102
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
103
103
|
import { resolveModelOverride } from '../internal/model-lookup.js'
|
|
104
104
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
105
|
-
import { installedPlugins } from '../internal/plugins.js'
|
|
105
|
+
import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
|
|
106
106
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
107
107
|
import { repoRoot } from '../internal/project-root.js'
|
|
108
108
|
import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
@@ -283,7 +283,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
283
283
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
|
|
284
284
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
285
285
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
286
|
-
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild, hook.shell)
|
|
286
|
+
return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined })
|
|
287
287
|
}
|
|
288
288
|
// Claude's `once` (skill-frontmatter hooks only): removed after the first
|
|
289
289
|
// successful run; a failure, block, or timeout leaves it in place.
|
|
@@ -414,7 +414,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
414
414
|
// Claude's allowManagedHooksOnly: user, project, local, plugin, and skill
|
|
415
415
|
// hooks are blocked; only the managed set runs.
|
|
416
416
|
managedHooksOnly = managedSettings.allowManagedHooksOnly === true
|
|
417
|
-
|
|
417
|
+
// The escape hatch is checked FIRST, and deliberately: a settings-level
|
|
418
|
+
// disableAllHooks turns off every non-managed hook, and a plugin's hooks are
|
|
419
|
+
// non-managed however the plugin came to be enabled. Only the managed policy hooks
|
|
420
|
+
// loaded above survive it. Putting the force-enable exemption ahead of this let an
|
|
421
|
+
// administrator's enabledPlugins entry defeat the user's own escape hatch.
|
|
422
|
+
if (readSettingsDisableAllHooks(files)) return
|
|
423
|
+
if (managedHooksOnly) {
|
|
424
|
+
// Claude: "Hooks from plugins force-enabled in managed settings `enabledPlugins`
|
|
425
|
+
// are exempt." That exemption is from allowManagedHooksOnly, not from the hatch.
|
|
426
|
+
loadPluginHooks(config, managedForceEnabled(installedPlugins(os.homedir())), hookSources)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
418
429
|
for (const [eventName, matchers] of Object.entries(loadHooks(files, hookSources))) config[eventName] = [...(config[eventName] ?? []), ...matchers]
|
|
419
430
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
420
431
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
8
|
+
import { CLAUDE_TOOL_MAP } from '../internal/claude-tool-names.js'
|
|
8
9
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
10
|
+
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from '../internal/scope-rules.js'
|
|
9
11
|
import { errorMessage } from '../internal/values.js'
|
|
10
12
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
11
13
|
|
|
@@ -179,13 +181,39 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
|
|
|
179
181
|
if (!toolMatches) return false
|
|
180
182
|
const pattern = parsed[2]
|
|
181
183
|
if (pattern === undefined) return true
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
// Either spelling can arrive; normalize to the pi name the specifier engines take.
|
|
185
|
+
const tool = CLAUDE_TOOL_MAP[fold(target.piName)] ?? CLAUDE_TOOL_MAP[fold(target.claudeName ?? '')] ?? fold(target.piName)
|
|
186
|
+
return matchesToolPattern(tool, target.input as Record<string, unknown> | null, pattern, target.anchors)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One `if` pattern against a call's arguments. Claude evaluates the rule "against
|
|
190
|
+
* the tool name and arguments together" in permission-rule syntax, so each tool uses
|
|
191
|
+
* the same specifier engine its permission rules use:
|
|
192
|
+
*
|
|
193
|
+
* PAIRED WITH commands.ts's tool_call guard, which dispatches the same tools to the
|
|
194
|
+
* same matchers for `allowed-tools` scopes. The two are deliberately NOT merged: bash
|
|
195
|
+
* differs on purpose (an allow scope requires every segment to match, an `if` filter is
|
|
196
|
+
* best effort and errs toward running the hook), and merging would hide that. They are
|
|
197
|
+
* two lists that must agree, so a new scoped tool has to be added in both places; the
|
|
198
|
+
* shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
|
|
199
|
+
* a command pattern for Bash, a
|
|
200
|
+
* `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
|
|
201
|
+
* path rule for the file tools. A tool with no specifier syntax matches nothing,
|
|
202
|
+
* which is also what an unparseable rule does. */
|
|
203
|
+
function matchesToolPattern(piName: string, input: Record<string, unknown> | null, pattern: string, anchors: PathAnchors): boolean {
|
|
204
|
+
const str = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
205
|
+
switch (piName) {
|
|
206
|
+
case 'bash':
|
|
207
|
+
return str(input?.command).length > 0 && matchesBashIfFilter(str(input?.command), pattern)
|
|
208
|
+
case 'web_fetch':
|
|
209
|
+
return matchesDomainRules(str(input?.url), [pattern])
|
|
210
|
+
case 'subagent':
|
|
211
|
+
return matchesAgentRules(agentNamesIn(input), [pattern])
|
|
212
|
+
case 'slash_command':
|
|
213
|
+
return matchesSkillRules(str(input?.command), [pattern])
|
|
214
|
+
default: {
|
|
215
|
+
const filePath = str(input?.path) || str(input?.file_path)
|
|
216
|
+
return filePath.length > 0 && matchesPathRules(filePath, [pattern], anchors)
|
|
217
|
+
}
|
|
186
218
|
}
|
|
187
|
-
let filePath = ''
|
|
188
|
-
if (typeof input?.path === 'string') filePath = input.path
|
|
189
|
-
else if (typeof input?.file_path === 'string') filePath = input.file_path
|
|
190
|
-
return filePath.length > 0 && matchesPathRules(filePath, [pattern], target.anchors)
|
|
191
219
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { type ChildProcess, spawn } from 'node:child_process'
|
|
8
|
+
import * as fs from 'node:fs'
|
|
8
9
|
import * as path from 'node:path'
|
|
9
10
|
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
10
11
|
import { runAgent } from '../internal/agent-run.js'
|
|
@@ -44,7 +45,20 @@ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number
|
|
|
44
45
|
* `args` array it becomes the exec path: `command` is spawned directly with those args.
|
|
45
46
|
* `onChild` hands the caller a kill for the spawned tree, so a background hook that is
|
|
46
47
|
* still running at session end can be reaped (Claude kills async hooks at teardown). */
|
|
47
|
-
|
|
48
|
+
/** How one command hook is spawned, beyond the payload and its budget. Grouped rather
|
|
49
|
+
* than trailing off the parameter list: the exec form, the shell choice and the
|
|
50
|
+
* declaring plugin are all per-hook fields that arrive together from one HookCommand. */
|
|
51
|
+
export interface HookSpawnOptions {
|
|
52
|
+
projectDir?: string
|
|
53
|
+
/** exec-form argv; shell-form when absent. */
|
|
54
|
+
args?: string[]
|
|
55
|
+
onChild?: (kill: () => void) => void
|
|
56
|
+
shell?: string
|
|
57
|
+
/** The declaring plugin's paths, exported to the child. */
|
|
58
|
+
plugin?: { root: string; dataDir: string }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, options?: HookSpawnOptions) => Promise<HookRunResult>
|
|
48
62
|
|
|
49
63
|
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
50
64
|
const MAX_TIMEOUT_S = 2_147_483
|
|
@@ -104,6 +118,17 @@ function killTree(child: ChildProcess): void {
|
|
|
104
118
|
child.kill('SIGKILL')
|
|
105
119
|
}
|
|
106
120
|
|
|
121
|
+
/** Create the plugin data directory the moment its path is handed to a child. Claude
|
|
122
|
+
* describes it as "created on first reference"; a best-effort mkdir, since a hook whose
|
|
123
|
+
* data dir cannot be created should still run. */
|
|
124
|
+
function ensureDir(dir: string): void {
|
|
125
|
+
try {
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
127
|
+
} catch {
|
|
128
|
+
// The hook runs anyway; a script that needs the directory reports its own failure.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
107
132
|
/** The shell invocation for a shell-form command, or undefined when this machine has no
|
|
108
133
|
* shell for it (Windows with neither Git Bash nor PowerShell). */
|
|
109
134
|
function shellInvocation(command: string, shell: string | undefined): { file: string; spawnArgs: string[] } | undefined {
|
|
@@ -111,7 +136,7 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
|
|
|
111
136
|
return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
|
|
112
137
|
}
|
|
113
138
|
|
|
114
|
-
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild, shell) =>
|
|
139
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin } = {}) =>
|
|
115
140
|
new Promise((resolve) => {
|
|
116
141
|
// /bin/sh by absolute path off Windows, so the shell can't be resolved through an
|
|
117
142
|
// attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
|
|
@@ -125,6 +150,15 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
|
|
|
125
150
|
// detection cannot see the captured terminal.
|
|
126
151
|
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
|
|
127
152
|
if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
|
|
153
|
+
// Claude: "All three are exported as environment variables to hook processes and to
|
|
154
|
+
// MCP and LSP server subprocesses", so a plugin script can read them rather than
|
|
155
|
+
// depend on inline substitution. The data directory is "created on first reference",
|
|
156
|
+
// and exporting the path is that reference: a script should not have to mkdir it.
|
|
157
|
+
if (plugin) {
|
|
158
|
+
env.CLAUDE_PLUGIN_ROOT = plugin.root
|
|
159
|
+
env.CLAUDE_PLUGIN_DATA = plugin.dataDir
|
|
160
|
+
ensureDir(plugin.dataDir)
|
|
161
|
+
}
|
|
128
162
|
if (process.stdout.columns) env.COLUMNS = String(process.stdout.columns)
|
|
129
163
|
if (process.stdout.rows) env.LINES = String(process.stdout.rows)
|
|
130
164
|
// An exec-form hook (an `args` array) spawns the executable directly with those args
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place pi tool names and Claude tool names are paired.
|
|
3
|
+
*
|
|
4
|
+
* Two independent tables used to encode this: a Claude->pi map for resolving
|
|
5
|
+
* `allowed-tools` entries, and a pi->Claude map for hook payloads and matchers.
|
|
6
|
+
* They disagreed. The second one listed six tools, so a hook matcher written in
|
|
7
|
+
* Claude's vocabulary never fired for `web_fetch`, `subagent`, `question`, `todo`,
|
|
8
|
+
* `slash_command`, `web_search` or `plan_mode_complete`, even though the first table
|
|
9
|
+
* had known those pairings all along. Both directions now derive from this list, so
|
|
10
|
+
* a tool cannot be nameable in one direction and invisible in the other.
|
|
11
|
+
*
|
|
12
|
+
* Canonical names are the tools reference's own spellings. `claude: null` marks a pi
|
|
13
|
+
* tool the reference has no counterpart for: it stays untranslated in payloads, and
|
|
14
|
+
* no Claude name resolves to it beyond its own spelling.
|
|
15
|
+
*
|
|
16
|
+
* Input SHAPES are a separate question and deliberately not driven from here. The
|
|
17
|
+
* hooks reference documents per-tool input tables only for the file and shell tools,
|
|
18
|
+
* so those are the only ones claude-tools.ts translates; for the rest there is no
|
|
19
|
+
* documented Claude shape to conform to and the pi input is passed through.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface ToolNamePair {
|
|
23
|
+
/** Canonical Claude tool name, or null when the reference has no such tool. */
|
|
24
|
+
claude: string | null
|
|
25
|
+
/** pi's registered tool name. */
|
|
26
|
+
pi: string
|
|
27
|
+
/** Further Claude spellings accepted when reading a grant: renamed tools and
|
|
28
|
+
* secondary tools that share one pi implementation. Never used for payloads. */
|
|
29
|
+
aliases?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TOOL_NAMES: ToolNamePair[] = [
|
|
33
|
+
{ claude: 'Read', pi: 'read' },
|
|
34
|
+
{ claude: 'Write', pi: 'write' },
|
|
35
|
+
{ claude: 'Edit', pi: 'edit' },
|
|
36
|
+
{ claude: 'Bash', pi: 'bash' },
|
|
37
|
+
{ claude: 'Grep', pi: 'grep' },
|
|
38
|
+
{ claude: 'Glob', pi: 'find' },
|
|
39
|
+
{ claude: 'WebFetch', pi: 'web_fetch' },
|
|
40
|
+
{ claude: 'WebSearch', pi: 'web_search' },
|
|
41
|
+
// `Task` was renamed `Agent`; both spellings resolve, the payload reports `Agent`.
|
|
42
|
+
{ claude: 'Agent', pi: 'subagent', aliases: ['Task'] },
|
|
43
|
+
{ claude: 'AskUserQuestion', pi: 'question' },
|
|
44
|
+
{ claude: 'ExitPlanMode', pi: 'plan_mode_complete' },
|
|
45
|
+
// Custom commands were folded into skills and the reference no longer lists a
|
|
46
|
+
// SlashCommand tool, so `Skill` is canonical and `SlashCommand` is the legacy name.
|
|
47
|
+
{ claude: 'Skill', pi: 'slash_command', aliases: ['SlashCommand'] },
|
|
48
|
+
// pi's single todo tool serves Claude's whole todo/task-list family. Claude now
|
|
49
|
+
// prefers TaskCreate/TaskGet/TaskList/TaskUpdate over TodoWrite/TodoRead; every
|
|
50
|
+
// spelling resolves so an allowed-tools entry naming the current tools still grants
|
|
51
|
+
// something, and TodoWrite stays the name a payload reports.
|
|
52
|
+
{ claude: 'TodoWrite', pi: 'todo', aliases: ['TodoRead', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskUpdate'] },
|
|
53
|
+
// pi tools the tools reference has no entry for.
|
|
54
|
+
{ claude: null, pi: 'ls', aliases: ['LS'] },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const fold = (name: string): string => name.trim().toLowerCase()
|
|
58
|
+
|
|
59
|
+
/** Every Claude spelling -> pi tool name, for reading a grant list. Includes each
|
|
60
|
+
* pi name itself so a list written in pi's own vocabulary still resolves. */
|
|
61
|
+
export const CLAUDE_TOOL_MAP: Record<string, string> = Object.fromEntries(TOOL_NAMES.flatMap(({ claude, pi, aliases }) => [...(claude === null ? [] : [[fold(claude), pi]]), ...(aliases ?? []).map((alias) => [fold(alias), pi]), [fold(pi), pi]]))
|
|
62
|
+
|
|
63
|
+
const PI_TO_CLAUDE: Record<string, string> = Object.fromEntries(TOOL_NAMES.filter((pair): pair is ToolNamePair & { claude: string } => pair.claude !== null).map(({ claude, pi }) => [pi, claude]))
|
|
64
|
+
|
|
65
|
+
/** pi built-in -> canonical Claude tool name for hook payloads and matchers, or
|
|
66
|
+
* undefined for a pi tool the reference has no counterpart for. MCP tools ride the
|
|
67
|
+
* alias bus instead and never reach here. */
|
|
68
|
+
export function claudeToolName(piName: string): string | undefined {
|
|
69
|
+
return PI_TO_CLAUDE[piName]
|
|
70
|
+
}
|