@massu/core 1.7.0 → 1.9.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.
@@ -0,0 +1,150 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu permissions <subcommand>` — MCP permission lifecycle CLI.
6
+ *
7
+ * Subcommands (each is documented at https://massu.ai/docs/reference/cli-reference):
8
+ * install Seed `mcp__massu__*` into permissions.allow + propagate global
9
+ * defaultMode (idempotent, kept-because-edited preservation).
10
+ * verify Read-only check; exit 0 if all canonical entries present, else 1.
11
+ * check-drift Extended diagnostic (4 drift kinds, severity-mapped exit codes).
12
+ *
13
+ * Exit code matrix for `check-drift` (highest severity wins when multiple kinds present):
14
+ * 0 = clean
15
+ * 1 = missing-allow
16
+ * 2 = invalid-default-mode
17
+ * 3 = unknown-key
18
+ * 4 = strips-global-defaultmode
19
+ *
20
+ * Mirrors the existing `handleConfigSubcommand` dispatch pattern at cli.ts.
21
+ */
22
+
23
+ import { resolve } from 'path';
24
+ import { getConfig } from '../config.ts';
25
+ import {
26
+ installPermissions,
27
+ verifyPermissions,
28
+ checkPermissionsDrift,
29
+ type DriftKind,
30
+ } from '../permissions.ts';
31
+ import { runWithManifest } from './install-commands.ts';
32
+
33
+ function resolveClaudeDir(): string {
34
+ let claudeDirName = '.claude';
35
+ try {
36
+ claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
37
+ } catch {
38
+ claudeDirName = '.claude';
39
+ }
40
+ return resolve(process.cwd(), claudeDirName);
41
+ }
42
+
43
+ const DRIFT_KIND_EXIT_CODE: Record<DriftKind, number> = {
44
+ 'missing-allow': 1,
45
+ 'invalid-default-mode': 2,
46
+ 'unknown-key': 3,
47
+ 'strips-global-defaultmode': 4,
48
+ };
49
+
50
+ export async function handlePermissionsSubcommand(
51
+ args: string[],
52
+ ): Promise<{ exitCode: number }> {
53
+ const sub = args[0];
54
+
55
+ switch (sub) {
56
+ case 'install': {
57
+ const claudeDir = resolveClaudeDir();
58
+ const result = runWithManifest(claudeDir, (manifest) =>
59
+ installPermissions(claudeDir, manifest, { silent: false }),
60
+ );
61
+ // Final user-facing summary line on stdout
62
+ if (result.installed > 0) {
63
+ process.stdout.write(
64
+ 'Wrote merged permissions block to .claude/settings.local.json.\n',
65
+ );
66
+ } else if (result.skipped > 0) {
67
+ process.stdout.write('Permissions already in sync — no changes.\n');
68
+ } else if (result.kept > 0) {
69
+ process.stdout.write(
70
+ 'Operator-edited permissions block preserved. Run `npx massu permissions check-drift` to inspect.\n',
71
+ );
72
+ }
73
+ return { exitCode: 0 };
74
+ }
75
+
76
+ case 'verify': {
77
+ const claudeDir = resolveClaudeDir();
78
+ const { missing } = verifyPermissions(claudeDir);
79
+ if (missing.length === 0) {
80
+ process.stdout.write('All MCP allowlist entries present.\n');
81
+ return { exitCode: 0 };
82
+ }
83
+ for (const entry of missing) {
84
+ process.stderr.write(`missing: ${entry}\n`);
85
+ }
86
+ return { exitCode: 1 };
87
+ }
88
+
89
+ case 'check-drift': {
90
+ const claudeDir = resolveClaudeDir();
91
+ const { driftItems } = checkPermissionsDrift(claudeDir);
92
+ if (driftItems.length === 0) {
93
+ process.stdout.write('No permission drift detected.\n');
94
+ return { exitCode: 0 };
95
+ }
96
+ // Highest-severity kind wins for exit code
97
+ let highest = 0;
98
+ for (const item of driftItems) {
99
+ const code = DRIFT_KIND_EXIT_CODE[item.kind];
100
+ if (code > highest) highest = code;
101
+ process.stderr.write(
102
+ `drift[${item.kind}]: ${item.detail} — remediation: ${item.remediation}\n`,
103
+ );
104
+ }
105
+ return { exitCode: highest };
106
+ }
107
+
108
+ case '--help':
109
+ case '-h':
110
+ case undefined: {
111
+ printPermissionsHelp();
112
+ return { exitCode: 0 };
113
+ }
114
+
115
+ default: {
116
+ process.stderr.write(`massu: unknown permissions subcommand: ${sub}\n`);
117
+ printPermissionsHelp();
118
+ return { exitCode: 1 };
119
+ }
120
+ }
121
+ }
122
+
123
+ export function printPermissionsHelp(): void {
124
+ process.stdout.write(`
125
+ massu permissions <subcommand>
126
+
127
+ Subcommands:
128
+ install Seed mcp__massu__* into .claude/settings.local.json's permissions.allow.
129
+ Also propagates global defaultMode (from ~/.claude/settings.json) into
130
+ the project-local file to prevent the merge-replacement trap (see
131
+ https://massu.ai/docs/reference/cli-reference#permissions-trap).
132
+ Idempotent. Preserves operator-edited values.
133
+
134
+ verify Read-only check that all canonical MCP allowlist entries are present.
135
+ Exit 0 if clean, exit 1 with one diagnostic line per missing entry.
136
+
137
+ check-drift Extended diagnostic surfacing 4 drift kinds:
138
+ - missing-allow (exit 1) — canonical entries missing
139
+ - invalid-default-mode (exit 2) — defaultMode requires launch flag
140
+ - unknown-key (exit 3) — undocumented top-level setting
141
+ - strips-global-defaultmode (exit 4) — project-local would strip global value
142
+
143
+ Examples:
144
+ npx massu permissions install
145
+ npx massu permissions verify
146
+ npx massu permissions check-drift
147
+
148
+ Documentation: https://massu.ai/docs/reference/cli-reference#massu-permissions
149
+ `);
150
+ }
@@ -0,0 +1,110 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Shared IO helper for `.claude/settings.local.json` and the user-global
6
+ * `~/.claude/settings.json`. SSOT for read+atomic-write semantics; closes
7
+ * the pre-existing non-atomic-write bug at init.ts installHooks (previously
8
+ * used writeFileSync which was vulnerable to SIGINT-between-truncate-and-write).
9
+ *
10
+ * Three exports:
11
+ * - readSettingsLocal(claudeDir): safe-parse of <claudeDir>/settings.local.json
12
+ * - writeSettingsLocalAtomic(claudeDir, settings): atomic write of same
13
+ * - readSettingsAtPath(absolutePath): generic safe-parse used by readSettingsLocal
14
+ * AND by permissions.ts:readGlobalSettings() reading ~/.claude/settings.json
15
+ *
16
+ * Plus the atomicWriteFile primitive (moved here from install-commands.ts to
17
+ * centralize). All consumers — install-commands manifest save, install-commands
18
+ * per-file syncs, init.ts installHooks, doctor.ts hooks-config check — share
19
+ * this single source of truth for filesystem IO.
20
+ */
21
+
22
+ import {
23
+ closeSync,
24
+ existsSync,
25
+ fsyncSync,
26
+ mkdirSync,
27
+ openSync,
28
+ readFileSync,
29
+ renameSync,
30
+ rmSync,
31
+ writeSync,
32
+ } from 'fs';
33
+ import { dirname, resolve } from 'path';
34
+
35
+ /**
36
+ * Atomic file write — tmp + fsync + rename. Moved from install-commands.ts so
37
+ * it can be reused by settings-local IO without circular import.
38
+ *
39
+ * Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
40
+ * data hits the platter before the rename. On any error, removes the tmp file.
41
+ * Tmp filename includes process.pid to avoid clashes with concurrent installs.
42
+ */
43
+ export function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
44
+ const tmpPath = `${targetPath}.${process.pid}.tmp`;
45
+ try {
46
+ const fd = openSync(tmpPath, 'w', mode);
47
+ try {
48
+ const buf = Buffer.from(content, 'utf-8');
49
+ writeSync(fd, buf, 0, buf.length, 0);
50
+ fsyncSync(fd);
51
+ } finally {
52
+ closeSync(fd);
53
+ }
54
+ renameSync(tmpPath, targetPath);
55
+ } catch (err) {
56
+ if (existsSync(tmpPath)) {
57
+ try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
58
+ }
59
+ throw err;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Generic safe-parse of a JSON settings file. Used by readSettingsLocal
65
+ * (project-local) AND permissions.ts:readGlobalSettings (user-global).
66
+ *
67
+ * Returns `{}` on any failure path: missing file, unreadable, malformed JSON,
68
+ * non-object root. This contract lets callers do `(settings.permissions as any)`
69
+ * shape-checks defensively without a separate "does the file exist" pre-check.
70
+ */
71
+ export function readSettingsAtPath(absolutePath: string): Record<string, unknown> {
72
+ if (!existsSync(absolutePath)) {
73
+ return {};
74
+ }
75
+ try {
76
+ const raw = readFileSync(absolutePath, 'utf-8');
77
+ const parsed = JSON.parse(raw);
78
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
79
+ return {};
80
+ }
81
+ return parsed as Record<string, unknown>;
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read `<claudeDir>/settings.local.json`. Returns `{}` on missing/corrupt.
89
+ */
90
+ export function readSettingsLocal(claudeDir: string): Record<string, unknown> {
91
+ return readSettingsAtPath(resolve(claudeDir, 'settings.local.json'));
92
+ }
93
+
94
+ /**
95
+ * Atomically write `<claudeDir>/settings.local.json`. Creates the parent
96
+ * directory if missing. JSON.stringify with 2-space indent + trailing newline
97
+ * (matches the existing convention in init.ts installHooks).
98
+ */
99
+ export function writeSettingsLocalAtomic(
100
+ claudeDir: string,
101
+ settings: Record<string, unknown>,
102
+ ): void {
103
+ const targetPath = resolve(claudeDir, 'settings.local.json');
104
+ const dir = dirname(targetPath);
105
+ if (!existsSync(dir)) {
106
+ mkdirSync(dir, { recursive: true });
107
+ }
108
+ const content = JSON.stringify(settings, null, 2) + '\n';
109
+ atomicWriteFile(targetPath, content);
110
+ }
@@ -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-12T04:00:39.397Z.
1
+ // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-15T03:59:04.298Z.
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