@miphamai/cli 0.11.0 → 0.13.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/package.json +1 -1
- package/src/agent/background-registry.ts +11 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/artifacts/server.ts +39 -0
- package/src/config/defaults.ts +35 -1
- package/src/config/loader.ts +122 -2
- package/src/core/credential-masker.ts +203 -0
- package/src/core/engine.ts +56 -1
- package/src/core/hooks.ts +23 -0
- package/src/core/inference-hook.ts +224 -0
- package/src/core/metrics.ts +364 -0
- package/src/core/permission.ts +62 -8
- package/src/index.tsx +16 -1
- package/src/plugin/plugin-manager.ts +39 -2
- package/src/shared/types.ts +100 -0
- package/src/tools/exec/bash.ts +29 -3
- package/src/tools/file/read.ts +30 -1
- package/src/ui/app.tsx +6 -1
- package/src/ui/chat.tsx +50 -17
- package/src/ui/commands.ts +348 -4
- package/dist/mipham +0 -0
package/src/core/permission.ts
CHANGED
|
@@ -20,6 +20,17 @@ export class PermissionSystem {
|
|
|
20
20
|
private legacyDefaultFallback: PermissionLevel | null = null
|
|
21
21
|
private mode: PermissionMode = 'default'
|
|
22
22
|
|
|
23
|
+
// ── Permission cache (P2) ──
|
|
24
|
+
/** Short-lived cache: toolName+input → permissionLevel. Invalidated on rule/mode change. */
|
|
25
|
+
private checkCache = new Map<string, PermissionLevel>()
|
|
26
|
+
private cacheMode: PermissionMode | null = null
|
|
27
|
+
|
|
28
|
+
/** Invalidate the permission cache (called on any rule/mode change). */
|
|
29
|
+
private invalidateCache(): void {
|
|
30
|
+
this.checkCache.clear()
|
|
31
|
+
this.cacheMode = null
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
constructor(modeOrLevel: PermissionLevel = 'default') {
|
|
24
35
|
if (VALID_MODES.has(modeOrLevel)) {
|
|
25
36
|
this.mode = modeOrLevel as PermissionMode
|
|
@@ -34,6 +45,7 @@ export class PermissionSystem {
|
|
|
34
45
|
|
|
35
46
|
setMode(mode: PermissionMode): void {
|
|
36
47
|
this.mode = mode
|
|
48
|
+
this.invalidateCache()
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
getMode(): PermissionMode {
|
|
@@ -75,6 +87,8 @@ export class PermissionSystem {
|
|
|
75
87
|
for (const rule of config.deny) {
|
|
76
88
|
this.denyRules.push(compileRule(rule, 'deny'))
|
|
77
89
|
}
|
|
90
|
+
|
|
91
|
+
this.invalidateCache()
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
// ── Permission check ──
|
|
@@ -91,37 +105,74 @@ export class PermissionSystem {
|
|
|
91
105
|
* 8. System default → 'ask'
|
|
92
106
|
*/
|
|
93
107
|
check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
|
|
108
|
+
// ── Cache lookup (P2): reuse decision for same tool+mode+input ──
|
|
109
|
+
const cacheKey = tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
|
|
110
|
+
if (this.cacheMode === this.mode) {
|
|
111
|
+
const cached = this.checkCache.get(cacheKey)
|
|
112
|
+
if (cached !== undefined) return cached
|
|
113
|
+
} else {
|
|
114
|
+
// Mode changed — invalidate entire cache
|
|
115
|
+
this.checkCache.clear()
|
|
116
|
+
this.cacheMode = this.mode
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
// 1. Check deny rules (always win)
|
|
95
120
|
for (const rule of this.denyRules) {
|
|
96
|
-
if (this.ruleMatches(rule, tool, input))
|
|
121
|
+
if (this.ruleMatches(rule, tool, input)) {
|
|
122
|
+
const result: PermissionLevel = 'ask'
|
|
123
|
+
this.checkCache.set(cacheKey, result)
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
// 2. Check ask rules
|
|
100
129
|
for (const rule of this.askRules) {
|
|
101
|
-
if (this.ruleMatches(rule, tool, input))
|
|
130
|
+
if (this.ruleMatches(rule, tool, input)) {
|
|
131
|
+
const result: PermissionLevel = 'ask'
|
|
132
|
+
this.checkCache.set(cacheKey, result)
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
102
135
|
}
|
|
103
136
|
|
|
104
137
|
// 3. Check allow rules
|
|
105
138
|
for (const rule of this.allowRules) {
|
|
106
|
-
if (this.ruleMatches(rule, tool, input))
|
|
139
|
+
if (this.ruleMatches(rule, tool, input)) {
|
|
140
|
+
const result: PermissionLevel = 'bypass'
|
|
141
|
+
this.checkCache.set(cacheKey, result)
|
|
142
|
+
return result
|
|
143
|
+
}
|
|
107
144
|
}
|
|
108
145
|
|
|
109
146
|
// 4. Legacy exact-name rules (backward compat)
|
|
110
147
|
const legacyLevel = this.legacyRules.get(tool.name)
|
|
111
|
-
if (legacyLevel !== undefined)
|
|
148
|
+
if (legacyLevel !== undefined) {
|
|
149
|
+
this.checkCache.set(cacheKey, legacyLevel)
|
|
150
|
+
return legacyLevel
|
|
151
|
+
}
|
|
112
152
|
|
|
113
153
|
// 5. Mode baseline
|
|
114
154
|
const baseline = this.modeBaseline(tool)
|
|
115
|
-
if (baseline !== 'mode-baseline')
|
|
155
|
+
if (baseline !== 'mode-baseline') {
|
|
156
|
+
this.checkCache.set(cacheKey, baseline)
|
|
157
|
+
return baseline
|
|
158
|
+
}
|
|
116
159
|
|
|
117
160
|
// 6. Tool's own permission level (backward compat fallback)
|
|
118
|
-
if (tool.permission)
|
|
161
|
+
if (tool.permission) {
|
|
162
|
+
this.checkCache.set(cacheKey, tool.permission)
|
|
163
|
+
return tool.permission
|
|
164
|
+
}
|
|
119
165
|
|
|
120
166
|
// 7. Legacy constructor fallback
|
|
121
|
-
if (this.legacyDefaultFallback)
|
|
167
|
+
if (this.legacyDefaultFallback) {
|
|
168
|
+
this.checkCache.set(cacheKey, this.legacyDefaultFallback)
|
|
169
|
+
return this.legacyDefaultFallback
|
|
170
|
+
}
|
|
122
171
|
|
|
123
172
|
// 8. System default
|
|
124
|
-
|
|
173
|
+
const result: PermissionLevel = 'ask'
|
|
174
|
+
this.checkCache.set(cacheKey, result)
|
|
175
|
+
return result
|
|
125
176
|
}
|
|
126
177
|
|
|
127
178
|
needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
@@ -193,6 +244,7 @@ export class PermissionSystem {
|
|
|
193
244
|
if (level === 'auto') this.mode = 'auto'
|
|
194
245
|
else if (level === 'bypass') this.mode = 'bypassPermissions'
|
|
195
246
|
else this.mode = 'default'
|
|
247
|
+
this.invalidateCache()
|
|
196
248
|
}
|
|
197
249
|
|
|
198
250
|
getDefaultLevel(): PermissionLevel {
|
|
@@ -229,11 +281,13 @@ export class PermissionSystem {
|
|
|
229
281
|
else if (rule.level === 'ask') this.ask(rule.toolName)
|
|
230
282
|
}
|
|
231
283
|
}
|
|
284
|
+
this.invalidateCache()
|
|
232
285
|
}
|
|
233
286
|
|
|
234
287
|
removeRule(toolName: string): void {
|
|
235
288
|
this.legacyRules.delete(toolName)
|
|
236
289
|
this.removeRuleFromArrays(toolName)
|
|
290
|
+
this.invalidateCache()
|
|
237
291
|
}
|
|
238
292
|
|
|
239
293
|
private removeRuleFromArrays(toolName: string): void {
|
package/src/index.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { render } from 'ink'
|
|
3
3
|
import { App } from './ui/app'
|
|
4
|
-
import { loadConfig } from './config/loader'
|
|
4
|
+
import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
|
|
5
5
|
import { bootstrapProviders } from './providers/bootstrap'
|
|
6
6
|
import { InstructionsLoader } from './core/instructions'
|
|
7
7
|
import { loadSessionMemories } from './core/memory/memory-loader'
|
|
@@ -18,6 +18,7 @@ import { registerMcpServerTools } from './mcp/registry'
|
|
|
18
18
|
import { AgentRegistry } from './agent/agent-registry'
|
|
19
19
|
import { HookEngine } from './core/hooks'
|
|
20
20
|
import { ArtifactServer } from './artifacts/server'
|
|
21
|
+
import { getMetrics } from './core/metrics'
|
|
21
22
|
import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
|
|
22
23
|
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
23
24
|
import { AgentViewDashboard } from './agent-view/dashboard'
|
|
@@ -32,6 +33,9 @@ interface RunOptions {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export async function runApp(options: RunOptions): Promise<void> {
|
|
36
|
+
// Metrics: count CLI invocation
|
|
37
|
+
getMetrics().cliInvocations.inc()
|
|
38
|
+
|
|
35
39
|
// Handle `mipham agents` subcommand — launch standalone dashboard
|
|
36
40
|
const args = process.argv.slice(2)
|
|
37
41
|
if (args[0] === 'agents') {
|
|
@@ -149,6 +153,17 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
149
153
|
engine.setAgentViewManager(agentViewManager)
|
|
150
154
|
engine.setSkillsLoader(skillsLoader)
|
|
151
155
|
|
|
156
|
+
// Wire inference hooks (DLP) configuration
|
|
157
|
+
const inferenceHookConfig = loadInferenceHookConfig()
|
|
158
|
+
engine.setInferenceHookConfig(inferenceHookConfig)
|
|
159
|
+
|
|
160
|
+
// Wire credential masking configuration into tools
|
|
161
|
+
const credentialMaskingConfig = loadCredentialMaskingConfig()
|
|
162
|
+
const { setCredentialMaskingConfigForRead } = await import('./tools/file/read')
|
|
163
|
+
const { setCredentialMaskingConfigForBash } = await import('./tools/exec/bash')
|
|
164
|
+
setCredentialMaskingConfigForRead(credentialMaskingConfig)
|
|
165
|
+
setCredentialMaskingConfigForBash(credentialMaskingConfig)
|
|
166
|
+
|
|
152
167
|
// Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
|
|
153
168
|
if (config.permission) {
|
|
154
169
|
engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
|
|
@@ -45,6 +45,7 @@ export class PluginManager {
|
|
|
45
45
|
mkdirSync(destDir, { recursive: true })
|
|
46
46
|
this.copyDir(sourcePath, destDir)
|
|
47
47
|
|
|
48
|
+
const similarWarning = this.findSimilarWarning(validation.manifest.name)
|
|
48
49
|
this.plugins.push({
|
|
49
50
|
name: validation.manifest.name,
|
|
50
51
|
version: validation.manifest.version,
|
|
@@ -56,7 +57,9 @@ export class PluginManager {
|
|
|
56
57
|
this.saveState()
|
|
57
58
|
return {
|
|
58
59
|
success: true,
|
|
59
|
-
message:
|
|
60
|
+
message:
|
|
61
|
+
`Plugin "${validation.manifest.name}" v${validation.manifest.version} installed` +
|
|
62
|
+
(similarWarning ? `\n⚠ ${similarWarning}` : ''),
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -120,9 +123,12 @@ export class PluginManager {
|
|
|
120
123
|
})
|
|
121
124
|
|
|
122
125
|
this.saveState()
|
|
126
|
+
const similarWarning = this.findSimilarWarning(pluginName)
|
|
123
127
|
return {
|
|
124
128
|
success: true,
|
|
125
|
-
message:
|
|
129
|
+
message:
|
|
130
|
+
`Plugin "${pluginName}" installed from npm` +
|
|
131
|
+
(similarWarning ? `\n⚠ ${similarWarning}` : ''),
|
|
126
132
|
}
|
|
127
133
|
} catch (err: unknown) {
|
|
128
134
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -140,6 +146,37 @@ export class PluginManager {
|
|
|
140
146
|
return [...this.plugins]
|
|
141
147
|
}
|
|
142
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Check if the new plugin name is similar to any existing installed plugin.
|
|
151
|
+
* Warns about potential name squatting / confusion.
|
|
152
|
+
* Returns a warning string or empty string.
|
|
153
|
+
*/
|
|
154
|
+
private findSimilarWarning(newName: string): string {
|
|
155
|
+
const existing = this.plugins.map((p) => p.name)
|
|
156
|
+
const similar = existing.filter((name) => this.areNamesSimilar(newName, name))
|
|
157
|
+
if (similar.length === 0) return ''
|
|
158
|
+
return `Similar plugin${similar.length > 1 ? 's' : ''} already installed: ${similar.join(', ')}. Verify you trust this source.`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Two names are "similar" if:
|
|
163
|
+
* - One is a prefix of the other (e.g. "auth" vs "auth-pro")
|
|
164
|
+
* - They differ by ≤ 2 characters (Levenshtein distance)
|
|
165
|
+
*/
|
|
166
|
+
private areNamesSimilar(a: string, b: string): boolean {
|
|
167
|
+
if (a === b) return false // exact match is handled by "already installed" check
|
|
168
|
+
if (a.startsWith(b) || b.startsWith(a)) return true
|
|
169
|
+
|
|
170
|
+
// Simple edit-distance approximation: count differing chars
|
|
171
|
+
const maxLen = Math.max(a.length, b.length)
|
|
172
|
+
let diffs = 0
|
|
173
|
+
for (let i = 0; i < maxLen; i++) {
|
|
174
|
+
if (a[i] !== b[i]) diffs++
|
|
175
|
+
if (diffs > 2) return false
|
|
176
|
+
}
|
|
177
|
+
return diffs <= 2
|
|
178
|
+
}
|
|
179
|
+
|
|
143
180
|
/**
|
|
144
181
|
* Register a cleanup callback that will be invoked when the named plugin is removed.
|
|
145
182
|
* This allows the plugin loader to tear down hooks, agents, MCP connections, and tools.
|
package/src/shared/types.ts
CHANGED
|
@@ -196,6 +196,7 @@ export type HookEvent =
|
|
|
196
196
|
| 'ConfigChange'
|
|
197
197
|
| 'SubagentStart'
|
|
198
198
|
| 'SubagentStop'
|
|
199
|
+
| 'PreInference'
|
|
199
200
|
|
|
200
201
|
export type HookType = 'command' | 'http' | 'code' | 'mcp_tool'
|
|
201
202
|
|
|
@@ -226,6 +227,18 @@ export interface HookContext {
|
|
|
226
227
|
userPrompt?: string
|
|
227
228
|
configKey?: string
|
|
228
229
|
configValue?: unknown
|
|
230
|
+
/** PreInference: full conversation messages for DLP inspection. */
|
|
231
|
+
messages?: Array<{ role: string; content: string }>
|
|
232
|
+
/** PreInference: recent tool calls and their results. */
|
|
233
|
+
toolCalls?: Array<{
|
|
234
|
+
name: string
|
|
235
|
+
input: Record<string, unknown>
|
|
236
|
+
resultPreview: string
|
|
237
|
+
}>
|
|
238
|
+
/** PreInference: current provider ID. */
|
|
239
|
+
provider?: string
|
|
240
|
+
/** PreInference: current model ID. */
|
|
241
|
+
model?: string
|
|
229
242
|
}
|
|
230
243
|
|
|
231
244
|
export interface HookResult {
|
|
@@ -278,3 +291,90 @@ export interface PermissionRule {
|
|
|
278
291
|
level: PermissionLevel
|
|
279
292
|
pattern?: string
|
|
280
293
|
}
|
|
294
|
+
|
|
295
|
+
// ── Inference Hook (DLP) Types ──
|
|
296
|
+
|
|
297
|
+
/** Configuration for the PreInference DLP hook, loaded from config.yml. */
|
|
298
|
+
export interface InferenceHookConfig {
|
|
299
|
+
/** DLP server endpoint (HTTPS). Empty = feature disabled. */
|
|
300
|
+
endpoint: string
|
|
301
|
+
/** HMAC signing secret (format: mis_<random>). */
|
|
302
|
+
signing_secret: string
|
|
303
|
+
/** Request timeout in milliseconds. Default 5000. */
|
|
304
|
+
timeout: number
|
|
305
|
+
/** Failure posture: 'fail-closed' blocks on error, 'fail-open' allows. */
|
|
306
|
+
on_failure: 'fail-closed' | 'fail-open'
|
|
307
|
+
/** Organization identifier (optional, sent in payload). */
|
|
308
|
+
organization_id: string
|
|
309
|
+
/** Additional custom headers to send with each request. */
|
|
310
|
+
headers: Record<string, string>
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Outgoing request to the DLP server. */
|
|
314
|
+
export interface InferenceCheckRequest {
|
|
315
|
+
type: 'inference_check'
|
|
316
|
+
id: string
|
|
317
|
+
created_at: string
|
|
318
|
+
data: {
|
|
319
|
+
type: 'pre_inference'
|
|
320
|
+
session_id: string
|
|
321
|
+
organization_id?: string
|
|
322
|
+
provider: string
|
|
323
|
+
model: string
|
|
324
|
+
messages: Array<{ role: string; content: string }>
|
|
325
|
+
tool_calls: Array<{
|
|
326
|
+
name: string
|
|
327
|
+
input: Record<string, unknown>
|
|
328
|
+
result_preview: string
|
|
329
|
+
}>
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Response from the DLP server. */
|
|
334
|
+
export interface InferenceCheckResponse {
|
|
335
|
+
verdict: 'allow' | 'deny'
|
|
336
|
+
reason?: string
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Credential Masking Types ──
|
|
340
|
+
|
|
341
|
+
/** Full-file masking rule: entire file content replaced with sentinel. */
|
|
342
|
+
export interface CredentialFullMaskRule {
|
|
343
|
+
path: string
|
|
344
|
+
mode: 'full'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Extract-based masking rule: only regex-matched tokens are replaced. */
|
|
348
|
+
export interface CredentialExtractRule {
|
|
349
|
+
path: string
|
|
350
|
+
mode: 'extract'
|
|
351
|
+
extract: Array<{
|
|
352
|
+
pattern: string
|
|
353
|
+
replacement?: string
|
|
354
|
+
}>
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type CredentialFileRule = CredentialFullMaskRule | CredentialExtractRule
|
|
358
|
+
|
|
359
|
+
/** Configuration for credential masking, loaded from config.yml. */
|
|
360
|
+
export interface CredentialMaskingConfig {
|
|
361
|
+
enabled: boolean
|
|
362
|
+
files: CredentialFileRule[]
|
|
363
|
+
output_scrubbing: {
|
|
364
|
+
enabled: boolean
|
|
365
|
+
patterns: string[]
|
|
366
|
+
}
|
|
367
|
+
env_filter: {
|
|
368
|
+
enabled: boolean
|
|
369
|
+
patterns: string[]
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Background Agent Types ──
|
|
374
|
+
|
|
375
|
+
export interface BackgroundAgentConfig {
|
|
376
|
+
auto_commit: boolean
|
|
377
|
+
auto_push: boolean
|
|
378
|
+
auto_worktree: boolean
|
|
379
|
+
commit_coauthors: boolean
|
|
380
|
+
}
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type { ToolDefinition } from '../../shared/index.ts'
|
|
1
|
+
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
// Injected at startup — set via index.tsx
|
|
4
|
+
let credentialConfig: CredentialMaskingConfig | undefined
|
|
5
|
+
|
|
6
|
+
export function setCredentialMaskingConfigForBash(config: CredentialMaskingConfig): void {
|
|
7
|
+
credentialConfig = config
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
// ── Dangerous command patterns ──
|
|
4
11
|
const BLOCKED_PATTERNS = [
|
|
@@ -115,19 +122,38 @@ export const bashTool: ToolDefinition = {
|
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
try {
|
|
125
|
+
// ── Credential masking: filter sensitive env vars ──
|
|
126
|
+
let spawnEnv: Record<string, string | undefined> | undefined
|
|
127
|
+
if (credentialConfig?.enabled && credentialConfig.env_filter.enabled) {
|
|
128
|
+
const { filterEnv } = await import('../../core/credential-masker')
|
|
129
|
+
spawnEnv = filterEnv(process.env as Record<string, string | undefined>, credentialConfig)
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
const proc = Bun.spawn(['bash', '-c', command], {
|
|
119
133
|
cwd: ctx.cwd,
|
|
120
134
|
stdout: 'pipe',
|
|
121
135
|
stderr: 'pipe',
|
|
136
|
+
env: spawnEnv,
|
|
122
137
|
})
|
|
123
138
|
|
|
124
139
|
const timer = setTimeout(() => proc.kill(), timeout)
|
|
125
|
-
const
|
|
140
|
+
const rawOutput = await new Response(proc.stdout).text()
|
|
126
141
|
const exitCode = await proc.exited
|
|
127
142
|
clearTimeout(timer)
|
|
128
143
|
|
|
144
|
+
// ── Credential masking: scrub output ──
|
|
145
|
+
let output = rawOutput
|
|
146
|
+
if (credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled) {
|
|
147
|
+
const { maskOutput } = await import('../../core/credential-masker')
|
|
148
|
+
output = maskOutput(rawOutput, credentialConfig)
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
if (exitCode !== 0) {
|
|
130
|
-
const
|
|
152
|
+
const rawStderr = await new Response(proc.stderr).text()
|
|
153
|
+
const stderr =
|
|
154
|
+
credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled
|
|
155
|
+
? (await import('../../core/credential-masker')).maskOutput(rawStderr, credentialConfig)
|
|
156
|
+
: rawStderr
|
|
131
157
|
return {
|
|
132
158
|
success: false,
|
|
133
159
|
content: output.slice(0, 5_000),
|
package/src/tools/file/read.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
|
-
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
3
3
|
import { resolveSafe } from '../../security/path'
|
|
4
4
|
|
|
5
|
+
// Injected at startup — set via index.tsx
|
|
6
|
+
let credentialConfig: CredentialMaskingConfig | undefined
|
|
7
|
+
|
|
8
|
+
export function setCredentialMaskingConfigForRead(config: CredentialMaskingConfig): void {
|
|
9
|
+
credentialConfig = config
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export const readTool: ToolDefinition = {
|
|
6
13
|
name: 'Read',
|
|
7
14
|
description: 'Read a file from the local filesystem. Supports offset and limit for large files.',
|
|
@@ -37,6 +44,28 @@ export const readTool: ToolDefinition = {
|
|
|
37
44
|
const offset = (params.offset as number) || 0
|
|
38
45
|
const limit = (params.limit as number) || 2000
|
|
39
46
|
const content = readFileSync(filePath, 'utf-8')
|
|
47
|
+
|
|
48
|
+
// ── Credential masking ──
|
|
49
|
+
if (credentialConfig) {
|
|
50
|
+
const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
|
|
51
|
+
await import('../../core/credential-masker')
|
|
52
|
+
const rule = matchCredentialFile(filePath, credentialConfig)
|
|
53
|
+
if (rule) {
|
|
54
|
+
const masked = maskContent(content, rule)
|
|
55
|
+
// Full-file mask: return sentinel immediately (offset/limit don't apply)
|
|
56
|
+
if (masked === CREDENTIAL_SENTINEL) {
|
|
57
|
+
return { success: true, content: masked }
|
|
58
|
+
}
|
|
59
|
+
// Extract mode: apply offset/limit on masked content
|
|
60
|
+
const maskedLines = masked.split('\n')
|
|
61
|
+
const maskedSlice = maskedLines.slice(offset, offset + limit)
|
|
62
|
+
const maskedResult = maskedSlice
|
|
63
|
+
.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
|
|
64
|
+
.join('\n')
|
|
65
|
+
return { success: true, content: maskedResult }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
const lines = content.split('\n')
|
|
41
70
|
const slice = lines.slice(offset, offset + limit)
|
|
42
71
|
const result = slice
|
package/src/ui/app.tsx
CHANGED
|
@@ -372,6 +372,11 @@ export function App({
|
|
|
372
372
|
setPickerOpen((prev) => !prev)
|
|
373
373
|
return
|
|
374
374
|
}
|
|
375
|
+
// Ctrl+F → toggle focus mode
|
|
376
|
+
if (_input === '\x06') {
|
|
377
|
+
setFocusMode((prev) => !prev)
|
|
378
|
+
return
|
|
379
|
+
}
|
|
375
380
|
// Ctrl+O → toggle last tool call expand/collapse
|
|
376
381
|
if (_input === '\x0f') {
|
|
377
382
|
setMessages((prev) => {
|
|
@@ -480,7 +485,7 @@ export function App({
|
|
|
480
485
|
● {PERMISSION_LABELS[permissionMode]}
|
|
481
486
|
</Text>
|
|
482
487
|
<Text dimColor> · Shift+Tab mode</Text>
|
|
483
|
-
<Text dimColor> · Ctrl+P model · Ctrl+O expand</Text>
|
|
488
|
+
<Text dimColor> · Ctrl+P model · Ctrl+F focus · Ctrl+O expand</Text>
|
|
484
489
|
<Text dimColor> · Esc cancel · /help · ← agents</Text>
|
|
485
490
|
</Box>
|
|
486
491
|
</Box>
|
package/src/ui/chat.tsx
CHANGED
|
@@ -18,16 +18,16 @@ function displayCwd(): string {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
|
|
21
|
-
// In focus mode,
|
|
22
|
-
const displayMessages = focusMode ?
|
|
21
|
+
// In focus mode, compact tool activity into summary lines
|
|
22
|
+
const displayMessages = focusMode ? compactForFocus(messages) : messages
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
25
|
<Box flexDirection="column" marginY={1} flexGrow={1}>
|
|
26
26
|
{focusMode && messages.length > 0 && (
|
|
27
27
|
<Box marginBottom={1}>
|
|
28
28
|
<Text dimColor>
|
|
29
|
-
🔍 Focus
|
|
30
|
-
|
|
29
|
+
🔍 Focus — {countHidden(messages, displayMessages)} hidden · /focus to toggle · Ctrl+O
|
|
30
|
+
to expand
|
|
31
31
|
</Text>
|
|
32
32
|
</Box>
|
|
33
33
|
)}
|
|
@@ -92,22 +92,55 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
95
|
+
* Compact messages for focus view:
|
|
96
|
+
* - Groups consecutive tool calls into [▶ N tools] ↳ name1 ↳ name2 summary lines
|
|
97
|
+
* - Keeps all user, assistant, and system messages intact
|
|
98
98
|
*/
|
|
99
|
-
function
|
|
100
|
-
|
|
99
|
+
function compactForFocus(messages: ChatMessage[]): ChatMessage[] {
|
|
100
|
+
const result: ChatMessage[] = []
|
|
101
|
+
let toolGroup: ChatMessage[] = []
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
for (const msg of messages) {
|
|
104
|
+
if (msg.toolMeta) {
|
|
105
|
+
toolGroup.push(msg)
|
|
106
|
+
} else {
|
|
107
|
+
// Flush pending tool group
|
|
108
|
+
if (toolGroup.length > 0) {
|
|
109
|
+
result.push(summarizeToolGroup(toolGroup))
|
|
110
|
+
toolGroup = []
|
|
111
|
+
}
|
|
112
|
+
result.push(msg)
|
|
108
113
|
}
|
|
109
114
|
}
|
|
115
|
+
// Flush remaining
|
|
116
|
+
if (toolGroup.length > 0) {
|
|
117
|
+
result.push(summarizeToolGroup(toolGroup))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Summarize a group of tool calls into a single compact message. */
|
|
124
|
+
function summarizeToolGroup(tools: ChatMessage[]): ChatMessage {
|
|
125
|
+
const names = tools.map((t) => t.toolMeta!.name)
|
|
126
|
+
const uniqueNames = [...new Set(names)]
|
|
127
|
+
const toolList = uniqueNames.map((n) => `↳ ${n}`).join(' ')
|
|
128
|
+
return {
|
|
129
|
+
role: 'system',
|
|
130
|
+
content: `[▶ ${tools.length} tool${tools.length > 1 ? 's' : ''}] ${toolList}`,
|
|
131
|
+
toolMeta: {
|
|
132
|
+
name: `tools (${tools.length})`,
|
|
133
|
+
input: '',
|
|
134
|
+
output: tools.map((t) => t.content).join('\n'),
|
|
135
|
+
collapsed: true,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
110
139
|
|
|
111
|
-
|
|
112
|
-
|
|
140
|
+
/** Count how many messages are hidden in focus mode. */
|
|
141
|
+
function countHidden(all: ChatMessage[], compacted: ChatMessage[]): string {
|
|
142
|
+
const toolCount = all.filter((m) => m.toolMeta).length
|
|
143
|
+
const hidden = all.length - compacted.length
|
|
144
|
+
if (hidden <= 0) return `showing all ${all.length} messages`
|
|
145
|
+
return `${hidden} tool outputs folded (${toolCount} total tool calls)`
|
|
113
146
|
}
|