@miphamai/cli 0.71.0 → 0.72.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,6 +1,182 @@
1
1
  import type { PermissionRuleEntry } from '../shared/index.ts'
2
2
  import { matchPath } from './credential-masker/matcher'
3
3
 
4
+ // ── Bash command analysis (Read/Write/Edit deny-rule extension) ──
5
+ //
6
+ // A Read(/etc/passwd) deny rule must also refuse Bash commands that read that
7
+ // file (`cat /etc/passwd`, `tac /etc/passwd`, `grep x /etc/passwd`,
8
+ // `< /etc/passwd`) — not only the Read tool. Same for Write/Edit rules and
9
+ // `> file` redirects / in-place editors. These helpers map a Bash command to
10
+ // the file paths it touches, conservatively: over-matching is the safe
11
+ // direction for a deny rule, under-matching is not.
12
+
13
+ /** Commands that read a file and print its contents (bypass Read() rules today). */
14
+ const READER_COMMANDS = new Set([
15
+ 'cat',
16
+ 'tac',
17
+ 'head',
18
+ 'tail',
19
+ 'less',
20
+ 'more',
21
+ 'grep',
22
+ 'egrep',
23
+ 'fgrep',
24
+ 'rg',
25
+ 'sed',
26
+ 'perl',
27
+ 'awk',
28
+ 'nl',
29
+ 'sort',
30
+ 'uniq',
31
+ 'cut',
32
+ 'diff',
33
+ 'comm',
34
+ 'join',
35
+ 'paste',
36
+ 'file',
37
+ 'stat',
38
+ 'xxd',
39
+ 'od',
40
+ 'hexdump',
41
+ 'strings',
42
+ 'wc',
43
+ 'view',
44
+ 'zcat',
45
+ 'bzcat',
46
+ ])
47
+
48
+ /** Commands that write/modify a file (bypass Write()/Edit() rules today). */
49
+ const WRITER_COMMANDS = new Set([
50
+ 'tee',
51
+ 'touch',
52
+ 'nano',
53
+ 'vi',
54
+ 'vim',
55
+ 'emacs',
56
+ 'ed',
57
+ 'ex',
58
+ 'cp',
59
+ 'mv',
60
+ 'rm',
61
+ 'dd',
62
+ 'install',
63
+ 'truncate',
64
+ ])
65
+
66
+ /**
67
+ * Split a (possibly compound) shell command into simple-command segments, so a
68
+ * Bash(pattern) rule matches any segment rather than only the whole string
69
+ * (`rm -rf /` buried in `foo && rm -rf /` must still match).
70
+ *
71
+ * Separators: `&&` `||` `;` `|` `&` and newline. Redirect operators (`>`,
72
+ * `>>`, `<`) are NOT separators — they belong to the same simple command.
73
+ * Quoting is not parsed (a `&` inside quotes still splits), which only
74
+ * over-matches — the safe direction for deny rules.
75
+ */
76
+ export function splitShellSegments(command: string): string[] {
77
+ return command
78
+ .split(/&&|\|\||;|\||&|\n|\r/g)
79
+ .map((s) => s.trim())
80
+ .filter((s) => s.length > 0)
81
+ }
82
+
83
+ function stripQuotes(s: string): string {
84
+ if (s.length >= 2) {
85
+ const first = s[0]
86
+ const last = s[s.length - 1]
87
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
88
+ return s.slice(1, -1)
89
+ }
90
+ }
91
+ return s
92
+ }
93
+
94
+ function uniq(items: string[]): string[] {
95
+ return [...new Set(items)]
96
+ }
97
+
98
+ /** File paths a Bash command reads or writes. */
99
+ export interface BashFileAccess {
100
+ read: string[]
101
+ write: string[]
102
+ }
103
+
104
+ /**
105
+ * Extract the file paths a Bash command reads or writes, so Read()/Write()/
106
+ * Edit() deny rules also apply to Bash (not just the Read/Write/Edit tools).
107
+ * Covers redirects (`<` reads, `>`/`>>` writes) and reader/writer command
108
+ * arguments — including inside `$(...)` and backtick substitutions. Deliberately
109
+ * conservative, not a full shell parser.
110
+ */
111
+ export function extractBashFileAccess(command: string): BashFileAccess {
112
+ const read: string[] = []
113
+ const write: string[] = []
114
+
115
+ // 1. Redirect targets. Handles `> file` and `>file`, optional fd prefix
116
+ // (`2>`, `&>`). Heredocs (`<<`) and fd-dup targets (`2>&1`) are skipped —
117
+ // their "target" is a delimiter or file descriptor, not a path.
118
+ const redirectRe = /(?:^|[\s;|&])([0-9]*&?)?(>>|<<|<|>)\s*([^\s;|&<>]+)/g
119
+ let m: RegExpExecArray | null
120
+ while ((m = redirectRe.exec(command)) !== null) {
121
+ const op = m[2]!
122
+ if (op === '<<') continue // heredoc delimiter
123
+ const target = stripQuotes(m[3]!)
124
+ if (op === '<') read.push(target)
125
+ else write.push(target)
126
+ }
127
+
128
+ // 2. Reader/writer command arguments (non-flag args are candidate paths),
129
+ // recursing into `$(...)` / backtick substitutions.
130
+ scanReaderWriterCommands(command, read, write)
131
+
132
+ return { read: uniq(read), write: uniq(write) }
133
+ }
134
+
135
+ /** Extract the inner commands of `$(...)` and backtick substitutions. */
136
+ function extractSubstitutions(command: string): string[] {
137
+ const inners: string[] = []
138
+ let m: RegExpExecArray | null
139
+ // `$(...)` — non-nested groups; deeper nesting is handled by the recursion
140
+ // in scanReaderWriterCommands (each level is extracted on the next pass).
141
+ const dollarParen = /\$\(([^()]*)\)/g
142
+ while ((m = dollarParen.exec(command)) !== null) inners.push(m[1]!)
143
+ const backtick = /`([^`]*)`/g
144
+ while ((m = backtick.exec(command)) !== null) inners.push(m[1]!)
145
+ return inners
146
+ }
147
+
148
+ /**
149
+ * Detect reader/writer commands at the front of each shell segment and recurse
150
+ * into command substitutions, so `echo $(cat .git-credentials)` is caught.
151
+ */
152
+ function scanReaderWriterCommands(command: string, read: string[], write: string[]): void {
153
+ for (const seg of splitShellSegments(command)) {
154
+ const tokens = seg.split(/\s+/).filter(Boolean)
155
+ if (tokens.length > 0) {
156
+ const base = (tokens[0] || '').split('/').pop() || ''
157
+ const args = tokens.slice(1)
158
+ if (READER_COMMANDS.has(base)) {
159
+ // `sed -i` / `perl -i` read AND write their file args.
160
+ const inPlace = args.some((a) => a === '-i' || a.startsWith('--in-place'))
161
+ for (const arg of args) {
162
+ if (arg.startsWith('-')) continue
163
+ const p = stripQuotes(arg)
164
+ read.push(p)
165
+ if (inPlace) write.push(p)
166
+ }
167
+ } else if (WRITER_COMMANDS.has(base)) {
168
+ for (const arg of args) {
169
+ if (arg.startsWith('-')) continue
170
+ write.push(stripQuotes(arg))
171
+ }
172
+ }
173
+ }
174
+ for (const inner of extractSubstitutions(seg)) {
175
+ scanReaderWriterCommands(inner, read, write)
176
+ }
177
+ }
178
+ }
179
+
4
180
  // Match a tool(parameter) rule against an actual tool call.
5
181
  //
6
182
  // Pattern formats:
@@ -25,12 +201,24 @@ export function matchBashRule(
25
201
 
26
202
  const [, baseTool, subPattern] = parenMatch
27
203
 
204
+ // A Read/Write/Edit rule must also refuse a Bash command that touches the
205
+ // same file (via a reader/editor command or a redirect), not only the
206
+ // Read/Write/Edit tool itself. Otherwise `cat .git-credentials` bypasses a
207
+ // `Read(.git-credentials)` deny rule.
208
+ if (toolName === 'Bash' && (baseTool === 'Read' || baseTool === 'Write' || baseTool === 'Edit')) {
209
+ const cmd = String(toolInput.command || '')
210
+ const access = extractBashFileAccess(cmd)
211
+ const paths = baseTool === 'Read' ? access.read : access.write
212
+ return paths.some((p) => matchPath(p, subPattern!))
213
+ }
214
+
28
215
  if (toolName !== baseTool!) return false
29
216
 
30
- // For Bash: match against the command string
217
+ // For Bash: match against the command string (any segment of a compound
218
+ // command — `rm -rf /` buried in `foo && rm -rf /` still matches).
31
219
  if (baseTool === 'Bash') {
32
220
  const cmd = String(toolInput.command || '')
33
- return wildcardMatch(subPattern!, cmd.trim())
221
+ return splitShellSegments(cmd).some((seg) => wildcardMatch(subPattern!, seg))
34
222
  }
35
223
 
36
224
  // For Write/Edit/Read: match against the file_path with path-glob semantics.
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.71.0' as const
12
+ export const PACKAGE_VERSION = '0.72.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -276,7 +276,7 @@ export const webFetchTool: ToolDefinition = {
276
276
  const message =
277
277
  err instanceof Error && err.name === 'AbortError'
278
278
  ? 'Request timed out (30s)'
279
- : `Fetch failed: ${String(err)}`
279
+ : `Fetch failed: ${String(err)}. If direct network is blocked, retry via the web-access skill (CDP through the user's logged-in Chrome).`
280
280
  return { success: false, content: '', error: message }
281
281
  }
282
282
  },
@@ -126,6 +126,8 @@ export const webSearchTool: ToolDefinition = {
126
126
  'Alternatives (add API key for any):',
127
127
  ' TAVILY_API_KEY — https://tavily.com',
128
128
  ' SERPAPI_API_KEY — https://serpapi.com',
129
+ '',
130
+ 'No API key? Use the web-access skill (CDP through your logged-in Chrome) for live web search.',
129
131
  ].join('\n'),
130
132
  }
131
133
  }
@@ -199,7 +201,7 @@ export const webSearchTool: ToolDefinition = {
199
201
  const message =
200
202
  err instanceof Error && err.name === 'AbortError'
201
203
  ? 'Search timed out (15s). Try a more specific query.'
202
- : `Search failed: ${String(err)}`
204
+ : `Search failed: ${String(err)}. If direct network is blocked, retry via the web-access skill (CDP through the user's logged-in Chrome).`
203
205
  return { success: false, content: '', error: message }
204
206
  }
205
207
  },