@massu/core 1.4.0-soak.0 → 1.4.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/commands/README.md +0 -3
- package/dist/cli.js +11 -3
- package/dist/hooks/auto-learning-pipeline.js +9 -1
- package/dist/hooks/classify-failure.js +9 -1
- package/dist/hooks/cost-tracker.js +9 -1
- package/dist/hooks/fix-detector.js +9 -1
- package/dist/hooks/incident-pipeline.js +9 -1
- package/dist/hooks/post-edit-context.js +9 -1
- package/dist/hooks/post-tool-use.js +9 -1
- package/dist/hooks/pre-compact.js +9 -1
- package/dist/hooks/pre-delete-check.js +9 -1
- package/dist/hooks/quality-event.js +9 -1
- package/dist/hooks/rule-enforcement-pipeline.js +9 -1
- package/dist/hooks/session-end.js +9 -1
- package/dist/hooks/session-start.js +9 -1
- package/dist/hooks/user-prompt.js +9 -1
- package/package.json +4 -1
- package/src/commands/config-refresh.ts +3 -3
- package/src/commands/init.ts +1 -1
- package/src/commands/template-engine.ts +0 -2
- package/src/commands/watch.ts +1 -1
- package/src/config.ts +8 -0
- package/src/detect/adapters/tree-sitter-loader.ts +121 -2
- package/src/detect/adapters/types.ts +1 -2
- package/src/detect/migrate.ts +4 -4
- package/src/lsp/auto-detect.ts +10 -1
- package/src/lsp/client.ts +188 -2
- package/src/watch/daemon.ts +1 -1
- package/src/watch/paths.ts +2 -2
package/commands/README.md
CHANGED
|
@@ -127,11 +127,8 @@ Pass `--skip-commands` to `massu init` or `massu refresh` to suppress command in
|
|
|
127
127
|
2. The default `<base>.md` should remain generic (or be regenerated if it was previously stack-specific — see `massu-scaffold-page.md` for the pattern of an embedded multi-stack default).
|
|
128
128
|
3. Add a row to the table in section 5.
|
|
129
129
|
4. Add a test case to `packages/core/src/__tests__/install-commands.test.ts` that exercises the new variant against a fixture config.
|
|
130
|
-
5. Update `docs/internal/2026-04-26-template-variant-audit.md` (the audit doc) with the new label.
|
|
131
130
|
|
|
132
131
|
## 7. Reference
|
|
133
132
|
|
|
134
|
-
- Plan: `/Users/ekoultra/hedge/docs/plans/2026-04-26-massu-stack-aware-command-templates.md`
|
|
135
|
-
- Audit: `docs/internal/2026-04-26-template-variant-audit.md`
|
|
136
133
|
- Implementation: `packages/core/src/commands/install-commands.ts`
|
|
137
134
|
- Tests: `packages/core/src/__tests__/install-commands.test.ts` and `show-template.test.ts`
|
package/dist/cli.js
CHANGED
|
@@ -490,7 +490,15 @@ var init_config = __esm({
|
|
|
490
490
|
enabled: z.boolean().default(false),
|
|
491
491
|
servers: z.array(z.object({
|
|
492
492
|
language: z.string(),
|
|
493
|
-
command: z.string()
|
|
493
|
+
command: z.string(),
|
|
494
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
495
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
496
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
497
|
+
allow_setuid: z.boolean().default(false),
|
|
498
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
499
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
500
|
+
// Set to 0 to disable the watchdog for this server.
|
|
501
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
494
502
|
})).default([]),
|
|
495
503
|
autoDetect: z.object({
|
|
496
504
|
viaPortScan: z.boolean().default(false)
|
|
@@ -18050,7 +18058,7 @@ var init_paths = __esm({
|
|
|
18050
18058
|
"**/.mypy_cache/**",
|
|
18051
18059
|
// Plan 3a hotfix 2026-05-02: high-churn directories that are never
|
|
18052
18060
|
// legitimate stack-detection inputs and produced sustained 30-100% CPU
|
|
18053
|
-
// when watched on
|
|
18061
|
+
// when watched on a large monorepo (62K files / 42 GB tree).
|
|
18054
18062
|
"**/.next/**",
|
|
18055
18063
|
"**/coverage/**",
|
|
18056
18064
|
"**/logs/**",
|
|
@@ -18058,7 +18066,7 @@ var init_paths = __esm({
|
|
|
18058
18066
|
// Runtime data dirs. Convention across Python/JS/Rust ecosystems is
|
|
18059
18067
|
// that `data/` holds runtime artifacts (caches, snapshots, model
|
|
18060
18068
|
// checkpoints, downloaded fixtures) that change frequently but are
|
|
18061
|
-
// never stack-detection inputs.
|
|
18069
|
+
// never stack-detection inputs. A large monorepo had 135K files in
|
|
18062
18070
|
// apps/ai-service/data alone, dwarfing legitimate source. If a
|
|
18063
18071
|
// project genuinely uses `data/` for source content, opt into
|
|
18064
18072
|
// `watch.scope: 'full'` and `watch.paths_full_root_opt_in: true`.
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -295,7 +295,15 @@ var LSPConfigSchema = z.object({
|
|
|
295
295
|
enabled: z.boolean().default(false),
|
|
296
296
|
servers: z.array(z.object({
|
|
297
297
|
language: z.string(),
|
|
298
|
-
command: z.string()
|
|
298
|
+
command: z.string(),
|
|
299
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
300
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
301
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
302
|
+
allow_setuid: z.boolean().default(false),
|
|
303
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
304
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
305
|
+
// Set to 0 to disable the watchdog for this server.
|
|
306
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
299
307
|
})).default([]),
|
|
300
308
|
autoDetect: z.object({
|
|
301
309
|
viaPortScan: z.boolean().default(false)
|
|
@@ -294,7 +294,15 @@ var LSPConfigSchema = z.object({
|
|
|
294
294
|
enabled: z.boolean().default(false),
|
|
295
295
|
servers: z.array(z.object({
|
|
296
296
|
language: z.string(),
|
|
297
|
-
command: z.string()
|
|
297
|
+
command: z.string(),
|
|
298
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
299
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
300
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
301
|
+
allow_setuid: z.boolean().default(false),
|
|
302
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
303
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
304
|
+
// Set to 0 to disable the watchdog for this server.
|
|
305
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
298
306
|
})).default([]),
|
|
299
307
|
autoDetect: z.object({
|
|
300
308
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -295,7 +295,15 @@ var LSPConfigSchema = z.object({
|
|
|
295
295
|
enabled: z.boolean().default(false),
|
|
296
296
|
servers: z.array(z.object({
|
|
297
297
|
language: z.string(),
|
|
298
|
-
command: z.string()
|
|
298
|
+
command: z.string(),
|
|
299
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
300
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
301
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
302
|
+
allow_setuid: z.boolean().default(false),
|
|
303
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
304
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
305
|
+
// Set to 0 to disable the watchdog for this server.
|
|
306
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
299
307
|
})).default([]),
|
|
300
308
|
autoDetect: z.object({
|
|
301
309
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -294,7 +294,15 @@ var LSPConfigSchema = z.object({
|
|
|
294
294
|
enabled: z.boolean().default(false),
|
|
295
295
|
servers: z.array(z.object({
|
|
296
296
|
language: z.string(),
|
|
297
|
-
command: z.string()
|
|
297
|
+
command: z.string(),
|
|
298
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
299
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
300
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
301
|
+
allow_setuid: z.boolean().default(false),
|
|
302
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
303
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
304
|
+
// Set to 0 to disable the watchdog for this server.
|
|
305
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
298
306
|
})).default([]),
|
|
299
307
|
autoDetect: z.object({
|
|
300
308
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
|
@@ -6072,7 +6072,15 @@ var LSPConfigSchema = z.object({
|
|
|
6072
6072
|
enabled: z.boolean().default(false),
|
|
6073
6073
|
servers: z.array(z.object({
|
|
6074
6074
|
language: z.string(),
|
|
6075
|
-
command: z.string()
|
|
6075
|
+
command: z.string(),
|
|
6076
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
6077
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
6078
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
6079
|
+
allow_setuid: z.boolean().default(false),
|
|
6080
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
6081
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
6082
|
+
// Set to 0 to disable the watchdog for this server.
|
|
6083
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
6076
6084
|
})).default([]),
|
|
6077
6085
|
autoDetect: z.object({
|
|
6078
6086
|
viaPortScan: z.boolean().default(false)
|
|
@@ -296,7 +296,15 @@ var LSPConfigSchema = z.object({
|
|
|
296
296
|
enabled: z.boolean().default(false),
|
|
297
297
|
servers: z.array(z.object({
|
|
298
298
|
language: z.string(),
|
|
299
|
-
command: z.string()
|
|
299
|
+
command: z.string(),
|
|
300
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
301
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
302
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
303
|
+
allow_setuid: z.boolean().default(false),
|
|
304
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
305
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
306
|
+
// Set to 0 to disable the watchdog for this server.
|
|
307
|
+
max_rss_mb: z.number().int().nonnegative().default(1024)
|
|
300
308
|
})).default([]),
|
|
301
309
|
autoDetect: z.object({
|
|
302
310
|
viaPortScan: z.boolean().default(false)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@massu/core",
|
|
3
|
-
"version": "1.4.0
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"fast-glob": "^3.3.0",
|
|
24
24
|
"proper-lockfile": "^4.1.2",
|
|
25
25
|
"smol-toml": "^1.3.0",
|
|
26
|
+
"tar": "^7.4.3",
|
|
27
|
+
"tweetnacl": "^1.0.3",
|
|
26
28
|
"vscode-languageserver-protocol": "^3.17.5",
|
|
27
29
|
"web-tree-sitter": "^0.26.8",
|
|
28
30
|
"yaml": "^2.4.0",
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
"dist/**/*",
|
|
49
51
|
"commands/**/*",
|
|
50
52
|
"agents/**/*",
|
|
53
|
+
"docs/**/*",
|
|
51
54
|
"patterns/**/*",
|
|
52
55
|
"protocols/**/*",
|
|
53
56
|
"reference/**/*",
|
|
@@ -179,7 +179,7 @@ export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConf
|
|
|
179
179
|
// paths.aliases is a 2-level-nested user block. Detector always writes
|
|
180
180
|
// { '@': <source-dir> }; user-authored alias map must survive. Spread user
|
|
181
181
|
// over detector so user keys win for any overlap AND user-only keys survive.
|
|
182
|
-
// (P5-002 discovery —
|
|
182
|
+
// (P5-002 discovery — a downstream consumer's paths.aliases['@'] was being overwritten.)
|
|
183
183
|
const existingPaths = existing.paths;
|
|
184
184
|
const outPaths = out.paths;
|
|
185
185
|
if (
|
|
@@ -203,9 +203,9 @@ export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConf
|
|
|
203
203
|
|
|
204
204
|
// verification is the other 2-level-nested detector-owned block. Semantics
|
|
205
205
|
// mirror migrate.ts:132-138 buildVerificationBlock: user's custom language
|
|
206
|
-
// sections (e.g.,
|
|
206
|
+
// sections (e.g., a multi-runtime monorepo's `gateway`, `ios`, `runtime`, `web`) survive
|
|
207
207
|
// wholesale; user's command overrides on shared languages (e.g., `python`)
|
|
208
|
-
// win over detector defaults. (P5-002 discovery —
|
|
208
|
+
// win over detector defaults. (P5-002 discovery — a downstream consumer was losing 15
|
|
209
209
|
// verification command entries across 4 custom language sections plus
|
|
210
210
|
// having 4 python commands overwritten with detector defaults.)
|
|
211
211
|
const existingVer = existing.verification;
|
package/src/commands/init.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `massu init` — One-command, detection-driven project setup.
|
|
6
6
|
*
|
|
7
7
|
* Phase 3 rewrite (2026-04-19): replaces the JS/TS template copier (old
|
|
8
|
-
* detectFramework/generateConfig path, root cause of
|
|
8
|
+
* detectFramework/generateConfig path, root cause of multi-runtime stale-config drift)
|
|
9
9
|
* with a flow that runs the Phase 1 detection engine (`runDetection`) and
|
|
10
10
|
* generates a v2 schema_version=2 `massu.config.yaml` that reflects the
|
|
11
11
|
* actual repo layout (languages, source_dirs, verification commands, domains).
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Massu codebase-aware templating engine — string substitution only.
|
|
6
6
|
*
|
|
7
|
-
* Spec: docs/internal/2026-04-26-codebase-aware-templates-spec.md
|
|
8
|
-
*
|
|
9
7
|
* Grammar (the entire surface):
|
|
10
8
|
* {{path.to.var}} Look up + render
|
|
11
9
|
* {{path.to.var | default("fallback")}} Look up; use literal on miss
|
package/src/commands/watch.ts
CHANGED
|
@@ -72,7 +72,7 @@ function parseFlags(args: string[]): ParsedFlags {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function findClaudeBg(): string | null {
|
|
75
|
-
// Prefer the
|
|
75
|
+
// Prefer the conventional ~/.claude/bin install path; fall back to PATH.
|
|
76
76
|
const home = process.env.HOME ?? '';
|
|
77
77
|
const fixed = home ? resolve(home, '.claude', 'bin', 'claude-bg') : null;
|
|
78
78
|
if (fixed && existsSync(fixed)) return fixed;
|
package/src/config.ts
CHANGED
|
@@ -383,6 +383,14 @@ export const LSPConfigSchema = z.object({
|
|
|
383
383
|
servers: z.array(z.object({
|
|
384
384
|
language: z.string(),
|
|
385
385
|
command: z.string(),
|
|
386
|
+
// F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
|
|
387
|
+
// binaries. Default false — argv[0] with the SUID bit is rejected
|
|
388
|
+
// unless this is true. Decision is auditable in the YAML.
|
|
389
|
+
allow_setuid: z.boolean().default(false),
|
|
390
|
+
// F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
|
|
391
|
+
// SIGKILLs the server after sustained breach. Default 1024 MB.
|
|
392
|
+
// Set to 0 to disable the watchdog for this server.
|
|
393
|
+
max_rss_mb: z.number().int().nonnegative().default(1024),
|
|
386
394
|
})).default([]),
|
|
387
395
|
autoDetect: z.object({
|
|
388
396
|
viaPortScan: z.boolean().default(false),
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Plan 3b — Phase 1: Tree-sitter WASM grammar loader (Strategy A).
|
|
6
6
|
*
|
|
7
|
-
* Strategy A
|
|
8
|
-
* §1, §8): grammars are NOT bundled in the npm tarball. The loader downloads
|
|
7
|
+
* Strategy A: grammars are NOT bundled in the npm tarball. The loader downloads
|
|
9
8
|
* each requested grammar at first use from a pinned URL, verifies SHA-256
|
|
10
9
|
* against a hardcoded manifest, caches under `~/.massu/wasm-cache/`.
|
|
11
10
|
*
|
|
@@ -25,12 +24,14 @@
|
|
|
25
24
|
import { createHash } from 'crypto';
|
|
26
25
|
import {
|
|
27
26
|
mkdirSync,
|
|
27
|
+
readdirSync,
|
|
28
28
|
readFileSync,
|
|
29
29
|
writeFileSync,
|
|
30
30
|
renameSync,
|
|
31
31
|
unlinkSync,
|
|
32
32
|
lstatSync,
|
|
33
33
|
chmodSync,
|
|
34
|
+
utimesSync,
|
|
34
35
|
} from 'fs';
|
|
35
36
|
import { homedir } from 'os';
|
|
36
37
|
import { dirname, join } from 'path';
|
|
@@ -174,6 +175,118 @@ function getCachedPath(language: TreeSitterLanguage, sha: string): string {
|
|
|
174
175
|
return join(getCacheDir(), `${language}-${sha}.wasm`);
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
// ============================================================
|
|
179
|
+
// LRU cache eviction (Phase 3.5 audit F-011 — closed 2026-05-06)
|
|
180
|
+
// ============================================================
|
|
181
|
+
//
|
|
182
|
+
// F-011 was deferred at v1 ("at ~3MB per grammar, full cache footprint is
|
|
183
|
+
// <100MB — not an attack vector"). The 2026-05-06 audit-leak retrospective
|
|
184
|
+
// elevated it: now that the cache path + naming convention are publicly
|
|
185
|
+
// known (the security audit doc was visible for 9 days), opportunistic
|
|
186
|
+
// disk-fill attacks become slightly less hypothetical, AND the cost of
|
|
187
|
+
// retrofitting LRU once Plan 3c expands the supported grammar set is
|
|
188
|
+
// strictly higher than doing it now while only 4 grammars exist.
|
|
189
|
+
//
|
|
190
|
+
// Eviction rule: keep the N most-recently-USED entries (mtime, updated by
|
|
191
|
+
// the cache-hit path on every read). Default cap = 16 — leaves headroom
|
|
192
|
+
// for Plan 3c's 31-grammar expansion plus dev-time version churn, while
|
|
193
|
+
// bounding total cache to ~50MB at 3MB/grammar.
|
|
194
|
+
|
|
195
|
+
const DEFAULT_CACHE_RETAIN_COUNT = 16;
|
|
196
|
+
|
|
197
|
+
function getCacheRetainCount(): number {
|
|
198
|
+
const env = process.env.MASSU_WASM_CACHE_RETAIN;
|
|
199
|
+
if (env) {
|
|
200
|
+
const n = Number(env);
|
|
201
|
+
if (Number.isFinite(n) && n >= 1 && n <= 1024) return Math.floor(n);
|
|
202
|
+
}
|
|
203
|
+
return DEFAULT_CACHE_RETAIN_COUNT;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Touch a cache file's mtime to mark "most recently used." Called on every
|
|
208
|
+
* cache-hit. Best-effort: any failure is silently swallowed — touching is
|
|
209
|
+
* an optimization signal for eviction, not load-bearing.
|
|
210
|
+
*
|
|
211
|
+
* Uses utimes via writeFileSync round-trip would be expensive; instead we
|
|
212
|
+
* use the same filesystem touch trick as `touch -a`: open + close. On
|
|
213
|
+
* macOS/Linux Node, `chmodSync` to the same mode does NOT update mtime,
|
|
214
|
+
* so we do a no-op write of empty content via a tmp marker file. Cheaper
|
|
215
|
+
* approach: just rely on atime if filesystem records it. Most modern
|
|
216
|
+
* filesystems are mounted with `relatime` so atime updates only when
|
|
217
|
+
* older than mtime — which means after our first eviction-relevant
|
|
218
|
+
* read, atime IS the right signal.
|
|
219
|
+
*
|
|
220
|
+
* Decision: use mtime via `utimesSync` — explicit and portable.
|
|
221
|
+
*/
|
|
222
|
+
function touchCacheFile(path: string): void {
|
|
223
|
+
try {
|
|
224
|
+
const now = new Date();
|
|
225
|
+
utimesSync(path, now, now);
|
|
226
|
+
} catch {
|
|
227
|
+
// best-effort
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Evict cache entries beyond the retain count, keeping the N most recently
|
|
233
|
+
* used (by mtime). Called after every successful cache write. Best-effort:
|
|
234
|
+
* eviction failure never blocks a load.
|
|
235
|
+
*
|
|
236
|
+
* Rejects symlinks and non-regular files via lstat — the same defense as
|
|
237
|
+
* the cache-hit path (F-008 fix). A symlink in the cache dir is logged
|
|
238
|
+
* as a security warning but not deleted (don't act on attacker-controlled
|
|
239
|
+
* paths automatically).
|
|
240
|
+
*/
|
|
241
|
+
function evictBeyondRetainCount(retain: number = getCacheRetainCount()): void {
|
|
242
|
+
const dir = getCacheDir();
|
|
243
|
+
let entries: string[];
|
|
244
|
+
try {
|
|
245
|
+
entries = readdirSync(dir);
|
|
246
|
+
} catch {
|
|
247
|
+
return; // Dir doesn't exist yet; nothing to evict.
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const candidates: { path: string; mtimeMs: number }[] = [];
|
|
251
|
+
for (const name of entries) {
|
|
252
|
+
if (!name.endsWith('.wasm')) continue; // Don't touch non-grammar files.
|
|
253
|
+
const path = join(dir, name);
|
|
254
|
+
let stat;
|
|
255
|
+
try {
|
|
256
|
+
stat = lstatSync(path);
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
261
|
+
// Skip — never automatically delete what could be an attacker-placed
|
|
262
|
+
// symlink. Surface via stderr; user's cache dir is suspect.
|
|
263
|
+
console.error(
|
|
264
|
+
`[tree-sitter-loader] cache eviction skipped non-regular file: ${path} ` +
|
|
265
|
+
`(possible symlink attack — see Phase 3.5 finding F-008).`,
|
|
266
|
+
);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
candidates.push({ path, mtimeMs: stat.mtimeMs });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (candidates.length <= retain) return;
|
|
273
|
+
|
|
274
|
+
// Sort newest-first; everything beyond `retain` is evictable.
|
|
275
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
276
|
+
for (const victim of candidates.slice(retain)) {
|
|
277
|
+
try {
|
|
278
|
+
unlinkSync(victim.path);
|
|
279
|
+
} catch {
|
|
280
|
+
// best-effort
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Test-injection hook: lets tests force eviction without writing a new grammar. */
|
|
286
|
+
export function _evictCacheForTest(retain?: number): void {
|
|
287
|
+
evictBeyondRetainCount(retain);
|
|
288
|
+
}
|
|
289
|
+
|
|
177
290
|
function sha256(bytes: Uint8Array): string {
|
|
178
291
|
return createHash('sha256').update(bytes).digest('hex');
|
|
179
292
|
}
|
|
@@ -271,6 +384,9 @@ export async function loadGrammar(
|
|
|
271
384
|
}
|
|
272
385
|
const lang = await Language.load(bytes);
|
|
273
386
|
loadedGrammars.set(language, lang);
|
|
387
|
+
// F-011 LRU: mark this entry as most-recently-used so it survives
|
|
388
|
+
// future evictions.
|
|
389
|
+
touchCacheFile(cachePath);
|
|
274
390
|
return lang;
|
|
275
391
|
}
|
|
276
392
|
}
|
|
@@ -326,6 +442,9 @@ export async function loadGrammar(
|
|
|
326
442
|
}
|
|
327
443
|
throw e;
|
|
328
444
|
}
|
|
445
|
+
// F-011 LRU: prune cache to retain count after every successful write.
|
|
446
|
+
// Best-effort — eviction failure never blocks a load.
|
|
447
|
+
evictBeyondRetainCount();
|
|
329
448
|
} catch (e) {
|
|
330
449
|
// Cache write failure is non-fatal — we still have `body` in memory and
|
|
331
450
|
// can load directly. Log to stderr per VR-USER-ERROR-MESSAGES style.
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Plan 3b — Phase 1: AST Adapter contract types.
|
|
6
6
|
*
|
|
7
|
-
* Lives at `packages/core/src/detect/adapters/types.ts
|
|
8
|
-
* (`docs/internal/2026-04-26-ast-lsp-spec.md` §2). All types are local —
|
|
7
|
+
* Lives at `packages/core/src/detect/adapters/types.ts`. All types are local —
|
|
9
8
|
* NONE re-exported from `web-tree-sitter`.
|
|
10
9
|
*
|
|
11
10
|
* Adapter authors import from this module only; the runner (`runner.ts`)
|
package/src/detect/migrate.ts
CHANGED
|
@@ -192,7 +192,7 @@ export function migrateV1ToV2(
|
|
|
192
192
|
framework.languages = languageEntries;
|
|
193
193
|
}
|
|
194
194
|
// P1-004: preserve any v1Framework subkey the explicit rebuild didn't emit
|
|
195
|
-
// (e.g.,
|
|
195
|
+
// (e.g., a multi-runtime monorepo's `framework.{python, rust, swift, typescript}` language sub-blocks).
|
|
196
196
|
preserveNestedSubkeys(v1Framework, framework);
|
|
197
197
|
|
|
198
198
|
// Paths: preserve user-set fields; fill `source` from detection if user had 'src' default.
|
|
@@ -234,13 +234,13 @@ export function migrateV1ToV2(
|
|
|
234
234
|
if (typeof v1Paths[k] === 'string') paths[k] = v1Paths[k];
|
|
235
235
|
}
|
|
236
236
|
// P1-005: preserve any v1Paths subkey the explicit rebuild didn't emit
|
|
237
|
-
// (e.g.,
|
|
237
|
+
// (e.g., a downstream consumer's 19 custom `paths.*` entries like adr, plans, monorepo_root).
|
|
238
238
|
preserveNestedSubkeys(v1Paths, paths);
|
|
239
239
|
|
|
240
240
|
const verification = buildVerificationBlock(detection, v1Verification);
|
|
241
241
|
|
|
242
242
|
// P1-006: build project block with nested passthrough so custom subkeys
|
|
243
|
-
// (e.g.,
|
|
243
|
+
// (e.g., a downstream consumer's `project.description`) survive the migration.
|
|
244
244
|
const project: Record<string, unknown> = {
|
|
245
245
|
name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
|
|
246
246
|
root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
|
|
@@ -265,7 +265,7 @@ export function migrateV1ToV2(
|
|
|
265
265
|
|
|
266
266
|
// P1-001: preserve any v1 top-level key not already handled by the explicit
|
|
267
267
|
// migrator. This is the generalization of PRESERVED_FIELDS — custom sections
|
|
268
|
-
// like `services`, `workflow`, `north_stars`
|
|
268
|
+
// like `services`, `workflow`, `north_stars` now pass through.
|
|
269
269
|
//
|
|
270
270
|
// `detection` is intentionally NOT in handledTopLevel: when a v2 config is
|
|
271
271
|
// fed back in (idempotence check at migrate.ts:16), the existing `detection`
|
package/src/lsp/auto-detect.ts
CHANGED
|
@@ -77,7 +77,12 @@ export async function findRunningLSPs(
|
|
|
77
77
|
* passing it to `LSPClient.fromCommand`. The factory rejects relative paths
|
|
78
78
|
* and `..`-containing argv elements.
|
|
79
79
|
*/
|
|
80
|
-
function splitCommand(server: {
|
|
80
|
+
function splitCommand(server: {
|
|
81
|
+
language: string;
|
|
82
|
+
command: string;
|
|
83
|
+
allow_setuid?: boolean;
|
|
84
|
+
max_rss_mb?: number;
|
|
85
|
+
}): LSPServerSpec {
|
|
81
86
|
const cmd = (server.command ?? '').trim();
|
|
82
87
|
// Whitespace split — not a full shell parser. Quoted args with spaces are
|
|
83
88
|
// not supported at v1; users with such commands should run a wrapper script.
|
|
@@ -85,5 +90,9 @@ function splitCommand(server: { language: string; command: string }): LSPServerS
|
|
|
85
90
|
return {
|
|
86
91
|
language: server.language,
|
|
87
92
|
argv,
|
|
93
|
+
// F-014 / F-015 (closed 2026-05-06): pass through the security knobs
|
|
94
|
+
// from config so the spawn surface enforces them.
|
|
95
|
+
allowSetuid: server.allow_setuid ?? false,
|
|
96
|
+
maxRssMb: server.max_rss_mb ?? undefined,
|
|
88
97
|
};
|
|
89
98
|
}
|
package/src/lsp/client.ts
CHANGED
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
* ESM imports throughout.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import { spawn, type ChildProcess } from 'child_process';
|
|
37
|
-
import {
|
|
36
|
+
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
|
37
|
+
import { lstatSync, realpathSync } from 'fs';
|
|
38
|
+
import { isAbsolute, resolve as resolvePath } from 'path';
|
|
38
39
|
import {
|
|
39
40
|
DefinitionResponseSchema,
|
|
40
41
|
DocumentSymbolResponseSchema,
|
|
@@ -215,6 +216,166 @@ export interface LSPServerSpec {
|
|
|
215
216
|
argv: string[];
|
|
216
217
|
/** When true, allow non-absolute argv[0]. Default false (security). */
|
|
217
218
|
allowRelativePath?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* F-014 (closed 2026-05-06): when true, allow argv[0] to be a SUID/SGID
|
|
221
|
+
* binary (or symlink resolving to one). Default false. SUID binaries
|
|
222
|
+
* inherit elevated privileges from the kernel at exec time; Node has no
|
|
223
|
+
* post-spawn way to strip them. The user-trust boundary is at config
|
|
224
|
+
* time, but a defensive lstat catches accidental misconfigs (e.g.
|
|
225
|
+
* pointing argv[0] at a system tool).
|
|
226
|
+
*/
|
|
227
|
+
allowSetuid?: boolean;
|
|
228
|
+
/**
|
|
229
|
+
* F-015 (closed 2026-05-06): RSS budget in MB. The watchdog polls
|
|
230
|
+
* `ps -p <pid> -o rss=` every WATCHDOG_INTERVAL_MS and SIGKILLs the
|
|
231
|
+
* child if RSS exceeds this budget for two consecutive samples.
|
|
232
|
+
* Default 1024 (1 GB). Set to 0 to disable the watchdog.
|
|
233
|
+
*/
|
|
234
|
+
maxRssMb?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ============================================================
|
|
238
|
+
// Typed errors — F-014, F-015 (closed 2026-05-06)
|
|
239
|
+
// ============================================================
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Thrown by `LSPClient.fromCommand` when argv[0] (or its symlink target)
|
|
243
|
+
* has the SUID/SGID bit set and `spec.allowSetuid: true` was not opted in.
|
|
244
|
+
*
|
|
245
|
+
* Why throw rather than silently accept: SUID binaries inherit elevated
|
|
246
|
+
* privileges from the kernel at exec time. Node cannot strip them
|
|
247
|
+
* post-spawn. A user who wants this MUST opt in explicitly so the
|
|
248
|
+
* decision is auditable in their config.
|
|
249
|
+
*/
|
|
250
|
+
export class LspBinaryIsSetuidError extends Error {
|
|
251
|
+
public readonly path: string;
|
|
252
|
+
public readonly mode: number;
|
|
253
|
+
constructor(path: string, mode: number) {
|
|
254
|
+
super(
|
|
255
|
+
`LSPClient.fromCommand: refused SUID/SGID binary at "${path}" ` +
|
|
256
|
+
`(mode=${mode.toString(8)}). The kernel will exec this with ` +
|
|
257
|
+
`elevated privileges; Node cannot strip that post-spawn. ` +
|
|
258
|
+
`Set spec.allowSetuid: true to opt in (auditable in config).`,
|
|
259
|
+
);
|
|
260
|
+
this.name = 'LspBinaryIsSetuidError';
|
|
261
|
+
this.path = path;
|
|
262
|
+
this.mode = mode;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Constants for the F-015 RSS watchdog. Exported so tests can inspect
|
|
268
|
+
* (and so a future config can override per-deployment if needed).
|
|
269
|
+
*/
|
|
270
|
+
export const DEFAULT_LSP_MAX_RSS_MB = 1024;
|
|
271
|
+
export const LSP_WATCHDOG_INTERVAL_MS = 30_000;
|
|
272
|
+
/**
|
|
273
|
+
* Number of consecutive over-budget samples required before SIGKILL.
|
|
274
|
+
* Avoids killing a server that briefly spikes during indexing — only
|
|
275
|
+
* sustained over-budget triggers eviction.
|
|
276
|
+
*/
|
|
277
|
+
export const LSP_WATCHDOG_OVERBUDGET_SAMPLES = 2;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* F-014 helper: detect SUID/SGID bits on a file. Follows the chain via
|
|
281
|
+
* lstat then statSync(realpath) so a symlink to a SUID binary is also
|
|
282
|
+
* caught. Returns null if the file doesn't exist or the stat fails.
|
|
283
|
+
*
|
|
284
|
+
* Bit semantics (per stat(2)):
|
|
285
|
+
* - 0o4000 = SUID (set-user-ID on execution)
|
|
286
|
+
* - 0o2000 = SGID (set-group-ID on execution)
|
|
287
|
+
*/
|
|
288
|
+
export function _detectSetuid(path: string): { hasSetuid: boolean; mode: number; resolvedPath: string } | null {
|
|
289
|
+
// First lstat — if argv[0] itself is a symlink, follow it via realpath.
|
|
290
|
+
let resolved = path;
|
|
291
|
+
try {
|
|
292
|
+
const linkStat = lstatSync(path);
|
|
293
|
+
if (linkStat.isSymbolicLink()) {
|
|
294
|
+
resolved = realpathSync(path);
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
// Now stat the resolved (non-symlink) target.
|
|
300
|
+
try {
|
|
301
|
+
const targetStat = lstatSync(resolved);
|
|
302
|
+
const mode = targetStat.mode;
|
|
303
|
+
return {
|
|
304
|
+
hasSetuid: (mode & 0o4000) !== 0 || (mode & 0o2000) !== 0,
|
|
305
|
+
mode,
|
|
306
|
+
resolvedPath: resolved,
|
|
307
|
+
};
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* F-015 helper: probe a child's RSS in MB via `ps -p <pid> -o rss=`.
|
|
315
|
+
* Returns null if ps fails (e.g., process already gone, or non-POSIX
|
|
316
|
+
* platform without ps). Best-effort — watchdog treats null as "no
|
|
317
|
+
* sample, don't count toward over-budget streak."
|
|
318
|
+
*/
|
|
319
|
+
export function _probeChildRssMb(pid: number): number | null {
|
|
320
|
+
try {
|
|
321
|
+
const result = spawnSync('ps', ['-o', 'rss=', '-p', String(pid)], {
|
|
322
|
+
encoding: 'utf-8',
|
|
323
|
+
timeout: 5_000,
|
|
324
|
+
});
|
|
325
|
+
if (result.status !== 0 || !result.stdout) return null;
|
|
326
|
+
const rssKb = parseInt(result.stdout.trim(), 10);
|
|
327
|
+
if (!Number.isFinite(rssKb) || rssKb < 0) return null;
|
|
328
|
+
return Math.round((rssKb / 1024) * 10) / 10;
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* F-015 helper: install an interval-based RSS watchdog on a spawned child.
|
|
336
|
+
* Returns the watchdog handle (interval id + cleanup) so the caller can
|
|
337
|
+
* stop it on transport shutdown / process exit.
|
|
338
|
+
*
|
|
339
|
+
* The watchdog SIGKILLs the child if RSS exceeds the budget for
|
|
340
|
+
* `LSP_WATCHDOG_OVERBUDGET_SAMPLES` consecutive samples. Killing emits a
|
|
341
|
+
* stderr warning naming the LSP language and the breach.
|
|
342
|
+
*/
|
|
343
|
+
export function _startRssWatchdog(
|
|
344
|
+
child: ChildProcess,
|
|
345
|
+
language: string,
|
|
346
|
+
maxRssMb: number,
|
|
347
|
+
intervalMs: number = LSP_WATCHDOG_INTERVAL_MS,
|
|
348
|
+
): { stop: () => void } {
|
|
349
|
+
if (maxRssMb <= 0) return { stop: () => { /* disabled */ } };
|
|
350
|
+
let overBudgetStreak = 0;
|
|
351
|
+
const tick = (): void => {
|
|
352
|
+
if (!child.pid || child.killed || child.exitCode !== null) return;
|
|
353
|
+
const rss = _probeChildRssMb(child.pid);
|
|
354
|
+
if (rss === null) return; // no sample; don't penalise
|
|
355
|
+
if (rss > maxRssMb) {
|
|
356
|
+
overBudgetStreak += 1;
|
|
357
|
+
process.stderr.write(
|
|
358
|
+
`[massu/lsp] WARN: ${language} server RSS=${rss}MB > budget ${maxRssMb}MB ` +
|
|
359
|
+
`(streak=${overBudgetStreak}/${LSP_WATCHDOG_OVERBUDGET_SAMPLES})\n`,
|
|
360
|
+
);
|
|
361
|
+
if (overBudgetStreak >= LSP_WATCHDOG_OVERBUDGET_SAMPLES) {
|
|
362
|
+
process.stderr.write(
|
|
363
|
+
`[massu/lsp] KILLING ${language} server pid=${child.pid}: ` +
|
|
364
|
+
`sustained RSS over budget. (F-015 watchdog)\n`,
|
|
365
|
+
);
|
|
366
|
+
try { child.kill('SIGKILL'); } catch { /* best-effort */ }
|
|
367
|
+
clearInterval(handle);
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
overBudgetStreak = 0;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const handle = setInterval(tick, intervalMs);
|
|
374
|
+
// Don't keep the event loop alive solely for the watchdog.
|
|
375
|
+
if (typeof handle.unref === 'function') handle.unref();
|
|
376
|
+
return {
|
|
377
|
+
stop: () => clearInterval(handle),
|
|
378
|
+
};
|
|
218
379
|
}
|
|
219
380
|
|
|
220
381
|
// ============================================================
|
|
@@ -309,6 +470,20 @@ export class LSPClient {
|
|
|
309
470
|
);
|
|
310
471
|
}
|
|
311
472
|
|
|
473
|
+
// F-014 (closed 2026-05-06): SUID/SGID bit detection. We only check
|
|
474
|
+
// when argv[0] is absolute (post the relative-path gate) — for
|
|
475
|
+
// allowRelativePath shapes the user has explicitly accepted that
|
|
476
|
+
// PATH-resolution semantics apply, including any SUID a binary
|
|
477
|
+
// resolved from PATH might have. Resolve the path to an absolute
|
|
478
|
+
// form so the lstat target is unambiguous.
|
|
479
|
+
if (!spec.allowSetuid) {
|
|
480
|
+
const absExe = isAbsolute(exe) ? exe : resolvePath(exe);
|
|
481
|
+
const det = _detectSetuid(absExe);
|
|
482
|
+
if (det !== null && det.hasSetuid) {
|
|
483
|
+
throw new LspBinaryIsSetuidError(det.resolvedPath, det.mode);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
312
487
|
const child = spawn(exe, spec.argv.slice(1), {
|
|
313
488
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
314
489
|
// Explicitly NO `shell: true` — argv array form is the security
|
|
@@ -324,6 +499,17 @@ export class LSPClient {
|
|
|
324
499
|
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
325
500
|
},
|
|
326
501
|
});
|
|
502
|
+
|
|
503
|
+
// F-015 (closed 2026-05-06): RSS watchdog. Polls every 30s, kills
|
|
504
|
+
// child after sustained over-budget. Disabled when maxRssMb === 0.
|
|
505
|
+
const maxRssMb = spec.maxRssMb ?? DEFAULT_LSP_MAX_RSS_MB;
|
|
506
|
+
const watchdog = _startRssWatchdog(child, spec.language, maxRssMb);
|
|
507
|
+
|
|
508
|
+
// Stop the watchdog when the child exits naturally so the interval
|
|
509
|
+
// doesn't outlive the process.
|
|
510
|
+
child.once('exit', () => watchdog.stop());
|
|
511
|
+
child.once('error', () => watchdog.stop());
|
|
512
|
+
|
|
327
513
|
return new LSPClient(createStdioTransport(child), options);
|
|
328
514
|
}
|
|
329
515
|
|
package/src/watch/daemon.ts
CHANGED
|
@@ -329,7 +329,7 @@ export async function startDaemon(projectRoot: string, hooks: DaemonHooks): Prom
|
|
|
329
329
|
// (throws WatchSurfaceTooLargeError) if the configured globs would
|
|
330
330
|
// monitor more files than `watch.max_watched_files` AND the user has
|
|
331
331
|
// not set `watch.paths_full_root_opt_in: true`. Prevents the misconfig
|
|
332
|
-
// pattern that produced 30-100% sustained CPU on
|
|
332
|
+
// pattern that produced 30-100% sustained CPU on a large monorepo.
|
|
333
333
|
const cap = cfgYaml.watch?.max_watched_files ?? 10_000;
|
|
334
334
|
const optedIn = cfgYaml.watch?.paths_full_root_opt_in ?? false;
|
|
335
335
|
const t0 = now();
|
package/src/watch/paths.ts
CHANGED
|
@@ -49,7 +49,7 @@ export const DEFAULT_EXCLUSIONS = [
|
|
|
49
49
|
'**/.mypy_cache/**',
|
|
50
50
|
// Plan 3a hotfix 2026-05-02: high-churn directories that are never
|
|
51
51
|
// legitimate stack-detection inputs and produced sustained 30-100% CPU
|
|
52
|
-
// when watched on
|
|
52
|
+
// when watched on a large monorepo (62K files / 42 GB tree).
|
|
53
53
|
'**/.next/**',
|
|
54
54
|
'**/coverage/**',
|
|
55
55
|
'**/logs/**',
|
|
@@ -57,7 +57,7 @@ export const DEFAULT_EXCLUSIONS = [
|
|
|
57
57
|
// Runtime data dirs. Convention across Python/JS/Rust ecosystems is
|
|
58
58
|
// that `data/` holds runtime artifacts (caches, snapshots, model
|
|
59
59
|
// checkpoints, downloaded fixtures) that change frequently but are
|
|
60
|
-
// never stack-detection inputs.
|
|
60
|
+
// never stack-detection inputs. A large monorepo had 135K files in
|
|
61
61
|
// apps/ai-service/data alone, dwarfing legitimate source. If a
|
|
62
62
|
// project genuinely uses `data/` for source content, opt into
|
|
63
63
|
// `watch.scope: 'full'` and `watch.paths_full_root_opt_in: true`.
|