@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.
- package/README.md +1 -1
- 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/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +14 -9
- package/src/config/loader.ts +202 -63
- package/src/config/preferences.ts +5 -2
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/cron-poller.ts +30 -6
- 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 +157 -6
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +49 -2
- package/src/core/session-store.ts +11 -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/daemon/session-worker.ts +15 -0
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +47 -19
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +30 -8
- package/src/providers/anthropic.ts +74 -13
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +25 -2
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +17 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +146 -24
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +83 -3
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +70 -16
- package/src/tools/file/read.ts +151 -45
- package/src/tools/index.ts +12 -4
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +187 -41
- package/src/workflow/primitives/agent.ts +4 -0
- 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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 {
|
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) {
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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/cron-poller.ts
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
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)
|
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.
|