@miphamai/cli 0.15.0 → 0.16.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.
@@ -10,6 +10,8 @@ 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'
14
+ import communitySkills from './community-registry.json'
13
15
 
14
16
  const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
15
17
 
@@ -38,81 +40,7 @@ export interface SkillEntry {
38
40
  * Embedded community skill registry.
39
41
  * In the future, this can be fetched from a remote URL.
40
42
  */
41
- const COMMUNITY_SKILLS: SkillEntry[] = [
42
- {
43
- name: 'code-review',
44
- description:
45
- 'Automated code review with multiple dimensions — complexity, security, performance, quality',
46
- url: 'https://github.com/One-Mipham/skill-code-review',
47
- file: 'code-review.SKILL.md',
48
- category: 'Development',
49
- author: 'MiphamAI',
50
- builtin: true,
51
- },
52
- {
53
- name: 'systematic-debugging',
54
- description: 'Systematic debugging workflow — find root cause before fixing',
55
- url: 'https://github.com/anthropics/skills',
56
- file: 'systematic-debugging.SKILL.md',
57
- category: 'Development',
58
- author: 'Anthropic',
59
- },
60
- {
61
- name: 'test-driven-development',
62
- description: 'TDD workflow — write failing test first, then implement',
63
- url: 'https://github.com/anthropics/skills',
64
- file: 'test-driven-development.SKILL.md',
65
- category: 'Development',
66
- author: 'Anthropic',
67
- builtin: true,
68
- },
69
- {
70
- name: 'web-access',
71
- description:
72
- 'Web search, scraping, and browser automation — search engines, page fetching, authenticated sites',
73
- url: 'https://github.com/One-Mipham/skill-web-access',
74
- file: 'web-access.SKILL.md',
75
- category: 'Network',
76
- author: 'MiphamAI',
77
- builtin: true,
78
- },
79
- {
80
- name: 'doc-generator',
81
- description: 'Generate API docs, READMEs, and changelogs from code analysis',
82
- url: 'https://github.com/One-Mipham/skill-doc-generator',
83
- file: 'doc-generator.SKILL.md',
84
- category: 'Documentation',
85
- author: 'MiphamAI',
86
- builtin: true,
87
- },
88
- {
89
- name: 'github-ops',
90
- description: 'GitHub PR/issue management and automation — labels, milestones, reviews',
91
- url: 'https://github.com/One-Mipham/skill-github-ops',
92
- file: 'github-ops.SKILL.md',
93
- category: 'DevOps',
94
- author: 'MiphamAI',
95
- builtin: true,
96
- },
97
- {
98
- name: 'security-review',
99
- description:
100
- 'Security audit and vulnerability scanning for code changes — OWASP, secrets, supply chain',
101
- url: 'https://github.com/One-Mipham/skill-security-review',
102
- file: 'security-review.SKILL.md',
103
- category: 'Security',
104
- author: 'MiphamAI',
105
- builtin: true,
106
- },
107
- {
108
- name: 'frontend-design',
109
- description: 'Distinctive visual design guidance for UI components — typography, color, layout',
110
- url: 'https://github.com/anthropics/skills',
111
- file: 'frontend-design.SKILL.md',
112
- category: 'Design',
113
- author: 'Anthropic',
114
- },
115
- ]
43
+ const COMMUNITY_SKILLS: SkillEntry[] = communitySkills as SkillEntry[]
116
44
 
117
45
  // ═══════════════════════════════════════════════════════════════
118
46
  // Skill Installer
@@ -141,13 +69,58 @@ export function searchSkills(query: string): SkillEntry[] {
141
69
  )
142
70
  }
143
71
 
72
+ /**
73
+ * Match a marketplace pattern against an owner/repo.
74
+ *
75
+ * - "owner/*" → matches any repo under that owner
76
+ * - "owner/repo" → exact match
77
+ */
78
+ function matchOwnerPattern(pattern: string, owner: string, repo: string): boolean {
79
+ if (pattern.endsWith('/*')) {
80
+ return pattern.slice(0, -2) === owner
81
+ }
82
+ return pattern === repo
83
+ }
84
+
85
+ /**
86
+ * Check whether a GitHub URL (or any URL) is allowed by the marketplace config.
87
+ *
88
+ * Rules (in order):
89
+ * 1. No config → allow everything
90
+ * 2. blockedMarketplaces matched → deny (deny wins over allow)
91
+ * 3. strictKnownMarketplaces set → deny unless matched
92
+ * 4. Non-GitHub URL with no strict list → allow
93
+ * 5. Non-GitHub URL with strict list → deny
94
+ */
95
+ function isMarketplaceAllowed(url: string, config?: MiphamConfig['marketplace']): boolean {
96
+ if (!config) return true
97
+ const { strictKnownMarketplaces, blockedMarketplaces } = config
98
+ const match = url.match(/github\.com\/([^/]+\/[^/]+)/)
99
+ if (!match) return !strictKnownMarketplaces?.length // non-GitHub: allow only if no strict list
100
+ const repo = match[1]! // "owner/repo"
101
+ const [owner] = repo.split('/')
102
+
103
+ // Check blocked first (deny wins)
104
+ if (blockedMarketplaces?.some((pattern) => matchOwnerPattern(pattern, owner!, repo))) return false
105
+ // Check strict allowlist
106
+ if (
107
+ strictKnownMarketplaces?.length &&
108
+ !strictKnownMarketplaces.some((pattern) => matchOwnerPattern(pattern, owner!, repo))
109
+ )
110
+ return false
111
+ return true
112
+ }
113
+
144
114
  /**
145
115
  * Install a skill from a GitHub repository.
146
116
  *
147
117
  * Clones the repo to a temp directory, copies the skill file(s),
148
118
  * and cleans up.
149
119
  */
150
- export function installSkill(skillName: string): InstallResult {
120
+ export function installSkill(
121
+ skillName: string,
122
+ marketplaceConfig?: MiphamConfig['marketplace'],
123
+ ): InstallResult {
151
124
  const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
152
125
  if (!entry) {
153
126
  return {
@@ -157,6 +130,15 @@ export function installSkill(skillName: string): InstallResult {
157
130
  }
158
131
  }
159
132
 
133
+ // Check marketplace restrictions
134
+ if (!isMarketplaceAllowed(entry.url, marketplaceConfig)) {
135
+ return {
136
+ success: false,
137
+ name: skillName,
138
+ message: `Skill "${skillName}" is from a blocked or unapproved marketplace: ${entry.url}`,
139
+ }
140
+ }
141
+
160
142
  // Built-in skills are already available — no download needed
161
143
  if (entry.builtin) {
162
144
  return {
@@ -214,7 +196,19 @@ export function installSkill(skillName: string): InstallResult {
214
196
  /**
215
197
  * Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
216
198
  */
217
- export function installSkillFromUrl(url: string): InstallResult {
199
+ export function installSkillFromUrl(
200
+ url: string,
201
+ marketplaceConfig?: MiphamConfig['marketplace'],
202
+ ): InstallResult {
203
+ // Check marketplace restrictions
204
+ if (!isMarketplaceAllowed(url, marketplaceConfig)) {
205
+ return {
206
+ success: false,
207
+ name: url,
208
+ message: `URL is from a blocked or unapproved marketplace: ${url}`,
209
+ }
210
+ }
211
+
218
212
  // Derive skill name from URL
219
213
  const name =
220
214
  url
@@ -28,9 +28,9 @@ const BLOCKED_PATTERNS = [
28
28
  /\$\(/,
29
29
  // Backtick command substitution
30
30
  /`[^`]+`/,
31
- // Interpreter code execution (bypass vector)
32
- /\bpython3?\s+-c\b/,
33
- /\bpython3?\s+-m\b/,
31
+ // Interpreter code execution (bypass vector) — covers python, python2, python3
32
+ /\bpython[23]?\s+-c\b/,
33
+ /\bpython[23]?\s+-m\b/,
34
34
  /\bperl\s+-[ep]\b/,
35
35
  /\bruby\s+-e\b/,
36
36
  /\bnode\s+-e\b/,
@@ -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
- // Check exact blocked commands
78
- const firstWord = command.trim().split(/\s+/)[0]
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}" is blocked (destructive filesystem operation).`
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
  }
@@ -45,9 +45,31 @@ export const enterWorktreeTool: ToolDefinition = {
45
45
  'Worktree name may only contain letters, digits, dots, underscores, dashes, and slashes.',
46
46
  }
47
47
  }
48
+ // Prevent path traversal via .. segments
49
+ if (name.includes('..')) {
50
+ return {
51
+ success: false,
52
+ content: '',
53
+ error: 'Worktree name may not contain ".." path segments.',
54
+ }
55
+ }
48
56
 
49
57
  const cwd = ctx.cwd
50
- const worktreePath = `${cwd}/.claude/worktrees/${name}`
58
+ const { resolve } = await import('node:path')
59
+ const worktreePath = resolve(`${cwd}/.claude/worktrees/${name}`)
60
+ const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
61
+
62
+ // Defense-in-depth: verify resolved path is within .claude/worktrees/
63
+ if (
64
+ !worktreePath.startsWith(allowedPrefix + '/') &&
65
+ worktreePath !== allowedPrefix.slice(0, -1)
66
+ ) {
67
+ return {
68
+ success: false,
69
+ content: '',
70
+ error: 'Worktree path must be within .claude/worktrees/.',
71
+ }
72
+ }
51
73
 
52
74
  try {
53
75
  // Validate we're in a git repo
@@ -36,6 +36,19 @@ export const exitWorktreeTool: ToolDefinition = {
36
36
 
37
37
  // Validate the path is under .claude/worktrees
38
38
  const cwd = ctx.cwd
39
+ const { resolve } = await import('node:path')
40
+ const resolvedPath = resolve(worktreePath)
41
+ const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
42
+
43
+ if (!resolvedPath.startsWith(allowedPrefix)) {
44
+ return {
45
+ success: false,
46
+ content: '',
47
+ error:
48
+ `Path "${worktreePath}" is not under .claude/worktrees/. ` +
49
+ `Only worktrees created by EnterWorktree can be managed here.`,
50
+ }
51
+ }
39
52
 
40
53
  try {
41
54
  // Verify the worktree exists in git's worktree list
@@ -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
- const errors = validateParams(schema, params)
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(params, ctx)
106
+ return tool.execute(cleanParams, ctx)
104
107
  },
105
108
  }
106
109
  }
@@ -0,0 +1,89 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ export interface AgentEntry {
5
+ id: string
6
+ name: string
7
+ description: string
8
+ startTime: number
9
+ tokensUsed: number
10
+ status: 'running' | 'completed'
11
+ }
12
+
13
+ interface AgentFooterProps {
14
+ agents: AgentEntry[]
15
+ gitBranch: string
16
+ /** Tick counter for live elapsed-time re-renders. */
17
+ tick: number
18
+ }
19
+
20
+ /** Format elapsed seconds into human-readable string (e.g. "2m 51s", "38s"). */
21
+ function formatElapsed(seconds: number): string {
22
+ if (seconds < 60) return `${seconds}s`
23
+ const m = Math.floor(seconds / 60)
24
+ const s = seconds % 60
25
+ return `${m}m ${s}s`
26
+ }
27
+
28
+ /** Format token count: 42400 → "42.4k", 1500000 → "1.5M". */
29
+ function formatTokens(n: number): string {
30
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
31
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
32
+ return String(n)
33
+ }
34
+
35
+ export function AgentFooter({ agents, gitBranch, tick: _tick }: AgentFooterProps) {
36
+ const hasAgents = agents.length > 0
37
+ const hasBranch = gitBranch.length > 0
38
+
39
+ if (!hasAgents && !hasBranch) return null
40
+
41
+ return (
42
+ <Box flexDirection="column" marginTop={1}>
43
+ {/* Git branch indicator */}
44
+ {hasBranch && (
45
+ <Box>
46
+ <Text dimColor>
47
+ {' '}⏺ {gitBranch}
48
+ </Text>
49
+ </Box>
50
+ )}
51
+
52
+ {/* Agent status lines */}
53
+ {agents.map((agent) => {
54
+ const elapsed = Math.floor((Date.now() - agent.startTime) / 1000)
55
+ const isRunning = agent.status === 'running'
56
+ const symbol = isRunning ? '◯' : '◼'
57
+ const symbolColor = isRunning ? 'cyan' : undefined
58
+
59
+ // Truncate description to keep lines readable
60
+ const desc =
61
+ agent.description.length > 80 ? agent.description.slice(0, 80) + '...' : agent.description
62
+
63
+ return (
64
+ <Box key={agent.id}>
65
+ <Text dimColor={!isRunning} color={symbolColor}>
66
+ {' '}
67
+ {symbol}{' '}
68
+ </Text>
69
+ <Text bold color={isRunning ? 'cyan' : undefined} dimColor={!isRunning}>
70
+ {agent.name}
71
+ </Text>
72
+ <Text dimColor> {desc}</Text>
73
+ {/* Spacer pushes stats to the right — Ink handles this naturally */}
74
+ <Text dimColor>
75
+ {' '}
76
+ {isRunning ? formatElapsed(elapsed) : 'finished'}
77
+ </Text>
78
+ {isRunning && agent.tokensUsed > 0 && (
79
+ <Text dimColor>
80
+ {' · ↓ '}
81
+ {formatTokens(agent.tokensUsed)} tokens
82
+ </Text>
83
+ )}
84
+ </Box>
85
+ )
86
+ })}
87
+ </Box>
88
+ )
89
+ }