@miphamai/cli 0.81.8 → 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/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/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +7 -1
- package/src/config/loader.ts +202 -63
- package/src/core/credential-masker/output-scrub.ts +16 -2
- 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 +17 -2
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +5 -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/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +44 -17
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +13 -2
- package/src/providers/anthropic.ts +48 -11
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +19 -1
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/exec/bash.ts +106 -6
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +76 -1
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +33 -3
- package/src/tools/index.ts +12 -4
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +160 -30
- package/src/workflow/primitives/agent.ts +4 -0
|
@@ -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.
|
|
@@ -557,6 +557,7 @@ export function matchBashRule(
|
|
|
557
557
|
pattern: string,
|
|
558
558
|
toolName: string,
|
|
559
559
|
toolInput: Record<string, unknown>,
|
|
560
|
+
segmentMode: 'any' | 'all' = 'any',
|
|
560
561
|
): boolean {
|
|
561
562
|
// Check if pattern has a parenthesized sub-pattern
|
|
562
563
|
const parenMatch = pattern.match(/^(\w+)\((.+)\)$/)
|
|
@@ -567,6 +568,20 @@ export function matchBashRule(
|
|
|
567
568
|
|
|
568
569
|
const [, baseTool, subPattern] = parenMatch
|
|
569
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
|
+
|
|
570
585
|
// A Read/Write/Edit rule must also refuse a Bash command that touches the
|
|
571
586
|
// same file (via a reader/editor command or a redirect), not only the
|
|
572
587
|
// Read/Write/Edit tool itself. Otherwise `cat .git-credentials` bypasses a
|
|
@@ -575,7 +590,7 @@ export function matchBashRule(
|
|
|
575
590
|
const cmd = String(toolInput.command || '')
|
|
576
591
|
const access = extractBashFileAccess(cmd)
|
|
577
592
|
const paths = baseTool === 'Read' ? access.read : access.write
|
|
578
|
-
return paths
|
|
593
|
+
return qualifies(paths, (p) => matchPath(p, subPattern!))
|
|
579
594
|
}
|
|
580
595
|
|
|
581
596
|
if (toolName !== baseTool!) return false
|
|
@@ -585,7 +600,7 @@ export function matchBashRule(
|
|
|
585
600
|
// `$(...)`/backtick substitution, so `Bash(rm *)` catches `x=$(rm -rf ~)`).
|
|
586
601
|
if (baseTool === 'Bash') {
|
|
587
602
|
const cmd = String(toolInput.command || '')
|
|
588
|
-
return flattenCommand(cmd)
|
|
603
|
+
return qualifies(flattenCommand(cmd), (seg) => wildcardMatch(subPattern!, seg))
|
|
589
604
|
}
|
|
590
605
|
|
|
591
606
|
// For Write/Edit/Read: match against the file_path with path-glob semantics.
|
package/src/core/permission.ts
CHANGED
|
@@ -7,7 +7,13 @@ import type {
|
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
9
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
loadPermissionConfig,
|
|
12
|
+
nextMode,
|
|
13
|
+
clampMode,
|
|
14
|
+
normalizeRestrictions,
|
|
15
|
+
MODE_CYCLE,
|
|
16
|
+
} from './permission-config'
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Check if a Bash command is a "verification-only" command that should be
|
|
@@ -78,6 +84,8 @@ export class PermissionSystem {
|
|
|
78
84
|
private allowRules: PermissionRuleEntry[] = []
|
|
79
85
|
private denyRules: PermissionRuleEntry[] = []
|
|
80
86
|
private askRules: PermissionRuleEntry[] = []
|
|
87
|
+
/** Malformed `permissionRestrictions` entries from the last set/load — see below. */
|
|
88
|
+
private restrictionWarnings: string[] = []
|
|
81
89
|
/** Legacy exact-name rules for backward compat (set via setRule with 'auto' level). */
|
|
82
90
|
private legacyRules = new Map<string, PermissionLevel>()
|
|
83
91
|
/** Legacy default level from constructor when passed non-mode values like 'ask' or 'bypass'. */
|
|
@@ -130,9 +138,17 @@ export class PermissionSystem {
|
|
|
130
138
|
|
|
131
139
|
// ── Restrictions (P0: org-level policy gap) ──
|
|
132
140
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Apply org-level permission restrictions. Overwrites any previous restrictions.
|
|
143
|
+
*
|
|
144
|
+
* The value is **validated and normalized** first: a config typo used to leave the
|
|
145
|
+
* whole policy silently inert (fail-open). Anything unrecognizable is now reported
|
|
146
|
+
* via `getInvalidRestrictions()` and pins the mode to the strictest (`P1`).
|
|
147
|
+
*/
|
|
148
|
+
setRestrictions(raw: PermissionRestrictions | undefined): void {
|
|
149
|
+
const { restrictions, invalid } = normalizeRestrictions(raw)
|
|
135
150
|
this.restrictions = restrictions
|
|
151
|
+
this.restrictionWarnings = invalid
|
|
136
152
|
// Re-clamp current mode against new restrictions
|
|
137
153
|
if (restrictions) {
|
|
138
154
|
this.mode = clampMode(this.mode, restrictions)
|
|
@@ -140,6 +156,15 @@ export class PermissionSystem {
|
|
|
140
156
|
this.invalidateCache()
|
|
141
157
|
}
|
|
142
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Malformed `permissionRestrictions` entries, one message each — the sibling of
|
|
161
|
+
* `getInvalidRules()`. Callers are expected to surface these to stderr; a silent
|
|
162
|
+
* return here means a policy the operator believes is enforced isn't.
|
|
163
|
+
*/
|
|
164
|
+
getInvalidRestrictions(): string[] {
|
|
165
|
+
return this.restrictionWarnings
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
getRestrictions(): PermissionRestrictions | undefined {
|
|
144
169
|
return this.restrictions
|
|
145
170
|
}
|
|
@@ -242,7 +267,13 @@ export class PermissionSystem {
|
|
|
242
267
|
restrictions: PermissionRestrictions
|
|
243
268
|
}>,
|
|
244
269
|
)
|
|
245
|
-
|
|
270
|
+
// Same validation as setRestrictions — a config typo must not silently drop the cap
|
|
271
|
+
if (config.restrictions) {
|
|
272
|
+
const { restrictions, invalid } = normalizeRestrictions(config.restrictions)
|
|
273
|
+
this.restrictions = restrictions
|
|
274
|
+
this.restrictionWarnings = invalid
|
|
275
|
+
}
|
|
276
|
+
this.mode = clampMode(config.mode, this.restrictions)
|
|
246
277
|
|
|
247
278
|
this.allowRules = []
|
|
248
279
|
this.denyRules = []
|
|
@@ -255,10 +286,6 @@ export class PermissionSystem {
|
|
|
255
286
|
this.denyRules.push(compileRule(rule, 'deny'))
|
|
256
287
|
}
|
|
257
288
|
|
|
258
|
-
if (config.restrictions) {
|
|
259
|
-
this.restrictions = config.restrictions
|
|
260
|
-
}
|
|
261
|
-
|
|
262
289
|
this.invalidateCache()
|
|
263
290
|
}
|
|
264
291
|
|
|
@@ -310,10 +337,10 @@ export class PermissionSystem {
|
|
|
310
337
|
}
|
|
311
338
|
}
|
|
312
339
|
|
|
313
|
-
// 3. Check allow rules
|
|
340
|
+
// 3. Check allow rules — but an org ceiling still applies (see allowRuleDecision)
|
|
314
341
|
for (const rule of this.allowRules) {
|
|
315
342
|
if (this.ruleMatches(rule, tool, input)) {
|
|
316
|
-
const result
|
|
343
|
+
const result = this.allowRuleDecision(tool, input)
|
|
317
344
|
this.checkCache.set(cacheKey, result)
|
|
318
345
|
return result
|
|
319
346
|
}
|
|
@@ -399,19 +426,60 @@ export class PermissionSystem {
|
|
|
399
426
|
tool: ToolDefinition,
|
|
400
427
|
input: Record<string, unknown>,
|
|
401
428
|
): boolean {
|
|
402
|
-
// Try Bash-style matching first
|
|
429
|
+
// Try Bash-style matching first.
|
|
430
|
+
//
|
|
431
|
+
// The compound-command rule differs by direction, and the rule's own
|
|
432
|
+
// `level` is what decides it: a **deny/ask** rule matches if *any* part of
|
|
433
|
+
// a compound command matches (wide on purpose), while an **allow** rule
|
|
434
|
+
// matches only if *every* part does. Without that, `Bash(git:*)` grants
|
|
435
|
+
// `git status && rm -rf ./src` outright — see matchBashRule's `segmentMode`.
|
|
403
436
|
if (rule.pattern.includes('(')) {
|
|
404
|
-
return matchBashRule(rule.pattern, tool.name, input)
|
|
437
|
+
return matchBashRule(rule.pattern, tool.name, input, rule.level === 'allow' ? 'all' : 'any')
|
|
405
438
|
}
|
|
406
439
|
// Simple tool name match
|
|
407
440
|
return rule.pattern === tool.name || rule.compiled.test(tool.name)
|
|
408
441
|
}
|
|
409
442
|
|
|
443
|
+
/**
|
|
444
|
+
* An allow rule matched — what does it actually grant?
|
|
445
|
+
*
|
|
446
|
+
* Without an org ceiling it grants `'bypass'` (unchanged behavior). With
|
|
447
|
+
* `maxAllowedMode` set, the rule may only grant what **the ceiling's own
|
|
448
|
+
* baseline** would grant: the ceiling is an upper bound on permissiveness, and
|
|
449
|
+
* a rule is a *source of permission* — letting it jump over the ceiling is the
|
|
450
|
+
* same defect one layer in. So `allow: ['Bash']` under a ceiling of
|
|
451
|
+
* `acceptEdits` still permits verification-only Bash, while `git push` falls
|
|
452
|
+
* back to approval.
|
|
453
|
+
*
|
|
454
|
+
* Decided here, at check time, rather than inside `allow()`: `setRestrictions`
|
|
455
|
+
* may land **after** the rules are registered (`loadConfig`, sub-agents handed
|
|
456
|
+
* the same restrictions), and a rule registered before the ceiling would
|
|
457
|
+
* otherwise keep its old meaning.
|
|
458
|
+
*
|
|
459
|
+
* Only `maxAllowedMode` gates this. `forbiddenModes` is about *which mode you
|
|
460
|
+
* may sit in*, not about what a rule may grant, so a `forbiddenModes`-only
|
|
461
|
+
* config behaves exactly as before.
|
|
462
|
+
*
|
|
463
|
+
* The `'mode-baseline'` sentinel (mode `default`) resolves to `tool.permission`
|
|
464
|
+
* and stops there — deliberately not continuing to the legacy fallback of
|
|
465
|
+
* `check()` step 7. That fallback can only ever be *wider* than `'ask'`, and a
|
|
466
|
+
* ceiling must not hand out more than the un-restricted chain would.
|
|
467
|
+
*/
|
|
468
|
+
private allowRuleDecision(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
|
|
469
|
+
const cap = this.restrictions?.maxAllowedMode
|
|
470
|
+
if (!cap) return 'bypass'
|
|
471
|
+
|
|
472
|
+
const baseline = this.modeBaseline(tool, input, cap)
|
|
473
|
+
const level = baseline === 'mode-baseline' ? (tool.permission ?? 'ask') : baseline
|
|
474
|
+
return level === 'ask' ? 'ask' : 'bypass'
|
|
475
|
+
}
|
|
476
|
+
|
|
410
477
|
private modeBaseline(
|
|
411
478
|
tool: ToolDefinition,
|
|
412
479
|
input?: Record<string, unknown>,
|
|
480
|
+
mode: PermissionMode = this.mode,
|
|
413
481
|
): PermissionLevel | 'mode-baseline' {
|
|
414
|
-
switch (
|
|
482
|
+
switch (mode) {
|
|
415
483
|
case 'default':
|
|
416
484
|
// Delegate to tool.permission (backward compat)
|
|
417
485
|
return 'mode-baseline'
|
package/src/core/rules-loader.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
21
21
|
import { join } from 'node:path'
|
|
22
22
|
import { globToRegexSource } from './credential-masker/matcher'
|
|
23
|
+
import { findWorktreeMarker } from './paths.ts'
|
|
23
24
|
|
|
24
25
|
interface RuleFile {
|
|
25
26
|
name: string
|
|
@@ -31,9 +32,27 @@ interface RuleFile {
|
|
|
31
32
|
export class RulesLoader {
|
|
32
33
|
private rules: RuleFile[] = []
|
|
33
34
|
private rulesDir: string
|
|
35
|
+
/**
|
|
36
|
+
* Rules directory of the **project the cwd belongs to**, when cwd sits inside
|
|
37
|
+
* one of our worktrees; `null` otherwise.
|
|
38
|
+
*
|
|
39
|
+
* `.mipham/` is gitignored, so a worktree checkout never contains
|
|
40
|
+
* `.mipham/rules` — `git worktree add .mipham/worktrees/w1` produces a tree
|
|
41
|
+
* with no `.mipham/` at all (measured). Reading only `cwd` therefore made
|
|
42
|
+
* every project rule invisible inside a worktree session, **silently**: zero
|
|
43
|
+
* rules, no warning, empty context block.
|
|
44
|
+
*
|
|
45
|
+
* Detection is marker-based (`findWorktreeMarker`, the same one the git/bash
|
|
46
|
+
* tools use) — a worktree created outside `.mipham/worktrees/` and
|
|
47
|
+
* `.claude/worktrees/` is not covered.
|
|
48
|
+
*/
|
|
49
|
+
private projectRulesDir: string | null
|
|
34
50
|
|
|
35
51
|
constructor(cwd: string) {
|
|
36
52
|
this.rulesDir = join(cwd, '.mipham', 'rules')
|
|
53
|
+
const marker = findWorktreeMarker(cwd)
|
|
54
|
+
const projectRulesDir = marker ? join(marker.root, '.mipham', 'rules') : null
|
|
55
|
+
this.projectRulesDir = projectRulesDir === this.rulesDir ? null : projectRulesDir
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
/**
|
|
@@ -41,16 +60,27 @@ export class RulesLoader {
|
|
|
41
60
|
*/
|
|
42
61
|
load(): void {
|
|
43
62
|
this.rules = []
|
|
44
|
-
|
|
63
|
+
this.readDir(this.rulesDir)
|
|
64
|
+
if (this.projectRulesDir) this.readDir(this.projectRulesDir)
|
|
65
|
+
}
|
|
45
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Read every `.md` in `dir` into `this.rules`. A name already loaded wins —
|
|
69
|
+
* "nearest first": a rule the worktree defines overrides the project copy.
|
|
70
|
+
*/
|
|
71
|
+
private readDir(dir: string): void {
|
|
72
|
+
if (!existsSync(dir)) return
|
|
46
73
|
try {
|
|
47
|
-
const
|
|
74
|
+
const seen = new Set(this.rules.map((r) => r.name))
|
|
75
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md'))
|
|
48
76
|
for (const file of files) {
|
|
49
|
-
const
|
|
77
|
+
const name = file.replace(/\.md$/, '')
|
|
78
|
+
if (seen.has(name)) continue
|
|
50
79
|
try {
|
|
51
|
-
const raw = readFileSync(
|
|
80
|
+
const raw = readFileSync(join(dir, file), 'utf-8')
|
|
52
81
|
const { paths, description, content } = this.parseRule(raw, file)
|
|
53
|
-
this.rules.push({ name
|
|
82
|
+
this.rules.push({ name, paths, description, content })
|
|
83
|
+
seen.add(name)
|
|
54
84
|
} catch {
|
|
55
85
|
// Skip unparseable files
|
|
56
86
|
}
|
package/src/core/session-log.ts
CHANGED
|
@@ -78,7 +78,11 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
|
|
|
78
78
|
// 兼容旧 JSONL(存 content:string);新格式存 result:ToolResult(含 success/error)
|
|
79
79
|
const eo = e as unknown as { id: string; result?: ToolResult; content?: string }
|
|
80
80
|
const result: ToolResult = eo.result ?? { success: true, content: eo.content ?? '' }
|
|
81
|
-
|
|
81
|
+
// `?? ''`:成功但没记下 content 的事件(`JSON.stringify` 会把 `undefined` 的键整个
|
|
82
|
+
// 抹掉,所以它在盘上长这样:`{"success":true}`)投影出来必须是**字符串**。
|
|
83
|
+
// 投影负责形状、provider 负责出网合法(`tool_result.content` 是 `string`);
|
|
84
|
+
// 这里留 `undefined` 会让 `JSON.stringify` 同样抹掉该键,把问题送到线上。
|
|
85
|
+
const content = (result.success ? result.content : result.error || result.content) ?? ''
|
|
82
86
|
out.push({
|
|
83
87
|
role: 'user',
|
|
84
88
|
content: [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, resolve } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
4
5
|
|
|
5
6
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
6
7
|
const TRUST_STORE_PATH = join(MIPHAM_HOME, 'trusted-workspaces.json')
|
|
@@ -67,8 +68,16 @@ export class WorkspaceTrust {
|
|
|
67
68
|
}
|
|
68
69
|
const raw = readFileSync(TRUST_STORE_PATH, 'utf-8')
|
|
69
70
|
const parsed = JSON.parse(raw) as TrustedWorkspaces
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
// The version check alone guarded a *field* while leaving the shape open:
|
|
72
|
+
// `{ version: 1 }` passes it and then `for (const trusted of
|
|
73
|
+
// this.store.directories)` throws on `undefined`. Entries must be strings
|
|
74
|
+
// too — `isTrusted` calls `.toLowerCase()` on each one.
|
|
75
|
+
if (
|
|
76
|
+
parsed.version !== 1 ||
|
|
77
|
+
!Array.isArray(parsed.directories) ||
|
|
78
|
+
!parsed.directories.every((d) => typeof d === 'string')
|
|
79
|
+
) {
|
|
80
|
+
// Unknown version or malformed shape — reset
|
|
72
81
|
return { version: 1, directories: [], updatedAt: new Date().toISOString() }
|
|
73
82
|
}
|
|
74
83
|
return parsed
|
|
@@ -84,7 +93,14 @@ export class WorkspaceTrust {
|
|
|
84
93
|
mkdirSync(MIPHAM_HOME, { recursive: true })
|
|
85
94
|
}
|
|
86
95
|
this.store.updatedAt = new Date().toISOString()
|
|
87
|
-
|
|
96
|
+
// Atomic: this store's own reader swallows a parse failure into a *reset
|
|
97
|
+
// store* (see `load`'s catch), so a write interrupted midway does not just
|
|
98
|
+
// lose the record — it silently un-trusts every directory the user had
|
|
99
|
+
// approved. Fail-closed, but the user never learns why they are being
|
|
100
|
+
// asked again.
|
|
101
|
+
atomicWriteFileSync(TRUST_STORE_PATH, JSON.stringify(this.store, null, 2) + '\n', {
|
|
102
|
+
mode: 0o600,
|
|
103
|
+
})
|
|
88
104
|
} catch {
|
|
89
105
|
// Best-effort: don't crash if we can't save
|
|
90
106
|
}
|
|
@@ -165,3 +181,25 @@ export function getWorkspaceTrust(): WorkspaceTrust {
|
|
|
165
181
|
export function resetWorkspaceTrust(): void {
|
|
166
182
|
_instance = null
|
|
167
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Say out loud that repository-controlled config was skipped because this
|
|
187
|
+
* workspace is not trusted.
|
|
188
|
+
*
|
|
189
|
+
* Call this only when hooks were *actually* withheld — `SettingsJson` reports
|
|
190
|
+
* that (`projectHooksSkipped`), so this cannot announce a skip that never
|
|
191
|
+
* happened.
|
|
192
|
+
*
|
|
193
|
+
* Deliberately not silent. When there is no TTY the trust prompt cannot be
|
|
194
|
+
* asked, so the answer is "no" and the hooks stay out — but a gate that fails
|
|
195
|
+
* without saying so is indistinguishable from one that passed, and the user is
|
|
196
|
+
* left wondering why their hooks do nothing. Written to stderr so it cannot
|
|
197
|
+
* corrupt stdout rendering.
|
|
198
|
+
*/
|
|
199
|
+
export function warnProjectHooksSkipped(cwd: string): void {
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`⚠️ Workspace not trusted: skipped hooks from ${join(cwd, '.mipham', 'settings.json')}\n` +
|
|
202
|
+
` (hooks run commands — repository-controlled). Trust this directory in an interactive\n` +
|
|
203
|
+
` session to enable them.\n`,
|
|
204
|
+
)
|
|
205
|
+
}
|
package/src/daemon/auth.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
// apps/cli/src/daemon/auth.ts
|
|
2
|
-
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
4
4
|
import { dirname } from 'node:path'
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* bun-types@1.3.14 lacks constantTimeCompare in type definitions,
|
|
8
|
-
* though the method exists at Bun 1.2+ runtime.
|
|
9
|
-
*/
|
|
10
|
-
interface PasswordWithCompare {
|
|
11
|
-
constantTimeCompare(a: Buffer, b: Buffer): boolean
|
|
12
|
-
}
|
|
13
|
-
|
|
14
6
|
/**
|
|
15
7
|
* Generate a 64-character hex token using cryptographically secure random bytes.
|
|
16
8
|
*/
|
|
@@ -35,14 +27,23 @@ export function loadOrCreateToken(tokenPath: string): string {
|
|
|
35
27
|
|
|
36
28
|
/**
|
|
37
29
|
* Verify a provided token against the expected token.
|
|
38
|
-
* Uses
|
|
30
|
+
* Uses a constant-time comparison to prevent timing attacks.
|
|
31
|
+
*
|
|
32
|
+
* `Bun.password` has no `constantTimeCompare` — on bun 1.3.14 it is
|
|
33
|
+
* `['hash','hashSync','verify','verifySync']`. Calling it threw a TypeError, so
|
|
34
|
+
* every authenticated remote request got a 500 instead of the intended 200/403.
|
|
35
|
+
* Node's `timingSafeEqual` is the same primitive and works under Bun.
|
|
36
|
+
*
|
|
37
|
+
* Length is checked first because `timingSafeEqual` throws RangeError when the
|
|
38
|
+
* buffers differ in length; unequal length is itself a mismatch, so returning
|
|
39
|
+
* false early leaks nothing that comparing would not.
|
|
39
40
|
*/
|
|
40
41
|
export function verifyToken(expected: string, provided: string): boolean {
|
|
41
42
|
if (!provided || !expected) return false
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
43
|
+
const a = Buffer.from(expected)
|
|
44
|
+
const b = Buffer.from(provided)
|
|
45
|
+
if (a.length !== b.length) return false
|
|
46
|
+
return timingSafeEqual(a, b)
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -31,6 +31,7 @@ import { RulesLoader } from '../core/rules-loader'
|
|
|
31
31
|
import { HookEngine } from '../core/hooks'
|
|
32
32
|
import { loadHookConfigs } from '../core/hooks-config'
|
|
33
33
|
import { loadSettingsJson } from '../config/loader'
|
|
34
|
+
import { getWorkspaceTrust, warnProjectHooksSkipped } from '../core/workspace-trust'
|
|
34
35
|
import { AgentRegistry } from '../agent/agent-registry'
|
|
35
36
|
|
|
36
37
|
export interface DaemonEngineCapabilities {
|
|
@@ -87,11 +88,20 @@ function skillsFor(cwd: string, paths?: string[]): SkillsLoader {
|
|
|
87
88
|
function hooksFor(cwd: string, skills: SkillsLoader): HookEngine {
|
|
88
89
|
const cached = hookCache.get(cwd)
|
|
89
90
|
if (cached) return cached
|
|
90
|
-
|
|
91
|
+
// 会话 cwd 必须显式传:daemon 一个进程服务多个会话,`process.cwd()` 是它自己被
|
|
92
|
+
// 启动时所在的目录,不属于任何会话 —— hooks 会在那里跑,并且**被告知**它在那里。
|
|
93
|
+
const engine = new HookEngine(cwd)
|
|
91
94
|
for (const skill of skills.list()) {
|
|
92
95
|
for (const hook of skill.hooks ?? []) engine.register(hook)
|
|
93
96
|
}
|
|
94
|
-
|
|
97
|
+
// 项目级 hooks 是仓库可控的代码执行面,且 daemon 这条路径**从不**经过交互式信任
|
|
98
|
+
// 询问 —— 而 `isCwdAllowed` 只要求会话 cwd 在 daemon 根目录之内(`workspace-guard.ts:27`
|
|
99
|
+
// `isWithin(cwd, daemonRoot) || isTrusted(cwd)`)⇒ 在未信任的克隆里启动的 daemon,
|
|
100
|
+
// 以前会照着那个仓库的 settings.json 起子进程。未信任即不加载,并说出口。
|
|
101
|
+
const projectHooksTrusted = getWorkspaceTrust().isTrusted(cwd)
|
|
102
|
+
const settings = loadSettingsJson(cwd, { includeProjectHooks: projectHooksTrusted })
|
|
103
|
+
if (settings.projectHooksSkipped) warnProjectHooksSkipped(cwd)
|
|
104
|
+
for (const def of loadHookConfigs(settings.hooks)) engine.register(def)
|
|
95
105
|
hookCache.set(cwd, engine)
|
|
96
106
|
return engine
|
|
97
107
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// engine.close()
|
|
19
19
|
|
|
20
20
|
import type { ClientPromptMessage, ClientInterruptMessage, ServerMessage } from './attach-protocol'
|
|
21
|
-
import type { StreamChunk } from '../shared/types'
|
|
21
|
+
import type { PermissionMode, StreamChunk } from '../shared/types'
|
|
22
22
|
|
|
23
23
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
24
24
|
|
|
@@ -258,11 +258,16 @@ export class RemoteEngine {
|
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
/** Returns a stub permission object so slash commands don't crash. */
|
|
261
|
-
getPermission(): { setMode(_mode:
|
|
261
|
+
getPermission(): { setMode(_mode: PermissionMode): void; getMode(): PermissionMode } {
|
|
262
|
+
// Remote mode: permissions are managed by the daemon, and the attach protocol has
|
|
263
|
+
// no read-back — so `getMode` reports the last mode the user asked for (which is
|
|
264
|
+
// what the footer has always shown here), not a value the daemon never confirmed.
|
|
265
|
+
let requested: PermissionMode = 'default'
|
|
262
266
|
return {
|
|
263
|
-
setMode: (
|
|
264
|
-
|
|
267
|
+
setMode: (mode: PermissionMode) => {
|
|
268
|
+
requested = mode
|
|
265
269
|
},
|
|
270
|
+
getMode: () => requested,
|
|
266
271
|
}
|
|
267
272
|
}
|
|
268
273
|
|