@miphamai/cli 0.38.0 → 0.39.1

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 (62) hide show
  1. package/bin/mipham.ts +25 -0
  2. package/package.json +1 -1
  3. package/src/agent/sub-agent.ts +4 -3
  4. package/src/config/defaults.ts +19 -1
  5. package/src/config/keys-manager.ts +11 -1
  6. package/src/config/loader.ts +20 -10
  7. package/src/core/context.ts +86 -56
  8. package/src/core/credential-masker/output-scrub.ts +5 -0
  9. package/src/core/crsi-sandbox.ts +19 -7
  10. package/src/core/engine.ts +50 -33
  11. package/src/core/instructions.ts +4 -42
  12. package/src/core/permission.ts +7 -1
  13. package/src/core/red-team.ts +4 -4
  14. package/src/core/self-critique.ts +3 -1
  15. package/src/core/session-log.ts +225 -0
  16. package/src/core/session-store.ts +119 -34
  17. package/src/daemon/auth.ts +9 -6
  18. package/src/daemon/cors.ts +24 -16
  19. package/src/daemon/rate-limiter.ts +4 -0
  20. package/src/daemon/server.ts +24 -3
  21. package/src/index.tsx +54 -28
  22. package/src/mcp/http-transport.ts +2 -0
  23. package/src/mcp/oauth.ts +1 -1
  24. package/src/providers/llm-replay.ts +36 -0
  25. package/src/providers/llm.ts +16 -0
  26. package/src/providers/registry.ts +2 -1
  27. package/src/security/gate.ts +10 -0
  28. package/src/security/url.ts +37 -14
  29. package/src/shared/constants.ts +40 -54
  30. package/src/shared/mipham-models.json +50 -0
  31. package/src/shared/types.ts +53 -49
  32. package/src/skills/fork-executor.ts +3 -1
  33. package/src/skills/loader.ts +2 -1
  34. package/src/skills/seam.ts +18 -0
  35. package/src/tools/agent/agent.ts +1 -0
  36. package/src/tools/agent/skill.ts +1 -0
  37. package/src/tools/exec/bash.ts +140 -128
  38. package/src/tools/exec/git.ts +17 -0
  39. package/src/tools/file/read.ts +73 -66
  40. package/src/tools/file/write.ts +1 -1
  41. package/src/tools/index.ts +54 -132
  42. package/src/tools/network/web-fetch.ts +41 -26
  43. package/src/tools/seam.ts +25 -0
  44. package/src/tools/validation.ts +91 -0
  45. package/src/ui/commands.ts +17 -5
  46. package/src/ui/input.tsx +8 -1
  47. package/src/vajra/compose/assemble.ts +19 -0
  48. package/src/vajra/compose/bundle.ts +39 -0
  49. package/src/vajra/compose/dump.ts +5 -0
  50. package/src/vajra/compose/index.ts +4 -0
  51. package/src/vajra/compose/mount.ts +30 -0
  52. package/src/vajra/context.ts +183 -0
  53. package/src/vajra/events.ts +13 -0
  54. package/src/vajra/index.ts +4 -0
  55. package/src/vajra/leaf/plan-runner.ts +94 -0
  56. package/src/vajra/service.ts +15 -0
  57. package/src/workflow/primitives/agent.ts +3 -1
  58. package/src/workflow/runtime.ts +23 -15
  59. package/src/workflow/sandbox.ts +51 -9
  60. package/src/core/compaction-progress.ts +0 -205
  61. package/src/core/context-compact.ts +0 -124
  62. package/src/core/context-drain.ts +0 -113
package/bin/mipham.ts CHANGED
@@ -1144,6 +1144,7 @@ Usage:
1144
1144
  mipham --help Show this help
1145
1145
 
1146
1146
  Flags:
1147
+ --dump-config Print the assembled profile tree
1147
1148
  --safe-mode Skip custom agents, skills, hooks, plugins
1148
1149
  --version, -v, -V Print version
1149
1150
 
@@ -1152,6 +1153,30 @@ npm: https://www.npmjs.com/package/@miphamai/cli`)
1152
1153
  process.exit(0)
1153
1154
  }
1154
1155
 
1156
+ // ── Dump config flag ───────────────────────────────────────────────────────
1157
+ if (process.argv.includes('--dump-config')) {
1158
+ const { homedir } = await import('node:os')
1159
+ const { join } = await import('node:path')
1160
+ const { existsSync } = await import('node:fs')
1161
+ const { loadProfile, loadBundle, assemble, dumpConfig } = await import('../src/vajra/compose')
1162
+
1163
+ const profileIdx = process.argv.indexOf('--profile')
1164
+ const profileName =
1165
+ profileIdx !== -1 && process.argv[profileIdx + 1] ? process.argv[profileIdx + 1]! : 'default'
1166
+ const dir = join(homedir(), '.mipham', 'profiles')
1167
+ const profilePath = join(dir, `${profileName}.yml`)
1168
+
1169
+ if (!existsSync(profilePath)) {
1170
+ console.error(`Profile "${profileName}" not found at ${profilePath}`)
1171
+ process.exit(1)
1172
+ }
1173
+
1174
+ const profile = loadProfile(profilePath)
1175
+ const resolveBundle = (name: string) => loadBundle(join(dir, `${name}.yml`))
1176
+ console.log(dumpConfig(assemble(profile, resolveBundle)))
1177
+ process.exit(0)
1178
+ }
1179
+
1155
1180
  // ── Update / upgrade ──────────────────────────────────────────────────────
1156
1181
  const handledUpdate = await runUpdate()
1157
1182
  if (handledUpdate) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,4 +1,5 @@
1
1
  import type { ProviderRegistry } from '../providers/registry'
2
+ import type { Llm } from '../providers/llm'
2
3
  import type { ToolDefinition } from '../shared/index.ts'
3
4
  import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
4
5
  import { createAgentContext } from './agent-context'
@@ -44,6 +45,7 @@ export class SubAgent {
44
45
  private permission?: PermissionSystem,
45
46
  private hookEngine?: HookEngine,
46
47
  private ruleEngine?: ExperienceRuleEngine,
48
+ private llm?: Llm,
47
49
  ) {}
48
50
 
49
51
  /**
@@ -189,8 +191,7 @@ export class SubAgent {
189
191
  options: SubAgentOptions,
190
192
  signal?: AbortSignal,
191
193
  ): Promise<string> {
192
- const provider = this.registry.getActive()
193
- if (!provider) {
194
+ if (!this.registry.getActive()) {
194
195
  throw new Error('No active provider available for sub-agent execution')
195
196
  }
196
197
 
@@ -332,7 +333,7 @@ export class SubAgent {
332
333
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
333
334
  let turnText = ''
334
335
 
335
- for await (const chunk of provider.chat({
336
+ for await (const chunk of (this.llm ?? this.registry).chat({
336
337
  model: finalModel,
337
338
  messages: currentMessages,
338
339
  systemPrompt: currentSystemPrompt,
@@ -41,7 +41,17 @@ export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
41
41
 
42
42
  export const DEFAULT_CREDENTIAL_MASKING_CONFIG: CredentialMaskingConfig = {
43
43
  enabled: true,
44
- files: [],
44
+ // Default sensitive-file rules: full-mask any of these before the model sees them.
45
+ files: [
46
+ { path: '**/.env*', mode: 'full' },
47
+ { path: '**/.aws/credentials', mode: 'full' },
48
+ { path: '**/.aws/config', mode: 'full' },
49
+ { path: '**/.ssh/id_*', mode: 'full' },
50
+ { path: '**/*.pem', mode: 'full' },
51
+ { path: '**/*.key', mode: 'full' },
52
+ { path: '**/.netrc', mode: 'full' },
53
+ { path: '**/credentials.*', mode: 'full' },
54
+ ],
45
55
  output_scrubbing: {
46
56
  enabled: true,
47
57
  patterns: ['(?i)(api[_-]?key|secret|token|password|credential)\\s*[:=]\\s*\\S+'],
@@ -52,6 +62,14 @@ export const DEFAULT_CREDENTIAL_MASKING_CONFIG: CredentialMaskingConfig = {
52
62
  },
53
63
  }
54
64
 
65
+ /** 掩码中立配置:Read/Bash 照常挂载,但不启用任何凭据掩码(对齐 pre-seam 无参行为)。 */
66
+ export const DISABLED_CREDENTIAL_MASKING_CONFIG: CredentialMaskingConfig = {
67
+ enabled: false,
68
+ files: [],
69
+ output_scrubbing: { enabled: false, patterns: [] },
70
+ env_filter: { enabled: false, patterns: [] },
71
+ }
72
+
55
73
  export const DEFAULT_BACKGROUND_AGENT_CONFIG: BackgroundAgentConfig = {
56
74
  auto_commit: true,
57
75
  auto_push: true,
@@ -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 { saveProviderApiKey } from './loader'
4
5
 
5
6
  const MIPHAM_DIR = join(homedir(), '.mipham')
6
7
  const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
@@ -67,7 +68,16 @@ export class KeyManager {
67
68
  }))
68
69
  }
69
70
 
70
- rotate(provider: string, _newKey: string): { success: boolean; message: string } {
71
+ rotate(provider: string, newKey: string): { success: boolean; message: string } {
72
+ // Persist the new key first — the actual rotation. Metadata below is only
73
+ // bookkeeping; without writing the key, "rotate" would be a silent no-op.
74
+ if (!saveProviderApiKey(provider, newKey)) {
75
+ return {
76
+ success: false,
77
+ message: `Rotation aborted: failed to persist new key for "${provider}".`,
78
+ }
79
+ }
80
+
71
81
  const data = loadKeys()
72
82
  const now = new Date().toISOString()
73
83
  const existing = data[provider]
@@ -58,6 +58,7 @@ function safeParseYaml(path: string, label: string): Partial<MiphamConfig> | nul
58
58
  function mergeProviders(
59
59
  baseProviders: ProviderConfig[],
60
60
  overrideProviders: ProviderConfig[],
61
+ allowBaseUrlOverride: boolean,
61
62
  ): ProviderConfig[] {
62
63
  const merged = [...baseProviders]
63
64
 
@@ -75,7 +76,10 @@ function mergeProviders(
75
76
  id: base.id,
76
77
  name: op.name || base.name,
77
78
  protocol: op.protocol || base.protocol,
78
- baseUrl: op.baseUrl ?? base.baseUrl,
79
+ // baseUrl is a routing field — it decides where the user's API key is
80
+ // sent, so only trusted (user-level) config may override it. Untrusted
81
+ // (project-level) config cannot redirect a built-in provider's traffic.
82
+ baseUrl: allowBaseUrlOverride ? (op.baseUrl ?? base.baseUrl) : base.baseUrl,
79
83
  apiKey: op.apiKey ?? base.apiKey,
80
84
  models: op.models?.length ? op.models : base.models,
81
85
  status: op.status ?? base.status,
@@ -85,10 +89,14 @@ function mergeProviders(
85
89
  return merged
86
90
  }
87
91
 
88
- function mergeConfig(base: MiphamConfig, override: Partial<MiphamConfig>): MiphamConfig {
92
+ function mergeConfig(
93
+ base: MiphamConfig,
94
+ override: Partial<MiphamConfig>,
95
+ allowBaseUrlOverride: boolean,
96
+ ): MiphamConfig {
89
97
  const merged: MiphamConfig = { ...base, ...override }
90
98
  if (override.providers) {
91
- merged.providers = mergeProviders(base.providers, override.providers)
99
+ merged.providers = mergeProviders(base.providers, override.providers, allowBaseUrlOverride)
92
100
  } else {
93
101
  merged.providers = base.providers
94
102
  }
@@ -216,7 +224,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
216
224
  // ── Load project-level config ──
217
225
  const projectConfig = safeParseYaml(configPath, 'project config')
218
226
  if (projectConfig) {
219
- config = mergeConfig(config, projectConfig)
227
+ config = mergeConfig(config, projectConfig, false)
220
228
  } else if (existsSync(configPath)) {
221
229
  // File exists but failed to parse — try to restore from backup
222
230
  process.stderr.write(`⚠ Mipham Code: project config is corrupted, attempting recovery...\n`)
@@ -226,7 +234,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
226
234
  // Retry parsing after restore
227
235
  const restored = safeParseYaml(configPath, 'restored project config')
228
236
  if (restored) {
229
- config = mergeConfig(config, restored)
237
+ config = mergeConfig(config, restored, false)
230
238
  }
231
239
  }
232
240
  }
@@ -234,7 +242,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
234
242
  // ── Load user-level config ──
235
243
  const userConfig = safeParseYaml(userConfigPath, 'user config')
236
244
  if (userConfig) {
237
- config = mergeConfig(config, userConfig)
245
+ config = mergeConfig(config, userConfig, true)
238
246
  } else if (existsSync(userConfigPath)) {
239
247
  // File exists but failed to parse — try to restore from backup
240
248
  process.stderr.write(`⚠ Mipham Code: user config is corrupted, attempting recovery...\n`)
@@ -244,7 +252,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
244
252
  // Retry parsing after restore
245
253
  const restored = safeParseYaml(userConfigPath, 'restored user config')
246
254
  if (restored) {
247
- config = mergeConfig(config, restored)
255
+ config = mergeConfig(config, restored, true)
248
256
  }
249
257
  }
250
258
  } else {
@@ -286,13 +294,15 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
286
294
  *
287
295
  * Returns default (disabled) config if no inference_hooks section is present.
288
296
  */
289
- export function loadInferenceHookConfig(cwd: string = process.cwd()): InferenceHookConfig {
290
- const configPath = join(cwd, '.mipham', 'config.yml')
297
+ export function loadInferenceHookConfig(): InferenceHookConfig {
298
+ // DLP is a user/org-level security setting — the endpoint controls where the
299
+ // entire conversation is sent, so a project config must NOT be able to
300
+ // redirect it (exfiltration). Read only from the user-level config.
291
301
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
292
302
 
293
303
  let merged = { ...DEFAULT_INFERENCE_HOOK_CONFIG }
294
304
 
295
- const paths = [userConfigPath, configPath] // project wins (loaded last)
305
+ const paths = [userConfigPath]
296
306
  for (const path of paths) {
297
307
  try {
298
308
  if (!existsSync(path)) continue
@@ -1,9 +1,14 @@
1
- import type { Message } from '../shared/index.ts'
1
+ import type { Message, ToolResult } from '../shared/index.ts'
2
2
  import { snipMessages } from './context-snip'
3
3
  import { microcompact } from './context-microcompact'
4
- import { reactiveCompact } from './context-compact'
5
- import { emergencyDrain } from './context-drain'
6
4
  import { NoopCacheTracker, type CacheTracker, type CacheStatus } from './context-token'
5
+ import {
6
+ SessionLog,
7
+ messageToEvents,
8
+ deriveMessages,
9
+ assertModelVisible,
10
+ isAssertModelVisibleDebug,
11
+ } from './session-log'
7
12
 
8
13
  export type Summarizer = (messages: Message[], heading: string) => Promise<string>
9
14
 
@@ -18,9 +23,6 @@ export interface CompactionStats {
18
23
  snipMessagesRemoved: number
19
24
  microcompactCount: number
20
25
  microcompactTokensSaved: number
21
- compactCount: number
22
- compactTokensSaved: number
23
- drainCount: number
24
26
  lastCompaction: Date | null
25
27
  }
26
28
 
@@ -47,9 +49,6 @@ export class ContextManager {
47
49
  snipMessagesRemoved: 0,
48
50
  microcompactCount: 0,
49
51
  microcompactTokensSaved: 0,
50
- compactCount: 0,
51
- compactTokensSaved: 0,
52
- drainCount: 0,
53
52
  lastCompaction: null,
54
53
  }
55
54
  private compressionPending = false
@@ -58,6 +57,24 @@ export class ContextManager {
58
57
  this.calculateThresholds()
59
58
  }
60
59
 
60
+ private log?: SessionLog
61
+
62
+ /** 附加一个 append-only 会话日志;addMessage/seedMessages 将写通镜像到日志。 */
63
+ setLog(log?: SessionLog): void {
64
+ this.log = log
65
+ }
66
+
67
+ getLog(): SessionLog | undefined {
68
+ return this.log
69
+ }
70
+
71
+ /** 从已持久化的日志恢复:设 log 为源,messages 为投影(不重复写通)。 */
72
+ restoreLog(log: SessionLog): void {
73
+ this.log = log
74
+ this.messages = deriveMessages(log.events())
75
+ this.reEstimateTokens()
76
+ }
77
+
61
78
  /**
62
79
  * Calculate adaptive compaction thresholds based on the model's context window.
63
80
  *
@@ -112,10 +129,36 @@ export class ContextManager {
112
129
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
113
130
  )
114
131
 
132
+ if (this.log) {
133
+ for (const e of messageToEvents(msg, Date.now())) this.log.append(e)
134
+ }
135
+
136
+ if (this.log && isAssertModelVisibleDebug()) {
137
+ assertModelVisible(this.log.events(), this.messages)
138
+ }
139
+
115
140
  // Auto-trigger compression checks (fire-and-forget, don't await)
116
141
  this.checkCompression()
117
142
  }
118
143
 
144
+ /** 记录工具执行结果(全量 ToolResult 含 success/error)到日志,并写投影消息(不重复走 messageToEvents 拆分)。 */
145
+ addToolResult(toolUseId: string, result: ToolResult): void {
146
+ const content = result.success ? result.content : result.error || result.content
147
+ const msg: Message = {
148
+ role: 'user',
149
+ content: [{ type: 'tool_result', tool_use_id: toolUseId, content }],
150
+ }
151
+ this.messages.push(msg)
152
+ this.estimatedTokens += this.estimateTokens(JSON.stringify(msg.content))
153
+ if (this.log) this.log.append({ type: 'tool/result', at: Date.now(), id: toolUseId, result })
154
+ this.checkCompression()
155
+ }
156
+
157
+ /** 记录原始 assistant 流块(保 replay 保真);不写入投影 messages。 */
158
+ recordChunk(chunk: string): void {
159
+ if (this.log) this.log.append({ type: 'assistant/chunk', at: Date.now(), chunk })
160
+ }
161
+
119
162
  /**
120
163
  * Seed a batch of pre-existing messages (e.g., an inherited parent
121
164
  * conversation). Unlike addMessage, this does not trigger compaction so the
@@ -124,7 +167,14 @@ export class ContextManager {
124
167
  seedMessages(messages: Message[]): void {
125
168
  if (messages.length === 0) return
126
169
  this.messages.push(...messages)
170
+ if (this.log) {
171
+ for (const m of messages) for (const e of messageToEvents(m, Date.now())) this.log.append(e)
172
+ }
127
173
  this.reEstimateTokens()
174
+
175
+ if (this.log && isAssertModelVisibleDebug()) {
176
+ assertModelVisible(this.log.events(), this.messages)
177
+ }
128
178
  }
129
179
 
130
180
  getMessages(): Message[] {
@@ -155,13 +205,30 @@ export class ContextManager {
155
205
  content: `[Earlier conversation summary]: ${summary}`,
156
206
  }
157
207
  this.messages = [summaryMsg, ...this.messages.slice(-keep)]
208
+ if (this.log) {
209
+ const derivedCount = deriveMessages(this.log.events()).length
210
+ const replacedCount = Math.max(0, derivedCount - keep)
211
+ this.log.append({ type: 'compaction/summary', at: Date.now(), summary, replacedCount })
212
+ }
158
213
  } catch {
159
214
  // Fall back to truncation on summarizer failure
160
- this.messages = this.messages.slice(-keep)
215
+ const kept = this.messages.slice(-keep)
216
+ if (this.log) {
217
+ this.log.append({ type: 'compaction/rewrite', at: Date.now(), messages: kept })
218
+ this.messages = deriveMessages(this.log.events())
219
+ } else {
220
+ this.messages = kept
221
+ }
161
222
  }
162
223
  } else {
163
224
  // Simple truncation fallback
164
- this.messages = this.messages.slice(-keep)
225
+ const kept = this.messages.slice(-keep)
226
+ if (this.log) {
227
+ this.log.append({ type: 'compaction/rewrite', at: Date.now(), messages: kept })
228
+ this.messages = deriveMessages(this.log.events())
229
+ } else {
230
+ this.messages = kept
231
+ }
165
232
  }
166
233
 
167
234
  // Re-estimate tokens
@@ -280,50 +347,6 @@ export class ContextManager {
280
347
  return { ...this.compactionStats }
281
348
  }
282
349
 
283
- // ── 413 recovery ──
284
-
285
- /**
286
- * Emergency context drain for 413 (context too large) errors.
287
- * Progressively strips messages until the context fits.
288
- * Returns true if recovery was possible, false if already minimal.
289
- */
290
- async on413Error(): Promise<boolean> {
291
- const result = await emergencyDrain(this)
292
- if (result.recovered) {
293
- this.compactionStats.drainCount++
294
- this.compactionStats.lastCompaction = new Date()
295
- }
296
- return result.recovered
297
- }
298
-
299
- // ── Forced compaction (e.g. /compact command) ──
300
-
301
- /**
302
- * Force compaction of the conversation history.
303
- *
304
- * Uses the provided summarizer, the internal summarizer, or falls back
305
- * to emergency drain if neither is available.
306
- */
307
- async forceCompact(heading: string, summarizer?: Summarizer): Promise<void> {
308
- const beforeTokens = this.estimatedTokens
309
-
310
- if (summarizer) {
311
- await reactiveCompact(this, summarizer, heading)
312
- } else if (this.summarizer) {
313
- await reactiveCompact(this, this.summarizer, heading)
314
- } else {
315
- // Fallback: simple truncation via drain
316
- await emergencyDrain(this)
317
- }
318
-
319
- const tokensSaved = beforeTokens - this.estimatedTokens
320
- this.compactionStats.compactCount++
321
- if (tokensSaved > 0) {
322
- this.compactionStats.compactTokensSaved += tokensSaved
323
- }
324
- this.compactionStats.lastCompaction = new Date()
325
- }
326
-
327
350
  // ── Private compression helpers ──
328
351
 
329
352
  /**
@@ -373,7 +396,14 @@ export class ContextManager {
373
396
  this.compactionStats.lastCompaction = new Date()
374
397
  }
375
398
 
376
- this.messages = compacted
399
+ // 仅当内容真变时写日志并重建投影(model-visible means logged 不变量)。
400
+ // 用 deriveMessages(log) 而非直接赋值 compacted,避免与日志事件共享 messages 引用被 addMessage.push 污染。
401
+ if (this.log && (removed > 0 || tokensSaved > 0)) {
402
+ this.log.append({ type: 'compaction/rewrite', at: Date.now(), messages: compacted })
403
+ this.messages = deriveMessages(this.log.events())
404
+ } else {
405
+ this.messages = compacted
406
+ }
377
407
  this.reEstimateTokens()
378
408
  }
379
409
 
@@ -1,5 +1,6 @@
1
1
  import { CREDENTIAL_SENTINEL } from './types'
2
2
  import type { CredentialMaskingConfig } from '../../shared/index.ts'
3
+ import { SecurityGate } from '../../security/gate'
3
4
 
4
5
  /**
5
6
  * Well-known secret token prefixes. These are high-signal formats that must
@@ -23,6 +24,10 @@ export function maskOutput(output: string, config: CredentialMaskingConfig): str
23
24
  // Redact bare secret tokens by prefix (always on, independent of config patterns).
24
25
  masked = masked.replace(TOKEN_REDACTION_PATTERN, CREDENTIAL_SENTINEL)
25
26
 
27
+ // Redact bare sk-/sk-ant-/JWT tokens — wire the SecurityGate credential-leak
28
+ // detection into the output path (defense-in-depth beyond the config patterns).
29
+ masked = SecurityGate.redactCredentialLeak(masked)
30
+
26
31
  for (const pattern of config.output_scrubbing.patterns) {
27
32
  try {
28
33
  // Strip (?i) inline flags — JS uses the 'i' flag instead
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { execSync } from 'node:child_process'
18
18
  import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
- import { join, resolve } from 'node:path'
19
+ import { join, resolve, sep } from 'node:path'
20
20
  import { tmpdir, homedir } from 'node:os'
21
21
  import { randomUUID } from 'node:crypto'
22
22
 
@@ -172,7 +172,16 @@ export class CrsiSandbox {
172
172
  phase: 'pending',
173
173
  }
174
174
 
175
- const targetPath = join(this.worktreePath, mod.filePath)
175
+ const worktreeRoot = resolve(this.worktreePath)
176
+ const targetPath = resolve(this.worktreePath, mod.filePath)
177
+
178
+ // Path traversal guard: reject any target that resolves outside the sandbox worktree
179
+ if (targetPath !== worktreeRoot && !targetPath.startsWith(worktreeRoot + sep)) {
180
+ result.error = `Path traversal blocked: "${mod.filePath}" resolves outside the worktree.`
181
+ result.phase = 'failed'
182
+ this.sessionReport.modifications.push(result)
183
+ return result
184
+ }
176
185
 
177
186
  // Verify the file exists in the worktree
178
187
  if (!existsSync(targetPath)) {
@@ -182,7 +191,7 @@ export class CrsiSandbox {
182
191
  return result
183
192
  }
184
193
 
185
- // Verify original content matches (safety check)
194
+ // Verify original content matches (safety check — empty originalContent = lenient skip)
186
195
  try {
187
196
  const currentContent = readFileSync(targetPath, 'utf-8')
188
197
  if (currentContent !== mod.originalContent && mod.originalContent) {
@@ -319,12 +328,15 @@ export class CrsiSandbox {
319
328
  return { success: false, message: 'No worktree to merge from.' }
320
329
  }
321
330
 
322
- // Check that all modifications passed tests
323
- const failedCount = this.sessionReport.modifications.filter((m) => m.phase === 'failed').length
324
- if (failedCount > 0) {
331
+ // Require every modification to have passed tests before merging. A mod left
332
+ // in 'pending'/'applied'/'testing' (tests never run or never passed) must not merge.
333
+ const mergeable = new Set(['passed', 'approved', 'merged'])
334
+ const blockers = this.sessionReport.modifications.filter((m) => !mergeable.has(m.phase))
335
+ if (blockers.length > 0) {
336
+ const phases = [...new Set(blockers.map((m) => m.phase))].join(', ')
325
337
  return {
326
338
  success: false,
327
- message: `${failedCount} modification(s) failed tests. Cannot merge.`,
339
+ message: `${blockers.length} modification(s) not ready to merge (phase: ${phases}). Cannot merge.`,
328
340
  }
329
341
  }
330
342