@codyswann/lisa 2.324.0 → 2.325.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +71 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +102 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-shell-json-parsing.sh +220 -0
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +562 -0
- package/all/merge/.claude/settings.json +19 -4
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +42 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/standards/git-state.d.ts.map +1 -1
- package/dist/standards/git-state.js +10 -1
- package/dist/standards/git-state.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/SKILL.md +60 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +18 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
- package/plugins/lisa/commands/detect-tooling.md +7 -0
- package/plugins/lisa/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa/rules/reference/tracked-work.md +1 -1
- package/plugins/lisa/skills/lisa-detect-tooling/SKILL.md +60 -0
- package/plugins/lisa/skills/lisa-detect-tooling/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +18 -2
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
- package/plugins/lisa-agy/commands/lisa/detect-tooling.md +7 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-detect-tooling/SKILL.md +60 -0
- package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +18 -2
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/commands/lisa/detect-tooling.md +7 -0
- package/plugins/lisa-copilot/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa-copilot/rules/reference/tracked-work.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-detect-tooling/SKILL.md +60 -0
- package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +18 -2
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/detect-tooling.md +7 -0
- package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +1 -1
- package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
- package/plugins/lisa-cursor/skills/lisa-detect-tooling/SKILL.md +60 -0
- package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +18 -2
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/commands/detect-tooling.md +7 -0
- package/plugins/src/base/rules/eager/tracked-work.md +1 -1
- package/plugins/src/base/rules/reference/tracked-work.md +1 -1
- package/plugins/src/base/skills/lisa-detect-tooling/SKILL.md +60 -0
- package/plugins/src/base/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +18 -2
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
- package/scripts/build-plugins.sh +32 -0
- package/scripts/generate-upstream-evidence-manifest.mjs +50 -11
- package/scripts/lisa-enforcement-fallback.sh +16 -2
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Find the command-line tools a project needs but never declares.
|
|
4
|
+
*
|
|
5
|
+
* Lisa provisions tooling through several unrelated mechanisms — npm
|
|
6
|
+
* devDependencies from a stack template, MCP servers, a dedicated installer for
|
|
7
|
+
* Sonar, and the pinned `remoteEnv.tools` manifest. Only the last one puts a
|
|
8
|
+
* binary on PATH, and nothing populates it. So a project can ship npm scripts
|
|
9
|
+
* that invoke `maestro`, wire an MCP server whose CLI it also shells out to, and
|
|
10
|
+
* declare Playwright coverage thresholds, while the manifest that actually
|
|
11
|
+
* provisions binaries stays empty and every one of those fails at the moment of
|
|
12
|
+
* use rather than at setup.
|
|
13
|
+
*
|
|
14
|
+
* This program only ever *proposes*. It writes nothing, installs nothing, and
|
|
15
|
+
* emits manifest entries with the fields a human still has to fill — a pinned
|
|
16
|
+
* version, a URL, a checksum. That boundary is the point: a tool should reach a
|
|
17
|
+
* machine because someone reviewed a pinned entry, never because a detector was
|
|
18
|
+
* confident. Detection is evidence; the manifest is the decision.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* detect-tooling.mjs [--json]
|
|
22
|
+
* @module detect-tooling
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tools Lisa or its templates invoke, and how to recognise a need for them.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately conservative. A false positive costs a human a moment's review;
|
|
32
|
+
* a false negative is the failure this exists to prevent, so the signals are
|
|
33
|
+
* ones that only appear when the tool is genuinely used.
|
|
34
|
+
*/
|
|
35
|
+
const KNOWN_TOOLS = Object.freeze({
|
|
36
|
+
maestro: {
|
|
37
|
+
why: "Expo's template ships npm scripts that invoke `maestro`, and nothing installs the binary.",
|
|
38
|
+
mcpFallback: "maestro",
|
|
39
|
+
},
|
|
40
|
+
gh: {
|
|
41
|
+
why: "The work-item guardrails shell out to `gh` for every tracker read.",
|
|
42
|
+
},
|
|
43
|
+
bws: {
|
|
44
|
+
why: "The Bitwarden CLI is how every secret is resolved.",
|
|
45
|
+
},
|
|
46
|
+
"sonar-scanner": {
|
|
47
|
+
why: "Sonar analysis runs a scanner binary, which the Sonar installer configures but does not place on PATH.",
|
|
48
|
+
},
|
|
49
|
+
playwright: {
|
|
50
|
+
why: "Playwright drives browser E2E and its coverage thresholds are configured.",
|
|
51
|
+
viaNpm: "@playwright/test",
|
|
52
|
+
},
|
|
53
|
+
linear: {
|
|
54
|
+
why: "Linear is wired as an MCP server, which needs browser OAuth and so cannot authenticate in a container.",
|
|
55
|
+
mcpFallback: "linear-server",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read a JSON file, treating absence or damage as "no signal".
|
|
61
|
+
*
|
|
62
|
+
* A detector that throws on a malformed config is worse than one that misses a
|
|
63
|
+
* signal: it blocks the very command an operator runs to find out what is wrong.
|
|
64
|
+
* @param {string} path File to read.
|
|
65
|
+
* @returns {object|null} Parsed contents, or null.
|
|
66
|
+
*/
|
|
67
|
+
function readJson(path) {
|
|
68
|
+
if (!existsSync(path)) return null;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Tools named by npm scripts, which is the strongest signal there is.
|
|
78
|
+
*
|
|
79
|
+
* A script that runs `maestro test` is a project stating it needs Maestro, in
|
|
80
|
+
* executable form. Word-boundary matched so `maestro:test` as a script *name*
|
|
81
|
+
* does not count while `maestro test` as its body does — the name is a label,
|
|
82
|
+
* the body is a dependency.
|
|
83
|
+
* @param {object|null} pkg Parsed package.json.
|
|
84
|
+
* @returns {Map<string, string>} Tool name to the script that proves it.
|
|
85
|
+
*/
|
|
86
|
+
export function toolsFromScripts(pkg) {
|
|
87
|
+
const found = new Map();
|
|
88
|
+
const scripts = pkg?.scripts ?? {};
|
|
89
|
+
for (const [name, body] of Object.entries(scripts)) {
|
|
90
|
+
if (typeof body !== "string") continue;
|
|
91
|
+
for (const tool of Object.keys(KNOWN_TOOLS)) {
|
|
92
|
+
const pattern = new RegExp(`(^|[\\s;&|(])${tool}(\\s|$)`, "u");
|
|
93
|
+
if (pattern.test(body) && !found.has(tool)) {
|
|
94
|
+
found.set(tool, `npm script "${name}": ${body.trim().slice(0, 60)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Tools implied by MCP servers that also have a CLI.
|
|
103
|
+
*
|
|
104
|
+
* An MCP server is not a substitute for the binary. Several authenticate by
|
|
105
|
+
* browser OAuth, which a container cannot do at all, so a project relying on one
|
|
106
|
+
* remotely has no integration rather than a degraded one — the CLI is the form
|
|
107
|
+
* that survives the trip.
|
|
108
|
+
* @param {object|null} mcp Parsed .mcp.json.
|
|
109
|
+
* @returns {Map<string, string>} Tool name to the server that implies it.
|
|
110
|
+
*/
|
|
111
|
+
export function toolsFromMcp(mcp) {
|
|
112
|
+
const found = new Map();
|
|
113
|
+
const servers = Object.keys(mcp?.mcpServers ?? {});
|
|
114
|
+
for (const [tool, meta] of Object.entries(KNOWN_TOOLS)) {
|
|
115
|
+
if (!meta.mcpFallback) continue;
|
|
116
|
+
const server = servers.find(name => name.includes(meta.mcpFallback));
|
|
117
|
+
if (server) {
|
|
118
|
+
found.set(
|
|
119
|
+
tool,
|
|
120
|
+
`MCP server "${server}" (browser OAuth cannot run remotely)`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Tools named in credential usage notes.
|
|
129
|
+
*
|
|
130
|
+
* A note explaining what a token is *for* usually names the program that
|
|
131
|
+
* consumes it, and those notes are already readable without touching a value —
|
|
132
|
+
* `read-secret-note.mjs` exists precisely so an agent can learn a credential's
|
|
133
|
+
* blast radius safely. That makes them a first-class detection input rather than
|
|
134
|
+
* a clever trick.
|
|
135
|
+
* @param {object|null} notes Parsed secret-notes.json.
|
|
136
|
+
* @returns {Map<string, string>} Tool name to the credential that names it.
|
|
137
|
+
*/
|
|
138
|
+
export function toolsFromSecretNotes(notes) {
|
|
139
|
+
const found = new Map();
|
|
140
|
+
const entries = Object.entries(notes?.notes ?? notes ?? {});
|
|
141
|
+
for (const [secret, note] of entries) {
|
|
142
|
+
const text = typeof note === "string" ? note : JSON.stringify(note ?? "");
|
|
143
|
+
for (const tool of Object.keys(KNOWN_TOOLS)) {
|
|
144
|
+
const pattern = new RegExp(
|
|
145
|
+
`(^|[^A-Za-z0-9-])${tool}([^A-Za-z0-9-]|$)`,
|
|
146
|
+
"iu"
|
|
147
|
+
);
|
|
148
|
+
if (pattern.test(text) && !found.has(tool)) {
|
|
149
|
+
found.set(tool, `usage note on credential "${secret}"`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Tools implied by quality configuration.
|
|
158
|
+
* @param {object|null} config Parsed .lisa.config.json.
|
|
159
|
+
* @returns {Map<string, string>} Tool name to the setting that implies it.
|
|
160
|
+
*/
|
|
161
|
+
export function toolsFromQuality(config) {
|
|
162
|
+
const found = new Map();
|
|
163
|
+
if (config?.quality?.e2eCoverage?.playwright) {
|
|
164
|
+
found.set("playwright", "quality.e2eCoverage.playwright is configured");
|
|
165
|
+
}
|
|
166
|
+
if (config?.quality?.sonar || config?.sonar) {
|
|
167
|
+
found.set("sonar-scanner", "Sonar analysis is configured");
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Everything already declared, by name, so proposals exclude them.
|
|
174
|
+
* @param {object|null} config Parsed .lisa.config.json.
|
|
175
|
+
* @returns {Set<string>} Declared tool names.
|
|
176
|
+
*/
|
|
177
|
+
export function declaredTools(config, surface = "remote") {
|
|
178
|
+
const tools = config?.remoteEnv?.tools ?? {};
|
|
179
|
+
const applies = tool => {
|
|
180
|
+
const surfaces = tool.surfaces;
|
|
181
|
+
if (!Array.isArray(surfaces) || surfaces.length === 0) return true;
|
|
182
|
+
return surfaces.includes(surface);
|
|
183
|
+
};
|
|
184
|
+
// Filtered by surface, because a declaration that applies somewhere else is
|
|
185
|
+
// not coverage here. A local-only `require` entry would otherwise suppress
|
|
186
|
+
// the proposal for a tool genuinely missing from the remote manifest — the
|
|
187
|
+
// detector reporting "already declared" about the one surface where it is
|
|
188
|
+
// not.
|
|
189
|
+
return new Set(
|
|
190
|
+
[...(tools.require ?? []), ...(tools.install ?? [])]
|
|
191
|
+
.filter(applies)
|
|
192
|
+
.map(tool => tool.name)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Collect every signal and subtract what the manifest already covers.
|
|
198
|
+
* @param {string} [cwd] Project root.
|
|
199
|
+
* @returns {Array<{name: string, why: string, evidence: string[]}>} Proposals.
|
|
200
|
+
*/
|
|
201
|
+
export function detectTooling(cwd = process.cwd()) {
|
|
202
|
+
const config = readJson(join(cwd, ".lisa.config.json"));
|
|
203
|
+
const pkg = readJson(join(cwd, "package.json"));
|
|
204
|
+
const mcp = readJson(join(cwd, ".mcp.json"));
|
|
205
|
+
const notes = readJson(join(cwd, ".lisa", "secret-notes.json"));
|
|
206
|
+
|
|
207
|
+
const declared = declaredTools(config);
|
|
208
|
+
const signals = [
|
|
209
|
+
toolsFromScripts(pkg),
|
|
210
|
+
toolsFromMcp(mcp),
|
|
211
|
+
toolsFromSecretNotes(notes),
|
|
212
|
+
toolsFromQuality(config),
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const merged = new Map();
|
|
216
|
+
for (const signal of signals) {
|
|
217
|
+
for (const [tool, evidence] of signal) {
|
|
218
|
+
if (declared.has(tool)) continue;
|
|
219
|
+
const entry = merged.get(tool) ?? { evidence: [] };
|
|
220
|
+
entry.evidence.push(evidence);
|
|
221
|
+
merged.set(tool, entry);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return [...merged.entries()]
|
|
226
|
+
.map(([name, entry]) => ({
|
|
227
|
+
name,
|
|
228
|
+
why: KNOWN_TOOLS[name]?.why ?? "",
|
|
229
|
+
evidence: entry.evidence,
|
|
230
|
+
}))
|
|
231
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The manifest entries an operator can paste, with the parts only they supply.
|
|
236
|
+
*
|
|
237
|
+
* Returns both lists because a platform-specific archive needs both: an
|
|
238
|
+
* `install` entry for the surface it is built for, and a `require` entry so the
|
|
239
|
+
* other surface still asserts the tool instead of silently ignoring it.
|
|
240
|
+
* @param {{name: string}} proposal One detected tool.
|
|
241
|
+
* @returns {{install: object[], require: object[]}} Manifest skeletons.
|
|
242
|
+
*/
|
|
243
|
+
export function proposedEntries(proposal) {
|
|
244
|
+
const viaNpm = KNOWN_TOOLS[proposal.name]?.viaNpm;
|
|
245
|
+
if (viaNpm) {
|
|
246
|
+
// npm resolves per platform, so one entry serves every surface.
|
|
247
|
+
return {
|
|
248
|
+
install: [
|
|
249
|
+
{
|
|
250
|
+
name: proposal.name,
|
|
251
|
+
install: "npm-global",
|
|
252
|
+
package: viaNpm,
|
|
253
|
+
version: "<pin>",
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
require: [],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// A release archive is built for one platform, so it is proposed as
|
|
260
|
+
// remote-install plus a local `require`. Proposing only the install entry
|
|
261
|
+
// produced a manifest that either offered a Linux binary to a laptop or —
|
|
262
|
+
// once narrowed to remote — stopped checking for the tool locally at all.
|
|
263
|
+
// Both halves or neither.
|
|
264
|
+
return {
|
|
265
|
+
install: [
|
|
266
|
+
{
|
|
267
|
+
name: proposal.name,
|
|
268
|
+
version: "<pin>",
|
|
269
|
+
install: "release-tar",
|
|
270
|
+
url: "<release url for this exact version, for the remote platform>",
|
|
271
|
+
sha256: "<sha256 published with that release>",
|
|
272
|
+
surfaces: ["remote"],
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
require: [{ name: proposal.name, surfaces: ["local"] }],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
280
|
+
const proposals = detectTooling();
|
|
281
|
+
if (process.argv.includes("--json")) {
|
|
282
|
+
console.log(JSON.stringify(proposals, null, 2));
|
|
283
|
+
} else if (proposals.length === 0) {
|
|
284
|
+
console.log(
|
|
285
|
+
"Every tool this project appears to use is already declared in remoteEnv.tools."
|
|
286
|
+
);
|
|
287
|
+
} else {
|
|
288
|
+
console.log(
|
|
289
|
+
`${proposals.length} tool(s) look required but are not declared:\n`
|
|
290
|
+
);
|
|
291
|
+
for (const proposal of proposals) {
|
|
292
|
+
console.log(` ${proposal.name}`);
|
|
293
|
+
console.log(` why: ${proposal.why}`);
|
|
294
|
+
for (const evidence of proposal.evidence) {
|
|
295
|
+
console.log(` evidence: ${evidence}`);
|
|
296
|
+
}
|
|
297
|
+
const entries = proposedEntries(proposal);
|
|
298
|
+
for (const entry of entries.install) {
|
|
299
|
+
console.log(` tools.install: ${JSON.stringify(entry)}`);
|
|
300
|
+
}
|
|
301
|
+
for (const entry of entries.require) {
|
|
302
|
+
console.log(` tools.require: ${JSON.stringify(entry)}`);
|
|
303
|
+
}
|
|
304
|
+
console.log("");
|
|
305
|
+
}
|
|
306
|
+
console.log(
|
|
307
|
+
"Nothing has been written or installed. Confirm each entry, supply the\n" +
|
|
308
|
+
"pin and checksum, and add it to remoteEnv.tools in .lisa.config.json."
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -87,6 +87,14 @@ bash scripts/lisa-remote-env/session-start.sh # guard; delegates to --phase=se
|
|
|
87
87
|
|
|
88
88
|
The selection comes from the surface's `materializeAt` capability in `lisa-secrets-access`, not from its name, so adding a surface does not mean editing a branch. The hook is committed to the repository, so it also fires on a developer's machine — it exits `0` immediately there rather than failing, because a correct local session must not look broken.
|
|
89
89
|
|
|
90
|
+
## Detect before you provision
|
|
91
|
+
|
|
92
|
+
Run `/lisa:detect-tooling` first, every time. The manifest is the only thing that puts a binary on `PATH`, and nothing populates it — so a project ships npm scripts invoking `maestro`, wires an MCP server whose CLI it also shells out to, and configures Playwright thresholds, while `remoteEnv.tools` stays empty and each of those fails at the moment of use instead of at setup.
|
|
93
|
+
|
|
94
|
+
This repository has paid for that twice: `gh` was declared nowhere and a cloud session could not commit anything; `tar` was needed by an install method and asserted by nothing.
|
|
95
|
+
|
|
96
|
+
The detector proposes and a human decides. It writes nothing, so provisioning still only ever happens from a reviewed, pinned, checksummed entry.
|
|
97
|
+
|
|
90
98
|
## Toolchain manifest — two entry kinds
|
|
91
99
|
|
|
92
100
|
```json
|
|
@@ -165,9 +173,9 @@ Because `release-tar` unpacks with `tar` rather than `unzip`, a project using it
|
|
|
165
173
|
- **Prefer what is there.** A second Node beside the image's creates PATH ambiguity and wastes container time.
|
|
166
174
|
- **Pin and checksum together, in one reviewed commit.** A version bump that does not move the checksum fails before installation, not after. An archive is verified *before* it is unpacked, so unexpected contents never reach a directory on PATH.
|
|
167
175
|
|
|
168
|
-
## Tooling is never
|
|
176
|
+
## Tooling is never *authorised* by secret notes
|
|
169
177
|
|
|
170
|
-
Deliberately rejected:
|
|
178
|
+
Deliberately rejected — notes may not cause an install:
|
|
171
179
|
|
|
172
180
|
- Most tooling has **no credential** — `jq`, ripgrep, browsers, a linter — so notes could only ever cover a subset, and a second mechanism would be needed anyway.
|
|
173
181
|
- Most credentials **imply no tool**: a webhook is curl'd, a REST key needs no CLI.
|
|
@@ -177,6 +185,14 @@ Deliberately rejected:
|
|
|
177
185
|
|
|
178
186
|
**Notes are an assertion target instead.** At verify time, a tool that declares a credential which is not materialized is an **error**; a credential materialized with no declared consumer is a **warning**. The arrow points the safe way: the repository declares intent, and the provider is checked against it.
|
|
179
187
|
|
|
188
|
+
### Reading a note as *evidence* is a different act
|
|
189
|
+
|
|
190
|
+
`lisa-detect-tooling` does read notes, and this section is why it may. Every objection above is an objection to a note **causing** something: authorising an install, being the sole source, deciding a version. None of that changes.
|
|
191
|
+
|
|
192
|
+
What the detector produces is a proposal with `<pin>`, `<release url>` and `<sha256>` left blank, printed for a human. It cannot install, cannot write config, and its output is inert until someone commits a pinned, checksummed entry that `assertPinned` then enforces. Provider write access therefore buys an attacker one line of text in a proposal a human reads — not arbitrary install access, which is the specific escalation this section exists to block.
|
|
193
|
+
|
|
194
|
+
The other objections stay true and stay unfixed: most tooling has no credential and most credentials imply no tool, so notes are one signal among four and the weakest of them. They are read *after* npm scripts and MCP servers, and a note alone should be the least persuasive reason to add anything.
|
|
195
|
+
|
|
180
196
|
## Provisioning tiers
|
|
181
197
|
|
|
182
198
|
Preference order, falling back:
|
|
@@ -221,8 +221,9 @@ function installTool(tool, binDir) {
|
|
|
221
221
|
* @param {boolean} dryRun Whether to plan only.
|
|
222
222
|
* @returns {Array<object>} The plan that was applied.
|
|
223
223
|
*/
|
|
224
|
-
function applyToolchain(tools, dryRun) {
|
|
225
|
-
const
|
|
224
|
+
function applyToolchain(tools, dryRun, options = {}) {
|
|
225
|
+
const { surface = "remote", consent = true } = options;
|
|
226
|
+
const plan = planToolchain(tools, probe, surface);
|
|
226
227
|
const blocked = plan.filter(
|
|
227
228
|
p => p.action === "missing" || p.action === "invalid"
|
|
228
229
|
);
|
|
@@ -234,6 +235,34 @@ function applyToolchain(tools, dryRun) {
|
|
|
234
235
|
mkdirSync(binDir, { recursive: true, mode: 0o755 });
|
|
235
236
|
const byName = new Map((tools.install ?? []).map(t => [t.name, t]));
|
|
236
237
|
|
|
238
|
+
// Installing is a different act on a laptop than in a container. A container
|
|
239
|
+
// is disposable and provisioning it silently is the whole point; a developer
|
|
240
|
+
// machine belongs to a person, and putting pinned binaries in their
|
|
241
|
+
// ~/.local/bin without being asked is not ours to decide. So the same
|
|
242
|
+
// manifest drives both, and only the consent differs.
|
|
243
|
+
//
|
|
244
|
+
// Reporting still happens either way — knowing the machine diverges from what
|
|
245
|
+
// the project declares is most of the value, and is what nothing did before.
|
|
246
|
+
if (!consent) {
|
|
247
|
+
const missing = plan.filter(step => step.action === "install");
|
|
248
|
+
if (missing.length === 0) {
|
|
249
|
+
console.log(" every declared tool is already present");
|
|
250
|
+
return plan;
|
|
251
|
+
}
|
|
252
|
+
console.log(
|
|
253
|
+
` ${missing.length} declared tool(s) are missing or below their pin:`
|
|
254
|
+
);
|
|
255
|
+
for (const step of missing) {
|
|
256
|
+
const tool = byName.get(step.name);
|
|
257
|
+
console.log(` ${step.name}${tool?.version ? ` ${tool.version}` : ""}`);
|
|
258
|
+
}
|
|
259
|
+
console.log(
|
|
260
|
+
"\n Not installing without confirmation. Re-run with --install-tools\n" +
|
|
261
|
+
" to provision them, or install them yourself."
|
|
262
|
+
);
|
|
263
|
+
return plan;
|
|
264
|
+
}
|
|
265
|
+
|
|
237
266
|
// Installing a binary somewhere nothing looks is the same as not installing
|
|
238
267
|
// it. `~/.local/bin` is on PATH by default on a developer workstation and is
|
|
239
268
|
// NOT on a minimal container, so the toolchain step reported `install bws`,
|
|
@@ -526,6 +555,40 @@ export function emitClaudeWeb({ bootstrapKey }) {
|
|
|
526
555
|
].join("\n");
|
|
527
556
|
}
|
|
528
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Report tooling the project appears to need but has not declared.
|
|
560
|
+
*
|
|
561
|
+
* Called before the toolchain is applied, because the manifest is the only
|
|
562
|
+
* thing that puts a binary on PATH and nothing populates it — so provisioning
|
|
563
|
+
* "the declared toolchain" happily succeeds while the tool a project actually
|
|
564
|
+
* uses is absent, and fails later at the moment of use instead of here.
|
|
565
|
+
*
|
|
566
|
+
* Informational, never blocking. The detector proposes and a human decides, so
|
|
567
|
+
* an undeclared tool must not stop a setup that was asked to provision what IS
|
|
568
|
+
* declared; failing here would make a advisory signal into a gate nobody chose.
|
|
569
|
+
* Its own absence is silent for the same reason: an older installed copy of the
|
|
570
|
+
* skills has no detector, and that is not a reason to refuse to provision.
|
|
571
|
+
* @param {Function} [exec] Seam for tests.
|
|
572
|
+
*/
|
|
573
|
+
function reportUndeclaredTooling(exec = execFileSync) {
|
|
574
|
+
let script;
|
|
575
|
+
try {
|
|
576
|
+
script = siblingScript("lisa-detect-tooling", "detect-tooling.mjs");
|
|
577
|
+
} catch {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const out = exec("node", [script], {
|
|
582
|
+
encoding: "utf8",
|
|
583
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
584
|
+
});
|
|
585
|
+
const text = String(out).trim();
|
|
586
|
+
if (text) console.log(`\n${text}\n`);
|
|
587
|
+
} catch {
|
|
588
|
+
// A detector that cannot run is a missing hint, not a failed setup.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
529
592
|
/**
|
|
530
593
|
* Decide which phases this invocation runs.
|
|
531
594
|
*
|
|
@@ -640,8 +703,16 @@ async function main() {
|
|
|
640
703
|
}
|
|
641
704
|
|
|
642
705
|
if (phases.includes("toolchain")) {
|
|
706
|
+
reportUndeclaredTooling();
|
|
643
707
|
console.log("Toolchain:");
|
|
644
|
-
|
|
708
|
+
// A remote container consents by construction: it is disposable, nobody is
|
|
709
|
+
// watching, and provisioning it is why the script exists. Locally the
|
|
710
|
+
// operator opts in per run.
|
|
711
|
+
const remote = Boolean(materializeAt);
|
|
712
|
+
applyToolchain(cfg.tools, dryRun, {
|
|
713
|
+
surface: remote ? "remote" : "local",
|
|
714
|
+
consent: remote || process.argv.includes("--install-tools"),
|
|
715
|
+
});
|
|
645
716
|
}
|
|
646
717
|
|
|
647
718
|
if (phases.includes("secrets")) {
|
|
@@ -124,18 +124,65 @@ function planInstallable(tool, found) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Surfaces a manifest entry may name. */
|
|
128
|
+
const KNOWN_SURFACES = new Set(["local", "remote"]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether a manifest entry applies to the surface being provisioned.
|
|
132
|
+
*
|
|
133
|
+
* One declaration per tool, not one block per surface. Most tools a project
|
|
134
|
+
* needs are needed *everywhere* — a Maestro or Sonar CLI is as required on a
|
|
135
|
+
* laptop as in a container — and duplicated blocks drift, which this repository
|
|
136
|
+
* has paid for more than once. What actually differs between surfaces is the
|
|
137
|
+
* install method and, more importantly, consent: a disposable container may
|
|
138
|
+
* install silently, a developer's machine may not.
|
|
139
|
+
*
|
|
140
|
+
* Omitting `surfaces` means every surface, because that is true of most tools
|
|
141
|
+
* and the failure of forgetting it should be "checked somewhere unnecessary"
|
|
142
|
+
* rather than "silently absent where it was needed".
|
|
143
|
+
* @param {object} tool Manifest entry.
|
|
144
|
+
* @param {string} surface Surface being provisioned.
|
|
145
|
+
* @returns {boolean} Whether this entry applies.
|
|
146
|
+
*/
|
|
147
|
+
export function appliesToSurface(tool, surface) {
|
|
148
|
+
const surfaces = tool.surfaces;
|
|
149
|
+
if (surfaces === undefined) return true;
|
|
150
|
+
// A typo must not silently widen scope. Treating any non-array as "omitted"
|
|
151
|
+
// meant `surfaces: "remote"` — an easy thing to write — quietly applied the
|
|
152
|
+
// entry to every surface, which for a platform-specific archive means
|
|
153
|
+
// offering a Linux binary to a laptop. Absent means everywhere; malformed
|
|
154
|
+
// means stop.
|
|
155
|
+
if (!Array.isArray(surfaces)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`${tool.name}: surfaces must be an array, got ${typeof surfaces}.\n` +
|
|
158
|
+
`Omit it to mean every surface; write ["remote"] or ["local"] to narrow.`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (surfaces.length === 0) return true;
|
|
162
|
+
const unknown = surfaces.filter(entry => !KNOWN_SURFACES.has(entry));
|
|
163
|
+
if (unknown.length > 0) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`${tool.name}: unknown surface(s) ${unknown.join(", ")}.\n` +
|
|
166
|
+
`Known: ${[...KNOWN_SURFACES].join(", ")}.`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return surfaces.includes(surface);
|
|
170
|
+
}
|
|
171
|
+
|
|
127
172
|
/**
|
|
128
173
|
* Produce the complete plan for a toolchain manifest.
|
|
129
174
|
* @param {{require?: object[], install?: object[]}} tools Manifest.
|
|
130
175
|
* @param {(name: string) => {version: string|null, present: boolean}} probe Version probe.
|
|
131
176
|
* @returns {Array<{name: string, action: string, reason: string}>} Ordered decisions.
|
|
132
177
|
*/
|
|
133
|
-
export function planToolchain(tools, probe) {
|
|
178
|
+
export function planToolchain(tools, probe, surface = "remote") {
|
|
134
179
|
const plan = [];
|
|
135
180
|
for (const tool of tools.require ?? [])
|
|
136
|
-
|
|
181
|
+
if (appliesToSurface(tool, surface))
|
|
182
|
+
plan.push(planRequired(tool, probe(tool.name)));
|
|
137
183
|
for (const tool of tools.install ?? [])
|
|
138
|
-
|
|
184
|
+
if (appliesToSurface(tool, surface))
|
|
185
|
+
plan.push(planInstallable(tool, probe(tool.name)));
|
|
139
186
|
return plan;
|
|
140
187
|
}
|
|
141
188
|
|
package/scripts/build-plugins.sh
CHANGED
|
@@ -90,6 +90,38 @@ if [ -f "$SRC_DIR/base/hooks/threshold-ratchet.mjs" ]; then
|
|
|
90
90
|
done
|
|
91
91
|
fi
|
|
92
92
|
|
|
93
|
+
# Enforcement guards for host projects.
|
|
94
|
+
#
|
|
95
|
+
# These three are declared in the Lisa plugin, so a container whose plugin
|
|
96
|
+
# install fails runs with no PreToolUse enforcement at all — silently. Lisa
|
|
97
|
+
# closes that with a repository hook, which reaches a cloud session because it
|
|
98
|
+
# is part of the clone; a host project needs the same thing, and has no
|
|
99
|
+
# plugins/ directory to fall back on.
|
|
100
|
+
#
|
|
101
|
+
# Synced rather than hand-copied, for the same reason the ratchet above is: a
|
|
102
|
+
# unit test asserts byte-equality, so the shipped guard can never drift from the
|
|
103
|
+
# reviewed one.
|
|
104
|
+
HOST_GUARD_DIR="$ROOT_DIR/all/copy-overwrite/scripts/lisa-hooks"
|
|
105
|
+
if [ -d "$SRC_DIR/base/hooks" ]; then
|
|
106
|
+
mkdir -p "$HOST_GUARD_DIR"
|
|
107
|
+
fi
|
|
108
|
+
for guard in block-no-verify parity-safety-net block-shell-json-parsing; do
|
|
109
|
+
if [ -f "$SRC_DIR/base/hooks/$guard.sh" ]; then
|
|
110
|
+
cp "$SRC_DIR/base/hooks/$guard.sh" "$HOST_GUARD_DIR/$guard.sh"
|
|
111
|
+
chmod +x "$HOST_GUARD_DIR/$guard.sh"
|
|
112
|
+
fi
|
|
113
|
+
done
|
|
114
|
+
# The dispatcher itself, so a host project gets the identical entry point.
|
|
115
|
+
# Guarded like the ratchet above: this script is also run against isolated
|
|
116
|
+
# fixtures that carry a source tree but none of the repository's own scripts,
|
|
117
|
+
# and an unconditional copy fails the whole build there.
|
|
118
|
+
if [ -f "$ROOT_DIR/scripts/lisa-enforcement-fallback.sh" ]; then
|
|
119
|
+
mkdir -p "$ROOT_DIR/all/copy-overwrite/scripts"
|
|
120
|
+
cp "$ROOT_DIR/scripts/lisa-enforcement-fallback.sh" \
|
|
121
|
+
"$ROOT_DIR/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh"
|
|
122
|
+
chmod +x "$ROOT_DIR/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh"
|
|
123
|
+
fi
|
|
124
|
+
|
|
93
125
|
# Stack-specific plugins (NO base copy)
|
|
94
126
|
STACKS=(typescript expo nestjs cdk harper-fabric phaser rails)
|
|
95
127
|
for stack in "${STACKS[@]}"; do
|
|
@@ -42,18 +42,57 @@ const forbiddenBasenames = new Set([
|
|
|
42
42
|
".mcp.json",
|
|
43
43
|
"audit.local",
|
|
44
44
|
]);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"https://github.com/CodySwannGT/lisa",
|
|
48
|
-
"git@github.com:CodySwannGT/lisa.git",
|
|
49
|
-
]);
|
|
45
|
+
/** The repository this manifest may be generated from. Not a URL — see below. */
|
|
46
|
+
const canonicalRepository = "CodySwannGT/lisa";
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The `owner/repo` an origin URL points at, or null if it names none.
|
|
50
|
+
*
|
|
51
|
+
* The guard exists to refuse hash-pinning from a fork, which is a question
|
|
52
|
+
* about **identity**. It used to compare the whole origin URL against a list of
|
|
53
|
+
* spellings, which answers a question about **transport** instead — and the two
|
|
54
|
+
* come apart in ordinary situations, not just exotic ones. `remote.origin.url`
|
|
55
|
+
* and `remote.origin.pushurl` can differ, `url.<base>.insteadOf` rewrites what
|
|
56
|
+
* `git remote get-url` returns, and a cloud container clones through a local
|
|
57
|
+
* git proxy. All three are proxied checkouts of the real repository, and all
|
|
58
|
+
* three were refused before a single hash was checked.
|
|
59
|
+
*
|
|
60
|
+
* Comparing the path keeps the property that matters: a fork is
|
|
61
|
+
* `someone-else/lisa` and is still refused, while `https://…`, `git@…` and
|
|
62
|
+
* `http://local_proxy@127.0.0.1:PORT/git/…` all resolve to the same identity.
|
|
63
|
+
* @param {string} url Whatever `git remote get-url` returned.
|
|
64
|
+
* @returns {string|null} `owner/repo`, lowercased, or null.
|
|
65
|
+
*/
|
|
66
|
+
function repositoryPath(url) {
|
|
67
|
+
// scp-style (git@host:owner/repo.git) is not a URL, so it is handled first.
|
|
68
|
+
const scp = /^[^/]+@[^:/]+:(?<path>.+)$/u.exec(url);
|
|
69
|
+
const raw =
|
|
70
|
+
scp?.groups?.path ?? (URL.canParse(url) ? new URL(url).pathname : null);
|
|
71
|
+
if (raw === null) return null;
|
|
72
|
+
const segments = raw
|
|
73
|
+
.replace(/\.git$/u, "")
|
|
74
|
+
.split("/")
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
if (segments.length < 2) return null;
|
|
77
|
+
return segments.slice(-2).join("/").toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The configured value rather than the resolved one, for the same reason the
|
|
81
|
+
// comparison is by path: `url.<base>.insteadOf` rewrites what `git remote
|
|
82
|
+
// get-url` returns, so it answers "how do I reach this" rather than "what is
|
|
83
|
+
// this". Falls back to the resolved URL only if no value is configured.
|
|
84
|
+
const originUrl = execFileSync(
|
|
85
|
+
"git",
|
|
86
|
+
["config", "--get", "remote.origin.url"],
|
|
87
|
+
{ cwd: repoRoot, encoding: "utf8" }
|
|
88
|
+
).trim();
|
|
89
|
+
if (repositoryPath(originUrl) !== canonicalRepository.toLowerCase()) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Refusing to generate from a non-canonical repository.\n` +
|
|
92
|
+
`origin resolves to: ${repositoryPath(originUrl) ?? "(no owner/repo)"}\n` +
|
|
93
|
+
`expected: ${canonicalRepository}\n` +
|
|
94
|
+
`(origin URL was: ${originUrl})`
|
|
95
|
+
);
|
|
57
96
|
}
|
|
58
97
|
|
|
59
98
|
const tracked = execFileSync("git", ["ls-files", "-z"], {
|
|
@@ -34,10 +34,24 @@ if [ -f "$installed" ] && grep -q '"lisa@lisa"' "$installed" 2>/dev/null; then
|
|
|
34
34
|
exit 0
|
|
35
35
|
fi
|
|
36
36
|
|
|
37
|
+
# Where the guards live depends on which repository this is.
|
|
38
|
+
#
|
|
39
|
+
# `plugins/lisa/hooks/` exists only in the Lisa monorepo. A host project gets
|
|
40
|
+
# the same three scripts written into its checkout by `lisa apply`, because a
|
|
41
|
+
# host project whose plugin install fails has exactly the same hole and no
|
|
42
|
+
# `plugins/` directory to fall back on.
|
|
37
43
|
status=0
|
|
38
44
|
for guard in block-no-verify parity-safety-net block-shell-json-parsing; do
|
|
39
|
-
script="
|
|
40
|
-
|
|
45
|
+
script=""
|
|
46
|
+
for candidate in \
|
|
47
|
+
"$repo_root/scripts/lisa-hooks/$guard.sh" \
|
|
48
|
+
"$repo_root/plugins/lisa/hooks/$guard.sh"; do
|
|
49
|
+
if [ -f "$candidate" ]; then
|
|
50
|
+
script="$candidate"
|
|
51
|
+
break
|
|
52
|
+
fi
|
|
53
|
+
done
|
|
54
|
+
[ -n "$script" ] || continue
|
|
41
55
|
# Each guard reads the tool payload on stdin and signals a refusal with exit
|
|
42
56
|
# 2. The payload is replayed to every one of them, and the strongest refusal
|
|
43
57
|
# is returned — a guard that declines must not be able to clear one that did
|