@miphamai/cli 0.81.8 → 0.82.0
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/crsi-modify.ts +34 -1
- package/src/core/crsi-producer.ts +77 -7
- package/src/core/crsi-sandbox.ts +65 -0
- package/src/core/engine.ts +7 -2
- package/src/core/eval-harness.ts +77 -4
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/improvement-track.ts +41 -0
- 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 +205 -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/crsi-modify.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
|
-
import { CrsiSandbox, validateBlastRadius } from './crsi-sandbox'
|
|
16
|
+
import { CrsiSandbox, validateBlastRadius, validateMergeConvergence } from './crsi-sandbox'
|
|
17
17
|
import type { CrsiModificationResult } from './crsi-sandbox'
|
|
18
18
|
import { appendEvalScore, getLastEvalScore, regressedAnchors } from './eval-harness'
|
|
19
19
|
import { mechanismSentinel, type RewardFn } from './reward-fn'
|
|
@@ -36,6 +36,19 @@ export interface CrsiProposal {
|
|
|
36
36
|
* 局部正确、全局遗漏。自修改前必须摸清并声明全部受影响路径,否则 fail-closed 拒绝。
|
|
37
37
|
*/
|
|
38
38
|
blastRadius?: string[]
|
|
39
|
+
/**
|
|
40
|
+
* ε:提交者**事前**写下的预期效果(任务表现提升点数)。
|
|
41
|
+
* 缺席 = 不预测。判定见 improvement-track 的 predictionHit。
|
|
42
|
+
*/
|
|
43
|
+
expectedEffect?: number
|
|
44
|
+
/** R:风险声明(这次改动可能在哪方面变差)。缺席 = 未声明。 */
|
|
45
|
+
risk?: string
|
|
46
|
+
/**
|
|
47
|
+
* 声明这是一次**合并型**提案(整合已有内容,而非新增)。
|
|
48
|
+
* 只有它为 true 时 `validateMergeConvergence` 才开火 —— 学习本身就是增长,
|
|
49
|
+
* 对新增型设非增长约束等于永久禁掉 `/crsi propose`。
|
|
50
|
+
*/
|
|
51
|
+
merge?: boolean
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
// ── Pending proposal registry (两阶段闸门) ──
|
|
@@ -70,6 +83,26 @@ export async function runCrsiModification(
|
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
|
|
86
|
+
// B_H 收敛闸:合并型提案不得抬高脚手架成本。
|
|
87
|
+
// 位置与 blast radius 闸同序 —— 都在 worktree 之前,纯字符串比较、零磁盘 I/O、零副作用。
|
|
88
|
+
const convergenceError = validateMergeConvergence(proposal)
|
|
89
|
+
if (convergenceError) {
|
|
90
|
+
return {
|
|
91
|
+
modification: {
|
|
92
|
+
id: 'crsi-mod-rejected-merge-convergence',
|
|
93
|
+
description: proposal.description,
|
|
94
|
+
filePath: proposal.filePath,
|
|
95
|
+
newContent: proposal.newContent,
|
|
96
|
+
originalContent: proposal.originalContent ?? '',
|
|
97
|
+
crsiInsightId: proposal.crsiInsightId,
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
},
|
|
100
|
+
applied: false,
|
|
101
|
+
phase: 'failed',
|
|
102
|
+
error: convergenceError,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
73
106
|
sandbox.createWorktree()
|
|
74
107
|
|
|
75
108
|
const applied = sandbox.applyModification({
|
|
@@ -336,7 +336,7 @@ export async function selectTargetSkill(
|
|
|
336
336
|
return extractFilePath(response, skillFiles)
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
const PROSE_GENERATE_PROMPT_VERSION = '1.
|
|
339
|
+
const PROSE_GENERATE_PROMPT_VERSION = '1.1.0'
|
|
340
340
|
|
|
341
341
|
function buildGenerateProsePrompt(
|
|
342
342
|
signal: CrsiSignal,
|
|
@@ -357,7 +357,12 @@ function buildGenerateProsePrompt(
|
|
|
357
357
|
'当前内容:',
|
|
358
358
|
originalContent,
|
|
359
359
|
'',
|
|
360
|
-
'
|
|
360
|
+
'返回格式(严格遵守,两段):',
|
|
361
|
+
'第 1 行:一行 JSON,写下你对这次改动的**预期效果**与**风险**:',
|
|
362
|
+
'{"expectedDelta": <number 或 null>, "risk": "<字符串>"}',
|
|
363
|
+
'- expectedDelta 是预期该 skill 的任务表现提升**点数**(可正可负;无法预测写 null)。',
|
|
364
|
+
'- risk 是这次改动可能在哪方面变差(一句话)。',
|
|
365
|
+
'第 2 行起:改进后的完整 markdown(保持 YAML frontmatter 的 name/description 字段,正文针对失败信号做针对性改进)。不要用代码围栏包住。',
|
|
361
366
|
].join('\n')
|
|
362
367
|
}
|
|
363
368
|
|
|
@@ -366,16 +371,68 @@ function stripMarkdownFence(text: string): string {
|
|
|
366
371
|
return match ? match[1]! : text
|
|
367
372
|
}
|
|
368
373
|
|
|
374
|
+
/** prose 提议的解析产物:正文 + 可选的事前预登记(ε 与风险声明)。 */
|
|
375
|
+
export interface ProsePrediction {
|
|
376
|
+
body: string
|
|
377
|
+
/** ε:事前写下的预期提升点数。缺席 = 模型没预测(含显式写 null)。 */
|
|
378
|
+
expectedEffect?: number
|
|
379
|
+
/** R:风险声明。缺席 = 未声明。 */
|
|
380
|
+
risk?: string
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 解析 prose 响应:可选的一行 JSON 前缀(ε)+ 正文。
|
|
385
|
+
*
|
|
386
|
+
* 顺序是**先归一化、后嗅探**(不可颠倒):stripMarkdownFence 的正则锚在串首
|
|
387
|
+
* (/^```(?:markdown|md)?\s*\n…\n```\s*$/)。若先剥「首行围栏」再嗅探,正文尾部的
|
|
388
|
+
* 那个 ``` 就再没有东西去剥它 ⇒ 孤立的尾部围栏会进入写盘路径。
|
|
389
|
+
*
|
|
390
|
+
* 认领标记是**含 `expectedDelta` 键**(盖住 number 与显式 null 两种写法);
|
|
391
|
+
* 其余任何情况都走兜底 —— 正文 = 归一化后的原文,一字不改。
|
|
392
|
+
*/
|
|
393
|
+
export function parseProsePrediction(raw: string): ProsePrediction {
|
|
394
|
+
const stripped = stripMarkdownFence(raw)
|
|
395
|
+
const lines = stripped.split('\n')
|
|
396
|
+
const firstIdx = lines.findIndex((l) => l.trim() !== '')
|
|
397
|
+
if (firstIdx === -1) return { body: stripped }
|
|
398
|
+
|
|
399
|
+
let parsed: unknown
|
|
400
|
+
try {
|
|
401
|
+
parsed = JSON.parse(lines[firstIdx]!.trim())
|
|
402
|
+
} catch {
|
|
403
|
+
return { body: stripped } // 首行不是 JSON → 兜底
|
|
404
|
+
}
|
|
405
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
406
|
+
return { body: stripped }
|
|
407
|
+
}
|
|
408
|
+
const rec = parsed as { expectedDelta?: unknown; risk?: unknown }
|
|
409
|
+
if (!('expectedDelta' in rec)) return { body: stripped } // 不带 ε 的 JSON 不吃
|
|
410
|
+
|
|
411
|
+
const expectedEffect = typeof rec.expectedDelta === 'number' ? rec.expectedDelta : undefined
|
|
412
|
+
const risk = typeof rec.risk === 'string' ? rec.risk : undefined
|
|
413
|
+
// 剥掉 JSON 行本身 + 紧随其后的空行
|
|
414
|
+
const body = lines
|
|
415
|
+
.slice(firstIdx + 1)
|
|
416
|
+
.join('\n')
|
|
417
|
+
.replace(/^[ \t]*\n/, '')
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
body,
|
|
421
|
+
...(expectedEffect !== undefined ? { expectedEffect } : {}),
|
|
422
|
+
...(risk !== undefined ? { risk } : {}),
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
369
426
|
export async function generateProseContent(
|
|
370
427
|
signal: CrsiSignal,
|
|
371
428
|
llm: Llm,
|
|
372
429
|
filePath: string,
|
|
373
430
|
originalContent: string,
|
|
374
|
-
): Promise<
|
|
431
|
+
): Promise<ProsePrediction | null> {
|
|
375
432
|
const prompt = buildGenerateProsePrompt(signal, filePath, originalContent)
|
|
376
433
|
const response = await collectLlmText(llm, prompt)
|
|
377
434
|
if (!response) return null
|
|
378
|
-
return
|
|
435
|
+
return parseProsePrediction(response)
|
|
379
436
|
}
|
|
380
437
|
|
|
381
438
|
export interface ProseProposalResult {
|
|
@@ -383,6 +440,10 @@ export interface ProseProposalResult {
|
|
|
383
440
|
newContent: string
|
|
384
441
|
originalContent: string
|
|
385
442
|
description: string
|
|
443
|
+
/** ε:由模型在正文之前写下(见 parseProsePrediction)。 */
|
|
444
|
+
expectedEffect?: number
|
|
445
|
+
/** R:风险声明。 */
|
|
446
|
+
risk?: string
|
|
386
447
|
}
|
|
387
448
|
|
|
388
449
|
export async function produceProseProposal(
|
|
@@ -401,10 +462,17 @@ export async function produceProseProposal(
|
|
|
401
462
|
return null
|
|
402
463
|
}
|
|
403
464
|
|
|
404
|
-
const
|
|
405
|
-
if (!
|
|
465
|
+
const generated = await generateProseContent(signal, llm, filePath, originalContent)
|
|
466
|
+
if (!generated || !generated.body) return null
|
|
406
467
|
|
|
407
|
-
return {
|
|
468
|
+
return {
|
|
469
|
+
filePath,
|
|
470
|
+
newContent: generated.body,
|
|
471
|
+
originalContent,
|
|
472
|
+
description: signal.title,
|
|
473
|
+
...(generated.expectedEffect !== undefined ? { expectedEffect: generated.expectedEffect } : {}),
|
|
474
|
+
...(generated.risk !== undefined ? { risk: generated.risk } : {}),
|
|
475
|
+
}
|
|
408
476
|
}
|
|
409
477
|
|
|
410
478
|
const SKILL_DIRS: Array<[string, string]> = [
|
|
@@ -582,6 +650,7 @@ export async function produceCrossoverProposal(
|
|
|
582
650
|
newContent: string
|
|
583
651
|
originalContent: string
|
|
584
652
|
blastRadius: string[]
|
|
653
|
+
merge: boolean
|
|
585
654
|
} | null> {
|
|
586
655
|
const response = await collectLlmText(llm, buildCrossoverPrompt(currentLessons))
|
|
587
656
|
if (!response) return null
|
|
@@ -607,5 +676,6 @@ export async function produceCrossoverProposal(
|
|
|
607
676
|
newContent,
|
|
608
677
|
originalContent: currentLessons,
|
|
609
678
|
blastRadius: [LESSONS_FILE],
|
|
679
|
+
merge: true,
|
|
610
680
|
}
|
|
611
681
|
}
|