pi-code 1.0.15 → 1.0.17
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 +1 -1
- package/extensions/commands.ts +4 -3
- package/extensions/hooks/claude-tools.ts +132 -0
- package/extensions/hooks/config.ts +3 -0
- package/extensions/hooks/decisions.ts +118 -28
- package/extensions/hooks/index.ts +91 -21
- package/extensions/hooks/matcher.ts +54 -5
- package/extensions/hooks/runners.ts +7 -5
- package/extensions/internal/command-file.ts +7 -5
- package/extensions/mcp/index.ts +14 -11
- package/extensions/mcp/policy.ts +129 -27
- package/extensions/mcp/transport.ts +3 -1
- package/extensions/subagent/README.md +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
45
45
|
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `effort` (raises reasoning for the turn, restored after), `shell: powershell` (injected spans run through PowerShell when a `pwsh` binary is present, else `/bin/sh`); the model can also run a command itself through the `SlashCommand` tool (Claude's `SlashCommand` in `allowed-tools`), steered by `when_to_use` and opted out per file with `disable-model-invocation` (`user-invocable: false` hides a command from the menu while still exposing it to the model); `disableSkillShellExecution` (managed and user always, project when trusted) replaces every `!` span with a policy-disabled placeholder; project commands gated on approval | `commands.ts` |
|
|
46
46
|
| `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
|
|
47
47
|
| Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
48
|
-
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
|
|
48
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s for PreToolUse, 30s for UserPromptSubmit, both non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); payloads use Claude's vocabulary for pi's built-ins (Bash/Edit/Write/Read/Grep/Glob names, documented input and Bash/Write response shapes, absolute `file_path`, `updatedInput` translated back); PreToolUse `additionalContext` lands beside the tool result, `updatedToolOutput`/`updatedMCPToolOutput` replace what the model sees, `permissionDecision: "defer"` blocks (pi cannot resume a deferred call), the `if` permission-rule filter runs on tool events only (a hook carrying it never runs elsewhere), Stop/UserPromptSubmit ignore stray matchers, and a Stop hook's `additionalContext` continues the conversation under the block cap; a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
|
|
49
49
|
| Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
|
|
50
50
|
| CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads the user `~/.claude/CLAUDE.md` and the project `.claude/CLAUDE.md` (approval-gated, deduped against the repo-root `CLAUDE.md`/`AGENTS.md` pi loads natively) that pi's own loader does not; loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects the managed `CLAUDE.md` (a per-OS file beside `managed-settings.json`) and the `claudeMd` string from `managed-settings.json` at the top of context (managed content is never excludable); honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
|
|
51
51
|
| Settings `env` | `env` blocks from `managed-settings.json`, `~/.claude/settings.json`, and the project `.claude/settings.json`/`settings.local.json` exported into the session (per-key `managed > user > project`); the project scope is approval-gated (a repo's env can redirect providers), a shell `export` outranks user and project but a managed key overrides even that, and a key an approved project set is unset once a later session no longer defines it | `env-settings.ts` |
|
package/extensions/commands.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers Claude Code's custom slash commands with pi directly, rather than
|
|
5
5
|
* handing `.claude/commands` to pi's prompt-template loader. Owning registration
|
|
6
|
-
* is what makes the rest of Claude's command contract reachable:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* is what makes the rest of Claude's command contract reachable: `$ARGUMENTS` and
|
|
7
|
+
* positional substitution, `` !`cmd` `` bash output, `@file` inlining, subdirectory
|
|
8
|
+
* commands (registered as `/frontend:build`; current Claude docs name a command by
|
|
9
|
+
* file name alone, so the qualified form is a pi-code divergence), and the
|
|
9
10
|
* `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
|
|
10
11
|
* session model for the command's run via `pi.setModel`, restored on agent_end).
|
|
11
12
|
* `shell: powershell` runs a command's injected spans through PowerShell when a
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude-vocabulary translation for hook payloads on pi's built-in tools.
|
|
3
|
+
*
|
|
4
|
+
* Claude-written hook scripts branch on documented names ("Bash", "Edit") and read
|
|
5
|
+
* documented input shapes (`tool_input.file_path`); pi's tools carry their own
|
|
6
|
+
* names (`bash`, `edit`) and shapes (`path`, `edits[]`). Payloads report the
|
|
7
|
+
* Claude form, exactly as MCP aliases and user_bash already do, and the decision
|
|
8
|
+
* outputs that reference input/output shapes (`updatedInput`, `updatedToolOutput`)
|
|
9
|
+
* are translated back. Mappings against pi's schemas in
|
|
10
|
+
* node_modules/@earendil-works/pi-coding-agent/dist/core/tools and Claude's hooks
|
|
11
|
+
* reference (per-tool input tables, PostToolUse response shapes).
|
|
12
|
+
*
|
|
13
|
+
* Translation choices the shapes force, each documented on its function:
|
|
14
|
+
* - pi's multi-entry `edits[]` maps to Claude's single Edit with the first entry in
|
|
15
|
+
* `old_string`/`new_string` and the full array carried alongside as `edits`.
|
|
16
|
+
* - Bash `timeout` converts between pi's seconds and Claude's milliseconds.
|
|
17
|
+
* - pi's bash output is one combined stream, so the Bash response reports it all as
|
|
18
|
+
* `stdout` with an empty `stderr`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as os from 'node:os'
|
|
22
|
+
import * as path from 'node:path'
|
|
23
|
+
|
|
24
|
+
/** pi built-in -> Claude tool name for hook payloads and matchers. MCP tools ride
|
|
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
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Claude file-tool paths are always absolute with `~` expanded before hooks run,
|
|
33
|
+
* so a path guard cannot be bypassed by a relative or `~` spelling of the same path. */
|
|
34
|
+
function absolutePath(value: unknown, cwd: string): unknown {
|
|
35
|
+
if (typeof value !== 'string' || value.length === 0) return value
|
|
36
|
+
const expanded = value === '~' || value.startsWith('~/') ? path.join(os.homedir(), value.slice(1)) : value
|
|
37
|
+
return path.resolve(cwd, expanded)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const record = (value: unknown): Record<string, unknown> | undefined => (value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined)
|
|
41
|
+
|
|
42
|
+
/** Only the entries whose value passes the filter, so optional fields stay absent
|
|
43
|
+
* rather than arriving as explicit undefined. */
|
|
44
|
+
function pick(entries: Record<string, unknown>): Record<string, unknown> {
|
|
45
|
+
return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type InputMapper = (raw: Record<string, unknown>, cwd: string) => Record<string, unknown>
|
|
49
|
+
|
|
50
|
+
const TO_CLAUDE_INPUT: Record<string, InputMapper> = {
|
|
51
|
+
// pi timeout is seconds, Claude's is milliseconds.
|
|
52
|
+
bash: (raw) => pick({ command: raw.command, timeout: typeof raw.timeout === 'number' ? raw.timeout * 1000 : undefined }),
|
|
53
|
+
write: (raw, cwd) => ({ file_path: absolutePath(raw.path, cwd), content: raw.content }),
|
|
54
|
+
read: (raw, cwd) => pick({ file_path: absolutePath(raw.path, cwd), offset: raw.offset, limit: raw.limit }),
|
|
55
|
+
// Claude's Edit is a single replacement; pi's edit call carries one or more. The
|
|
56
|
+
// documented fields expose the first entry, and the full array rides along as
|
|
57
|
+
// `edits` so a hook auditing the whole call loses nothing.
|
|
58
|
+
edit: (raw, cwd) => {
|
|
59
|
+
const edits = Array.isArray(raw.edits) ? raw.edits : []
|
|
60
|
+
const first = record(edits[0]) ?? {}
|
|
61
|
+
return { file_path: absolutePath(raw.path, cwd), old_string: first.oldText ?? '', new_string: first.newText ?? '', replace_all: false, ...(edits.length > 1 ? { edits } : {}) }
|
|
62
|
+
},
|
|
63
|
+
grep: (raw, cwd) => pick({ pattern: raw.pattern, path: raw.path === undefined ? undefined : absolutePath(raw.path, cwd), glob: raw.glob, '-i': raw.ignoreCase === true ? true : undefined }),
|
|
64
|
+
find: (raw, cwd) => pick({ pattern: raw.pattern, path: raw.path === undefined ? undefined : absolutePath(raw.path, cwd) }),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** pi input -> Claude `tool_input` for the translated built-ins; undefined keeps the
|
|
68
|
+
* pi shape (MCP and unknown tools). */
|
|
69
|
+
export function claudeToolInput(piName: string, input: unknown, cwd: string): Record<string, unknown> | undefined {
|
|
70
|
+
const raw = record(input)
|
|
71
|
+
if (!raw) return undefined
|
|
72
|
+
return TO_CLAUDE_INPUT[piName]?.(raw, cwd)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type RewriteMapper = (updated: Record<string, unknown>) => Record<string, unknown> | undefined
|
|
76
|
+
|
|
77
|
+
const FROM_CLAUDE_INPUT: Record<string, RewriteMapper> = {
|
|
78
|
+
bash: (updated) => (typeof updated.command === 'string' ? pick({ command: updated.command, timeout: typeof updated.timeout === 'number' ? updated.timeout / 1000 : undefined }) : undefined),
|
|
79
|
+
write: (updated) => (typeof updated.file_path === 'string' && typeof updated.content === 'string' ? { path: updated.file_path, content: updated.content } : undefined),
|
|
80
|
+
read: (updated) => (typeof updated.file_path === 'string' ? pick({ path: updated.file_path, offset: updated.offset, limit: updated.limit }) : undefined),
|
|
81
|
+
edit: (updated) => {
|
|
82
|
+
if (typeof updated.file_path !== 'string') return undefined
|
|
83
|
+
if (Array.isArray(updated.edits)) return { path: updated.file_path, edits: updated.edits }
|
|
84
|
+
if (typeof updated.old_string !== 'string' || typeof updated.new_string !== 'string') return undefined
|
|
85
|
+
return { path: updated.file_path, edits: [{ oldText: updated.old_string, newText: updated.new_string }] }
|
|
86
|
+
},
|
|
87
|
+
grep: (updated) => (typeof updated.pattern === 'string' ? pick({ pattern: updated.pattern, path: updated.path, glob: updated.glob, ignoreCase: updated['-i'] === true ? true : undefined }) : undefined),
|
|
88
|
+
find: (updated) => (typeof updated.pattern === 'string' ? pick({ pattern: updated.pattern, path: updated.path }) : undefined),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Claude-shaped `updatedInput` back into pi's input shape for a translated tool.
|
|
92
|
+
* Returns undefined when the rewrite is missing the tool's required fields, so the
|
|
93
|
+
* caller keeps the ORIGINAL input rather than handing pi a corrupted one; for an
|
|
94
|
+
* untranslated tool the caller applies the rewrite verbatim. Claude's Edit
|
|
95
|
+
* `replace_all` has no pi counterpart (pi requires a unique oldText) and is dropped. */
|
|
96
|
+
export function piToolInput(piName: string, updated: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
97
|
+
return FROM_CLAUDE_INPUT[piName]?.(updated)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** pi result -> Claude `tool_response` where the docs pin a shape: Bash's structured
|
|
101
|
+
* object (pi's single combined stream reported as stdout) and Write's
|
|
102
|
+
* `{filePath, success}`. Other tools keep pi's `{content, details, isError}`. */
|
|
103
|
+
export function claudeToolResponse(piName: string, input: unknown, text: string, isError: boolean, cwd: string): Record<string, unknown> | undefined {
|
|
104
|
+
const raw = record(input)
|
|
105
|
+
switch (piName) {
|
|
106
|
+
case 'bash':
|
|
107
|
+
return { stdout: text, stderr: '', interrupted: false, isImage: false }
|
|
108
|
+
case 'write':
|
|
109
|
+
return { filePath: absolutePath(raw?.path, cwd), success: !isError }
|
|
110
|
+
default:
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A hook's `updatedToolOutput` back into pi text content. Claude validates built-in
|
|
116
|
+
* replacements against the tool's output schema and ignores mismatches (returns
|
|
117
|
+
* undefined here, keeping the original); MCP output passes through unvalidated. */
|
|
118
|
+
export function piToolOutput(piName: string, value: unknown, isMcp: boolean): string | undefined {
|
|
119
|
+
if (isMcp) {
|
|
120
|
+
if (typeof value === 'string') return value
|
|
121
|
+
return value === undefined ? undefined : JSON.stringify(value)
|
|
122
|
+
}
|
|
123
|
+
if (piName === 'bash') {
|
|
124
|
+
const raw = record(value)
|
|
125
|
+
if (!raw || typeof raw.stdout !== 'string') return undefined
|
|
126
|
+
const stderr = typeof raw.stderr === 'string' && raw.stderr.length > 0 ? `\n${raw.stderr}` : ''
|
|
127
|
+
return `${raw.stdout}${stderr}`
|
|
128
|
+
}
|
|
129
|
+
// pi's other tool outputs are text content, so a string replacement is
|
|
130
|
+
// shape-valid; a structured value has no pi counterpart and is ignored.
|
|
131
|
+
return typeof value === 'string' ? value : undefined
|
|
132
|
+
}
|
|
@@ -25,6 +25,9 @@ export interface HookCommand {
|
|
|
25
25
|
* the next turn, and any still running are killed at session end. */
|
|
26
26
|
async?: boolean
|
|
27
27
|
asyncRewake?: boolean
|
|
28
|
+
/** Claude's permission-rule filter (`"Bash(git *)"`, `"Edit(*.ts)"`): evaluated only
|
|
29
|
+
* on tool events; on any other event a hook carrying `if` never runs. */
|
|
30
|
+
if?: string
|
|
28
31
|
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
29
32
|
url?: string
|
|
30
33
|
headers?: Record<string, string>
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import type { PathAnchors } from '../internal/path-rules.js'
|
|
9
|
+
import { claudeToolInput, claudeToolName, piToolInput } from './claude-tools.js'
|
|
8
10
|
import { type HookCommand, type HooksConfig, isRecord } from './config.js'
|
|
9
|
-
import { matchingCommands } from './matcher.js'
|
|
11
|
+
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
10
12
|
import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
|
|
11
13
|
|
|
12
14
|
export interface HookDecision {
|
|
@@ -17,7 +19,19 @@ export interface HookDecision {
|
|
|
17
19
|
ask?: boolean
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function tryParseJson(text: string):
|
|
22
|
+
export function tryParseJson(text: string):
|
|
23
|
+
| {
|
|
24
|
+
hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }
|
|
25
|
+
decision?: string
|
|
26
|
+
reason?: string
|
|
27
|
+
continue?: boolean
|
|
28
|
+
stopReason?: string
|
|
29
|
+
systemMessage?: string
|
|
30
|
+
updatedToolOutput?: unknown
|
|
31
|
+
updatedMCPToolOutput?: unknown
|
|
32
|
+
ok?: boolean
|
|
33
|
+
}
|
|
34
|
+
| undefined {
|
|
21
35
|
try {
|
|
22
36
|
return JSON.parse(text)
|
|
23
37
|
} catch {
|
|
@@ -25,16 +39,42 @@ export function tryParseJson(text: string): { hookSpecificOutput?: { permissionD
|
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
41
|
|
|
42
|
+
/** The reason a JSON body's blocking decision carries, whichever spelling made it. */
|
|
43
|
+
function jsonBlockingReason(parsed: ReturnType<typeof tryParseJson>): string | undefined {
|
|
44
|
+
if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return parsed.hookSpecificOutput.permissionDecisionReason
|
|
45
|
+
if (parsed?.decision === 'block') return parsed.reason
|
|
46
|
+
if (parsed?.continue === false) return parsed.stopReason
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A blocking verdict in any of the JSON spellings a hook can answer with: the
|
|
51
|
+
* command-hook fields, and the prompt/agent reply schemas (`permissionDecision:
|
|
52
|
+
* "deny"` from pi's prompt-hook system prompt, `ok: false` from Claude's documented
|
|
53
|
+
* prompt-hook response). Undefined when the body renders no block. */
|
|
54
|
+
export function jsonBlockVerdict(parsed: ReturnType<typeof tryParseJson>, fallback: string): { reason: string } | undefined {
|
|
55
|
+
if (parsed?.decision === 'block') return { reason: parsed.reason ?? fallback }
|
|
56
|
+
if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return { reason: parsed.hookSpecificOutput.permissionDecisionReason ?? fallback }
|
|
57
|
+
if (parsed?.ok === false) return { reason: parsed.reason ?? fallback }
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
/** Map a hook's exit code / output to a block-or-allow decision. */
|
|
29
62
|
export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
|
|
30
|
-
if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
|
|
31
63
|
const parsed = tryParseJson(stdout)
|
|
64
|
+
// Claude: on exit 2 the blocking message is the JSON blocking decision's reason
|
|
65
|
+
// when it makes one, and the stderr text otherwise.
|
|
66
|
+
if (code === 2) return { block: true, reason: jsonBlockingReason(parsed) ?? (stderr.trim() || 'Blocked by hook') }
|
|
32
67
|
const specific = parsed?.hookSpecificOutput
|
|
33
68
|
// Claude's "ask" prompts the user; the tool_call handler turns this into a
|
|
34
69
|
// ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
|
|
35
70
|
// headless run with no dialog to show, which is the safe reading on a gated path.
|
|
36
71
|
if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
|
|
37
72
|
if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
|
|
73
|
+
// Claude's "defer" exits gracefully so the tool can be resumed later; pi cannot
|
|
74
|
+
// resume a deferred call, so running it now would invert the intent. The block
|
|
75
|
+
// carries its own explanation (the hook's reason is ignored for defer, as
|
|
76
|
+
// documented).
|
|
77
|
+
if (specific?.permissionDecision === 'defer') return { block: true, reason: 'Tool call deferred by hook; pi cannot resume a deferred call, so it was not run.' }
|
|
38
78
|
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
|
|
39
79
|
if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
|
|
40
80
|
return { block: false }
|
|
@@ -57,36 +97,80 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
|
|
|
57
97
|
}
|
|
58
98
|
}
|
|
59
99
|
|
|
100
|
+
/** What PreToolUse resolved to: the decision, plus any additionalContext strings
|
|
101
|
+
* the hooks contributed, delivered alongside the eventual tool result. */
|
|
102
|
+
export interface PreToolUseOutcome extends HookDecision {
|
|
103
|
+
context?: string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
60
106
|
/** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
|
|
61
|
-
* verdict in config order wins.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
107
|
+
* verdict in config order wins. The payload reports the Claude vocabulary: the MCP
|
|
108
|
+
* alias for MCP tools, and the documented name and tool_input shape for pi's
|
|
109
|
+
* built-ins (see claude-tools). Every hook sees the original tool input;
|
|
110
|
+
* hookSpecificOutput.updatedInput replaces the input in place as each hook
|
|
111
|
+
* completes, translated back to the pi shape for a built-in (an incomplete rewrite
|
|
112
|
+
* keeps the original input rather than corrupting it), so with several rewrites
|
|
113
|
+
* the last to finish takes effect (the docs leave multi-rewrite ordering
|
|
114
|
+
* unspecified). */
|
|
115
|
+
/** Apply a hook's updatedInput rewrite in place, translating a built-in rewrite
|
|
116
|
+
* back to the pi shape; an incomplete built-in rewrite keeps the original input
|
|
117
|
+
* rather than corrupting it. */
|
|
118
|
+
function applyUpdatedInput(toolName: string, toolInput: unknown, translated: boolean, stdout: string): void {
|
|
119
|
+
const updated = tryParseJson(stdout)?.hookSpecificOutput?.updatedInput
|
|
120
|
+
if (!isRecord(updated) || !isRecord(toolInput)) return
|
|
121
|
+
const replacement = translated ? piToolInput(toolName, updated) : updated
|
|
122
|
+
if (replacement) replaceRecord(toolInput, replacement)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The fail-closed scan for the gated events: a timed-out or never-spawned hook
|
|
126
|
+
* reached no verdict, and its silence must not read as an allow. */
|
|
127
|
+
function failClosedVerdict(commands: HookCommand[], results: HookRunResult[]): HookDecision | undefined {
|
|
80
128
|
for (const [i, result] of results.entries()) {
|
|
81
|
-
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code
|
|
82
|
-
// would otherwise read as a clean allow.
|
|
129
|
+
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code
|
|
130
|
+
// that would otherwise read as a clean allow.
|
|
83
131
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
|
|
84
132
|
// A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
|
|
85
133
|
// its code 0 must fail closed like a timeout, not read as an allow exactly when
|
|
86
134
|
// the machine is degraded.
|
|
87
135
|
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
|
|
88
136
|
}
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** additionalContext strings the PreToolUse hooks contributed; delivered alongside
|
|
141
|
+
* the tool result, so a deferring hook's context is discarded, as Claude documents. */
|
|
142
|
+
function preToolContexts(results: HookRunResult[]): string[] {
|
|
143
|
+
return results.flatMap((result) => {
|
|
144
|
+
const parsed = tryParseJson(result.stdout)
|
|
145
|
+
if (parsed?.hookSpecificOutput?.permissionDecision === 'defer') return []
|
|
146
|
+
const text = parsed?.hookSpecificOutput?.additionalContext
|
|
147
|
+
return typeof text === 'string' && text.length > 0 ? [text] : []
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink, anchors?: PathAnchors): Promise<PreToolUseOutcome> {
|
|
152
|
+
const cwd = anchors?.cwd ?? process.cwd()
|
|
153
|
+
const translatedName = claudeName ?? claudeToolName(toolName)
|
|
154
|
+
// A built-in's payload input is the translated Claude shape; MCP and unknown
|
|
155
|
+
// tools keep the pi shape (MCP input passes through untranslated in Claude too).
|
|
156
|
+
const translatedInput = claudeName === undefined ? claudeToolInput(toolName, toolInput, cwd) : undefined
|
|
157
|
+
const names = translatedName ? [toolName, translatedName] : [toolName]
|
|
158
|
+
const target = anchors ? { piName: toolName, claudeName: translatedName, input: toolInput, anchors } : undefined
|
|
159
|
+
const commands = matchingCommands(config.PreToolUse, names).filter((command) => passesIfFilter(command, target))
|
|
160
|
+
const payload = { hook_event_name: 'PreToolUse', tool_name: translatedName ?? toolName, tool_input: translatedInput ?? toolInput }
|
|
161
|
+
const results = await Promise.all(
|
|
162
|
+
commands.map((command) =>
|
|
163
|
+
runner(command, payload, timeoutMs(command)).then((result) => {
|
|
164
|
+
applyUpdatedInput(toolName, toolInput, translatedInput !== undefined, result.stdout)
|
|
165
|
+
return result
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
surfaceHookFailures(commands, results, onSystemMessage)
|
|
170
|
+
const failClosed = failClosedVerdict(commands, results)
|
|
171
|
+
if (failClosed) return failClosed
|
|
89
172
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
173
|
+
const context = preToolContexts(results)
|
|
90
174
|
// A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
|
|
91
175
|
// scan for any deny first, and only fall back to the first ask.
|
|
92
176
|
let ask: HookDecision | undefined
|
|
@@ -95,7 +179,7 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
|
|
|
95
179
|
if (decision.block && !decision.ask) return decision
|
|
96
180
|
if (decision.ask && ask === undefined) ask = decision
|
|
97
181
|
}
|
|
98
|
-
return ask ?? { block: false }
|
|
182
|
+
return ask ?? { block: false, context: context.length > 0 ? context : undefined }
|
|
99
183
|
}
|
|
100
184
|
|
|
101
185
|
type SystemMessageSink = (message: string) => void
|
|
@@ -124,9 +208,10 @@ export function promptContext(stdout: string): string {
|
|
|
124
208
|
|
|
125
209
|
/** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
|
|
126
210
|
* verdict in config order wins; otherwise their additional context is concatenated
|
|
127
|
-
* in config order for injection ahead of the prompt.
|
|
211
|
+
* in config order for injection ahead of the prompt. The event has no matcher
|
|
212
|
+
* support (a stray matcher is ignored) and an `if`-carrying hook never runs here. */
|
|
128
213
|
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
|
|
129
|
-
const commands =
|
|
214
|
+
const commands = allCommands(config.UserPromptSubmit).filter((command) => passesIfFilter(command, undefined))
|
|
130
215
|
const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
|
|
131
216
|
surfaceHookFailures(commands, results, onSystemMessage)
|
|
132
217
|
for (const [i, result] of results.entries()) {
|
|
@@ -155,7 +240,12 @@ export function postToolFeedback(result: HookRunResult, eventName: string, isErr
|
|
|
155
240
|
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
156
241
|
// success, exit-2 / decision:block feed back as a block notice.
|
|
157
242
|
if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
158
|
-
else if (!isError
|
|
243
|
+
else if (!isError) {
|
|
244
|
+
// Any JSON blocking spelling feeds back, including the prompt/agent hook reply
|
|
245
|
+
// schemas (permissionDecision deny, ok:false), which arrive as stdout here.
|
|
246
|
+
const verdict = jsonBlockVerdict(parsed, 'Blocked by hook')
|
|
247
|
+
if (verdict) lines.push(`PostToolUse hook: ${verdict.reason}`)
|
|
248
|
+
}
|
|
159
249
|
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
160
250
|
if (context) lines.push(context)
|
|
161
251
|
return lines
|
|
@@ -34,10 +34,22 @@
|
|
|
34
34
|
*
|
|
35
35
|
* Every payload carries session_id, transcript_path (pi's session file), cwd,
|
|
36
36
|
* permission_mode (plan-mode state off the shared bus) and effort; tool events add
|
|
37
|
-
* tool_use_id.
|
|
38
|
-
*
|
|
37
|
+
* tool_use_id. The universal `systemMessage` output (a user-facing warning) is
|
|
38
|
+
* honored on the decision-bearing events; the observational paths (Notification,
|
|
39
|
+
* InstructionsLoaded) ignore it, as Claude documents for them.
|
|
39
40
|
* `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
|
|
40
41
|
* transcript in the first place.
|
|
42
|
+
* Payloads speak Claude's vocabulary for pi's built-in tools: tool_name Bash/Edit/
|
|
43
|
+
* Write/Read/Grep/Glob, the documented tool_input shapes with absolute file_path,
|
|
44
|
+
* and the documented Bash/Write tool_response shapes, with `updatedInput`
|
|
45
|
+
* translated back to pi's shape (see claude-tools; an incomplete rewrite keeps
|
|
46
|
+
* the original input). PreToolUse `additionalContext` lands next to the tool
|
|
47
|
+
* result; `updatedToolOutput` replaces the output the model sees (schema-checked
|
|
48
|
+
* for built-ins, unvalidated for MCP); `permissionDecision: "defer"` blocks the
|
|
49
|
+
* call, since pi cannot resume a deferred one. The `if` permission-rule filter is
|
|
50
|
+
* honored on tool events, and a hook carrying it never runs elsewhere; Stop and
|
|
51
|
+
* UserPromptSubmit ignore a stray matcher, and a Stop hook's `additionalContext`
|
|
52
|
+
* continues the conversation under the same block cap.
|
|
41
53
|
* `async`/`asyncRewake` (command hooks only, as Claude documents) run in the
|
|
42
54
|
* background on every event: they never block or delay the event that fired them
|
|
43
55
|
* and render no decision. An asyncRewake hook exiting 2 wakes the model with its
|
|
@@ -79,9 +91,10 @@ import { installedPlugins } from '../internal/plugins.js'
|
|
|
79
91
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
80
92
|
import { repoRoot } from '../internal/project-root.js'
|
|
81
93
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
94
|
+
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
82
95
|
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
|
|
83
|
-
import { blockedToolCall, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
84
|
-
import { matchingCommands } from './matcher.js'
|
|
96
|
+
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
97
|
+
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
85
98
|
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, timeoutMs } from './runners.js'
|
|
86
99
|
|
|
87
100
|
export * from './config.js'
|
|
@@ -111,15 +124,27 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
|
|
|
111
124
|
const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
|
|
112
125
|
|
|
113
126
|
/** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
|
|
114
|
-
* is a positive integer,
|
|
115
|
-
*
|
|
127
|
+
* is a positive integer, the documented "disable the cap" for 0 (unbounded, not a
|
|
128
|
+
* zero-cap that would suppress the very first block), else the default for a negative
|
|
129
|
+
* or malformed value. */
|
|
116
130
|
export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
|
|
117
131
|
const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
|
|
132
|
+
if (override === 0) return Number.POSITIVE_INFINITY
|
|
118
133
|
return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
|
|
122
|
-
|
|
137
|
+
// Non-tool events: a hook carrying `if` never runs, as Claude documents.
|
|
138
|
+
const runnable = commands.filter((command) => passesIfFilter(command, undefined))
|
|
139
|
+
return await Promise.all(runnable.map((command) => runner(command, payload, timeoutMs(command))))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The text of a result's content blocks, for Claude-shaped tool_response fields. */
|
|
143
|
+
function textContent(content: ReadonlyArray<{ type: string; text?: string }>): string {
|
|
144
|
+
return content
|
|
145
|
+
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
146
|
+
.map((block) => block.text)
|
|
147
|
+
.join('\n')
|
|
123
148
|
}
|
|
124
149
|
|
|
125
150
|
/** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
|
|
@@ -150,6 +175,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
150
175
|
let hooksDisabled = false
|
|
151
176
|
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
152
177
|
const hookSources = new Map<HookMatcher, string>()
|
|
178
|
+
/** PreToolUse additionalContext per tool call, delivered alongside its result. */
|
|
179
|
+
const pendingToolContext = new Map<string, string[]>()
|
|
153
180
|
/** Claude sends session_id, transcript_path, cwd and effort on every payload. */
|
|
154
181
|
const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
155
182
|
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
@@ -277,6 +304,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
277
304
|
// carry into the next session, so reset before any early return (disableAllHooks below).
|
|
278
305
|
stopHookActive = false
|
|
279
306
|
stopHookBlockCount = 0
|
|
307
|
+
pendingToolContext.clear()
|
|
280
308
|
const trusted = await isProjectApproved(ctx)
|
|
281
309
|
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
282
310
|
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
@@ -322,8 +350,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
322
350
|
})
|
|
323
351
|
|
|
324
352
|
pi.on('tool_call', async (event, ctx) => {
|
|
325
|
-
const
|
|
326
|
-
|
|
353
|
+
const anchors = { cwd: ctx.cwd, projectRoot: projectDir || ctx.cwd, home: os.homedir() }
|
|
354
|
+
const decision = await runPreToolUse(config, event.toolName, event.input, boundRunner(ctx, { tool_use_id: event.toolCallId }), mcpAliases.get(event.toolName), (message) => ctx.ui.notify(message, 'warning'), anchors)
|
|
355
|
+
if (!decision.block) {
|
|
356
|
+
// additionalContext is delivered alongside the tool result, so stash it for
|
|
357
|
+
// this call's tool_result to append.
|
|
358
|
+
if (decision.context && decision.context.length > 0) pendingToolContext.set(event.toolCallId, decision.context)
|
|
359
|
+
return undefined
|
|
360
|
+
}
|
|
327
361
|
// Claude's "ask": prompt the user and let the call through if they approve.
|
|
328
362
|
// With no UI (headless) the block stands, which is the safe default.
|
|
329
363
|
if (decision.ask && ctx.hasUI) {
|
|
@@ -338,20 +372,45 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
338
372
|
// stderr) and additionalContext are appended, which is where Claude documents they
|
|
339
373
|
// land. The failure branch shows the hook's stderr to the model too ("Shows stderr
|
|
340
374
|
// to Claude; the tool already failed"), it just cannot block a call that failed.
|
|
375
|
+
// Payloads report the Claude vocabulary (names, input shapes, and the documented
|
|
376
|
+
// Bash/Write response shapes; see claude-tools), and a schema-valid
|
|
377
|
+
// updatedToolOutput replaces the output the model sees.
|
|
341
378
|
pi.on('tool_result', async (event, ctx) => {
|
|
342
379
|
const alias = mcpAliases.get(event.toolName)
|
|
343
|
-
const
|
|
344
|
-
const
|
|
380
|
+
const translatedName = alias ?? claudeToolName(event.toolName)
|
|
381
|
+
const names = translatedName ? [event.toolName, translatedName] : [event.toolName]
|
|
345
382
|
const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
383
|
+
// Contexts stashed by this call's PreToolUse hooks land next to the result even
|
|
384
|
+
// when no PostToolUse hook is configured.
|
|
385
|
+
const pending = pendingToolContext.get(event.toolCallId) ?? []
|
|
386
|
+
pendingToolContext.delete(event.toolCallId)
|
|
387
|
+
const anchors = { cwd: ctx.cwd, projectRoot: projectDir || ctx.cwd, home: os.homedir() }
|
|
388
|
+
const target = { piName: event.toolName, claudeName: translatedName, input: event.input, anchors }
|
|
389
|
+
const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names).filter((command) => passesIfFilter(command, target))
|
|
390
|
+
if (commands.length === 0 && pending.length === 0) return
|
|
391
|
+
const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
|
|
392
|
+
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
|
|
393
|
+
const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, tool_response: response }
|
|
349
394
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
350
395
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
351
396
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
397
|
+
// Claude's updatedToolOutput replaces the output the model sees; a value that
|
|
398
|
+
// doesn't match the tool's output schema is ignored, MCP output passes through
|
|
399
|
+
// unvalidated, and a failed call keeps its error output.
|
|
400
|
+
const replacement = event.isError
|
|
401
|
+
? undefined
|
|
402
|
+
: results
|
|
403
|
+
.filter((result) => !result.timedOut)
|
|
404
|
+
.map((result) => {
|
|
405
|
+
const parsed = tryParseJson(result.stdout)
|
|
406
|
+
const value = alias !== undefined ? (parsed?.updatedMCPToolOutput ?? parsed?.updatedToolOutput) : parsed?.updatedToolOutput
|
|
407
|
+
return value === undefined ? undefined : piToolOutput(event.toolName, value, alias !== undefined)
|
|
408
|
+
})
|
|
409
|
+
.find((text) => text !== undefined)
|
|
410
|
+
const feedback = [...pending, ...results.flatMap((result) => postToolFeedback(result, eventName, event.isError))]
|
|
411
|
+
if (replacement === undefined && feedback.length === 0) return
|
|
412
|
+
const base = replacement !== undefined ? [{ type: 'text' as const, text: replacement }] : event.content
|
|
413
|
+
return { content: [...base, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
355
414
|
})
|
|
356
415
|
|
|
357
416
|
// Claude's PreToolUse for Bash, extended to a command the user runs directly with the
|
|
@@ -367,7 +426,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
367
426
|
// no execution result and fires only before the command runs, so there is deliberately
|
|
368
427
|
// no PostToolUse for it.
|
|
369
428
|
pi.on('user_bash', async (event, ctx) => {
|
|
370
|
-
const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
|
|
429
|
+
const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'), { cwd: ctx.cwd, projectRoot: projectDir || ctx.cwd, home: os.homedir() })
|
|
371
430
|
if (!decision.block) return undefined
|
|
372
431
|
// Claude's "ask": prompt before running and let the command through on approval; with
|
|
373
432
|
// no UI (headless) the block stands, the same safe default as the tool_call path.
|
|
@@ -417,7 +476,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
417
476
|
void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
|
|
418
477
|
}
|
|
419
478
|
|
|
420
|
-
|
|
479
|
+
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
480
|
+
// and an `if`-carrying hook never runs on a non-tool event.
|
|
481
|
+
const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
|
|
421
482
|
if (commands.length === 0) {
|
|
422
483
|
stopHookActive = false
|
|
423
484
|
return
|
|
@@ -432,9 +493,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
432
493
|
const block = results
|
|
433
494
|
.filter((result) => !result.timedOut)
|
|
434
495
|
.map((result) => {
|
|
435
|
-
if (result.code === 2) return { block: true, reason: result.stderr.trim() || 'Stop blocked by hook' }
|
|
436
496
|
const parsed = tryParseJson(result.stdout)
|
|
437
|
-
if (
|
|
497
|
+
if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
|
|
498
|
+
// Any JSON blocking spelling counts, including the prompt/agent hook reply
|
|
499
|
+
// schemas (permissionDecision deny, ok:false), which arrive as stdout here.
|
|
500
|
+
const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
|
|
501
|
+
if (verdict) return { block: true, reason: verdict.reason }
|
|
502
|
+
// Claude's non-error continue: additionalContext feeds back and the
|
|
503
|
+
// conversation continues so Claude can act on it. It rides the same
|
|
504
|
+
// continuation path (and the same block cap) so a hook emitting it every
|
|
505
|
+
// firing cannot loop the turn forever.
|
|
506
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
507
|
+
if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
|
|
438
508
|
return { block: false, reason: '' }
|
|
439
509
|
})
|
|
440
510
|
.find((verdict) => verdict.block)
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* commands an event fires. Owns the module-level compiled-matcher cache.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { matchesBashRules } from '../internal/bash-rules.js'
|
|
8
|
+
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
7
9
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
8
10
|
|
|
9
11
|
/** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
|
|
@@ -106,14 +108,11 @@ function withCommand(raw: HookCommand): HookCommand {
|
|
|
106
108
|
return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
* Multiple candidates let one event offer both the pi name and its Claude alias. */
|
|
111
|
-
export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
|
|
112
|
-
const candidates = typeof names === 'string' ? [names] : names
|
|
111
|
+
function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: HookMatcher) => boolean): HookCommand[] {
|
|
113
112
|
const result: HookCommand[] = []
|
|
114
113
|
const seen = new Set<string>()
|
|
115
114
|
for (const entry of matchers ?? []) {
|
|
116
|
-
if (!
|
|
115
|
+
if (!applies(entry)) continue
|
|
117
116
|
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
118
117
|
const hook = withCommand(raw)
|
|
119
118
|
// Claude runs a handler defined in more than one settings file once.
|
|
@@ -124,3 +123,53 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
124
123
|
}
|
|
125
124
|
return result
|
|
126
125
|
}
|
|
126
|
+
|
|
127
|
+
/** Command specs whose matcher applies to any of the given tool/source names.
|
|
128
|
+
* Multiple candidates let one event offer both the pi name and its Claude alias. */
|
|
129
|
+
export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
|
|
130
|
+
const candidates = typeof names === 'string' ? [names] : names
|
|
131
|
+
return collectCommands(matchers, (entry) => matcherApplies(entry.matcher, candidates))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Command specs for an event without matcher support (Stop, UserPromptSubmit): a
|
|
135
|
+
* stray `matcher` on such an event is silently ignored, as Claude documents, so
|
|
136
|
+
* every entry's hooks run. */
|
|
137
|
+
export function allCommands(matchers: HookMatcher[] | undefined): HookCommand[] {
|
|
138
|
+
return collectCommands(matchers, () => true)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The tool call an `if` filter evaluates against; absent on non-tool events. */
|
|
142
|
+
export interface IfFilterTarget {
|
|
143
|
+
piName: string
|
|
144
|
+
claudeName?: string
|
|
145
|
+
input: unknown
|
|
146
|
+
anchors: PathAnchors
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Claude's `if` handler field: permission-rule syntax evaluated only on tool
|
|
150
|
+
* events; on any other event a hook carrying `if` never runs. A bare tool name
|
|
151
|
+
* matches by name; `Bash(pattern)` evaluates against the command via the shared
|
|
152
|
+
* bash-rule matcher and file-tool patterns against the path via the shared
|
|
153
|
+
* permission path rules. A pattern for any other tool matches nothing, which is
|
|
154
|
+
* also what an unparseable rule does. */
|
|
155
|
+
export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undefined): boolean {
|
|
156
|
+
if (hook.if === undefined) return true
|
|
157
|
+
if (target === undefined) return false
|
|
158
|
+
const parsed = /^([A-Za-z_|]+?)(?:\((.*)\))?$/.exec(hook.if.trim())
|
|
159
|
+
if (!parsed) return false
|
|
160
|
+
const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
|
|
161
|
+
const ruleTools = new Set(parsed[1].split('|').map(fold))
|
|
162
|
+
const toolMatches = ruleTools.has(fold(target.piName)) || (target.claudeName !== undefined && ruleTools.has(fold(target.claudeName)))
|
|
163
|
+
if (!toolMatches) return false
|
|
164
|
+
const pattern = parsed[2]
|
|
165
|
+
if (pattern === undefined) return true
|
|
166
|
+
const input = target.input as Record<string, unknown> | null
|
|
167
|
+
if (fold(target.piName) === 'bash' || (target.claudeName !== undefined && fold(target.claudeName) === 'bash')) {
|
|
168
|
+
const command = typeof input?.command === 'string' ? input.command : ''
|
|
169
|
+
return command.length > 0 && matchesBashRules(command, [pattern])
|
|
170
|
+
}
|
|
171
|
+
let filePath = ''
|
|
172
|
+
if (typeof input?.path === 'string') filePath = input.path
|
|
173
|
+
else if (typeof input?.file_path === 'string') filePath = input.file_path
|
|
174
|
+
return filePath.length > 0 && matchesPathRules(filePath, [pattern], target.anchors)
|
|
175
|
+
}
|
|
@@ -11,11 +11,13 @@ import { callMcpTool } from '../internal/mcp-call.js'
|
|
|
11
11
|
import { completeText } from '../internal/model-complete.js'
|
|
12
12
|
import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
|
|
13
13
|
|
|
14
|
-
// Claude defaults
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
|
|
15
|
+
// for prompt, 60s for agent, lowered to 30s on UserPromptSubmit and to a shared
|
|
16
|
+
// 1.5s budget on SessionEnd) and a timed-out hook proceeds; here one flat default
|
|
17
|
+
// applies and a timed-out PreToolUse or UserPromptSubmit hook fails closed (pi
|
|
18
|
+
// has no permission prompt to fall back on), so ten minutes of default budget
|
|
19
|
+
// would wedge the turn on a hung hook. Hooks that legitimately run long can
|
|
20
|
+
// raise their own per-hook `timeout`.
|
|
19
21
|
const DEFAULT_TIMEOUT_S = 60
|
|
20
22
|
|
|
21
23
|
export interface HookRunResult {
|
|
@@ -99,10 +99,12 @@ export function normalizeToolName(name: string): string {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* Entries are comma-separated,
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
102
|
+
* Entries are comma- or space-separated, as Claude documents ("a space- or
|
|
103
|
+
* comma-separated string, or a YAML list"), except a separator inside an argument
|
|
104
|
+
* scope belongs to the scope: `Bash(cat, tail)` and `Bash(git add *)` are one grant
|
|
105
|
+
* each. Splitting on every comma made the fragments between them top-level entries,
|
|
106
|
+
* so a command naming only `Bash` came away with pi's `edit` tool active; splitting
|
|
107
|
+
* on no spaces mangled the docs' own space-separated examples into one garbage rule.
|
|
106
108
|
*
|
|
107
109
|
* Scanned rather than matched with a regex: the pattern form is quadratic on an input
|
|
108
110
|
* of unclosed parens, and a command file comes from the repository.
|
|
@@ -114,7 +116,7 @@ function toolEntries(raw: string): string[] {
|
|
|
114
116
|
for (const ch of raw) {
|
|
115
117
|
if (ch === '(') depth++
|
|
116
118
|
else if (ch === ')') depth = Math.max(0, depth - 1)
|
|
117
|
-
if (ch === ',' && depth === 0) {
|
|
119
|
+
if ((ch === ',' || ch === ' ' || ch === '\t') && depth === 0) {
|
|
118
120
|
entries.push(current)
|
|
119
121
|
current = ''
|
|
120
122
|
} else {
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -41,10 +41,11 @@ import { setMcpToolCaller } from '../internal/mcp-call.js'
|
|
|
41
41
|
import { capForContext } from '../internal/output-guard.js'
|
|
42
42
|
import { installedPlugins } from '../internal/plugins.js'
|
|
43
43
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
44
|
+
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
44
45
|
import { loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
|
|
45
46
|
import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
|
|
46
47
|
import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
|
|
47
|
-
import { applyServerPolicy, loadManagedMcpServers, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
|
|
48
|
+
import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
|
|
48
49
|
import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, withTimeout } from './transport.js'
|
|
49
50
|
|
|
50
51
|
export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
|
|
@@ -57,7 +58,7 @@ export type { McpToolInfo } from './listing.js'
|
|
|
57
58
|
export type { McpPromptArgumentInfo, McpPromptInfo, ToolContent } from './mapping.js'
|
|
58
59
|
export { capTotal, formatPromptCommandName, formatToolName, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
|
|
59
60
|
export type { ProjectServerPolicy } from './policy.js'
|
|
60
|
-
export { applyServerPolicy, loadManagedMcpServers, managedMcpPath, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
|
|
61
|
+
export { applyServerPolicy, loadManagedMcpServers, type McpPolicy, type McpPolicyEntry, managedMcpPath, mcpAllowDeny, projectServerPolicy, splitByPolicy, urlPatternMatches } from './policy.js'
|
|
61
62
|
export type { AuthUi } from './transport.js'
|
|
62
63
|
export { parseHelperHeaders, resolveBearerToken } from './transport.js'
|
|
63
64
|
|
|
@@ -401,8 +402,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
401
402
|
* connected client not in the managed set (delete it from the map first so the onclose
|
|
402
403
|
* handler's guard sees it gone and does not overwrite the status, then close it
|
|
403
404
|
* best-effort and mark it disabled), then connect only the managed servers. */
|
|
404
|
-
async function connectManagedExclusive(managed: Record<string, ServerConfig>,
|
|
405
|
-
const managedServers = applyServerPolicy(managed,
|
|
405
|
+
async function connectManagedExclusive(managed: Record<string, ServerConfig>, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
|
|
406
|
+
const managedServers = applyServerPolicy(managed, policy)
|
|
406
407
|
const managedNames = new Set(Object.keys(managedServers))
|
|
407
408
|
for (const [name, client] of Array.from(clients.entries())) {
|
|
408
409
|
if (managedNames.has(name)) continue
|
|
@@ -420,11 +421,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
420
421
|
* factory: pi runs the factory for invocations that never start a session. Names still
|
|
421
422
|
* connected are filtered out, so a later session start only retries servers that failed
|
|
422
423
|
* or whose transport dropped, without duplicate-name warnings. */
|
|
423
|
-
async function connectNormalScopes(ctx: ExtensionContext,
|
|
424
|
+
async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
|
|
424
425
|
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
425
426
|
// the user's own entry wins a name clash with a plugin's.
|
|
426
427
|
const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
|
|
427
|
-
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) },
|
|
428
|
+
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, policy)
|
|
428
429
|
// Claude's precedence is project over user for a duplicate name. A project .mcp.json
|
|
429
430
|
// server only outranks the user's own when it will actually connect (the user already
|
|
430
431
|
// consented to it, or an approved project's), so a merely-present untrusted project
|
|
@@ -434,7 +435,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
434
435
|
// The stored project decision, read without prompting: consent recorded inside
|
|
435
436
|
// the project only counts once the project itself has been approved.
|
|
436
437
|
const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
437
|
-
const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)),
|
|
438
|
+
const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy), projectPolicy)
|
|
438
439
|
const projectWinners = new Set(Object.keys(consented))
|
|
439
440
|
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
|
|
440
441
|
// The consented project servers carry no ordering dependency on the user scope:
|
|
@@ -462,8 +463,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
462
463
|
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
463
464
|
status.clear()
|
|
464
465
|
const authUi = authUiFor(ctx)
|
|
465
|
-
// The
|
|
466
|
-
|
|
466
|
+
// The allow/deny lists filter every scope, including a managed-mcp.json set. They
|
|
467
|
+
// merge from managed settings plus the trust-gated settings chain, as Claude
|
|
468
|
+
// documents (a repo's file counts only once the project is approved).
|
|
469
|
+
const policy = mcpAllowDeny(claudeSettingsChain(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx)))
|
|
467
470
|
// managed-mcp.json (beside managed-settings.json) takes exclusive control when present:
|
|
468
471
|
// only its servers load, and the user, project, and plugin scopes plus the whole
|
|
469
472
|
// project-approval flow below are skipped. An empty map disables MCP entirely. An absent
|
|
@@ -471,9 +474,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
471
474
|
// empty set (see loadManagedMcpServers).
|
|
472
475
|
const managed = loadManagedMcpServers()
|
|
473
476
|
if (managed !== null) {
|
|
474
|
-
await connectManagedExclusive(managed,
|
|
477
|
+
await connectManagedExclusive(managed, policy, authUi)
|
|
475
478
|
} else {
|
|
476
|
-
await connectNormalScopes(ctx,
|
|
479
|
+
await connectNormalScopes(ctx, policy, authUi)
|
|
477
480
|
}
|
|
478
481
|
|
|
479
482
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -9,7 +9,7 @@ import * as path from 'node:path'
|
|
|
9
9
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
10
|
import { managedSettingsFile } from '../internal/managed-settings.js'
|
|
11
11
|
import { findNearestFile } from '../internal/project-root.js'
|
|
12
|
-
import type
|
|
12
|
+
import { interpolateEnv, type ServerConfig } from './config.js'
|
|
13
13
|
|
|
14
14
|
export interface ProjectServerPolicy {
|
|
15
15
|
disabled: Set<string>
|
|
@@ -60,31 +60,133 @@ export function splitByPolicy(candidates: Record<string, ServerConfig>, policy:
|
|
|
60
60
|
return { consented, gated }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** Claude's
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
/** One allow/deny list entry, keyed by exactly one of Claude's three match kinds. */
|
|
64
|
+
export interface McpPolicyEntry {
|
|
65
|
+
/** Exact user-assigned label; never expanded. */
|
|
66
|
+
serverName?: string
|
|
67
|
+
/** Remote server URL, exact or with `*` wildcards anywhere. */
|
|
68
|
+
serverUrl?: string
|
|
69
|
+
/** Exact command and arguments that start a stdio server. */
|
|
70
|
+
serverCommand?: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface McpPolicy {
|
|
74
|
+
/** null when no honored scope sets allowedMcpServers: every server passing the denylist loads. */
|
|
75
|
+
allowed: McpPolicyEntry[] | null
|
|
76
|
+
denied: McpPolicyEntry[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A raw list entry into its typed form. A bare string is a name (Claude tolerates it);
|
|
80
|
+
* an object is keyed serverUrl > serverCommand > serverName; anything else is dropped. */
|
|
81
|
+
function parsePolicyEntry(entry: unknown): McpPolicyEntry | undefined {
|
|
82
|
+
if (typeof entry === 'string') return entry.length > 0 ? { serverName: entry } : undefined
|
|
83
|
+
if (entry === null || typeof entry !== 'object') return undefined
|
|
84
|
+
const { serverName, serverUrl, serverCommand } = entry as Record<string, unknown>
|
|
85
|
+
if (typeof serverUrl === 'string' && serverUrl.length > 0) return { serverUrl }
|
|
86
|
+
if (Array.isArray(serverCommand) && serverCommand.length > 0 && serverCommand.every((part): part is string => typeof part === 'string')) return { serverCommand: serverCommand as string[] }
|
|
87
|
+
if (typeof serverName === 'string' && serverName.length > 0) return { serverName }
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Claude's `allowedMcpServers`/`deniedMcpServers`. Both lists merge from every honored
|
|
92
|
+
* settings scope (the caller passes the trust-gated chain, so a repo's file counts only
|
|
93
|
+
* once the project is approved); managed `allowManagedMcpServersOnly: true` keeps only
|
|
94
|
+
* the managed allowlist, while the denylist always merges from every scope, as Claude
|
|
95
|
+
* documents. An allowlist `serverName` entry is limited to letters, numbers, hyphens and
|
|
96
|
+
* underscores (a denylist name accepts any non-empty string). `allowed` is null when no
|
|
97
|
+
* honored scope sets the key; an empty list is an explicit lockdown (deny all). */
|
|
98
|
+
export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = managedSettingsFile()): McpPolicy {
|
|
99
|
+
const read = (file: string): Record<string, unknown> => {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
102
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
103
|
+
} catch {
|
|
104
|
+
// Missing or invalid file contributes no policy.
|
|
105
|
+
return {}
|
|
106
|
+
}
|
|
80
107
|
}
|
|
81
|
-
const
|
|
108
|
+
const entries = (value: unknown): McpPolicyEntry[] => (Array.isArray(value) ? value.map(parsePolicyEntry).filter((entry): entry is McpPolicyEntry => entry !== undefined) : [])
|
|
109
|
+
const managed = read(managedFile)
|
|
110
|
+
const scopes = scopeFiles.map(read)
|
|
111
|
+
const allowSources = managed.allowManagedMcpServersOnly === true ? [managed] : [managed, ...scopes]
|
|
112
|
+
const allowSet = allowSources.some((settings) => Array.isArray(settings.allowedMcpServers))
|
|
82
113
|
return {
|
|
83
|
-
allowed:
|
|
84
|
-
denied:
|
|
114
|
+
allowed: allowSet ? allowSources.flatMap((settings) => entries(settings.allowedMcpServers)).filter((entry) => entry.serverName === undefined || /^[A-Za-z0-9_-]+$/.test(entry.serverName)) : null,
|
|
115
|
+
denied: [managed, ...scopes].flatMap((settings) => entries(settings.deniedMcpServers)),
|
|
85
116
|
}
|
|
86
117
|
}
|
|
87
118
|
|
|
119
|
+
/** `*` in a policy URL pattern matches any run of characters; everything else is literal. */
|
|
120
|
+
function wildcardRegExp(pattern: string): RegExp {
|
|
121
|
+
const source = pattern
|
|
122
|
+
.split('*')
|
|
123
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
124
|
+
.join('.*')
|
|
125
|
+
return new RegExp(`^${source}$`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Claude's URL pattern match: `*` anywhere including the scheme; the scheme/host/port
|
|
129
|
+
* part compares case-insensitively and ignores a trailing FQDN dot, the path stays
|
|
130
|
+
* case-sensitive, and a pattern with no path matches any path. */
|
|
131
|
+
export function urlPatternMatches(pattern: string, url: string): boolean {
|
|
132
|
+
const split = (value: string): { pre: string; path: string | undefined } => {
|
|
133
|
+
const schemeEnd = value.indexOf('://')
|
|
134
|
+
const slash = value.indexOf('/', schemeEnd === -1 ? 0 : schemeEnd + 3)
|
|
135
|
+
if (slash === -1) return { pre: value, path: undefined }
|
|
136
|
+
return { pre: value.slice(0, slash), path: value.slice(slash) }
|
|
137
|
+
}
|
|
138
|
+
const pre = (value: string): string => value.toLowerCase().replace(/\.(?=:\d+$|$)/, '')
|
|
139
|
+
const patternParts = split(pattern)
|
|
140
|
+
const urlParts = split(url)
|
|
141
|
+
if (!wildcardRegExp(pre(patternParts.pre)).test(pre(urlParts.pre))) return false
|
|
142
|
+
if (patternParts.path === undefined) return true
|
|
143
|
+
return wildcardRegExp(patternParts.path).test(urlParts.path ?? '/')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const configUrl = (config: ServerConfig): string | undefined => (config as { url?: string }).url
|
|
147
|
+
const configArgv = (config: ServerConfig): string[] | undefined => {
|
|
148
|
+
const command = (config as { command?: string }).command
|
|
149
|
+
if (typeof command !== 'string') return undefined
|
|
150
|
+
const args = (config as { args?: unknown }).args
|
|
151
|
+
return [command, ...(Array.isArray(args) ? args.filter((part): part is string => typeof part === 'string') : [])]
|
|
152
|
+
}
|
|
153
|
+
const argvEqual = (a: string[], b: string[]): boolean => a.length === b.length && a.every((part, i) => part === b[i])
|
|
154
|
+
/** Policy-side expansion. Divergence from Claude, documented: Claude expands policy
|
|
155
|
+
* entries from a pinned startup environment; pi-code expands from the live process env.
|
|
156
|
+
* The deny path compensates by also matching the unexpanded forms (see entryMatches),
|
|
157
|
+
* which can only widen a deny, never weaken it. */
|
|
158
|
+
const expand = (value: string): string => interpolateEnv(value)
|
|
159
|
+
|
|
160
|
+
/** Whether one policy entry matches a server. `includeRawForms` is the deny-side rule:
|
|
161
|
+
* raw and expanded forms both count, so expansion drift can only widen a deny. The
|
|
162
|
+
* allow side matches expanded forms only, mirroring Claude ignoring an allow entry
|
|
163
|
+
* whose expansion would change what it means. */
|
|
164
|
+
function entryMatches(entry: McpPolicyEntry, name: string, config: ServerConfig, includeRawForms: boolean): boolean {
|
|
165
|
+
if (entry.serverUrl !== undefined) {
|
|
166
|
+
const url = configUrl(config)
|
|
167
|
+
if (url === undefined) return false
|
|
168
|
+
return urlPatternMatches(expand(entry.serverUrl), expand(url)) || (includeRawForms && urlPatternMatches(entry.serverUrl, url))
|
|
169
|
+
}
|
|
170
|
+
if (entry.serverCommand !== undefined) {
|
|
171
|
+
const argv = configArgv(config)
|
|
172
|
+
if (argv === undefined) return false
|
|
173
|
+
return argvEqual(entry.serverCommand.map(expand), argv.map(expand)) || (includeRawForms && argvEqual(entry.serverCommand, argv))
|
|
174
|
+
}
|
|
175
|
+
return entry.serverName === name
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Claude's allowlist rule per server type: a remote server must match a `serverUrl`
|
|
179
|
+
* entry and a stdio server a `serverCommand` entry; a `serverName` match counts only
|
|
180
|
+
* when the allowlist contains no typed entries for that transport. */
|
|
181
|
+
function serverAllowed(name: string, config: ServerConfig, allowed: McpPolicyEntry[] | null): boolean {
|
|
182
|
+
if (allowed === null) return true
|
|
183
|
+
const remote = configUrl(config) !== undefined
|
|
184
|
+
const typed = allowed.filter((entry) => (remote ? entry.serverUrl !== undefined : entry.serverCommand !== undefined))
|
|
185
|
+
if (typed.some((entry) => entryMatches(entry, name, config, false))) return true
|
|
186
|
+
if (typed.length > 0) return false
|
|
187
|
+
return allowed.some((entry) => entry.serverName === name)
|
|
188
|
+
}
|
|
189
|
+
|
|
88
190
|
/** The managed-mcp.json path: a sibling of managed-settings.json (same directory). Derived
|
|
89
191
|
* through the same test seam so a test can write both into one temp dir. */
|
|
90
192
|
export function managedMcpPath(managedFile: string = managedSettingsFile()): string {
|
|
@@ -126,14 +228,14 @@ export function loadManagedMcpServers(managedFile: string = managedSettingsFile(
|
|
|
126
228
|
return servers as Record<string, ServerConfig>
|
|
127
229
|
}
|
|
128
230
|
|
|
129
|
-
/** Claude's
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
export function applyServerPolicy(servers: Record<string, ServerConfig>,
|
|
231
|
+
/** Claude's allow/deny lists: a denylist match by URL, command or name blocks, and
|
|
232
|
+
* nothing overrides it; then a null allowlist keeps all, while a present one (even
|
|
233
|
+
* empty) is exclusive per the typed rule in serverAllowed. */
|
|
234
|
+
export function applyServerPolicy(servers: Record<string, ServerConfig>, policy: McpPolicy): Record<string, ServerConfig> {
|
|
133
235
|
const out: Record<string, ServerConfig> = {}
|
|
134
236
|
for (const [name, config] of Object.entries(servers)) {
|
|
135
|
-
if (denied.
|
|
136
|
-
if (
|
|
237
|
+
if (policy.denied.some((entry) => entryMatches(entry, name, config, true))) continue
|
|
238
|
+
if (!serverAllowed(name, config, policy.allowed)) continue
|
|
137
239
|
out[name] = config
|
|
138
240
|
}
|
|
139
241
|
return out
|
|
@@ -144,7 +144,9 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
144
144
|
// The SDK's WebSocket transport takes only a url: it carries no headers, bearer
|
|
145
145
|
// token, or headersHelper output. Warn rather than silently dropping configured
|
|
146
146
|
// auth, and skip the helper entirely (running it would block the connect for up to
|
|
147
|
-
// 10s while contributing nothing).
|
|
147
|
+
// 10s while contributing nothing). Divergence: Claude documents header auth as the
|
|
148
|
+
// ws mechanism ("Authentication is header-only"); under pi an authenticated ws
|
|
149
|
+
// server cannot be used until the SDK transport grows header support.
|
|
148
150
|
if (config.headers || config.bearerToken || config.bearerTokenEnv || config.headersHelper) {
|
|
149
151
|
console.warn(`pi-code-mcp: server ${name} is a WebSocket server; the SDK ws transport is url-only, so its headers/bearerToken/headersHelper are ignored`)
|
|
150
152
|
}
|
|
@@ -114,15 +114,16 @@ Claude Code fields map onto pi where a sensible seam exists: `tools` and
|
|
|
114
114
|
`--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model, or
|
|
115
115
|
`--thinking` when no model is pinned;
|
|
116
116
|
`permissionMode: plan` selects a read-only toolset unless `tools` is set. Model
|
|
117
|
-
aliases (`sonnet`, `opus`, `haiku`) resolve against the models this machine
|
|
118
|
-
authenticated for, falling back to the session's default model when that tier is
|
|
117
|
+
aliases (`sonnet`, `opus`, `haiku`, `fable`) resolve against the models this machine
|
|
118
|
+
is authenticated for, falling back to the session's default model when that tier is
|
|
119
119
|
unavailable; `inherit` is the session model by definition. `skills` names skills to preload: their bodies are inlined into the child's
|
|
120
120
|
prompt, since a child pi process does not inherit the parent's skill discovery, and
|
|
121
121
|
a name that resolves to nothing is reported in the prompt rather than dropped.
|
|
122
122
|
Fields with no pi seam are ignored, each verified against pi's CLI rather than
|
|
123
|
-
assumed: `
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
assumed: `mcpServers` (a child reads MCP config from files, and writing config
|
|
124
|
+
into the workspace to fake it would be worse than the gap). `maxTurns` and
|
|
125
|
+
`memory` are honored (turn cap enforced at the turn boundary; per-agent memory
|
|
126
|
+
directories injected into the child's prompt).
|
|
126
127
|
|
|
127
128
|
**Locations:**
|
|
128
129
|
- `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.17",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|