@miphamai/cli 0.33.0 → 0.33.2
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/cross-session/file-inbox.ts +30 -12
- package/src/core/compaction-progress.ts +205 -0
- package/src/core/context-compact.ts +70 -6
- package/src/core/context-drain.ts +65 -6
- package/src/core/context.ts +11 -4
- package/src/core/engine.ts +8 -0
- package/src/core/hooks.ts +114 -9
- package/src/shared/types.ts +2 -0
- package/src/skills/fork-executor.ts +6 -2
- package/src/skills/loader.ts +37 -4
- package/src/skills/sanitizer.ts +214 -0
- package/src/tools/agent/skill.ts +6 -1
- package/src/tools/file/edit.ts +4 -0
- package/src/tools/file/read.ts +3 -0
- package/src/tools/file/write.ts +20 -3
- package/src/ui/app.tsx +142 -129
- package/src/ui/commands.ts +71 -4
- package/src/ui/error-boundary.tsx +85 -0
package/src/core/hooks.ts
CHANGED
|
@@ -6,6 +6,29 @@ import type {
|
|
|
6
6
|
ToolResult,
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
|
|
9
|
+
// ── Hook resilience types ──
|
|
10
|
+
|
|
11
|
+
interface HookHealth {
|
|
12
|
+
/** Consecutive failure count */
|
|
13
|
+
failures: number
|
|
14
|
+
/** Timestamp of last failure (ms since epoch) */
|
|
15
|
+
lastFailureTime: number
|
|
16
|
+
/** Whether the hook is currently disabled due to repeated failures */
|
|
17
|
+
disabled: boolean
|
|
18
|
+
/** Timestamp when disabled (ms since epoch) */
|
|
19
|
+
disabledAt: number
|
|
20
|
+
/** Total failure count (lifetime) */
|
|
21
|
+
totalFailures: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Max consecutive failures before auto-disabling a hook */
|
|
25
|
+
const MAX_CONSECUTIVE_FAILURES = 5
|
|
26
|
+
|
|
27
|
+
/** Cooldown period in ms before a disabled hook is re-tried (5 minutes) */
|
|
28
|
+
const COOLDOWN_MS = 5 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
// ── Helpers ──
|
|
31
|
+
|
|
9
32
|
/**
|
|
10
33
|
* Merge two permission decisions, keeping the more restrictive one.
|
|
11
34
|
* Priority: deny > defer > ask > allow
|
|
@@ -22,6 +45,8 @@ export function mergePermissionDecision(
|
|
|
22
45
|
|
|
23
46
|
export class HookEngine {
|
|
24
47
|
private hooks: HookDefinition[] = []
|
|
48
|
+
/** Health tracking per hook key (event[:toolName]) */
|
|
49
|
+
private health = new Map<string, HookHealth>()
|
|
25
50
|
|
|
26
51
|
register(hook: HookDefinition): void {
|
|
27
52
|
this.hooks.push(hook)
|
|
@@ -71,31 +96,26 @@ export class HookEngine {
|
|
|
71
96
|
|
|
72
97
|
// ── NEW event executors ──
|
|
73
98
|
|
|
74
|
-
/** Stop hook: fires when AI completes a turn. Can block to force continuation. */
|
|
75
99
|
async executeStop(sessionId: string): Promise<HookResult> {
|
|
76
100
|
const ctx: HookContext = { event: 'Stop', sessionId }
|
|
77
101
|
return this.runHooks('Stop', undefined, ctx)
|
|
78
102
|
}
|
|
79
103
|
|
|
80
|
-
/** UserPromptSubmit: fires when user submits input. Can block malicious input. */
|
|
81
104
|
async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
|
|
82
105
|
const ctx: HookContext = { event: 'UserPromptSubmit', sessionId, userPrompt: prompt }
|
|
83
106
|
return this.runHooks('UserPromptSubmit', undefined, ctx)
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
/** PreCompact: fires before context compaction. */
|
|
87
109
|
async executePreCompact(sessionId: string): Promise<HookResult> {
|
|
88
110
|
const ctx: HookContext = { event: 'PreCompact', sessionId }
|
|
89
111
|
return this.runHooks('PreCompact', undefined, ctx)
|
|
90
112
|
}
|
|
91
113
|
|
|
92
|
-
/** PostCompact: fires after context compaction. Can inject additional context. */
|
|
93
114
|
async executePostCompact(sessionId: string): Promise<HookResult> {
|
|
94
115
|
const ctx: HookContext = { event: 'PostCompact', sessionId }
|
|
95
116
|
return this.runHooks('PostCompact', undefined, ctx)
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
/** ConfigChange: fires when configuration changes. */
|
|
99
119
|
async executeConfigChange(key: string, value: unknown, sessionId: string): Promise<HookResult> {
|
|
100
120
|
const ctx: HookContext = {
|
|
101
121
|
event: 'ConfigChange',
|
|
@@ -106,7 +126,6 @@ export class HookEngine {
|
|
|
106
126
|
return this.runHooks('ConfigChange', undefined, ctx)
|
|
107
127
|
}
|
|
108
128
|
|
|
109
|
-
/** SubagentStart: fires when a sub-agent begins execution. */
|
|
110
129
|
async executeSubagentStart(
|
|
111
130
|
agentType: string,
|
|
112
131
|
description: string,
|
|
@@ -120,7 +139,6 @@ export class HookEngine {
|
|
|
120
139
|
return this.runHooks('SubagentStart', undefined, ctx)
|
|
121
140
|
}
|
|
122
141
|
|
|
123
|
-
/** SubagentStop: fires when a sub-agent completes (success or failure). */
|
|
124
142
|
async executeSubagentStop(
|
|
125
143
|
agentType: string,
|
|
126
144
|
description: string,
|
|
@@ -137,7 +155,6 @@ export class HookEngine {
|
|
|
137
155
|
return this.runHooks('SubagentStop', undefined, ctx)
|
|
138
156
|
}
|
|
139
157
|
|
|
140
|
-
/** PostToolUseFailure: fires when a tool call fails. */
|
|
141
158
|
async executePostToolUseFailure(
|
|
142
159
|
toolName: string,
|
|
143
160
|
toolInput: Record<string, unknown>,
|
|
@@ -154,7 +171,6 @@ export class HookEngine {
|
|
|
154
171
|
return this.runHooks('PostToolUseFailure', toolName, ctx)
|
|
155
172
|
}
|
|
156
173
|
|
|
157
|
-
/** PreInference: fires before every model API call for DLP inspection. */
|
|
158
174
|
async executePreInference(
|
|
159
175
|
messages: Array<{ role: string; content: string }>,
|
|
160
176
|
toolCalls: Array<{
|
|
@@ -177,6 +193,79 @@ export class HookEngine {
|
|
|
177
193
|
return this.runHooks('PreInference', undefined, ctx)
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
// ── Health & Resilience ──
|
|
197
|
+
|
|
198
|
+
/** Get a hook health key for tracking. */
|
|
199
|
+
private healthKey(hook: HookDefinition): string {
|
|
200
|
+
return hook.toolName ? `${hook.event}:${hook.toolName}` : hook.event
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Check if a hook should be skipped due to repeated failures. */
|
|
204
|
+
private shouldSkip(key: string): boolean {
|
|
205
|
+
const h = this.health.get(key)
|
|
206
|
+
if (!h) return false
|
|
207
|
+
|
|
208
|
+
// If disabled, check cooldown
|
|
209
|
+
if (h.disabled) {
|
|
210
|
+
const elapsed = Date.now() - h.disabledAt
|
|
211
|
+
if (elapsed < COOLDOWN_MS) {
|
|
212
|
+
return true // still in cooldown
|
|
213
|
+
}
|
|
214
|
+
// Cooldown expired — re-enable for a trial
|
|
215
|
+
h.disabled = false
|
|
216
|
+
h.failures = 0
|
|
217
|
+
process.stderr.write(`🔄 Hook "${key}" cooldown expired — re-enabled for trial.\n`)
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Record a hook success — resets failure counter. */
|
|
225
|
+
private recordSuccess(key: string): void {
|
|
226
|
+
const h = this.health.get(key)
|
|
227
|
+
if (h) {
|
|
228
|
+
h.failures = 0
|
|
229
|
+
h.disabled = false
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Record a hook failure — may trigger auto-disable. */
|
|
234
|
+
private recordFailure(key: string): void {
|
|
235
|
+
let h = this.health.get(key)
|
|
236
|
+
if (!h) {
|
|
237
|
+
h = { failures: 0, lastFailureTime: 0, disabled: false, disabledAt: 0, totalFailures: 0 }
|
|
238
|
+
this.health.set(key, h)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
h.failures++
|
|
242
|
+
h.totalFailures++
|
|
243
|
+
h.lastFailureTime = Date.now()
|
|
244
|
+
|
|
245
|
+
if (h.failures >= MAX_CONSECUTIVE_FAILURES && !h.disabled) {
|
|
246
|
+
h.disabled = true
|
|
247
|
+
h.disabledAt = Date.now()
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
`⚠️ Hook "${key}" failed ${MAX_CONSECUTIVE_FAILURES} consecutive times — auto-disabled for ${COOLDOWN_MS / 60_000} minutes.\n` +
|
|
250
|
+
` Use /hooks enable "${key}" to re-enable manually.\n`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Get the health status of all hooks. */
|
|
256
|
+
getHookHealth(): Array<{ key: string; health: HookHealth }> {
|
|
257
|
+
return Array.from(this.health.entries()).map(([key, health]) => ({ key, health }))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Manually re-enable a disabled hook. */
|
|
261
|
+
reEnableHook(key: string): boolean {
|
|
262
|
+
const h = this.health.get(key)
|
|
263
|
+
if (!h || !h.disabled) return false
|
|
264
|
+
h.disabled = false
|
|
265
|
+
h.failures = 0
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
180
269
|
// ── Core execution ──
|
|
181
270
|
|
|
182
271
|
private async runHooks(
|
|
@@ -191,9 +280,23 @@ export class HookEngine {
|
|
|
191
280
|
const result: HookResult = { allowed: true }
|
|
192
281
|
|
|
193
282
|
for (const hook of matching) {
|
|
283
|
+
const key = this.healthKey(hook)
|
|
284
|
+
|
|
285
|
+
// ── Resilience: skip disabled hooks ──
|
|
286
|
+
if (this.shouldSkip(key)) {
|
|
287
|
+
result.additionalContext = result.additionalContext
|
|
288
|
+
? result.additionalContext +
|
|
289
|
+
`\n[Hook "${key}" skipped — temporarily disabled after repeated failures]`
|
|
290
|
+
: `[Hook "${key}" skipped — temporarily disabled after repeated failures]`
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
194
294
|
try {
|
|
195
295
|
const hookResult = await hook.handler(ctx)
|
|
196
296
|
|
|
297
|
+
// Record success
|
|
298
|
+
this.recordSuccess(key)
|
|
299
|
+
|
|
197
300
|
// Block on first deny — stops further hook execution
|
|
198
301
|
if (!hookResult.allowed) {
|
|
199
302
|
return { ...hookResult }
|
|
@@ -233,6 +336,8 @@ export class HookEngine {
|
|
|
233
336
|
result.updatedOutput = hookResult.updatedOutput
|
|
234
337
|
}
|
|
235
338
|
} catch {
|
|
339
|
+
// Record failure — may trigger auto-disable
|
|
340
|
+
this.recordFailure(key)
|
|
236
341
|
// Hook failures do not block execution
|
|
237
342
|
}
|
|
238
343
|
}
|
package/src/shared/types.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface ToolContext {
|
|
|
73
73
|
backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
|
|
74
74
|
permissionSystem?: import('../core/permission').PermissionSystem
|
|
75
75
|
ruleEngine?: import('../core/rule-engine').ExperienceRuleEngine
|
|
76
|
+
/** Files read this session — used by Write tool to check read-before-write */
|
|
77
|
+
readFiles?: Set<string>
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
// ── Artifact Types ──
|
|
@@ -3,6 +3,7 @@ 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
5
|
import type { PermissionSystem } from '../core/permission'
|
|
6
|
+
import { sanitizeSkillDescription } from './sanitizer.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Execute a skill in an isolated subagent context (context: fork).
|
|
@@ -10,6 +11,8 @@ import type { PermissionSystem } from '../core/permission'
|
|
|
10
11
|
* The skill's markdown body becomes the subagent's system prompt.
|
|
11
12
|
* The skill's allowed-tools become the subagent's tool whitelist.
|
|
12
13
|
* Results are returned to the AI as internal context (not shown directly to user).
|
|
14
|
+
*
|
|
15
|
+
* Safety: skill description is sanitized before injection into subagent prompt.
|
|
13
16
|
*/
|
|
14
17
|
export async function executeForkedSkill(
|
|
15
18
|
skill: SkillDefinition,
|
|
@@ -20,7 +23,7 @@ export async function executeForkedSkill(
|
|
|
20
23
|
): Promise<string> {
|
|
21
24
|
const agentDef: AgentDefinition = {
|
|
22
25
|
name: `skill:${skill.name}`,
|
|
23
|
-
description: skill.description,
|
|
26
|
+
description: sanitizeSkillDescription(skill.description, skill.type),
|
|
24
27
|
systemPrompt: buildSkillSystemPrompt(skill),
|
|
25
28
|
tools: skill.allowedTools?.join(', '),
|
|
26
29
|
model: skill.model || 'inherit',
|
|
@@ -38,5 +41,6 @@ export async function executeForkedSkill(
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function buildSkillSystemPrompt(skill: SkillDefinition): string {
|
|
41
|
-
|
|
44
|
+
const safeDescription = sanitizeSkillDescription(skill.description, skill.type)
|
|
45
|
+
return `You are executing the "${skill.name}" skill (v${skill.version}).\n\n${safeDescription}\n\nFollow the skill instructions precisely and return results.`
|
|
42
46
|
}
|
package/src/skills/loader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { parse as parseYaml } from 'yaml'
|
|
5
5
|
import type { SkillDefinition } from '../shared/index.ts'
|
|
6
|
+
import { sanitizeSkillDescription, sanitizeSkillBody, checkSkillShadow } from './sanitizer.js'
|
|
6
7
|
|
|
7
8
|
interface FrontmatterResult {
|
|
8
9
|
data: Record<string, unknown>
|
|
@@ -100,12 +101,43 @@ export class SkillsLoader {
|
|
|
100
101
|
const raw = readFileSync(path, 'utf-8')
|
|
101
102
|
const { data, content } = parseFrontmatter(raw)
|
|
102
103
|
|
|
104
|
+
const rawDescription = (data.description as string) || ''
|
|
105
|
+
const rawBody = content.trim() || undefined
|
|
106
|
+
const skillName = (data.name as string) || this.nameFromPath(path)
|
|
107
|
+
|
|
108
|
+
// ── Safety: check for command/MCP shadowing ──
|
|
109
|
+
const shadowCheck = checkSkillShadow(skillName, rawDescription)
|
|
110
|
+
if (shadowCheck.shadowed) {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`⚠️ Skill "${skillName}" shadows ${shadowCheck.conflictType} "${shadowCheck.conflictsWith}" — skipped for safety. Rename the skill and reload.\n`,
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Safety: sanitize description ──
|
|
118
|
+
const description = sanitizeSkillDescription(
|
|
119
|
+
rawDescription,
|
|
120
|
+
type === 'mipham' ? undefined : type,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// ── Safety: sanitize body ──
|
|
124
|
+
let body: string | undefined
|
|
125
|
+
if (rawBody) {
|
|
126
|
+
const bodyResult = sanitizeSkillBody(rawBody)
|
|
127
|
+
body = bodyResult.text
|
|
128
|
+
if (bodyResult.warnings.length > 0) {
|
|
129
|
+
process.stderr.write(
|
|
130
|
+
`⚠️ Skill "${skillName}" body sanitized: ${bodyResult.warnings.join('; ')}\n`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
const skill: SkillDefinition = {
|
|
104
|
-
name:
|
|
105
|
-
description
|
|
136
|
+
name: skillName,
|
|
137
|
+
description,
|
|
106
138
|
version: (data.version as string) || '0.1.0',
|
|
107
139
|
type,
|
|
108
|
-
body
|
|
140
|
+
body,
|
|
109
141
|
tools: data.tools as SkillDefinition['tools'],
|
|
110
142
|
hooks: data.hooks as SkillDefinition['hooks'],
|
|
111
143
|
prompts: data.prompts as SkillDefinition['prompts'],
|
|
@@ -143,7 +175,8 @@ export class SkillsLoader {
|
|
|
143
175
|
|
|
144
176
|
let tokenBudget = 0
|
|
145
177
|
for (const skill of skills) {
|
|
146
|
-
const
|
|
178
|
+
const safeDesc = sanitizeSkillDescription(skill.description, skill.type)
|
|
179
|
+
const entry = `- ${skill.name}: ${safeDesc}`
|
|
147
180
|
const entryTokens = Math.ceil(entry.length / 4) + 1 // rough estimate
|
|
148
181
|
if (tokenBudget + entryTokens > maxTokens) break
|
|
149
182
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Safety Sanitizer
|
|
3
|
+
*
|
|
4
|
+
* Claude Code v2.1.228 security hardening for skills:
|
|
5
|
+
* 1. Strip/escape `!` command prefixes from skill body text
|
|
6
|
+
* 2. Block `@` file expansion references in skill body
|
|
7
|
+
* 3. Sanitize skill descriptions (strip markdown formatting, shell injection chars)
|
|
8
|
+
* 4. Tag synced skills with source identifier
|
|
9
|
+
* 5. Detect shadowing of local commands and MCP tool names
|
|
10
|
+
*
|
|
11
|
+
* Applied at skill load time (loader.ts) and execution time (fork-executor.ts, skill.ts).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Types ──
|
|
15
|
+
|
|
16
|
+
export interface SanitizeResult {
|
|
17
|
+
/** Sanitized text */
|
|
18
|
+
text: string
|
|
19
|
+
/** Warnings generated during sanitization */
|
|
20
|
+
warnings: string[]
|
|
21
|
+
/** Whether the text was modified */
|
|
22
|
+
modified: boolean
|
|
23
|
+
/** Whether the text was blocked entirely */
|
|
24
|
+
blocked: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ShadowCheck {
|
|
28
|
+
/** Whether a shadowing conflict was detected */
|
|
29
|
+
shadowed: boolean
|
|
30
|
+
/** Name of the conflicting command/tool */
|
|
31
|
+
conflictsWith: string
|
|
32
|
+
/** Type of conflict: 'command' | 'mcp-tool' */
|
|
33
|
+
conflictType: 'command' | 'mcp-tool'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Constants ──
|
|
37
|
+
|
|
38
|
+
/** Known local command names that skills should not shadow */
|
|
39
|
+
const BUILTIN_COMMANDS = new Set([
|
|
40
|
+
'/help',
|
|
41
|
+
'/model',
|
|
42
|
+
'/models',
|
|
43
|
+
'/switch',
|
|
44
|
+
'/compact',
|
|
45
|
+
'/clear',
|
|
46
|
+
'/resume',
|
|
47
|
+
'/config',
|
|
48
|
+
'/skills',
|
|
49
|
+
'/browse-skills',
|
|
50
|
+
'/install-skill',
|
|
51
|
+
'/reload-skills',
|
|
52
|
+
'/remove-skill',
|
|
53
|
+
'/crsi',
|
|
54
|
+
'/sis',
|
|
55
|
+
'/plan',
|
|
56
|
+
'/no-plan',
|
|
57
|
+
'/triage',
|
|
58
|
+
'/workflows',
|
|
59
|
+
'/tasks',
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
/** Source tags for synced skills */
|
|
63
|
+
const SOURCE_TAGS: Record<string, string> = {
|
|
64
|
+
'claude.ai': '🔗 [claude.ai synced]',
|
|
65
|
+
community: '📦 [community]',
|
|
66
|
+
standard: '',
|
|
67
|
+
mipham: '🏔️ [Mipham]',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Sanitizer ──
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sanitize a skill body (markdown instructions) for safe execution.
|
|
74
|
+
*
|
|
75
|
+
* Security measures:
|
|
76
|
+
* - `! command` patterns → escaped to `! command` (prevents AI from treating as shell command)
|
|
77
|
+
* - `@file` references → prefixed with safety note
|
|
78
|
+
* - Trailing backticks that could break markdown fences → escaped
|
|
79
|
+
* - Excessively long lines → truncated
|
|
80
|
+
*/
|
|
81
|
+
export function sanitizeSkillBody(body: string): SanitizeResult {
|
|
82
|
+
const warnings: string[] = []
|
|
83
|
+
let text = body
|
|
84
|
+
let modified = false
|
|
85
|
+
|
|
86
|
+
// ── 1. Detect and warn about `!` shell command patterns ──
|
|
87
|
+
// Pattern: line starting with `!` (Bash command marker in some AI contexts)
|
|
88
|
+
const bangCommandRegex = /^!\s*(\S+)/gm
|
|
89
|
+
let bangMatch: RegExpExecArray | null
|
|
90
|
+
const bangCommands: string[] = []
|
|
91
|
+
while ((bangMatch = bangCommandRegex.exec(text)) !== null) {
|
|
92
|
+
bangCommands.push(bangMatch[1] || '')
|
|
93
|
+
}
|
|
94
|
+
if (bangCommands.length > 0) {
|
|
95
|
+
// Escape `!` → `! ` (zero-width space after bang to neutralize)
|
|
96
|
+
text = text.replace(/^!(?=\s*\S)/gm, '! ')
|
|
97
|
+
modified = true
|
|
98
|
+
warnings.push(
|
|
99
|
+
`Skill body contained ${bangCommands.length} shell-command-like patterns (${bangCommands.slice(0, 3).join(', ')}${bangCommands.length > 3 ? '...' : ''}). Escaped to prevent unintended command execution.`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 2. Detect and warn about `@file` references ──
|
|
104
|
+
const atFileRegex = /@(\S+\.(?:md|ts|js|json|yml|yaml|txt|csv|py|rs|go|java|html|css|sh))/gi
|
|
105
|
+
let atMatch: RegExpExecArray | null
|
|
106
|
+
const atFiles: string[] = []
|
|
107
|
+
while ((atMatch = atFileRegex.exec(text)) !== null) {
|
|
108
|
+
atFiles.push(atMatch[0])
|
|
109
|
+
}
|
|
110
|
+
if (atFiles.length > 0) {
|
|
111
|
+
// Replace @file with `@ file` (space after @ to neutralize expansion)
|
|
112
|
+
text = text.replace(/@(\S+\.\w{1,6})\b/gi, '@ $1')
|
|
113
|
+
modified = true
|
|
114
|
+
warnings.push(
|
|
115
|
+
`Skill body contained ${atFiles.length} file reference patterns (${atFiles.slice(0, 3).join(', ')}${atFiles.length > 3 ? '...' : ''}). Neutralized to prevent unintended file expansion.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 3. Sanitize markdown fence break attempts ──
|
|
120
|
+
// Prevent skills from breaking out of their markdown code block context
|
|
121
|
+
if (text.includes('```')) {
|
|
122
|
+
const fenceCount = (text.match(/```/g) || []).length
|
|
123
|
+
if (fenceCount % 2 !== 0) {
|
|
124
|
+
// Odd number: append closing fence
|
|
125
|
+
text += '\n```'
|
|
126
|
+
modified = true
|
|
127
|
+
warnings.push('Skill body had unclosed markdown fence — auto-closed for safety.')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { text, warnings, modified, blocked: false }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sanitize a skill description for display in system reminders.
|
|
136
|
+
*
|
|
137
|
+
* - Strip shell-injection characters ($, `, ;, |, &, <, >)
|
|
138
|
+
* - Strip markdown links that could be misleading
|
|
139
|
+
* - Truncate to max length
|
|
140
|
+
* - Add source tag for synced skills
|
|
141
|
+
*/
|
|
142
|
+
export function sanitizeSkillDescription(
|
|
143
|
+
description: string,
|
|
144
|
+
source?: string,
|
|
145
|
+
maxLength: number = 200,
|
|
146
|
+
): string {
|
|
147
|
+
let text = description
|
|
148
|
+
|
|
149
|
+
// Strip shell injection characters
|
|
150
|
+
text = text.replace(/[`$;|&<>]/g, '')
|
|
151
|
+
|
|
152
|
+
// Strip markdown links — keep text, drop URL
|
|
153
|
+
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
154
|
+
|
|
155
|
+
// Strip HTML tags
|
|
156
|
+
text = text.replace(/<[^>]*>/g, '')
|
|
157
|
+
|
|
158
|
+
// Collapse whitespace
|
|
159
|
+
text = text.replace(/\s+/g, ' ').trim()
|
|
160
|
+
|
|
161
|
+
// Truncate
|
|
162
|
+
if (text.length > maxLength) {
|
|
163
|
+
text = text.slice(0, maxLength - 3) + '...'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Add source tag
|
|
167
|
+
if (source && SOURCE_TAGS[source]) {
|
|
168
|
+
text = `${SOURCE_TAGS[source]} ${text}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return text
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if a skill name or description shadows a local command or MCP tool.
|
|
176
|
+
*
|
|
177
|
+
* @returns ShadowCheck with conflict info, or { shadowed: false }
|
|
178
|
+
*/
|
|
179
|
+
export function checkSkillShadow(
|
|
180
|
+
skillName: string,
|
|
181
|
+
description: string,
|
|
182
|
+
mcpToolNames?: string[],
|
|
183
|
+
): ShadowCheck {
|
|
184
|
+
// Check against builtin commands
|
|
185
|
+
if (BUILTIN_COMMANDS.has(skillName)) {
|
|
186
|
+
return { shadowed: true, conflictsWith: skillName, conflictType: 'command' }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check description for embedded command names
|
|
190
|
+
for (const cmd of BUILTIN_COMMANDS) {
|
|
191
|
+
const cmdPattern = cmd.replace(/\//g, '')
|
|
192
|
+
if (description.toLowerCase().includes(cmdPattern.toLowerCase())) {
|
|
193
|
+
// Only flag if the description could be confused as a command invocation
|
|
194
|
+
if (
|
|
195
|
+
description.includes(`\`${cmd}\``) ||
|
|
196
|
+
description.includes(` ${cmd} `) ||
|
|
197
|
+
description.startsWith(cmd)
|
|
198
|
+
) {
|
|
199
|
+
return { shadowed: true, conflictsWith: cmd, conflictType: 'command' }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check against MCP tool names
|
|
205
|
+
if (mcpToolNames) {
|
|
206
|
+
for (const toolName of mcpToolNames) {
|
|
207
|
+
if (skillName === toolName || description.includes(`\`${toolName}\``)) {
|
|
208
|
+
return { shadowed: true, conflictsWith: toolName, conflictType: 'mcp-tool' }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { shadowed: false, conflictsWith: '', conflictType: 'command' }
|
|
214
|
+
}
|
package/src/tools/agent/skill.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
2
|
import { executeForkedSkill } from '../../skills/fork-executor'
|
|
3
|
+
import { sanitizeSkillBody } from '../../skills/sanitizer.js'
|
|
3
4
|
|
|
4
5
|
export const skillTool: ToolDefinition = {
|
|
5
6
|
name: 'Skill',
|
|
@@ -66,15 +67,19 @@ export const skillTool: ToolDefinition = {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
// Standard inline execution — return skill body for AI to follow
|
|
70
|
+
const safeBodyResult = skill.body ? sanitizeSkillBody(skill.body) : undefined
|
|
71
|
+
const bodyText = safeBodyResult?.text || skill.body || '(no instructions body)'
|
|
72
|
+
|
|
69
73
|
const lines: string[] = [
|
|
70
74
|
`── Skill Invoked: ${skill.name} ──`,
|
|
71
75
|
`Type: ${skill.type} | Version: ${skill.version}`,
|
|
72
76
|
skill.description ? `Description: ${skill.description}` : '',
|
|
73
77
|
args ? `Arguments: ${args}` : '',
|
|
78
|
+
safeBodyResult?.warnings.length ? `⚠️ Safety: ${safeBodyResult.warnings.join('; ')}` : '',
|
|
74
79
|
'',
|
|
75
80
|
'The AI should now follow these instructions:',
|
|
76
81
|
'',
|
|
77
|
-
|
|
82
|
+
bodyText,
|
|
78
83
|
].filter(Boolean)
|
|
79
84
|
|
|
80
85
|
return { success: true, content: lines.join('\n') }
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -90,6 +90,10 @@ export const editTool: ToolDefinition = {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
const content = readFileSync(filePath, 'utf-8')
|
|
93
|
+
|
|
94
|
+
// ── Read tracking: mark as read before editing ──
|
|
95
|
+
ctx.readFiles?.add(filePath)
|
|
96
|
+
|
|
93
97
|
if (content.length > MAX_FILE_SIZE) {
|
|
94
98
|
return {
|
|
95
99
|
success: false,
|
package/src/tools/file/read.ts
CHANGED
|
@@ -45,6 +45,9 @@ export const readTool: ToolDefinition = {
|
|
|
45
45
|
const limit = (params.limit as number) || 2000
|
|
46
46
|
const content = readFileSync(filePath, 'utf-8')
|
|
47
47
|
|
|
48
|
+
// ── Read tracking: mark file as read for Write tool safety ──
|
|
49
|
+
ctx.readFiles?.add(filePath)
|
|
50
|
+
|
|
48
51
|
// ── Credential masking ──
|
|
49
52
|
if (credentialConfig) {
|
|
50
53
|
const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
|
package/src/tools/file/write.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
4
|
import { resolveSafe } from '../../security/path'
|
|
5
5
|
|
|
6
6
|
export const writeTool: ToolDefinition = {
|
|
7
7
|
name: 'Write',
|
|
8
|
-
description:
|
|
8
|
+
description:
|
|
9
|
+
'Write a file to the local filesystem. Overwrites if one exists. You must have Read the file before overwriting it — this prevents accidental data loss.',
|
|
9
10
|
category: 'file',
|
|
10
11
|
permission: 'ask',
|
|
11
12
|
parameters: {
|
|
@@ -29,9 +30,25 @@ export const writeTool: ToolDefinition = {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// ── Read-before-write safety check ──
|
|
34
|
+
const fileExists = existsSync(filePath)
|
|
35
|
+
const wasRead = ctx.readFiles?.has(filePath) ?? true // if no tracking, allow
|
|
36
|
+
if (fileExists && !wasRead) {
|
|
37
|
+
return {
|
|
38
|
+
success: false,
|
|
39
|
+
content: '',
|
|
40
|
+
error: `File "${filePath}" already exists but has not been read this session. Read the file first before overwriting it. This prevents accidental data loss.`,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
33
45
|
writeFileSync(filePath, content, 'utf-8')
|
|
46
|
+
|
|
47
|
+
// Track as read since we just wrote it (future writes allowed)
|
|
48
|
+
ctx.readFiles?.add(filePath)
|
|
49
|
+
|
|
34
50
|
const lines = content.split('\n').length
|
|
35
|
-
|
|
51
|
+
const action = fileExists ? 'Updated' : 'Wrote'
|
|
52
|
+
return { success: true, content: `${action} ${lines} lines to ${filePath}` }
|
|
36
53
|
},
|
|
37
54
|
}
|