@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@massu/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
|
|
6
6
|
"main": "src/server.ts",
|
package/src/cli.ts
CHANGED
|
@@ -47,6 +47,12 @@ async function main(): Promise<void> {
|
|
|
47
47
|
await runInstallCommands();
|
|
48
48
|
break;
|
|
49
49
|
}
|
|
50
|
+
case 'permissions': {
|
|
51
|
+
const { handlePermissionsSubcommand } = await import('./commands/permissions.ts');
|
|
52
|
+
const result = await handlePermissionsSubcommand(args.slice(1));
|
|
53
|
+
process.exit(result.exitCode);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
50
56
|
case 'show-template': {
|
|
51
57
|
const { runShowTemplate } = await import('./commands/show-template.ts');
|
|
52
58
|
await runShowTemplate(args.slice(1));
|
|
@@ -162,12 +168,13 @@ Commands:
|
|
|
162
168
|
init Set up Massu AI in your project (one command, full setup)
|
|
163
169
|
doctor Check installation health
|
|
164
170
|
install-hooks Install/update Claude Code hooks
|
|
165
|
-
install-commands Install/update slash commands
|
|
171
|
+
install-commands Install/update slash commands (use --skip-permissions to opt out of MCP allowlist seeding)
|
|
166
172
|
show-template Print the resolved variant of a bundled template (e.g. for diffs)
|
|
167
173
|
watch Run the file-watcher daemon (auto-refresh on stack changes)
|
|
168
174
|
refresh-log [N] Show the last N watcher auto-refresh events
|
|
169
175
|
validate-config Validate massu.config.yaml (alias: config validate)
|
|
170
176
|
config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
|
|
177
|
+
permissions <sub> MCP permission lifecycle: install | verify | check-drift
|
|
171
178
|
adapters <sub> Third-party adapter registry: list | refresh | search | add-local | remove-local | install | resign
|
|
172
179
|
|
|
173
180
|
Options:
|
package/src/commands/doctor.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'url';
|
|
|
23
23
|
import { parse as parseYaml } from 'yaml';
|
|
24
24
|
import { getConfig, getResolvedPaths } from '../config.ts';
|
|
25
25
|
import { getCurrentTier, getLicenseInfo, daysUntilExpiry } from '../license.ts';
|
|
26
|
+
import { readSettingsAtPath } from '../lib/settings-local.ts';
|
|
26
27
|
|
|
27
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
29
|
const __dirname = dirname(__filename);
|
|
@@ -103,7 +104,7 @@ function checkHooksConfig(projectRoot: string): CheckResult {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
try {
|
|
106
|
-
const content =
|
|
107
|
+
const content = readSettingsAtPath(settingsPath);
|
|
107
108
|
if (!content.hooks) {
|
|
108
109
|
return { name: 'Hooks Config', status: 'fail', detail: 'No hooks configured. Run: npx massu install-hooks' };
|
|
109
110
|
}
|
|
@@ -238,8 +239,8 @@ function checkShellHooksWired(_projectRoot: string): CheckResult {
|
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
try {
|
|
241
|
-
const content =
|
|
242
|
-
const hooks = content.hooks ?? {}
|
|
242
|
+
const content = readSettingsAtPath(settingsPath);
|
|
243
|
+
const hooks = (content.hooks ?? {}) as Record<string, unknown>;
|
|
243
244
|
const hasSessionStart = Array.isArray(hooks.SessionStart) && hooks.SessionStart.length > 0;
|
|
244
245
|
const hasPreToolUse = Array.isArray(hooks.PreToolUse) && hooks.PreToolUse.length > 0;
|
|
245
246
|
if (!hasSessionStart && !hasPreToolUse) {
|
package/src/commands/init.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
|
|
34
34
|
import { backfillMemoryFiles } from '../memory-file-ingest.ts';
|
|
35
35
|
import { getConfig, resetConfig } from '../config.ts';
|
|
36
36
|
import { installAll } from './install-commands.ts';
|
|
37
|
+
import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
|
|
37
38
|
import {
|
|
38
39
|
runDetection,
|
|
39
40
|
type DetectionResult,
|
|
@@ -1107,20 +1108,12 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
|
|
|
1107
1108
|
claudeDirName = '.claude';
|
|
1108
1109
|
}
|
|
1109
1110
|
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
1110
|
-
const settingsPath = resolve(claudeDir, 'settings.local.json');
|
|
1111
1111
|
|
|
1112
1112
|
if (!existsSync(claudeDir)) {
|
|
1113
1113
|
mkdirSync(claudeDir, { recursive: true });
|
|
1114
1114
|
}
|
|
1115
1115
|
|
|
1116
|
-
|
|
1117
|
-
if (existsSync(settingsPath)) {
|
|
1118
|
-
try {
|
|
1119
|
-
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
1120
|
-
} catch {
|
|
1121
|
-
settings = {};
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1116
|
+
const settings = readSettingsLocal(claudeDir);
|
|
1124
1117
|
|
|
1125
1118
|
const hooksDir = resolveHooksDir();
|
|
1126
1119
|
const hooksConfig = buildHooksConfig(hooksDir);
|
|
@@ -1134,7 +1127,7 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
|
|
|
1134
1127
|
|
|
1135
1128
|
settings.hooks = hooksConfig;
|
|
1136
1129
|
|
|
1137
|
-
|
|
1130
|
+
writeSettingsLocalAtomic(claudeDir, settings);
|
|
1138
1131
|
|
|
1139
1132
|
return { installed: true, count: hookCount };
|
|
1140
1133
|
}
|
|
@@ -20,17 +20,11 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
-
closeSync,
|
|
24
23
|
existsSync,
|
|
25
|
-
fsyncSync,
|
|
26
|
-
openSync,
|
|
27
24
|
readFileSync,
|
|
28
|
-
rmSync,
|
|
29
|
-
writeSync,
|
|
30
25
|
mkdirSync,
|
|
31
26
|
readdirSync,
|
|
32
27
|
statSync,
|
|
33
|
-
renameSync,
|
|
34
28
|
} from 'fs';
|
|
35
29
|
import { resolve, dirname, relative, join } from 'path';
|
|
36
30
|
import { fileURLToPath } from 'url';
|
|
@@ -38,6 +32,8 @@ import { createHash } from 'crypto';
|
|
|
38
32
|
import { getConfig } from '../config.ts';
|
|
39
33
|
import type { Config } from '../config.ts';
|
|
40
34
|
import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
|
|
35
|
+
import { atomicWriteFile } from '../lib/settings-local.ts';
|
|
36
|
+
import { installPermissions } from '../permissions.ts';
|
|
41
37
|
|
|
42
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
43
39
|
const __dirname = dirname(__filename);
|
|
@@ -131,47 +127,7 @@ export function loadManifest(claudeDir: string): Manifest {
|
|
|
131
127
|
}
|
|
132
128
|
}
|
|
133
129
|
|
|
134
|
-
/**
|
|
135
|
-
* Iter-7 fix: atomic file write — tmp + fsync + rename.
|
|
136
|
-
*
|
|
137
|
-
* Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
|
|
138
|
-
* editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
|
|
139
|
-
* AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
|
|
140
|
-
* refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
|
|
141
|
-
* then renameSync") both demand this. Previously installAll's per-file writes
|
|
142
|
-
* (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
|
|
143
|
-
* SIGINT/power-loss between truncate and complete-write left a partial file.
|
|
144
|
-
* Now both go through this helper so the watcher's iter-6 "we don't await
|
|
145
|
-
* fireRefresh because atomic-rename covers everything" decision is sound.
|
|
146
|
-
*
|
|
147
|
-
* Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
|
|
148
|
-
* data hits the platter before the rename. On any error, removes the tmp file.
|
|
149
|
-
* Tmp filename includes process.pid to avoid clashes with concurrent installs
|
|
150
|
-
* from sibling processes (e.g. a manual `npx massu config refresh` racing the
|
|
151
|
-
* watcher daemon — the install-lock should prevent this, but the file-level
|
|
152
|
-
* tmp name disambiguates if it ever happens).
|
|
153
|
-
*/
|
|
154
|
-
function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
|
|
155
|
-
const tmpPath = `${targetPath}.${process.pid}.tmp`;
|
|
156
|
-
try {
|
|
157
|
-
const fd = openSync(tmpPath, 'w', mode);
|
|
158
|
-
try {
|
|
159
|
-
const buf = Buffer.from(content, 'utf-8');
|
|
160
|
-
writeSync(fd, buf, 0, buf.length, 0);
|
|
161
|
-
fsyncSync(fd);
|
|
162
|
-
} finally {
|
|
163
|
-
closeSync(fd);
|
|
164
|
-
}
|
|
165
|
-
renameSync(tmpPath, targetPath);
|
|
166
|
-
} catch (err) {
|
|
167
|
-
if (existsSync(tmpPath)) {
|
|
168
|
-
try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
|
|
169
|
-
}
|
|
170
|
-
throw err;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Write the manifest atomically: tempfile + fsync + renameSync. */
|
|
130
|
+
/** Write the manifest atomically: tempfile + fsync + renameSync (uses shared lib/settings-local.ts:atomicWriteFile). */
|
|
175
131
|
export function saveManifest(claudeDir: string, manifest: Manifest): void {
|
|
176
132
|
const dir = resolve(claudeDir, '.massu');
|
|
177
133
|
if (!existsSync(dir)) {
|
|
@@ -541,7 +497,15 @@ export function buildTemplateVars(): Record<string, unknown> {
|
|
|
541
497
|
};
|
|
542
498
|
}
|
|
543
499
|
|
|
544
|
-
export
|
|
500
|
+
export interface InstallCommandsOptions {
|
|
501
|
+
/** When true, skip seeding `mcp__massu__*` into permissions.allow. */
|
|
502
|
+
skipPermissions?: boolean;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function installCommands(
|
|
506
|
+
projectRoot: string,
|
|
507
|
+
opts: InstallCommandsOptions = {},
|
|
508
|
+
): InstallCommandsResult {
|
|
545
509
|
const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
|
|
546
510
|
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
547
511
|
const targetDir = resolve(claudeDir, 'commands');
|
|
@@ -559,9 +523,21 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
|
|
|
559
523
|
|
|
560
524
|
const framework = getConfig().framework;
|
|
561
525
|
const templateVars = buildTemplateVars();
|
|
562
|
-
const stats = runWithManifest(claudeDir, (manifest) =>
|
|
563
|
-
|
|
564
|
-
|
|
526
|
+
const stats = runWithManifest(claudeDir, (manifest) => {
|
|
527
|
+
const syncStats = syncDirectory(
|
|
528
|
+
sourceDir,
|
|
529
|
+
targetDir,
|
|
530
|
+
framework,
|
|
531
|
+
manifest,
|
|
532
|
+
'commands',
|
|
533
|
+
true,
|
|
534
|
+
templateVars,
|
|
535
|
+
);
|
|
536
|
+
if (!opts.skipPermissions) {
|
|
537
|
+
installPermissions(claudeDir, manifest, { silent: true });
|
|
538
|
+
}
|
|
539
|
+
return syncStats;
|
|
540
|
+
});
|
|
565
541
|
return { ...stats, commandsDir: targetDir };
|
|
566
542
|
}
|
|
567
543
|
|
|
@@ -576,9 +552,19 @@ export interface InstallAllResult {
|
|
|
576
552
|
totalSkipped: number;
|
|
577
553
|
totalKept: number;
|
|
578
554
|
claudeDir: string;
|
|
555
|
+
/** Permission-seeding outcome (undefined when --skip-permissions). */
|
|
556
|
+
permissions?: { installed: number; kept: number; skipped: number };
|
|
579
557
|
}
|
|
580
558
|
|
|
581
|
-
export
|
|
559
|
+
export interface InstallAllOptions {
|
|
560
|
+
/** When true, skip seeding `mcp__massu__*` into permissions.allow. */
|
|
561
|
+
skipPermissions?: boolean;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function installAll(
|
|
565
|
+
projectRoot: string,
|
|
566
|
+
opts: InstallAllOptions = {},
|
|
567
|
+
): InstallAllResult {
|
|
582
568
|
const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
|
|
583
569
|
const claudeDir = resolve(projectRoot, claudeDirName);
|
|
584
570
|
|
|
@@ -587,6 +573,7 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
587
573
|
let totalUpdated = 0;
|
|
588
574
|
let totalSkipped = 0;
|
|
589
575
|
let totalKept = 0;
|
|
576
|
+
let permissionsResult: { installed: number; kept: number; skipped: number } | undefined;
|
|
590
577
|
|
|
591
578
|
const framework = getConfig().framework;
|
|
592
579
|
const templateVars = buildTemplateVars();
|
|
@@ -613,6 +600,9 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
613
600
|
totalSkipped += stats.skipped;
|
|
614
601
|
totalKept += stats.kept;
|
|
615
602
|
}
|
|
603
|
+
if (!opts.skipPermissions) {
|
|
604
|
+
permissionsResult = installPermissions(claudeDir, manifest, { silent: true });
|
|
605
|
+
}
|
|
616
606
|
});
|
|
617
607
|
|
|
618
608
|
return {
|
|
@@ -622,6 +612,7 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
622
612
|
totalSkipped,
|
|
623
613
|
totalKept,
|
|
624
614
|
claudeDir,
|
|
615
|
+
permissions: permissionsResult,
|
|
625
616
|
};
|
|
626
617
|
}
|
|
627
618
|
|
|
@@ -631,13 +622,14 @@ export function installAll(projectRoot: string): InstallAllResult {
|
|
|
631
622
|
|
|
632
623
|
export async function runInstallCommands(): Promise<void> {
|
|
633
624
|
const projectRoot = process.cwd();
|
|
625
|
+
const skipPermissions = process.argv.slice(2).includes('--skip-permissions');
|
|
634
626
|
|
|
635
627
|
console.log('');
|
|
636
628
|
console.log('Massu AI - Install Project Assets');
|
|
637
629
|
console.log('==================================');
|
|
638
630
|
console.log('');
|
|
639
631
|
|
|
640
|
-
const result = installAll(projectRoot);
|
|
632
|
+
const result = installAll(projectRoot, { skipPermissions });
|
|
641
633
|
|
|
642
634
|
// Report per-asset-type
|
|
643
635
|
for (const assetType of ASSET_TYPES) {
|
|
@@ -667,6 +659,23 @@ export async function runInstallCommands(): Promise<void> {
|
|
|
667
659
|
` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
|
|
668
660
|
);
|
|
669
661
|
}
|
|
662
|
+
|
|
663
|
+
// Permission seeding outcome line
|
|
664
|
+
if (skipPermissions) {
|
|
665
|
+
console.log(' Permission seeding skipped (--skip-permissions).');
|
|
666
|
+
} else if (result.permissions) {
|
|
667
|
+
if (result.permissions.installed > 0) {
|
|
668
|
+
console.log(
|
|
669
|
+
` Wrote merged permissions block to .claude/settings.local.json (use --skip-permissions to opt out).`,
|
|
670
|
+
);
|
|
671
|
+
} else if (result.permissions.kept > 0) {
|
|
672
|
+
console.log(
|
|
673
|
+
` MCP allowlist entry was edited by operator; preserved. Use \`npx massu permissions check-drift\` to inspect.`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
// skipped:1 → silent (already in sync, no operator-visible change)
|
|
677
|
+
}
|
|
678
|
+
|
|
670
679
|
console.log('');
|
|
671
680
|
console.log(' Restart your Claude Code session to use them.');
|
|
672
681
|
console.log('');
|
|
@@ -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
|
+
}
|