@massu/core 1.7.0 → 1.8.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/README.md +72 -0
- package/dist/cli.js +991 -582
- package/package.json +1 -1
- package/src/cli.ts +8 -1
- package/src/commands/doctor.ts +4 -3
- package/src/commands/init.ts +3 -10
- package/src/commands/install-commands.ts +62 -53
- package/src/commands/permissions.ts +150 -0
- package/src/lib/settings-local.ts +110 -0
- package/src/permissions.ts +422 -0
- package/src/security/registry-pubkey.generated.ts +1 -1
|
@@ -0,0 +1,422 @@
|
|
|
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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-
|
|
1
|
+
// AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-14T20:20:21.775Z.
|
|
2
2
|
// Source pem: packages/core/security/registry-pubkey.pem
|
|
3
3
|
// RAW-bytes sha256: 3b6226d036c472e533110d11a7d0cd2773ce1d7d4f1003517d5bd69c5418ed4c
|
|
4
4
|
// DO NOT EDIT — regenerate via `node scripts/bundle-pubkey.mjs` or
|