@massu/core 1.10.7 → 1.12.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 (177) hide show
  1. package/CHANGELOG.md +1303 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +559 -400
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +66 -18
  16. package/dist/hooks/pre-compact.js +27 -2
  17. package/dist/hooks/pre-delete-check.js +48 -22
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +24 -0
  23. package/dist/hooks/session-start.js +8710 -8460
  24. package/dist/hooks/user-prompt.js +24 -0
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/gitToplevel.ts +0 -22
  110. package/src/lib/hook-registry.ts +0 -43
  111. package/src/lib/installLock.ts +0 -66
  112. package/src/lib/memory-path.ts +0 -49
  113. package/src/lib/pidLiveness.ts +0 -67
  114. package/src/lib/settings-local.ts +0 -110
  115. package/src/lib/sql-table-names.ts +0 -71
  116. package/src/license.ts +0 -547
  117. package/src/lsp/auto-detect.ts +0 -98
  118. package/src/lsp/client.ts +0 -776
  119. package/src/lsp/enrich.ts +0 -127
  120. package/src/lsp/types.ts +0 -221
  121. package/src/memory-db.ts +0 -1614
  122. package/src/memory-file-ingest.ts +0 -128
  123. package/src/memory-tools.ts +0 -424
  124. package/src/middleware-tree.ts +0 -71
  125. package/src/observability-tools.ts +0 -343
  126. package/src/observation-extractor.ts +0 -418
  127. package/src/page-deps.ts +0 -285
  128. package/src/permissions.ts +0 -422
  129. package/src/prompt-analyzer.ts +0 -332
  130. package/src/python/coupling-detector.ts +0 -125
  131. package/src/python/domain-enforcer.ts +0 -84
  132. package/src/python/impact-analyzer.ts +0 -96
  133. package/src/python/import-parser.ts +0 -244
  134. package/src/python/import-resolver.ts +0 -136
  135. package/src/python/migration-indexer.ts +0 -116
  136. package/src/python/migration-parser.ts +0 -332
  137. package/src/python/model-indexer.ts +0 -71
  138. package/src/python/model-parser.ts +0 -279
  139. package/src/python/route-indexer.ts +0 -59
  140. package/src/python/route-parser.ts +0 -317
  141. package/src/python-tools.ts +0 -630
  142. package/src/regression-detector.ts +0 -319
  143. package/src/rules.ts +0 -57
  144. package/src/schema-mapper.ts +0 -232
  145. package/src/security/adapter-origin.ts +0 -130
  146. package/src/security/adapter-verifier.ts +0 -319
  147. package/src/security/atomic-write.ts +0 -164
  148. package/src/security/fetcher.ts +0 -200
  149. package/src/security/install-tracking.ts +0 -319
  150. package/src/security/license-pubkey.generated.ts +0 -30
  151. package/src/security/license-response-verifier.ts +0 -149
  152. package/src/security/local-fingerprint.ts +0 -225
  153. package/src/security/manifest-cache.ts +0 -333
  154. package/src/security/manifest-schema.ts +0 -129
  155. package/src/security/registry-pubkey.generated.ts +0 -35
  156. package/src/security/telemetry.ts +0 -320
  157. package/src/security-scorer.ts +0 -405
  158. package/src/security-utils.ts +0 -133
  159. package/src/sentinel-db.ts +0 -580
  160. package/src/sentinel-scanner.ts +0 -406
  161. package/src/sentinel-tools.ts +0 -513
  162. package/src/sentinel-types.ts +0 -140
  163. package/src/server-dispatch.ts +0 -225
  164. package/src/server.ts +0 -107
  165. package/src/session-archiver.ts +0 -111
  166. package/src/session-state-generator.ts +0 -174
  167. package/src/team-knowledge.ts +0 -407
  168. package/src/tool-db-needs.ts +0 -232
  169. package/src/tools.ts +0 -1205
  170. package/src/transcript-parser.ts +0 -458
  171. package/src/trpc-index.ts +0 -215
  172. package/src/validate-features-runner.ts +0 -107
  173. package/src/validation-engine.ts +0 -358
  174. package/src/watch/daemon.ts +0 -385
  175. package/src/watch/lockfile-detector.ts +0 -65
  176. package/src/watch/paths.ts +0 -279
  177. package/src/watch/state.ts +0 -178
@@ -1,422 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * SSOT for MCP permission seeding, verification, and drift detection.
6
- *
7
- * Public surface:
8
- * MASSU_PERMISSION_ENTRIES — canonical glob entries (currently ['mcp__massu__*'])
9
- * LAUNCH_FLAG_REQUIRED_MODES — defaultMode values that require launch flag
10
- * per https://code.claude.com/docs/en/permission-modes
11
- * (cannot be activated from settings file alone)
12
- * findMissingEntries — pure SSOT helper used by writer + verifier
13
- * detectInvalidDefaultMode — checks settings.permissions.defaultMode against
14
- * LAUNCH_FLAG_REQUIRED_MODES
15
- * readGlobalSettings — reads ~/.claude/settings.json safely
16
- * mergedPermissionState — pure merge function: allow ∪ canonical;
17
- * defaultMode = local override OR global OR omit;
18
- * deny/ask preserved from local
19
- * installPermissions — read-merge-atomic-write-assert pipeline
20
- * verifyPermissions — read-only canonical-entry check
21
- * checkPermissionsDrift — extended diagnostic (4 drift kinds)
22
- * InstallPermissionsAssertionError — fail-loud post-write assertion type
23
- *
24
- * v3 fix: writes the FULL merged permissions block (not just .allow), eliminating
25
- * the merge-replacement trap empirically observed 2026-05-14 where a project-local
26
- * `permissions` object that omits defaultMode silently strips the global value.
27
- * See docs/plans/2026-05-14-1.8.0-mcp-permission-seeding.md §0.5 for evidence
28
- * (F15+F16+F17) and the docs gap at code.claude.com/docs/en/permissions.
29
- *
30
- * Manifest convention: synthetic non-file entries use prefix `__settings__/`.
31
- * This module uses `__settings__/permissions` as the manifest key for the
32
- * full merged permissions block hash (3-hash kept-because-edited pattern).
33
- */
34
-
35
- import { createHash } from 'crypto';
36
- import { homedir } from 'os';
37
- import { join } from 'path';
38
- import { getConfig } from './config.ts';
39
- import type { Manifest } from './commands/install-commands.ts';
40
- import {
41
- readSettingsAtPath,
42
- readSettingsLocal,
43
- writeSettingsLocalAtomic,
44
- } from './lib/settings-local.ts';
45
-
46
- // ============================================================
47
- // SSOT constants
48
- // ============================================================
49
-
50
- /**
51
- * Canonical MCP allowlist entries seeded by massu into project-local
52
- * `.claude/settings.local.json`. Currently a single glob covering all
53
- * `mcp__massu__<tool>` invocations (73+ tools as of 1.7.0). Future
54
- * additions require an ADR + explicit operator approval per CLAUDE.md
55
- * `### Default Permissions` policy.
56
- *
57
- * Format validated against https://code.claude.com/docs/en/permissions § MCP:
58
- * "`mcp__puppeteer__*` wildcard syntax that also matches all tools from the
59
- * `puppeteer` server".
60
- */
61
- export const MASSU_PERMISSION_ENTRIES = ['mcp__massu__*'] as const;
62
-
63
- /**
64
- * `defaultMode` values that CANNOT be activated from a settings file alone;
65
- * each requires a corresponding `--permission-mode <mode>` (or equivalent)
66
- * launch flag per https://code.claude.com/docs/en/permission-modes.
67
- *
68
- * Values OK from settings: `default`, `acceptEdits`, `plan`.
69
- * Values requiring launch flag: included in this constant.
70
- *
71
- * Used by detectInvalidDefaultMode + checkPermissionsDrift.
72
- */
73
- export const LAUNCH_FLAG_REQUIRED_MODES = ['bypassPermissions', 'auto', 'dontAsk'] as const;
74
-
75
- // ============================================================
76
- // Type surface
77
- // ============================================================
78
-
79
- export type DriftKind =
80
- | 'missing-allow'
81
- | 'invalid-default-mode'
82
- | 'unknown-key'
83
- | 'strips-global-defaultmode';
84
-
85
- export interface DriftItem {
86
- kind: DriftKind;
87
- detail: string;
88
- remediation: string;
89
- }
90
-
91
- export interface MergedPermissions {
92
- allow: string[];
93
- defaultMode?: string;
94
- deny?: string[];
95
- ask?: string[];
96
- }
97
-
98
- export class InstallPermissionsAssertionError extends Error {
99
- constructor(message: string) {
100
- super(message);
101
- this.name = 'InstallPermissionsAssertionError';
102
- }
103
- }
104
-
105
- // ============================================================
106
- // Pure helpers
107
- // ============================================================
108
-
109
- /** Deterministic JSON serialization with sorted keys. */
110
- function canonicalJson(value: unknown): string {
111
- if (value === null || typeof value !== 'object') {
112
- return JSON.stringify(value);
113
- }
114
- if (Array.isArray(value)) {
115
- return '[' + value.map(canonicalJson).join(',') + ']';
116
- }
117
- const keys = Object.keys(value as Record<string, unknown>).sort();
118
- const parts = keys.map((k) => {
119
- const v = (value as Record<string, unknown>)[k];
120
- if (v === undefined) return '';
121
- return JSON.stringify(k) + ':' + canonicalJson(v);
122
- }).filter((s) => s !== '');
123
- return '{' + parts.join(',') + '}';
124
- }
125
-
126
- function sha256Hex(input: string): string {
127
- return createHash('sha256').update(input, 'utf-8').digest('hex');
128
- }
129
-
130
- export function findMissingEntries(allow: readonly string[]): string[] {
131
- const allowSet = new Set(allow);
132
- return MASSU_PERMISSION_ENTRIES.filter((entry) => !allowSet.has(entry));
133
- }
134
-
135
- export function detectInvalidDefaultMode(
136
- settings: Record<string, unknown>,
137
- ): { invalid: boolean; mode?: string; reason?: string } {
138
- const permissions = settings.permissions as { defaultMode?: unknown } | undefined;
139
- const mode = permissions?.defaultMode;
140
- if (typeof mode !== 'string') {
141
- return { invalid: false };
142
- }
143
- if ((LAUNCH_FLAG_REQUIRED_MODES as readonly string[]).includes(mode)) {
144
- return {
145
- invalid: true,
146
- mode,
147
- reason:
148
- `defaultMode "${mode}" requires --permission-mode launch flag per ` +
149
- `https://code.claude.com/docs/en/permission-modes — settings-file value alone is inert. ` +
150
- `Remediation: launch with --permission-mode ${mode} OR change defaultMode to one of {default, acceptEdits, plan}.`,
151
- };
152
- }
153
- return { invalid: false };
154
- }
155
-
156
- /**
157
- * Read user-global ~/.claude/settings.json safely. Returns {} on any failure
158
- * (missing, corrupt, permission denied). Honors $HOME via os.homedir().
159
- */
160
- export function readGlobalSettings(): Record<string, unknown> {
161
- return readSettingsAtPath(join(homedir(), '.claude', 'settings.json'));
162
- }
163
-
164
- /**
165
- * Pure merge function. Computes the target `permissions` block for the
166
- * project-local settings.local.json.
167
- *
168
- * Rules:
169
- * - allow: dedupe(local.allow ∪ canonical), preserving local order; canonical
170
- * entries that are not already present are appended at the end
171
- * - defaultMode: local override wins (user choice respected); else global
172
- * value is propagated; else key is OMITTED entirely (no key in output object,
173
- * not `undefined` — verified by PERM-DRIFT-15)
174
- * - deny: preserved from local verbatim (or omitted if local has none)
175
- * - ask: preserved from local verbatim (or omitted if local has none)
176
- */
177
- export function mergedPermissionState(
178
- global: Record<string, unknown>,
179
- local: Record<string, unknown>,
180
- canonical: readonly string[],
181
- ): MergedPermissions {
182
- const globalPerm = (global.permissions as Record<string, unknown> | undefined) ?? {};
183
- const localPerm = (local.permissions as Record<string, unknown> | undefined) ?? {};
184
-
185
- const localAllow = Array.isArray(localPerm.allow)
186
- ? (localPerm.allow as unknown[]).filter((e): e is string => typeof e === 'string')
187
- : [];
188
- const allowSet = new Set(localAllow);
189
- const allow = [...localAllow];
190
- for (const entry of canonical) {
191
- if (!allowSet.has(entry)) {
192
- allow.push(entry);
193
- allowSet.add(entry);
194
- }
195
- }
196
-
197
- const result: MergedPermissions = { allow };
198
-
199
- // defaultMode resolution
200
- if (typeof localPerm.defaultMode === 'string') {
201
- result.defaultMode = localPerm.defaultMode;
202
- } else if (typeof globalPerm.defaultMode === 'string') {
203
- result.defaultMode = globalPerm.defaultMode;
204
- }
205
- // Else: key OMITTED (not undefined, not present at all)
206
-
207
- if (Array.isArray(localPerm.deny)) {
208
- result.deny = (localPerm.deny as unknown[]).filter((e): e is string => typeof e === 'string');
209
- }
210
- if (Array.isArray(localPerm.ask)) {
211
- result.ask = (localPerm.ask as unknown[]).filter((e): e is string => typeof e === 'string');
212
- }
213
-
214
- return result;
215
- }
216
-
217
- // ============================================================
218
- // installPermissions (read-merge-atomic-write-assert pipeline)
219
- // ============================================================
220
-
221
- const MANIFEST_KEY_PERMISSIONS = '__settings__/permissions';
222
-
223
- function resolveClaudeDir(claudeDir: string): string {
224
- return claudeDir;
225
- }
226
-
227
- function hashOfPermissions(perm: Record<string, unknown> | MergedPermissions): string {
228
- return sha256Hex(canonicalJson(perm));
229
- }
230
-
231
- /**
232
- * Install the canonical massu permission entries plus any propagated
233
- * defaultMode from global settings. Mirrors the install-commands.ts 3-hash
234
- * kept-because-edited pattern for operator-edit preservation.
235
- *
236
- * Cases (mirrors install-commands.ts:syncDirectory file-write logic):
237
- * 1. existing == expected → skipped:1 (idempotent)
238
- * 2. no last-installed hash → first install, write merged → installed:1
239
- * 3. existing == last-installed → operator did not edit; reapply merge → installed:1
240
- * 4. existing != last-installed → operator edited after last install → kept:1 (preserve)
241
- *
242
- * Post-write: re-reads disk and asserts the merged state survived. If a
243
- * defaultMode was decided (from local or global) but is absent on disk,
244
- * throws `InstallPermissionsAssertionError`.
245
- */
246
- export function installPermissions(
247
- claudeDir: string,
248
- manifest: Manifest,
249
- opts: { silent?: boolean; global?: Record<string, unknown> } = {},
250
- ): { installed: number; kept: number; skipped: number } {
251
- const resolvedDir = resolveClaudeDir(claudeDir);
252
- const global = opts.global ?? readGlobalSettings();
253
- const local = readSettingsLocal(resolvedDir);
254
-
255
- const merged = mergedPermissionState(global, local, MASSU_PERMISSION_ENTRIES);
256
- const expectedHash = hashOfPermissions(merged);
257
-
258
- const existingPerm = (local.permissions as Record<string, unknown> | undefined) ?? undefined;
259
- const existingHash = existingPerm ? hashOfPermissions(existingPerm) : undefined;
260
- const lastInstalledHash = manifest.entries[MANIFEST_KEY_PERMISSIONS];
261
-
262
- // Case 1: already in sync
263
- if (existingHash === expectedHash) {
264
- manifest.entries[MANIFEST_KEY_PERMISSIONS] = expectedHash;
265
- if (!opts.silent) {
266
- process.stderr.write(
267
- ` Permissions: already in sync (allow: ${merged.allow.length} entries; defaultMode: ${merged.defaultMode ?? 'omitted'}).\n`,
268
- );
269
- }
270
- return { installed: 0, kept: 0, skipped: 1 };
271
- }
272
-
273
- // Case 4: operator edited after last install — preserve
274
- if (lastInstalledHash !== undefined && existingHash !== lastInstalledHash) {
275
- if (!opts.silent) {
276
- process.stderr.write(
277
- ` Permissions: operator-edited since last install — preserving. ` +
278
- `Use \`npx massu permissions check-drift\` to inspect.\n`,
279
- );
280
- }
281
- return { installed: 0, kept: 1, skipped: 0 };
282
- }
283
-
284
- // Case 2/3: write merged
285
- const nextSettings: Record<string, unknown> = { ...local, permissions: merged };
286
- writeSettingsLocalAtomic(resolvedDir, nextSettings);
287
- manifest.entries[MANIFEST_KEY_PERMISSIONS] = expectedHash;
288
-
289
- // Post-write fail-loud assertion: re-read, confirm defaultMode propagation
290
- const onDisk = readSettingsLocal(resolvedDir);
291
- const onDiskPerm = (onDisk.permissions as Record<string, unknown> | undefined) ?? {};
292
- if (merged.defaultMode !== undefined) {
293
- const diskDefaultMode = onDiskPerm.defaultMode;
294
- if (diskDefaultMode !== merged.defaultMode) {
295
- throw new InstallPermissionsAssertionError(
296
- `Post-write assertion failed: expected permissions.defaultMode="${merged.defaultMode}" on disk, ` +
297
- `got ${JSON.stringify(diskDefaultMode)}. This indicates a filesystem race or write failure.`,
298
- );
299
- }
300
- }
301
- const diskAllow = Array.isArray(onDiskPerm.allow) ? (onDiskPerm.allow as unknown[]) : [];
302
- for (const entry of MASSU_PERMISSION_ENTRIES) {
303
- if (!diskAllow.includes(entry)) {
304
- throw new InstallPermissionsAssertionError(
305
- `Post-write assertion failed: canonical entry "${entry}" missing from permissions.allow on disk.`,
306
- );
307
- }
308
- }
309
-
310
- if (!opts.silent) {
311
- process.stderr.write(
312
- ` Wrote merged permissions block to .claude/settings.local.json ` +
313
- `(allow: ${merged.allow.length} entries; defaultMode: ${merged.defaultMode ?? 'omitted'}).\n`,
314
- );
315
- }
316
- return { installed: 1, kept: 0, skipped: 0 };
317
- }
318
-
319
- // ============================================================
320
- // verifyPermissions (read-only)
321
- // ============================================================
322
-
323
- export function verifyPermissions(claudeDir: string): {
324
- missing: string[];
325
- allowList: readonly string[];
326
- } {
327
- const local = readSettingsLocal(claudeDir);
328
- const permissions = (local.permissions as { allow?: unknown[] } | undefined) ?? {};
329
- const allow = Array.isArray(permissions.allow)
330
- ? (permissions.allow as unknown[]).filter((e): e is string => typeof e === 'string')
331
- : [];
332
- return {
333
- missing: findMissingEntries(allow),
334
- allowList: allow,
335
- };
336
- }
337
-
338
- // ============================================================
339
- // checkPermissionsDrift (extended diagnostic)
340
- // ============================================================
341
-
342
- /**
343
- * Known-bad top-level setting keys that look like permission settings but
344
- * are NOT documented at code.claude.com/docs/en/settings. Reports as
345
- * `drift[unknown-key]`. Conservative list: only flags strings that are
346
- * clearly typo-equivalents of documented keys or that have been observed
347
- * in the wild causing confusion.
348
- */
349
- const KNOWN_UNKNOWN_KEYS = new Set<string>([
350
- // From operator's observed ~/.claude/settings.json: top-level key that is
351
- // NOT in the docs (looks like a typo/wishful-thinking of skipDangerousModePermissionPrompt).
352
- 'skipAutoPermissionPrompt',
353
- ]);
354
-
355
- export function checkPermissionsDrift(
356
- claudeDir: string,
357
- opts: { global?: Record<string, unknown> } = {},
358
- ): { driftItems: DriftItem[] } {
359
- const global = opts.global ?? readGlobalSettings();
360
- const local = readSettingsLocal(claudeDir);
361
- const items: DriftItem[] = [];
362
-
363
- // (a) missing-allow
364
- const localPerm = (local.permissions as Record<string, unknown> | undefined) ?? {};
365
- const allow = Array.isArray(localPerm.allow)
366
- ? (localPerm.allow as unknown[]).filter((e): e is string => typeof e === 'string')
367
- : [];
368
- const missing = findMissingEntries(allow);
369
- for (const entry of missing) {
370
- items.push({
371
- kind: 'missing-allow',
372
- detail: `Canonical massu allowlist entry missing from permissions.allow: "${entry}"`,
373
- remediation: 'Run `npx massu permissions install` to seed.',
374
- });
375
- }
376
-
377
- // (b) invalid-default-mode
378
- const invalidMode = detectInvalidDefaultMode(local);
379
- if (invalidMode.invalid && invalidMode.mode) {
380
- items.push({
381
- kind: 'invalid-default-mode',
382
- detail: `defaultMode "${invalidMode.mode}" requires --permission-mode launch flag per code.claude.com/docs/en/permission-modes — settings-file value alone is inert.`,
383
- remediation:
384
- `Launch with --permission-mode ${invalidMode.mode} OR change defaultMode to one of {default, acceptEdits, plan}.`,
385
- });
386
- }
387
-
388
- // (c) strips-global-defaultmode (v3 — the F15+F16 trap)
389
- const globalPerm = (global.permissions as { defaultMode?: unknown } | undefined) ?? {};
390
- const localHasPermissions =
391
- local.permissions !== undefined && local.permissions !== null && typeof local.permissions === 'object';
392
- const localHasDefaultMode = typeof (localPerm as { defaultMode?: unknown }).defaultMode === 'string';
393
- const globalHasDefaultMode = typeof globalPerm.defaultMode === 'string';
394
- if (localHasPermissions && !localHasDefaultMode && globalHasDefaultMode) {
395
- items.push({
396
- kind: 'strips-global-defaultmode',
397
- detail:
398
- `Project-local permissions object omits defaultMode while global ~/.claude/settings.json has ` +
399
- `defaultMode="${globalPerm.defaultMode as string}". Per empirical observation (2026-05-14) the ` +
400
- `merge unit is the entire permissions object, so the global defaultMode is silently stripped.`,
401
- remediation:
402
- 'Run `npx massu permissions install` to write the full merged permissions block (auto-propagates global defaultMode).',
403
- });
404
- }
405
-
406
- // (d) unknown-key (top-level only)
407
- for (const key of Object.keys(local)) {
408
- if (KNOWN_UNKNOWN_KEYS.has(key)) {
409
- items.push({
410
- kind: 'unknown-key',
411
- detail: `Top-level settings key "${key}" is not documented at code.claude.com/docs/en/settings — silently ignored by Claude Code.`,
412
- remediation: `Remove or replace with a documented key (e.g. skipDangerousModePermissionPrompt).`,
413
- });
414
- }
415
- }
416
-
417
- return { driftItems: items };
418
- }
419
-
420
- // Suppress unused-import warning: getConfig is imported for future use (e.g.,
421
- // resolving config.conventions.claudeDirName from a higher-level caller).
422
- void getConfig;