@miphamai/cli 0.14.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 +37 -3
- package/src/agent/types.ts +2 -0
- 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 +52 -1
- package/src/core/permission-config.ts +70 -6
- package/src/core/permission.ts +50 -9
- package/src/core/session-store.ts +4 -2
- package/src/core/usage-tracker.ts +103 -0
- package/src/index.tsx +50 -2
- package/src/providers/anthropic.ts +9 -1
- package/src/providers/openai-compat.ts +10 -0
- package/src/providers/registry.ts +8 -0
- package/src/shared/sanitize.ts +38 -0
- package/src/shared/types.ts +32 -1
- package/src/skills/fork-executor.ts +3 -1
- package/src/skills/registry.ts +69 -2
- package/src/tools/agent/agent.ts +1 -1
- package/src/tools/agent/skill.ts +1 -0
- 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 +68 -56
- package/src/workflow/primitives/agent.ts +132 -46
- package/src/workflow/runtime.ts +24 -37
- package/src/workflow/sandbox.ts +22 -10
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()
|
|
@@ -194,7 +194,15 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
case 'message_delta': {
|
|
197
|
-
//
|
|
197
|
+
// Capture token usage for accurate cost tracking
|
|
198
|
+
if (event.usage) {
|
|
199
|
+
yield {
|
|
200
|
+
type: 'usage',
|
|
201
|
+
inputTokens: event.usage.input_tokens,
|
|
202
|
+
outputTokens: event.usage.output_tokens,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Contains stop_reason; also handles late input_json_delta
|
|
198
206
|
if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
|
|
199
207
|
accumulatedToolInput += event.delta.partial_json
|
|
200
208
|
}
|
|
@@ -99,6 +99,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
99
99
|
try {
|
|
100
100
|
const parsed = JSON.parse(data)
|
|
101
101
|
const choice = parsed.choices?.[0]
|
|
102
|
+
|
|
103
|
+
// Capture token usage when available (final chunk with stream_options.include_usage)
|
|
104
|
+
if (parsed.usage) {
|
|
105
|
+
yield {
|
|
106
|
+
type: 'usage',
|
|
107
|
+
inputTokens: parsed.usage.prompt_tokens,
|
|
108
|
+
outputTokens: parsed.usage.completion_tokens,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
if (!choice) continue
|
|
103
113
|
|
|
104
114
|
const delta = choice.delta
|
|
@@ -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
|
@@ -81,6 +81,7 @@ export interface ToolContext {
|
|
|
81
81
|
artifactServer?: import('../artifacts/server').ArtifactServer
|
|
82
82
|
agentRegistry?: import('../agent/agent-registry').AgentRegistry
|
|
83
83
|
backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
|
|
84
|
+
permissionSystem?: import('../core/permission').PermissionSystem
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// ── Artifact Types ──
|
|
@@ -128,7 +129,15 @@ export interface TaskNotification {
|
|
|
128
129
|
|
|
129
130
|
// ── Stream Types ──
|
|
130
131
|
export interface StreamChunk {
|
|
131
|
-
type:
|
|
132
|
+
type:
|
|
133
|
+
| 'text'
|
|
134
|
+
| 'tool_use'
|
|
135
|
+
| 'tool_result'
|
|
136
|
+
| 'thinking'
|
|
137
|
+
| 'stop'
|
|
138
|
+
| 'error'
|
|
139
|
+
| 'task_notification'
|
|
140
|
+
| 'usage'
|
|
132
141
|
content?: string
|
|
133
142
|
toolUse?: ToolUseContent
|
|
134
143
|
tool_use_id?: string
|
|
@@ -139,6 +148,10 @@ export interface StreamChunk {
|
|
|
139
148
|
thinking?: string
|
|
140
149
|
/** Background task notification payload (type: 'task_notification'). */
|
|
141
150
|
taskNotification?: TaskNotification
|
|
151
|
+
/** API-reported input token count (type: 'usage'). */
|
|
152
|
+
inputTokens?: number
|
|
153
|
+
/** API-reported output token count (type: 'usage'). */
|
|
154
|
+
outputTokens?: number
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
// ── Config Types ──
|
|
@@ -147,8 +160,16 @@ export interface MiphamConfig {
|
|
|
147
160
|
defaultProvider: string
|
|
148
161
|
defaultModel: string
|
|
149
162
|
permission: ToolPermission
|
|
163
|
+
/** Org-level permission restrictions (forbiddenModes, maxAllowedMode). */
|
|
164
|
+
permissionRestrictions?: PermissionRestrictions
|
|
150
165
|
providers: ProviderConfig[]
|
|
151
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
|
+
}
|
|
152
173
|
}
|
|
153
174
|
|
|
154
175
|
export interface McpServerConfig {
|
|
@@ -274,10 +295,20 @@ export type PermissionMode =
|
|
|
274
295
|
/** Backward-compatible alias: PermissionMode plus legacy 'ask' and 'bypass' */
|
|
275
296
|
export type PermissionLevel = PermissionMode | 'ask' | 'bypass'
|
|
276
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
|
+
|
|
277
306
|
export interface PermissionConfig {
|
|
278
307
|
mode: PermissionMode
|
|
279
308
|
allow: string[]
|
|
280
309
|
deny: string[]
|
|
310
|
+
/** Optional org-level restrictions enforced on every mode transition. */
|
|
311
|
+
restrictions?: PermissionRestrictions
|
|
281
312
|
}
|
|
282
313
|
|
|
283
314
|
export interface PermissionRuleEntry {
|
|
@@ -2,6 +2,7 @@ import { SubAgent } from '../agent/sub-agent'
|
|
|
2
2
|
import type { ProviderRegistry } from '../providers/registry'
|
|
3
3
|
import type { ToolDefinition, SkillDefinition } from '../shared/index.ts'
|
|
4
4
|
import type { AgentDefinition } from '../agent/types'
|
|
5
|
+
import type { PermissionSystem } from '../core/permission'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Execute a skill in an isolated subagent context (context: fork).
|
|
@@ -15,6 +16,7 @@ export async function executeForkedSkill(
|
|
|
15
16
|
args: string,
|
|
16
17
|
registry: ProviderRegistry,
|
|
17
18
|
toolRegistry: Map<string, ToolDefinition>,
|
|
19
|
+
permissionSystem?: PermissionSystem,
|
|
18
20
|
): Promise<string> {
|
|
19
21
|
const agentDef: AgentDefinition = {
|
|
20
22
|
name: `skill:${skill.name}`,
|
|
@@ -27,7 +29,7 @@ export async function executeForkedSkill(
|
|
|
27
29
|
source: 'builtin',
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
const sub = new SubAgent(registry, toolRegistry)
|
|
32
|
+
const sub = new SubAgent(registry, toolRegistry, permissionSystem)
|
|
31
33
|
const prompt = args
|
|
32
34
|
? `Execute the "${skill.name}" skill with arguments: ${args}`
|
|
33
35
|
: `Execute the "${skill.name}" skill.`
|
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/agent/agent.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const agentTool: ToolDefinition = {
|
|
|
60
60
|
const agentDef = ctx.agentRegistry?.resolve(agentType)
|
|
61
61
|
|
|
62
62
|
try {
|
|
63
|
-
const sub = new SubAgent(registry, toolRegistry)
|
|
63
|
+
const sub = new SubAgent(registry, toolRegistry, ctx.permissionSystem)
|
|
64
64
|
const result = await sub.execute(prompt, description, {
|
|
65
65
|
type: agentType,
|
|
66
66
|
agentDef,
|
package/src/tools/agent/skill.ts
CHANGED
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(
|