@miphamai/cli 0.12.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/config/defaults.ts +26 -1
- package/src/config/loader.ts +79 -1
- package/src/core/credential-masker.ts +203 -0
- package/src/core/permission.ts +62 -8
- package/src/index.tsx +8 -1
- package/src/plugin/plugin-manager.ts +39 -2
- package/src/shared/types.ts +43 -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 +345 -0
package/package.json
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* can be queried via the Task tool (output/stop actions) or the Agent View.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
export type BackgroundTaskKind = 'interactive' | 'forked' | 'attached' | 'unattended'
|
|
10
|
+
|
|
9
11
|
export type BackgroundTaskStatus = 'running' | 'completed' | 'failed'
|
|
10
12
|
|
|
11
13
|
export interface BackgroundTask {
|
|
@@ -13,10 +15,17 @@ export interface BackgroundTask {
|
|
|
13
15
|
description: string
|
|
14
16
|
agentType: string
|
|
15
17
|
status: BackgroundTaskStatus
|
|
18
|
+
kind: BackgroundTaskKind
|
|
16
19
|
startedAt: Date
|
|
17
20
|
completedAt?: Date
|
|
18
21
|
result?: string
|
|
19
22
|
error?: string
|
|
23
|
+
/** Git worktree path (forked tasks) */
|
|
24
|
+
worktree?: string
|
|
25
|
+
/** Git branch name (forked tasks) */
|
|
26
|
+
branch?: string
|
|
27
|
+
/** Created PR URL (if requested) */
|
|
28
|
+
prUrl?: string
|
|
20
29
|
abortController: AbortController
|
|
21
30
|
}
|
|
22
31
|
|
|
@@ -39,6 +48,7 @@ export class BackgroundAgentRegistry {
|
|
|
39
48
|
description: string,
|
|
40
49
|
agentType: string,
|
|
41
50
|
executor: (signal: AbortSignal) => Promise<string>,
|
|
51
|
+
kind: BackgroundTaskKind = 'interactive',
|
|
42
52
|
): string {
|
|
43
53
|
const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
|
|
44
54
|
|
|
@@ -47,6 +57,7 @@ export class BackgroundAgentRegistry {
|
|
|
47
57
|
description,
|
|
48
58
|
agentType,
|
|
49
59
|
status: 'running',
|
|
60
|
+
kind,
|
|
50
61
|
startedAt: new Date(),
|
|
51
62
|
abortController: new AbortController(),
|
|
52
63
|
}
|
|
@@ -13,10 +13,13 @@ export interface SessionMessage {
|
|
|
13
13
|
content: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export type SessionKind = 'interactive' | 'forked' | 'attached' | 'unattended'
|
|
17
|
+
|
|
16
18
|
export interface AgentSession {
|
|
17
19
|
id: string
|
|
18
20
|
title: string
|
|
19
21
|
status: SessionStatus
|
|
22
|
+
kind: SessionKind
|
|
20
23
|
provider: string
|
|
21
24
|
model: string
|
|
22
25
|
task: string
|
|
@@ -25,6 +28,12 @@ export interface AgentSession {
|
|
|
25
28
|
completedAt?: Date
|
|
26
29
|
elapsedMs: number
|
|
27
30
|
messages: SessionMessage[]
|
|
31
|
+
/** Git worktree path (forked sessions) */
|
|
32
|
+
worktree?: string
|
|
33
|
+
/** Git branch name (forked sessions) */
|
|
34
|
+
branch?: string
|
|
35
|
+
/** Created PR URL (if requested) */
|
|
36
|
+
prUrl?: string
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export interface CreateSessionOptions {
|
|
@@ -53,6 +62,7 @@ export class AgentViewManager {
|
|
|
53
62
|
id,
|
|
54
63
|
title,
|
|
55
64
|
status: 'needs-input',
|
|
65
|
+
kind: 'interactive',
|
|
56
66
|
provider: options.provider ?? 'unknown',
|
|
57
67
|
model: options.model ?? 'unknown',
|
|
58
68
|
task,
|
package/src/config/defaults.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
MiphamConfig,
|
|
3
|
+
InferenceHookConfig,
|
|
4
|
+
CredentialMaskingConfig,
|
|
5
|
+
BackgroundAgentConfig,
|
|
6
|
+
} from '../shared/index.ts'
|
|
2
7
|
import { DEFAULT_PROVIDERS } from '../shared/index.ts'
|
|
3
8
|
import { PACKAGE_VERSION } from '../shared/index.ts'
|
|
4
9
|
|
|
@@ -18,3 +23,23 @@ export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
|
|
|
18
23
|
organization_id: '',
|
|
19
24
|
headers: {},
|
|
20
25
|
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_CREDENTIAL_MASKING_CONFIG: CredentialMaskingConfig = {
|
|
28
|
+
enabled: true,
|
|
29
|
+
files: [],
|
|
30
|
+
output_scrubbing: {
|
|
31
|
+
enabled: true,
|
|
32
|
+
patterns: ['(?i)(api[_-]?key|secret|token|password|credential)\\s*[:=]\\s*\\S+'],
|
|
33
|
+
},
|
|
34
|
+
env_filter: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
patterns: ['(?i)(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)$'],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_BACKGROUND_AGENT_CONFIG: BackgroundAgentConfig = {
|
|
41
|
+
auto_commit: true,
|
|
42
|
+
auto_push: true,
|
|
43
|
+
auto_worktree: true,
|
|
44
|
+
commit_coauthors: true,
|
|
45
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -15,8 +15,15 @@ import type {
|
|
|
15
15
|
ProviderConfig,
|
|
16
16
|
McpServerConfig,
|
|
17
17
|
InferenceHookConfig,
|
|
18
|
+
CredentialMaskingConfig,
|
|
19
|
+
BackgroundAgentConfig,
|
|
18
20
|
} from '../shared/index.ts'
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_CONFIG,
|
|
23
|
+
DEFAULT_INFERENCE_HOOK_CONFIG,
|
|
24
|
+
DEFAULT_CREDENTIAL_MASKING_CONFIG,
|
|
25
|
+
DEFAULT_BACKGROUND_AGENT_CONFIG,
|
|
26
|
+
} from './defaults'
|
|
20
27
|
|
|
21
28
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
22
29
|
const BACKUP_PREFIX = 'config.backup-'
|
|
@@ -298,3 +305,74 @@ export function loadInferenceHookConfig(cwd: string = process.cwd()): InferenceH
|
|
|
298
305
|
|
|
299
306
|
return merged
|
|
300
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Load credential masking configuration from the same config sources.
|
|
311
|
+
* Merges project-level over user-level. Returns defaults if no section present.
|
|
312
|
+
*/
|
|
313
|
+
export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
|
|
314
|
+
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
315
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
316
|
+
|
|
317
|
+
let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
|
|
318
|
+
|
|
319
|
+
const paths = [userConfigPath, configPath] // project wins (loaded last)
|
|
320
|
+
for (const path of paths) {
|
|
321
|
+
try {
|
|
322
|
+
if (!existsSync(path)) continue
|
|
323
|
+
const raw = readFileSync(path, 'utf-8')
|
|
324
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
325
|
+
const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
|
|
326
|
+
if (section) {
|
|
327
|
+
merged = {
|
|
328
|
+
enabled: section.enabled ?? merged.enabled,
|
|
329
|
+
files: section.files ?? merged.files,
|
|
330
|
+
output_scrubbing: {
|
|
331
|
+
enabled: section.output_scrubbing?.enabled ?? merged.output_scrubbing.enabled,
|
|
332
|
+
patterns: section.output_scrubbing?.patterns ?? merged.output_scrubbing.patterns,
|
|
333
|
+
},
|
|
334
|
+
env_filter: {
|
|
335
|
+
enabled: section.env_filter?.enabled ?? merged.env_filter.enabled,
|
|
336
|
+
patterns: section.env_filter?.patterns ?? merged.env_filter.patterns,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// Silently skip malformed configs
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return merged
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Load background agent configuration from config sources.
|
|
350
|
+
*/
|
|
351
|
+
export function loadBackgroundAgentConfig(cwd: string = process.cwd()): BackgroundAgentConfig {
|
|
352
|
+
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
353
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
354
|
+
|
|
355
|
+
let merged = { ...DEFAULT_BACKGROUND_AGENT_CONFIG }
|
|
356
|
+
|
|
357
|
+
const paths = [userConfigPath, configPath]
|
|
358
|
+
for (const path of paths) {
|
|
359
|
+
try {
|
|
360
|
+
if (!existsSync(path)) continue
|
|
361
|
+
const raw = readFileSync(path, 'utf-8')
|
|
362
|
+
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
363
|
+
const section = parsed.background_agent as Partial<BackgroundAgentConfig> | undefined
|
|
364
|
+
if (section) {
|
|
365
|
+
merged = {
|
|
366
|
+
auto_commit: section.auto_commit ?? merged.auto_commit,
|
|
367
|
+
auto_push: section.auto_push ?? merged.auto_push,
|
|
368
|
+
auto_worktree: section.auto_worktree ?? merged.auto_worktree,
|
|
369
|
+
commit_coauthors: section.commit_coauthors ?? merged.commit_coauthors,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// Silently skip malformed configs
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return merged
|
|
378
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { relative, isAbsolute } from 'node:path'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import type { CredentialMaskingConfig, CredentialFileRule } from '../shared/index.ts'
|
|
4
|
+
|
|
5
|
+
/** Sentinel value used to replace masked credentials. */
|
|
6
|
+
export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a file path matches any credential file rule.
|
|
10
|
+
* Returns the matching rule, or null if no match.
|
|
11
|
+
*/
|
|
12
|
+
export function matchCredentialFile(
|
|
13
|
+
filePath: string,
|
|
14
|
+
config: CredentialMaskingConfig,
|
|
15
|
+
): CredentialFileRule | null {
|
|
16
|
+
if (!config.enabled || config.files.length === 0) return null
|
|
17
|
+
|
|
18
|
+
const expanded = expandHome(filePath)
|
|
19
|
+
for (const rule of config.files) {
|
|
20
|
+
if (matchPath(expanded, rule.path)) {
|
|
21
|
+
return rule
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Apply credential masking to file content.
|
|
29
|
+
* - 'full' mode: return sentinel value only
|
|
30
|
+
* - 'extract' mode: replace regex-matched tokens with sentinel
|
|
31
|
+
*/
|
|
32
|
+
export function maskContent(content: string, rule: CredentialFileRule): string {
|
|
33
|
+
if (rule.mode === 'full') {
|
|
34
|
+
return CREDENTIAL_SENTINEL
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// extract mode
|
|
38
|
+
let masked = content
|
|
39
|
+
for (const extractRule of rule.extract) {
|
|
40
|
+
const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
|
|
41
|
+
try {
|
|
42
|
+
const regex = new RegExp(extractRule.pattern, 'gm')
|
|
43
|
+
masked = masked.replace(regex, replacement)
|
|
44
|
+
} catch {
|
|
45
|
+
// Invalid regex — skip this rule silently
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return masked
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Scrub credential patterns from stdout/stderr output.
|
|
53
|
+
* Uses the configured output_scrubbing patterns to detect and replace
|
|
54
|
+
* credentials that may have leaked into command output.
|
|
55
|
+
*/
|
|
56
|
+
export function maskOutput(output: string, config: CredentialMaskingConfig): string {
|
|
57
|
+
if (!config.enabled || !config.output_scrubbing.enabled) return output
|
|
58
|
+
|
|
59
|
+
let masked = output
|
|
60
|
+
for (const pattern of config.output_scrubbing.patterns) {
|
|
61
|
+
try {
|
|
62
|
+
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
63
|
+
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
64
|
+
const regex = new RegExp(clean, 'gim')
|
|
65
|
+
masked = masked.replace(regex, (match) => {
|
|
66
|
+
// Replace everything after the separator (= or :)
|
|
67
|
+
return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
|
|
68
|
+
})
|
|
69
|
+
} catch {
|
|
70
|
+
// Invalid regex — skip
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return masked
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Filter sensitive environment variables before spawning a subprocess.
|
|
78
|
+
* Returns a sanitized copy of process.env with matching vars removed.
|
|
79
|
+
*/
|
|
80
|
+
export function filterEnv(
|
|
81
|
+
env: Record<string, string | undefined>,
|
|
82
|
+
config: CredentialMaskingConfig,
|
|
83
|
+
): Record<string, string | undefined> {
|
|
84
|
+
if (!config.enabled || !config.env_filter.enabled) return { ...env }
|
|
85
|
+
|
|
86
|
+
const filtered: Record<string, string | undefined> = {}
|
|
87
|
+
for (const [key, value] of Object.entries(env)) {
|
|
88
|
+
let blocked = false
|
|
89
|
+
for (const pattern of config.env_filter.patterns) {
|
|
90
|
+
try {
|
|
91
|
+
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
|
92
|
+
const clean = pattern.replace(/^\(\?i\)/, '')
|
|
93
|
+
const regex = new RegExp(clean, 'i')
|
|
94
|
+
if (regex.test(key)) {
|
|
95
|
+
blocked = true
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Invalid regex — skip
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (blocked) {
|
|
103
|
+
// Replace with sentinel so tools can detect they're filtered
|
|
104
|
+
filtered[key] = CREDENTIAL_SENTINEL
|
|
105
|
+
} else {
|
|
106
|
+
filtered[key] = value
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return filtered
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the file display name for a sentinel value (for model visibility).
|
|
115
|
+
* The model sees the sentinel, doesn't know the original file path.
|
|
116
|
+
*/
|
|
117
|
+
export function getSentinelDisplay(hint?: string): string {
|
|
118
|
+
if (hint) {
|
|
119
|
+
return `[Credential file masked: ${hint}]`
|
|
120
|
+
}
|
|
121
|
+
return CREDENTIAL_SENTINEL
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Internal helpers ──
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Expand ~ to the user's home directory.
|
|
128
|
+
*/
|
|
129
|
+
function expandHome(p: string): string {
|
|
130
|
+
if (p.startsWith('~')) {
|
|
131
|
+
return homedir() + p.slice(1)
|
|
132
|
+
}
|
|
133
|
+
return p
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Match a file path against a glob-like pattern.
|
|
138
|
+
* Supports:
|
|
139
|
+
* - ** globstar (matches any number of path segments)
|
|
140
|
+
* - * wildcard within a single path segment
|
|
141
|
+
* - ~ home directory expansion
|
|
142
|
+
* - basename-only matching (e.g. ".env*" matches any .env file)
|
|
143
|
+
*/
|
|
144
|
+
function matchPath(filePath: string, pattern: string): boolean {
|
|
145
|
+
const expandedPattern = expandHome(pattern)
|
|
146
|
+
const normalizedFile = filePath.replace(/\\/g, '/')
|
|
147
|
+
const normalizedPattern = expandedPattern.replace(/\\/g, '/')
|
|
148
|
+
|
|
149
|
+
// Exact match
|
|
150
|
+
if (normalizedFile === normalizedPattern) {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// File ends with pattern (for relative basename matching like ".env*")
|
|
155
|
+
if (normalizedFile.endsWith('/' + normalizedPattern)) {
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Convert glob pattern to regex
|
|
160
|
+
const regex = globToRegex(normalizedPattern)
|
|
161
|
+
return regex.test(normalizedFile)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Convert a glob pattern to a RegExp.
|
|
166
|
+
* - ** → .* (matches any path segments)
|
|
167
|
+
* - * → [^/]* (matches within a single segment)
|
|
168
|
+
* - ? → . (single char)
|
|
169
|
+
* - All other special regex chars are escaped.
|
|
170
|
+
*/
|
|
171
|
+
function globToRegex(pattern: string): RegExp {
|
|
172
|
+
let regexStr = ''
|
|
173
|
+
let i = 0
|
|
174
|
+
while (i < pattern.length) {
|
|
175
|
+
const ch = pattern[i]!
|
|
176
|
+
if (ch === '*' && pattern[i + 1] === '*') {
|
|
177
|
+
// ** — match zero or more path segments
|
|
178
|
+
regexStr += '.*'
|
|
179
|
+
i += 2
|
|
180
|
+
// Skip trailing slash after **
|
|
181
|
+
if (pattern[i] === '/') i++
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (ch === '*') {
|
|
185
|
+
regexStr += '[^/]*'
|
|
186
|
+
i++
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if (ch === '?') {
|
|
190
|
+
regexStr += '.'
|
|
191
|
+
i++
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
// Escape regex special chars
|
|
195
|
+
if ('\\^$.|+()[]{}'.includes(ch)) {
|
|
196
|
+
regexStr += '\\' + ch
|
|
197
|
+
} else {
|
|
198
|
+
regexStr += ch
|
|
199
|
+
}
|
|
200
|
+
i++
|
|
201
|
+
}
|
|
202
|
+
return new RegExp('^' + regexStr + '$')
|
|
203
|
+
}
|
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, loadInferenceHookConfig } 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'
|
|
@@ -157,6 +157,13 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
157
157
|
const inferenceHookConfig = loadInferenceHookConfig()
|
|
158
158
|
engine.setInferenceHookConfig(inferenceHookConfig)
|
|
159
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
|
+
|
|
160
167
|
// Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
|
|
161
168
|
if (config.permission) {
|
|
162
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
|
@@ -335,3 +335,46 @@ export interface InferenceCheckResponse {
|
|
|
335
335
|
verdict: 'allow' | 'deny'
|
|
336
336
|
reason?: string
|
|
337
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
|
}
|
package/src/ui/commands.ts
CHANGED
|
@@ -3052,6 +3052,223 @@ Permission level is controlled by /config permission <level>.`,
|
|
|
3052
3052
|
// Security / Audit — security review checklist
|
|
3053
3053
|
// ═══════════════════════════════════════════════════════════════
|
|
3054
3054
|
|
|
3055
|
+
const promptAuditCmd: CommandHandler = async () => {
|
|
3056
|
+
const { existsSync, readFileSync, readdirSync, statSync } = await import('node:fs')
|
|
3057
|
+
const { join, extname, basename } = await import('node:path')
|
|
3058
|
+
const cwd = process.cwd()
|
|
3059
|
+
|
|
3060
|
+
// Patterns that suggest prompts written for older/less-capable models
|
|
3061
|
+
const AUDIT_RULES: Array<{
|
|
3062
|
+
id: string
|
|
3063
|
+
pattern: RegExp
|
|
3064
|
+
severity: 'low' | 'medium' | 'high'
|
|
3065
|
+
message: string
|
|
3066
|
+
suggestion: string
|
|
3067
|
+
}> = [
|
|
3068
|
+
{
|
|
3069
|
+
id: 'over-explaining',
|
|
3070
|
+
pattern:
|
|
3071
|
+
/you are (a|an) (helpful |friendly |knowledgeable )?(AI |language model |assistant)/i,
|
|
3072
|
+
severity: 'low',
|
|
3073
|
+
message: 'Self-identification preamble ("You are a...")',
|
|
3074
|
+
suggestion: "Modern models don't need role preamble. Remove or shorten to 1 line.",
|
|
3075
|
+
},
|
|
3076
|
+
{
|
|
3077
|
+
id: 'step-by-step',
|
|
3078
|
+
pattern: /(think|reason|work) (step[ -]?by[ -]?step|through this|carefully about this)/i,
|
|
3079
|
+
severity: 'low',
|
|
3080
|
+
message: 'Step-by-step reasoning prompt',
|
|
3081
|
+
suggestion: 'Reasoning models handle this natively. Remove for token savings.',
|
|
3082
|
+
},
|
|
3083
|
+
{
|
|
3084
|
+
id: 'do-not-list',
|
|
3085
|
+
pattern:
|
|
3086
|
+
/(do not|never|you must not|don't|under no circumstances).*\n.*(do not|never|you must not|don't)/i,
|
|
3087
|
+
severity: 'medium',
|
|
3088
|
+
message: 'Multiple "do not" constraints',
|
|
3089
|
+
suggestion: 'Modern models need fewer prohibitions. Consolidate to 1-2 key constraints.',
|
|
3090
|
+
},
|
|
3091
|
+
{
|
|
3092
|
+
id: 'token-waste',
|
|
3093
|
+
pattern:
|
|
3094
|
+
/(remember|keep in mind|it is important to note|please note that|i want to emphasize)/i,
|
|
3095
|
+
severity: 'low',
|
|
3096
|
+
message: 'Filler emphasis phrases ("remember", "please note")',
|
|
3097
|
+
suggestion: 'Remove filler. State the instruction directly.',
|
|
3098
|
+
},
|
|
3099
|
+
{
|
|
3100
|
+
id: 'format-over-spec',
|
|
3101
|
+
pattern: /(you must (always |only )?respond (in|with|using) (JSON|XML|YAML|markdown|HTML))/i,
|
|
3102
|
+
severity: 'low',
|
|
3103
|
+
message: 'Over-specified output format',
|
|
3104
|
+
suggestion: 'Modern models follow format instructions with fewer words. Shorten.',
|
|
3105
|
+
},
|
|
3106
|
+
{
|
|
3107
|
+
id: 'example-overload',
|
|
3108
|
+
pattern: /((?:example|for instance|e\.g\.,).*\n){3,}/i,
|
|
3109
|
+
severity: 'low',
|
|
3110
|
+
message: 'Three or more examples in sequence',
|
|
3111
|
+
suggestion: '1-2 examples suffice for modern models. Remove redundant ones.',
|
|
3112
|
+
},
|
|
3113
|
+
{
|
|
3114
|
+
id: 'old-model-ref',
|
|
3115
|
+
pattern: /(GPT-3|GPT-3\.5|Claude\s*[12][^3]|text-davinci|command-r|llama\s*2)/i,
|
|
3116
|
+
severity: 'high',
|
|
3117
|
+
message: 'Reference to older model',
|
|
3118
|
+
suggestion: 'Update model references to current generation (Sonnet 5, Opus 5, GPT-5, etc.).',
|
|
3119
|
+
},
|
|
3120
|
+
{
|
|
3121
|
+
id: 'long-preamble',
|
|
3122
|
+
pattern: /^.{200,}$/m,
|
|
3123
|
+
severity: 'medium',
|
|
3124
|
+
message: 'Very long single-line instructions (200+ chars)',
|
|
3125
|
+
suggestion: 'Break into bullet points. Modern models parse structured prompts better.',
|
|
3126
|
+
},
|
|
3127
|
+
{
|
|
3128
|
+
id: 'verbose-constraint',
|
|
3129
|
+
pattern:
|
|
3130
|
+
/you (must|should|shall) (always |only |never )?(ensure|guarantee|verify|validate|confirm|cross-check|double-check)/i,
|
|
3131
|
+
severity: 'medium',
|
|
3132
|
+
message: 'Overly verbose constraint language',
|
|
3133
|
+
suggestion: 'Replace "you must ensure that" with imperative: "Ensure".',
|
|
3134
|
+
},
|
|
3135
|
+
]
|
|
3136
|
+
|
|
3137
|
+
// Files to scan
|
|
3138
|
+
const scanDirs = [
|
|
3139
|
+
join(cwd, '.mipham', 'skills'),
|
|
3140
|
+
join(cwd, '.mipham', 'rules'),
|
|
3141
|
+
join(cwd, '.claude'),
|
|
3142
|
+
]
|
|
3143
|
+
const skillFiles: string[] = []
|
|
3144
|
+
// Also check standard project files
|
|
3145
|
+
const projectFiles = [join(cwd, 'CLAUDE.md'), join(cwd, 'MIPHAM.md'), join(cwd, 'PRODUCT.md')]
|
|
3146
|
+
|
|
3147
|
+
// Collect skill/rules files
|
|
3148
|
+
for (const dir of scanDirs) {
|
|
3149
|
+
try {
|
|
3150
|
+
if (!existsSync(dir)) continue
|
|
3151
|
+
const walk = (d: string) => {
|
|
3152
|
+
const entries = readdirSync(d)
|
|
3153
|
+
for (const e of entries) {
|
|
3154
|
+
const fp = join(d, e)
|
|
3155
|
+
const st = statSync(fp)
|
|
3156
|
+
if (st.isDirectory()) {
|
|
3157
|
+
walk(fp)
|
|
3158
|
+
continue
|
|
3159
|
+
}
|
|
3160
|
+
if (['.md', '.SKILL.md', '.mipham-skill.md', '.txt'].some((ext) => fp.endsWith(ext))) {
|
|
3161
|
+
skillFiles.push(fp)
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
walk(dir)
|
|
3166
|
+
} catch {
|
|
3167
|
+
/* skip */
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
const allFiles = [...new Set([...projectFiles.filter((f) => existsSync(f)), ...skillFiles])]
|
|
3172
|
+
|
|
3173
|
+
if (allFiles.length === 0) {
|
|
3174
|
+
return {
|
|
3175
|
+
content: [
|
|
3176
|
+
'🔍 Prompt Audit',
|
|
3177
|
+
'',
|
|
3178
|
+
'No prompt files found to audit.',
|
|
3179
|
+
'Place skills in .mipham/skills/ and rules in .mipham/rules/.',
|
|
3180
|
+
].join('\n'),
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
// Scan each file
|
|
3185
|
+
const results: Array<{
|
|
3186
|
+
file: string
|
|
3187
|
+
line: number
|
|
3188
|
+
ruleId: string
|
|
3189
|
+
severity: string
|
|
3190
|
+
message: string
|
|
3191
|
+
suggestion: string
|
|
3192
|
+
}> = []
|
|
3193
|
+
|
|
3194
|
+
for (const fp of allFiles) {
|
|
3195
|
+
try {
|
|
3196
|
+
const content = readFileSync(fp, 'utf-8')
|
|
3197
|
+
const lines = content.split('\n')
|
|
3198
|
+
for (const rule of AUDIT_RULES) {
|
|
3199
|
+
// Multi-line pattern: check full content
|
|
3200
|
+
if (rule.id === 'do-not-list' || rule.id === 'example-overload') {
|
|
3201
|
+
if (rule.pattern.test(content)) {
|
|
3202
|
+
// Find approx line
|
|
3203
|
+
const match = rule.pattern.exec(content)
|
|
3204
|
+
const idx = match ? content.slice(0, match.index).split('\n').length : 1
|
|
3205
|
+
results.push({
|
|
3206
|
+
file: fp.replace(cwd + '/', ''),
|
|
3207
|
+
line: idx,
|
|
3208
|
+
ruleId: rule.id,
|
|
3209
|
+
severity: rule.severity,
|
|
3210
|
+
message: rule.message,
|
|
3211
|
+
suggestion: rule.suggestion,
|
|
3212
|
+
})
|
|
3213
|
+
}
|
|
3214
|
+
continue
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// Line-by-line patterns
|
|
3218
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3219
|
+
const line = lines[i]!
|
|
3220
|
+
if (rule.pattern.test(line)) {
|
|
3221
|
+
results.push({
|
|
3222
|
+
file: fp.replace(cwd + '/', ''),
|
|
3223
|
+
line: i + 1,
|
|
3224
|
+
ruleId: rule.id,
|
|
3225
|
+
severity: rule.severity,
|
|
3226
|
+
message: rule.message,
|
|
3227
|
+
suggestion: rule.suggestion,
|
|
3228
|
+
})
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
} catch {
|
|
3233
|
+
/* skip unreadable files */
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// Format output
|
|
3238
|
+
const severityIcon: Record<string, string> = { high: '🔴', medium: '🟡', low: '🟢' }
|
|
3239
|
+
const grouped: Record<string, typeof results> = {}
|
|
3240
|
+
for (const r of results) {
|
|
3241
|
+
const key = r.file
|
|
3242
|
+
if (!grouped[key]) grouped[key] = []
|
|
3243
|
+
grouped[key]!.push(r)
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
const output: string[] = [
|
|
3247
|
+
`🔍 Prompt Audit — ${allFiles.length} file(s) scanned, ${results.length} finding(s)`,
|
|
3248
|
+
'',
|
|
3249
|
+
]
|
|
3250
|
+
|
|
3251
|
+
if (results.length === 0) {
|
|
3252
|
+
output.push('✅ All prompts look optimized for modern models. No issues found.')
|
|
3253
|
+
} else {
|
|
3254
|
+
for (const [file, items] of Object.entries(grouped)) {
|
|
3255
|
+
output.push(`📄 ${file} (${items!.length} finding${items!.length > 1 ? 's' : ''})`)
|
|
3256
|
+
for (const item of items!) {
|
|
3257
|
+
output.push(
|
|
3258
|
+
` ${severityIcon[item.severity] || '⚪'} L${item.line}: [${item.ruleId}] ${item.message}`,
|
|
3259
|
+
)
|
|
3260
|
+
output.push(` → ${item.suggestion}`)
|
|
3261
|
+
}
|
|
3262
|
+
output.push('')
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
output.push('─'.repeat(60))
|
|
3267
|
+
output.push('Scanned: ' + allFiles.map((f) => basename(f)).join(', '))
|
|
3268
|
+
|
|
3269
|
+
return { content: output.join('\n') }
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3055
3272
|
const securityCmd: CommandHandler = async () => {
|
|
3056
3273
|
const findings: string[] = []
|
|
3057
3274
|
const ok: string[] = []
|
|
@@ -3691,6 +3908,128 @@ Run \`mipham agents\` for the interactive dashboard.`,
|
|
|
3691
3908
|
// Artifacts
|
|
3692
3909
|
// ═══════════════════════════════════════════════════════════════
|
|
3693
3910
|
|
|
3911
|
+
const forkCmd: CommandHandler = async (ctx, args) => {
|
|
3912
|
+
const prompt = args.join(' ')
|
|
3913
|
+
if (!prompt.trim()) {
|
|
3914
|
+
return {
|
|
3915
|
+
content: [
|
|
3916
|
+
'Usage: /fork <task description>',
|
|
3917
|
+
'',
|
|
3918
|
+
'Fork a task into an isolated worktree with auto commit/push.',
|
|
3919
|
+
'',
|
|
3920
|
+
'Workflow:',
|
|
3921
|
+
' 1. Creates a new git worktree on branch worktree/<slug>',
|
|
3922
|
+
' 2. Spawns a background agent to work on the task',
|
|
3923
|
+
' 3. Auto-commits changes with Co-Authored-By on completion',
|
|
3924
|
+
' 4. Auto-pushes to origin',
|
|
3925
|
+
' 5. Reports branch and worktree path in the result',
|
|
3926
|
+
'',
|
|
3927
|
+
'Examples:',
|
|
3928
|
+
' /fork Refactor the auth module',
|
|
3929
|
+
' /fork Add input validation to all API endpoints',
|
|
3930
|
+
].join('\n'),
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
const { execSync } = await import('node:child_process')
|
|
3935
|
+
const { join } = await import('node:path')
|
|
3936
|
+
|
|
3937
|
+
try {
|
|
3938
|
+
execSync('git rev-parse --git-dir', { stdio: 'ignore' })
|
|
3939
|
+
} catch {
|
|
3940
|
+
return { content: '/fork requires a git repository.' }
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
const agentViewManager = ctx.engine.getAgentViewManager?.()
|
|
3944
|
+
if (!agentViewManager) {
|
|
3945
|
+
return { content: 'Agent View manager is initializing...' }
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
const slug = prompt
|
|
3949
|
+
.toLowerCase()
|
|
3950
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
3951
|
+
.replace(/^-|-$/g, '')
|
|
3952
|
+
.slice(0, 40)
|
|
3953
|
+
const name = `${slug}-${Date.now().toString(36)}`
|
|
3954
|
+
const branch = `worktree/${name}`
|
|
3955
|
+
const wtPath = join(process.cwd(), '.claude', 'worktrees', name)
|
|
3956
|
+
|
|
3957
|
+
try {
|
|
3958
|
+
execSync(`git worktree add -b ${branch} ${wtPath} HEAD`, { stdio: 'ignore', timeout: 30_000 })
|
|
3959
|
+
} catch (err) {
|
|
3960
|
+
try {
|
|
3961
|
+
execSync(`git worktree remove --force ${wtPath}`, { stdio: 'ignore' })
|
|
3962
|
+
} catch {
|
|
3963
|
+
/* ok */
|
|
3964
|
+
}
|
|
3965
|
+
try {
|
|
3966
|
+
execSync(`git branch -D ${branch}`, { stdio: 'ignore' })
|
|
3967
|
+
} catch {
|
|
3968
|
+
/* ok */
|
|
3969
|
+
}
|
|
3970
|
+
return {
|
|
3971
|
+
content: `Worktree creation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
const session = agentViewManager.create(prompt, prompt, {
|
|
3976
|
+
provider: ctx.providerId,
|
|
3977
|
+
model: ctx.modelId,
|
|
3978
|
+
})
|
|
3979
|
+
agentViewManager.addMessage(session.id, { role: 'user', content: prompt })
|
|
3980
|
+
agentViewManager.updateStatus(session.id, 'working')
|
|
3981
|
+
session.worktree = wtPath
|
|
3982
|
+
session.branch = branch
|
|
3983
|
+
session.kind = 'forked'
|
|
3984
|
+
|
|
3985
|
+
const bgReg = (await import('../agent/background-registry')).getBackgroundAgentRegistry()
|
|
3986
|
+
bgReg.spawn(
|
|
3987
|
+
prompt,
|
|
3988
|
+
'general',
|
|
3989
|
+
async (_signal) => {
|
|
3990
|
+
const { SubAgent } = await import('../agent/sub-agent')
|
|
3991
|
+
const sa = new SubAgent(ctx.engine.getRegistry(), ctx.engine.getTools())
|
|
3992
|
+
const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {})
|
|
3993
|
+
try {
|
|
3994
|
+
const { execSync: ex } = await import('node:child_process')
|
|
3995
|
+
ex(`git -C ${wtPath} add -A`, { stdio: 'ignore', timeout: 10_000 })
|
|
3996
|
+
const st = ex(`git -C ${wtPath} status --porcelain`, { encoding: 'utf-8', timeout: 10_000 })
|
|
3997
|
+
if (st.trim()) {
|
|
3998
|
+
ex(
|
|
3999
|
+
`git -C ${wtPath} commit -m "feat: ${prompt.slice(0, 60)}\n\nCo-Authored-By: Claude <noreply@anthropic.com>"`,
|
|
4000
|
+
{ stdio: 'ignore', timeout: 10_000 },
|
|
4001
|
+
)
|
|
4002
|
+
ex(`git push origin ${branch}`, { stdio: 'ignore', timeout: 30_000 })
|
|
4003
|
+
}
|
|
4004
|
+
} catch {
|
|
4005
|
+
/* best-effort */
|
|
4006
|
+
}
|
|
4007
|
+
return [
|
|
4008
|
+
`## Fork done: ${prompt}`,
|
|
4009
|
+
'',
|
|
4010
|
+
`Branch: \`${branch}\``,
|
|
4011
|
+
`Worktree: \`${wtPath}\``,
|
|
4012
|
+
'',
|
|
4013
|
+
result || '(no output)',
|
|
4014
|
+
].join('\n')
|
|
4015
|
+
},
|
|
4016
|
+
'forked',
|
|
4017
|
+
)
|
|
4018
|
+
|
|
4019
|
+
return {
|
|
4020
|
+
content: [
|
|
4021
|
+
`✓ Forked: ${session.id}`,
|
|
4022
|
+
` Task: ${prompt.slice(0, 80)}`,
|
|
4023
|
+
` Branch: ${branch}`,
|
|
4024
|
+
` Worktree: ${wtPath}`,
|
|
4025
|
+
` Status: working`,
|
|
4026
|
+
'',
|
|
4027
|
+
'Agent will auto-commit + push on completion.',
|
|
4028
|
+
'Use /agents to track.',
|
|
4029
|
+
].join('\n'),
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
|
|
3694
4033
|
async function openBrowser(url: string): Promise<void> {
|
|
3695
4034
|
const cmd =
|
|
3696
4035
|
process.platform === 'darwin'
|
|
@@ -3857,6 +4196,7 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
3857
4196
|
'/recommend': 'Project',
|
|
3858
4197
|
'/security': 'Project',
|
|
3859
4198
|
'/audit': 'Project',
|
|
4199
|
+
'/prompt-audit': 'Code Quality',
|
|
3860
4200
|
'/ide': 'Environment',
|
|
3861
4201
|
'/terminal-setup': 'Environment',
|
|
3862
4202
|
'/memory': 'Environment',
|
|
@@ -3866,6 +4206,7 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
3866
4206
|
'/feedback': 'Account',
|
|
3867
4207
|
'/agents': 'Agents',
|
|
3868
4208
|
'/bg': 'Agents',
|
|
4209
|
+
'/fork': 'Agents',
|
|
3869
4210
|
'/artifact': 'Artifacts',
|
|
3870
4211
|
'/schedule': 'Other',
|
|
3871
4212
|
'/commit': 'Workflow',
|
|
@@ -3996,6 +4337,7 @@ registry.set('/permissions', permissionsCmd)
|
|
|
3996
4337
|
registry.set('/add-dir', addDirCmd)
|
|
3997
4338
|
registry.set('/security', securityCmd)
|
|
3998
4339
|
registry.set('/audit', securityCmd)
|
|
4340
|
+
registry.set('/prompt-audit', promptAuditCmd)
|
|
3999
4341
|
|
|
4000
4342
|
// Environment
|
|
4001
4343
|
registry.set('/theme', themeCmd)
|
|
@@ -4010,6 +4352,7 @@ registry.set('/logout', logoutCmd)
|
|
|
4010
4352
|
registry.set('/feedback', feedbackCmd)
|
|
4011
4353
|
registry.set('/agents', agentsCmd)
|
|
4012
4354
|
registry.set('/bg', bgCmd)
|
|
4355
|
+
registry.set('/fork', forkCmd)
|
|
4013
4356
|
|
|
4014
4357
|
// Artifacts
|
|
4015
4358
|
registry.set('/artifact', artifactBaseCmd)
|
|
@@ -4119,6 +4462,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
4119
4462
|
'/recommend': 'Analyze project + recommend skills & setup',
|
|
4120
4463
|
'/security': 'Security review checklist',
|
|
4121
4464
|
'/audit': 'Same as /security',
|
|
4465
|
+
'/prompt-audit': 'Audit prompts for modern model optimization',
|
|
4122
4466
|
'/ide': 'IDE integration guide',
|
|
4123
4467
|
'/terminal-setup': 'Shell & terminal config',
|
|
4124
4468
|
'/memory': 'Manage AI memories',
|
|
@@ -4129,6 +4473,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
4129
4473
|
'/feedback': 'Send feedback',
|
|
4130
4474
|
'/agents': 'Agent view dashboard',
|
|
4131
4475
|
'/bg': 'Run a background agent task',
|
|
4476
|
+
'/fork': 'Fork task into isolated worktree',
|
|
4132
4477
|
'/artifact': 'Manage artifacts',
|
|
4133
4478
|
'/schedule': 'View scheduled tasks and cron jobs',
|
|
4134
4479
|
'/commit': 'Generate commit message and review staged changes',
|