@miphamai/cli 0.83.0 → 0.84.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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/skills/standard/mipham-code-setup.SKILL.md +30 -8
  3. package/src/agent/agent-context.ts +5 -5
  4. package/src/agent/agent-experience.ts +2 -2
  5. package/src/agent/agent-registry.ts +4 -4
  6. package/src/agent/cross-session/discovery.ts +3 -2
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +2 -2
  9. package/src/agent/pattern-analyzer.ts +3 -4
  10. package/src/agent/sub-agent.ts +12 -2
  11. package/src/agent/types.ts +4 -1
  12. package/src/commands/autoloop-journal.ts +2 -2
  13. package/src/commands/environment.ts +2 -1
  14. package/src/commands/loop-scaffold.ts +2 -1
  15. package/src/commands/project.ts +86 -33
  16. package/src/config/keys-manager.ts +2 -2
  17. package/src/config/loader.ts +11 -10
  18. package/src/config/preferences.ts +3 -4
  19. package/src/core/auto-memory.ts +2 -3
  20. package/src/core/constitution-loader.ts +3 -4
  21. package/src/core/crsi-producer.ts +3 -3
  22. package/src/core/crsi-sandbox.ts +3 -2
  23. package/src/core/dream-engine.ts +2 -2
  24. package/src/core/engine.ts +38 -6
  25. package/src/core/error-signature-db.ts +2 -2
  26. package/src/core/eval-harness.ts +4 -3
  27. package/src/core/improvement-track.ts +5 -6
  28. package/src/core/instructions.ts +28 -4
  29. package/src/core/memory/memory-loader.ts +2 -3
  30. package/src/core/paths.ts +21 -1
  31. package/src/core/permission-audit.ts +120 -0
  32. package/src/core/permission-classifier.ts +449 -0
  33. package/src/core/permission-config.ts +106 -15
  34. package/src/core/permission.ts +369 -16
  35. package/src/core/rule-engine.ts +2 -2
  36. package/src/core/rules-loader.ts +3 -2
  37. package/src/core/session-log.ts +2 -3
  38. package/src/core/session-store.ts +2 -3
  39. package/src/core/workspace-trust.ts +4 -3
  40. package/src/daemon/database.ts +2 -2
  41. package/src/daemon/index.ts +2 -3
  42. package/src/daemon/launch.ts +3 -3
  43. package/src/daemon/server.ts +15 -0
  44. package/src/i18n-core/locales/en-US.json +3 -0
  45. package/src/i18n-core/locales/zh-CN.json +3 -0
  46. package/src/index.tsx +30 -6
  47. package/src/mcp/token-store.ts +2 -2
  48. package/src/plugin/plugin-manager.ts +2 -2
  49. package/src/shared/constants.ts +0 -1
  50. package/src/shared/package-info.ts +1 -1
  51. package/src/shared/types.ts +46 -6
  52. package/src/shared/update.ts +2 -3
  53. package/src/skills/bundled-skills.ts +1 -1
  54. package/src/skills/loader.ts +2 -3
  55. package/src/skills/marketplace.ts +2 -3
  56. package/src/skills/registry.ts +2 -2
  57. package/src/skills/skill-assets.ts +2 -2
  58. package/src/skills/usage.ts +2 -2
  59. package/src/telemetry/consent.ts +2 -3
  60. package/src/tools/agent/enter-plan.ts +3 -2
  61. package/src/tools/agent/exit-plan.ts +1 -1
  62. package/src/tools/agent/list-agents.ts +1 -1
  63. package/src/tools/agent/memory.ts +3 -3
  64. package/src/tools/agent/plan.ts +3 -2
  65. package/src/tools/agent/report-findings.ts +1 -1
  66. package/src/tools/agent/send-message.ts +1 -1
  67. package/src/tools/agent/skill.ts +1 -1
  68. package/src/tools/exec/git.ts +2 -2
  69. package/src/tools/exec/task.ts +1 -1
  70. package/src/tools/file/glob.ts +1 -1
  71. package/src/tools/file/grep.ts +1 -1
  72. package/src/tools/file/read.ts +1 -1
  73. package/src/tools/network/web-fetch.ts +1 -1
  74. package/src/tools/network/web-search.ts +1 -1
  75. package/src/tools/scheduling/cron.ts +5 -5
  76. package/src/tools/scheduling/schedule-wakeup.ts +1 -1
  77. package/src/tools/system/config.ts +2 -2
  78. package/src/tools/system/tool-search.ts +1 -1
  79. package/src/ui/app.tsx +51 -9
  80. package/src/ui/commands.ts +8 -17
  81. package/src/ui/config-wizard.tsx +2 -2
  82. package/src/ui/input.tsx +15 -3
  83. package/src/workflow/journal.ts +2 -2
@@ -12,8 +12,26 @@ import {
12
12
  nextMode,
13
13
  clampMode,
14
14
  normalizeRestrictions,
15
- MODE_CYCLE,
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
- const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
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
- /** Legacy exact-name rules for backward compat (set via setRule with 'auto' level). */
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', 'auto'))
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 = tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
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.category === 'file' && ['Read', 'Grep', 'Glob'].includes(tool.name)
505
- ? 'bypass'
506
- : 'ask'
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
- setDefaultLevel(level: PermissionLevel): void {
519
- // Map legacy 3-level (auto/ask/bypass) to new 4-level mode.
520
- // Legacy 'auto'/'ask' = "let each tool self-decide" → 'default'.
521
- // Legacy 'bypass' → 'bypassPermissions'.
522
- const newMode: PermissionMode = level === 'bypass' ? 'bypassPermissions' : 'default'
523
- this.mode = clampMode(newMode, this.restrictions)
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 'auto'
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
- // 'auto' is stored only in legacyRules (returns 'auto', not 'bypass')
898
+ // 'self' is stored only in legacyRules (returns 'self', not 'bypass')
546
899
  }
547
900
  } else {
548
901
  const rule = toolNameOrRule
@@ -1,8 +1,8 @@
1
1
  import type { ExperienceRule } from '../agent/experience-rules.js'
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
3
3
  import { join, dirname } from 'node:path'
4
- import { homedir } from 'node:os'
5
4
  import { MANAGED_RULES } from './crsi-managed-rules'
5
+ import { miphamHome } from './paths.ts'
6
6
 
7
7
  export interface ToolRule {
8
8
  id: string
@@ -62,7 +62,7 @@ export class ExperienceRuleEngine {
62
62
  private rules: ToolRule[]
63
63
  private storePath: string
64
64
 
65
- constructor(storeDir: string = join(homedir(), '.mipham', 'rule-engine')) {
65
+ constructor(storeDir: string = miphamHome('rule-engine')) {
66
66
  this.rules = [...BUILTIN_RULES, ...MANAGED_RULES].map((r) => ({ ...r }))
67
67
  this.storePath = join(storeDir, 'rules.json')
68
68
  this.load()
@@ -21,6 +21,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs'
21
21
  import { join } from 'node:path'
22
22
  import { globToRegexSource } from './credential-masker/matcher'
23
23
  import { findWorktreeMarker } from './paths.ts'
24
+ import { MIPHAM_DIR } from '../shared/constants.ts'
24
25
 
25
26
  interface RuleFile {
26
27
  name: string
@@ -49,9 +50,9 @@ export class RulesLoader {
49
50
  private projectRulesDir: string | null
50
51
 
51
52
  constructor(cwd: string) {
52
- this.rulesDir = join(cwd, '.mipham', 'rules')
53
+ this.rulesDir = join(cwd, MIPHAM_DIR, 'rules')
53
54
  const marker = findWorktreeMarker(cwd)
54
- const projectRulesDir = marker ? join(marker.root, '.mipham', 'rules') : null
55
+ const projectRulesDir = marker ? join(marker.root, MIPHAM_DIR, 'rules') : null
55
56
  this.projectRulesDir = projectRulesDir === this.rulesDir ? null : projectRulesDir
56
57
  }
57
58
 
@@ -1,9 +1,9 @@
1
1
  import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
2
- import { homedir } from 'node:os'
3
2
  import { join } from 'node:path'
4
3
  import { createHash } from 'node:crypto'
5
4
  import type { Message, ToolUseContent, ToolResultContent, ToolResult } from '../shared/types'
6
5
  import type { CheckerDecision } from './post-flight-checker'
6
+ import { miphamHome } from './paths.ts'
7
7
 
8
8
  export type SessionEvent =
9
9
  | {
@@ -115,8 +115,7 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
115
115
  return out
116
116
  }
117
117
 
118
- const HOME = homedir()
119
- const LOG_DIR = join(HOME, '.mipham', 'sessions')
118
+ const LOG_DIR = miphamHome('sessions')
120
119
 
121
120
  /** 将会话名消毒为安全文件名(与 SessionStore 共用;防路径穿越)。 */
122
121
  export function sanitizeSessionName(name: string): string {
@@ -8,11 +8,11 @@ import {
8
8
  existsSync,
9
9
  statSync,
10
10
  } from 'node:fs'
11
- import { homedir } from 'node:os'
12
11
  import { join } from 'node:path'
13
12
  import { createHash } from 'node:crypto'
14
13
  import type { Message } from '../shared/types'
15
14
  import { sanitizeSessionName, SessionLog, deriveMessages, messageToEvents } from './session-log'
15
+ import { miphamHome } from './paths.ts'
16
16
 
17
17
  export interface SessionMetadata {
18
18
  name: string
@@ -29,8 +29,7 @@ export interface StoredSession {
29
29
  messages: Message[]
30
30
  }
31
31
 
32
- const HOME = homedir()
33
- const SESSIONS_DIR = join(HOME, '.mipham', 'sessions')
32
+ const SESSIONS_DIR = miphamHome('sessions')
34
33
  const INDEX_FILE = join(SESSIONS_DIR, '.index.json')
35
34
  const SUMMARIES_DIR = join(SESSIONS_DIR, '.summaries')
36
35
 
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { join, dirname, resolve } from 'node:path'
3
- import { homedir } from 'node:os'
4
3
  import { atomicWriteFileSync } from '../shared/atomic-write'
4
+ import { miphamHome } from './paths.ts'
5
+ import { MIPHAM_DIR } from '../shared/constants.ts'
5
6
 
6
- const MIPHAM_HOME = join(homedir(), '.mipham')
7
+ const MIPHAM_HOME = miphamHome()
7
8
  const TRUST_STORE_PATH = join(MIPHAM_HOME, 'trusted-workspaces.json')
8
9
 
9
10
  export interface TrustedWorkspaces {
@@ -198,7 +199,7 @@ export function resetWorkspaceTrust(): void {
198
199
  */
199
200
  export function warnProjectHooksSkipped(cwd: string): void {
200
201
  process.stderr.write(
201
- `⚠️ Workspace not trusted: skipped hooks from ${join(cwd, '.mipham', 'settings.json')}\n` +
202
+ `⚠️ Workspace not trusted: skipped hooks from ${join(cwd, MIPHAM_DIR, 'settings.json')}\n` +
202
203
  ` (hooks run commands — repository-controlled). Trust this directory in an interactive\n` +
203
204
  ` session to enable them.\n`,
204
205
  )
@@ -1,7 +1,6 @@
1
1
  // apps/cli/src/daemon/database.ts
2
2
  import { Database } from 'bun:sqlite'
3
3
  import { readdirSync, readFileSync, existsSync } from 'node:fs'
4
- import { homedir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
  import type {
7
6
  DaemonSession,
@@ -12,6 +11,7 @@ import type {
12
11
  CreateSessionInput,
13
12
  SessionStatus,
14
13
  } from './types'
14
+ import { miphamHome } from '../core/paths.ts'
15
15
 
16
16
  export class DaemonDatabase {
17
17
  private db: Database
@@ -340,7 +340,7 @@ export class DaemonDatabase {
340
340
  // ── Migration ──────────────────────────────────────────────
341
341
 
342
342
  migrateFromJsonl(): number {
343
- const sessionsDir = join(homedir(), '.mipham', 'sessions')
343
+ const sessionsDir = miphamHome('sessions')
344
344
 
345
345
  if (!existsSync(sessionsDir)) return 0
346
346
 
@@ -8,7 +8,6 @@
8
8
  import { join } from 'node:path'
9
9
  import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'
10
10
  import { createServer as createNetServer } from 'node:net'
11
- import { homedir } from 'node:os'
12
11
  import type { Server } from 'bun'
13
12
  import { DaemonDatabase } from './database'
14
13
  import { SessionManager } from './session-manager'
@@ -32,9 +31,9 @@ import type { WecomConfig } from './wecom/types.js'
32
31
  import { parseDingtalkEnv } from './dingtalk/env.js'
33
32
  import type { DingtalkConfig } from './dingtalk/types.js'
34
33
  import { loadConfig } from '../config/loader'
34
+ import { miphamHome } from '../core/paths.ts'
35
35
 
36
- const HOME = homedir()
37
- const MIPHAM_HOME = join(HOME, '.mipham')
36
+ const MIPHAM_HOME = miphamHome()
38
37
  const DB_PATH = join(MIPHAM_HOME, 'daemon.db')
39
38
  const TOKEN_PATH = join(MIPHAM_HOME, 'daemon.token')
40
39
  const PID_FILE = join(MIPHAM_HOME, 'daemon.pid')
@@ -13,13 +13,13 @@
13
13
 
14
14
  import { spawn, type SpawnOptions } from 'node:child_process'
15
15
  import { closeSync, mkdirSync, openSync, readFileSync, statSync } from 'node:fs'
16
- import { homedir } from 'node:os'
17
- import { dirname, join, resolve } from 'node:path'
16
+ import { dirname, resolve } from 'node:path'
17
+ import { miphamHome } from '../core/paths.ts'
18
18
 
19
19
  /** argv sentinel that re-enters this program as a daemon. Not user-facing. */
20
20
  export const DAEMON_ENTRY = '__daemon'
21
21
 
22
- const DEFAULT_LOG_FILE = join(homedir(), '.mipham', 'daemon.log')
22
+ const DEFAULT_LOG_FILE = miphamHome('daemon.log')
23
23
 
24
24
  /**
25
25
  * argv prefix that re-runs *this* program.