@miphamai/cli 0.81.8 → 0.81.9
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/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +7 -1
- package/src/config/loader.ts +202 -63
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/engine.ts +7 -2
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +17 -2
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +5 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +44 -17
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +13 -2
- package/src/providers/anthropic.ts +48 -11
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +19 -1
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/exec/bash.ts +106 -6
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +76 -1
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +33 -3
- package/src/tools/index.ts +12 -4
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +160 -30
- package/src/workflow/primitives/agent.ts +4 -0
package/src/config/loader.ts
CHANGED
|
@@ -65,10 +65,21 @@ function mergeProviders(
|
|
|
65
65
|
const merged = [...baseProviders]
|
|
66
66
|
|
|
67
67
|
for (const op of overrideProviders) {
|
|
68
|
+
// This array comes straight out of hand-written YAML: an entry can be
|
|
69
|
+
// `null`, or a bare scalar (iterating `providers: nope` yields its
|
|
70
|
+
// characters). Neither is a provider config, and neither should reach the
|
|
71
|
+
// field reads below.
|
|
72
|
+
if (!op || typeof op !== 'object') continue
|
|
73
|
+
|
|
74
|
+
// A non-string `apiKey` (a number, a list, a nested map) is not a secret.
|
|
75
|
+
// It reads as "no key" — the same invariant getProviderApiKey enforces —
|
|
76
|
+
// rather than reaching a `.startsWith` and taking the whole CLI down.
|
|
77
|
+
const apiKey = typeof op.apiKey === 'string' ? op.apiKey : undefined
|
|
78
|
+
|
|
68
79
|
const idx = merged.findIndex((bp) => bp.id === op.id)
|
|
69
80
|
if (idx === -1) {
|
|
70
81
|
// Provider not in defaults — add it wholesale (custom provider)
|
|
71
|
-
merged.push(op)
|
|
82
|
+
merged.push(apiKey === undefined ? op : { ...op, apiKey })
|
|
72
83
|
continue
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -82,7 +93,7 @@ function mergeProviders(
|
|
|
82
93
|
// sent, so only trusted (user-level) config may override it. Untrusted
|
|
83
94
|
// (project-level) config cannot redirect a built-in provider's traffic.
|
|
84
95
|
baseUrl: allowBaseUrlOverride ? (op.baseUrl ?? base.baseUrl) : base.baseUrl,
|
|
85
|
-
apiKey:
|
|
96
|
+
apiKey: apiKey ?? base.apiKey,
|
|
86
97
|
models: op.models?.length ? op.models : base.models,
|
|
87
98
|
status: op.status ?? base.status,
|
|
88
99
|
}
|
|
@@ -91,16 +102,44 @@ function mergeProviders(
|
|
|
91
102
|
return merged
|
|
92
103
|
}
|
|
93
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Merge `override` into `base`, recursing into plain objects so that a source
|
|
107
|
+
* setting one branch of an object does not drop the other source's siblings
|
|
108
|
+
* (`features.mcp` must not wipe `features.context`).
|
|
109
|
+
*
|
|
110
|
+
* Arrays and scalars replace: a project's `skills.paths` must not append to the
|
|
111
|
+
* user's list. Objects are rebuilt with spread rather than assignment — a parsed
|
|
112
|
+
* `__proto__` key is then copied as an ordinary own property instead of
|
|
113
|
+
* mutating the result's prototype.
|
|
114
|
+
*/
|
|
115
|
+
function mergeObjects<T extends Record<string, unknown>>(base: T, override: T): T {
|
|
116
|
+
let merged: Record<string, unknown> = { ...base }
|
|
117
|
+
for (const [key, value] of Object.entries(override)) {
|
|
118
|
+
const existing = (base as Record<string, unknown>)[key]
|
|
119
|
+
merged = {
|
|
120
|
+
...merged,
|
|
121
|
+
[key]:
|
|
122
|
+
isPlainObject(value) && isPlainObject(existing) ? mergeObjects(existing, value) : value,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return merged as T
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
129
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
130
|
+
}
|
|
131
|
+
|
|
94
132
|
function mergeConfig(
|
|
95
133
|
base: MiphamConfig,
|
|
96
134
|
override: Partial<MiphamConfig>,
|
|
97
135
|
allowBaseUrlOverride: boolean,
|
|
98
136
|
): MiphamConfig {
|
|
99
|
-
const merged
|
|
137
|
+
const merged = mergeObjects(
|
|
138
|
+
base as unknown as Record<string, unknown>,
|
|
139
|
+
override as Record<string, unknown>,
|
|
140
|
+
) as unknown as MiphamConfig
|
|
100
141
|
if (override.providers) {
|
|
101
142
|
merged.providers = mergeProviders(base.providers, override.providers, allowBaseUrlOverride)
|
|
102
|
-
} else {
|
|
103
|
-
merged.providers = base.providers
|
|
104
143
|
}
|
|
105
144
|
return merged
|
|
106
145
|
}
|
|
@@ -182,31 +221,19 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
|
|
|
182
221
|
try {
|
|
183
222
|
if (!existsSync(path)) continue
|
|
184
223
|
const raw = readFileSync(path, 'utf-8')
|
|
224
|
+
// Every McpServerConfig field is optional here (the name comes from the
|
|
225
|
+
// key), so a field added to the type is accepted without touching this.
|
|
185
226
|
const parsed = JSON.parse(raw) as {
|
|
186
|
-
mcpServers?: Record<
|
|
187
|
-
string,
|
|
188
|
-
{
|
|
189
|
-
command?: string
|
|
190
|
-
args?: string[]
|
|
191
|
-
url?: string
|
|
192
|
-
headers?: Record<string, string>
|
|
193
|
-
env?: Record<string, string>
|
|
194
|
-
}
|
|
195
|
-
>
|
|
227
|
+
mcpServers?: Record<string, Partial<McpServerConfig>>
|
|
196
228
|
}
|
|
197
229
|
|
|
198
230
|
if (parsed.mcpServers) {
|
|
199
231
|
for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
|
|
200
232
|
// Avoid duplicates by name
|
|
201
233
|
if (servers.some((s) => s.name === name)) continue
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
args: cfg.args || [],
|
|
206
|
-
url: cfg.url,
|
|
207
|
-
headers: cfg.headers,
|
|
208
|
-
env: cfg.env,
|
|
209
|
-
})
|
|
234
|
+
// Spread the whole entry rather than re-listing fields: hand-rebuilding
|
|
235
|
+
// the object silently dropped request_timeout_ms and auth.
|
|
236
|
+
servers.push({ ...cfg, name, args: cfg.args || [] })
|
|
210
237
|
}
|
|
211
238
|
}
|
|
212
239
|
} catch {
|
|
@@ -224,20 +251,57 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
|
|
|
224
251
|
export interface SettingsJson {
|
|
225
252
|
hooks: SettingsHooks
|
|
226
253
|
permissions: { allow: string[]; deny: string[] }
|
|
254
|
+
/**
|
|
255
|
+
* Present (and `true`) only when the project-level file really did declare
|
|
256
|
+
* hooks and they were withheld because the caller did not vouch for the
|
|
257
|
+
* workspace. Absent in every other case — including "the file has no hooks",
|
|
258
|
+
* so a caller announcing the skip cannot announce one that never happened.
|
|
259
|
+
*/
|
|
260
|
+
projectHooksSkipped?: true
|
|
261
|
+
/**
|
|
262
|
+
* The subset of `hooks` that came from the project-level file — the entries
|
|
263
|
+
* the workspace-trust gate governs. Absent unless the caller vouched for the
|
|
264
|
+
* workspace *and* that file really declared hooks.
|
|
265
|
+
*
|
|
266
|
+
* Needed because `hooks` is provenance-free once merged: project and user
|
|
267
|
+
* entries sit in one bucket, yet only one of the two is gated. A caller that
|
|
268
|
+
* lists the merged form therefore cannot say which entries will run.
|
|
269
|
+
*/
|
|
270
|
+
projectHooks?: SettingsHooks
|
|
227
271
|
}
|
|
228
272
|
|
|
229
273
|
/**
|
|
230
274
|
* Load `settings.json` — project-level `.mipham/settings.json` then user-level
|
|
231
275
|
* `~/.mipham/settings.json`. Mirrors the Claude Code convention (hooks additive,
|
|
232
276
|
* permissions merged), so users can migrate their Claude settings unchanged.
|
|
277
|
+
*
|
|
278
|
+
* `includeProjectHooks` defaults to **false**, because the project-level file is
|
|
279
|
+
* repository-controlled and its `hooks` are shell commands this process will
|
|
280
|
+
* spawn — reading them is an act of trust, not a default. Callers that have
|
|
281
|
+
* established trust (or that only *display* the configured list) opt in
|
|
282
|
+
* explicitly. The flag gates hooks only: `permissions` still merge from both
|
|
283
|
+
* levels, since that question is answered by the mode ceiling, not by trust.
|
|
233
284
|
*/
|
|
234
|
-
export function loadSettingsJson(
|
|
285
|
+
export function loadSettingsJson(
|
|
286
|
+
cwd: string = process.cwd(),
|
|
287
|
+
options: { includeProjectHooks?: boolean } = {},
|
|
288
|
+
): SettingsJson {
|
|
235
289
|
const hooks: SettingsHooks = {}
|
|
236
290
|
const permissions = { allow: [] as string[], deny: [] as string[] }
|
|
291
|
+
let projectHooksSkipped = false
|
|
292
|
+
// The project file's entries, kept out of the merge so provenance survives it.
|
|
293
|
+
const projectHooks: SettingsHooks = {}
|
|
294
|
+
|
|
295
|
+
const searchPaths: Array<{ path: string; readHooks: boolean; isProject?: boolean }> = [
|
|
296
|
+
{
|
|
297
|
+
path: join(cwd, '.mipham', 'settings.json'),
|
|
298
|
+
readHooks: options.includeProjectHooks ?? false,
|
|
299
|
+
isProject: true,
|
|
300
|
+
},
|
|
301
|
+
{ path: join(MIPHAM_HOME, 'settings.json'), readHooks: true },
|
|
302
|
+
]
|
|
237
303
|
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
for (const path of searchPaths) {
|
|
304
|
+
for (const { path, readHooks, isProject } of searchPaths) {
|
|
241
305
|
try {
|
|
242
306
|
if (!existsSync(path)) continue
|
|
243
307
|
const raw = readFileSync(path, 'utf-8')
|
|
@@ -246,11 +310,27 @@ export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
|
|
|
246
310
|
permissions?: { allow?: unknown; deny?: unknown }
|
|
247
311
|
}
|
|
248
312
|
|
|
249
|
-
if (
|
|
313
|
+
if (!readHooks) {
|
|
314
|
+
// "Was anything actually withheld?" is answered from the same parse that
|
|
315
|
+
// would have read it, so the skip notice cannot outrun the fact.
|
|
316
|
+
const declared = Object.values(parsed.hooks ?? {}).some(
|
|
317
|
+
(entries) => Array.isArray(entries) && entries.length > 0,
|
|
318
|
+
)
|
|
319
|
+
if (declared) projectHooksSkipped = true
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (readHooks && parsed.hooks && typeof parsed.hooks === 'object') {
|
|
250
323
|
for (const [eventName, entries] of Object.entries(parsed.hooks)) {
|
|
251
324
|
if (!Array.isArray(entries)) continue
|
|
252
325
|
const bucket = (hooks as Record<string, unknown[]>)[eventName]
|
|
253
326
|
;(hooks as Record<string, unknown[]>)[eventName] = [...(bucket ?? []), ...entries]
|
|
327
|
+
if (isProject) {
|
|
328
|
+
const pBucket = (projectHooks as Record<string, unknown[]>)[eventName]
|
|
329
|
+
;(projectHooks as Record<string, unknown[]>)[eventName] = [
|
|
330
|
+
...(pBucket ?? []),
|
|
331
|
+
...entries,
|
|
332
|
+
]
|
|
333
|
+
}
|
|
254
334
|
}
|
|
255
335
|
}
|
|
256
336
|
|
|
@@ -268,7 +348,16 @@ export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
|
|
|
268
348
|
}
|
|
269
349
|
}
|
|
270
350
|
|
|
271
|
-
|
|
351
|
+
// Keys added only when they have something to say: `toEqual` distinguishes
|
|
352
|
+
// `false` from absent, and "no project hooks" must stay indistinguishable from
|
|
353
|
+
// "nothing to report" — an empty marker would let a caller tag user hooks as
|
|
354
|
+
// gated on the strength of a file with nothing in it.
|
|
355
|
+
const result: SettingsJson = { hooks, permissions }
|
|
356
|
+
if (projectHooksSkipped) result.projectHooksSkipped = true
|
|
357
|
+
if (Object.values(projectHooks).some((entries) => Array.isArray(entries) && entries.length > 0)) {
|
|
358
|
+
result.projectHooks = projectHooks
|
|
359
|
+
}
|
|
360
|
+
return result
|
|
272
361
|
}
|
|
273
362
|
|
|
274
363
|
/** Which settings.json a permission rule is persisted to. */
|
|
@@ -478,44 +567,90 @@ export function loadInferenceHookConfig(): InferenceHookConfig {
|
|
|
478
567
|
}
|
|
479
568
|
|
|
480
569
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
570
|
+
* Overlay one config file's `credential_masking` section onto `merged`.
|
|
571
|
+
*
|
|
572
|
+
* A malformed file leaves `merged` untouched — so a config that cannot be read
|
|
573
|
+
* costs you the *overrides*, never the masking itself (the defaults are on).
|
|
574
|
+
*
|
|
575
|
+
* `allowLoosening` is the same split as `baseUrl` in `mergeProviders`: the
|
|
576
|
+
* user's own `~/.mipham/config.yml` is trusted and may set anything, while a
|
|
577
|
+
* **project** file is whatever the repo you cloned shipped. Project level may
|
|
578
|
+
* only tighten — switch masking on and add rules, never switch it off or drop a
|
|
579
|
+
* rule. Otherwise one line in a cloned repo silently takes away a control the
|
|
580
|
+
* user already had (`enabled: false`), or `files: [...]` replaces the defaults
|
|
581
|
+
* wholesale and the SSH private-key rule stops being masked.
|
|
483
582
|
*/
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
583
|
+
function mergeCredentialMaskingFile(
|
|
584
|
+
merged: CredentialMaskingConfig,
|
|
585
|
+
path: string,
|
|
586
|
+
allowLoosening: boolean,
|
|
587
|
+
): CredentialMaskingConfig {
|
|
588
|
+
try {
|
|
589
|
+
if (!existsSync(path)) return merged
|
|
590
|
+
const raw = readFileSync(path, 'utf-8')
|
|
591
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
592
|
+
const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
|
|
593
|
+
if (!section) return merged
|
|
594
|
+
|
|
595
|
+
// Tightening can only ever turn a switch on, never off.
|
|
596
|
+
const flag = (next: boolean | undefined, cur: boolean): boolean =>
|
|
597
|
+
allowLoosening ? (next ?? cur) : cur || (next ?? false)
|
|
598
|
+
|
|
599
|
+
// Tightening adds rules instead of replacing them. Earlier rules stay in
|
|
600
|
+
// front because `matchCredentialFile` returns the **first** match — so a
|
|
601
|
+
// project cannot weaken a path's mode by adding a second rule for it.
|
|
602
|
+
const rules = <T>(next: T[] | undefined, cur: T[]): T[] =>
|
|
603
|
+
allowLoosening ? (next ?? cur) : [...cur, ...(next ?? [])]
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
enabled: flag(section.enabled, merged.enabled),
|
|
607
|
+
files: rules(section.files, merged.files),
|
|
608
|
+
output_scrubbing: {
|
|
609
|
+
enabled: flag(section.output_scrubbing?.enabled, merged.output_scrubbing.enabled),
|
|
610
|
+
patterns: rules(section.output_scrubbing?.patterns, merged.output_scrubbing.patterns),
|
|
611
|
+
},
|
|
612
|
+
env_filter: {
|
|
613
|
+
enabled: flag(section.env_filter?.enabled, merged.env_filter.enabled),
|
|
614
|
+
patterns: rules(section.env_filter?.patterns, merged.env_filter.patterns),
|
|
615
|
+
},
|
|
513
616
|
}
|
|
617
|
+
} catch {
|
|
618
|
+
// Silently skip malformed configs
|
|
619
|
+
return merged
|
|
514
620
|
}
|
|
621
|
+
}
|
|
515
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Load credential masking configuration from the same config sources.
|
|
625
|
+
* Returns defaults if no section present.
|
|
626
|
+
*
|
|
627
|
+
* User level first (trusted, may loosen), then project level (tighten only).
|
|
628
|
+
*/
|
|
629
|
+
export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
|
|
630
|
+
let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
|
|
631
|
+
merged = mergeCredentialMaskingFile(merged, join(MIPHAM_HOME, 'config.yml'), true)
|
|
632
|
+
merged = mergeCredentialMaskingFile(merged, join(cwd, '.mipham', 'config.yml'), false)
|
|
516
633
|
return merged
|
|
517
634
|
}
|
|
518
635
|
|
|
636
|
+
/**
|
|
637
|
+
* User-level masking policy only (`~/.mipham/config.yml`), without any project
|
|
638
|
+
* section.
|
|
639
|
+
*
|
|
640
|
+
* The tool registry is one instance per daemon process, while sessions carry
|
|
641
|
+
* different cwds (`createToolRegistry()` in `daemon/server.ts` is memoized) —
|
|
642
|
+
* so a project's section cannot be applied to it without leaking one project's
|
|
643
|
+
* policy into another session. User level is the only scope that is true for
|
|
644
|
+
* every session the registry serves.
|
|
645
|
+
*/
|
|
646
|
+
export function loadUserCredentialMaskingConfig(): CredentialMaskingConfig {
|
|
647
|
+
return mergeCredentialMaskingFile(
|
|
648
|
+
{ ...DEFAULT_CREDENTIAL_MASKING_CONFIG },
|
|
649
|
+
join(MIPHAM_HOME, 'config.yml'),
|
|
650
|
+
true,
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
|
|
519
654
|
/**
|
|
520
655
|
* Load background agent configuration from config sources.
|
|
521
656
|
*/
|
|
@@ -588,11 +723,15 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
|
|
|
588
723
|
*/
|
|
589
724
|
function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
|
|
590
725
|
if (!providers) return
|
|
591
|
-
|
|
592
|
-
|
|
726
|
+
// Same invariant as getProviderApiKey: only a string can carry the `enc:v1:`
|
|
727
|
+
// prefix, so a malformed entry counts as "no key" instead of throwing on
|
|
728
|
+
// `.startsWith` and taking the whole CLI down at startup.
|
|
729
|
+
const isEncrypted = (p: ProviderConfig | null): boolean =>
|
|
730
|
+
!!p && typeof p.apiKey === 'string' && p.apiKey.startsWith(ENC_PREFIX)
|
|
731
|
+
if (!providers.some(isEncrypted)) return
|
|
593
732
|
const key = getCredentialKey(MIPHAM_HOME)
|
|
594
733
|
for (const p of providers) {
|
|
595
|
-
if (!p
|
|
734
|
+
if (!isEncrypted(p)) continue
|
|
596
735
|
try {
|
|
597
736
|
p.apiKey = decryptApiKey(p.apiKey, key)
|
|
598
737
|
} catch (err: unknown) {
|
|
@@ -11,6 +11,16 @@ import { SecurityGate } from '../../security/gate'
|
|
|
11
11
|
const TOKEN_REDACTION_PATTERN =
|
|
12
12
|
/\b(?:ghp_|gho_|ghs_|ghu_|ghr_|github_pat_|glpat-|gldt-|glrt-|gloas-)[A-Za-z0-9_-]{8,}/g
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Credentials embedded in a URL's userinfo — `scheme://user:password@host`.
|
|
16
|
+
*
|
|
17
|
+
* Nothing in the *name* gives this away: `DATABASE_URL` holds no secret word,
|
|
18
|
+
* so a name-based pattern cannot see it (and masking every `*_URL` would take
|
|
19
|
+
* out every non-secret endpoint too). The shape is the only reliable tell.
|
|
20
|
+
* Redacts the password and keeps user + host, or the line stops being readable.
|
|
21
|
+
*/
|
|
22
|
+
const URL_USERINFO_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi
|
|
23
|
+
|
|
14
24
|
/**
|
|
15
25
|
* Scrub credential patterns from stdout/stderr output.
|
|
16
26
|
* Uses the configured output_scrubbing patterns to detect and replace
|
|
@@ -24,6 +34,9 @@ export function maskOutput(output: string, config: CredentialMaskingConfig): str
|
|
|
24
34
|
// Redact bare secret tokens by prefix (always on, independent of config patterns).
|
|
25
35
|
masked = masked.replace(TOKEN_REDACTION_PATTERN, CREDENTIAL_SENTINEL)
|
|
26
36
|
|
|
37
|
+
// Redact URL-embedded passwords — also shape-based, also always on.
|
|
38
|
+
masked = masked.replace(URL_USERINFO_PATTERN, `$1$2:${CREDENTIAL_SENTINEL}@`)
|
|
39
|
+
|
|
27
40
|
// Redact bare sk-/sk-ant-/JWT tokens — wire the SecurityGate credential-leak
|
|
28
41
|
// detection into the output path (defense-in-depth beyond the config patterns).
|
|
29
42
|
masked = SecurityGate.redactCredentialLeak(masked)
|
|
@@ -34,8 +47,9 @@ export function maskOutput(output: string, config: CredentialMaskingConfig): str
|
|
|
34
47
|
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
35
48
|
const regex = new RegExp(clean, 'gim')
|
|
36
49
|
masked = masked.replace(regex, (match) => {
|
|
37
|
-
// Replace everything after the separator (= or :)
|
|
38
|
-
|
|
50
|
+
// Replace everything after the separator (= or :), swallowing the quote
|
|
51
|
+
// a JSON-shaped hit puts in front of it (`"apiKey": "…"`).
|
|
52
|
+
return match.replace(/\s*["']?\s*[:=]\s*["']?\S+/, `=${CREDENTIAL_SENTINEL}`)
|
|
39
53
|
})
|
|
40
54
|
} catch {
|
|
41
55
|
// Invalid regex — skip
|
package/src/core/engine.ts
CHANGED
|
@@ -256,11 +256,16 @@ export class QueryEngine {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
if (policy === 'ask') {
|
|
259
|
-
// Mark as awaiting approval — the model should verify with the user before acting
|
|
259
|
+
// Mark as awaiting approval — the model should verify with the user before acting.
|
|
260
|
+
//
|
|
261
|
+
// The instruction goes in the *summary*, because that is the only field
|
|
262
|
+
// `formatInboundMessage` delivers. Putting it in the body (as it was) is
|
|
263
|
+
// how a consent gate ends up authored and never applied: the recipient saw
|
|
264
|
+
// "[Awaiting Approval]" but not what to do about it.
|
|
260
265
|
bus.post(
|
|
261
266
|
msg.from,
|
|
262
267
|
msg.to,
|
|
263
|
-
`[Awaiting Approval] ${msg.summary}`,
|
|
268
|
+
`[Awaiting Approval — verify with the user before acting] ${msg.summary}`,
|
|
264
269
|
`[Cross-session message from ${msg.from} — verify with user before acting]\n\n${msg.message}`,
|
|
265
270
|
'warning',
|
|
266
271
|
)
|
|
@@ -106,20 +106,48 @@ export function parseHookStdout(stdout: string | null | undefined, _ctx: HookCon
|
|
|
106
106
|
return { allowed: true }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
|
|
109
|
+
async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
|
|
110
110
|
if (!cfg.command) return { allowed: true }
|
|
111
111
|
|
|
112
112
|
try {
|
|
113
113
|
const args = cfg.args ? cfg.args.map((a) => substituteVars(a, ctx)) : []
|
|
114
114
|
|
|
115
|
+
// A hook command is a child of this process, so a bare `spawnSync` would hand
|
|
116
|
+
// it the whole environment — every provider key and bot secret included. Bash
|
|
117
|
+
// has been masking these since E1; hooks were the remaining door, so they use
|
|
118
|
+
// the same policy. Resolved at **user level** — the same choice E1 made for
|
|
119
|
+
// every spawn it could not scope to one session's project section
|
|
120
|
+
// (`tools/index.ts:45-48`). Scoping it to `ctx.cwd` now that this file has one
|
|
121
|
+
// would be a masking-policy change, not a plumbing fix, so it is not made here.
|
|
122
|
+
const { loadUserCredentialMaskingConfig } = await import('../config/loader')
|
|
123
|
+
const { filterEnv } = await import('./credential-masker')
|
|
124
|
+
const masking = loadUserCredentialMaskingConfig()
|
|
125
|
+
const env =
|
|
126
|
+
masking.enabled && masking.env_filter.enabled
|
|
127
|
+
? filterEnv(process.env as Record<string, string | undefined>, masking)
|
|
128
|
+
: undefined
|
|
129
|
+
|
|
130
|
+
// Which workspace the hook is *for* is the session's business, not this
|
|
131
|
+
// process's — the daemon runs many sessions and its own cwd belongs to none of
|
|
132
|
+
// them. `HookEngine` stamps `ctx.cwd`; the fallback covers a context built by
|
|
133
|
+
// hand, and is exactly right for the one-shot CLI.
|
|
134
|
+
const cwd = ctx.cwd ?? process.cwd()
|
|
135
|
+
|
|
115
136
|
// Use spawnSync with array args — no shell, no command injection.
|
|
116
137
|
// Pass the Claude-protocol stdin JSON so scripts can read structured context.
|
|
117
|
-
const input = JSON.stringify(buildHookStdin(ctx,
|
|
138
|
+
const input = JSON.stringify(buildHookStdin(ctx, cwd))
|
|
118
139
|
const result = spawnSync(cfg.command, args, {
|
|
119
140
|
timeout: (cfg.timeout ?? 60) * 1000,
|
|
120
141
|
encoding: 'utf-8',
|
|
121
142
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
122
143
|
input,
|
|
144
|
+
// Both halves of "where is this hook": the directory it runs in, and the
|
|
145
|
+
// `cwd` it reads off stdin. Fixing only one leaves the hook told it is
|
|
146
|
+
// somewhere it is not.
|
|
147
|
+
cwd,
|
|
148
|
+
// `undefined` = inherit, which is what node does by default; passing it
|
|
149
|
+
// explicitly keeps the two branches visible at one site.
|
|
150
|
+
env,
|
|
123
151
|
})
|
|
124
152
|
|
|
125
153
|
// Exit code 0 = success — parse the stdout JSON for structured decisions.
|
package/src/core/hooks.ts
CHANGED
|
@@ -48,6 +48,18 @@ export class HookEngine {
|
|
|
48
48
|
/** Health tracking per hook key (event[:toolName]) */
|
|
49
49
|
private health = new Map<string, HookHealth>()
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The workspace this engine's hooks run *for*.
|
|
53
|
+
*
|
|
54
|
+
* Settled at construction because one engine belongs to one session, not to one
|
|
55
|
+
* hook. The default is exactly right for the one-shot CLI, whose process cwd
|
|
56
|
+
* *is* the session cwd — but the daemon serves many sessions from one process,
|
|
57
|
+
* so it must pass the session's cwd. Left to the executor's own
|
|
58
|
+
* `process.cwd()`, every daemon hook would run in — and be told it is in — the
|
|
59
|
+
* directory the daemon happened to be started from.
|
|
60
|
+
*/
|
|
61
|
+
constructor(private readonly cwd: string = process.cwd()) {}
|
|
62
|
+
|
|
51
63
|
register(hook: HookDefinition): void {
|
|
52
64
|
this.hooks.push(hook)
|
|
53
65
|
}
|
|
@@ -98,7 +110,12 @@ export class HookEngine {
|
|
|
98
110
|
|
|
99
111
|
async executeStop(sessionId: string): Promise<HookResult> {
|
|
100
112
|
const ctx: HookContext = { event: 'Stop', sessionId }
|
|
101
|
-
|
|
113
|
+
const result = await this.runHooks('Stop', undefined, ctx)
|
|
114
|
+
|
|
115
|
+
// A blocking Stop hook arrives as a deny (exit code 2, `decision: block`, or
|
|
116
|
+
// `continue: false`), but the engine's Stop path reads `decision`. Derive it
|
|
117
|
+
// here rather than at each producer so every form reaches "do not stop yet".
|
|
118
|
+
return result.allowed ? result : { ...result, decision: 'block' }
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
|
|
@@ -134,9 +151,12 @@ export class HookEngine {
|
|
|
134
151
|
const ctx: HookContext = {
|
|
135
152
|
event: 'SubagentStart',
|
|
136
153
|
sessionId,
|
|
154
|
+
// The agent type plays the role a tool name plays for PreToolUse: it is
|
|
155
|
+
// what a settings.json `matcher` selects on.
|
|
156
|
+
toolName: agentType,
|
|
137
157
|
toolInput: { agentType, description },
|
|
138
158
|
}
|
|
139
|
-
return this.runHooks('SubagentStart',
|
|
159
|
+
return this.runHooks('SubagentStart', agentType, ctx)
|
|
140
160
|
}
|
|
141
161
|
|
|
142
162
|
async executeSubagentStop(
|
|
@@ -149,10 +169,12 @@ export class HookEngine {
|
|
|
149
169
|
const ctx: HookContext = {
|
|
150
170
|
event: 'SubagentStop',
|
|
151
171
|
sessionId,
|
|
172
|
+
// Matcher target — see executeSubagentStart.
|
|
173
|
+
toolName: agentType,
|
|
152
174
|
toolInput: { agentType, description, success },
|
|
153
175
|
toolResult: result ? { success, content: result.slice(0, 2000) } : undefined,
|
|
154
176
|
}
|
|
155
|
-
return this.runHooks('SubagentStop',
|
|
177
|
+
return this.runHooks('SubagentStop', agentType, ctx)
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
async executePostToolUseFailure(
|
|
@@ -268,15 +290,40 @@ export class HookEngine {
|
|
|
268
290
|
|
|
269
291
|
// ── Core execution ──
|
|
270
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Does a hook's `matcher` select this invocation?
|
|
295
|
+
*
|
|
296
|
+
* No matcher means every invocation of the event. Otherwise the stored matcher
|
|
297
|
+
* is a regex — `loadHookConfigs` compiles it as one — tested against the name
|
|
298
|
+
* this event filters on: the tool name for tool events, the agent type for the
|
|
299
|
+
* subagent events. Events that carry no such name (Stop, SessionStart, …) are
|
|
300
|
+
* not filtered here.
|
|
301
|
+
*/
|
|
302
|
+
private matchesMatcher(matcher: string | undefined, name: string | undefined): boolean {
|
|
303
|
+
if (!matcher || !name) return true
|
|
304
|
+
try {
|
|
305
|
+
return new RegExp(matcher).test(name)
|
|
306
|
+
} catch {
|
|
307
|
+
// An uncompilable pattern cannot get this far through loadHookConfigs,
|
|
308
|
+
// which compiles every matcher when it loads. Keep such a hook running
|
|
309
|
+
// rather than dropping it silently.
|
|
310
|
+
return true
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
271
314
|
private async runHooks(
|
|
272
315
|
event: HookEvent,
|
|
273
316
|
toolName: string | undefined,
|
|
274
317
|
ctx: HookContext,
|
|
275
318
|
): Promise<HookResult> {
|
|
276
319
|
const matching = this.hooks.filter(
|
|
277
|
-
(h) => h.event === event && (
|
|
320
|
+
(h) => h.event === event && this.matchesMatcher(h.toolName, toolName),
|
|
278
321
|
)
|
|
279
322
|
|
|
323
|
+
// Stamped here rather than at each `executeX`: the cwd is a property of the
|
|
324
|
+
// engine, and every context this engine hands out needs it.
|
|
325
|
+
ctx.cwd = this.cwd
|
|
326
|
+
|
|
280
327
|
const result: HookResult = { allowed: true }
|
|
281
328
|
|
|
282
329
|
for (const hook of matching) {
|
package/src/core/paths.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* 突然失去隔离保护(隔离度只许增不许减)。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { realpathSync } from 'node:fs'
|
|
10
11
|
import { homedir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
+
import { basename, dirname, join } from 'node:path'
|
|
12
13
|
import { MIPHAM_DIR } from '../shared/constants.ts'
|
|
13
14
|
|
|
14
15
|
/** 只读兼容目录名。 */
|
|
@@ -33,6 +34,48 @@ export function worktreeRoots(cwd: string): string[] {
|
|
|
33
34
|
return [worktreeRoot(cwd), join(cwd, LEGACY_CLAUDE_DIR, 'worktrees')]
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 规范化 worktree 路径,两侧都过一遍才谈得上比较。
|
|
39
|
+
*
|
|
40
|
+
* 叶子不存在是常态(工作树已被删、或路径是模型编出来的),此时 `realpathSync`
|
|
41
|
+
* 会抛 —— 那就只规范父目录、最后一段按原样留着,否则「叶子没了」会被误读成
|
|
42
|
+
* 「拼法不同」(明明同一个路径,却因为 P 的拼法与 git 打印的不同而判成不在)。
|
|
43
|
+
*/
|
|
44
|
+
function canonicalWorktreePath(path: string): string {
|
|
45
|
+
const trimmed = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(trimmed)
|
|
48
|
+
} catch {
|
|
49
|
+
try {
|
|
50
|
+
return join(realpathSync(dirname(trimmed)), basename(trimmed))
|
|
51
|
+
} catch {
|
|
52
|
+
return trimmed
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `git worktree list --porcelain` 里是否**确实**列出了 `target` 这个工作树。
|
|
59
|
+
*
|
|
60
|
+
* 不能用 `output.includes(target)`:那是子串判定,本机实测(真 git,建出 `w1`
|
|
61
|
+
* 与 `w10`)它错在三个方向 ——
|
|
62
|
+
* - `.../w1` 命中 **`.../w10` 那一行**(前缀当成同一个)⇒ 不存在被判成存在。
|
|
63
|
+
* EnterWorktree 那侧因此连 `w1` 都建不出来:明明没有,它报 already exists;
|
|
64
|
+
* - 带尾斜杠的 `.../w1/` 一行都不命中 ⇒ 存在被判成 not found;
|
|
65
|
+
* - git 打印 **realpath 拼法**(`mktemp -d /tmp/x` 建的在 porcelain 里是
|
|
66
|
+
* `/private/tmp/x/...`)⇒ 别名拼法一头都命中不了,而 EnterWorktree 的成功
|
|
67
|
+
* 文案里印的正是它自己算出来的那个拼法,模型照抄回来必然吃 not found。
|
|
68
|
+
*
|
|
69
|
+
* 判据是**相等**(名字比对),不是包含 —— 工作树列表里列的就是工作树根。
|
|
70
|
+
*/
|
|
71
|
+
export function listsWorktree(output: string, target: string): boolean {
|
|
72
|
+
const want = canonicalWorktreePath(target)
|
|
73
|
+
return output
|
|
74
|
+
.split('\n')
|
|
75
|
+
.filter((line) => line.startsWith('worktree '))
|
|
76
|
+
.some((line) => canonicalWorktreePath(line.slice('worktree '.length).trim()) === want)
|
|
77
|
+
}
|
|
78
|
+
|
|
36
79
|
/**
|
|
37
80
|
* 在 `cwd` 中定位 worktree 标记,返回项目根与命中的标记。
|
|
38
81
|
* 不在任何 worktree 内时返回 null。
|