@miphamai/cli 0.81.7 → 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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/bin/mipham.ts +35 -1
  3. package/package.json +1 -1
  4. package/src/agent/message-bus.ts +10 -3
  5. package/src/agent/sub-agent.ts +60 -12
  6. package/src/agent/types.ts +14 -1
  7. package/src/artifacts/manifest.ts +90 -34
  8. package/src/artifacts/paths.ts +19 -0
  9. package/src/artifacts/server.ts +48 -8
  10. package/src/config/credential-crypto.ts +28 -5
  11. package/src/config/defaults.ts +18 -10
  12. package/src/config/keys-manager.ts +14 -9
  13. package/src/config/loader.ts +202 -63
  14. package/src/config/preferences.ts +5 -2
  15. package/src/core/credential-masker/output-scrub.ts +16 -2
  16. package/src/core/cron-poller.ts +30 -6
  17. package/src/core/engine.ts +7 -2
  18. package/src/core/hooks-executor.ts +30 -2
  19. package/src/core/hooks.ts +51 -4
  20. package/src/core/paths.ts +44 -1
  21. package/src/core/permission-config.ts +146 -14
  22. package/src/core/permission-rules.ts +157 -6
  23. package/src/core/permission.ts +81 -13
  24. package/src/core/rules-loader.ts +35 -5
  25. package/src/core/session-log.ts +49 -2
  26. package/src/core/session-store.ts +11 -1
  27. package/src/core/workspace-trust.ts +42 -4
  28. package/src/daemon/auth.ts +15 -14
  29. package/src/daemon/engine-capabilities.ts +12 -2
  30. package/src/daemon/remote-engine.ts +9 -4
  31. package/src/daemon/server.ts +29 -1
  32. package/src/daemon/session-worker.ts +15 -0
  33. package/src/i18n-core/locales/en-US.json +12 -8
  34. package/src/i18n-core/locales/zh-CN.json +12 -8
  35. package/src/index.tsx +47 -19
  36. package/src/mcp/client.ts +24 -0
  37. package/src/mcp/http-transport.ts +35 -3
  38. package/src/plugin/plugin-manager.ts +30 -8
  39. package/src/providers/anthropic.ts +74 -13
  40. package/src/providers/openai-compat.ts +14 -1
  41. package/src/security/gate.ts +18 -0
  42. package/src/security/path.ts +25 -2
  43. package/src/shared/arg-validation.ts +37 -2
  44. package/src/shared/atomic-write.ts +28 -5
  45. package/src/shared/package-info.ts +1 -1
  46. package/src/shared/sanitize.ts +27 -2
  47. package/src/shared/types.ts +17 -0
  48. package/src/shared/update.ts +22 -5
  49. package/src/tools/agent/agent.ts +3 -0
  50. package/src/tools/artifact/artifact.ts +14 -4
  51. package/src/tools/exec/bash.ts +146 -24
  52. package/src/tools/exec/enter-worktree.ts +9 -3
  53. package/src/tools/exec/exit-worktree.ts +6 -3
  54. package/src/tools/exec/git.ts +83 -3
  55. package/src/tools/file/glob.ts +19 -3
  56. package/src/tools/file/grep.ts +70 -16
  57. package/src/tools/file/read.ts +151 -45
  58. package/src/tools/index.ts +12 -4
  59. package/src/tools/scheduling/cron.ts +34 -5
  60. package/src/tools/system/config.ts +6 -2
  61. package/src/ui/app.tsx +47 -11
  62. package/src/ui/commands.ts +187 -41
  63. package/src/workflow/primitives/agent.ts +4 -0
  64. package/src/artifacts/versioning.ts +0 -127
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
+ import { atomicWriteFileSync } from '../shared/atomic-write'
4
5
  import { saveProviderApiKey } from './loader'
5
6
 
6
7
  const MIPHAM_HOME = join(homedir(), '.mipham')
@@ -31,7 +32,13 @@ function loadKeys(): KeysData {
31
32
  if (!existsSync(KEYS_FILE)) return {}
32
33
  try {
33
34
  const raw = readFileSync(KEYS_FILE, 'utf-8')
34
- return JSON.parse(raw) as KeysData
35
+ const parsed: unknown = JSON.parse(raw)
36
+ // Valid JSON is not necessarily a key map. `null` throws straight out of
37
+ // `Object.entries` in `list()` — i.e. on the startup path, not in some corner
38
+ // — and an array or a scalar yields entries with none of the fields, which
39
+ // reads as "not expired" instead of as corrupt.
40
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
41
+ return parsed as KeysData
35
42
  } catch {
36
43
  return {}
37
44
  }
@@ -39,14 +46,12 @@ function loadKeys(): KeysData {
39
46
 
40
47
  function saveKeys(data: KeysData): void {
41
48
  mkdirSync(dirname(KEYS_FILE), { recursive: true })
42
- const tmp = KEYS_FILE + '.tmp'
43
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
44
- writeFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
45
- try {
46
- chmodSync(KEYS_FILE, 0o600)
47
- } catch {
48
- // chmod on Windows is a no-op
49
- }
49
+ // 从前这里「转了一半」:先写一个固定名的 `.tmp`,**然后不 rename、直接再写一遍
50
+ // 目标**。留在磁盘上的 `.tmp` 是废物,目标仍然非原子 —— 并发 `/keys rotate` 撞进
51
+ // 同一个临时名,或写到一半被打断,`loadKeys` 把不可解析的 JSON 吞成 `{}`
52
+ // (见上面的 catch)⇒ 全部轮换元数据静默消失。atomicWriteFileSync 自己写唯一名
53
+ // 临时文件再 rename,两者一起解决。
54
+ atomicWriteFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
50
55
  }
51
56
 
52
57
  function daysSince(iso: string): number {
@@ -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: op.apiKey ?? base.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: MiphamConfig = { ...base, ...override }
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
- servers.push({
203
- name,
204
- command: cfg.command,
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(cwd: string = process.cwd()): SettingsJson {
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 searchPaths = [join(cwd, '.mipham', 'settings.json'), join(MIPHAM_HOME, 'settings.json')]
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 (parsed.hooks && typeof parsed.hooks === 'object') {
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
- return { hooks, permissions }
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
- * Load credential masking configuration from the same config sources.
482
- * Merges project-level over user-level. Returns defaults if no section present.
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
- export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
485
- const configPath = join(cwd, '.mipham', 'config.yml')
486
- const userConfigPath = join(MIPHAM_HOME, 'config.yml')
487
-
488
- let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
489
-
490
- const paths = [userConfigPath, configPath] // project wins (loaded last)
491
- for (const path of paths) {
492
- try {
493
- if (!existsSync(path)) continue
494
- const raw = readFileSync(path, 'utf-8')
495
- const parsed = parseYaml(raw) as Record<string, unknown>
496
- const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
497
- if (section) {
498
- merged = {
499
- enabled: section.enabled ?? merged.enabled,
500
- files: section.files ?? merged.files,
501
- output_scrubbing: {
502
- enabled: section.output_scrubbing?.enabled ?? merged.output_scrubbing.enabled,
503
- patterns: section.output_scrubbing?.patterns ?? merged.output_scrubbing.patterns,
504
- },
505
- env_filter: {
506
- enabled: section.env_filter?.enabled ?? merged.env_filter.enabled,
507
- patterns: section.env_filter?.patterns ?? merged.env_filter.patterns,
508
- },
509
- }
510
- }
511
- } catch {
512
- // Silently skip malformed configs
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
- const needsKey = providers.some((p) => p.apiKey.startsWith(ENC_PREFIX))
592
- if (!needsKey) return
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.apiKey.startsWith(ENC_PREFIX)) continue
734
+ if (!isEncrypted(p)) continue
596
735
  try {
597
736
  p.apiKey = decryptApiKey(p.apiKey, key)
598
737
  } catch (err: unknown) {
@@ -5,9 +5,10 @@
5
5
  * NOT for config.yml settings — those belong in the YAML config system.
6
6
  * NOT for secrets — this file is plain JSON, not encrypted.
7
7
  */
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
8
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
10
  import { homedir } from 'node:os'
11
+ import { atomicWriteFileSync } from '../shared/atomic-write'
11
12
 
12
13
  const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
13
14
 
@@ -27,7 +28,9 @@ function writePrefs(prefs: Record<string, string>): void {
27
28
  try {
28
29
  const dir = join(homedir(), '.mipham')
29
30
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
30
- writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600, encoding: 'utf-8' })
31
+ // 原子写:裸 writeFileSync 原地截断,崩在写中途就留下一份不可解析的文件,
32
+ // 而 readPrefs 把不可解析吞成「空」⇒ **全部**偏好静默消失(不是丢一项)。
33
+ atomicWriteFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600 })
31
34
  } catch {
32
35
  // best-effort; never crash because preferences failed to save
33
36
  }
@@ -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
- return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
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
@@ -9,9 +9,22 @@ import { computeNextFire } from './cron'
9
9
  import type { CronJob } from '../tools/scheduling/cron'
10
10
  import { readAllJobs, writeJob, deleteJobFile } from '../tools/scheduling/cron'
11
11
 
12
- /** Jobs whose nextFire is at or before `now`. Pure — separated for tests. */
13
- export function findDueJobs(jobs: CronJob[], now: Date): CronJob[] {
14
- return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime())
12
+ /**
13
+ * Whether a job belongs to `cwd`.
14
+ *
15
+ * A job with no `cwd` is from a file written before jobs carried one; it matches
16
+ * anywhere so an existing user's schedule keeps firing instead of going silent.
17
+ * `cwd === undefined` means the caller did not ask for scoping at all (the pure
18
+ * helpers' existing callers), so nothing is filtered.
19
+ */
20
+ function matchesCwd(job: CronJob, cwd?: string): boolean {
21
+ if (job.cwd === undefined || cwd === undefined) return true
22
+ return job.cwd === cwd
23
+ }
24
+
25
+ /** Jobs whose nextFire is at or before `now` — and which belong to `cwd`. */
26
+ export function findDueJobs(jobs: CronJob[], now: Date, cwd?: string): CronJob[] {
27
+ return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime() && matchesCwd(j, cwd))
15
28
  }
16
29
 
17
30
  /** Next state after firing a due job: recurring advances; one-shot → null (delete). */
@@ -24,9 +37,20 @@ export function advanceJob(job: CronJob, now: Date): CronJob | null {
24
37
  }
25
38
  }
26
39
 
27
- /** Read due jobs, enqueue their prompts, and advance/delete. Returns fired count. */
28
- export function checkCronJobs(enqueue: (prompt: string) => void, now = new Date()): number {
29
- const due = findDueJobs(readAllJobs(), now)
40
+ /**
41
+ * Read due jobs, enqueue their prompts, and advance/delete. Returns fired count.
42
+ *
43
+ * `cwd` defaults to the process's working directory — the same source
44
+ * `ToolContext.cwd` comes from — because the enqueued prompt lands in *this*
45
+ * session and is executed here. Without the filter, a schedule created in one
46
+ * project would be run by whatever session happened to be open in another.
47
+ */
48
+ export function checkCronJobs(
49
+ enqueue: (prompt: string) => void,
50
+ now = new Date(),
51
+ cwd = process.cwd(),
52
+ ): number {
53
+ const due = findDueJobs(readAllJobs(), now, cwd)
30
54
  for (const job of due) {
31
55
  enqueue(job.prompt)
32
56
  const next = advanceJob(job, now)
@@ -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, process.cwd()))
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.