@miphamai/cli 0.33.0 → 0.33.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,6 +55,8 @@ export class QueryEngine {
55
55
  private _preflightChecker?: PreFlightChecker
56
56
  private _autoCorrector?: AutoCorrector
57
57
  private _metaRuleEngine?: MetaRuleEngine
58
+ /** Files read this session — tracks what the Read tool has loaded */
59
+ private readFiles = new Set<string>()
58
60
  private goal?: string
59
61
  private maxGoalLoops = 20
60
62
  private lastAssistantContent?: string
@@ -943,6 +945,7 @@ export class QueryEngine {
943
945
  backgroundAgentRegistry: getBackgroundAgentRegistry(),
944
946
  permissionSystem: this.permission,
945
947
  ruleEngine: this.ruleEngine,
948
+ readFiles: this.readFiles,
946
949
  })
947
950
 
948
951
  // Track touched files for rules matching
@@ -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
  }