@codyswann/lisa 2.317.7 → 2.319.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.
Files changed (77) hide show
  1. package/cdk/package-lisa/package.lisa.json +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +12 -7
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/expo/package-lisa/package.lisa.json +5 -5
  6. package/nestjs/package-lisa/package.lisa.json +1 -1
  7. package/package.json +1 -1
  8. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
  11. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
  12. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
  13. package/plugins/lisa/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
  14. package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
  15. package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
  16. package/plugins/lisa-agy/plugin.json +1 -1
  17. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
  18. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
  19. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
  27. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
  28. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
  29. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
  31. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
  32. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
  33. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo-agy/plugin.json +1 -1
  36. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  56. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails-agy/plugin.json +1 -1
  61. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  66. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  71. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/src/base/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +34 -6
  74. package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +12 -0
  75. package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +65 -5
  76. package/scripts/check-security-floors.mjs +285 -0
  77. package/typescript/package-lisa/package.lisa.json +6 -6
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.317.7",
3
+ "version": "2.319.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -47,6 +47,16 @@ function fingerprint(value) {
47
47
  return createHash("sha256").update(value).digest("hex").slice(0, 12);
48
48
  }
49
49
 
50
+ /**
51
+ * The value a proxied credential reads as instead of a usable token.
52
+ *
53
+ * Some surfaces keep a credential outside the sandbox entirely and substitute
54
+ * the real value at egress. The variable is then present and non-empty, which
55
+ * is exactly what makes it dangerous: a presence check passes, and only a
56
+ * consumer that reads the variable *itself* discovers it holds a placeholder.
57
+ */
58
+ const PROXY_PLACEHOLDER = "proxy-injected";
59
+
50
60
  /**
51
61
  * Assert every declared name actually resolves.
52
62
  * @param {object} cfg Resolved configuration.
@@ -56,18 +66,36 @@ function fingerprint(value) {
56
66
  */
57
67
  export function checkRequired(cfg, provider, file, report) {
58
68
  for (const name of cfg.require ?? []) {
59
- const resolves =
60
- (process.env[name] ?? "").trim() ||
61
- file.get(name) ||
62
- provider.get(name)?.value;
63
- if (resolves) report("ok", name, "resolves");
64
- else
69
+ const fromEnv = (process.env[name] ?? "").trim();
70
+ const resolves = fromEnv || file.get(name) || provider.get(name)?.value;
71
+
72
+ if (!resolves) {
65
73
  report(
66
74
  "error",
67
75
  name,
68
76
  "declared in secrets.require but resolves nowhere — a startup error, " +
69
77
  "not a late surprise"
70
78
  );
79
+ continue;
80
+ }
81
+
82
+ // Reported separately from "resolves" because the two are not the same
83
+ // claim. A tool that authenticates through the proxy works; a script that
84
+ // reads this variable and puts it in an Authorization header sends the
85
+ // literal placeholder and gets a confusing auth failure far from here.
86
+ if (fromEnv === PROXY_PLACEHOLDER) {
87
+ report(
88
+ "warn",
89
+ name,
90
+ `reads as the literal string "${PROXY_PLACEHOLDER}" — the value is ` +
91
+ `substituted at egress and never enters this environment. Tools that ` +
92
+ `authenticate through the proxy work; anything reading this variable ` +
93
+ `directly gets the placeholder, not a credential`
94
+ );
95
+ continue;
96
+ }
97
+
98
+ report("ok", name, "resolves");
71
99
  }
72
100
  }
73
101
 
@@ -28,11 +28,19 @@ import { join } from "node:path";
28
28
  * drift and leak. It is *required* on surfaces whose bootstrap runs before the
29
29
  * consuming process exists and which therefore have no other channel; a remote
30
30
  * agent container prepares itself during setup, long before any task starts.
31
+ *
32
+ * The two remote surfaces share the capability but not the timing, and a reader
33
+ * adding a third should not assume otherwise. `codex-cloud` re-runs its setup
34
+ * script when a container resumes, so materializing there picks up a rotated
35
+ * value. `claude-web` *skips* its setup script whenever a filesystem cache
36
+ * exists, so materializing there would strand a rotated value until the cache
37
+ * expired — its materialize step runs from a session-start hook instead.
31
38
  */
32
39
  export const SURFACES = {
33
40
  local: { materialized: false, mayWriteValues: false },
34
41
  "github-actions": { materialized: false, mayWriteValues: false },
35
42
  "codex-cloud": { materialized: true, mayWriteValues: true },
43
+ "claude-web": { materialized: true, mayWriteValues: true },
36
44
  };
37
45
 
38
46
  /** Config defaults when `.lisa.config.json` carries no `secrets` block. */
@@ -68,6 +76,10 @@ export function detectSurface(configured = null, env = process.env) {
68
76
  return explicit;
69
77
  }
70
78
  if ((env.GITHUB_ACTIONS ?? "") === "true") return "github-actions";
79
+ // Compared to the exact string rather than tested for presence: the variable
80
+ // is documented as carrying "true" in a cloud session and never being true
81
+ // locally, so a presence test would misread a shell that exports it empty.
82
+ if ((env.CLAUDE_CODE_REMOTE ?? "") === "true") return "claude-web";
71
83
  if ((env.CODEX_SANDBOX ?? env.CODEX_HOME ?? "") !== "") return "codex-cloud";
72
84
  return "local";
73
85
  }
@@ -19,14 +19,59 @@
19
19
  import { existsSync, readFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
 
22
+ import { SURFACES as SURFACE_CAPABILITIES } from "./surfaces.mjs";
23
+
22
24
  /** Providers with a read implementation today. */
23
25
  const IMPLEMENTED_PROVIDERS = new Set(["bitwarden", "doppler", "env"]);
24
26
 
25
27
  /** Providers named in the dispatch table but not yet implemented. */
26
28
  const DECLARED_PROVIDERS = new Set(["1password", "aws", "vault"]);
27
29
 
28
- /** Surfaces the resolver knows. */
29
- const SURFACES = new Set(["local", "github-actions", "codex-cloud"]);
30
+ /**
31
+ * Surfaces the resolver knows.
32
+ *
33
+ * Derived from the resolver's own table rather than restated here. The two
34
+ * lists previously drifted apart by construction: adding a surface meant
35
+ * remembering to edit a second file, and forgetting produced a config that
36
+ * resolved correctly at runtime while `doctor` called it unknown.
37
+ */
38
+ const SURFACES = new Set(Object.keys(SURFACE_CAPABILITIES));
39
+
40
+ /**
41
+ * What a provisioned surface must record before anything dispatches to it.
42
+ *
43
+ * Deliberately not uniform, because these surfaces do not bind the same way. A
44
+ * Codex Cloud environment is bound to one repository, so naming the repository
45
+ * is part of proving the environment is the right one. A Claude cloud
46
+ * environment has no repository at all — it is account-scoped configuration
47
+ * (network policy, variables, setup script) and the repository arrives per
48
+ * session — so its durable handle is the routine that dispatch fires.
49
+ *
50
+ * Requiring `repository` of every surface, as this file used to, would demand
51
+ * a field that cannot be true of `claude-web` in any meaningful sense.
52
+ *
53
+ * `repository` stays the default so every existing surface keeps its current
54
+ * contract. This file checks structure only — whether a declaration *could* be
55
+ * correct — so it deliberately does not restate the fuller preconditions that
56
+ * `lisa-remote-dispatch` enforces at the moment it actually dispatches.
57
+ */
58
+ const SURFACE_BINDINGS = {
59
+ "claude-web": ["routineId", "fireUrl"],
60
+ };
61
+
62
+ /**
63
+ * The fields a surface must record, with the default applied.
64
+ *
65
+ * Read through one helper rather than at each call site, because the two
66
+ * callers answer different questions — "could this declaration be correct" and
67
+ * "may an automation dispatch to it" — and a fallback that drifted between them
68
+ * would let those answers disagree about the very same config.
69
+ * @param {string} surface Surface name.
70
+ * @returns {string[]} Field names that must be present.
71
+ */
72
+ function bindingsFor(surface) {
73
+ return SURFACE_BINDINGS[surface] ?? ["repository"];
74
+ }
30
75
 
31
76
  /** Install methods the toolchain runner supports. */
32
77
  const INSTALL_METHODS = new Set(["release-zip", "npm-global"]);
@@ -139,15 +184,30 @@ export function validateRemoteEnv(remoteEnv) {
139
184
  for (const [surface, block] of Object.entries(remoteEnv.surfaces ?? {})) {
140
185
  if (!SURFACES.has(surface)) {
141
186
  problems.push(`remoteEnv.surfaces has unknown surface "${surface}"`);
187
+ continue;
142
188
  }
143
- if (!block.repository) {
144
- problems.push(`remoteEnv.surfaces["${surface}"] has no repository`);
189
+ for (const field of bindingsFor(surface)) {
190
+ if (!block[field]) {
191
+ problems.push(`remoteEnv.surfaces["${surface}"] has no ${field}`);
192
+ }
145
193
  }
146
194
  }
147
195
 
148
196
  return problems;
149
197
  }
150
198
 
199
+ /**
200
+ * Report whether a surface has been provisioned far enough to dispatch to.
201
+ * @param {object|undefined} remoteEnv The remote-environment block.
202
+ * @param {string} surface Surface name.
203
+ * @returns {boolean} Whether every binding field is recorded.
204
+ */
205
+ export function isProvisioned(remoteEnv, surface) {
206
+ const block = remoteEnv?.surfaces?.[surface];
207
+ if (!block) return false;
208
+ return bindingsFor(surface).every(field => Boolean(block[field]));
209
+ }
210
+
151
211
  /**
152
212
  * Validate the `automations` block against declared surfaces.
153
213
  * @param {object|undefined} automations The block, if present.
@@ -173,7 +233,7 @@ export function validateAutomations(automations, remoteEnv) {
173
233
  );
174
234
  continue;
175
235
  }
176
- if (!remoteEnv?.surfaces?.[loop.executionEnv]?.repository) {
236
+ if (!isProvisioned(remoteEnv, loop.executionEnv)) {
177
237
  problems.push(
178
238
  `automations["${name}"] dispatches to "${loop.executionEnv}", which is ` +
179
239
  `not provisioned. Run /lisa:setup:remote-env ${loop.executionEnv} first.`
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Verify no force-pinned version floor permits a known-vulnerable release.
4
+ *
5
+ * A floor in a `force` section does not merely fail to protect when it goes
6
+ * stale — it OVERWRITES whatever the downstream project had. A project that
7
+ * pinned correctly gets moved back onto the vulnerable range by `lisa apply`,
8
+ * silently, and the only signal is a suspicious line in a diff nobody is
9
+ * looking for. That is how `brace-expansion >=5.0.7` survived: it was written
10
+ * to match GHSA-3jxr-9vmj-r5cp, whose first_patched_version genuinely is
11
+ * 5.0.7, and GHSA-mh99-v99m-4gvg later moved the floor to 5.0.8.
12
+ *
13
+ * The floors are therefore checked against the advisory database rather than
14
+ * against what npm happens to resolve. Resolution drifts as new advisories
15
+ * land; `first_patched_version` does not. Deriving floors from resolution is
16
+ * the specific mistake this exists to catch — it produced the same defect
17
+ * three times (fast-uri, js-yaml, and the three fixed in #2168).
18
+ *
19
+ * Usage:
20
+ * check-security-floors.mjs [--strict] [--json]
21
+ *
22
+ * Exits non-zero only with --strict, so the job can report before it gates.
23
+ * @module scripts/check-security-floors
24
+ */
25
+
26
+ import { readFileSync } from "node:fs";
27
+ import { globSync } from "node:fs";
28
+
29
+ /** Sections whose entries are version constraints worth checking. */
30
+ const CONSTRAINT_SECTIONS = [
31
+ "overrides",
32
+ "resolutions",
33
+ "dependencies",
34
+ "devDependencies",
35
+ "peerDependencies",
36
+ ];
37
+
38
+ /** Governance groups in a package.lisa.json. */
39
+ const GOVERNANCE_GROUPS = ["force", "defaults", "merge"];
40
+
41
+ /** Severities that gate. Medium and low advisories are ignored by this check. */
42
+ const GATING_SEVERITIES = new Set(["high", "critical"]);
43
+
44
+ const ADVISORY_ENDPOINT = "https://api.github.com/advisories";
45
+
46
+ /**
47
+ * Every version constraint declared across the template manifests.
48
+ * @returns {Map<string, Array<{file: string, path: string, spec: string}>>} By package name.
49
+ */
50
+ function collectFloors() {
51
+ const found = new Map();
52
+ const files = globSync("*/package-lisa/package.lisa.json").filter(
53
+ file => !file.includes("node_modules") && !file.includes(".worktrees")
54
+ );
55
+ for (const file of files) {
56
+ let manifest;
57
+ try {
58
+ manifest = JSON.parse(readFileSync(file, "utf8"));
59
+ } catch (error) {
60
+ throw new Error(`${file} is not valid JSON: ${error.message}`);
61
+ }
62
+ for (const group of GOVERNANCE_GROUPS) {
63
+ const block = manifest[group];
64
+ if (!block || typeof block !== "object") continue;
65
+ for (const section of CONSTRAINT_SECTIONS) {
66
+ const entries = block[section];
67
+ if (!entries || typeof entries !== "object") continue;
68
+ for (const [name, spec] of Object.entries(entries)) {
69
+ // `$name` self-references defer to the project's own pin and carry
70
+ // no floor of their own; a literal like "workspace:*" likewise.
71
+ if (typeof spec !== "string" || !/\d/.test(spec)) continue;
72
+ if (spec.startsWith("$")) continue;
73
+ if (!found.has(name)) found.set(name, []);
74
+ found.get(name).push({ file, path: `${group}.${section}`, spec });
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return found;
80
+ }
81
+
82
+ /**
83
+ * Lowest version a spec permits, as a comparable tuple.
84
+ * @param {string} spec A range like ">=5.0.7", "^1.2.3", "~2.0.0".
85
+ * @returns {number[]|null} [major, minor, patch], or null if unparseable.
86
+ */
87
+ export function lowestPermitted(spec) {
88
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(spec);
89
+ if (!match) return null;
90
+ return match.slice(1, 4).map(Number);
91
+ }
92
+
93
+ /**
94
+ * Compare two version tuples.
95
+ * @param {number[]} left First version.
96
+ * @param {number[]} right Second version.
97
+ * @returns {number} Negative, zero, or positive.
98
+ */
99
+ function compare(left, right) {
100
+ for (let index = 0; index < 3; index += 1) {
101
+ if (left[index] !== right[index]) return left[index] - right[index];
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ /**
107
+ * Whether a version falls inside an advisory's vulnerable range.
108
+ * @param {number[]} version Candidate version.
109
+ * @param {string} range e.g. ">= 4.0.0, < 5.0.8".
110
+ * @returns {boolean} True when the version is vulnerable.
111
+ */
112
+ export function withinRange(version, range) {
113
+ if (!range) return false;
114
+ let inside = true;
115
+ for (const clause of range.split(",")) {
116
+ const match = /(>=|<=|<|>|=)\s*(\d+)\.(\d+)\.(\d+)/.exec(clause.trim());
117
+ if (!match) continue;
118
+ const bound = match.slice(2, 5).map(Number);
119
+ const delta = compare(version, bound);
120
+ switch (match[1]) {
121
+ case ">=":
122
+ inside &&= delta >= 0;
123
+ break;
124
+ case ">":
125
+ inside &&= delta > 0;
126
+ break;
127
+ case "<=":
128
+ inside &&= delta <= 0;
129
+ break;
130
+ case "<":
131
+ inside &&= delta < 0;
132
+ break;
133
+ default:
134
+ inside &&= delta === 0;
135
+ }
136
+ }
137
+ return inside;
138
+ }
139
+
140
+ /**
141
+ * Advisories affecting one package.
142
+ *
143
+ * Distinguishes "no advisories" from "could not ask", because conflating them
144
+ * is how a check like this goes quietly blind. The anonymous advisory limit is
145
+ * 60 requests/hour and these manifests declare roughly 200 packages, so an
146
+ * unauthenticated run answers for the first 60 and silently reports the other
147
+ * 140 as clean unless the difference is tracked.
148
+ * @param {string} name npm package name.
149
+ * @returns {Promise<{advisories: Array}|{error: string}>} Result or reason.
150
+ */
151
+ async function advisoriesFor(name) {
152
+ const url = `${ADVISORY_ENDPOINT}?ecosystem=npm&affects=${encodeURIComponent(name)}&per_page=100`;
153
+ const headers = { accept: "application/vnd.github+json" };
154
+ // Authenticated calls get 5000/hour instead of 60. CI supplies the token via
155
+ // the standard GITHUB_TOKEN; locally, `gh auth token` covers it.
156
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
157
+ if (token) headers.authorization = `Bearer ${token}`;
158
+
159
+ for (let attempt = 0; attempt < 3; attempt += 1) {
160
+ let response;
161
+ try {
162
+ response = await fetch(url, { headers });
163
+ } catch (error) {
164
+ return { error: `network: ${error.message}` };
165
+ }
166
+ if (response.ok) return { advisories: await response.json() };
167
+
168
+ const remaining = response.headers.get("x-ratelimit-remaining");
169
+ const limited =
170
+ response.status === 429 || (response.status === 403 && remaining === "0");
171
+ if (!limited) return { error: `HTTP ${response.status}` };
172
+
173
+ // Honour Retry-After when given, otherwise back off. A rate-limited run
174
+ // cannot demonstrate anything, so it is worth waiting rather than
175
+ // reporting a clean sheet that was never checked.
176
+ const retryAfter = Number(response.headers.get("retry-after"));
177
+ const waitSeconds =
178
+ Number.isFinite(retryAfter) && retryAfter > 0
179
+ ? Math.min(retryAfter, 30)
180
+ : 2 ** attempt;
181
+ await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
182
+ }
183
+ return { error: "rate limited" };
184
+ }
185
+
186
+ /**
187
+ * Audit every collected floor.
188
+ * @returns {Promise<{problems: Array, unreachable: string[], checked: number}>} Findings.
189
+ */
190
+ async function audit() {
191
+ const floors = collectFloors();
192
+ const problems = [];
193
+ const unreachable = [];
194
+ for (const [name, sites] of floors) {
195
+ const result = await advisoriesFor(name);
196
+ if (result.error) {
197
+ unreachable.push({ name, reason: result.error });
198
+ continue;
199
+ }
200
+ const { advisories } = result;
201
+ for (const advisory of advisories) {
202
+ if (!GATING_SEVERITIES.has(advisory.severity)) continue;
203
+ for (const vulnerability of advisory.vulnerabilities ?? []) {
204
+ if (vulnerability.package?.name !== name) continue;
205
+ for (const site of sites) {
206
+ const lowest = lowestPermitted(site.spec);
207
+ if (!lowest) continue;
208
+ if (!withinRange(lowest, vulnerability.vulnerable_version_range)) {
209
+ continue;
210
+ }
211
+ problems.push({
212
+ package: name,
213
+ ...site,
214
+ advisory: advisory.ghsa_id,
215
+ severity: advisory.severity,
216
+ vulnerableRange: vulnerability.vulnerable_version_range,
217
+ patched: vulnerability.first_patched_version,
218
+ });
219
+ }
220
+ }
221
+ }
222
+ }
223
+ return { problems, unreachable, checked: floors.size };
224
+ }
225
+
226
+ async function main() {
227
+ const strict = process.argv.includes("--strict");
228
+ const asJson = process.argv.includes("--json");
229
+ const { problems, unreachable, checked } = await audit();
230
+
231
+ if (asJson) {
232
+ console.log(JSON.stringify({ problems, unreachable, checked }, null, 2));
233
+ } else if (problems.length === 0) {
234
+ console.log(
235
+ `## Security floors\n\nNo force-pinned floor permits a high or critical vulnerable release. ${checked} package(s) checked.`
236
+ );
237
+ } else {
238
+ console.log("## Security floors\n");
239
+ console.log(
240
+ "These pins permit a release the advisory database marks vulnerable. Because they sit in governance sections, a stale floor **overwrites** a downstream project that pinned correctly.\n"
241
+ );
242
+ console.log("| package | pinned | permits | advisory | patched | site |");
243
+ console.log("|---|---|---|---|---|---|");
244
+ for (const problem of problems) {
245
+ console.log(
246
+ `| \`${problem.package}\` | \`${problem.spec}\` | ${problem.vulnerableRange} | ${problem.advisory} (${problem.severity}) | **${problem.patched}** | ${problem.file} ${problem.path} |`
247
+ );
248
+ }
249
+ console.log(
250
+ "\nRaise each floor to the advisory's `first_patched_version`, then confirm the new value actually resolves on npm — a floor nothing satisfies is its own breakage."
251
+ );
252
+ }
253
+
254
+ if (unreachable.length > 0) {
255
+ const limited = unreachable.filter(entry =>
256
+ entry.reason.includes("rate limited")
257
+ );
258
+ console.log(
259
+ `\n> **Inconclusive for ${unreachable.length} of ${checked} package(s).** Their floors were NOT verified — this is not a clean result for them.`
260
+ );
261
+ if (limited.length > 0) {
262
+ console.log(
263
+ "> Cause is rate limiting. The anonymous advisory limit is 60 requests/hour; set `GITHUB_TOKEN` for 5000."
264
+ );
265
+ }
266
+ console.log(
267
+ `> Affected: ${unreachable.map(entry => entry.name).join(", ")}`
268
+ );
269
+ }
270
+
271
+ // Deliberately exit 0 without --strict. An advisory landing overnight would
272
+ // otherwise fail a PR that changed nothing, and a check that cries wolf gets
273
+ // disabled rather than fixed.
274
+ //
275
+ // Under --strict an inconclusive run also fails: it proves nothing, and
276
+ // treating "could not check" as "checked and clean" is exactly the silent
277
+ // degradation this script exists to prevent.
278
+ if (strict && (problems.length > 0 || unreachable.length > 0)) {
279
+ process.exitCode = 1;
280
+ }
281
+ }
282
+
283
+ if (import.meta.main) {
284
+ await main();
285
+ }