@miphamai/cli 0.85.3 → 0.85.5
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/package.json +1 -1
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/commands/project.ts +75 -24
- package/src/core/context.ts +56 -5
- package/src/core/engine.ts +95 -34
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/permission-classifier.ts +21 -3
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +5 -2
- package/src/i18n-core/locales/zh-CN.json +5 -2
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +36 -15
- package/src/providers/fetch-utils.ts +53 -5
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +8 -0
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +1 -1
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { toMcpServerConfig } from './claude-plugin'
|
|
3
4
|
|
|
4
5
|
export interface PluginManifest {
|
|
5
6
|
name: string
|
|
@@ -16,12 +17,43 @@ export type PluginFormat = 'mipham' | 'claude'
|
|
|
16
17
|
export interface PluginValidation {
|
|
17
18
|
valid: boolean
|
|
18
19
|
errors: string[]
|
|
20
|
+
/**
|
|
21
|
+
* Findings that do not block installation.
|
|
22
|
+
*
|
|
23
|
+
* The split is the point: a declaration we would silently drop is a defect in one
|
|
24
|
+
* part of one plugin, and refusing to install the whole thing over it would make
|
|
25
|
+
* the validator an obstacle rather than a report. What it must not do is stay
|
|
26
|
+
* quiet — a dropped declaration that nothing mentions is indistinguishable from
|
|
27
|
+
* one that worked.
|
|
28
|
+
*/
|
|
29
|
+
warnings: string[]
|
|
19
30
|
manifest?: PluginManifest
|
|
20
31
|
format: PluginFormat
|
|
21
32
|
/** Resolved manifest path (empty when no manifest found). */
|
|
22
33
|
manifestPath: string
|
|
23
34
|
}
|
|
24
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Whether `loadPlugins` will hand this declaration to the MCP client.
|
|
38
|
+
*
|
|
39
|
+
* Lives here so the check and the loader read one rule: `plugin-loader.ts` calls
|
|
40
|
+
* this at its guard, and the MCP checks below call it to decide what to report. It
|
|
41
|
+
* requires a `name` because that is the key the client connects under, and one
|
|
42
|
+
* transport or the other — `McpServerConfig` makes `command` and `url` mutually
|
|
43
|
+
* exclusive, so demanding a `command` is what used to drop every remote server.
|
|
44
|
+
*/
|
|
45
|
+
export function isLoadableMcpConfig(cfg: {
|
|
46
|
+
name?: unknown
|
|
47
|
+
command?: unknown
|
|
48
|
+
url?: unknown
|
|
49
|
+
}): boolean {
|
|
50
|
+
return (
|
|
51
|
+
typeof cfg.name === 'string' &&
|
|
52
|
+
cfg.name.length > 0 &&
|
|
53
|
+
(typeof cfg.command === 'string' || typeof cfg.url === 'string')
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
/**
|
|
26
58
|
* Resolve a plugin's manifest. Mipham plugins use `plugin.json` at the plugin
|
|
27
59
|
* root; Claude marketplace plugins use `.claude-plugin/plugin.json`.
|
|
@@ -39,12 +71,144 @@ export function detectPluginFormat(dir: string): PluginFormat {
|
|
|
39
71
|
return resolveManifestPath(dir)?.format ?? 'mipham'
|
|
40
72
|
}
|
|
41
73
|
|
|
74
|
+
/**
|
|
75
|
+
* `${user_config.*}` is Claude Code's placeholder for a value it prompts the user
|
|
76
|
+
* for at install time. This loader has no such step: `expandPluginRoot` substitutes
|
|
77
|
+
* `${CLAUDE_PLUGIN_ROOT}` and nothing else, so the reference reaches the server as
|
|
78
|
+
* literal text. Every reference is reported — "declared" and "undeclared" are not a
|
|
79
|
+
* useful split here, because neither one resolves.
|
|
80
|
+
*/
|
|
81
|
+
const USER_CONFIG_RE = /\$\{user_config\.([A-Za-z0-9_.-]+)\}/g
|
|
82
|
+
|
|
83
|
+
function userConfigKeys(texts: string[]): string[] {
|
|
84
|
+
const keys: string[] = []
|
|
85
|
+
const seen = new Set<string>()
|
|
86
|
+
for (const text of texts) {
|
|
87
|
+
for (const m of text.matchAll(USER_CONFIG_RE)) {
|
|
88
|
+
const key = m[1]!
|
|
89
|
+
if (seen.has(key)) continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
keys.push(key)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return keys
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Hosts for which a cleartext URL stays on the machine, so there is nothing in transit. */
|
|
98
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
|
|
99
|
+
|
|
100
|
+
function cleartextWarning(server: string, url: string): string | null {
|
|
101
|
+
if (!/^http:\/\//i.test(url)) return null
|
|
102
|
+
let host: string
|
|
103
|
+
try {
|
|
104
|
+
host = new URL(url).hostname
|
|
105
|
+
} catch {
|
|
106
|
+
return null // an unparseable URL is not the finding this check is for
|
|
107
|
+
}
|
|
108
|
+
if (LOOPBACK_HOSTS.has(host)) return null
|
|
109
|
+
return `MCP server "${server}" uses a cleartext http:// URL (${url}) — it is readable in transit`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DROPPED = 'declares neither command nor url and would be skipped at load'
|
|
113
|
+
|
|
114
|
+
function describe(err: unknown): string {
|
|
115
|
+
return err instanceof Error ? err.message : String(err)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Check the Claude-format sources: `.mcp.json` at the root, plus inline `mcpServers`. */
|
|
119
|
+
function checkClaudeMcp(
|
|
120
|
+
dir: string,
|
|
121
|
+
manifest: PluginManifest,
|
|
122
|
+
texts: string[],
|
|
123
|
+
warnings: string[],
|
|
124
|
+
): void {
|
|
125
|
+
const inspect = (label: string, servers: unknown): void => {
|
|
126
|
+
if (!servers || typeof servers !== 'object') return
|
|
127
|
+
for (const [name, entry] of Object.entries(servers as Record<string, unknown>)) {
|
|
128
|
+
const raw = (entry ?? {}) as Record<string, unknown>
|
|
129
|
+
if (toMcpServerConfig(name, raw) === null) {
|
|
130
|
+
warnings.push(`${label}: MCP server "${name}" ${DROPPED}`)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw.url === 'string') {
|
|
134
|
+
const cleartext = cleartextWarning(name, raw.url)
|
|
135
|
+
if (cleartext) warnings.push(`${label}: ${cleartext}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mcpJsonPath = join(dir, '.mcp.json')
|
|
141
|
+
if (existsSync(mcpJsonPath)) {
|
|
142
|
+
const text = readFileSync(mcpJsonPath, 'utf-8')
|
|
143
|
+
texts.push(text)
|
|
144
|
+
try {
|
|
145
|
+
inspect('`.mcp.json`', (JSON.parse(text) as { mcpServers?: unknown }).mcpServers)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
warnings.push(`\`.mcp.json\` could not be parsed and is skipped at load: ${describe(err)}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const inline = manifest.mcpServers
|
|
152
|
+
if (typeof inline === 'string') {
|
|
153
|
+
// The manifest type admits a string here, and the loader's `collect` returns
|
|
154
|
+
// early on anything that is not an object — so this form is declared and read
|
|
155
|
+
// by nothing.
|
|
156
|
+
warnings.push('`mcpServers` is a string; this loader only reads an object and ignores it')
|
|
157
|
+
} else {
|
|
158
|
+
inspect('manifest `mcpServers`', inline)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Check the Mipham-format source: `mcp-servers/*.json` at the plugin root. */
|
|
163
|
+
function checkMiphamMcp(dir: string, texts: string[], warnings: string[]): void {
|
|
164
|
+
const mcpDir = join(dir, 'mcp-servers')
|
|
165
|
+
if (!existsSync(mcpDir)) return
|
|
166
|
+
|
|
167
|
+
let entries: string[]
|
|
168
|
+
try {
|
|
169
|
+
entries = readdirSync(mcpDir)
|
|
170
|
+
} catch {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (!entry.endsWith('.json')) continue
|
|
176
|
+
const label = `\`mcp-servers/${entry}\``
|
|
177
|
+
let text: string
|
|
178
|
+
try {
|
|
179
|
+
text = readFileSync(join(mcpDir, entry), 'utf-8')
|
|
180
|
+
} catch (err) {
|
|
181
|
+
warnings.push(`${label} could not be read and is skipped at load: ${describe(err)}`)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
texts.push(text)
|
|
185
|
+
|
|
186
|
+
let cfg: Record<string, unknown>
|
|
187
|
+
try {
|
|
188
|
+
cfg = JSON.parse(text) as Record<string, unknown>
|
|
189
|
+
} catch (err) {
|
|
190
|
+
warnings.push(`${label} could not be parsed and is skipped at load: ${describe(err)}`)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!isLoadableMcpConfig(cfg ?? {})) {
|
|
195
|
+
warnings.push(`${label}: MCP server "${String(cfg?.name ?? entry)}" ${DROPPED}`)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (typeof cfg.url === 'string') {
|
|
199
|
+
const cleartext = cleartextWarning(String(cfg.name), cfg.url)
|
|
200
|
+
if (cleartext) warnings.push(`${label}: ${cleartext}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
42
205
|
export function validatePlugin(dir: string): PluginValidation {
|
|
43
206
|
const resolved = resolveManifestPath(dir)
|
|
44
207
|
if (!resolved) {
|
|
45
208
|
return {
|
|
46
209
|
valid: false,
|
|
47
210
|
errors: ['No plugin manifest found (expected plugin.json or .claude-plugin/plugin.json)'],
|
|
211
|
+
warnings: [],
|
|
48
212
|
format: 'mipham',
|
|
49
213
|
manifestPath: '',
|
|
50
214
|
}
|
|
@@ -69,9 +233,26 @@ export function validatePlugin(dir: string): PluginValidation {
|
|
|
69
233
|
}
|
|
70
234
|
}
|
|
71
235
|
|
|
236
|
+
const warnings: string[] = []
|
|
237
|
+
// Every text read on the way to a declaration, so a `${user_config.*}` inside an
|
|
238
|
+
// `args` array or a header value is caught wherever it appears.
|
|
239
|
+
const texts: string[] = [raw]
|
|
240
|
+
if (resolved.format === 'claude') {
|
|
241
|
+
checkClaudeMcp(dir, manifest, texts, warnings)
|
|
242
|
+
} else {
|
|
243
|
+
checkMiphamMcp(dir, texts, warnings)
|
|
244
|
+
}
|
|
245
|
+
for (const key of userConfigKeys(texts)) {
|
|
246
|
+
warnings.push(
|
|
247
|
+
`\`\${user_config.${key}}\` is passed through literally — this loader has no user-config ` +
|
|
248
|
+
`step, so the server receives the text itself`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
72
252
|
return {
|
|
73
253
|
valid: errors.length === 0,
|
|
74
254
|
errors,
|
|
255
|
+
warnings,
|
|
75
256
|
manifest,
|
|
76
257
|
format: resolved.format,
|
|
77
258
|
manifestPath: resolved.path,
|
|
@@ -81,6 +262,7 @@ export function validatePlugin(dir: string): PluginValidation {
|
|
|
81
262
|
return {
|
|
82
263
|
valid: false,
|
|
83
264
|
errors: [`Failed to read ${label}: ${String(err)}`],
|
|
265
|
+
warnings: [],
|
|
84
266
|
format: resolved.format,
|
|
85
267
|
manifestPath: resolved.path,
|
|
86
268
|
}
|
|
@@ -78,6 +78,15 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
78
78
|
// off at the output ceiling rather than ended by the model.
|
|
79
79
|
let truncated = false
|
|
80
80
|
|
|
81
|
+
// Whether this stream reached `message_stop`. A stream that runs out without
|
|
82
|
+
// one was cut — a proxy or gateway closing the connection cleanly looks
|
|
83
|
+
// exactly like a finished response otherwise.
|
|
84
|
+
let sawTerminalEvent = false
|
|
85
|
+
|
|
86
|
+
// Tool blocks already emitted. A replayed event is the same call, not a
|
|
87
|
+
// second one; emitting it twice makes the engine run the tool twice.
|
|
88
|
+
const emittedToolIds = new Set<string>()
|
|
89
|
+
|
|
81
90
|
const messages = this.convertMessages(req.messages)
|
|
82
91
|
this.markPrefixCacheBreakpoint(messages)
|
|
83
92
|
|
|
@@ -223,21 +232,27 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
223
232
|
// 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
|
|
224
233
|
// 那是一次行为变更,不属本次范围。
|
|
225
234
|
if (currentToolId && currentToolName && accumulatedToolInput) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
// A replayed block carries the id it was first sent with, so the
|
|
236
|
+
// id is what tells a second call apart from the same call twice.
|
|
237
|
+
if (!emittedToolIds.has(currentToolId)) {
|
|
238
|
+
emittedToolIds.add(currentToolId)
|
|
239
|
+
|
|
240
|
+
let parsedInput: Record<string, unknown> = {}
|
|
241
|
+
try {
|
|
242
|
+
parsedInput = JSON.parse(accumulatedToolInput)
|
|
243
|
+
} catch {
|
|
244
|
+
parsedInput = { _raw: accumulatedToolInput }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
yield {
|
|
236
248
|
type: 'tool_use',
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
249
|
+
toolUse: {
|
|
250
|
+
type: 'tool_use',
|
|
251
|
+
id: currentToolId,
|
|
252
|
+
name: currentToolName,
|
|
253
|
+
input: parsedInput,
|
|
254
|
+
},
|
|
255
|
+
}
|
|
241
256
|
}
|
|
242
257
|
|
|
243
258
|
// Reset accumulator
|
|
@@ -272,6 +287,7 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
272
287
|
}
|
|
273
288
|
|
|
274
289
|
case 'message_stop': {
|
|
290
|
+
sawTerminalEvent = true
|
|
275
291
|
yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
|
|
276
292
|
return
|
|
277
293
|
}
|
|
@@ -287,7 +303,12 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
287
303
|
}
|
|
288
304
|
}
|
|
289
305
|
|
|
290
|
-
|
|
306
|
+
// The stream ran out without `message_stop`. Whatever stopped it, the turn is
|
|
307
|
+
// incomplete — and this is the only place that knows, because a cleanly
|
|
308
|
+
// closed connection and a finished response are otherwise the same stream.
|
|
309
|
+
if (!sawTerminalEvent) truncated = true
|
|
310
|
+
|
|
311
|
+
yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
|
|
291
312
|
}
|
|
292
313
|
|
|
293
314
|
async listModels(): Promise<ModelInfo[]> {
|
|
@@ -16,6 +16,58 @@ export interface FetchWithRetryOptions {
|
|
|
16
16
|
|
|
17
17
|
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Upper bound on a server-supplied `Retry-After`, in ms.
|
|
21
|
+
*
|
|
22
|
+
* The header is a *request*, not a contract: a 5xx answering `Retry-After: 3600`
|
|
23
|
+
* used to park the CLI in `sleep` for a full hour with nothing on screen. The
|
|
24
|
+
* user cannot cancel what they cannot see, so the wait is capped and the reason
|
|
25
|
+
* is left in the comment rather than the terminal.
|
|
26
|
+
*/
|
|
27
|
+
export const RETRY_AFTER_MAX_MS = 60_000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lower bound on a server-supplied `Retry-After`, in ms.
|
|
31
|
+
*
|
|
32
|
+
* `Retry-After: 0` is a real thing servers send, and honouring it literally
|
|
33
|
+
* means retrying the instant the previous attempt failed — a hammering loop
|
|
34
|
+
* dressed up as politeness. Any present-but-tiny value lands here instead.
|
|
35
|
+
*/
|
|
36
|
+
const RETRY_AFTER_MIN_MS = 1_000
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delay before the next retry attempt.
|
|
40
|
+
*
|
|
41
|
+
* `Retry-After` is honoured when it is parseable, clamped to
|
|
42
|
+
* `[RETRY_AFTER_MIN_MS, RETRY_AFTER_MAX_MS]`, and **ignored in favour of
|
|
43
|
+
* exponential backoff when it is not** — an unparseable header must not become
|
|
44
|
+
* `sleep(NaN)`, which `setTimeout` reads as 0 (the same back-to-back retry as
|
|
45
|
+
* `Retry-After: 0`, but silent).
|
|
46
|
+
*
|
|
47
|
+
* Accepts both RFC 9110 forms: delta-seconds and an HTTP-date.
|
|
48
|
+
*/
|
|
49
|
+
export function retryDelayMs(
|
|
50
|
+
retryAfter: string | null,
|
|
51
|
+
attempt: number,
|
|
52
|
+
baseDelay: number,
|
|
53
|
+
): number {
|
|
54
|
+
const backoff = baseDelay * Math.pow(2, attempt)
|
|
55
|
+
if (retryAfter === null) return backoff
|
|
56
|
+
|
|
57
|
+
const header = retryAfter.trim()
|
|
58
|
+
const seconds = parseInt(header, 10)
|
|
59
|
+
let requested: number
|
|
60
|
+
if (!Number.isNaN(seconds)) {
|
|
61
|
+
requested = seconds * 1000
|
|
62
|
+
} else {
|
|
63
|
+
const at = Date.parse(header)
|
|
64
|
+
if (Number.isNaN(at)) return backoff // unparseable → backoff, never a zero sleep
|
|
65
|
+
requested = at - Date.now()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Math.min(Math.max(requested, RETRY_AFTER_MIN_MS), RETRY_AFTER_MAX_MS)
|
|
69
|
+
}
|
|
70
|
+
|
|
19
71
|
function isRetryableError(err: unknown): boolean {
|
|
20
72
|
if (err instanceof DOMException && err.name === 'AbortError') return false
|
|
21
73
|
return true
|
|
@@ -51,11 +103,7 @@ export async function fetchWithRetry(
|
|
|
51
103
|
|
|
52
104
|
// 429 / 5xx → retry
|
|
53
105
|
if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
|
|
54
|
-
|
|
55
|
-
const delay = retryAfter
|
|
56
|
-
? parseInt(retryAfter, 10) * 1000
|
|
57
|
-
: baseDelay * Math.pow(2, attempt)
|
|
58
|
-
await sleep(delay)
|
|
106
|
+
await sleep(retryDelayMs(response.headers.get('Retry-After'), attempt, baseDelay))
|
|
59
107
|
continue
|
|
60
108
|
}
|
|
61
109
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive `rm` whose target cannot be read off the command text.
|
|
3
|
+
*
|
|
4
|
+
* `rm -rf node_modules` names what it deletes. `rm -rf "$(pwd)"` does not — the
|
|
5
|
+
* target is produced at run time, so no allow rule, no mode and no reviewer can
|
|
6
|
+
* see how far the deletion reaches.
|
|
7
|
+
*
|
|
8
|
+
* This is deliberately **not** a blocklist of dangerous paths. Those already
|
|
9
|
+
* exist (`tools/exec/bash.ts` BLOCKED_PATTERNS refuses `/`, `~`, `*`, `.` and
|
|
10
|
+
* absolute paths). What is left uncovered is the case where the *path is not in
|
|
11
|
+
* the text at all* — and that case is invisible to every string-matching guard,
|
|
12
|
+
* which is why it has to be recognised structurally instead.
|
|
13
|
+
*
|
|
14
|
+
* Judged on every command line inside the input, via the same `flattenCommand`
|
|
15
|
+
* the deny-rule path uses: reading one normalization while the deny rules read
|
|
16
|
+
* another is how a guard fires on one spelling and not on its twin.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { flattenCommand } from '../core/permission-rules'
|
|
20
|
+
|
|
21
|
+
export type DangerousRmKind =
|
|
22
|
+
/** The target is led by command-substitution output: `rm -rf "$(pwd)"`. */
|
|
23
|
+
| 'substitution'
|
|
24
|
+
/** A variable plus one top-level directory name: `rm -rf $ROOT/usr`. */
|
|
25
|
+
| 'variable-top-level'
|
|
26
|
+
/** The target is anchored to a working-directory variable: `rm -rf $PWD`. */
|
|
27
|
+
| 'cwd-derived'
|
|
28
|
+
/** Nothing but backslashes: `rm -rf \`. */
|
|
29
|
+
| 'backslash-only'
|
|
30
|
+
|
|
31
|
+
export interface DangerousRm {
|
|
32
|
+
kind: DangerousRmKind
|
|
33
|
+
/** The offending target, with its surrounding quotes stripped. */
|
|
34
|
+
target: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Variables whose value is the directory the shell is in.
|
|
39
|
+
*
|
|
40
|
+
* The danger is not an unknown value — it is that the value is *movable*: an
|
|
41
|
+
* earlier segment of the same command line (`cd /tmp && …`) decides it, so the
|
|
42
|
+
* target is anchored to whatever directory the command happens to reach.
|
|
43
|
+
*/
|
|
44
|
+
const CWD_VARS = new Set(['PWD', 'OLDPWD'])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Directory names that sit at the filesystem root.
|
|
48
|
+
*
|
|
49
|
+
* These are what make an empty variable dangerous. If `$VAR` is unset, the shell
|
|
50
|
+
* drops it and `rm -rf $VAR/usr` runs as `rm -rf /usr` — the variable does not
|
|
51
|
+
* fail loudly, it *disappears*, and what is left behind is an absolute path to a
|
|
52
|
+
* system directory. `$VAR/node_modules` collapsing to `/node_modules` is not in
|
|
53
|
+
* that class, so the directory name is what decides, not the variable.
|
|
54
|
+
*/
|
|
55
|
+
const TOP_LEVEL_DIRS = new Set([
|
|
56
|
+
'bin',
|
|
57
|
+
'boot',
|
|
58
|
+
'dev',
|
|
59
|
+
'etc',
|
|
60
|
+
'home',
|
|
61
|
+
'lib',
|
|
62
|
+
'lib64',
|
|
63
|
+
'opt',
|
|
64
|
+
'proc',
|
|
65
|
+
'root',
|
|
66
|
+
'run',
|
|
67
|
+
'sbin',
|
|
68
|
+
'srv',
|
|
69
|
+
'sys',
|
|
70
|
+
'tmp',
|
|
71
|
+
'usr',
|
|
72
|
+
'var',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/** Strip one layer of matching quotes — `"$(pwd)"` and `$(pwd)` are one target. */
|
|
76
|
+
function stripQuotes(s: string): string {
|
|
77
|
+
if (s.length < 2) return s
|
|
78
|
+
const first = s[0]
|
|
79
|
+
const last = s[s.length - 1]
|
|
80
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return s.slice(1, -1)
|
|
81
|
+
return s
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Split an argument region into operands, keeping a quoted run or a `$(…)`
|
|
86
|
+
* substitution whole.
|
|
87
|
+
*
|
|
88
|
+
* Plain whitespace splitting is not enough here, and it fails in the direction
|
|
89
|
+
* that matters: `rm -rf "$(git rev-parse --show-toplevel)"` tokenizes to
|
|
90
|
+
* `['"$(git', 'rev-parse', '--show-toplevel)"']`, so the target never reads as a
|
|
91
|
+
* substitution at all. The cases this guard exists to catch are exactly the ones
|
|
92
|
+
* that contain spaces.
|
|
93
|
+
*/
|
|
94
|
+
function splitOperands(args: string): string[] {
|
|
95
|
+
const out: string[] = []
|
|
96
|
+
let cur = ''
|
|
97
|
+
let quote: string | null = null
|
|
98
|
+
let depth = 0
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < args.length; i++) {
|
|
101
|
+
const ch = args[i]!
|
|
102
|
+
|
|
103
|
+
if (quote) {
|
|
104
|
+
cur += ch
|
|
105
|
+
if (ch === quote) quote = null
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
109
|
+
quote = ch
|
|
110
|
+
cur += ch
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (ch === '$' && args[i + 1] === '(') {
|
|
114
|
+
depth++
|
|
115
|
+
cur += '$('
|
|
116
|
+
i++
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (depth > 0) {
|
|
120
|
+
if (ch === ')') depth--
|
|
121
|
+
cur += ch
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (/\s/.test(ch)) {
|
|
125
|
+
if (cur) out.push(cur)
|
|
126
|
+
cur = ''
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
cur += ch
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (cur) out.push(cur)
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* True for an `rm` invocation carrying a recursive flag.
|
|
138
|
+
*
|
|
139
|
+
* Force (`-f`) is deliberately not required: `rm -r "$(pwd)"` deletes just as
|
|
140
|
+
* much as `rm -rf "$(pwd)"` and asks fewer questions on the way in.
|
|
141
|
+
*/
|
|
142
|
+
function isRecursiveRm(tokens: string[]): boolean {
|
|
143
|
+
if (tokens[0] !== 'rm') return false
|
|
144
|
+
return tokens.slice(1).some((t) => {
|
|
145
|
+
if (t === '--recursive') return true
|
|
146
|
+
// A combined cluster (`-rf`, `-fr`, `-r`). A `--flag` long form is not one.
|
|
147
|
+
return /^-[A-Za-z]+$/.test(t) && t.includes('r')
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Which enumerated shape (if any) this target has. */
|
|
152
|
+
function classifyTarget(raw: string): DangerousRmKind | null {
|
|
153
|
+
const target = stripQuotes(raw)
|
|
154
|
+
|
|
155
|
+
if (/^\\+$/.test(target)) return 'backslash-only'
|
|
156
|
+
|
|
157
|
+
// Led by command-substitution output. A substitution *anywhere* in the target
|
|
158
|
+
// is not enough to judge, but a target that starts with one is anchored to a
|
|
159
|
+
// value decided at run time — including the prefix form, where the suffix only
|
|
160
|
+
// narrows an unknown directory to a named entry inside it.
|
|
161
|
+
const sub = target.match(/^(\$\([^)]*\)|`[^`]*`)(\/.*)?$/)
|
|
162
|
+
if (sub) return 'substitution'
|
|
163
|
+
|
|
164
|
+
const m = target.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?(\/\S*)?$/)
|
|
165
|
+
if (!m) return null
|
|
166
|
+
const name = m[1]!
|
|
167
|
+
const rest = m[2]
|
|
168
|
+
if (CWD_VARS.has(name)) return 'cwd-derived'
|
|
169
|
+
|
|
170
|
+
// A variable followed by exactly one more segment, and that segment is a
|
|
171
|
+
// filesystem-root directory name. Deeper paths are a named subdirectory, which
|
|
172
|
+
// is the ordinary case this must not refuse.
|
|
173
|
+
if (rest) {
|
|
174
|
+
const segments = rest.split('/').filter(Boolean)
|
|
175
|
+
if (segments.length === 1 && TOP_LEVEL_DIRS.has(segments[0]!)) return 'variable-top-level'
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Return the first unbounded recursive-`rm` target in `command`, or `null`. */
|
|
181
|
+
export function detectDangerousRm(command: string): DangerousRm | null {
|
|
182
|
+
for (const segment of flattenCommand(command)) {
|
|
183
|
+
const tokens = splitOperands(segment)
|
|
184
|
+
if (!isRecursiveRm(tokens)) continue
|
|
185
|
+
for (const token of tokens.slice(1)) {
|
|
186
|
+
if (token.startsWith('-')) continue
|
|
187
|
+
const kind = classifyTarget(token)
|
|
188
|
+
if (kind) return { kind, target: stripQuotes(token) }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
package/src/shared/constants.ts
CHANGED
|
@@ -95,6 +95,24 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
95
95
|
vision: true,
|
|
96
96
|
status: 'active',
|
|
97
97
|
},
|
|
98
|
+
{
|
|
99
|
+
id: 'claude-fable-5-1',
|
|
100
|
+
name: 'Claude Fable 5.1',
|
|
101
|
+
providerId: 'anthropic',
|
|
102
|
+
contextWindow: 1_000_000,
|
|
103
|
+
maxOutput: 128_000,
|
|
104
|
+
vision: true,
|
|
105
|
+
status: 'active',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'claude-opus-5-5',
|
|
109
|
+
name: 'Claude Opus 5.5',
|
|
110
|
+
providerId: 'anthropic',
|
|
111
|
+
contextWindow: 1_000_000,
|
|
112
|
+
maxOutput: 128_000,
|
|
113
|
+
vision: true,
|
|
114
|
+
status: 'active',
|
|
115
|
+
},
|
|
98
116
|
],
|
|
99
117
|
},
|
|
100
118
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Detection and messaging for the "current working directory was deleted"
|
|
3
|
-
* startup
|
|
3
|
+
* failure — at startup, and mid-session.
|
|
4
4
|
*
|
|
5
5
|
* `process.cwd()` throws `ENOENT` when the directory the process was launched
|
|
6
6
|
* from no longer exists (e.g. a removed git worktree). The CLI entry checks
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* error surface as a raw crash dump (matches Claude Code 2.1.239).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
|
|
11
13
|
/** True when `err` is the ENOENT thrown by `process.cwd()` on a deleted cwd. */
|
|
12
14
|
export function isDeletedCwdError(err: unknown): boolean {
|
|
13
15
|
if (!(err instanceof Error)) return false
|
|
@@ -24,3 +26,46 @@ export function deletedCwdMessage(): string {
|
|
|
24
26
|
`Change to a valid directory and run \`mipham\` again.`
|
|
25
27
|
)
|
|
26
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `dir` (default `process.cwd()`) if it still exists, otherwise `null`.
|
|
32
|
+
*
|
|
33
|
+
* The launch-time check above covers a directory that was already gone when the
|
|
34
|
+
* process started. A directory deleted *while* the session runs reaches the same
|
|
35
|
+
* state, and neither runtime reports it on its own:
|
|
36
|
+
*
|
|
37
|
+
* - Node throws `ENOENT` from `process.cwd()` — but from inside whatever call
|
|
38
|
+
* site happens to touch it first, so the message names the wrong thing;
|
|
39
|
+
* - Bun does not throw at all. It keeps returning the path it cached at startup,
|
|
40
|
+
* so the deleted directory arrives as an ordinary string and only fails later,
|
|
41
|
+
* at the first syscall that uses it (`spawn /bin/sh ENOENT` blames the shell).
|
|
42
|
+
*
|
|
43
|
+
* Asking the filesystem makes both runtimes agree, and lets the caller decide
|
|
44
|
+
* what to say. An error that is not this one is not swallowed.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveExistingCwd(dir?: string): string | null {
|
|
47
|
+
let target: string
|
|
48
|
+
try {
|
|
49
|
+
target = dir ?? process.cwd()
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (isDeletedCwdError(err)) return null
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
return existsSync(target) ? target : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Guidance for a session whose working directory was deleted while it ran.
|
|
59
|
+
*
|
|
60
|
+
* `deletedCwdMessage` is read before there is a session — it tells the reader to
|
|
61
|
+
* launch the CLI. This one is read from inside a running session, by the model
|
|
62
|
+
* and the operator, so it says what is broken now and what to do instead.
|
|
63
|
+
*/
|
|
64
|
+
export function deletedCwdSessionMessage(): string {
|
|
65
|
+
return (
|
|
66
|
+
`The working directory for this session no longer exists — it was deleted ` +
|
|
67
|
+
`while the session was running.\n` +
|
|
68
|
+
`Tools that need a directory cannot run until the session is restarted from ` +
|
|
69
|
+
`a directory that exists.`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.85.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.5' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
|
@@ -278,6 +278,14 @@ export interface HookConfig {
|
|
|
278
278
|
export interface HookDefinition {
|
|
279
279
|
event: HookEvent
|
|
280
280
|
toolName?: string
|
|
281
|
+
/**
|
|
282
|
+
* Who declared this hook, when it was not the operator — a plugin name, today.
|
|
283
|
+
*
|
|
284
|
+
* The operator is no longer the only author of the hooks that fire, and without
|
|
285
|
+
* this the engine cannot tell them apart: a failure message names only a command,
|
|
286
|
+
* health is tracked per key, and removal has nothing to scope to.
|
|
287
|
+
*/
|
|
288
|
+
source?: string
|
|
281
289
|
handler: (context: HookContext) => Promise<HookResult>
|
|
282
290
|
}
|
|
283
291
|
|