@miphamai/cli 0.81.7 → 0.81.9
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/README.md +1 -1
- package/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +14 -9
- package/src/config/loader.ts +202 -63
- package/src/config/preferences.ts +5 -2
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/engine.ts +7 -2
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +157 -6
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +49 -2
- package/src/core/session-store.ts +11 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/daemon/session-worker.ts +15 -0
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +47 -19
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +30 -8
- package/src/providers/anthropic.ts +74 -13
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +25 -2
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +17 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +146 -24
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +83 -3
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +70 -16
- package/src/tools/file/read.ts +151 -45
- package/src/tools/index.ts +12 -4
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +187 -41
- package/src/workflow/primitives/agent.ts +4 -0
- package/src/artifacts/versioning.ts +0 -127
package/src/core/hooks.ts
CHANGED
|
@@ -48,6 +48,18 @@ export class HookEngine {
|
|
|
48
48
|
/** Health tracking per hook key (event[:toolName]) */
|
|
49
49
|
private health = new Map<string, HookHealth>()
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The workspace this engine's hooks run *for*.
|
|
53
|
+
*
|
|
54
|
+
* Settled at construction because one engine belongs to one session, not to one
|
|
55
|
+
* hook. The default is exactly right for the one-shot CLI, whose process cwd
|
|
56
|
+
* *is* the session cwd — but the daemon serves many sessions from one process,
|
|
57
|
+
* so it must pass the session's cwd. Left to the executor's own
|
|
58
|
+
* `process.cwd()`, every daemon hook would run in — and be told it is in — the
|
|
59
|
+
* directory the daemon happened to be started from.
|
|
60
|
+
*/
|
|
61
|
+
constructor(private readonly cwd: string = process.cwd()) {}
|
|
62
|
+
|
|
51
63
|
register(hook: HookDefinition): void {
|
|
52
64
|
this.hooks.push(hook)
|
|
53
65
|
}
|
|
@@ -98,7 +110,12 @@ export class HookEngine {
|
|
|
98
110
|
|
|
99
111
|
async executeStop(sessionId: string): Promise<HookResult> {
|
|
100
112
|
const ctx: HookContext = { event: 'Stop', sessionId }
|
|
101
|
-
|
|
113
|
+
const result = await this.runHooks('Stop', undefined, ctx)
|
|
114
|
+
|
|
115
|
+
// A blocking Stop hook arrives as a deny (exit code 2, `decision: block`, or
|
|
116
|
+
// `continue: false`), but the engine's Stop path reads `decision`. Derive it
|
|
117
|
+
// here rather than at each producer so every form reaches "do not stop yet".
|
|
118
|
+
return result.allowed ? result : { ...result, decision: 'block' }
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
|
|
@@ -134,9 +151,12 @@ export class HookEngine {
|
|
|
134
151
|
const ctx: HookContext = {
|
|
135
152
|
event: 'SubagentStart',
|
|
136
153
|
sessionId,
|
|
154
|
+
// The agent type plays the role a tool name plays for PreToolUse: it is
|
|
155
|
+
// what a settings.json `matcher` selects on.
|
|
156
|
+
toolName: agentType,
|
|
137
157
|
toolInput: { agentType, description },
|
|
138
158
|
}
|
|
139
|
-
return this.runHooks('SubagentStart',
|
|
159
|
+
return this.runHooks('SubagentStart', agentType, ctx)
|
|
140
160
|
}
|
|
141
161
|
|
|
142
162
|
async executeSubagentStop(
|
|
@@ -149,10 +169,12 @@ export class HookEngine {
|
|
|
149
169
|
const ctx: HookContext = {
|
|
150
170
|
event: 'SubagentStop',
|
|
151
171
|
sessionId,
|
|
172
|
+
// Matcher target — see executeSubagentStart.
|
|
173
|
+
toolName: agentType,
|
|
152
174
|
toolInput: { agentType, description, success },
|
|
153
175
|
toolResult: result ? { success, content: result.slice(0, 2000) } : undefined,
|
|
154
176
|
}
|
|
155
|
-
return this.runHooks('SubagentStop',
|
|
177
|
+
return this.runHooks('SubagentStop', agentType, ctx)
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
async executePostToolUseFailure(
|
|
@@ -268,15 +290,40 @@ export class HookEngine {
|
|
|
268
290
|
|
|
269
291
|
// ── Core execution ──
|
|
270
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Does a hook's `matcher` select this invocation?
|
|
295
|
+
*
|
|
296
|
+
* No matcher means every invocation of the event. Otherwise the stored matcher
|
|
297
|
+
* is a regex — `loadHookConfigs` compiles it as one — tested against the name
|
|
298
|
+
* this event filters on: the tool name for tool events, the agent type for the
|
|
299
|
+
* subagent events. Events that carry no such name (Stop, SessionStart, …) are
|
|
300
|
+
* not filtered here.
|
|
301
|
+
*/
|
|
302
|
+
private matchesMatcher(matcher: string | undefined, name: string | undefined): boolean {
|
|
303
|
+
if (!matcher || !name) return true
|
|
304
|
+
try {
|
|
305
|
+
return new RegExp(matcher).test(name)
|
|
306
|
+
} catch {
|
|
307
|
+
// An uncompilable pattern cannot get this far through loadHookConfigs,
|
|
308
|
+
// which compiles every matcher when it loads. Keep such a hook running
|
|
309
|
+
// rather than dropping it silently.
|
|
310
|
+
return true
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
271
314
|
private async runHooks(
|
|
272
315
|
event: HookEvent,
|
|
273
316
|
toolName: string | undefined,
|
|
274
317
|
ctx: HookContext,
|
|
275
318
|
): Promise<HookResult> {
|
|
276
319
|
const matching = this.hooks.filter(
|
|
277
|
-
(h) => h.event === event && (
|
|
320
|
+
(h) => h.event === event && this.matchesMatcher(h.toolName, toolName),
|
|
278
321
|
)
|
|
279
322
|
|
|
323
|
+
// Stamped here rather than at each `executeX`: the cwd is a property of the
|
|
324
|
+
// engine, and every context this engine hands out needs it.
|
|
325
|
+
ctx.cwd = this.cwd
|
|
326
|
+
|
|
280
327
|
const result: HookResult = { allowed: true }
|
|
281
328
|
|
|
282
329
|
for (const hook of matching) {
|
package/src/core/paths.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* 突然失去隔离保护(隔离度只许增不许减)。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { realpathSync } from 'node:fs'
|
|
10
11
|
import { homedir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
+
import { basename, dirname, join } from 'node:path'
|
|
12
13
|
import { MIPHAM_DIR } from '../shared/constants.ts'
|
|
13
14
|
|
|
14
15
|
/** 只读兼容目录名。 */
|
|
@@ -33,6 +34,48 @@ export function worktreeRoots(cwd: string): string[] {
|
|
|
33
34
|
return [worktreeRoot(cwd), join(cwd, LEGACY_CLAUDE_DIR, 'worktrees')]
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 规范化 worktree 路径,两侧都过一遍才谈得上比较。
|
|
39
|
+
*
|
|
40
|
+
* 叶子不存在是常态(工作树已被删、或路径是模型编出来的),此时 `realpathSync`
|
|
41
|
+
* 会抛 —— 那就只规范父目录、最后一段按原样留着,否则「叶子没了」会被误读成
|
|
42
|
+
* 「拼法不同」(明明同一个路径,却因为 P 的拼法与 git 打印的不同而判成不在)。
|
|
43
|
+
*/
|
|
44
|
+
function canonicalWorktreePath(path: string): string {
|
|
45
|
+
const trimmed = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(trimmed)
|
|
48
|
+
} catch {
|
|
49
|
+
try {
|
|
50
|
+
return join(realpathSync(dirname(trimmed)), basename(trimmed))
|
|
51
|
+
} catch {
|
|
52
|
+
return trimmed
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `git worktree list --porcelain` 里是否**确实**列出了 `target` 这个工作树。
|
|
59
|
+
*
|
|
60
|
+
* 不能用 `output.includes(target)`:那是子串判定,本机实测(真 git,建出 `w1`
|
|
61
|
+
* 与 `w10`)它错在三个方向 ——
|
|
62
|
+
* - `.../w1` 命中 **`.../w10` 那一行**(前缀当成同一个)⇒ 不存在被判成存在。
|
|
63
|
+
* EnterWorktree 那侧因此连 `w1` 都建不出来:明明没有,它报 already exists;
|
|
64
|
+
* - 带尾斜杠的 `.../w1/` 一行都不命中 ⇒ 存在被判成 not found;
|
|
65
|
+
* - git 打印 **realpath 拼法**(`mktemp -d /tmp/x` 建的在 porcelain 里是
|
|
66
|
+
* `/private/tmp/x/...`)⇒ 别名拼法一头都命中不了,而 EnterWorktree 的成功
|
|
67
|
+
* 文案里印的正是它自己算出来的那个拼法,模型照抄回来必然吃 not found。
|
|
68
|
+
*
|
|
69
|
+
* 判据是**相等**(名字比对),不是包含 —— 工作树列表里列的就是工作树根。
|
|
70
|
+
*/
|
|
71
|
+
export function listsWorktree(output: string, target: string): boolean {
|
|
72
|
+
const want = canonicalWorktreePath(target)
|
|
73
|
+
return output
|
|
74
|
+
.split('\n')
|
|
75
|
+
.filter((line) => line.startsWith('worktree '))
|
|
76
|
+
.some((line) => canonicalWorktreePath(line.slice('worktree '.length).trim()) === want)
|
|
77
|
+
}
|
|
78
|
+
|
|
36
79
|
/**
|
|
37
80
|
* 在 `cwd` 中定位 worktree 标记,返回项目根与命中的标记。
|
|
38
81
|
* 不在任何 worktree 内时返回 null。
|
|
@@ -20,21 +20,161 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Nominal permissiveness ranking, least → most permissive. Two consumers only:
|
|
24
|
+
* `maxAllowedMode` (drop every mode ranked above the cap) and `clampMode` (walk
|
|
25
|
+
* downward to the nearest allowed mode below the one requested).
|
|
26
|
+
*
|
|
27
|
+
* **The four modes are not totally ordered in reality**, so this array carries
|
|
28
|
+
* only the relations that are actually measurable:
|
|
29
|
+
*
|
|
30
|
+
* - `plan` is strictly the narrowest. It passes only Read/Grep/Glob and sends
|
|
31
|
+
* *everything* else to approval, while `default` passes every tool that
|
|
32
|
+
* declares `permission: 'auto'` — git, task, web-fetch, cron, memory, … So a
|
|
33
|
+
* cap of `'plan'` must not admit `default`, and `plan` belongs at the bottom.
|
|
34
|
+
* - `acceptEdits` and `default` are **incomparable**: acceptEdits auto-approves
|
|
35
|
+
* Write/Edit and verification-only Bash that `default` asks about, while
|
|
36
|
+
* `default` auto-approves the non-file `'auto'` tools that acceptEdits asks
|
|
37
|
+
* about. No total order is faithful there, so the ranking only needs to carry
|
|
38
|
+
* the relations the two consumers rely on.
|
|
39
|
+
*
|
|
40
|
+
* The array used to read `default → acceptEdits → plan → …`, which **inverted**
|
|
41
|
+
* both `plan` relations rather than merely approximating them: `maxAllowedMode:
|
|
42
|
+
* 'plan'` admitted acceptEdits *and* default — the ceiling let through the wider
|
|
43
|
+
* mode each time. The pairs are pinned by a probe in `test/core/permission.test.ts`
|
|
44
|
+
* (P4) so the claim stays measured rather than asserted.
|
|
25
45
|
*/
|
|
26
46
|
export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
|
|
47
|
+
'plan',
|
|
27
48
|
'default',
|
|
28
49
|
'acceptEdits',
|
|
29
|
-
'plan',
|
|
30
50
|
'bypassPermissions',
|
|
31
51
|
]
|
|
32
52
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Shift+Tab cycling order — deliberately **not** the permissiveness order above.
|
|
55
|
+
* The cycle is UX (manual → accept edits → plan → bypass); only the hierarchy
|
|
56
|
+
* answers "is this mode wider than that one". Keeping them separate is what lets
|
|
57
|
+
* `forbiddenModes` drop an entry from the cycle without disturbing the ranking
|
|
58
|
+
* that `clampMode` walks.
|
|
59
|
+
*/
|
|
60
|
+
export const MODE_CYCLE: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']
|
|
61
|
+
|
|
62
|
+
/** 规范形 → 把别名与大小写归一到一个键上(键一律小写)。 */
|
|
63
|
+
const MODE_ALIASES: Record<string, PermissionMode> = {
|
|
64
|
+
default: 'default',
|
|
65
|
+
plan: 'plan',
|
|
66
|
+
acceptedits: 'acceptEdits',
|
|
67
|
+
bypasspermissions: 'bypassPermissions',
|
|
68
|
+
bypass: 'bypassPermissions', // 遗留 3 档名(PermissionLevel 里的 'bypass')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 认不出的配置一律按这一档收紧 —— 层级表首位即最严的一档(与 P4 同一真源)。 */
|
|
72
|
+
const STRICTEST_MODE: PermissionMode = PERMISSION_MODE_HIERARCHY[0]!
|
|
73
|
+
|
|
74
|
+
const VALID_MODE_LIST = 'default, plan, acceptEdits, bypassPermissions'
|
|
75
|
+
|
|
76
|
+
/** 可读的类型名 —— 报错要说清「你给的是个字符串」,而不是只说 invalid。 */
|
|
77
|
+
function describeValue(value: unknown): string {
|
|
78
|
+
if (value === null) return 'null'
|
|
79
|
+
if (Array.isArray(value)) return 'array'
|
|
80
|
+
return typeof value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 认得出就返回规范形,认不出返回 undefined(调用方负责告警)。 */
|
|
84
|
+
function normalizeModeName(value: unknown): PermissionMode | undefined {
|
|
85
|
+
if (typeof value !== 'string') return undefined
|
|
86
|
+
return MODE_ALIASES[value.trim().toLowerCase()]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 校验并规范化 `permissionRestrictions`。
|
|
91
|
+
*
|
|
92
|
+
* 与 `getInvalidRules()` 同一形状:写错的**规则**一直会被告警,写错的**限制**却不会
|
|
93
|
+
* —— 而限制写错的失效方向是 **fail-open**:`forbiddenModes` 里的错拼一个模式都匹配
|
|
94
|
+
* 不上;`maxAllowedMode` 认不出时 `indexOf` 返回 -1,`if (capIdx >= 0)` 之后整个上限
|
|
95
|
+
* 被跳过。于是配置里一个 typo 就让整条组织级策略静默失效,且无任何提示。
|
|
96
|
+
*
|
|
97
|
+
* `restrictions` 是规范化后的值(别名与大小写归一、认不出的条目剔除);
|
|
98
|
+
* `invalid` 是逐条可读告警。**只要有任意一条认不出来,就按最严一档封顶** ——
|
|
99
|
+
* 拒绝而不是忽略(忽略就是上面那种 fail-open)。
|
|
100
|
+
*
|
|
101
|
+
* 幂等:已规范化的值再喂一次,`invalid` 必为空(子代理会原样转交一次)。
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeRestrictions(raw: unknown): {
|
|
104
|
+
restrictions?: PermissionRestrictions
|
|
105
|
+
invalid: string[]
|
|
106
|
+
} {
|
|
107
|
+
if (raw === undefined || raw === null) return { restrictions: undefined, invalid: [] }
|
|
108
|
+
|
|
109
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
110
|
+
return {
|
|
111
|
+
restrictions: { maxAllowedMode: STRICTEST_MODE },
|
|
112
|
+
invalid: [`permissionRestrictions is not an object (got ${describeValue(raw)})`],
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const source = raw as Record<string, unknown>
|
|
117
|
+
const invalid: string[] = []
|
|
118
|
+
const forbiddenModes: PermissionMode[] = []
|
|
119
|
+
let maxAllowedMode: PermissionMode | undefined
|
|
120
|
+
let sawForbidden = false
|
|
121
|
+
let sawMaxAllowed = false
|
|
122
|
+
|
|
123
|
+
for (const key of Object.keys(source)) {
|
|
124
|
+
if (key !== 'forbiddenModes' && key !== 'maxAllowedMode') {
|
|
125
|
+
invalid.push(
|
|
126
|
+
`permissionRestrictions has an unknown key "${key}"; valid keys: forbiddenModes, maxAllowedMode`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (source.forbiddenModes !== undefined) {
|
|
132
|
+
sawForbidden = true
|
|
133
|
+
if (!Array.isArray(source.forbiddenModes)) {
|
|
134
|
+
invalid.push(
|
|
135
|
+
`permissionRestrictions.forbiddenModes must be an array (got ${describeValue(source.forbiddenModes)})`,
|
|
136
|
+
)
|
|
137
|
+
} else {
|
|
138
|
+
source.forbiddenModes.forEach((entry: unknown, i: number) => {
|
|
139
|
+
const mode = normalizeModeName(entry)
|
|
140
|
+
if (mode) forbiddenModes.push(mode)
|
|
141
|
+
else
|
|
142
|
+
invalid.push(
|
|
143
|
+
`permissionRestrictions.forbiddenModes[${i}] is not a permission mode (${JSON.stringify(entry)}); valid: ${VALID_MODE_LIST}`,
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (source.maxAllowedMode !== undefined) {
|
|
150
|
+
sawMaxAllowed = true
|
|
151
|
+
const mode = normalizeModeName(source.maxAllowedMode)
|
|
152
|
+
if (mode) maxAllowedMode = mode
|
|
153
|
+
else
|
|
154
|
+
invalid.push(
|
|
155
|
+
`permissionRestrictions.maxAllowedMode is not a permission mode (${JSON.stringify(source.maxAllowedMode)}); valid: ${VALID_MODE_LIST}`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (invalid.length > 0) {
|
|
160
|
+
// fail-closed:认不出来就按最严一档封顶。识别得出的部分照旧保留。
|
|
161
|
+
invalid.push(
|
|
162
|
+
`permissionRestrictions could not be fully parsed → mode pinned to the strictest ("${STRICTEST_MODE}")`,
|
|
163
|
+
)
|
|
164
|
+
const restrictions: PermissionRestrictions = { maxAllowedMode: STRICTEST_MODE }
|
|
165
|
+
if (forbiddenModes.length > 0) restrictions.forbiddenModes = forbiddenModes
|
|
166
|
+
return { restrictions, invalid }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const restrictions: PermissionRestrictions = {}
|
|
170
|
+
if (sawForbidden) restrictions.forbiddenModes = forbiddenModes
|
|
171
|
+
if (sawMaxAllowed) restrictions.maxAllowedMode = maxAllowedMode
|
|
172
|
+
if (Object.keys(restrictions).length === 0) return { restrictions: undefined, invalid: [] }
|
|
173
|
+
return { restrictions, invalid }
|
|
174
|
+
}
|
|
35
175
|
|
|
36
176
|
/** Resolve which modes are actually permitted given the restrictions. */
|
|
37
|
-
|
|
177
|
+
function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
|
|
38
178
|
let allowed = [...MODE_CYCLE]
|
|
39
179
|
|
|
40
180
|
if (restrictions?.forbiddenModes && restrictions.forbiddenModes.length > 0) {
|
|
@@ -52,14 +192,6 @@ export function getAllowedModes(restrictions?: PermissionRestrictions): Permissi
|
|
|
52
192
|
return allowed
|
|
53
193
|
}
|
|
54
194
|
|
|
55
|
-
/** Check whether a given mode is permitted under the restrictions. */
|
|
56
|
-
export function isModeAllowed(
|
|
57
|
-
mode: PermissionMode,
|
|
58
|
-
restrictions?: PermissionRestrictions,
|
|
59
|
-
): boolean {
|
|
60
|
-
return getAllowedModes(restrictions).includes(mode)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
195
|
/**
|
|
64
196
|
* Return the highest allowed mode at or below `desired` given the restrictions.
|
|
65
197
|
* Used to silently downgrade when a forbidden mode is requested.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
2
4
|
import { matchPath } from './credential-masker/matcher'
|
|
3
5
|
|
|
@@ -106,6 +108,9 @@ const PREFIX_COMMANDS = new Set([
|
|
|
106
108
|
'stdbuf',
|
|
107
109
|
])
|
|
108
110
|
|
|
111
|
+
/** `timeout` 的 duration 形态:`5` / `0.5` / `30s` / `2m` / `1h` / `1d`。 */
|
|
112
|
+
const TIMEOUT_DURATION_RE = /^\d+(\.\d+)?[smhd]?$/
|
|
113
|
+
|
|
109
114
|
/** Value-taking options of wrapper commands (consume the following token). */
|
|
110
115
|
const PREFIX_VALUE_OPTIONS = new Set([
|
|
111
116
|
'-u',
|
|
@@ -193,6 +198,76 @@ function stripQuotes(s: string): string {
|
|
|
193
198
|
return s
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
/** Shell 结构符号:分组与取反 —— 它们自身不是命令,紧跟其后的是。 */
|
|
202
|
+
const LEADING_SHELL_PUNCT = new Set(['(', '{', '!'])
|
|
203
|
+
|
|
204
|
+
/** Shell 关键字:其后才是真正的命令(`do rm -rf x` 执行的命令是 `rm`)。 */
|
|
205
|
+
const LEADING_SHELL_KEYWORDS = new Set([
|
|
206
|
+
'do',
|
|
207
|
+
'then',
|
|
208
|
+
'else',
|
|
209
|
+
'elif',
|
|
210
|
+
'if',
|
|
211
|
+
'while',
|
|
212
|
+
'until',
|
|
213
|
+
'for',
|
|
214
|
+
'case',
|
|
215
|
+
'time',
|
|
216
|
+
'coproc',
|
|
217
|
+
])
|
|
218
|
+
|
|
219
|
+
/** 一条前导赋值:`NAME=value`(name 是合法标识符,`=` 前无引号)。 */
|
|
220
|
+
const LEADING_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 剥掉一段 shell 片段**前导**的噪声 token,露出真正的基命令。
|
|
224
|
+
*
|
|
225
|
+
* 为什么需要它:`Bash(rm *)` / `Read(secret)` 这类规则要匹配的是**命令本身**,
|
|
226
|
+
* 而 shell 允许在命令前放赋值(`IFS=x rm -rf x`)、分组符号(`( rm -rf x )`)、
|
|
227
|
+
* 关键字(`for …; do rm -rf x; done`)。这些都不改变「执行了什么命令」,却足以
|
|
228
|
+
* 让匹配器看不到 `rm`,于是 deny 规则被一个空格级的改写绕过。
|
|
229
|
+
*
|
|
230
|
+
* 只剥前导、可反复剥(`FOO=1 ! rm …`)。剥多了一律是过匹配,而 deny 规则的过
|
|
231
|
+
* 匹配是安全方向。关键字自己的裸 flag 也一并剥掉(`time -p rm …` 里的 `-p`),
|
|
232
|
+
* 否则关键字被剥走后会剩下 `-p rm …`,仍然看不见 `rm`。
|
|
233
|
+
*
|
|
234
|
+
* 不剥尾随符号(`rm -rf x )` 里的 `)`):`wildcardMatch` 的 `*` 已经吃掉它。
|
|
235
|
+
*/
|
|
236
|
+
export function stripLeadingShellNoise(segment: string): string {
|
|
237
|
+
let tokens = segment.trim().split(/\s+/).filter(Boolean)
|
|
238
|
+
let stripped = false
|
|
239
|
+
let skipFlags = false
|
|
240
|
+
for (;;) {
|
|
241
|
+
const head = tokens[0]
|
|
242
|
+
if (!head) break
|
|
243
|
+
if (skipFlags && /^--?[A-Za-z]/.test(head)) {
|
|
244
|
+
tokens = tokens.slice(1)
|
|
245
|
+
stripped = true
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
skipFlags = false
|
|
249
|
+
const bare = head.replace(/^[({!]+/, '') // `(!` 这类连写
|
|
250
|
+
if (bare !== head) {
|
|
251
|
+
tokens = bare ? [bare, ...tokens.slice(1)] : tokens.slice(1)
|
|
252
|
+
stripped = true
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
if (LEADING_SHELL_PUNCT.has(head) || LEADING_ASSIGNMENT_RE.test(head)) {
|
|
256
|
+
tokens = tokens.slice(1)
|
|
257
|
+
stripped = true
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (LEADING_SHELL_KEYWORDS.has(head)) {
|
|
261
|
+
tokens = tokens.slice(1)
|
|
262
|
+
stripped = true
|
|
263
|
+
skipFlags = true // `time -p rm …` —— 关键字自己的裸 flag 不是命令
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
return stripped ? tokens.join(' ') : segment
|
|
269
|
+
}
|
|
270
|
+
|
|
196
271
|
function uniq(items: string[]): string[] {
|
|
197
272
|
return [...new Set(items)]
|
|
198
273
|
}
|
|
@@ -203,6 +278,29 @@ export interface BashFileAccess {
|
|
|
203
278
|
write: string[]
|
|
204
279
|
}
|
|
205
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 展开候选路径里**已知**的变量:前导 `~`、`$HOME`/`${HOME}`。
|
|
283
|
+
*
|
|
284
|
+
* 为什么需要:规则里写的是绝对路径(`Read(/Users/me/.ssh/id_rsa)`),而用户敲的
|
|
285
|
+
* 是同一个文件的另一种拼法(`cat ~/.ssh/id_rsa`)—— 不展开即等于放行。展开后
|
|
286
|
+
* 两种拼法落到同一个字符串上,绝对路径形与路径通配形(如「.ssh 下任意文件」)
|
|
287
|
+
* 规则都能命中。
|
|
288
|
+
*
|
|
289
|
+
* **只展开 HOME**。`$FOO` 这类未知变量原样保留:展开它需要求值环境,静默展开成
|
|
290
|
+
* 空串会让 `/x/$FOO/y` 变成 `/x//y` —— 那是**新增**一个漏判方向,比不展开更坏。
|
|
291
|
+
* `$PWD` 同理需要 cwd,而 `matchBashRule` 的调用点拿不到 cwd,故未覆盖。这两条
|
|
292
|
+
* 都是本函数已知的边界,不是遗漏。
|
|
293
|
+
*
|
|
294
|
+
* `~` 只在**开头**展开(`a/~/b` 里的 `~` 是普通字符,shell 也不展开它)。
|
|
295
|
+
*/
|
|
296
|
+
export function expandKnownPathVars(p: string): string {
|
|
297
|
+
const home = homedir() // 每次取,不在模块加载时绑定(HOME 可能被测试隔离改写)
|
|
298
|
+
let out = p
|
|
299
|
+
if (out === '~') out = home
|
|
300
|
+
else if (out.startsWith('~/')) out = join(home, out.slice(2))
|
|
301
|
+
return out.replace(/\$\{?HOME\}?/g, home)
|
|
302
|
+
}
|
|
303
|
+
|
|
206
304
|
/**
|
|
207
305
|
* Extract the file paths a Bash command reads or writes, so Read()/Write()/
|
|
208
306
|
* Edit() deny rules also apply to Bash (not just the Read/Write/Edit tools).
|
|
@@ -231,7 +329,9 @@ export function extractBashFileAccess(command: string): BashFileAccess {
|
|
|
231
329
|
// recursing into `$(...)` / backtick substitutions.
|
|
232
330
|
scanReaderWriterCommands(command, read, write)
|
|
233
331
|
|
|
234
|
-
|
|
332
|
+
// 展开放在出口这一处,而不是每个 push 点 —— 重定向与读/写命令、以及它们的
|
|
333
|
+
// 递归内层都汇进这两个数组,一处展开即全覆盖。
|
|
334
|
+
return { read: uniq(read.map(expandKnownPathVars)), write: uniq(write.map(expandKnownPathVars)) }
|
|
235
335
|
}
|
|
236
336
|
|
|
237
337
|
/** Extract the inner commands of `$(...)` and backtick substitutions. */
|
|
@@ -244,6 +344,12 @@ function extractSubstitutions(command: string): string[] {
|
|
|
244
344
|
while ((m = dollarParen.exec(command)) !== null) inners.push(m[1]!)
|
|
245
345
|
const backtick = /`([^`]*)`/g
|
|
246
346
|
while ((m = backtick.exec(command)) !== null) inners.push(m[1]!)
|
|
347
|
+
// 进程替换 `<(cmd)` / `>(cmd)`:`cat <(cat secret)` 里读 secret 的是里层的
|
|
348
|
+
// `cat`,外层只是把它的 stdout 当成一个文件名。参数排除 `<>` 是因为
|
|
349
|
+
// `<(cat secret)` 的捕获若允许 `>`,遇到 `>(...)` 形态会被提前截断;非嵌套组
|
|
350
|
+
// 由调用方的递归处理。
|
|
351
|
+
const procSub = /[<>]\(([^()<>]*)\)/g
|
|
352
|
+
while ((m = procSub.exec(command)) !== null) inners.push(m[1]!)
|
|
247
353
|
return inners
|
|
248
354
|
}
|
|
249
355
|
|
|
@@ -261,6 +367,12 @@ function flattenCommand(command: string, depth = 0): string[] {
|
|
|
261
367
|
out.push(seg)
|
|
262
368
|
const stripped = stripPrefixCommand(seg)
|
|
263
369
|
if (stripped !== seg) out.push(stripped)
|
|
370
|
+
// 剥掉前导噪声后的形态也要参与匹配:`IFS=x rm -rf x` / `( rm -rf x )` /
|
|
371
|
+
// `for …; do rm -rf x; done` 执行的仍是 `rm`,规则必须看得见它。同一个函数
|
|
372
|
+
// 也被 scanReaderWriterCommands 用(Read/Write/Edit 桥接那条路径)—— 两条
|
|
373
|
+
// 路径共用一份归一化,只接一条就是只修一半。
|
|
374
|
+
const denoised = stripLeadingShellNoise(seg)
|
|
375
|
+
if (denoised !== seg) out.push(denoised)
|
|
264
376
|
for (const inner of extractSubstitutions(seg)) {
|
|
265
377
|
out.push(...flattenCommand(inner, depth + 1))
|
|
266
378
|
}
|
|
@@ -303,7 +415,10 @@ function effectiveCommand(tokens: string[]): {
|
|
|
303
415
|
if (name === 'env') {
|
|
304
416
|
while (i < tokens.length && tokens[i]!.includes('=')) i++ // `VAR=value` assignments
|
|
305
417
|
}
|
|
306
|
-
|
|
418
|
+
// `timeout` 的位置参数是 duration,**不是**必然存在:`timeout 5 cmd` 有,
|
|
419
|
+
// `timeout --preserve-status cmd` 没有。无条件 `i++` 会把后者真正的命令
|
|
420
|
+
// (`cat`)当成 duration 吃掉,基命令退化成它的第一个参数。
|
|
421
|
+
if (name === 'timeout' && i < tokens.length && TIMEOUT_DURATION_RE.test(tokens[i]!)) i++
|
|
307
422
|
// `eval`'s remaining arguments are themselves a command line, and must be
|
|
308
423
|
// captured here rather than via the SHELL_COMMANDS branch below: `eval "cat
|
|
309
424
|
// secret"` tokenizes to ['eval', '"cat', 'secret"'], so the base degrades to
|
|
@@ -388,7 +503,10 @@ function scanReaderWriterCommands(
|
|
|
388
503
|
depth = 0,
|
|
389
504
|
): void {
|
|
390
505
|
for (const seg of splitShellSegments(command)) {
|
|
391
|
-
|
|
506
|
+
// 先剥前导噪声再 tokenize:`IFS=x cat secret` / `! cat secret` /
|
|
507
|
+
// `time -p cat secret` 读的是同一个文件,Read() 规则必须看得见 `cat`。
|
|
508
|
+
// 与 flattenCommand 共用 stripLeadingShellNoise —— 两条路径一份归一化。
|
|
509
|
+
const tokens = stripLeadingShellNoise(seg).split(/\s+/).filter(Boolean)
|
|
392
510
|
if (tokens.length > 0) {
|
|
393
511
|
const { base, args, payload } = effectiveCommand(tokens)
|
|
394
512
|
if (READER_COMMANDS.has(base)) {
|
|
@@ -418,6 +536,13 @@ function scanReaderWriterCommands(
|
|
|
418
536
|
}
|
|
419
537
|
}
|
|
420
538
|
|
|
539
|
+
/**
|
|
540
|
+
* `matchBashRule` 真正会按参数匹配工具名的集合。**与 matchBashRule 的分支一一
|
|
541
|
+
* 对应** —— 那里 `return false` 的工具,参数化规则在 validateRulePattern 里必须
|
|
542
|
+
* 被拒(否则规则永远不命中,是静默空防护)。改一边必须改另一边。
|
|
543
|
+
*/
|
|
544
|
+
const PARAMETERISED_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'])
|
|
545
|
+
|
|
421
546
|
// Match a tool(parameter) rule against an actual tool call.
|
|
422
547
|
//
|
|
423
548
|
// Pattern formats:
|
|
@@ -432,6 +557,7 @@ export function matchBashRule(
|
|
|
432
557
|
pattern: string,
|
|
433
558
|
toolName: string,
|
|
434
559
|
toolInput: Record<string, unknown>,
|
|
560
|
+
segmentMode: 'any' | 'all' = 'any',
|
|
435
561
|
): boolean {
|
|
436
562
|
// Check if pattern has a parenthesized sub-pattern
|
|
437
563
|
const parenMatch = pattern.match(/^(\w+)\((.+)\)$/)
|
|
@@ -442,6 +568,20 @@ export function matchBashRule(
|
|
|
442
568
|
|
|
443
569
|
const [, baseTool, subPattern] = parenMatch
|
|
444
570
|
|
|
571
|
+
// How a *compound* command is judged when only some parts match:
|
|
572
|
+
// 'any' (deny / ask) — one matching part is enough. Deliberately wide: a
|
|
573
|
+
// deny rule that misses a part is a hole, so `foo && rm -rf /` must be
|
|
574
|
+
// caught by `Bash(rm *)`.
|
|
575
|
+
// 'all' (allow) — every part must match. An allow rule is a *grant*, and
|
|
576
|
+
// granting on one matching part hands over the whole compound command:
|
|
577
|
+
// `Bash(git:*)` plus `git status && rm -rf ./src` used to return
|
|
578
|
+
// `bypass` with no prompt at all.
|
|
579
|
+
// The `length > 0` guard is load-bearing: `[].every()` is `true`, so a
|
|
580
|
+
// command with no matchable segment (or no extracted file access) would
|
|
581
|
+
// otherwise satisfy **any** allow rule — a fail-open of its own.
|
|
582
|
+
const qualifies = (items: string[], match: (s: string) => boolean): boolean =>
|
|
583
|
+
segmentMode === 'all' ? items.length > 0 && items.every(match) : items.some(match)
|
|
584
|
+
|
|
445
585
|
// A Read/Write/Edit rule must also refuse a Bash command that touches the
|
|
446
586
|
// same file (via a reader/editor command or a redirect), not only the
|
|
447
587
|
// Read/Write/Edit tool itself. Otherwise `cat .git-credentials` bypasses a
|
|
@@ -450,7 +590,7 @@ export function matchBashRule(
|
|
|
450
590
|
const cmd = String(toolInput.command || '')
|
|
451
591
|
const access = extractBashFileAccess(cmd)
|
|
452
592
|
const paths = baseTool === 'Read' ? access.read : access.write
|
|
453
|
-
return paths
|
|
593
|
+
return qualifies(paths, (p) => matchPath(p, subPattern!))
|
|
454
594
|
}
|
|
455
595
|
|
|
456
596
|
if (toolName !== baseTool!) return false
|
|
@@ -460,7 +600,7 @@ export function matchBashRule(
|
|
|
460
600
|
// `$(...)`/backtick substitution, so `Bash(rm *)` catches `x=$(rm -rf ~)`).
|
|
461
601
|
if (baseTool === 'Bash') {
|
|
462
602
|
const cmd = String(toolInput.command || '')
|
|
463
|
-
return flattenCommand(cmd)
|
|
603
|
+
return qualifies(flattenCommand(cmd), (seg) => wildcardMatch(subPattern!, seg))
|
|
464
604
|
}
|
|
465
605
|
|
|
466
606
|
// For Write/Edit/Read: match against the file_path with path-glob semantics.
|
|
@@ -500,6 +640,10 @@ export function wildcardMatch(pattern: string, input: string): boolean {
|
|
|
500
640
|
* `Bash()`) is silently dead today — this returns a human-readable reason so
|
|
501
641
|
* the caller can report it as an invalid setting instead of ignoring it.
|
|
502
642
|
*
|
|
643
|
+
* 校验的是**结构 + 工具名**(后者见 PARAMETERISED_TOOLS)。**不**校验子模式本身
|
|
644
|
+
* 能否匹配任何东西 —— `Bash(zzz *)` 合法、装得上、只是不会命中,那是用户自己的
|
|
645
|
+
* 选择,不是配置错误。
|
|
646
|
+
*
|
|
503
647
|
* Returns null when the pattern is valid, or a reason string when malformed.
|
|
504
648
|
*/
|
|
505
649
|
export function validateRulePattern(pattern: string): string | null {
|
|
@@ -510,9 +654,16 @@ export function validateRulePattern(pattern: string): string | null {
|
|
|
510
654
|
// Empty parameter: `Bash()` or `Bash( )`
|
|
511
655
|
if (/\(\s*\)$/.test(pattern)) return 'empty parameter'
|
|
512
656
|
// Must be exactly `ToolName(param)` with nothing before or after.
|
|
513
|
-
|
|
657
|
+
const shape = pattern.match(/^(\w+)\((.+)\)$/)
|
|
658
|
+
if (!shape) {
|
|
514
659
|
return 'unexpected text after the closing parenthesis'
|
|
515
660
|
}
|
|
661
|
+
// 工具名必须落在 matchBashRule 真正会按参数匹配的那一集合里,否则这条规则是
|
|
662
|
+
// **静默空防护**:语法合法、装得上、永远不命中,而用户以为配了保护。
|
|
663
|
+
const tool = shape[1]!
|
|
664
|
+
if (!PARAMETERISED_TOOLS.has(tool)) {
|
|
665
|
+
return `parameterised rules are not supported for tool "${tool}" (it would never match)`
|
|
666
|
+
}
|
|
516
667
|
return null
|
|
517
668
|
}
|
|
518
669
|
|