@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.
@@ -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 Hedge (62K files / 42 GB tree).
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. Hedge had 135K files in
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-soak.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 — hedge's paths.aliases['@'] was being overwritten.)
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., hedge's `gateway`, `ios`, `runtime`, `web`) survive
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 — hedge was losing 15
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;
@@ -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 Hedge-style stale configs)
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
@@ -72,7 +72,7 @@ function parseFlags(args: string[]): ParsedFlags {
72
72
  }
73
73
 
74
74
  function findClaudeBg(): string | null {
75
- // Prefer the well-known Hedge-author install path; fall back to PATH.
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 locked at Phase 0 (`docs/internal/2026-04-26-ast-lsp-spec.md`
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` per the spec doc
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`)
@@ -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., hedge's `framework.{python, rust, swift, typescript}` language sub-blocks).
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., hedge's 19 custom `paths.*` entries like adr, plans, monorepo_root).
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., hedge's `project.description`) survive the migration.
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` (hedge) now pass through.
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`
@@ -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: { language: string; command: string }): LSPServerSpec {
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 { isAbsolute } from 'path';
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
 
@@ -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 Hedge.
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();
@@ -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 Hedge (62K files / 42 GB tree).
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. Hedge had 135K files in
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`.