loadout-ai 0.1.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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,453 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, readFile, readdir, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { writeFileAtomically } from "./atomic-file.js";
6
+ import { detectAgents, loadoutHome, userHome } from "./paths.js";
7
+ import { parseRuntimeToolRecipe, renderRuntimeRecipeValue, resolveRuntimeRecipePath, } from "./runtime-tool-recipe.js";
8
+ import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
9
+ const execFileAsync = promisify(execFile);
10
+ function deepFreeze(value) {
11
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
12
+ Object.freeze(value);
13
+ for (const child of Object.values(value))
14
+ deepFreeze(child);
15
+ }
16
+ return value;
17
+ }
18
+ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
19
+ schemaVersion: 1,
20
+ kind: "loadout.reviewed-runtime-recipe",
21
+ id: "graphify",
22
+ displayName: "Graphify",
23
+ version: "0.9.17",
24
+ source: "https://github.com/Graphify-Labs/graphify/tree/ecf1416a7e0ef3a2273a2ad9c796c4e573ca8037",
25
+ sourceRepository: "https://github.com/Graphify-Labs/graphify",
26
+ reviewedCommit: "ecf1416a7e0ef3a2273a2ad9c796c4e573ca8037",
27
+ artifactUrl: "https://files.pythonhosted.org/packages/39/37/a28af8342d78d322511b6307fac2760ca7b9b3c859fa2dcfbaf7c4b5ddf9/graphifyy-0.9.17-py3-none-any.whl",
28
+ artifactSha256: "ef60768aaee7e315d2e2d7da89e971bc1f445f5c8d73ebe4fed550e40a1d687e",
29
+ artifacts: [
30
+ {
31
+ id: "graphify-wheel",
32
+ kind: "python-wheel",
33
+ packageName: "graphifyy",
34
+ url: "https://files.pythonhosted.org/packages/39/37/a28af8342d78d322511b6307fac2760ca7b9b3c859fa2dcfbaf7c4b5ddf9/graphifyy-0.9.17-py3-none-any.whl",
35
+ sha256: "ef60768aaee7e315d2e2d7da89e971bc1f445f5c8d73ebe4fed550e40a1d687e",
36
+ },
37
+ ],
38
+ excludeNewer: "2026-07-17T00:00:00Z",
39
+ license: "MIT",
40
+ trust: {
41
+ reviewType: "manual-source-and-artifact-review",
42
+ reviewedAt: "2026-07-16T00:00:00Z",
43
+ reviewer: "Loadout maintainers",
44
+ provenance: "direct-upstream-and-package-index",
45
+ },
46
+ dependencies: {
47
+ resolution: "cutoff-bounded",
48
+ excludeNewer: "2026-07-17T00:00:00Z",
49
+ hostTools: [
50
+ {
51
+ executable: "uv",
52
+ versionPolicy: "unversioned-host-dependency",
53
+ purpose: "create and manage the isolated Python tool environment",
54
+ },
55
+ ],
56
+ disclosure: "The top-level wheel is SHA-256 pinned; transitive Python dependencies are resolved by uv no newer than the reviewed cutoff and are not fully locked. The host uv version is not pinned.",
57
+ },
58
+ permissions: [
59
+ "install an isolated Python tool below Loadout state",
60
+ "write the generated Graphify skill for explicitly selected agents",
61
+ "read project files only when the user later invokes Graphify",
62
+ ],
63
+ operatingSystems: ["darwin", "linux", "win32"],
64
+ environment: {
65
+ inherit: ["PATH", "SystemRoot", "WINDIR", "TMPDIR", "TMP", "TEMP"],
66
+ fixed: {
67
+ HOME: { root: "userHome", path: [] },
68
+ USERPROFILE: { root: "userHome", path: [] },
69
+ UV_TOOL_DIR: { root: "runtimeRoot", path: ["uv"] },
70
+ UV_TOOL_BIN_DIR: { root: "runtimeRoot", path: ["bin"] },
71
+ UV_CACHE_DIR: { root: "runtimeRoot", path: ["cache"] },
72
+ },
73
+ },
74
+ runtime: {
75
+ root: ["runtime", "{recipeId}"],
76
+ binaryPaths: {
77
+ darwin: ["bin", "graphify"],
78
+ linux: ["bin", "graphify"],
79
+ win32: ["bin", "graphify.exe"],
80
+ },
81
+ },
82
+ commands: {
83
+ install: [
84
+ {
85
+ executable: "uv",
86
+ args: [
87
+ "tool",
88
+ "install",
89
+ "{artifactRequirement}",
90
+ "--exclude-newer",
91
+ "2026-07-17T00:00:00Z",
92
+ ],
93
+ purpose: "install the exact hashed Graphify wheel in isolated Loadout state",
94
+ },
95
+ ],
96
+ register: {
97
+ "claude-code": {
98
+ args: ["install"],
99
+ purpose: "generate the official {agentDisplayName} Graphify skill",
100
+ },
101
+ codex: {
102
+ args: ["install", "--platform", "codex"],
103
+ purpose: "generate the official {agentDisplayName} Graphify skill",
104
+ },
105
+ cursor: {
106
+ args: ["cursor", "install"],
107
+ purpose: "generate the official {agentDisplayName} Graphify skill",
108
+ },
109
+ "gemini-cli": {
110
+ args: ["install", "--platform", "gemini"],
111
+ purpose: "generate the official {agentDisplayName} Graphify skill",
112
+ },
113
+ opencode: {
114
+ args: ["install", "--platform", "opencode"],
115
+ purpose: "generate the official {agentDisplayName} Graphify skill",
116
+ },
117
+ hermes: {
118
+ args: ["install", "--platform", "hermes"],
119
+ purpose: "generate the official {agentDisplayName} Graphify skill",
120
+ },
121
+ "github-copilot": {
122
+ args: ["install", "--platform", "copilot"],
123
+ purpose: "generate the official {agentDisplayName} Graphify skill",
124
+ },
125
+ "kiro-cli": {
126
+ args: ["kiro", "install"],
127
+ purpose: "generate the official {agentDisplayName} Graphify skill",
128
+ },
129
+ },
130
+ },
131
+ healthChecks: [
132
+ {
133
+ executable: "{runtimeBinary}",
134
+ args: ["--version"],
135
+ purpose: "verify Graphify {version}",
136
+ stdoutIncludes: "{version}",
137
+ },
138
+ ],
139
+ targets: {
140
+ "claude-code": { path: [".claude", "skills", "graphify"] },
141
+ codex: { path: [".codex", "skills", "graphify"] },
142
+ cursor: { path: [".cursor", "skills", "graphify"] },
143
+ "gemini-cli": { path: [".gemini", "skills", "graphify"] },
144
+ opencode: { path: [".config", "opencode", "skills", "graphify"] },
145
+ hermes: { path: [".hermes", "skills", "graphify"] },
146
+ "github-copilot": { path: [".copilot", "skills", "graphify"] },
147
+ "kiro-cli": { path: [".kiro", "skills", "graphify"] },
148
+ },
149
+ timeouts: { commandMs: 300_000 },
150
+ snapshotRoots: ["{runtimeRoot}", "{agentTargets}"],
151
+ removal: {
152
+ strategy: "restore-preinstall-snapshot",
153
+ runtimeRoot: "restore-preinstall-state",
154
+ },
155
+ generatedFiles: {
156
+ requiredRelativePaths: ["SKILL.md"],
157
+ rejectSymlinks: true,
158
+ textRewrites: [
159
+ {
160
+ fileExtension: ".md",
161
+ from: "--from graphifyy",
162
+ to: "--from '{artifactRequirement}'",
163
+ mustEliminate: true,
164
+ },
165
+ ],
166
+ },
167
+ guarantees: [
168
+ "dry-run unless --yes and --approve-risk are both supplied",
169
+ "exact top-level wheel URL and SHA-256; dependency uploads bounded by the reviewed cutoff",
170
+ "no API keys or provider credentials inherited by installer subprocesses",
171
+ "agent skill targets and isolated runtime are snapshotted for rollback/removal",
172
+ "generated runtime lookup is rewritten to the same pinned Graphify artifact",
173
+ ],
174
+ }));
175
+ export const REVIEWED_RUNTIME_TOOLS = [GRAPHIFY_RECIPE];
176
+ function statePath(stateHome = loadoutHome()) {
177
+ return join(stateHome, "runtime-tools.json");
178
+ }
179
+ function emptyState() {
180
+ return { schemaVersion: 1, tools: {} };
181
+ }
182
+ async function readState(stateHome) {
183
+ try {
184
+ const value = JSON.parse(await readFile(statePath(stateHome), "utf8"));
185
+ if (!value ||
186
+ typeof value !== "object" ||
187
+ value.schemaVersion !== 1 ||
188
+ !value.tools ||
189
+ typeof value.tools !== "object")
190
+ throw new Error("Runtime tool state is invalid");
191
+ return value;
192
+ }
193
+ catch (error) {
194
+ if (error &&
195
+ typeof error === "object" &&
196
+ "code" in error &&
197
+ error.code === "ENOENT")
198
+ return emptyState();
199
+ throw error;
200
+ }
201
+ }
202
+ async function writeState(state, stateHome) {
203
+ await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
204
+ }
205
+ export function findRuntimeToolRecipe(id) {
206
+ const recipe = REVIEWED_RUNTIME_TOOLS.find((item) => item.id === id);
207
+ if (!recipe)
208
+ throw new Error(`Unknown runtime tool '${id}'. Available: ${REVIEWED_RUNTIME_TOOLS.map((item) => item.id).join(", ")}`);
209
+ return recipe;
210
+ }
211
+ function currentRecipePlatform() {
212
+ if (process.platform === "darwin" ||
213
+ process.platform === "linux" ||
214
+ process.platform === "win32")
215
+ return process.platform;
216
+ throw new Error(`Runtime tool recipes do not support ${process.platform}`);
217
+ }
218
+ function buildRuntimeToolCommands(recipe, runtimeRoot, agents, platform, action) {
219
+ if (action === "remove")
220
+ return [];
221
+ const binary = resolveRuntimeRecipePath(runtimeRoot, recipe.runtime.binaryPaths[platform], platform);
222
+ return [
223
+ ...recipe.commands.install.map((command) => ({
224
+ command: command.executable === "{runtimeBinary}" ? binary : command.executable,
225
+ args: command.args.map((argument) => renderRuntimeRecipeValue(argument, recipe)),
226
+ purpose: renderRuntimeRecipeValue(command.purpose, recipe),
227
+ })),
228
+ ...agents.map((agent) => ({
229
+ command: binary,
230
+ args: recipe.commands.register[agent.id].args.map((argument) => renderRuntimeRecipeValue(argument, recipe)),
231
+ purpose: renderRuntimeRecipeValue(recipe.commands.register[agent.id].purpose.replaceAll("{agentDisplayName}", agent.displayName), recipe),
232
+ })),
233
+ ...recipe.healthChecks.map((command) => ({
234
+ command: command.executable === "{runtimeBinary}" ? binary : command.executable,
235
+ args: command.args.map((argument) => renderRuntimeRecipeValue(argument, recipe)),
236
+ purpose: renderRuntimeRecipeValue(command.purpose, recipe),
237
+ })),
238
+ ];
239
+ }
240
+ export async function planRuntimeTool(id, options = {}) {
241
+ const recipe = findRuntimeToolRecipe(id);
242
+ const action = options.action ?? "install";
243
+ const home = options.home ?? userHome();
244
+ const stateHome = options.stateHome ?? loadoutHome();
245
+ const platform = currentRecipePlatform();
246
+ if (!recipe.operatingSystems.includes(platform))
247
+ throw new Error(`${recipe.displayName} does not support ${platform}`);
248
+ const runtimeRoot = resolveRuntimeRecipePath(stateHome, recipe.runtime.root.map((segment) => segment === "{recipeId}" ? recipe.id : segment), platform);
249
+ const state = await readState(stateHome);
250
+ const existing = state.tools[id];
251
+ if (action === "install" && existing)
252
+ throw new Error(`${recipe.displayName} is already managed by Loadout at ${existing.version}; remove it before reinstalling`);
253
+ if (action === "remove" && !existing)
254
+ throw new Error(`${recipe.displayName} is not managed by Loadout`);
255
+ const detected = options.detectedAgents ?? (await detectAgents());
256
+ const requested = new Set(action === "remove"
257
+ ? existing.agents
258
+ : (options.requestedAgents ??
259
+ detected.filter((agent) => agent.installed).map((agent) => agent.id)));
260
+ const knownDetected = new Map(detected.map((agent) => [agent.id, agent]));
261
+ const missing = [...requested].filter((agent) => action === "install" && !knownDetected.get(agent)?.installed);
262
+ if (missing.length)
263
+ throw new Error(`Requested agent(s) are not detected: ${missing.join(", ")}`);
264
+ const supported = [...requested].filter((agent) => recipe.commands.register[agent] && recipe.targets[agent]);
265
+ if (!supported.length)
266
+ throw new Error(`No detected selected agent has a reviewed ${recipe.displayName} registration recipe`);
267
+ const agents = supported.map((id) => ({
268
+ id,
269
+ displayName: knownDetected.get(id)?.displayName ?? id,
270
+ target: resolveRuntimeRecipePath(home, recipe.targets[id].path, platform),
271
+ }));
272
+ const commands = buildRuntimeToolCommands(recipe, runtimeRoot, agents, platform, action);
273
+ return {
274
+ action,
275
+ recipe,
276
+ agents,
277
+ unsupportedDetectedAgents: [...requested].filter((agent) => !supported.includes(agent)),
278
+ userHome: home,
279
+ stateHome,
280
+ runtimeRoot,
281
+ commands,
282
+ guarantees: recipe.guarantees,
283
+ };
284
+ }
285
+ function runtimeEnvironment(plan) {
286
+ const platform = currentRecipePlatform();
287
+ const inherited = Object.fromEntries(plan.recipe.environment.inherit.flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
288
+ const fixed = Object.fromEntries(Object.entries(plan.recipe.environment.fixed).map(([name, value]) => {
289
+ const root = value.root === "userHome" ? plan.userHome : plan.runtimeRoot;
290
+ return [name, resolveRuntimeRecipePath(root, value.path, platform)];
291
+ }));
292
+ return { ...inherited, ...fixed };
293
+ }
294
+ const defaultRunner = async (command, args, options) => {
295
+ try {
296
+ const result = await execFileAsync(command, [...args], {
297
+ env: options.env,
298
+ timeout: options.timeoutMs,
299
+ windowsHide: true,
300
+ maxBuffer: 2 * 1024 * 1024,
301
+ });
302
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
303
+ }
304
+ catch (error) {
305
+ const result = error;
306
+ if (result.killed)
307
+ throw new Error("Runtime tool command timed out");
308
+ return {
309
+ stdout: result.stdout ?? "",
310
+ stderr: result.stderr ?? "",
311
+ exitCode: typeof result.code === "number" ? result.code : 1,
312
+ };
313
+ }
314
+ };
315
+ async function applyGeneratedFilePolicy(directory, recipe) {
316
+ async function visit(path) {
317
+ for (const entry of await readdir(path, { withFileTypes: true })) {
318
+ const absolute = join(path, entry.name);
319
+ if (entry.isSymbolicLink() && recipe.generatedFiles.rejectSymlinks)
320
+ throw new Error(`Generated ${recipe.displayName} files contain a symlink`);
321
+ if (entry.isDirectory()) {
322
+ await visit(absolute);
323
+ continue;
324
+ }
325
+ if (!entry.isFile())
326
+ continue;
327
+ const rewrites = recipe.generatedFiles.textRewrites.filter((rewrite) => entry.name.endsWith(rewrite.fileExtension));
328
+ if (!rewrites.length)
329
+ continue;
330
+ const before = await readFile(absolute, "utf8");
331
+ let after = before;
332
+ for (const rewrite of rewrites) {
333
+ after = after.replaceAll(rewrite.from, renderRuntimeRecipeValue(rewrite.to, recipe));
334
+ if (rewrite.mustEliminate && after.includes(rewrite.from))
335
+ throw new Error(`Generated ${recipe.displayName} files retained an unpinned runtime lookup`);
336
+ }
337
+ if (after !== before)
338
+ await writeFileAtomically(absolute, after);
339
+ }
340
+ }
341
+ await visit(directory);
342
+ }
343
+ function assertRuntimeToolPlanIntegrity(plan) {
344
+ const recipe = parseRuntimeToolRecipe(plan.recipe);
345
+ const reviewed = findRuntimeToolRecipe(recipe.id);
346
+ if (JSON.stringify(recipe) !== JSON.stringify(reviewed))
347
+ throw new Error(`${recipe.displayName} recipe does not match Loadout's reviewed recipe`);
348
+ const platform = currentRecipePlatform();
349
+ const expectedRuntimeRoot = resolveRuntimeRecipePath(plan.stateHome, recipe.runtime.root.map((segment) => segment === "{recipeId}" ? recipe.id : segment), platform);
350
+ if (plan.runtimeRoot !== expectedRuntimeRoot)
351
+ throw new Error("Runtime tool plan has an unexpected runtime root");
352
+ if (new Set(plan.agents.map((agent) => agent.id)).size !== plan.agents.length)
353
+ throw new Error("Runtime tool plan contains duplicate agent targets");
354
+ for (const agent of plan.agents) {
355
+ const target = recipe.targets[agent.id];
356
+ const registration = recipe.commands.register[agent.id];
357
+ if (!target || !registration)
358
+ throw new Error(`Runtime tool plan contains unsupported agent ${agent.id}`);
359
+ const expectedTarget = resolveRuntimeRecipePath(plan.userHome, target.path, platform);
360
+ if (agent.target !== expectedTarget)
361
+ throw new Error(`Runtime tool plan has an unexpected ${agent.id} target`);
362
+ }
363
+ const expectedCommands = buildRuntimeToolCommands(recipe, expectedRuntimeRoot, plan.agents, platform, plan.action);
364
+ if (JSON.stringify(plan.commands) !== JSON.stringify(expectedCommands))
365
+ throw new Error("Runtime tool plan commands do not match the reviewed recipe");
366
+ if (JSON.stringify(plan.guarantees) !== JSON.stringify(recipe.guarantees))
367
+ throw new Error("Runtime tool plan guarantees do not match the recipe");
368
+ }
369
+ export async function applyRuntimeToolPlan(plan, options) {
370
+ if (!options.approveRisk)
371
+ throw new Error("Runtime tool installation requires explicit --approve-risk");
372
+ assertRuntimeToolPlanIntegrity(plan);
373
+ const state = await readState(plan.stateHome);
374
+ if (plan.action === "remove") {
375
+ const installed = state.tools[plan.recipe.id];
376
+ if (!installed)
377
+ throw new Error(`${plan.recipe.displayName} is not managed by Loadout`);
378
+ const snapshot = await readSnapshot(installed.snapshotId);
379
+ await restoreSnapshot(snapshot);
380
+ delete state.tools[plan.recipe.id];
381
+ await writeState(state, plan.stateHome);
382
+ return { action: "remove", snapshotId: snapshot.id };
383
+ }
384
+ if (state.tools[plan.recipe.id])
385
+ throw new Error(`${plan.recipe.displayName} is already managed by Loadout`);
386
+ let snapshot;
387
+ try {
388
+ const snapshotRoots = plan.recipe.snapshotRoots.flatMap((root) => root === "{runtimeRoot}"
389
+ ? [plan.runtimeRoot]
390
+ : plan.agents.map((agent) => agent.target));
391
+ snapshot = await createSnapshot(snapshotRoots);
392
+ const runner = options.runner ?? defaultRunner;
393
+ const env = runtimeEnvironment(plan);
394
+ const healthCheckStart = plan.commands.length - plan.recipe.healthChecks.length;
395
+ for (const [index, item] of plan.commands.entries()) {
396
+ const result = await runner(item.command, item.args, {
397
+ env,
398
+ timeoutMs: options.timeoutMs ?? plan.recipe.timeouts.commandMs,
399
+ });
400
+ if (result.exitCode !== 0)
401
+ throw new Error(`${item.purpose} failed${result.stderr.trim() ? `: ${result.stderr.trim().slice(0, 500)}` : ""}`);
402
+ const healthCheck = index >= healthCheckStart
403
+ ? plan.recipe.healthChecks[index - healthCheckStart]
404
+ : undefined;
405
+ const expectedOutput = healthCheck
406
+ ? renderRuntimeRecipeValue(healthCheck.stdoutIncludes, plan.recipe)
407
+ : undefined;
408
+ if (expectedOutput && !result.stdout.includes(expectedOutput))
409
+ throw new Error(`Installed ${plan.recipe.displayName} version did not match ${plan.recipe.version}`);
410
+ }
411
+ for (const agent of plan.agents) {
412
+ for (const relativePath of plan.recipe.generatedFiles
413
+ .requiredRelativePaths)
414
+ await access(join(agent.target, relativePath));
415
+ await applyGeneratedFilePolicy(agent.target, plan.recipe);
416
+ }
417
+ state.tools[plan.recipe.id] = {
418
+ version: plan.recipe.version,
419
+ installedAt: new Date().toISOString(),
420
+ snapshotId: snapshot.id,
421
+ agents: plan.agents.map((agent) => agent.id),
422
+ runtimeRoot: plan.runtimeRoot,
423
+ };
424
+ await writeState(state, plan.stateHome);
425
+ return { action: "install", snapshotId: snapshot.id };
426
+ }
427
+ catch (error) {
428
+ if (snapshot)
429
+ await restoreSnapshot(snapshot).catch(() => undefined);
430
+ else
431
+ await rm(plan.runtimeRoot, { recursive: true, force: true });
432
+ throw error;
433
+ }
434
+ }
435
+ export function formatRuntimeToolPlan(plan) {
436
+ return [
437
+ `${plan.recipe.displayName} ${plan.recipe.version} — ${plan.action}`,
438
+ `Source: ${plan.recipe.source}`,
439
+ `Artifact SHA-256: ${plan.recipe.artifactSha256}`,
440
+ `Targets: ${plan.agents.map((agent) => `${agent.displayName} (${agent.target})`).join(", ")}`,
441
+ ...(plan.unsupportedDetectedAgents.length
442
+ ? [
443
+ `Not registered (no reviewed adapter): ${plan.unsupportedDetectedAgents.join(", ")}`,
444
+ ]
445
+ : []),
446
+ ...(plan.commands.length
447
+ ? plan.commands.map((item, index) => `${index + 1}. ${item.purpose}: ${item.command} ${item.args.join(" ")}`)
448
+ : [
449
+ "Restore the pre-install snapshot and remove isolated runtime state.",
450
+ ]),
451
+ ...plan.guarantees.map((guarantee) => `Safety: ${guarantee}`),
452
+ ].join("\n");
453
+ }
@@ -0,0 +1,234 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { basename, relative, resolve } from "node:path";
3
+ const BINARY_EXTENSIONS = /\.(?:exe|dll|so|dylib|bin|msi|dmg|appimage)$/i;
4
+ const SCRIPT_EXTENSIONS = /\.(?:sh|bash|zsh|fish|ps1|bat|cmd|py|rb|pl)$/i;
5
+ const DOMAIN_PATTERN = /\bhttps?:\/\/([^\s/:'"`<>)}\]]+)/gi;
6
+ const ENV_PATTERNS = [
7
+ /process\.env\.([A-Z_][A-Z0-9_]*)/g,
8
+ /process\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g,
9
+ /\b(?:env|environment)\s*[:=]\s*["']?([A-Z_][A-Z0-9_]*)/gi,
10
+ /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g,
11
+ ];
12
+ const SECRET_PATTERNS = [
13
+ {
14
+ name: "private key",
15
+ pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
16
+ },
17
+ { name: "GitHub token", pattern: /\bgh[oprsu]_[A-Za-z0-9_]{30,}\b/ },
18
+ { name: "AWS access key", pattern: /\bAKIA[0-9A-Z]{16}\b/ },
19
+ {
20
+ name: "generic API key assignment",
21
+ pattern: /\b(?:api[_-]?key|secret|token)["']?\s*[:=]\s*["'][A-Za-z0-9_./+=-]{20,}["']/i,
22
+ },
23
+ ];
24
+ const SUSPICIOUS_INSTRUCTIONS = [
25
+ {
26
+ name: "instruction override",
27
+ pattern: /\bignore (?:all |any )?(?:previous|prior|system|developer) instructions?\b/i,
28
+ },
29
+ {
30
+ name: "credential extraction",
31
+ pattern: /\b(?:read|print|send|upload|exfiltrate)\b.{0,80}\b(?:credentials?|tokens?|secrets?|\.ssh|\.aws)\b/i,
32
+ },
33
+ {
34
+ name: "hidden destructive command",
35
+ pattern: /\b(?:rm\s+-rf|del\s+\/s|format\s+[a-z]:)\b/i,
36
+ },
37
+ ];
38
+ export function detectSecretKinds(content) {
39
+ return SECRET_PATTERNS.filter((check) => check.pattern.test(content)).map((check) => check.name);
40
+ }
41
+ async function files(root) {
42
+ const result = new Map();
43
+ const base = resolve(root);
44
+ try {
45
+ const rootInfo = await stat(base);
46
+ if (rootInfo.isFile() && rootInfo.size <= 2_000_000) {
47
+ result.set(basename(base), await readFile(base));
48
+ return result;
49
+ }
50
+ }
51
+ catch {
52
+ return result;
53
+ }
54
+ async function visit(directory, depth) {
55
+ if (depth > 10)
56
+ return;
57
+ let entries;
58
+ try {
59
+ entries = await readdir(directory, { withFileTypes: true });
60
+ }
61
+ catch {
62
+ return;
63
+ }
64
+ for (const entry of entries) {
65
+ if (entry.name === ".git" || entry.name === "node_modules")
66
+ continue;
67
+ const absolute = resolve(directory, entry.name);
68
+ if (entry.isSymbolicLink())
69
+ continue;
70
+ if (entry.isDirectory()) {
71
+ await visit(absolute, depth + 1);
72
+ continue;
73
+ }
74
+ if (!entry.isFile())
75
+ continue;
76
+ try {
77
+ const info = await stat(absolute);
78
+ if (info.size > 2_000_000)
79
+ continue;
80
+ result.set(relative(base, absolute).split("\\").join("/"), await readFile(absolute));
81
+ }
82
+ catch {
83
+ /* repository may change while being inspected */
84
+ }
85
+ }
86
+ }
87
+ await visit(base, 0);
88
+ return result;
89
+ }
90
+ /** Scan only the component sources selected by a plan, never unrelated repository files. */
91
+ export async function analyzeInstallPlanSafety(plan) {
92
+ const roots = [...new Set(plan.files.map((file) => file.source))];
93
+ const analyses = await Promise.all(roots.map((root) => analyzeUpdateSafety(undefined, root)));
94
+ const findings = analyses.flatMap((analysis) => analysis.findings);
95
+ const unique = new Map();
96
+ for (const finding of findings) {
97
+ const key = `${finding.severity}:${finding.category}:${finding.message}:${finding.paths.join(",")}:${finding.names?.join(",") ?? ""}`;
98
+ unique.set(key, finding);
99
+ }
100
+ const result = [...unique.values()];
101
+ return {
102
+ approvalRequired: result.some((finding) => finding.severity === "blocking"),
103
+ findings: result,
104
+ };
105
+ }
106
+ function changedPaths(oldFiles, newFiles) {
107
+ return [...new Set([...oldFiles.keys(), ...newFiles.keys()])]
108
+ .filter((path) => {
109
+ const oldValue = oldFiles.get(path);
110
+ const newValue = newFiles.get(path);
111
+ return !oldValue || !newValue || !oldValue.equals(newValue);
112
+ })
113
+ .sort();
114
+ }
115
+ function collectDomains(content) {
116
+ return [...content.matchAll(DOMAIN_PATTERN)]
117
+ .map((match) => match[1].toLowerCase())
118
+ .filter(Boolean);
119
+ }
120
+ function collectEnvironmentNames(content) {
121
+ const names = new Set();
122
+ for (const pattern of ENV_PATTERNS) {
123
+ pattern.lastIndex = 0;
124
+ for (const match of content.matchAll(pattern)) {
125
+ const name = (match[1] ?? match[2] ?? "").toUpperCase();
126
+ if (name)
127
+ names.add(name);
128
+ }
129
+ }
130
+ return [...names].sort();
131
+ }
132
+ /** Analyze changed repository files without running package code or revealing secret values. */
133
+ export async function analyzeUpdateSafety(oldPath, newPath) {
134
+ const oldFiles = oldPath ? await files(oldPath) : new Map();
135
+ const newFiles = await files(newPath);
136
+ const changed = changedPaths(oldFiles, newFiles);
137
+ const findings = [];
138
+ const domains = new Set();
139
+ const envNames = new Set();
140
+ const hookPaths = [];
141
+ const scriptPaths = [];
142
+ const binaryPaths = [];
143
+ const secretPaths = [];
144
+ const secretKinds = new Set();
145
+ const instructionPaths = [];
146
+ const instructionKinds = new Set();
147
+ for (const path of changed) {
148
+ const lower = path.toLowerCase();
149
+ const content = newFiles.get(path)?.toString("utf8") ?? "";
150
+ if (BINARY_EXTENSIONS.test(path) ||
151
+ lower.includes("/bin/") ||
152
+ lower.startsWith("bin/"))
153
+ binaryPaths.push(path);
154
+ if (SCRIPT_EXTENSIONS.test(path) ||
155
+ lower.includes("/scripts/") ||
156
+ lower.startsWith("scripts/"))
157
+ scriptPaths.push(path);
158
+ if (lower.includes("hook") ||
159
+ (lower === "package.json" &&
160
+ /"(?:pre|post)?(?:install|publish|pack|prepare)"\s*:/.test(content)))
161
+ hookPaths.push(path);
162
+ for (const domain of collectDomains(content))
163
+ domains.add(domain);
164
+ for (const name of collectEnvironmentNames(content))
165
+ envNames.add(name);
166
+ for (const kind of detectSecretKinds(content)) {
167
+ secretPaths.push(path);
168
+ secretKinds.add(kind);
169
+ }
170
+ for (const check of SUSPICIOUS_INSTRUCTIONS)
171
+ if (check.pattern.test(content)) {
172
+ instructionPaths.push(path);
173
+ instructionKinds.add(check.name);
174
+ }
175
+ }
176
+ if (binaryPaths.length)
177
+ findings.push({
178
+ severity: "blocking",
179
+ category: "binary",
180
+ message: "Update adds or changes executable/binary files.",
181
+ paths: binaryPaths,
182
+ });
183
+ if (scriptPaths.length)
184
+ findings.push({
185
+ severity: "blocking",
186
+ category: "script",
187
+ message: "Update adds or changes scripts that may execute during setup or use.",
188
+ paths: scriptPaths,
189
+ });
190
+ if (hookPaths.length)
191
+ findings.push({
192
+ severity: "blocking",
193
+ category: "hook",
194
+ message: "Update changes hooks or package lifecycle configuration.",
195
+ paths: hookPaths,
196
+ });
197
+ if (domains.size)
198
+ findings.push({
199
+ severity: "blocking",
200
+ category: "domain",
201
+ message: "Update references network domains; verify and explicitly approve the new network surface.",
202
+ paths: changed.filter((path) => collectDomains(newFiles.get(path)?.toString("utf8") ?? "").length > 0),
203
+ names: [...domains].sort(),
204
+ });
205
+ if (envNames.size)
206
+ findings.push({
207
+ severity: "blocking",
208
+ category: "environment",
209
+ message: "Update references environment-variable names; values are not inspected or displayed, and explicit approval is required.",
210
+ paths: changed.filter((path) => collectEnvironmentNames(newFiles.get(path)?.toString("utf8") ?? "")
211
+ .length > 0),
212
+ names: [...envNames].sort(),
213
+ });
214
+ if (secretPaths.length)
215
+ findings.push({
216
+ severity: "blocking",
217
+ category: "secret",
218
+ message: "Content appears to contain embedded secret material; secret values are hidden.",
219
+ paths: [...new Set(secretPaths)].sort(),
220
+ names: [...secretKinds].sort(),
221
+ });
222
+ if (instructionPaths.length)
223
+ findings.push({
224
+ severity: "blocking",
225
+ category: "instruction",
226
+ message: "Content contains suspicious instruction patterns that require human review.",
227
+ paths: [...new Set(instructionPaths)].sort(),
228
+ names: [...instructionKinds].sort(),
229
+ });
230
+ return {
231
+ approvalRequired: findings.some((finding) => finding.severity === "blocking"),
232
+ findings,
233
+ };
234
+ }