@codyswann/lisa 4.4.1 → 4.4.3

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 (79) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +12 -29
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa-agy/plugin.json +1 -1
  8. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo-agy/plugin.json +1 -1
  18. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  28. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  33. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  38. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails-agy/plugin.json +1 -1
  43. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  48. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  53. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  55. package/scripts/check-delivery-deletion-conflicts.mjs +2 -2
  56. package/scripts/check-derived-artifacts.mjs +24 -5
  57. package/scripts/check-orphan-test-processes.mjs +2 -2
  58. package/scripts/check-security-floors.mjs +20 -9
  59. package/scripts/check-template-workflow-refs.mjs +23 -8
  60. package/scripts/check-workflow-package-paths.mjs +12 -8
  61. package/scripts/generate-lisa-owned-hash-ledger.mjs +12 -4
  62. package/scripts/generate-upstream-evidence-manifest.mjs +4 -4
  63. package/scripts/verify-health-agentic-built.mjs +4 -3
  64. package/scripts/verify-health-consumer-built.mjs +3 -2
  65. package/scripts/verify-health-contract-built.mjs +3 -2
  66. package/scripts/verify-health-deterministic-built.mjs +5 -4
  67. package/plugins/lisa/.codex-plugin/skills/lisa-parity-safety-net-rules/SKILL.md +0 -263
  68. package/plugins/lisa/.codex-plugin/skills/lisa-parity-safety-net-rules/agents/openai.yaml +0 -4
  69. package/plugins/lisa/commands/parity-safety-net-rules.md +0 -6
  70. package/plugins/lisa/skills/lisa-parity-safety-net-rules/SKILL.md +0 -263
  71. package/plugins/lisa/skills/lisa-parity-safety-net-rules/agents/openai.yaml +0 -4
  72. package/plugins/lisa-agy/commands/lisa/parity-safety-net-rules.md +0 -6
  73. package/plugins/lisa-agy/skills/lisa-parity-safety-net-rules/SKILL.md +0 -263
  74. package/plugins/lisa-copilot/commands/lisa/parity-safety-net-rules.md +0 -6
  75. package/plugins/lisa-copilot/skills/lisa-parity-safety-net-rules/SKILL.md +0 -263
  76. package/plugins/lisa-cursor/commands/lisa/parity-safety-net-rules.md +0 -6
  77. package/plugins/lisa-cursor/skills/lisa-parity-safety-net-rules/SKILL.md +0 -263
  78. package/plugins/src/base/commands/parity-safety-net-rules.md +0 -6
  79. package/plugins/src/base/skills/lisa-parity-safety-net-rules/SKILL.md +0 -263
@@ -23,10 +23,11 @@
23
23
  * @module scripts/check-security-floors
24
24
  */
25
25
 
26
- import { execFileSync } from "node:child_process";
27
26
  import { existsSync, readFileSync } from "node:fs";
28
27
  import { globSync } from "node:fs";
29
28
 
29
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
30
+
30
31
  /**
31
32
  * Fixed absolute git locations, tried in order rather than a bare command name
32
33
  * so a writeable directory early on `PATH` cannot decide which binary runs.
@@ -129,18 +130,28 @@ const NOT_A_MANIFEST = [
129
130
  /**
130
131
  * Every manifest git tracks, so the glob can be checked against reality.
131
132
  *
132
- * Returns null when the question cannot be asked — outside a checkout, or with
133
- * no git. A null is reported as "not reconciled", never folded into "nothing
134
- * missing": the whole defect being fixed here is a scan that narrowed silently.
133
+ * Returns null when the question cannot be asked — outside a checkout, with no
134
+ * git, or with the child killed at its deadline. A null is reported as "not
135
+ * reconciled", never folded into "nothing missing": the whole defect being
136
+ * fixed here is a scan that narrowed silently.
137
+ *
138
+ * The swallow is therefore KEPT for a timeout rather than kept by inheritance.
139
+ * A kill is one more way the question cannot be asked, and this function
140
+ * already routes "cannot ask" to the safe answer — so re-raising would trade a
141
+ * correct report for a crash and gain nothing.
135
142
  * @returns {string[]|null} Tracked manifest paths, or null when unknowable.
136
143
  */
137
144
  function trackedManifests() {
138
145
  try {
139
- return execFileSync(GIT_BIN, ["ls-files", "-z", "*package.lisa.json"], {
140
- encoding: "utf8",
141
- maxBuffer: 8 * 1024 * 1024,
142
- stdio: ["ignore", "pipe", "ignore"],
143
- })
146
+ return boundedExecFileSync(
147
+ GIT_BIN,
148
+ ["ls-files", "-z", "*package.lisa.json"],
149
+ {
150
+ encoding: "utf8",
151
+ maxBuffer: 8 * 1024 * 1024,
152
+ stdio: ["ignore", "pipe", "ignore"],
153
+ }
154
+ )
144
155
  .split("\0")
145
156
  .filter(Boolean)
146
157
  .filter(
@@ -57,12 +57,12 @@
57
57
  *
58
58
  * @module scripts/check-template-workflow-refs
59
59
  */
60
- import { execFileSync } from "node:child_process";
61
60
  import fs from "node:fs";
62
61
  import path from "node:path";
63
62
  import process from "node:process";
64
63
  import { fileURLToPath } from "node:url";
65
64
 
65
+ import { boundedExecFileSync, isChildTimeout } from "./lib/bounded-spawn.mjs";
66
66
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
67
67
 
68
68
  const REPO_ROOT = path.resolve(
@@ -124,7 +124,7 @@ export function findWorkflowRefs(content) {
124
124
  function listTrackedFiles(root) {
125
125
  let stdout;
126
126
  try {
127
- stdout = execFileSync("git", ["-C", root, "ls-files", "-z"], {
127
+ stdout = boundedExecFileSync("git", ["-C", root, "ls-files", "-z"], {
128
128
  encoding: "utf8",
129
129
  maxBuffer: MAX_GIT_OUTPUT_BYTES,
130
130
  stdio: ["ignore", "pipe", "ignore"],
@@ -140,13 +140,19 @@ function listTrackedFiles(root) {
140
140
  /**
141
141
  * Whether `ref` names an object present in this clone.
142
142
  *
143
+ * A KILLED child re-raises. `false` here does not mean "unknown", it means
144
+ * "this reference is broken" — a verdict the caller reports. So a busy machine
145
+ * would manufacture a failure about a reference that is perfectly fine, and a
146
+ * guard that invents failures is one people learn to re-run rather than read.
147
+ * That is the sibling harm to failing open, and it is just as corrosive.
143
148
  * @param {string} root - the repository root.
144
149
  * @param {string} ref - a tag, branch, or SHA.
145
150
  * @returns {boolean} whether the ref resolves locally.
151
+ * @throws {Error} When the child was killed at its deadline
146
152
  */
147
153
  function refExists(root, ref) {
148
154
  try {
149
- execFileSync(
155
+ boundedExecFileSync(
150
156
  "git",
151
157
  ["-C", root, "rev-parse", "--verify", `${ref}^{commit}`],
152
158
  {
@@ -154,7 +160,8 @@ function refExists(root, ref) {
154
160
  }
155
161
  );
156
162
  return true;
157
- } catch {
163
+ } catch (error) {
164
+ if (isChildTimeout(error)) throw error;
158
165
  return false;
159
166
  }
160
167
  }
@@ -162,18 +169,26 @@ function refExists(root, ref) {
162
169
  /**
163
170
  * Whether `target` exists in the tree named by `ref`.
164
171
  *
172
+ * A KILLED child re-raises, for the reason given on `refExists` above: `false`
173
+ * is a verdict of "absent at that ref", not an admission of ignorance.
165
174
  * @param {string} root - the repository root.
166
175
  * @param {string} ref - a tag, branch, or SHA known to resolve.
167
176
  * @param {string} target - a repo-relative path.
168
177
  * @returns {boolean} whether the path exists at that ref.
178
+ * @throws {Error} When the child was killed at its deadline
169
179
  */
170
180
  function pathExistsAtRef(root, ref, target) {
171
181
  try {
172
- execFileSync("git", ["-C", root, "cat-file", "-e", `${ref}:${target}`], {
173
- stdio: ["ignore", "ignore", "ignore"],
174
- });
182
+ boundedExecFileSync(
183
+ "git",
184
+ ["-C", root, "cat-file", "-e", `${ref}:${target}`],
185
+ {
186
+ stdio: ["ignore", "ignore", "ignore"],
187
+ }
188
+ );
175
189
  return true;
176
- } catch {
190
+ } catch (error) {
191
+ if (isChildTimeout(error)) throw error;
177
192
  return false;
178
193
  }
179
194
  }
@@ -77,7 +77,6 @@
77
77
  *
78
78
  * @module scripts/check-workflow-package-paths
79
79
  */
80
- import { execFileSync } from "node:child_process";
81
80
  import {
82
81
  existsSync,
83
82
  mkdirSync,
@@ -89,6 +88,7 @@ import { mkdtempSync } from "node:fs";
89
88
  import { tmpdir } from "node:os";
90
89
  import path from "node:path";
91
90
 
91
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
92
92
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
93
93
 
94
94
  /** The package whose released layout a workflow may make claims about. */
@@ -179,7 +179,7 @@ function parseArgs(argv) {
179
179
  * @returns {readonly string[]} Repo-relative workflow paths
180
180
  */
181
181
  function trackedWorkflows(root) {
182
- const output = execFileSync(
182
+ const output = boundedExecFileSync(
183
183
  "git",
184
184
  ["-C", root, "ls-files", ".github/workflows"],
185
185
  { encoding: "utf8" }
@@ -285,7 +285,7 @@ export function collectClaims(root) {
285
285
  */
286
286
  function publishedVersions() {
287
287
  const versions = JSON.parse(
288
- execFileSync("npm", ["view", PACKAGE_NAME, "versions", "--json"], {
288
+ boundedExecFileSync("npm", ["view", PACKAGE_NAME, "versions", "--json"], {
289
289
  encoding: "utf8",
290
290
  stdio: ["ignore", "pipe", "pipe"],
291
291
  maxBuffer: 16 * 1024 * 1024,
@@ -357,7 +357,7 @@ function fetchReleaseListings(chosen, cacheDir) {
357
357
  continue;
358
358
  }
359
359
  const staging = mkdtempSync(path.join(tmpdir(), "lisa-release-"));
360
- execFileSync(
360
+ boundedExecFileSync(
361
361
  "npm",
362
362
  ["pack", `${PACKAGE_NAME}@${version}`, "--pack-destination", staging],
363
363
  { stdio: ["ignore", "pipe", "pipe"] }
@@ -366,10 +366,14 @@ function fetchReleaseListings(chosen, cacheDir) {
366
366
  if (tarball === undefined) {
367
367
  throw new Error(`npm pack produced no tarball for ${version}`);
368
368
  }
369
- const entries = execFileSync("tar", ["-tzf", path.join(staging, tarball)], {
370
- encoding: "utf8",
371
- maxBuffer: 64 * 1024 * 1024,
372
- })
369
+ const entries = boundedExecFileSync(
370
+ "tar",
371
+ ["-tzf", path.join(staging, tarball)],
372
+ {
373
+ encoding: "utf8",
374
+ maxBuffer: 64 * 1024 * 1024,
375
+ }
376
+ )
373
377
  .split("\n")
374
378
  .filter(Boolean);
375
379
  listings[version] = entries;
@@ -19,11 +19,12 @@
19
19
  * legitimately stale file as host-modified and stop refreshing it — failing
20
20
  * safe, but permanently.
21
21
  */
22
- import { execFileSync } from "node:child_process";
23
22
  import { createHash } from "node:crypto";
24
23
  import { readFileSync, writeFileSync } from "node:fs";
25
24
  import path from "node:path";
26
25
 
26
+ import { boundedExecFileSync, isChildTimeout } from "./lib/bounded-spawn.mjs";
27
+
27
28
  const repoRoot = path.resolve(import.meta.dirname, "..");
28
29
  const outputPath = path.join(repoRoot, "src/core/lisa-owned-hash-ledger.ts");
29
30
  const COPY_OVERWRITE = "copy-overwrite";
@@ -35,18 +36,25 @@ const ENFORCEMENT_TREE = "scripts/";
35
36
  *
36
37
  * Blobs are read as `binary` so arbitrary bytes survive the round trip into the
37
38
  * Buffer that gets hashed; decoding as UTF-8 would corrupt any non-text file.
39
+ * A KILLED child re-raises. Every caller reads this as content, and an empty
40
+ * string is a perfectly well-formed answer meaning "no files" — so a timeout
41
+ * on the `ls-files` call would build a ledger that vouches for NOTHING and
42
+ * report it as a complete one. A ledger's whole job is to vouch for bytes; one
43
+ * assembled from silence is worse than none, because it looks authoritative.
38
44
  * @param {readonly string[]} args - Arguments passed to git
39
- * @returns {string} Command stdout, or an empty string when git fails
45
+ * @returns {string} Command stdout, or an empty string when git said nothing
46
+ * @throws {Error} When the child was killed at its deadline
40
47
  */
41
48
  function git(args) {
42
49
  try {
43
- return execFileSync("git", args, {
50
+ return boundedExecFileSync("git", args, {
44
51
  cwd: repoRoot,
45
52
  encoding: "buffer",
46
53
  maxBuffer: 64 * 1024 * 1024,
47
54
  stdio: ["ignore", "pipe", "ignore"],
48
55
  }).toString("binary");
49
- } catch {
56
+ } catch (error) {
57
+ if (isChildTimeout(error)) throw error;
50
58
  return "";
51
59
  }
52
60
  }
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /** Generate the exact hash-pinned allowlist for public upstream evidence. */
3
- import { execFileSync } from "node:child_process";
4
3
  import { createHash } from "node:crypto";
5
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
5
  import path from "node:path";
7
6
 
7
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
8
8
  import {
9
9
  describeStaleManifest,
10
10
  diagnoseStaleManifest,
@@ -86,7 +86,7 @@ function repositoryPath(url) {
86
86
  // comparison is by path: `url.<base>.insteadOf` rewrites what `git remote
87
87
  // get-url` returns, so it answers "how do I reach this" rather than "what is
88
88
  // this". Falls back to the resolved URL only if no value is configured.
89
- const originUrl = execFileSync(
89
+ const originUrl = boundedExecFileSync(
90
90
  "git",
91
91
  ["config", "--get", "remote.origin.url"],
92
92
  { cwd: repoRoot, encoding: "utf8" }
@@ -100,7 +100,7 @@ if (repositoryPath(originUrl) !== canonicalRepository.toLowerCase()) {
100
100
  );
101
101
  }
102
102
 
103
- const tracked = execFileSync("git", ["ls-files", "-z"], {
103
+ const tracked = boundedExecFileSync("git", ["ls-files", "-z"], {
104
104
  cwd: repoRoot,
105
105
  encoding: "utf8",
106
106
  })
@@ -135,7 +135,7 @@ const existingPublicCommits = [
135
135
  .filter(Boolean);
136
136
  const refreshPublicCommits = process.argv.includes("--refresh-public-commits");
137
137
  const discoveredPublicCommits = refreshPublicCommits
138
- ? execFileSync("git", ["rev-list", "--remotes=origin"], {
138
+ ? boundedExecFileSync("git", ["rev-list", "--remotes=origin"], {
139
139
  cwd: repoRoot,
140
140
  encoding: "utf8",
141
141
  })
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /** Empirical proof for the shipped optional agentic Health composition API. */
3
3
  import assert from "node:assert/strict";
4
- import { execFileSync } from "node:child_process";
5
4
  import { createHash } from "node:crypto";
6
5
  import {
7
6
  lstat,
@@ -17,6 +16,8 @@ import {
17
16
  import { tmpdir } from "node:os";
18
17
  import path from "node:path";
19
18
 
19
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
20
+
20
21
  // Literals named once — each was repeated enough times that a typo in one
21
22
  // copy would diverge silently.
22
23
  const TEMPLATES_MANAGED = "templates.managed";
@@ -62,14 +63,14 @@ const root = process.cwd();
62
63
  const workspace = await mkdtemp(path.join(tmpdir(), "lisa-health-agentic-"));
63
64
  const project = path.join(workspace, "host");
64
65
  const isolatedBin = path.join(workspace, "bin");
65
- const gitExecutable = execFileSync("which", ["git"], {
66
+ const gitExecutable = boundedExecFileSync("which", ["git"], {
66
67
  encoding: "utf8",
67
68
  }).trim();
68
69
  const healthFile = path.join(project, ".lisa", "health", "latest.json");
69
70
  const startedAt = Date.now();
70
71
 
71
72
  const run = (executable, argv, options = {}) =>
72
- execFileSync(executable, argv, {
73
+ boundedExecFileSync(executable, argv, {
73
74
  cwd: project,
74
75
  encoding: "utf8",
75
76
  stdio: ["ignore", "pipe", "pipe"],
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /** Built-package proof for the real two-phase Health CLI consumer. */
3
3
  import assert from "node:assert/strict";
4
- import { execFileSync } from "node:child_process";
5
4
  import {
6
5
  lstat,
7
6
  mkdir,
@@ -14,6 +13,8 @@ import {
14
13
  import { tmpdir } from "node:os";
15
14
  import path from "node:path";
16
15
 
16
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
17
+
17
18
  // Literals named once — each was repeated enough times that a typo in one
18
19
  // copy would diverge silently.
19
20
  const LATEST_JSON = "latest.json";
@@ -53,7 +54,7 @@ async function createProject(name) {
53
54
 
54
55
  /** Run the compiled CLI and capture its exact standard-output bytes. */
55
56
  function runCli(project, argv, input) {
56
- return execFileSync(
57
+ return boundedExecFileSync(
57
58
  process.execPath,
58
59
  [cli, "--no-update-check", "health", ...argv, project],
59
60
  {
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /** Empirical proof for the shipped Health v1 contract and real built CLI. */
3
3
  import assert from "node:assert/strict";
4
- import { execFileSync } from "node:child_process";
5
4
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
5
  import { tmpdir } from "node:os";
7
6
  import path from "node:path";
8
7
 
8
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
9
+
9
10
  let assertions = 0;
10
11
  const check = (condition, message) => {
11
12
  assertions += 1;
@@ -23,7 +24,7 @@ try {
23
24
  path.join(project, ".lisa.config.json"),
24
25
  `${JSON.stringify({ tracker: "github", github: { org: "acme", repo: "app" } }, null, 2)}\n`
25
26
  );
26
- execFileSync(
27
+ boundedExecFileSync(
27
28
  process.execPath,
28
29
  [path.join(root, "dist/index.js"), "sync", project, "--json"],
29
30
  {
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /** Empirical proof for the shipped deterministic Health fast path. */
3
3
  import assert from "node:assert/strict";
4
- import { execFileSync } from "node:child_process";
5
4
  import { createHash } from "node:crypto";
6
5
  import {
7
6
  chmod,
@@ -18,6 +17,8 @@ import {
18
17
  import { tmpdir } from "node:os";
19
18
  import path from "node:path";
20
19
 
20
+ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
21
+
21
22
  // Literals named once — each was repeated enough times that a typo in one
22
23
  // copy would diverge silently.
23
24
  const PACKAGE_JSON = "package.json";
@@ -40,14 +41,14 @@ const root = process.cwd();
40
41
  const workspace = await mkdtemp(path.join(tmpdir(), "lisa-health-fast-path-"));
41
42
  const project = path.join(workspace, "host");
42
43
  const isolatedBin = path.join(workspace, "bin");
43
- const gitExecutable = execFileSync("which", ["git"], {
44
+ const gitExecutable = boundedExecFileSync("which", ["git"], {
44
45
  encoding: "utf8",
45
46
  }).trim();
46
47
  const healthFile = path.join(project, ".lisa", "health", "latest.json");
47
48
  const startedAt = Date.now();
48
49
 
49
50
  const run = (executable, argv, options = {}) =>
50
- execFileSync(executable, argv, {
51
+ boundedExecFileSync(executable, argv, {
51
52
  cwd: project,
52
53
  encoding: "utf8",
53
54
  stdio: ["ignore", "pipe", "pipe"],
@@ -466,7 +467,7 @@ try {
466
467
  path.join(unsafeProject, LISA_CONFIG_JSON),
467
468
  '{"tracker":"github","harness":"codex"}\n'
468
469
  );
469
- execFileSync("mkfifo", [path.join(unsafeProject, PACKAGE_JSON)]);
470
+ boundedExecFileSync("mkfifo", [path.join(unsafeProject, PACKAGE_JSON)]);
470
471
  const unsafeStarted = Date.now();
471
472
  const unsafe = await health.runDeterministicHealth(unsafeProject, {
472
473
  lisaRoot: root,
@@ -1,263 +0,0 @@
1
- ---
2
- name: lisa-parity-safety-net-rules
3
- description: "View, set, and verify the…"
4
- allowed-tools: ["Read", "Edit", "Write", "Bash"]
5
- synced-from: safety-net@cc-marketplace@1.0.6
6
- ---
7
-
8
- # Parity Safety-Net Rules
9
-
10
- Manage the **custom guard rules** that Lisa's safety-net hook enforces on every
11
- Bash command. The hook (`hooks/parity-safety-net.sh`, registered as a
12
- `PreToolUse` matcher on `Bash`) ships with built-in guards against catastrophic
13
- commands; this skill lets a project **view**, **set**, and **verify** *additional*
14
- project-specific rules on top of those built-ins.
15
-
16
- > **Lisa-native reimplementation.** Upstream 0.9.0 shipped two rule-management
17
- > skills (`set-custom-rules` + `verify-custom-rules`), which this skill
18
- > consolidates. Upstream 1.0.6 consolidated them too (into `cc-safety-net`) and
19
- > moved custom rules to a JSON rulebook system driven by the
20
- > `npx cc-safety-net rule` CLI. Lisa **deliberately keeps** its simpler
21
- > ERE-lines-file design: the Lisa hook must run identically on Codex, agy,
22
- > Copilot, Cursor, and Claude without an npx dependency, and a flat regex file
23
- > is auditable in any of those runtimes. It is reimplemented from scratch
24
- > against Lisa conventions — it does **not** port or invoke upstream plugin
25
- > code.
26
- >
27
- > **Drift tracking.** Pinned to `safety-net@cc-marketplace@1.0.6`.
28
- > `scripts/plugin-parity-drift.mjs` compares this pin against the upstream
29
- > version in the plugin cache and flags staleness. **Do not port or copy upstream
30
- > plugin code.**
31
- >
32
- > **2.0.3 review.** Upstream 2.0.2–2.0.3 added Kimi Code and Amp installation
33
- > support and release tooling. Those changes do not alter the upstream
34
- > rule-management skill or Lisa's project-local ERE rule contract, and Lisa does
35
- > not currently ship hooks for those runtimes, so no behavior is absorbed here.
36
- >
37
- > **2.0.4 review.** Method, so the next person can re-run it rather than trust
38
- > it: hash the sorted (relative path, content) manifest of every rule-bearing
39
- > directory in the plugin cache under both versions, then diff the trees
40
- > excluding `dist/`, `.git/`, and `node_modules/`. Result — `src/` (230 files),
41
- > `skills/` (1), and `hooks/` (1) are byte-identical across 2.0.3 and 2.0.4
42
- > (`src` digest `7e346e205fbd1aa7` both sides). The only non-generated
43
- > differences are the `version` string in `package.json`, `kimi.plugin.json`,
44
- > and `.claude-plugin/plugin.json`, plus a test-harness fix in
45
- > `tests/cli/cli-statusline.test.ts` that tolerates EPIPE when an oversized
46
- > payload is flushed at the bounded reader's cap. Everything else is rebuilt
47
- > `dist/` output. No rule, guard family, or skill surface moved, so nothing is
48
- > absorbed here — and, critically, **2.0.4 does not close the gap recorded
49
- > below**, which therefore carries forward unchanged rather than being dropped
50
- > at the new pin.
51
- >
52
- > **Known gap at the 2.0.4 pin.** Upstream 2.0.0 rebuilt its engine and added two
53
- > guard families this hook does **not** mirror: `secret.*` (blocks reading or
54
- > copying SSH keys, `.env` files, cloud credentials, and coding-CLI credential
55
- > stores) and `rm.git-metadata` (blocks deleting the `.git` control plane). Both
56
- > were measured against this hook rather than assumed — see the probe table in
57
- > `parity/plugin-routing/safety-net@cc-marketplace.md`. Absorbing `secret.*` also
58
- > needs a matcher change, because this hook is registered on `Bash` only while
59
- > upstream also screens file tools. That work is tracked separately in
60
- > `parity/FOLLOWUPS.md` §5; **do not treat this skill's built-in guard list as
61
- > covering secret access.**
62
-
63
- ## How the rules work
64
-
65
- - The hook always enforces its **built-in guards** (see below). These cannot be
66
- disabled from the rules file — they are the floor.
67
- - **Custom rules** live in a project-local file, one **POSIX extended regular
68
- expression (ERE)** per line. Blank lines and lines beginning with `#` are
69
- ignored. Matching is case-insensitive (`grep -Ei`).
70
- - If *any* built-in guard or custom rule matches the proposed command, the hook
71
- exits non-zero and the Bash call is **blocked**, with the reason shown to the
72
- agent.
73
-
74
- ### Rules file location
75
-
76
- Resolved in this order:
77
-
78
- 1. `$SAFETY_NET_RULES_FILE` (explicit override), else
79
- 2. `${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt`
80
-
81
- ```bash
82
- RULES_FILE="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
83
- ```
84
-
85
- ### Built-in guards (always on)
86
-
87
- 1. `rm -rf` of a filesystem root, `$HOME`/`~`, or a top-level wildcard — with
88
- quote-aware boundaries, so wrapper/interpreter forms like
89
- `bash -c "rm -rf /"` and `python -c "… os.system('rm -rf /')"` match too.
90
- Path-prefixed spellings (`/bin/rm`, `./rm`) count as `rm` for every rm
91
- guard.
92
- 2. `rm -rf` target hardening: the cwd itself (`.`/`./`), `..`-traversal paths,
93
- home-anchored `~/…` paths, absolute paths outside the project (`/tmp`,
94
- `/var/tmp`, and `$TMPDIR` are allowed), `$VAR` targets other than `$TMPDIR`,
95
- and **any** recursive forced delete while the working directory is `$HOME`.
96
- 3. Force-pushing a protected branch
97
- (`main`/`master`/`production`/`prod`/`release`). `--force-with-lease` is
98
- intentionally allowed, and so is force-pushing a feature branch (sanctioned
99
- rebase workflow). Acceptable parity divergence: a refspec force-push
100
- (`git push origin +main`) is not blocked — upstream 1.0.6 allows it too.
101
- 4. `git reset --hard` / `git reset --merge` while the working tree is **dirty**
102
- (would discard work). Clean-tree resets are intentionally allowed.
103
- 5. `git rebase --abort` / `git rebase --quit` while the in-progress rebase
104
- holds human-made conflict resolutions (they would be discarded). A clean or
105
- untouched rebase state stays abortable — an agent-recoverable wedge; the
106
- apply backend and a missing `AUTO_MERGE` ref fail closed. Deliberate
107
- divergence: upstream blocks `rebase --abort` unconditionally.
108
- 6. `git checkout` discards: the `--` pathspec form (with or without a ref),
109
- `-f`/`--force`, `--pathspec-from-file`, and bare `git checkout .`.
110
- Branch switching and `-b`/`-B` creation stay allowed.
111
- 7. `git switch --discard-changes` / `-f`/`--force`.
112
- 8. `git restore` touching the worktree — only `git restore --staged <path>`
113
- without `--worktree` is allowed (unstaging is safe).
114
- 9. `git stash drop` / `git stash clear` (push/pop/list/apply stay allowed).
115
- 10. `git clean` with a force flag and no dry-run — `-n`/`--dry-run` anywhere
116
- makes it a safe preview.
117
- 11. `git branch -D` (or `-d` combined with `-f`, clustered or split);
118
- safe `-d` and rename `-m` stay allowed.
119
- 12. `git tag -d`, `git reflog delete`, `git worktree remove --force`.
120
- 13. Deletion via `find … -delete`, `find … -exec rm -rf`, and `xargs … rm -rf`
121
- (plain non-recursive `rm` on find/xargs output stays allowed).
122
- 14. Disk destroyers: `dd of=/dev/…`, `mkfs … /dev/…`, `shred`.
123
- 15. Destructive SQL — `DROP DATABASE/SCHEMA/TABLE`, `TRUNCATE TABLE`.
124
-
125
- Every git guard sees through leading git **global options** (`-C <path>`,
126
- `-c <k>=<v>`, `--git-dir[=…]`, `--no-pager`, …), so `git -C /path <destructive>`
127
- is screened the same as `git <destructive>`.
128
-
129
- Malformed hook input fails **closed** (exit 2 denies the command). A text-scan
130
- hook cannot exempt display commands, so prose like
131
- `echo "docs about rm -rf /"` can false-positive — quote-break the string or use
132
- the gh-writer heredoc form (payload is stripped before the guards run). For the
133
- same reason the rm guard treats substitution-wrapping as verdict-neutral (issue
134
- #1982): an executable `echo "$(rm -rf /)"` is blocked, and so are inert twins the
135
- shell would never expand — a single-quoted `echo '$(rm -rf /)'` or an escaped
136
- `echo "\$(rm -rf /)"` — since the scan has no quote-context awareness. The same
137
- quote-break or heredoc workaround applies. Spell that heredoc workaround with a
138
- **quote-pair** delimiter (`gh issue create --body-file - <<'EOF'`) — only that
139
- spelling reaches the payload-strip path. `<<"EOF"` and `<<\EOF` do not, and a
140
- `gh … <<\EOF` writer now fails closed instead.
141
-
142
- ## What this does and does not defend against
143
-
144
- Read this before treating the safety net as a wall.
145
-
146
- **What it is for:** stopping *accidental* catastrophic commands — an agent (or a
147
- human) that reaches for `rm -rf /`, force-pushes `main`, drops a table, or writes
148
- to a raw device by mistake. Within that job the guards above are the floor and
149
- they are always on.
150
-
151
- **What it is not:** a security boundary against a deliberate adversary. Two
152
- structural reasons, both accepted rather than open bugs:
153
-
154
- - **It is a text scan, not a shell engine.** It has no quote-context awareness,
155
- which produces the false positives described above and means an operation
156
- spelled in a way the patterns do not anticipate is not matched.
157
- - **The here-doc classifier re-implements bash's lexer in Python** to decide
158
- which payload text is inert data. Every divergence from real bash is a shape it
159
- mis-classifies. Eight distinct divergences were found and fixed across issues
160
- #1958 and #1993; the class is open-ended, so it is now closed as an accepted
161
- limitation rather than chased indefinitely.
162
-
163
- **Why that is tolerable:** a here-doc mis-classification degrades to the content
164
- guards rather than bypassing them. Only the narrow `gh issue|pr` writer form ever
165
- strips payload text; every other path passes the **raw** command to the guards,
166
- and since issue #1982 those guards see through substitution wrappers. So a
167
- mis-read here-doc costs no guard coverage for any destructive class listed above.
168
-
169
- **The honest bottom line:** an agent that is actively trying to run something
170
- destructive has many paths that never involve a here-doc at all — the safety net
171
- does not claim to close them. Do not rely on it as the only control over what an
172
- agent may execute; treat it as the last line of defence against mistakes, and
173
- keep the real boundary (credentials, permissions, environment isolation) outside
174
- the agent.
175
-
176
- ## View the current rules
177
-
178
- ```bash
179
- RULES_FILE="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
180
- if [ -f "$RULES_FILE" ]; then
181
- echo "Custom safety-net rules ($RULES_FILE):"
182
- grep -vE '^[[:space:]]*(#|$)' "$RULES_FILE" || echo "(no active rules)"
183
- else
184
- echo "No custom rules file yet ($RULES_FILE). Only built-in guards are active."
185
- fi
186
- ```
187
-
188
- `Read` the file to show comments and structure as well.
189
-
190
- ## Set (add or edit) a rule
191
-
192
- A rule is an ERE matched against the full command string. Keep rules **specific**
193
- to avoid blocking legitimate work — anchor on the dangerous verb and its target.
194
-
195
- 1. Ensure the file exists, then **append** a commented rule (use `Edit`/`Write`,
196
- or append from the shell):
197
-
198
- ```bash
199
- RULES_FILE="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
200
- mkdir -p "$(dirname "$RULES_FILE")"
201
- {
202
- echo "# Block deleting a Kubernetes namespace"
203
- echo 'kubectl[[:space:]]+delete[[:space:]]+namespace'
204
- } >> "$RULES_FILE"
205
- ```
206
-
207
- 2. **Always verify** the new rule (next section) before considering it set —
208
- confirm it blocks what it should and allows what it shouldn't.
209
-
210
- Editing/removing: open the file with `Edit` and change or delete the line.
211
- Removing a rule never affects the built-in guards.
212
-
213
- ## Verify the rules
214
-
215
- Two checks — both should pass before you trust a rule.
216
-
217
- ### 1. The ERE is valid
218
-
219
- An invalid regex would make the hook error on every command. Validate it:
220
-
221
- ```bash
222
- printf '%s' "$RULE" | grep -Eq -- "$RULE" 2>/dev/null && echo "valid ERE" \
223
- || echo "INVALID ERE — fix before saving"
224
- ```
225
-
226
- ### 2. The rule behaves as intended
227
-
228
- Drive the **actual hook** with a fake `PreToolUse` payload and assert the exit
229
- code (non-zero = blocked, 0 = allowed). Build the JSON with `jq` so the test
230
- command line itself never contains the dangerous literal:
231
-
232
- ```bash
233
- HOOK="${CLAUDE_PLUGIN_ROOT}/hooks/parity-safety-net.sh"
234
-
235
- check() { # check <expect: block|allow> <command>
236
- jq -nc --arg c "$2" '{tool_name:"Bash",tool_input:{command:$c}}' \
237
- | bash "$HOOK" >/dev/null 2>&1
238
- local code=$?
239
- local got=allow; [ "$code" -ne 0 ] && got=block
240
- printf '%-5s want=%-5s got=%-5s %s\n' \
241
- "$([ "$got" = "$1" ] && echo OK || echo FAIL)" "$1" "$got" "$2"
242
- }
243
-
244
- # Should block (matches the new rule):
245
- check block "kubectl delete namespace prod"
246
- # Should allow (must not over-match):
247
- check allow "kubectl get pods"
248
- ```
249
-
250
- Report a table of cases with want/got/verdict. If any case disagrees, tighten the
251
- ERE and re-verify.
252
-
253
- ## Rules
254
-
255
- - **Built-in guards are the floor** — custom rules only *add* blocks; they cannot
256
- weaken the built-ins.
257
- - **Prefer specific over broad** — a rule that blocks too much trains users to
258
- bypass the safety net. Anchor on verb + target.
259
- - **Verify every rule against the real hook** before saving — never ship an
260
- unverified or syntactically invalid ERE.
261
- - **Never weaken the net to unblock yourself.** If a built-in guard fires on a
262
- command that is genuinely safe, run it manually outside the agent after the
263
- user confirms — do not edit the hook to remove the guard.
@@ -1,4 +0,0 @@
1
- display_name: "Parity Safety Net Rules"
2
- short_description: "View, set, and verify the…"
3
- default_prompt:
4
- - "Use $lisa-parity-safety-net-rules: View, set, and verify the…."