@miphamai/cli 0.12.0 → 0.14.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/skills/workflows/audit.js +51 -0
- package/skills/workflows/hunt.js +95 -0
- package/skills/workflows/judge.js +55 -0
- package/skills/workflows/migrate.js +92 -0
- package/skills/workflows/research.js +87 -0
- package/skills/workflows/review.js +92 -0
- 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/instructions.ts +28 -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/agent/workflow.ts +67 -3
- 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 +451 -0
- package/src/workflow/primitives/loop.ts +103 -0
- package/src/workflow/primitives/verify.ts +275 -0
- package/src/workflow/runtime.ts +17 -1
|
@@ -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/instructions.ts
CHANGED
|
@@ -66,6 +66,34 @@ export class InstructionsLoader {
|
|
|
66
66
|
parts.push(this.skillsReminder)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Inject workflow auto-generation guidance
|
|
70
|
+
parts.push(`## Workflow Auto-Generation
|
|
71
|
+
|
|
72
|
+
When a task involves 3+ independent subtasks, multi-file operations,
|
|
73
|
+
or unknown-size discovery, generate a workflow script and execute it
|
|
74
|
+
via the Workflow tool instead of running agents sequentially. The
|
|
75
|
+
orchestration itself is code (zero tokens for inter-agent coordination).
|
|
76
|
+
|
|
77
|
+
Prefer workflows for: audits across many files, web research with multiple
|
|
78
|
+
sources, code migrations touching many files, security scans, bug hunts
|
|
79
|
+
with unknown scope, multi-dimensional code reviews.
|
|
80
|
+
|
|
81
|
+
Available primitives: agent(), parallel(), pipeline(), verify(),
|
|
82
|
+
judge(), loopUntilConvergence(), phase(), log(), args, budget.
|
|
83
|
+
|
|
84
|
+
Key rules:
|
|
85
|
+
- Default to pipeline() — only use parallel() barrier when a stage
|
|
86
|
+
genuinely needs all prior results at once
|
|
87
|
+
- Edge logic (flatten, dedupe, filter) is plain JS — not agent calls
|
|
88
|
+
- Use verify() on edges where confidence matters
|
|
89
|
+
- Use loopUntilConvergence() for discovery tasks with unknown size
|
|
90
|
+
|
|
91
|
+
When a workflow completes successfully, offer to save it:
|
|
92
|
+
"Workflow complete. Save this script? /workflow save <name>"
|
|
93
|
+
|
|
94
|
+
Script format: export const meta = { name, description, phases: [...] }
|
|
95
|
+
// script body using primitives...`)
|
|
96
|
+
|
|
69
97
|
return parts.join('\n\n---\n\n')
|
|
70
98
|
}
|
|
71
99
|
|
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
|
+
}
|
|
@@ -5,9 +5,56 @@ import type { QueryEngine } from '../../core/engine'
|
|
|
5
5
|
export const workflowTool: ToolDefinition = {
|
|
6
6
|
name: 'Workflow',
|
|
7
7
|
description:
|
|
8
|
-
'Execute a
|
|
9
|
-
'
|
|
10
|
-
'and
|
|
8
|
+
'Execute a workflow script that orchestrates multiple subagents deterministically. ' +
|
|
9
|
+
'Workflows run in the background — this tool returns immediately with a task ID, ' +
|
|
10
|
+
'and a <task-notification> arrives when the workflow completes. Use /workflows to watch live progress.\n\n' +
|
|
11
|
+
'A workflow structures work across many agents — to be comprehensive (decompose and cover in parallel), ' +
|
|
12
|
+
'to be confident (independent perspectives and adversarial checks before committing), ' +
|
|
13
|
+
'or to take on scale one context cannot hold (migrations, audits, broad sweeps). ' +
|
|
14
|
+
'The script is where you encode that structure: what fans out, what verifies, what synthesizes.\n\n' +
|
|
15
|
+
'ONLY call this tool when the task benefits from multi-agent orchestration. ' +
|
|
16
|
+
'For a simple single-agent lookup or edit, use the Agent tool or direct tools instead.\n\n' +
|
|
17
|
+
'## Primitives\n\n' +
|
|
18
|
+
'- agent(prompt: string, opts?: {label?, phase?, schema?, model?, effort?, isolation?}): Promise<any> — spawn a subagent. ' +
|
|
19
|
+
'Without schema, returns final text as string. With schema (JSON Schema), returns validated object — retries on mismatch.\n' +
|
|
20
|
+
'- parallel(thunks: Array<() => Promise<any>>): Promise<any[]> — BARRIER: runs all thunks concurrently, waits for all. ' +
|
|
21
|
+
'Failed thunks resolve to null. Use filter(Boolean) before consuming results.\n' +
|
|
22
|
+
'- pipeline(items: T[], ...stages): Promise<any[]> — NO barrier: each item flows through all stages independently. ' +
|
|
23
|
+
'Item A can be in stage 3 while item B is still in stage 1. DEFAULT choice for multi-stage work.\n' +
|
|
24
|
+
'- verify(finding, opts): Promise<VerifyResult> — adversarial/perspective/consensus quality gate. ' +
|
|
25
|
+
'Spawns skeptics or lens-based judges, applies threshold, returns {survives, votes, score}.\n' +
|
|
26
|
+
'- judge(attempts, opts): Promise<JudgeResult> — judge panel: N attempts scored by M judges across K criteria. ' +
|
|
27
|
+
'Returns winner with optional synthesis grafting runner-up ideas.\n' +
|
|
28
|
+
'- loopUntilConvergence(opts): Promise<LoopUntilConvergenceResult> — convergent discovery loop. ' +
|
|
29
|
+
'Fans out finders repeatedly, deduplicates against seen-set (NOT confirmed-set), ' +
|
|
30
|
+
'stops after N consecutive dry rounds or maxRounds. Optionally verifies each finding.\n' +
|
|
31
|
+
'- phase(title: string): void — start a new progress group\n' +
|
|
32
|
+
'- log(message: string): void — emit progress message\n' +
|
|
33
|
+
'- args: any — verbatim args passed to Workflow tool\n' +
|
|
34
|
+
'- budget: {total, spent(), remaining()} — token budget tracking\n\n' +
|
|
35
|
+
'## Topology Selection Guide\n\n' +
|
|
36
|
+
'DEFAULT TO pipeline(). Only reach for a barrier (parallel between stages) when you genuinely ' +
|
|
37
|
+
'need ALL prior-stage results together.\n\n' +
|
|
38
|
+
'A barrier is correct ONLY when stage N needs cross-item context from all of stage N-1: ' +
|
|
39
|
+
'dedup/merge across the full result set, early-exit if total count is zero, cross-finding comparison.\n\n' +
|
|
40
|
+
'A barrier is NOT justified by: flatten/map/filter (do it inside a pipeline stage), ' +
|
|
41
|
+
'conceptually separate stages, cleaner code — barrier latency is real and measurable.\n\n' +
|
|
42
|
+
'- Diamond (fan-out → reduce → synthesize): market scans, audits, research\n' +
|
|
43
|
+
'- Pipeline (no barrier): each item flows independently — DEFAULT\n' +
|
|
44
|
+
'- Loop-until-convergence: unknown-size discovery (bugs, vulnerabilities, edge cases)\n' +
|
|
45
|
+
'- Judge panel: multiple competing approaches, pick best + graft runner-ups\n' +
|
|
46
|
+
'- Verifier-on-edge: quality gates before results reach downstream\n\n' +
|
|
47
|
+
'## Critical Rules\n\n' +
|
|
48
|
+
'- EDGE LOGIC IS FREE: flatten, dedupe, filter in plain JavaScript — NOT agent calls. ' +
|
|
49
|
+
'results.flatMap(...) and a Set are deterministic, instant, zero tokens.\n' +
|
|
50
|
+
'- seen-set dedup for loops, NOT confirmed-set — rejected findings would otherwise revive every round.\n' +
|
|
51
|
+
'- Each node should have bounded input, validated output (schema), and one clear purpose.\n' +
|
|
52
|
+
'- Model tiering: use cheaper models for repetitive extraction/classification nodes, ' +
|
|
53
|
+
'expensive models for synthesis/judgment nodes.\n\n' +
|
|
54
|
+
'## Script Format\n\n' +
|
|
55
|
+
'Every script MUST begin with: export const meta = { name, description, phases: [{title, detail}] }\n' +
|
|
56
|
+
'The meta object must be a PURE LITERAL — no variables, function calls, or template interpolation.\n' +
|
|
57
|
+
'Use the SAME phase titles in meta.phases as in phase() calls.',
|
|
11
58
|
category: 'agent',
|
|
12
59
|
permission: 'ask',
|
|
13
60
|
parameters: {
|
|
@@ -60,6 +107,23 @@ export const workflowTool: ToolDefinition = {
|
|
|
60
107
|
resumeFromRunId,
|
|
61
108
|
)
|
|
62
109
|
|
|
110
|
+
// Persist last-run state for /workflow save
|
|
111
|
+
try {
|
|
112
|
+
const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
|
|
113
|
+
const { join } = await import('node:path')
|
|
114
|
+
const workflowsDir = join(process.cwd(), '.claude', 'workflows')
|
|
115
|
+
if (!existsSync(workflowsDir)) {
|
|
116
|
+
mkdirSync(workflowsDir, { recursive: true })
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(
|
|
119
|
+
join(workflowsDir, '.last-run.json'),
|
|
120
|
+
JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
|
|
121
|
+
'utf-8',
|
|
122
|
+
)
|
|
123
|
+
} catch {
|
|
124
|
+
// best-effort — don't fail the workflow if state persistence fails
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
let content = `Workflow ${runId} completed.\n\n`
|
|
64
128
|
if (resumeFromRunId) {
|
|
65
129
|
content += `Cache: ${cacheHits} hits · ${cacheMisses} live\n\n`
|
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),
|