@miphamai/cli 0.15.0 → 0.16.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/message-bus.ts +13 -1
- package/src/agent/sub-agent.ts +19 -2
- package/src/config/defaults.ts +4 -0
- package/src/config/preferences.ts +45 -0
- package/src/core/context.ts +9 -0
- package/src/core/engine.ts +12 -1
- package/src/core/permission-config.ts +70 -6
- package/src/core/permission.ts +45 -9
- package/src/core/session-store.ts +4 -2
- package/src/index.tsx +50 -2
- package/src/providers/registry.ts +8 -0
- package/src/shared/sanitize.ts +38 -0
- package/src/shared/types.ts +18 -0
- package/src/skills/registry.ts +69 -2
- package/src/tools/exec/bash.ts +37 -5
- package/src/tools/index.ts +5 -2
- package/src/ui/app.tsx +16 -3
- package/src/ui/commands.ts +31 -50
- package/src/workflow/runtime.ts +19 -31
- package/src/workflow/sandbox.ts +22 -10
package/package.json
CHANGED
package/src/agent/message-bus.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* const unread = bus.poll('bg-1') // messages addressed TO bg-1
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
export type AgentMessageType = 'message' | 'warning' | 'error'
|
|
15
|
+
|
|
14
16
|
export interface AgentMessage {
|
|
15
17
|
id: string
|
|
16
18
|
from: string
|
|
@@ -19,6 +21,7 @@ export interface AgentMessage {
|
|
|
19
21
|
message: string
|
|
20
22
|
timestamp: Date
|
|
21
23
|
read: boolean
|
|
24
|
+
type: AgentMessageType
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export class AgentMessageBus {
|
|
@@ -29,7 +32,7 @@ export class AgentMessageBus {
|
|
|
29
32
|
* Post a message from one agent to another.
|
|
30
33
|
* Returns the message ID.
|
|
31
34
|
*/
|
|
32
|
-
post(from: string, to: string, summary: string, message: string): string {
|
|
35
|
+
post(from: string, to: string, summary: string, message: string, type: AgentMessageType = 'message'): string {
|
|
33
36
|
const id = `msg-${++this.idCounter}`
|
|
34
37
|
this.messages.push({
|
|
35
38
|
id,
|
|
@@ -39,6 +42,7 @@ export class AgentMessageBus {
|
|
|
39
42
|
message,
|
|
40
43
|
timestamp: new Date(),
|
|
41
44
|
read: false,
|
|
45
|
+
type,
|
|
42
46
|
})
|
|
43
47
|
return id
|
|
44
48
|
}
|
|
@@ -91,6 +95,14 @@ export class AgentMessageBus {
|
|
|
91
95
|
return this.messages.filter((m) => m.to === agentId && !m.read).length
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Get all unread warning messages for an agent.
|
|
100
|
+
* Does NOT mark them as read — use markRead() for that.
|
|
101
|
+
*/
|
|
102
|
+
getWarnings(agentId: string): AgentMessage[] {
|
|
103
|
+
return this.messages.filter((m) => m.to === agentId && m.type === 'warning' && !m.read)
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
/**
|
|
95
107
|
* Prune messages older than maxAgeMs. Returns the number pruned.
|
|
96
108
|
*/
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ToolDefinition } from '../shared/index.ts'
|
|
|
3
3
|
import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
|
|
4
4
|
import { createAgentContext } from './agent-context'
|
|
5
5
|
import { getBackgroundAgentRegistry } from './background-registry'
|
|
6
|
+
import { getMessageBus } from './message-bus'
|
|
6
7
|
import type { HookEngine } from '../core/hooks'
|
|
7
8
|
import type { PermissionSystem } from '../core/permission'
|
|
8
9
|
|
|
@@ -156,6 +157,22 @@ export class SubAgent {
|
|
|
156
157
|
// 'inherit' means use parent model
|
|
157
158
|
const resolvedModel = modelToUse === 'inherit' ? model : modelToUse
|
|
158
159
|
|
|
160
|
+
// Validate resolved model exists in registry; fall back to parent model with warning
|
|
161
|
+
let finalModel = resolvedModel
|
|
162
|
+
if (resolvedModel !== model) {
|
|
163
|
+
const modelExists = this.registry.findModel(resolvedModel) !== undefined
|
|
164
|
+
if (!modelExists) {
|
|
165
|
+
const warnMsg = `Warning: model "${resolvedModel}" not found in provider registry. Falling back to "${model}".`
|
|
166
|
+
console.warn(warnMsg)
|
|
167
|
+
|
|
168
|
+
// Post warning to message bus for UI display
|
|
169
|
+
const bus = getMessageBus()
|
|
170
|
+
bus.post('system', 'main', `Sub-agent model fallback: ${resolvedModel} → ${model}`, warnMsg, 'warning')
|
|
171
|
+
|
|
172
|
+
finalModel = model
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
159
176
|
const chunks: string[] = []
|
|
160
177
|
const MAX_TOOL_TURNS = options.maxTurns || 5
|
|
161
178
|
|
|
@@ -173,7 +190,7 @@ export class SubAgent {
|
|
|
173
190
|
let turnText = ''
|
|
174
191
|
|
|
175
192
|
for await (const chunk of provider.chat({
|
|
176
|
-
model:
|
|
193
|
+
model: finalModel,
|
|
177
194
|
messages: currentMessages,
|
|
178
195
|
systemPrompt: currentSystemPrompt,
|
|
179
196
|
tools: toolDefs,
|
|
@@ -251,7 +268,7 @@ export class SubAgent {
|
|
|
251
268
|
cwd: execCwd,
|
|
252
269
|
sessionId: 'sub-agent',
|
|
253
270
|
provider: '',
|
|
254
|
-
model:
|
|
271
|
+
model: finalModel,
|
|
255
272
|
})
|
|
256
273
|
|
|
257
274
|
currentMessages.push({
|
package/src/config/defaults.ts
CHANGED
|
@@ -13,6 +13,10 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
13
13
|
defaultModel: 'deepseek-v4-pro',
|
|
14
14
|
permission: 'auto',
|
|
15
15
|
providers: DEFAULT_PROVIDERS,
|
|
16
|
+
marketplace: {
|
|
17
|
+
strictKnownMarketplaces: [],
|
|
18
|
+
blockedMarketplaces: [],
|
|
19
|
+
},
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight preferences store backed by ~/.mipham/preferences.json.
|
|
3
|
+
* Used for persisting user-level UI state (e.g. last code review effort).
|
|
4
|
+
*
|
|
5
|
+
* NOT for config.yml settings — those belong in the YAML config system.
|
|
6
|
+
* NOT for secrets — this file is plain JSON, not encrypted.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
|
|
12
|
+
const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
|
|
13
|
+
|
|
14
|
+
function readPrefs(): Record<string, string> {
|
|
15
|
+
try {
|
|
16
|
+
if (!existsSync(PREFS_PATH)) return {}
|
|
17
|
+
const raw = readFileSync(PREFS_PATH, 'utf-8')
|
|
18
|
+
const parsed: unknown = JSON.parse(raw)
|
|
19
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
|
|
20
|
+
return parsed as Record<string, string>
|
|
21
|
+
} catch {
|
|
22
|
+
return {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writePrefs(prefs: Record<string, string>): void {
|
|
27
|
+
try {
|
|
28
|
+
const dir = join(homedir(), '.mipham')
|
|
29
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
30
|
+
writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600, encoding: 'utf-8' })
|
|
31
|
+
} catch {
|
|
32
|
+
// best-effort; never crash because preferences failed to save
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getPreference(key: string, defaultValue: string): string {
|
|
37
|
+
const prefs = readPrefs()
|
|
38
|
+
return prefs[key] ?? defaultValue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setPreference(key: string, value: string): void {
|
|
42
|
+
const prefs = readPrefs()
|
|
43
|
+
prefs[key] = value
|
|
44
|
+
writePrefs(prefs)
|
|
45
|
+
}
|
package/src/core/context.ts
CHANGED
|
@@ -55,6 +55,15 @@ export class ContextManager {
|
|
|
55
55
|
|
|
56
56
|
constructor(private config: ContextConfig) {}
|
|
57
57
|
|
|
58
|
+
/** Dynamically update the max token limit (e.g., when switching models). */
|
|
59
|
+
updateMaxTokens(maxTokens: number): void {
|
|
60
|
+
this.config.maxTokens = maxTokens
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getMaxTokens(): number {
|
|
64
|
+
return this.config.maxTokens
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
/** Set an optional LLM summarizer for intelligent compaction. */
|
|
59
68
|
setSummarizer(fn: Summarizer): void {
|
|
60
69
|
this.summarizer = fn
|
package/src/core/engine.ts
CHANGED
|
@@ -41,7 +41,7 @@ export class QueryEngine {
|
|
|
41
41
|
private registry: ProviderRegistry,
|
|
42
42
|
private context: ContextManager,
|
|
43
43
|
private tools: Map<string, ToolDefinition>,
|
|
44
|
-
private permission: PermissionSystem = new PermissionSystem('
|
|
44
|
+
private permission: PermissionSystem = new PermissionSystem('default'),
|
|
45
45
|
) {}
|
|
46
46
|
|
|
47
47
|
/** Register a hook engine for pre/post tool-use lifecycle events. */
|
|
@@ -821,6 +821,17 @@ export class QueryEngine {
|
|
|
821
821
|
|
|
822
822
|
switchProvider(providerId: string, modelId?: string): void {
|
|
823
823
|
this.registry.switchProvider(providerId, modelId)
|
|
824
|
+
// Update context manager's max tokens to match the new model's context window
|
|
825
|
+
if (modelId) {
|
|
826
|
+
const model = this.registry.findModel(modelId)
|
|
827
|
+
if (model) {
|
|
828
|
+
const DISABLE_1M = process.env.MIPHAM_DISABLE_1M_CONTEXT === '1'
|
|
829
|
+
const maxTokens = (DISABLE_1M && model.contextWindow > 200_000)
|
|
830
|
+
? 200_000
|
|
831
|
+
: model.contextWindow
|
|
832
|
+
this.context.updateMaxTokens(maxTokens)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
824
835
|
}
|
|
825
836
|
|
|
826
837
|
/** Wrap context compaction with PreCompact/PostCompact hooks. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PermissionConfig, PermissionMode } from '../shared/index.ts'
|
|
1
|
+
import type { PermissionConfig, PermissionMode, PermissionRestrictions } from '../shared/index.ts'
|
|
2
2
|
|
|
3
3
|
const DEFAULT_CONFIG: PermissionConfig = {
|
|
4
4
|
mode: 'default',
|
|
@@ -15,11 +15,15 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
|
|
|
15
15
|
mode: (raw.mode as PermissionMode) || DEFAULT_CONFIG.mode,
|
|
16
16
|
allow: Array.isArray(raw.allow) ? raw.allow : [...DEFAULT_CONFIG.allow],
|
|
17
17
|
deny: Array.isArray(raw.deny) ? raw.deny : [...DEFAULT_CONFIG.deny],
|
|
18
|
+
restrictions: raw.restrictions ?? undefined,
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Permission modes ordered from least to most permissive.
|
|
24
|
+
* Used to enforce maxAllowedMode: any mode ranked higher than the cap is forbidden.
|
|
25
|
+
*/
|
|
26
|
+
export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
|
|
23
27
|
'default',
|
|
24
28
|
'acceptEdits',
|
|
25
29
|
'plan',
|
|
@@ -28,7 +32,67 @@ export const MODE_CYCLE: PermissionMode[] = [
|
|
|
28
32
|
'bypassPermissions',
|
|
29
33
|
]
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
/** Valid mode transition order for Shift+Tab cycling. */
|
|
36
|
+
export const MODE_CYCLE: PermissionMode[] = [...PERMISSION_MODE_HIERARCHY]
|
|
37
|
+
|
|
38
|
+
/** Resolve which modes are actually permitted given the restrictions. */
|
|
39
|
+
export function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
|
|
40
|
+
let allowed = [...MODE_CYCLE]
|
|
41
|
+
|
|
42
|
+
if (restrictions?.forbiddenModes && restrictions.forbiddenModes.length > 0) {
|
|
43
|
+
const forbidden = new Set(restrictions.forbiddenModes)
|
|
44
|
+
allowed = allowed.filter((m) => !forbidden.has(m))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (restrictions?.maxAllowedMode) {
|
|
48
|
+
const capIdx = PERMISSION_MODE_HIERARCHY.indexOf(restrictions.maxAllowedMode)
|
|
49
|
+
if (capIdx >= 0) {
|
|
50
|
+
allowed = allowed.filter((m) => PERMISSION_MODE_HIERARCHY.indexOf(m) <= capIdx)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return allowed
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Check whether a given mode is permitted under the restrictions. */
|
|
58
|
+
export function isModeAllowed(
|
|
59
|
+
mode: PermissionMode,
|
|
60
|
+
restrictions?: PermissionRestrictions,
|
|
61
|
+
): boolean {
|
|
62
|
+
return getAllowedModes(restrictions).includes(mode)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return the highest allowed mode at or below `desired` given the restrictions.
|
|
67
|
+
* Used to silently downgrade when a forbidden mode is requested.
|
|
68
|
+
*/
|
|
69
|
+
export function clampMode(
|
|
70
|
+
desired: PermissionMode,
|
|
71
|
+
restrictions?: PermissionRestrictions,
|
|
72
|
+
): PermissionMode {
|
|
73
|
+
const allowed = getAllowedModes(restrictions)
|
|
74
|
+
if (allowed.includes(desired)) return desired
|
|
75
|
+
|
|
76
|
+
// Walk downward through the hierarchy to find the closest allowed mode
|
|
77
|
+
const desiredIdx = PERMISSION_MODE_HIERARCHY.indexOf(desired)
|
|
78
|
+
for (let i = desiredIdx - 1; i >= 0; i--) {
|
|
79
|
+
const candidate = PERMISSION_MODE_HIERARCHY[i]!
|
|
80
|
+
if (allowed.includes(candidate)) return candidate
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: return the first allowed mode (should always be at least 'default')
|
|
84
|
+
return allowed[0] ?? 'default'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function nextMode(
|
|
88
|
+
current: PermissionMode,
|
|
89
|
+
restrictions?: PermissionRestrictions,
|
|
90
|
+
): PermissionMode {
|
|
91
|
+
const allowed = getAllowedModes(restrictions)
|
|
92
|
+
const idx = allowed.indexOf(current)
|
|
93
|
+
if (idx === -1) {
|
|
94
|
+
// Current mode is not in the allowed set — clamp then find next
|
|
95
|
+
return clampMode(current, restrictions)
|
|
96
|
+
}
|
|
97
|
+
return allowed[(idx + 1) % allowed.length]!
|
|
34
98
|
}
|
package/src/core/permission.ts
CHANGED
|
@@ -3,10 +3,11 @@ import type {
|
|
|
3
3
|
PermissionMode,
|
|
4
4
|
PermissionLevel,
|
|
5
5
|
PermissionRule,
|
|
6
|
+
PermissionRestrictions,
|
|
6
7
|
} from '../shared/index.ts'
|
|
7
8
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
8
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
9
|
-
import { loadPermissionConfig, nextMode, MODE_CYCLE } from './permission-config'
|
|
10
|
+
import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
|
|
10
11
|
|
|
11
12
|
const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
|
|
12
13
|
|
|
@@ -25,6 +26,9 @@ export class PermissionSystem {
|
|
|
25
26
|
private checkCache = new Map<string, PermissionLevel>()
|
|
26
27
|
private cacheMode: PermissionMode | null = null
|
|
27
28
|
|
|
29
|
+
// ── Org-level restrictions (P0 security) ──
|
|
30
|
+
private restrictions: PermissionRestrictions | undefined = undefined
|
|
31
|
+
|
|
28
32
|
/** Invalidate the permission cache (called on any rule/mode change). */
|
|
29
33
|
private invalidateCache(): void {
|
|
30
34
|
this.checkCache.clear()
|
|
@@ -44,7 +48,7 @@ export class PermissionSystem {
|
|
|
44
48
|
// ── Mode management ──
|
|
45
49
|
|
|
46
50
|
setMode(mode: PermissionMode): void {
|
|
47
|
-
this.mode = mode
|
|
51
|
+
this.mode = clampMode(mode, this.restrictions)
|
|
48
52
|
this.invalidateCache()
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -53,10 +57,26 @@ export class PermissionSystem {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
cycleMode(): PermissionMode {
|
|
56
|
-
this.mode = nextMode(this.mode)
|
|
60
|
+
this.mode = nextMode(this.mode, this.restrictions)
|
|
57
61
|
return this.mode
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
// ── Restrictions (P0: org-level policy gap) ──
|
|
65
|
+
|
|
66
|
+
/** Apply org-level permission restrictions. Overwrites any previous restrictions. */
|
|
67
|
+
setRestrictions(restrictions: PermissionRestrictions | undefined): void {
|
|
68
|
+
this.restrictions = restrictions
|
|
69
|
+
// Re-clamp current mode against new restrictions
|
|
70
|
+
if (restrictions) {
|
|
71
|
+
this.mode = clampMode(this.mode, restrictions)
|
|
72
|
+
}
|
|
73
|
+
this.invalidateCache()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getRestrictions(): PermissionRestrictions | undefined {
|
|
77
|
+
return this.restrictions
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
// ── Rule management ──
|
|
61
81
|
|
|
62
82
|
allow(rule: string): void {
|
|
@@ -71,11 +91,21 @@ export class PermissionSystem {
|
|
|
71
91
|
this.askRules.push(compileRule(rule, 'ask'))
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
loadConfig(raw: {
|
|
94
|
+
loadConfig(raw: {
|
|
95
|
+
mode?: string
|
|
96
|
+
allow?: string[]
|
|
97
|
+
deny?: string[]
|
|
98
|
+
restrictions?: PermissionRestrictions
|
|
99
|
+
}): void {
|
|
75
100
|
const config = loadPermissionConfig(
|
|
76
|
-
raw as Partial<{
|
|
101
|
+
raw as Partial<{
|
|
102
|
+
mode: PermissionMode
|
|
103
|
+
allow: string[]
|
|
104
|
+
deny: string[]
|
|
105
|
+
restrictions: PermissionRestrictions
|
|
106
|
+
}>,
|
|
77
107
|
)
|
|
78
|
-
this.mode = config.mode
|
|
108
|
+
this.mode = clampMode(config.mode, config.restrictions ?? this.restrictions)
|
|
79
109
|
|
|
80
110
|
this.allowRules = []
|
|
81
111
|
this.denyRules = []
|
|
@@ -88,6 +118,10 @@ export class PermissionSystem {
|
|
|
88
118
|
this.denyRules.push(compileRule(rule, 'deny'))
|
|
89
119
|
}
|
|
90
120
|
|
|
121
|
+
if (config.restrictions) {
|
|
122
|
+
this.restrictions = config.restrictions
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
this.invalidateCache()
|
|
92
126
|
}
|
|
93
127
|
|
|
@@ -246,9 +280,11 @@ export class PermissionSystem {
|
|
|
246
280
|
|
|
247
281
|
setDefaultLevel(level: PermissionLevel): void {
|
|
248
282
|
// Map legacy 3-level to new mode
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
else
|
|
283
|
+
let newMode: PermissionMode
|
|
284
|
+
if (level === 'auto') newMode = 'auto'
|
|
285
|
+
else if (level === 'bypass') newMode = 'bypassPermissions'
|
|
286
|
+
else newMode = 'default'
|
|
287
|
+
this.mode = clampMode(newMode, this.restrictions)
|
|
252
288
|
this.invalidateCache()
|
|
253
289
|
}
|
|
254
290
|
|
|
@@ -17,6 +17,7 @@ export interface SessionMetadata {
|
|
|
17
17
|
provider: string
|
|
18
18
|
model: string
|
|
19
19
|
messageCount: number
|
|
20
|
+
cwd?: string
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface StoredSession {
|
|
@@ -44,7 +45,7 @@ export class SessionStore {
|
|
|
44
45
|
static save(
|
|
45
46
|
name: string,
|
|
46
47
|
messages: Message[],
|
|
47
|
-
metadata?: { provider?: string; model?: string },
|
|
48
|
+
metadata?: { provider?: string; model?: string; cwd?: string },
|
|
48
49
|
): void {
|
|
49
50
|
ensureDir()
|
|
50
51
|
const path = filePath(name)
|
|
@@ -57,6 +58,7 @@ export class SessionStore {
|
|
|
57
58
|
provider: metadata?.provider || 'unknown',
|
|
58
59
|
model: metadata?.model || 'unknown',
|
|
59
60
|
messageCount: messages.length,
|
|
61
|
+
cwd: metadata?.cwd,
|
|
60
62
|
},
|
|
61
63
|
messages,
|
|
62
64
|
}
|
|
@@ -132,7 +134,7 @@ export class SessionStore {
|
|
|
132
134
|
/**
|
|
133
135
|
* Auto-save with timestamp-based name.
|
|
134
136
|
*/
|
|
135
|
-
static autoSave(messages: Message[], metadata?: { provider?: string; model?: string }): string {
|
|
137
|
+
static autoSave(messages: Message[], metadata?: { provider?: string; model?: string; cwd?: string }): string {
|
|
136
138
|
const name = `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
|
|
137
139
|
SessionStore.save(name, messages, metadata)
|
|
138
140
|
return name
|
package/src/index.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
2
3
|
import { render } from 'ink'
|
|
3
4
|
import { App } from './ui/app'
|
|
4
5
|
import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
|
|
@@ -85,12 +86,52 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
85
86
|
// Initialize plugin manager
|
|
86
87
|
const pluginManager = new PluginManager()
|
|
87
88
|
|
|
89
|
+
// Generate session name for tracking (used by /cd to persist cwd)
|
|
90
|
+
const sessionName = options.resume || `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
|
|
91
|
+
|
|
88
92
|
// Initialize context — restore saved session if available
|
|
89
|
-
|
|
93
|
+
// Read the active model's context window for dynamic max-token sizing.
|
|
94
|
+
// MIPHAM_DISABLE_1M_CONTEXT=1 caps the effective window at 200K even for
|
|
95
|
+
// models that support larger contexts (e.g. 1M).
|
|
96
|
+
// When the model is unknown (not in any provider's model list), assume a
|
|
97
|
+
// conservative 128K context window. Set MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1
|
|
98
|
+
// to restore the old behavior (default 200K fallback for unknown models).
|
|
99
|
+
const activeModel = registry.findModel(defaultModel)
|
|
100
|
+
let modelContextWindow: number
|
|
101
|
+
if (activeModel) {
|
|
102
|
+
modelContextWindow = activeModel.contextWindow
|
|
103
|
+
} else if (process.env.MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT === '1') {
|
|
104
|
+
modelContextWindow = 200_000 // old behavior: default fallback
|
|
105
|
+
} else {
|
|
106
|
+
modelContextWindow = 128_000 // conservative assumption for unknown models
|
|
107
|
+
console.error(
|
|
108
|
+
`[mipham] ⚠ Unknown model "${defaultModel}": assuming 128K context window. ` +
|
|
109
|
+
`Set MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1 to disable.`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DISABLE_1M = process.env.MIPHAM_DISABLE_1M_CONTEXT === '1'
|
|
114
|
+
const contextMaxTokens = (DISABLE_1M && modelContextWindow > 200_000)
|
|
115
|
+
? 200_000
|
|
116
|
+
: modelContextWindow
|
|
117
|
+
|
|
118
|
+
if (DISABLE_1M && modelContextWindow <= 200_000) {
|
|
119
|
+
console.error('[mipham] ⚠ MIPHAM_DISABLE_1M_CONTEXT is set but auto-compaction is not holding the session to 200K — model context window is already ≤ 200K')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const context = new ContextManager({ maxTokens: contextMaxTokens, compactionThreshold: 0.9 })
|
|
90
123
|
|
|
91
124
|
if (options.resume) {
|
|
92
125
|
const saved = SessionStore.load(options.resume)
|
|
93
126
|
if (saved) {
|
|
127
|
+
// Restore working directory if saved
|
|
128
|
+
if (saved.metadata.cwd && existsSync(saved.metadata.cwd)) {
|
|
129
|
+
try {
|
|
130
|
+
process.chdir(saved.metadata.cwd)
|
|
131
|
+
} catch {
|
|
132
|
+
// cwd may no longer exist; silently continue
|
|
133
|
+
}
|
|
134
|
+
}
|
|
94
135
|
for (const msg of saved.messages) {
|
|
95
136
|
context.addMessage(msg)
|
|
96
137
|
}
|
|
@@ -169,6 +210,11 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
169
210
|
engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
|
|
170
211
|
}
|
|
171
212
|
|
|
213
|
+
// Apply org-level permission restrictions (P0: bypassPermissions policy gap)
|
|
214
|
+
if (config.permissionRestrictions) {
|
|
215
|
+
engine.getPermission().setRestrictions(config.permissionRestrictions)
|
|
216
|
+
}
|
|
217
|
+
|
|
172
218
|
// Initialize agent registry and load plugin agents/skills/MCP/hooks
|
|
173
219
|
const agentRegistry = new AgentRegistry()
|
|
174
220
|
agentRegistry.loadUserAgents()
|
|
@@ -190,9 +236,10 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
190
236
|
const saveAndExit = () => {
|
|
191
237
|
artifactServer.stop()
|
|
192
238
|
if (context.getMessageCount() > 0) {
|
|
193
|
-
SessionStore.
|
|
239
|
+
SessionStore.save(sessionName, context.getMessages(), {
|
|
194
240
|
provider: defaultProvider,
|
|
195
241
|
model: defaultModel,
|
|
242
|
+
cwd: process.cwd(),
|
|
196
243
|
})
|
|
197
244
|
}
|
|
198
245
|
process.exit(0)
|
|
@@ -211,6 +258,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
211
258
|
skillsLoader={skillsLoader}
|
|
212
259
|
pluginManager={pluginManager}
|
|
213
260
|
version={options.version}
|
|
261
|
+
sessionId={sessionName}
|
|
214
262
|
/>,
|
|
215
263
|
)
|
|
216
264
|
await waitUntilExit()
|
|
@@ -64,6 +64,14 @@ export class ProviderRegistry {
|
|
|
64
64
|
return provider.config.models.filter((m) => m.status === 'active')
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
findModel(modelId: string): ModelInfo | undefined {
|
|
68
|
+
for (const provider of this.providers.values()) {
|
|
69
|
+
const model = provider.config.models.find((m) => m.id === modelId)
|
|
70
|
+
if (model) return model
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
68
76
|
const provider = this.getActive()
|
|
69
77
|
yield* provider.chat({ ...req, model: req.model || this.activeModelId })
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unicode sanitization for tool inputs.
|
|
3
|
+
* Strips invisible/control characters that could hide command content
|
|
4
|
+
* from visual inspection or enable homoglyph attacks.
|
|
5
|
+
*
|
|
6
|
+
* Claude Code v2.1.223 parity: hidden command text via tabs/invisible Unicode.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DANGEROUS_UNICODE = /[]/g
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Strip dangerous invisible Unicode characters from a string.
|
|
13
|
+
* - Zero-width: U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+200E/F (LTR/RTL marks)
|
|
14
|
+
* - Bidi controls: U+202A-E, U+2066-9
|
|
15
|
+
* - Word joiner: U+2060
|
|
16
|
+
* - BOM: U+FEFF
|
|
17
|
+
*/
|
|
18
|
+
export function stripDangerousUnicode(input: string): string {
|
|
19
|
+
if (!input) return input
|
|
20
|
+
return input.replace(DANGEROUS_UNICODE, '')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursively sanitize all string values in a params object.
|
|
25
|
+
*/
|
|
26
|
+
export function sanitizeParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
27
|
+
const result: Record<string, unknown> = {}
|
|
28
|
+
for (const [key, value] of Object.entries(params)) {
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
result[key] = stripDangerousUnicode(value)
|
|
31
|
+
} else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
32
|
+
result[key] = sanitizeParams(value as Record<string, unknown>)
|
|
33
|
+
} else {
|
|
34
|
+
result[key] = value
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result
|
|
38
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -160,8 +160,16 @@ export interface MiphamConfig {
|
|
|
160
160
|
defaultProvider: string
|
|
161
161
|
defaultModel: string
|
|
162
162
|
permission: ToolPermission
|
|
163
|
+
/** Org-level permission restrictions (forbiddenModes, maxAllowedMode). */
|
|
164
|
+
permissionRestrictions?: PermissionRestrictions
|
|
163
165
|
providers: ProviderConfig[]
|
|
164
166
|
skills?: { paths: string[]; mcpServers: McpServerConfig[] }
|
|
167
|
+
marketplace?: {
|
|
168
|
+
/** If set, only allow installs from matching repos (e.g. ["One-Mipham/*"]) */
|
|
169
|
+
strictKnownMarketplaces?: string[]
|
|
170
|
+
/** Block installs from matching repos (e.g. ["malicious-org/*"]) */
|
|
171
|
+
blockedMarketplaces?: string[]
|
|
172
|
+
}
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
export interface McpServerConfig {
|
|
@@ -287,10 +295,20 @@ export type PermissionMode =
|
|
|
287
295
|
/** Backward-compatible alias: PermissionMode plus legacy 'ask' and 'bypass' */
|
|
288
296
|
export type PermissionLevel = PermissionMode | 'ask' | 'bypass'
|
|
289
297
|
|
|
298
|
+
/** Org-level restrictions that cap or forbid specific permission modes. */
|
|
299
|
+
export interface PermissionRestrictions {
|
|
300
|
+
/** Modes that may not be entered (cycle skips them). */
|
|
301
|
+
forbiddenModes?: PermissionMode[]
|
|
302
|
+
/** Ceiling — modes ranked higher (more permissive) than this are treated as forbidden. */
|
|
303
|
+
maxAllowedMode?: PermissionMode
|
|
304
|
+
}
|
|
305
|
+
|
|
290
306
|
export interface PermissionConfig {
|
|
291
307
|
mode: PermissionMode
|
|
292
308
|
allow: string[]
|
|
293
309
|
deny: string[]
|
|
310
|
+
/** Optional org-level restrictions enforced on every mode transition. */
|
|
311
|
+
restrictions?: PermissionRestrictions
|
|
294
312
|
}
|
|
295
313
|
|
|
296
314
|
export interface PermissionRuleEntry {
|
package/src/skills/registry.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from 'node:path'
|
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { spawnSync } from 'node:child_process'
|
|
12
12
|
import { URL } from 'node:url'
|
|
13
|
+
import type { MiphamConfig } from '../shared/types.js'
|
|
13
14
|
|
|
14
15
|
const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
|
|
15
16
|
|
|
@@ -141,13 +142,58 @@ export function searchSkills(query: string): SkillEntry[] {
|
|
|
141
142
|
)
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Match a marketplace pattern against an owner/repo.
|
|
147
|
+
*
|
|
148
|
+
* - "owner/*" → matches any repo under that owner
|
|
149
|
+
* - "owner/repo" → exact match
|
|
150
|
+
*/
|
|
151
|
+
function matchOwnerPattern(pattern: string, owner: string, repo: string): boolean {
|
|
152
|
+
if (pattern.endsWith('/*')) {
|
|
153
|
+
return pattern.slice(0, -2) === owner
|
|
154
|
+
}
|
|
155
|
+
return pattern === repo
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check whether a GitHub URL (or any URL) is allowed by the marketplace config.
|
|
160
|
+
*
|
|
161
|
+
* Rules (in order):
|
|
162
|
+
* 1. No config → allow everything
|
|
163
|
+
* 2. blockedMarketplaces matched → deny (deny wins over allow)
|
|
164
|
+
* 3. strictKnownMarketplaces set → deny unless matched
|
|
165
|
+
* 4. Non-GitHub URL with no strict list → allow
|
|
166
|
+
* 5. Non-GitHub URL with strict list → deny
|
|
167
|
+
*/
|
|
168
|
+
function isMarketplaceAllowed(url: string, config?: MiphamConfig['marketplace']): boolean {
|
|
169
|
+
if (!config) return true
|
|
170
|
+
const { strictKnownMarketplaces, blockedMarketplaces } = config
|
|
171
|
+
const match = url.match(/github\.com\/([^/]+\/[^/]+)/)
|
|
172
|
+
if (!match) return !strictKnownMarketplaces?.length // non-GitHub: allow only if no strict list
|
|
173
|
+
const repo = match[1]! // "owner/repo"
|
|
174
|
+
const [owner] = repo.split('/')
|
|
175
|
+
|
|
176
|
+
// Check blocked first (deny wins)
|
|
177
|
+
if (blockedMarketplaces?.some((pattern) => matchOwnerPattern(pattern, owner!, repo))) return false
|
|
178
|
+
// Check strict allowlist
|
|
179
|
+
if (
|
|
180
|
+
strictKnownMarketplaces?.length &&
|
|
181
|
+
!strictKnownMarketplaces.some((pattern) => matchOwnerPattern(pattern, owner!, repo))
|
|
182
|
+
)
|
|
183
|
+
return false
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
|
|
144
187
|
/**
|
|
145
188
|
* Install a skill from a GitHub repository.
|
|
146
189
|
*
|
|
147
190
|
* Clones the repo to a temp directory, copies the skill file(s),
|
|
148
191
|
* and cleans up.
|
|
149
192
|
*/
|
|
150
|
-
export function installSkill(
|
|
193
|
+
export function installSkill(
|
|
194
|
+
skillName: string,
|
|
195
|
+
marketplaceConfig?: MiphamConfig['marketplace'],
|
|
196
|
+
): InstallResult {
|
|
151
197
|
const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
|
|
152
198
|
if (!entry) {
|
|
153
199
|
return {
|
|
@@ -157,6 +203,15 @@ export function installSkill(skillName: string): InstallResult {
|
|
|
157
203
|
}
|
|
158
204
|
}
|
|
159
205
|
|
|
206
|
+
// Check marketplace restrictions
|
|
207
|
+
if (!isMarketplaceAllowed(entry.url, marketplaceConfig)) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
name: skillName,
|
|
211
|
+
message: `Skill "${skillName}" is from a blocked or unapproved marketplace: ${entry.url}`,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
160
215
|
// Built-in skills are already available — no download needed
|
|
161
216
|
if (entry.builtin) {
|
|
162
217
|
return {
|
|
@@ -214,7 +269,19 @@ export function installSkill(skillName: string): InstallResult {
|
|
|
214
269
|
/**
|
|
215
270
|
* Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
|
|
216
271
|
*/
|
|
217
|
-
export function installSkillFromUrl(
|
|
272
|
+
export function installSkillFromUrl(
|
|
273
|
+
url: string,
|
|
274
|
+
marketplaceConfig?: MiphamConfig['marketplace'],
|
|
275
|
+
): InstallResult {
|
|
276
|
+
// Check marketplace restrictions
|
|
277
|
+
if (!isMarketplaceAllowed(url, marketplaceConfig)) {
|
|
278
|
+
return {
|
|
279
|
+
success: false,
|
|
280
|
+
name: url,
|
|
281
|
+
message: `URL is from a blocked or unapproved marketplace: ${url}`,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
218
285
|
// Derive skill name from URL
|
|
219
286
|
const name =
|
|
220
287
|
url
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -52,6 +52,18 @@ const BLOCKED_PATTERNS = [
|
|
|
52
52
|
/\bscp\s+.*(?:\.ssh|\.aws|\.env)/,
|
|
53
53
|
// Write to system paths
|
|
54
54
|
/>\s*\/(?:etc|usr|boot|sys|proc)\//,
|
|
55
|
+
// P0 hardening — ANSI-C quoting bypass (e.g. $'\x72\x6d' = rm)
|
|
56
|
+
/\$'\\x[0-9a-fA-F]{2}/,
|
|
57
|
+
// P0 hardening — nested interpreter invocation
|
|
58
|
+
/\b(?:bash|sh|zsh|dash|ksh)\s+-c\b/,
|
|
59
|
+
// P0 hardening — eval builtin (obfuscation vector)
|
|
60
|
+
/\beval\s+/,
|
|
61
|
+
// P0 hardening — exec redirect bypass (e.g. exec >/dev/sda)
|
|
62
|
+
/\bexec\s+\d*>/,
|
|
63
|
+
// P0 hardening — source/dot builtin (script sourcing)
|
|
64
|
+
/\bsource\s+/,
|
|
65
|
+
// P0 hardening — base64 decode + pipe
|
|
66
|
+
/\bbase64\s+(?:-d|--decode)\b/,
|
|
55
67
|
]
|
|
56
68
|
|
|
57
69
|
const BLOCKED_COMMANDS = [
|
|
@@ -71,18 +83,38 @@ const BLOCKED_COMMANDS = [
|
|
|
71
83
|
'init',
|
|
72
84
|
'telinit',
|
|
73
85
|
'systemctl',
|
|
86
|
+
'eval',
|
|
87
|
+
'exec',
|
|
88
|
+
'source',
|
|
89
|
+
'.',
|
|
74
90
|
]
|
|
75
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Normalize ANSI-C escape sequences ($'...') in a command string.
|
|
94
|
+
* Converts hex escapes (\xHH) back to literal characters so that
|
|
95
|
+
* existing patterns (e.g. rm -rf /) still catch obfuscated payloads.
|
|
96
|
+
*/
|
|
97
|
+
function normalizeEscapes(command: string): string {
|
|
98
|
+
return command.replace(/\$'([^']*)'/g, (_, inner: string) =>
|
|
99
|
+
inner.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
|
|
100
|
+
String.fromCharCode(parseInt(hex, 16)),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
function isBlocked(command: string): string | null {
|
|
77
|
-
//
|
|
78
|
-
const
|
|
106
|
+
// Normalize ANSI-C escape sequences for defense-in-depth
|
|
107
|
+
const normalized = normalizeEscapes(command)
|
|
108
|
+
|
|
109
|
+
// Check exact blocked commands (on normalized command)
|
|
110
|
+
const firstWord = normalized.trim().split(/\s+/)[0]
|
|
79
111
|
if (firstWord && BLOCKED_COMMANDS.includes(firstWord)) {
|
|
80
|
-
return `Command "${firstWord}"
|
|
112
|
+
return `Command "${firstWord}" rejected by security policy.`
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
// Check dangerous patterns
|
|
115
|
+
// Check dangerous patterns (on both original and normalized)
|
|
84
116
|
for (const pattern of BLOCKED_PATTERNS) {
|
|
85
|
-
if (pattern.test(command)) {
|
|
117
|
+
if (pattern.test(command) || pattern.test(normalized)) {
|
|
86
118
|
return `Command rejected by security policy. Pattern matched: ${pattern.source.slice(0, 40)}...`
|
|
87
119
|
}
|
|
88
120
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition, ToolResult } from '../shared/index.ts'
|
|
2
|
+
import { sanitizeParams } from '../shared/sanitize'
|
|
2
3
|
import { readTool } from './file/read'
|
|
3
4
|
import { writeTool } from './file/write'
|
|
4
5
|
import { editTool } from './file/edit'
|
|
@@ -96,11 +97,13 @@ function withValidation(tool: ToolDefinition): ToolDefinition {
|
|
|
96
97
|
return {
|
|
97
98
|
...tool,
|
|
98
99
|
async execute(params, ctx): Promise<ToolResult> {
|
|
99
|
-
|
|
100
|
+
// Sanitize dangerous Unicode from all inputs
|
|
101
|
+
const cleanParams = sanitizeParams(params)
|
|
102
|
+
const errors = validateParams(schema, cleanParams)
|
|
100
103
|
if (errors.length > 0) {
|
|
101
104
|
return { success: false, content: '', error: `Invalid parameters: ${errors.join('; ')}` }
|
|
102
105
|
}
|
|
103
|
-
return tool.execute(
|
|
106
|
+
return tool.execute(cleanParams, ctx)
|
|
104
107
|
},
|
|
105
108
|
}
|
|
106
109
|
}
|
package/src/ui/app.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React, { useState, useCallback, useRef, useMemo } from 'react'
|
|
1
|
+
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
3
|
import type { QueryEngine } from '../core/engine'
|
|
4
4
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
5
5
|
import type { SkillsLoader } from '../skills/loader'
|
|
6
6
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
7
|
+
import { getPreference, setPreference } from '../config/preferences'
|
|
7
8
|
import { AgentRegistry } from '../agent/agent-registry'
|
|
8
9
|
import { ChatPanel } from './chat'
|
|
9
10
|
import { InputBar } from './input'
|
|
@@ -26,6 +27,7 @@ interface AppProps {
|
|
|
26
27
|
skillsLoader?: SkillsLoader
|
|
27
28
|
pluginManager?: PluginManager
|
|
28
29
|
version?: string
|
|
30
|
+
sessionId?: string
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface ToolMeta {
|
|
@@ -84,6 +86,7 @@ export function App({
|
|
|
84
86
|
skillsLoader,
|
|
85
87
|
pluginManager,
|
|
86
88
|
version,
|
|
89
|
+
sessionId,
|
|
87
90
|
}: AppProps) {
|
|
88
91
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
89
92
|
const [isLoading, setIsLoading] = useState(false)
|
|
@@ -100,6 +103,12 @@ export function App({
|
|
|
100
103
|
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
|
|
101
104
|
const [agentElapsed, setAgentElapsed] = useState(0)
|
|
102
105
|
|
|
106
|
+
// Load persisted effort on mount
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const saved = getPreference('lastCodeReviewEffort', 'high')
|
|
109
|
+
setEffort(saved)
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
103
112
|
// Agent elapsed timer
|
|
104
113
|
React.useEffect(() => {
|
|
105
114
|
if (!agentProgress) {
|
|
@@ -131,15 +140,19 @@ export function App({
|
|
|
131
140
|
providerId,
|
|
132
141
|
modelId,
|
|
133
142
|
version: version || '0.0.0',
|
|
143
|
+
sessionId: sessionId || '',
|
|
134
144
|
setSessionTitle: (title: string) => setSessionTitle(title),
|
|
135
145
|
setFastMode: (on: boolean) => setFastMode(on),
|
|
136
|
-
setEffort: (level: string) =>
|
|
146
|
+
setEffort: (level: string) => {
|
|
147
|
+
setEffort(level)
|
|
148
|
+
setPreference('lastCodeReviewEffort', level)
|
|
149
|
+
},
|
|
137
150
|
setFocusMode: (on: boolean) => setFocusMode(on),
|
|
138
151
|
setGoal: (text: string) => setGoalText(text),
|
|
139
152
|
skillsLoader,
|
|
140
153
|
pluginManager,
|
|
141
154
|
}),
|
|
142
|
-
[engine, config, providerId, modelId, skillsLoader, pluginManager],
|
|
155
|
+
[engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId],
|
|
143
156
|
)
|
|
144
157
|
|
|
145
158
|
const handleSubmit = useCallback(
|
package/src/ui/commands.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { SkillsLoader } from '../skills/loader'
|
|
|
10
10
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
11
11
|
import { McpClient } from '../mcp/client'
|
|
12
12
|
import { NPM_INSTALL_COMMAND, NPM_UPDATE_COMMAND, PACKAGE_VERSION } from '../shared/index.ts'
|
|
13
|
+
import { getPreference } from '../config/preferences'
|
|
13
14
|
|
|
14
15
|
export interface CommandContext {
|
|
15
16
|
engine: QueryEngine
|
|
@@ -17,6 +18,7 @@ export interface CommandContext {
|
|
|
17
18
|
providerId: string
|
|
18
19
|
modelId: string
|
|
19
20
|
version: string
|
|
21
|
+
sessionId: string
|
|
20
22
|
// Callbacks for commands that mutate App state
|
|
21
23
|
setSessionTitle: (title: string) => void
|
|
22
24
|
setFastMode: (on: boolean) => void
|
|
@@ -799,7 +801,7 @@ const browseSkillsCmd: CommandHandler = async () => {
|
|
|
799
801
|
return { content: lines.join('\n') }
|
|
800
802
|
}
|
|
801
803
|
|
|
802
|
-
const installSkillCmd: CommandHandler = async (
|
|
804
|
+
const installSkillCmd: CommandHandler = async (ctx, args) => {
|
|
803
805
|
const { installSkill, installSkillFromUrl } = await import('../skills/registry')
|
|
804
806
|
|
|
805
807
|
const target = args[0]
|
|
@@ -807,12 +809,13 @@ const installSkillCmd: CommandHandler = async (_ctx, args) => {
|
|
|
807
809
|
return { content: 'Usage: /install-skill <skill-name> or /install-skill <url>' }
|
|
808
810
|
}
|
|
809
811
|
|
|
812
|
+
const marketplaceConfig = ctx.config.marketplace
|
|
810
813
|
let result: { success: boolean; name: string; message: string }
|
|
811
814
|
|
|
812
815
|
if (target.startsWith('http://') || target.startsWith('https://')) {
|
|
813
|
-
result = installSkillFromUrl(target)
|
|
816
|
+
result = installSkillFromUrl(target, marketplaceConfig)
|
|
814
817
|
} else {
|
|
815
|
-
result = installSkill(target)
|
|
818
|
+
result = installSkill(target, marketplaceConfig)
|
|
816
819
|
}
|
|
817
820
|
|
|
818
821
|
return {
|
|
@@ -1681,7 +1684,7 @@ function gitDiffBridgeCmd(opts: {
|
|
|
1681
1684
|
label: string
|
|
1682
1685
|
noChangesHint: string
|
|
1683
1686
|
runningMsg: string
|
|
1684
|
-
forwardToAI: string
|
|
1687
|
+
forwardToAI: string | (() => string)
|
|
1685
1688
|
}): CommandHandler {
|
|
1686
1689
|
return async () => {
|
|
1687
1690
|
try {
|
|
@@ -1692,7 +1695,7 @@ function gitDiffBridgeCmd(opts: {
|
|
|
1692
1695
|
}
|
|
1693
1696
|
return {
|
|
1694
1697
|
content: `─ ${opts.label} ─\n\n${opts.runningMsg}\n\nChanged files:\n${diff}`,
|
|
1695
|
-
forwardToAI: opts.forwardToAI,
|
|
1698
|
+
forwardToAI: typeof opts.forwardToAI === 'function' ? opts.forwardToAI() : opts.forwardToAI,
|
|
1696
1699
|
}
|
|
1697
1700
|
} catch {
|
|
1698
1701
|
return {
|
|
@@ -1708,8 +1711,8 @@ const codeReviewCmd = gitDiffBridgeCmd({
|
|
|
1708
1711
|
'No uncommitted changes to review.\n\nTo review a specific file: /code-review path/to/file.ts',
|
|
1709
1712
|
runningMsg:
|
|
1710
1713
|
'Reviewing uncommitted changes with the code-review skill (7 dimensions: correctness, security, performance, code quality, architecture, testing, language-specific)...',
|
|
1711
|
-
forwardToAI:
|
|
1712
|
-
|
|
1714
|
+
forwardToAI: () =>
|
|
1715
|
+
`use the code-review skill to review all uncommitted changes. Check all 7 dimensions: correctness, security, performance, code quality, architecture & design, testing, and language-specific issues. Use effort level: ${getPreference('lastCodeReviewEffort', 'high')}.`,
|
|
1713
1716
|
})
|
|
1714
1717
|
|
|
1715
1718
|
const simplifyCmd = gitDiffBridgeCmd({
|
|
@@ -1887,7 +1890,7 @@ const summaryCmd: CommandHandler = (ctx) => {
|
|
|
1887
1890
|
}
|
|
1888
1891
|
}
|
|
1889
1892
|
|
|
1890
|
-
const cdCmd: CommandHandler = async (
|
|
1893
|
+
const cdCmd: CommandHandler = async (ctx, args) => {
|
|
1891
1894
|
const target = args[0]
|
|
1892
1895
|
if (!target) {
|
|
1893
1896
|
return {
|
|
@@ -1912,6 +1915,22 @@ const cdCmd: CommandHandler = async (_ctx, args) => {
|
|
|
1912
1915
|
|
|
1913
1916
|
try {
|
|
1914
1917
|
process.chdir(resolved)
|
|
1918
|
+
|
|
1919
|
+
// Persist cwd to active session (best-effort)
|
|
1920
|
+
try {
|
|
1921
|
+
const { SessionStore } = await import('../core/session-store')
|
|
1922
|
+
const saved = SessionStore.load(ctx.sessionId)
|
|
1923
|
+
if (saved) {
|
|
1924
|
+
SessionStore.save(ctx.sessionId, saved.messages, {
|
|
1925
|
+
provider: saved.metadata.provider,
|
|
1926
|
+
model: saved.metadata.model,
|
|
1927
|
+
cwd: resolved,
|
|
1928
|
+
})
|
|
1929
|
+
}
|
|
1930
|
+
} catch {
|
|
1931
|
+
/* session persistence is best-effort */
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1915
1934
|
return {
|
|
1916
1935
|
content: [
|
|
1917
1936
|
'── Directory Changed ──',
|
|
@@ -2039,48 +2058,10 @@ const exportCmd: CommandHandler = async (ctx) => {
|
|
|
2039
2058
|
}
|
|
2040
2059
|
|
|
2041
2060
|
// ═══════════════════════════════════════════════════════════════
|
|
2042
|
-
// Review — code
|
|
2061
|
+
// Review — alias for /code-review (P1: 2026-08-06 polish)
|
|
2043
2062
|
// ═══════════════════════════════════════════════════════════════
|
|
2044
2063
|
|
|
2045
|
-
const reviewCmd
|
|
2046
|
-
try {
|
|
2047
|
-
const { execSync } = await import('node:child_process')
|
|
2048
|
-
const diff = execSync('git diff --stat', { encoding: 'utf-8', timeout: 5000 }).trim()
|
|
2049
|
-
const unstaged = execSync('git diff --name-only', { encoding: 'utf-8', timeout: 3000 }).trim()
|
|
2050
|
-
const staged = execSync('git diff --cached --name-only', {
|
|
2051
|
-
encoding: 'utf-8',
|
|
2052
|
-
timeout: 3000,
|
|
2053
|
-
}).trim()
|
|
2054
|
-
|
|
2055
|
-
if (!diff) {
|
|
2056
|
-
return {
|
|
2057
|
-
content:
|
|
2058
|
-
'─ Code Review ─\n\nNo uncommitted changes detected.\n\nUse /pr-comments for PR-level review, or make changes first.',
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
const lines: string[] = ['─ Code Review ─', '', 'Uncommitted changes:', '', diff]
|
|
2063
|
-
|
|
2064
|
-
if (staged) {
|
|
2065
|
-
lines.push('')
|
|
2066
|
-
lines.push('Staged files (ready for commit):')
|
|
2067
|
-
for (const f of staged.split('\n')) lines.push(` ✓ ${f}`)
|
|
2068
|
-
}
|
|
2069
|
-
if (unstaged) {
|
|
2070
|
-
lines.push('')
|
|
2071
|
-
lines.push('Unstaged files (working directory):')
|
|
2072
|
-
for (const f of unstaged.split('\n')) lines.push(` • ${f}`)
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
lines.push('')
|
|
2076
|
-
lines.push('To review with AI: type "review these changes" in chat.')
|
|
2077
|
-
lines.push('To commit: git add -A && git commit -m "..."')
|
|
2078
|
-
|
|
2079
|
-
return { content: lines.join('\n') }
|
|
2080
|
-
} catch {
|
|
2081
|
-
return { content: '─ Code Review ─\n\nCould not run git diff. Are you in a git repository?' }
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2064
|
+
const reviewCmd = codeReviewCmd
|
|
2084
2065
|
|
|
2085
2066
|
// ═══════════════════════════════════════════════════════════════
|
|
2086
2067
|
// PR Comments
|
|
@@ -4320,7 +4301,7 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
4320
4301
|
'/tdd': 'Workflow',
|
|
4321
4302
|
'/todos': 'Workflow',
|
|
4322
4303
|
'/tasks': 'Workflow',
|
|
4323
|
-
'/review': '
|
|
4304
|
+
'/review': 'Code Quality',
|
|
4324
4305
|
'/pr-comments': 'Workflow',
|
|
4325
4306
|
'/diff': 'Workflow',
|
|
4326
4307
|
'/workflows': 'Workflow',
|
|
@@ -4587,7 +4568,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
4587
4568
|
'/tdd': 'Test-Driven Development workflow (RED → GREEN → REFACTOR)',
|
|
4588
4569
|
'/todos': 'Task management (list/create tasks)',
|
|
4589
4570
|
'/tasks': 'Background tasks',
|
|
4590
|
-
'/review': '
|
|
4571
|
+
'/review': 'Review code for bugs, security, performance (alias for /code-review)',
|
|
4591
4572
|
'/pr-comments': 'PR review summary',
|
|
4592
4573
|
'/diff': 'Show git diff',
|
|
4593
4574
|
'/workflows': 'List workflow scripts',
|
package/src/workflow/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import vm from 'node:vm'
|
|
1
2
|
import { createSandbox } from './sandbox'
|
|
2
3
|
import { createJournal, appendJournal, loadJournal, loadScript } from './journal'
|
|
3
4
|
import { createBudget } from './budget'
|
|
@@ -47,7 +48,8 @@ function agentCacheKey(prompt: string, opts: Record<string, unknown> = {}): stri
|
|
|
47
48
|
* - Returns cacheHits and cacheMisses counts
|
|
48
49
|
*
|
|
49
50
|
* The sandbox blocks non-deterministic APIs (Date.now, Math.random) to make
|
|
50
|
-
* this deterministic replay possible.
|
|
51
|
+
* this deterministic replay possible. Scripts execute inside a node:vm.Context
|
|
52
|
+
* that blocks sandbox escape vectors (eval, Function, import, require, process, etc.).
|
|
51
53
|
*/
|
|
52
54
|
export async function runWorkflow(
|
|
53
55
|
script: string,
|
|
@@ -134,42 +136,28 @@ export async function runWorkflow(
|
|
|
134
136
|
appendJournal(runId, { type: 'log', message })
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
const sandbox = createSandbox(args, budget)
|
|
138
|
-
|
|
139
139
|
// Build the script wrapper
|
|
140
140
|
const wrappedScript = `
|
|
141
|
-
|
|
141
|
+
(async () => {
|
|
142
142
|
${script}
|
|
143
143
|
})()
|
|
144
144
|
`
|
|
145
145
|
|
|
146
|
-
// Execute in
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const result = await scriptFn(
|
|
162
|
-
agent,
|
|
163
|
-
parallel,
|
|
164
|
-
pipeline,
|
|
165
|
-
verify,
|
|
166
|
-
judge,
|
|
167
|
-
loopUntilConvergence,
|
|
168
|
-
wrappedPhase,
|
|
169
|
-
log,
|
|
170
|
-
args,
|
|
171
|
-
budget,
|
|
172
|
-
)
|
|
146
|
+
// Execute in node:vm sandbox — no access to host globals
|
|
147
|
+
const vmScript = new vm.Script(wrappedScript, { filename: 'workflow.js' })
|
|
148
|
+
const sandboxCtx = createSandbox(args, budget)
|
|
149
|
+
|
|
150
|
+
// Inject primitives into the sandbox context (whitelist approach)
|
|
151
|
+
sandboxCtx.agent = agent
|
|
152
|
+
sandboxCtx.parallel = parallel
|
|
153
|
+
sandboxCtx.pipeline = pipeline
|
|
154
|
+
sandboxCtx.verify = verify
|
|
155
|
+
sandboxCtx.judge = judge
|
|
156
|
+
sandboxCtx.loopUntilConvergence = loopUntilConvergence
|
|
157
|
+
sandboxCtx.phase = wrappedPhase
|
|
158
|
+
sandboxCtx.log = log
|
|
159
|
+
|
|
160
|
+
const result = await vmScript.runInContext(sandboxCtx, { timeout: 120_000 })
|
|
173
161
|
|
|
174
162
|
// Count journal entries from state
|
|
175
163
|
const priorEntries = loadJournal(runId)
|
package/src/workflow/sandbox.ts
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
import vm from 'node:vm'
|
|
2
|
+
|
|
3
|
+
/** APIs disabled in workflow scripts to ensure deterministic replay + sandbox escape prevention. */
|
|
2
4
|
const FORBIDDEN = new Set(['Date.now', 'Math.random', 'crypto.randomUUID'])
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* Create a sandboxed
|
|
6
|
-
* Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID()
|
|
7
|
+
* Create a sandboxed VM context for workflow script execution.
|
|
8
|
+
* Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID(),
|
|
9
|
+
* plus explicit sandbox escape vectors: eval, Function constructor, import(),
|
|
10
|
+
* require(), process, Bun, fetch, setTimeout/setInterval, etc.
|
|
7
11
|
*/
|
|
8
12
|
export function createSandbox(
|
|
9
13
|
args: unknown,
|
|
10
14
|
budget: { total: number | null; spent(): number; remaining(): number },
|
|
11
|
-
):
|
|
12
|
-
const
|
|
15
|
+
): vm.Context {
|
|
16
|
+
const sandboxObj: Record<string, unknown> = {
|
|
13
17
|
args,
|
|
14
18
|
budget,
|
|
15
19
|
console: {
|
|
16
20
|
log: (..._a: unknown[]) => {}, // no-op in sandbox
|
|
17
21
|
error: (..._a: unknown[]) => {},
|
|
18
22
|
},
|
|
19
|
-
|
|
23
|
+
|
|
24
|
+
// ── Explicit sandbox escape prevention ──
|
|
25
|
+
// V8 builtins that would otherwise be available in a vm.Context
|
|
26
|
+
eval: () => {
|
|
27
|
+
throw new Error('eval() is disabled in workflow sandbox.')
|
|
28
|
+
},
|
|
29
|
+
Function: () => {
|
|
30
|
+
throw new Error('new Function() is disabled in workflow sandbox.')
|
|
31
|
+
},
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
// Override Date to block now() and argless constructor
|
|
23
35
|
const OriginalDate = Date
|
|
24
|
-
|
|
36
|
+
sandboxObj.Date = new Proxy(OriginalDate, {
|
|
25
37
|
construct(_target, constructorArgs) {
|
|
26
38
|
if (constructorArgs.length === 0) {
|
|
27
39
|
throw new Error('new Date() is disabled in workflow sandbox. Pass timestamps via args.')
|
|
@@ -42,7 +54,7 @@ export function createSandbox(
|
|
|
42
54
|
})
|
|
43
55
|
|
|
44
56
|
// Override Math.random
|
|
45
|
-
|
|
57
|
+
sandboxObj.Math = new Proxy(Math, {
|
|
46
58
|
get(_target, prop) {
|
|
47
59
|
if (prop === 'random') {
|
|
48
60
|
throw new Error('Math.random() is disabled in workflow sandbox. Use a seed from args.')
|
|
@@ -57,7 +69,7 @@ export function createSandbox(
|
|
|
57
69
|
| { randomUUID?: unknown; [key: string]: unknown }
|
|
58
70
|
| undefined
|
|
59
71
|
if (globalCrypto) {
|
|
60
|
-
|
|
72
|
+
sandboxObj.crypto = new Proxy(globalCrypto, {
|
|
61
73
|
get(_target, prop) {
|
|
62
74
|
if (prop === 'randomUUID') {
|
|
63
75
|
throw new Error('crypto.randomUUID() is disabled in workflow sandbox.')
|
|
@@ -70,7 +82,7 @@ export function createSandbox(
|
|
|
70
82
|
})
|
|
71
83
|
}
|
|
72
84
|
|
|
73
|
-
return
|
|
85
|
+
return vm.createContext(sandboxObj)
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
/** Check whether a given API identifier is in the forbidden set. */
|