@miphamai/cli 0.83.0 → 0.85.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/bin/mipham.ts +20 -5
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +30 -8
- package/src/agent/agent-context.ts +5 -5
- package/src/agent/agent-experience.ts +2 -2
- package/src/agent/agent-registry.ts +4 -4
- package/src/agent/cross-session/discovery.ts +3 -2
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +2 -2
- package/src/agent/pattern-analyzer.ts +3 -4
- package/src/agent/sub-agent.ts +12 -2
- package/src/agent/types.ts +4 -1
- package/src/commands/autoloop-journal.ts +2 -2
- package/src/commands/environment.ts +2 -1
- package/src/commands/loop-scaffold.ts +2 -1
- package/src/commands/project.ts +86 -33
- package/src/config/keys-manager.ts +2 -2
- package/src/config/loader.ts +11 -10
- package/src/config/preferences.ts +3 -4
- package/src/core/auto-memory.ts +2 -3
- package/src/core/constitution-loader.ts +3 -4
- package/src/core/crsi-producer.ts +3 -3
- package/src/core/crsi-sandbox.ts +3 -2
- package/src/core/dream-engine.ts +2 -2
- package/src/core/engine.ts +38 -6
- package/src/core/error-signature-db.ts +2 -2
- package/src/core/eval-harness.ts +4 -3
- package/src/core/improvement-track.ts +5 -6
- package/src/core/instructions.ts +28 -4
- package/src/core/memory/memory-loader.ts +2 -3
- package/src/core/paths.ts +21 -1
- package/src/core/permission-audit.ts +120 -0
- package/src/core/permission-classifier.ts +449 -0
- package/src/core/permission-config.ts +106 -15
- package/src/core/permission.ts +369 -16
- package/src/core/rule-engine.ts +2 -2
- package/src/core/rules-loader.ts +3 -2
- package/src/core/session-log.ts +2 -3
- package/src/core/session-store.ts +2 -3
- package/src/core/workspace-trust.ts +4 -3
- package/src/daemon/database.ts +2 -2
- package/src/daemon/index.ts +2 -3
- package/src/daemon/launch.ts +3 -3
- package/src/daemon/server.ts +15 -0
- package/src/i18n-core/locales/en-US.json +7 -1
- package/src/i18n-core/locales/zh-CN.json +7 -1
- package/src/index.tsx +30 -6
- package/src/mcp/token-store.ts +2 -2
- package/src/plugin/plugin-manager.ts +2 -2
- package/src/shared/constants.ts +0 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +46 -6
- package/src/shared/update.ts +255 -20
- package/src/skills/bundled-skills.ts +1 -1
- package/src/skills/loader.ts +2 -3
- package/src/skills/marketplace.ts +2 -3
- package/src/skills/registry.ts +2 -2
- package/src/skills/skill-assets.ts +2 -2
- package/src/skills/usage.ts +2 -2
- package/src/telemetry/consent.ts +2 -3
- package/src/tools/agent/enter-plan.ts +3 -2
- package/src/tools/agent/exit-plan.ts +1 -1
- package/src/tools/agent/list-agents.ts +1 -1
- package/src/tools/agent/memory.ts +3 -3
- package/src/tools/agent/plan.ts +3 -2
- package/src/tools/agent/report-findings.ts +1 -1
- package/src/tools/agent/send-message.ts +1 -1
- package/src/tools/agent/skill.ts +1 -1
- package/src/tools/exec/git.ts +2 -2
- package/src/tools/exec/task.ts +1 -1
- package/src/tools/file/glob.ts +1 -1
- package/src/tools/file/grep.ts +1 -1
- package/src/tools/file/read.ts +1 -1
- package/src/tools/network/web-fetch.ts +1 -1
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/scheduling/cron.ts +5 -5
- package/src/tools/scheduling/schedule-wakeup.ts +1 -1
- package/src/tools/system/config.ts +2 -2
- package/src/tools/system/tool-search.ts +1 -1
- package/src/ui/app.tsx +51 -9
- package/src/ui/commands.ts +26 -21
- package/src/ui/config-wizard.tsx +2 -2
- package/src/ui/input.tsx +15 -3
- package/src/workflow/journal.ts +2 -2
|
@@ -29,11 +29,11 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
|
|
|
29
29
|
*
|
|
30
30
|
* - `plan` is strictly the narrowest. It passes only Read/Grep/Glob and sends
|
|
31
31
|
* *everything* else to approval, while `default` passes every tool that
|
|
32
|
-
* declares `permission: '
|
|
32
|
+
* declares `permission: 'self'` — git, task, web-fetch, cron, memory, … So a
|
|
33
33
|
* cap of `'plan'` must not admit `default`, and `plan` belongs at the bottom.
|
|
34
34
|
* - `acceptEdits` and `default` are **incomparable**: acceptEdits auto-approves
|
|
35
35
|
* Write/Edit and verification-only Bash that `default` asks about, while
|
|
36
|
-
* `default` auto-approves the non-file `'
|
|
36
|
+
* `default` auto-approves the non-file `'self'` tools that acceptEdits asks
|
|
37
37
|
* about. No total order is faithful there, so the ranking only needs to carry
|
|
38
38
|
* the relations the two consumers rely on.
|
|
39
39
|
*
|
|
@@ -42,28 +42,97 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
|
|
|
42
42
|
* 'plan'` admitted acceptEdits *and* default — the ceiling let through the wider
|
|
43
43
|
* mode each time. The pairs are pinned by a probe in `test/core/permission.test.ts`
|
|
44
44
|
* (P4) so the claim stays measured rather than asserted.
|
|
45
|
+
*
|
|
46
|
+
* `auto` sits between `acceptEdits` and `bypassPermissions` — the same rung Claude
|
|
47
|
+
* Code puts it on. It has to sit above `acceptEdits`, because at runtime the
|
|
48
|
+
* classifier may allow calls `acceptEdits` refuses (network, non-verification
|
|
49
|
+
* Bash), so a ceiling of `acceptEdits` must not admit it. Its own static baseline
|
|
50
|
+
* grants nothing at all, which is why the P4 width probe **excludes** it by name:
|
|
51
|
+
* measuring "who is narrower" on the static chain would otherwise call `auto` the
|
|
52
|
+
* narrowest mode of all and point the hierarchy's first slot at it.
|
|
53
|
+
*
|
|
54
|
+
* **Every member of `PermissionMode` must appear here.** A missing member makes
|
|
55
|
+
* `indexOf` return `-1`, and `getAllowedModes` then skips the whole ceiling
|
|
56
|
+
* (`if (capIdx >= 0)`) — the org-level cap goes silently inert, fail-open, with no
|
|
57
|
+
* warning anywhere. There is a compile-time-exhaustive coverage assertion for this
|
|
58
|
+
* in `test/core/permission.test.ts` (P4c); the ordering probe (P4) catches a wrong
|
|
59
|
+
* *order* but never a *missing* entry.
|
|
60
|
+
*
|
|
61
|
+
* **Inserting `auto` moved a fallback destination, on purpose and without a
|
|
62
|
+
* failure.** `clampMode` answers "the highest allowed mode at or below `desired`",
|
|
63
|
+
* so every mode gains a neighbour below it. A config that forbids
|
|
64
|
+
* `bypassPermissions` and then requests it now lands on `auto` — previously
|
|
65
|
+
* `acceptEdits`. Both readings satisfy the contract and `auto` is a strict subset
|
|
66
|
+
* of `bypassPermissions` at runtime (it gates each call), so the move narrows
|
|
67
|
+
* rather than escalates; but it *is* a change in what those configs do, and it is
|
|
68
|
+
* pinned in `test/core/permission.test.ts` and `test/daemon/permission.test.ts`
|
|
69
|
+
* rather than left to be discovered. **Until the classifier is wired, `auto`'s
|
|
70
|
+
* static baseline is `ask` throughout, so that landing means "every call
|
|
71
|
+
* refused"** — fail-closed, and honest, but not a behaviour to install by accident.
|
|
45
72
|
*/
|
|
46
73
|
export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
|
|
47
74
|
'plan',
|
|
48
75
|
'default',
|
|
49
76
|
'acceptEdits',
|
|
77
|
+
'auto',
|
|
50
78
|
'bypassPermissions',
|
|
51
79
|
]
|
|
52
80
|
|
|
53
81
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
82
|
+
* Every **legal** mode — the full internal enumeration, and the base set that
|
|
83
|
+
* `forbiddenModes` / `maxAllowedMode` are applied to.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately a separate array from `MODE_CYCLE`, and deliberately able to be a
|
|
86
|
+
* **superset** of it: `bypassPermissions` is reachable through config /
|
|
87
|
+
* `MIPHAM_DAEMON_PERMISSION` / settings without being something a user can
|
|
88
|
+
* Shift+Tab into. Claude Code arranges it the same way — its descriptor table
|
|
89
|
+
* lists `bypassPermissions` while its cycle array does not.
|
|
90
|
+
*
|
|
91
|
+
* **The two arrays must not be collapsed back into one.** `getAllowedModes`
|
|
92
|
+
* filters *this* array, never `MODE_CYCLE`. If it filtered the cycle, then the
|
|
93
|
+
* moment the cycle stops listing `bypassPermissions`, a config requesting it
|
|
94
|
+
* would be silently walked *down* to `acceptEdits` by `clampMode` — a quiet
|
|
95
|
+
* downgrade of a security-relevant setting, with every existing test still
|
|
96
|
+
* green. The fixed point is pinned by a probe in `test/core/permission.test.ts`
|
|
97
|
+
* (P4b).
|
|
98
|
+
*
|
|
99
|
+
* Order is insignificant to both consumers except in one place: `clampMode`'s
|
|
100
|
+
* last-resort fallback is `allowed[0]`, so `default` stays first.
|
|
59
101
|
*/
|
|
60
|
-
export const
|
|
102
|
+
export const ALL_MODES: PermissionMode[] = [
|
|
103
|
+
'default',
|
|
104
|
+
'acceptEdits',
|
|
105
|
+
'plan',
|
|
106
|
+
'auto',
|
|
107
|
+
'bypassPermissions',
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Shift+Tab cycling order — **what the user actually presses through**. Also
|
|
112
|
+
* deliberately **not** the permissiveness order above: the cycle is UX, and only
|
|
113
|
+
* the hierarchy answers "is this mode wider than that one". Keeping them
|
|
114
|
+
* separate is what lets `forbiddenModes` drop an entry from the cycle without
|
|
115
|
+
* disturbing the ranking that `clampMode` walks.
|
|
116
|
+
*
|
|
117
|
+
* The two arrays **differ**, and that is the whole reason both exist:
|
|
118
|
+
* `bypassPermissions` is a legal mode that no Shift+Tab reaches (asked for
|
|
119
|
+
* through config / `MIPHAM_DAEMON_PERMISSION` / settings, where the user named
|
|
120
|
+
* it explicitly), while `auto` is on the wheel. Collapsing them back into one
|
|
121
|
+
* would either drop a legal mode or advertise one the wheel cannot reach —
|
|
122
|
+
* **do not "simplify" one back into the other.**
|
|
123
|
+
*
|
|
124
|
+
* Both of those are load-bearing, so an off-wheel *current* mode is a real state
|
|
125
|
+
* (`permission: bypassPermissions` in config, then Shift+Tab). `nextMode` owns
|
|
126
|
+
* the rule for it — see there.
|
|
127
|
+
*/
|
|
128
|
+
export const MODE_CYCLE: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
|
|
61
129
|
|
|
62
130
|
/** 规范形 → 把别名与大小写归一到一个键上(键一律小写)。 */
|
|
63
131
|
const MODE_ALIASES: Record<string, PermissionMode> = {
|
|
64
132
|
default: 'default',
|
|
65
133
|
plan: 'plan',
|
|
66
134
|
acceptedits: 'acceptEdits',
|
|
135
|
+
auto: 'auto',
|
|
67
136
|
bypasspermissions: 'bypassPermissions',
|
|
68
137
|
bypass: 'bypassPermissions', // 遗留 3 档名(PermissionLevel 里的 'bypass')
|
|
69
138
|
}
|
|
@@ -71,7 +140,7 @@ const MODE_ALIASES: Record<string, PermissionMode> = {
|
|
|
71
140
|
/** 认不出的配置一律按这一档收紧 —— 层级表首位即最严的一档(与 P4 同一真源)。 */
|
|
72
141
|
const STRICTEST_MODE: PermissionMode = PERMISSION_MODE_HIERARCHY[0]!
|
|
73
142
|
|
|
74
|
-
const VALID_MODE_LIST = 'default, plan, acceptEdits, bypassPermissions'
|
|
143
|
+
const VALID_MODE_LIST = 'default, plan, acceptEdits, auto, bypassPermissions'
|
|
75
144
|
|
|
76
145
|
/** 可读的类型名 —— 报错要说清「你给的是个字符串」,而不是只说 invalid。 */
|
|
77
146
|
function describeValue(value: unknown): string {
|
|
@@ -173,9 +242,15 @@ export function normalizeRestrictions(raw: unknown): {
|
|
|
173
242
|
return { restrictions, invalid }
|
|
174
243
|
}
|
|
175
244
|
|
|
176
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* Resolve which modes are actually permitted given the restrictions.
|
|
247
|
+
*
|
|
248
|
+
* Filtered from `ALL_MODES` (the full legal set), **not** from `MODE_CYCLE` —
|
|
249
|
+
* see `ALL_MODES` for what filtering the cycle would silently do to
|
|
250
|
+
* `bypassPermissions`. `nextMode` re-intersects with the cycle afterwards.
|
|
251
|
+
*/
|
|
177
252
|
function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
|
|
178
|
-
let allowed = [...
|
|
253
|
+
let allowed = [...ALL_MODES]
|
|
179
254
|
|
|
180
255
|
if (restrictions?.forbiddenModes && restrictions.forbiddenModes.length > 0) {
|
|
181
256
|
const forbidden = new Set(restrictions.forbiddenModes)
|
|
@@ -218,11 +293,27 @@ export function nextMode(
|
|
|
218
293
|
current: PermissionMode,
|
|
219
294
|
restrictions?: PermissionRestrictions,
|
|
220
295
|
): PermissionMode {
|
|
296
|
+
// The user-facing cycle is `MODE_CYCLE`, narrowed by what the restrictions
|
|
297
|
+
// leave allowed — so reading the *cycle's* order (not `getAllowedModes`'
|
|
298
|
+
// order) is what keeps Shift+Tab on the same path once the two arrays
|
|
299
|
+
// diverge. An off-cycle mode (or one forbidden here) is not `indexOf`-able
|
|
300
|
+
// and falls through to `clampMode`.
|
|
221
301
|
const allowed = getAllowedModes(restrictions)
|
|
222
|
-
const
|
|
302
|
+
const cycle = MODE_CYCLE.filter((m) => allowed.includes(m))
|
|
303
|
+
const idx = cycle.indexOf(current)
|
|
223
304
|
if (idx === -1) {
|
|
224
|
-
//
|
|
225
|
-
|
|
305
|
+
// `current` is off the cycle: legal-but-uncyclable (`bypassPermissions`) or
|
|
306
|
+
// forbidden by the restrictions. Reading "clamp then find next" as
|
|
307
|
+
// `return clampMode(current)` returns `current` itself in the first case —
|
|
308
|
+
// clamping an *allowed* mode is the identity — so Shift+Tab would do nothing
|
|
309
|
+
// and `nextMode` would hand back a mode the wheel cannot reach. Clamp *onto*
|
|
310
|
+
// the cycle, then advance from there; if that still lands off-cycle, take the
|
|
311
|
+
// cycle's first entry. That entry is `default`, which is not the narrowest mode
|
|
312
|
+
// on the wheel (`plan` is) — the wheel is not a permissiveness order — but the
|
|
313
|
+
// only off-wheel state reachable is `bypassPermissions`, so the fallback still
|
|
314
|
+
// steps away from wider rather than toward it.
|
|
315
|
+
const onCycle = cycle.indexOf(clampMode(current, restrictions))
|
|
316
|
+
return onCycle === -1 ? (cycle[0] ?? current) : cycle[(onCycle + 1) % cycle.length]!
|
|
226
317
|
}
|
|
227
|
-
return
|
|
318
|
+
return cycle[(idx + 1) % cycle.length]!
|
|
228
319
|
}
|
package/src/core/permission.ts
CHANGED
|
@@ -12,8 +12,26 @@ import {
|
|
|
12
12
|
nextMode,
|
|
13
13
|
clampMode,
|
|
14
14
|
normalizeRestrictions,
|
|
15
|
-
|
|
15
|
+
ALL_MODES,
|
|
16
16
|
} from './permission-config'
|
|
17
|
+
import type { PermissionClassifier } from './permission-classifier'
|
|
18
|
+
import { recordClassifierRuling } from './permission-audit'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A tool that only reads: it cannot modify a file or run anything.
|
|
22
|
+
*
|
|
23
|
+
* Named and shared because two modes now depend on the same judgement — `plan`
|
|
24
|
+
* allows exactly these and nothing else, and `auto` lets them past the classifier.
|
|
25
|
+
* Two hand-written copies of this list are how the two modes would come to disagree
|
|
26
|
+
* about what "read-only" means, which is the shape of defect this file has already
|
|
27
|
+
* been bitten by more than once.
|
|
28
|
+
*
|
|
29
|
+
* The `category === 'file'` half is load-bearing rather than redundant: it keeps a
|
|
30
|
+
* future non-file tool that happens to be called `Read` out of the carve-out.
|
|
31
|
+
*/
|
|
32
|
+
function isReadOnlyTool(tool: ToolDefinition): boolean {
|
|
33
|
+
return tool.category === 'file' && ['Read', 'Grep', 'Glob'].includes(tool.name)
|
|
34
|
+
}
|
|
17
35
|
|
|
18
36
|
/**
|
|
19
37
|
* Check if a Bash command is a "verification-only" command that should be
|
|
@@ -66,7 +84,32 @@ function isVerificationCommand(input: Record<string, unknown>): boolean {
|
|
|
66
84
|
return verifyPatterns.some((p) => p.test(cmd))
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Which `PermissionLevel` spellings the constructor accepts as an actual
|
|
89
|
+
* *mode*. Filtered from `ALL_MODES`, **not** `MODE_CYCLE`: `bypassPermissions`
|
|
90
|
+
* is a legal destination even though it is not part of the Shift+Tab cycle, and
|
|
91
|
+
* reading the cycle here would quietly demote it to the legacy-level fallback.
|
|
92
|
+
*/
|
|
93
|
+
const VALID_MODES: Set<string> = new Set<string>(ALL_MODES)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Legacy 3-level spellings, still honoured — and still **silent**, because they
|
|
97
|
+
* are *mapped* rather than ignored: `'self'`/`'ask'` both meant "let each tool
|
|
98
|
+
* self-decide" (→ `'default'`), `'bypass'` → `'bypassPermissions'`.
|
|
99
|
+
*
|
|
100
|
+
* They predate `PermissionMode` and stay accepted so an old `config.yml` keeps
|
|
101
|
+
* working. They are also why `setDefaultLevel` cannot use `VALID_MODES` as its
|
|
102
|
+
* only test: a legacy spelling and a mode name must both count as *recognized*,
|
|
103
|
+
* while only a value that is neither gets a warning.
|
|
104
|
+
*/
|
|
105
|
+
const LEGACY_LEVEL_MODES: Record<string, PermissionMode> = {
|
|
106
|
+
self: 'default',
|
|
107
|
+
ask: 'default',
|
|
108
|
+
bypass: 'bypassPermissions',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Human-readable mode list for warnings — derived, so no message can hold a stale copy. */
|
|
112
|
+
const MODE_LIST = ALL_MODES.join(', ')
|
|
70
113
|
|
|
71
114
|
/**
|
|
72
115
|
* Why a tool resolved to 'ask' — for rich denial errors (#52).
|
|
@@ -79,6 +122,51 @@ export type PermissionDenialReason =
|
|
|
79
122
|
| 'mode-baseline' // mode-specific default (acceptEdits/plan) → ask
|
|
80
123
|
| 'tool-default' // tool.permission === 'ask'
|
|
81
124
|
| 'system-default' // no rule, no tool permission → fallback ask
|
|
125
|
+
| 'classifier-deny' // `auto` mode's classifier ruled against the call
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Which denial reasons `auto` mode's classifier is allowed to rule on — an
|
|
129
|
+
* **allowlist**, not a denylist, and the direction is the whole point.
|
|
130
|
+
*
|
|
131
|
+
* `deny-rule` and `ask-rule` are absent deliberately: those are decisions a human
|
|
132
|
+
* wrote down. Adding them here would silently turn the classifier into a universal
|
|
133
|
+
* bypass of every org-level rule — the one thing the mode must never be. A reason
|
|
134
|
+
* missing from this set therefore fails **closed** (the call stays `'ask'`), which
|
|
135
|
+
* is why the set is spelled as the reasons that are *permitted*, not the ones that
|
|
136
|
+
* are not.
|
|
137
|
+
*
|
|
138
|
+
* `legacy-rule` is absent for the same reason as the rules: it is an explicit
|
|
139
|
+
* per-tool decision from `setRule()`. `classifier-deny` is absent because it is not
|
|
140
|
+
* a *static* reason at all — `explainDenial()` never returns it.
|
|
141
|
+
*/
|
|
142
|
+
const CLASSIFIABLE: ReadonlySet<PermissionDenialReason> = new Set<PermissionDenialReason>([
|
|
143
|
+
'mode-baseline',
|
|
144
|
+
'tool-default',
|
|
145
|
+
'system-default',
|
|
146
|
+
])
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What a tool call actually resolved to, after the classifier has had its say.
|
|
150
|
+
*
|
|
151
|
+
* `level` is what the caller acts on (`'ask'` ⇒ blocked). `source` records whether
|
|
152
|
+
* the decision was the static chain's or the classifier's, so a caller can word the
|
|
153
|
+
* denial correctly: telling a model "denied" when the classifier was merely
|
|
154
|
+
* unreachable makes it abandon the task, while the honest reading is "this did not
|
|
155
|
+
* run, a retry is appropriate".
|
|
156
|
+
*/
|
|
157
|
+
export interface ApprovalDecision {
|
|
158
|
+
level: PermissionLevel
|
|
159
|
+
source: 'static' | 'classifier'
|
|
160
|
+
/** Why it is `'ask'`. Present on every denial, from either source. */
|
|
161
|
+
denialReason?: PermissionDenialReason
|
|
162
|
+
/** The classifier's own one-line justification, when it ruled. */
|
|
163
|
+
classifierReason?: string
|
|
164
|
+
/**
|
|
165
|
+
* `true` ⇒ held back because the classifier could not be reached or its answer
|
|
166
|
+
* could not be read — **not** a policy decision, and worth retrying.
|
|
167
|
+
*/
|
|
168
|
+
retryable?: boolean
|
|
169
|
+
}
|
|
82
170
|
|
|
83
171
|
export class PermissionSystem {
|
|
84
172
|
private allowRules: PermissionRuleEntry[] = []
|
|
@@ -86,7 +174,9 @@ export class PermissionSystem {
|
|
|
86
174
|
private askRules: PermissionRuleEntry[] = []
|
|
87
175
|
/** Malformed `permissionRestrictions` entries from the last set/load — see below. */
|
|
88
176
|
private restrictionWarnings: string[] = []
|
|
89
|
-
/**
|
|
177
|
+
/** Unrecognized `permission:` value from the last `setDefaultLevel` — third of the warning family. */
|
|
178
|
+
private levelWarnings: string[] = []
|
|
179
|
+
/** Legacy exact-name rules for backward compat (set via setRule with 'self' level). */
|
|
90
180
|
private legacyRules = new Map<string, PermissionLevel>()
|
|
91
181
|
/** Legacy default level from constructor when passed non-mode values like 'ask' or 'bypass'. */
|
|
92
182
|
private legacyDefaultFallback: PermissionLevel | null = null
|
|
@@ -97,6 +187,19 @@ export class PermissionSystem {
|
|
|
97
187
|
private checkCache = new Map<string, PermissionLevel>()
|
|
98
188
|
private cacheMode: PermissionMode | null = null
|
|
99
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Cache for classifier rulings, same key as `checkCache`. Only **terminal**
|
|
192
|
+
* rulings are stored — see `resolveApproval`.
|
|
193
|
+
*/
|
|
194
|
+
private classifierCache = new Map<string, ApprovalDecision>()
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The `auto`-mode classifier, when one was handed in. Absent is a legal state
|
|
198
|
+
* (every other mode ignores it, and `auto` without one fails closed), so nothing
|
|
199
|
+
* here assumes it exists.
|
|
200
|
+
*/
|
|
201
|
+
private classifier: PermissionClassifier | undefined = undefined
|
|
202
|
+
|
|
100
203
|
// ── Org-level restrictions (P0 security) ──
|
|
101
204
|
private restrictions: PermissionRestrictions | undefined = undefined
|
|
102
205
|
|
|
@@ -107,9 +210,34 @@ export class PermissionSystem {
|
|
|
107
210
|
/** Invalidate the permission cache (called on any rule/mode change). */
|
|
108
211
|
private invalidateCache(): void {
|
|
109
212
|
this.checkCache.clear()
|
|
213
|
+
this.classifierCache.clear()
|
|
110
214
|
this.cacheMode = null
|
|
111
215
|
}
|
|
112
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Hand in the classifier that `auto` mode consults. Separating this from the
|
|
219
|
+
* constructor keeps the permission system free of provider/registry imports: the
|
|
220
|
+
* wiring site (the CLI entry, where the registry exists) builds the classifier and
|
|
221
|
+
* attaches it here. `undefined` removes it, which makes `auto` refuse every gated
|
|
222
|
+
* call again — fail-closed, not fail-open.
|
|
223
|
+
*
|
|
224
|
+
* The seam deliberately lives on the permission system rather than on the engine:
|
|
225
|
+
* an engine-side setter would be a new engine capability that the daemon would
|
|
226
|
+
* then have to match or be given a named exemption from
|
|
227
|
+
* (`test/integrity/daemon-capability-parity.test.ts`). The cost of that choice is
|
|
228
|
+
* stated where it matters: this guard therefore cannot see whether anyone ever
|
|
229
|
+
* calls this, which is why the wiring has its own source-side assertion.
|
|
230
|
+
*/
|
|
231
|
+
setClassifier(classifier: PermissionClassifier | undefined): void {
|
|
232
|
+
this.classifier = classifier
|
|
233
|
+
this.invalidateCache()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether an `auto`-mode classifier is attached. For diagnostics, not decisions. */
|
|
237
|
+
hasClassifier(): boolean {
|
|
238
|
+
return this.classifier !== undefined
|
|
239
|
+
}
|
|
240
|
+
|
|
113
241
|
constructor(modeOrLevel: PermissionLevel = 'default') {
|
|
114
242
|
if (VALID_MODES.has(modeOrLevel)) {
|
|
115
243
|
this.mode = modeOrLevel as PermissionMode
|
|
@@ -212,6 +340,23 @@ export class PermissionSystem {
|
|
|
212
340
|
subPerm.deny(denyEntry.pattern)
|
|
213
341
|
}
|
|
214
342
|
|
|
343
|
+
// The classifier travels with the `auto` mode, not with the agent: a sub-agent
|
|
344
|
+
// gets it exactly when its *resolved* mode is `auto`, and not otherwise. That
|
|
345
|
+
// makes the two natural ways in behave consistently — an agent that names `auto`
|
|
346
|
+
// explicitly, and one that inherits from a parent already sitting in `auto`
|
|
347
|
+
// (`resolveAgentMode` reads `inherit` as "the parent's mode"). Inheriting the
|
|
348
|
+
// label without the engine would be the worst of both: a sub-agent pinned to a
|
|
349
|
+
// mode whose only substance is a classifier it does not have, refusing every
|
|
350
|
+
// gated call with a message about a mode that is working fine for its parent.
|
|
351
|
+
//
|
|
352
|
+
// No sub-agent lands here by default: the default mode is `default`, so this is
|
|
353
|
+
// opt-in through the mode itself. What is *not* inherited is any allowance —
|
|
354
|
+
// `resolvedMode` is already clamped against the org restrictions above, so an
|
|
355
|
+
// org that caps the mode also removes the classifier.
|
|
356
|
+
if (resolvedMode === 'auto' && this.classifier) {
|
|
357
|
+
subPerm.setClassifier(this.classifier)
|
|
358
|
+
}
|
|
359
|
+
|
|
215
360
|
return subPerm
|
|
216
361
|
}
|
|
217
362
|
|
|
@@ -224,8 +369,13 @@ export class PermissionSystem {
|
|
|
224
369
|
// Normalize aliases
|
|
225
370
|
const normalized = agentMode === 'bypass' ? 'bypassPermissions' : agentMode
|
|
226
371
|
|
|
372
|
+
// Hand-written map, so a mode missing from it does not fail to compile: it
|
|
373
|
+
// falls to the `|| 'default'` below and the agent silently runs narrower than
|
|
374
|
+
// it asked for. `auto` therefore has to be added here *and* in
|
|
375
|
+
// `agent/types.ts`'s union — the type does not force either.
|
|
227
376
|
const modeMap: Record<string, PermissionMode> = {
|
|
228
377
|
bypassPermissions: 'bypassPermissions',
|
|
378
|
+
auto: 'auto',
|
|
229
379
|
plan: 'plan',
|
|
230
380
|
acceptEdits: 'acceptEdits',
|
|
231
381
|
default: 'default',
|
|
@@ -296,7 +446,7 @@ export class PermissionSystem {
|
|
|
296
446
|
* 1. Deny rules → block
|
|
297
447
|
* 2. Ask rules → require approval
|
|
298
448
|
* 3. Allow rules → permit
|
|
299
|
-
* 4. Legacy exact-name rules (backward compat — e.g. setRule('tool', '
|
|
449
|
+
* 4. Legacy exact-name rules (backward compat — e.g. setRule('tool', 'self'))
|
|
300
450
|
* 5. Mode baseline → mode-specific default (overrides tool.permission for explicit modes)
|
|
301
451
|
* 6. Tool's own permission → tool-specific default (backward compat)
|
|
302
452
|
* 7. Legacy constructor fallback (when constructed with 'ask'/'bypass')
|
|
@@ -309,7 +459,7 @@ export class PermissionSystem {
|
|
|
309
459
|
}
|
|
310
460
|
|
|
311
461
|
// ── Cache lookup (P2): reuse decision for same tool+mode+input ──
|
|
312
|
-
const cacheKey =
|
|
462
|
+
const cacheKey = this.cacheKey(tool, input)
|
|
313
463
|
if (this.cacheMode === this.mode) {
|
|
314
464
|
const cached = this.checkCache.get(cacheKey)
|
|
315
465
|
if (cached !== undefined) return cached
|
|
@@ -419,6 +569,133 @@ export class PermissionSystem {
|
|
|
419
569
|
return { reason: 'system-default' }
|
|
420
570
|
}
|
|
421
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Same key both caches use. Extracted rather than written twice: two copies of a
|
|
574
|
+
* cache key would drift, and a key that drifts is a cache that answers for the
|
|
575
|
+
* wrong call.
|
|
576
|
+
*/
|
|
577
|
+
private cacheKey(tool: ToolDefinition, input: Record<string, unknown>): string {
|
|
578
|
+
return tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Resolve a call to a decision, consulting `auto` mode's classifier when — and
|
|
583
|
+
* only when — the static chain answered `'ask'` for a reason a classifier is
|
|
584
|
+
* allowed to rule on.
|
|
585
|
+
*
|
|
586
|
+
* **The step order below is the security contract, not an implementation
|
|
587
|
+
* detail.** Each numbered step exists to close a specific way this could go
|
|
588
|
+
* wrong, and reordering them is how the mode would become a bypass:
|
|
589
|
+
*
|
|
590
|
+
* 1. `check()` first, untouched. Everything it decides *without* asking —
|
|
591
|
+
* `bypassPermissions`, `acceptEdits`, `plan`, allow rules, tool defaults that
|
|
592
|
+
* are not `'ask'` — is returned verbatim. This is the compatibility guarantee:
|
|
593
|
+
* non-`'ask'` decisions are byte-for-byte what they were before this method
|
|
594
|
+
* existed, and the classifier is never even consulted for them.
|
|
595
|
+
* 2. Only `'ask'` continues, and only for a reason in `CLASSIFIABLE`. A denial
|
|
596
|
+
* caused by a deny rule, an ask rule, or a legacy exact-name rule stops here
|
|
597
|
+
* and stays denied. Without this step the classifier would be a universal
|
|
598
|
+
* bypass of every rule a human wrote.
|
|
599
|
+
* 3. `auto` without a classifier stops here too, still `'ask'` — fail-closed.
|
|
600
|
+
* 4. A ruling of "allow" is **not** returned as `'bypass'`. It is re-derived
|
|
601
|
+
* through `allowRuleDecision()`, the same ceiling-aware path an allow *rule*
|
|
602
|
+
* takes, so the classifier can never grant more than a rule could and an org's
|
|
603
|
+
* `maxAllowedMode` caps it automatically.
|
|
604
|
+
*
|
|
605
|
+
* A refusal is always `'ask'` — never a new kind of denial. The classifier may
|
|
606
|
+
* only ever turn a blocked call into a running one; it cannot manufacture a
|
|
607
|
+
* denial the static chain did not already produce. Read the other way round: it
|
|
608
|
+
* can only *narrow* what runs, never widen the gate.
|
|
609
|
+
*
|
|
610
|
+
* Caching: rulings are cached on the same key as `check()`, but a ruling that came
|
|
611
|
+
* from an engine failure is **not** cached. Its own verdict says a retry is
|
|
612
|
+
* appropriate (`retryable`), and a cache would make that false by replaying the
|
|
613
|
+
* failure without asking anyone.
|
|
614
|
+
*/
|
|
615
|
+
async resolveApproval(
|
|
616
|
+
tool: ToolDefinition,
|
|
617
|
+
input: Record<string, unknown>,
|
|
618
|
+
opts: { signal?: AbortSignal } = {},
|
|
619
|
+
): Promise<ApprovalDecision> {
|
|
620
|
+
// 1. The static chain decides everything it can decide without asking.
|
|
621
|
+
const level = this.check(tool, input)
|
|
622
|
+
if (level !== 'ask') return { level, source: 'static' }
|
|
623
|
+
|
|
624
|
+
// 2. Why it is 'ask' — and may a classifier rule on that reason at all?
|
|
625
|
+
const { reason } = this.explainDenial(tool, input)
|
|
626
|
+
if (!CLASSIFIABLE.has(reason)) return { level: 'ask', source: 'static', denialReason: reason }
|
|
627
|
+
|
|
628
|
+
// 3. Only `auto` consults a classifier, and only if one was handed in.
|
|
629
|
+
if (!this.classifier || this.mode !== 'auto') {
|
|
630
|
+
return { level: 'ask', source: 'static', denialReason: reason }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const key = this.cacheKey(tool, input)
|
|
634
|
+
const cached = this.classifierCache.get(key)
|
|
635
|
+
if (cached) return cached
|
|
636
|
+
|
|
637
|
+
const verdict = await this.classifier.classify({
|
|
638
|
+
tool: tool.name,
|
|
639
|
+
input,
|
|
640
|
+
mode: this.mode,
|
|
641
|
+
reason,
|
|
642
|
+
signal: opts.signal,
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
if (verdict.allow) {
|
|
646
|
+
// 4. An allow is re-derived through the rule path, so the org ceiling applies.
|
|
647
|
+
const decision: ApprovalDecision = {
|
|
648
|
+
level: this.allowRuleDecision(tool, input),
|
|
649
|
+
source: 'classifier',
|
|
650
|
+
classifierReason: verdict.reason,
|
|
651
|
+
}
|
|
652
|
+
// Only cache a ruling that actually let the call through, or one the
|
|
653
|
+
// classifier refused on policy. (`allowRuleDecision` can still answer 'ask'
|
|
654
|
+
// under a ceiling — that is a terminal answer too, so it caches.)
|
|
655
|
+
this.classifierCache.set(key, decision)
|
|
656
|
+
return this.ruled(tool, decision, 'allow')
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const decision: ApprovalDecision = {
|
|
660
|
+
level: 'ask',
|
|
661
|
+
source: 'classifier',
|
|
662
|
+
denialReason: 'classifier-deny',
|
|
663
|
+
classifierReason: verdict.reason,
|
|
664
|
+
retryable: verdict.retryable,
|
|
665
|
+
}
|
|
666
|
+
// A retryable failure is a statement that asking again is appropriate; caching
|
|
667
|
+
// it would contradict the field we just set.
|
|
668
|
+
if (!verdict.retryable) this.classifierCache.set(key, decision)
|
|
669
|
+
return this.ruled(tool, decision, 'deny')
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* 记一条裁决,再把**同一个对象**交回去:放行那一支此前是**无声**的,而无人值守的
|
|
674
|
+
* 子代理 + 无声放行是最坏的组合(`permission-audit.ts` 文件头有完整的来龙去脉)。
|
|
675
|
+
*
|
|
676
|
+
* 这个私有方法的存在方式就是那条不变量 —— 返回 `source: 'classifier'` 与落一条台账
|
|
677
|
+
* 在代码上**分不开**:两处都在这里出口,将来加第三条路也必须过这里。
|
|
678
|
+
*
|
|
679
|
+
* **缓存命中不在此列**(`resolveApproval` 在调用分类器之前就返回了):那时分类器
|
|
680
|
+
* 根本没被咨询,写一行等于声称有一个没人做过的裁决。
|
|
681
|
+
*/
|
|
682
|
+
private ruled(
|
|
683
|
+
tool: ToolDefinition,
|
|
684
|
+
decision: ApprovalDecision,
|
|
685
|
+
verdict: 'allow' | 'deny',
|
|
686
|
+
): ApprovalDecision {
|
|
687
|
+
recordClassifierRuling({
|
|
688
|
+
mode: this.mode,
|
|
689
|
+
tool: tool.name,
|
|
690
|
+
verdict,
|
|
691
|
+
level: decision.level,
|
|
692
|
+
reason: decision.classifierReason,
|
|
693
|
+
retryable: decision.retryable,
|
|
694
|
+
denialReason: decision.denialReason,
|
|
695
|
+
})
|
|
696
|
+
return decision
|
|
697
|
+
}
|
|
698
|
+
|
|
422
699
|
// ── Helpers ──
|
|
423
700
|
|
|
424
701
|
private ruleMatches(
|
|
@@ -501,9 +778,40 @@ export class PermissionSystem {
|
|
|
501
778
|
|
|
502
779
|
case 'plan':
|
|
503
780
|
// Only reads, no writes or executes
|
|
504
|
-
return tool
|
|
505
|
-
|
|
506
|
-
|
|
781
|
+
return isReadOnlyTool(tool) ? 'bypass' : 'ask'
|
|
782
|
+
|
|
783
|
+
case 'auto':
|
|
784
|
+
// Reads stay free; everything else is handed to the classifier.
|
|
785
|
+
//
|
|
786
|
+
// The tempting one-liner is `return 'ask'` — every call ruled on, which is
|
|
787
|
+
// what a mode table reading `auto → classify` suggests. Measured against the
|
|
788
|
+
// actual registry, that one-liner is a broken mode: 20 of the 31 tools
|
|
789
|
+
// declare `permission: 'self'`, and the list includes **Read, Grep and
|
|
790
|
+
// Glob**. Gating those makes `auto` the only mode in the ladder that cannot
|
|
791
|
+
// read a file without an LLM round-trip — every other mode (including
|
|
792
|
+
// `plan`) allows reads unconditionally — and when the classifier is
|
|
793
|
+
// unreachable, fail-closed means the agent cannot even read. A gate that
|
|
794
|
+
// fails catastrophically on the most benign operation is not a conservative
|
|
795
|
+
// gate; it is a broken one.
|
|
796
|
+
//
|
|
797
|
+
// So reads are carved out using `plan`'s own definition of read-only rather
|
|
798
|
+
// than a second list, and everything else — `Bash`, `Write`, `Edit`, and the
|
|
799
|
+
// `self`-declared tools that can reach outside this machine (`Git`,
|
|
800
|
+
// `WebFetch`, `CronCreate`, `Task`, `Memory`, …) — reaches
|
|
801
|
+
// `resolveApproval`. That is the half the classifier is actually needed for,
|
|
802
|
+
// and leaving them to auto-approve would be the fail-open version of this
|
|
803
|
+
// mistake.
|
|
804
|
+
//
|
|
805
|
+
// Returning the sentinel `'mode-baseline'` instead would hand those tools to
|
|
806
|
+
// step 6 of `check()`, i.e. `tool.permission` — `'self'` for all 20, so they
|
|
807
|
+
// would auto-approve and the classifier would never see them. (20 is counted
|
|
808
|
+
// from `createToolRegistry()`, not from grep: the literal `permission: 'self'`
|
|
809
|
+
// also appears in prose comments.)
|
|
810
|
+
//
|
|
811
|
+
// This does not contradict "the classifier may only allow, never deny": the
|
|
812
|
+
// baseline is `'ask'` (what an un-configured Mipham already answers), and a
|
|
813
|
+
// classifier refusal merely *keeps* that `'ask'`.
|
|
814
|
+
return isReadOnlyTool(tool) ? 'bypass' : 'ask'
|
|
507
815
|
|
|
508
816
|
case 'bypassPermissions':
|
|
509
817
|
return 'bypass'
|
|
@@ -515,12 +823,47 @@ export class PermissionSystem {
|
|
|
515
823
|
|
|
516
824
|
// ── Legacy compatibility ──
|
|
517
825
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
826
|
+
/**
|
|
827
|
+
* Set the default mode from a `permission:` config value. Accepts **both** the
|
|
828
|
+
* legacy 3-level spellings and any real mode name.
|
|
829
|
+
*
|
|
830
|
+
* This used to read `newMode = level === 'bypass' ? 'bypassPermissions' :
|
|
831
|
+
* 'default'` — i.e. it honoured exactly one string and sent everything else to
|
|
832
|
+
* `'default'`. Every mode name a user could write in `config.yml` therefore
|
|
833
|
+
* landed on `default` **silently**: `permission: plan` became a mode that
|
|
834
|
+
* auto-approves every tool declaring `permission: 'self'` (git, task,
|
|
835
|
+
* web-fetch, cron, memory, …), so the user believes they narrowed the gate
|
|
836
|
+
* while it moved the other way; `permission: bypassPermissions` and
|
|
837
|
+
* `permission: auto` did not do what they say either. No warning, no error,
|
|
838
|
+
* no way to tell — the same fail-open shape `normalizeRestrictions` was written
|
|
839
|
+
* to fix, arriving through a different door. Hence the same remedy: honour what
|
|
840
|
+
* is recognized, pin a safe fallback for what is not, and **say so** through
|
|
841
|
+
* `getInvalidPermissionMode()`.
|
|
842
|
+
*
|
|
843
|
+
* `VALID_MODES` is the discriminator, deliberately the same one the constructor
|
|
844
|
+
* uses — so a `permission:` value and a `new PermissionSystem(...)` argument
|
|
845
|
+
* cannot drift apart in which spellings they accept.
|
|
846
|
+
*
|
|
847
|
+
* The fallback for an unrecognized value stays `'default'`: the caller asked for
|
|
848
|
+
* a mode we cannot name, and `default` is the only mode that is not *wider* than
|
|
849
|
+
* a well-formed request (`plan` is narrower; the rest are comparable or wider).
|
|
850
|
+
* The org restrictions are applied last, so a clamped mode is what actually lands
|
|
851
|
+
* — `getMode()` reports the clamped value, never the requested one.
|
|
852
|
+
*/
|
|
853
|
+
setDefaultLevel(level: PermissionLevel | PermissionMode): void {
|
|
854
|
+
const mode: PermissionMode | undefined = VALID_MODES.has(level)
|
|
855
|
+
? (level as PermissionMode)
|
|
856
|
+
: LEGACY_LEVEL_MODES[level]
|
|
857
|
+
|
|
858
|
+
// Only a value that is neither a mode name nor a legacy spelling warns. The
|
|
859
|
+
// legacy ones are mapped, not dropped, so they have nothing to report.
|
|
860
|
+
this.levelWarnings = mode
|
|
861
|
+
? []
|
|
862
|
+
: [
|
|
863
|
+
`permission "${String(level)}" is not a permission mode; valid: ${MODE_LIST} (legacy spellings also accepted: ${Object.keys(LEGACY_LEVEL_MODES).join(', ')}). Using "default".`,
|
|
864
|
+
]
|
|
865
|
+
|
|
866
|
+
this.mode = clampMode(mode ?? 'default', this.restrictions)
|
|
524
867
|
this.invalidateCache()
|
|
525
868
|
}
|
|
526
869
|
|
|
@@ -529,7 +872,17 @@ export class PermissionSystem {
|
|
|
529
872
|
if (this.legacyDefaultFallback) return this.legacyDefaultFallback
|
|
530
873
|
if (this.mode === 'bypassPermissions') return 'bypass'
|
|
531
874
|
if (this.mode === 'plan') return 'ask'
|
|
532
|
-
return '
|
|
875
|
+
return 'self'
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Unrecognized `permission:` values from the last `setDefaultLevel`, one message
|
|
880
|
+
* each — third member of the warning family beside `getInvalidRules()` and
|
|
881
|
+
* `getInvalidRestrictions()`. Callers surface all three to stderr; a silent
|
|
882
|
+
* return here means the gate is not where the user's config says it is.
|
|
883
|
+
*/
|
|
884
|
+
getInvalidPermissionMode(): string[] {
|
|
885
|
+
return this.levelWarnings
|
|
533
886
|
}
|
|
534
887
|
|
|
535
888
|
setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
|
|
@@ -542,7 +895,7 @@ export class PermissionSystem {
|
|
|
542
895
|
// Also sync to new-style arrays for listRules / new API consistency
|
|
543
896
|
if (level === 'bypass') this.allow(toolName)
|
|
544
897
|
else if (level === 'ask') this.ask(toolName)
|
|
545
|
-
// '
|
|
898
|
+
// 'self' is stored only in legacyRules (returns 'self', not 'bypass')
|
|
546
899
|
}
|
|
547
900
|
} else {
|
|
548
901
|
const rule = toolNameOrRule
|