@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/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
  }
@@ -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
- return `You are executing the "${skill.name}" skill (v${skill.version}).\n\n${skill.description}\n\nFollow the skill instructions precisely and return results.`
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
  }
@@ -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: (data.name as string) || this.nameFromPath(path),
105
- description: (data.description as string) || '',
136
+ name: skillName,
137
+ description,
106
138
  version: (data.version as string) || '0.1.0',
107
139
  type,
108
- body: content.trim() || undefined,
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 entry = `- ${skill.name}: ${skill.description}`
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
+ }
@@ -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
- skill.body || '(no instructions body)',
82
+ bodyText,
78
83
  ].filter(Boolean)
79
84
 
80
85
  return { success: true, content: lines.join('\n') }
@@ -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,
@@ -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 } =
@@ -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: 'Write a file to the local filesystem. Creates parent directories if needed.',
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
- return { success: true, content: `Wrote ${lines} lines to ${filePath}` }
51
+ const action = fileExists ? 'Updated' : 'Wrote'
52
+ return { success: true, content: `${action} ${lines} lines to ${filePath}` }
36
53
  },
37
54
  }