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,78 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { resolve } from "node:path";
4
+ const execFileAsync = promisify(execFile);
5
+ /**
6
+ * Run an explicitly approved command in a disposable local Docker container.
7
+ * Source is mounted read-only; no host environment, socket, network, or
8
+ * writable source path is exposed. This executes nothing unless approveRisk is
9
+ * true and the caller supplies an image they have reviewed/pinned.
10
+ */
11
+ export async function runDisposableSandbox(options) {
12
+ if (!options.approveRisk)
13
+ throw new Error("Sandbox execution requires explicit --approve-risk");
14
+ if (!options.image.trim() || /\s/.test(options.image))
15
+ throw new Error("Sandbox image must be one reviewed image reference");
16
+ if (!options.command.length || options.command.some((item) => !item.trim()))
17
+ throw new Error("Sandbox command must contain at least one non-empty argument");
18
+ const sourceDirectory = resolve(options.sourceDirectory);
19
+ const timeoutMs = options.timeoutMs ?? 120_000;
20
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 900_000)
21
+ throw new Error("Sandbox timeout must be between 1000ms and 900000ms");
22
+ const args = [
23
+ "run",
24
+ "--rm",
25
+ "--network",
26
+ "none",
27
+ "--read-only",
28
+ "--tmpfs",
29
+ "/tmp:rw,noexec,nosuid,size=64m",
30
+ "--cap-drop",
31
+ "ALL",
32
+ "--security-opt",
33
+ "no-new-privileges",
34
+ "--pids-limit",
35
+ "128",
36
+ "--memory",
37
+ "512m",
38
+ "--cpus",
39
+ "1",
40
+ "--user",
41
+ "65532:65532",
42
+ "--mount",
43
+ `type=bind,src=${sourceDirectory},dst=/input,readonly`,
44
+ options.image,
45
+ ...options.command,
46
+ ];
47
+ const runner = options.runner ??
48
+ (async (dockerArgs, timeout) => {
49
+ try {
50
+ const result = await execFileAsync("docker", dockerArgs, {
51
+ timeout,
52
+ windowsHide: true,
53
+ // Deliberately do not spread process.env into an untrusted process.
54
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin" },
55
+ maxBuffer: 2 * 1024 * 1024,
56
+ });
57
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
58
+ }
59
+ catch (error) {
60
+ const result = error;
61
+ if (result.killed)
62
+ throw new Error(`Sandbox timed out after ${timeout}ms`);
63
+ return {
64
+ stdout: result.stdout ?? "",
65
+ stderr: result.stderr ?? String(error),
66
+ exitCode: typeof result.code === "number" ? result.code : 1,
67
+ };
68
+ }
69
+ });
70
+ const result = await runner(args, timeoutMs);
71
+ return {
72
+ image: options.image,
73
+ command: options.command,
74
+ stdout: result.stdout,
75
+ stderr: result.stderr,
76
+ exitCode: result.exitCode,
77
+ };
78
+ }
@@ -0,0 +1,264 @@
1
+ import { execFile } from "node:child_process";
2
+ import { rm } from "node:fs/promises";
3
+ import { dirname, win32, join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { writeFileAtomically } from "./atomic-file.js";
6
+ import { ensureDirectory, loadoutHome, userHome } from "./paths.js";
7
+ import { createSnapshot } from "./snapshot.js";
8
+ import { beginTransaction, completeTransaction, markTransactionCommitting, recoverPendingTransactions, rollbackTransaction, } from "./transaction.js";
9
+ const execFileAsync = promisify(execFile);
10
+ function parseTime(value) {
11
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
12
+ const hour = Number(match?.[1]);
13
+ const minute = Number(match?.[2]);
14
+ if (!match || hour > 23 || minute > 59)
15
+ throw new Error("--time must use 24-hour HH:MM");
16
+ return { hour, minute, text: value };
17
+ }
18
+ function xml(value) {
19
+ return value
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;");
24
+ }
25
+ function systemd(value) {
26
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
27
+ }
28
+ export function planNativeScheduler(action, options = {}) {
29
+ const platform = options.platform ?? process.platform;
30
+ if (!["darwin", "linux", "win32"].includes(platform))
31
+ throw new Error(`Native scheduling is unsupported on '${platform}'. Supported: macOS, Windows, Linux.`);
32
+ const selectedPlatform = platform;
33
+ const time = parseTime(options.time ?? "09:00");
34
+ const home = options.home ?? userHome(process.env, platform);
35
+ const stateHome = options.stateHome ?? loadoutHome(process.env, platform);
36
+ const nodePath = options.nodePath ?? process.execPath;
37
+ const cliPath = options.cliPath ?? process.argv[1];
38
+ const launcher = options.launcher ?? [nodePath, cliPath];
39
+ if (!launcher.length || launcher.some((item) => !item))
40
+ throw new Error("Scheduler launcher arguments must be non-empty");
41
+ const job = options.job ?? "updates";
42
+ const nativeId = `loadout-daily-${job}`;
43
+ const command = job === "updates"
44
+ ? [...launcher, "watch", "--once", "--json"]
45
+ : [...launcher, "discover", "--source", "all", "--queue", "--json"];
46
+ if (selectedPlatform === "darwin") {
47
+ const label = `com.loadout.daily.${job}`;
48
+ const path = join(home, "Library", "LaunchAgents", `${label}.plist`);
49
+ const log = join(stateHome, "logs", `daily-${job}.log`);
50
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
51
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
52
+ <plist version="1.0"><dict>
53
+ <key>Label</key><string>${label}</string>
54
+ <key>ProgramArguments</key><array>${command.map((item) => `<string>${xml(item)}</string>`).join("")}</array>
55
+ <key>StartCalendarInterval</key><dict><key>Hour</key><integer>${time.hour}</integer><key>Minute</key><integer>${time.minute}</integer></dict>
56
+ <key>ProcessType</key><string>Background</string>
57
+ <key>StandardOutPath</key><string>${xml(log)}</string>
58
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
59
+ </dict></plist>
60
+ `;
61
+ const domain = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
62
+ return {
63
+ action,
64
+ job,
65
+ platform: selectedPlatform,
66
+ time: time.text,
67
+ command,
68
+ directories: [join(stateHome, "logs")],
69
+ files: [{ path, content }],
70
+ applyCommands: action === "schedule"
71
+ ? [
72
+ {
73
+ command: "launchctl",
74
+ args: ["bootout", domain, path],
75
+ allowFailure: true,
76
+ },
77
+ { command: "launchctl", args: ["bootstrap", domain, path] },
78
+ ]
79
+ : [
80
+ {
81
+ command: "launchctl",
82
+ args: ["bootout", domain, path],
83
+ allowFailure: true,
84
+ },
85
+ ],
86
+ rollbackCommands: action === "schedule"
87
+ ? [{ command: "launchctl", args: ["bootout", domain, path] }]
88
+ : [],
89
+ guarantee: "read-only-checks-only",
90
+ };
91
+ }
92
+ if (selectedPlatform === "linux") {
93
+ const service = join(home, ".config", "systemd", "user", `${nativeId}.service`);
94
+ const timer = join(home, ".config", "systemd", "user", `${nativeId}.timer`);
95
+ return {
96
+ action,
97
+ job,
98
+ platform: selectedPlatform,
99
+ time: time.text,
100
+ command,
101
+ directories: [],
102
+ files: [
103
+ {
104
+ path: service,
105
+ content: `[Unit]
106
+ Description=Loadout read-only ${job === "updates" ? "update check" : "candidate discovery"}
107
+
108
+ [Service]
109
+ Type=oneshot
110
+ ExecStart=${command.map(systemd).join(" ")}
111
+ `,
112
+ },
113
+ {
114
+ path: timer,
115
+ content: `[Unit]
116
+ Description=Daily Loadout read-only ${job === "updates" ? "update check" : "candidate discovery"}
117
+
118
+ [Timer]
119
+ OnCalendar=*-*-* ${time.text}:00
120
+ Persistent=true
121
+
122
+ [Install]
123
+ WantedBy=timers.target
124
+ `,
125
+ },
126
+ ],
127
+ applyCommands: action === "schedule"
128
+ ? [
129
+ { command: "systemctl", args: ["--user", "daemon-reload"] },
130
+ {
131
+ command: "systemctl",
132
+ args: ["--user", "enable", "--now", `${nativeId}.timer`],
133
+ },
134
+ ]
135
+ : [
136
+ {
137
+ command: "systemctl",
138
+ args: ["--user", "disable", "--now", `${nativeId}.timer`],
139
+ allowFailure: true,
140
+ },
141
+ { command: "systemctl", args: ["--user", "daemon-reload"] },
142
+ ],
143
+ rollbackCommands: action === "schedule"
144
+ ? [
145
+ {
146
+ command: "systemctl",
147
+ args: ["--user", "disable", "--now", `${nativeId}.timer`],
148
+ },
149
+ ]
150
+ : [],
151
+ guarantee: "read-only-checks-only",
152
+ };
153
+ }
154
+ const path = win32.join(stateHome, "scheduler", `${nativeId}.xml`);
155
+ const taskName = `LoadoutDaily${job === "updates" ? "Updates" : "Discovery"}`;
156
+ const argumentsValue = command
157
+ .slice(1)
158
+ .map((item) => `"${item.replace(/"/g, '\\"')}"`)
159
+ .join(" ");
160
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
161
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
162
+ <Triggers><CalendarTrigger><StartBoundary>2000-01-01T${time.text}:00</StartBoundary><Enabled>true</Enabled><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger></Triggers>
163
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
164
+ <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT10M</ExecutionTimeLimit></Settings>
165
+ <Actions Context="Author"><Exec><Command>${xml(command[0])}</Command><Arguments>${xml(argumentsValue)}</Arguments></Exec></Actions>
166
+ </Task>
167
+ `;
168
+ return {
169
+ action,
170
+ job,
171
+ platform: selectedPlatform,
172
+ time: time.text,
173
+ command,
174
+ directories: [],
175
+ files: [{ path, content }],
176
+ applyCommands: action === "schedule"
177
+ ? [
178
+ {
179
+ command: "schtasks",
180
+ args: ["/Create", "/TN", taskName, "/XML", path, "/F"],
181
+ },
182
+ ]
183
+ : [
184
+ {
185
+ command: "schtasks",
186
+ args: ["/Delete", "/TN", taskName, "/F"],
187
+ allowFailure: true,
188
+ },
189
+ ],
190
+ rollbackCommands: action === "schedule"
191
+ ? [
192
+ {
193
+ command: "schtasks",
194
+ args: ["/Delete", "/TN", taskName, "/F"],
195
+ },
196
+ ]
197
+ : [],
198
+ guarantee: "read-only-checks-only",
199
+ };
200
+ }
201
+ const defaultRunner = async (command, args) => {
202
+ await execFileAsync(command, args);
203
+ };
204
+ export async function applyNativeScheduler(plan, runner = defaultRunner) {
205
+ return applyNativeSchedulerBundle([plan], runner);
206
+ }
207
+ /** Apply both read-only daily jobs through one filesystem transaction. */
208
+ export async function applyNativeSchedulerBundle(plans, runner = defaultRunner) {
209
+ if (!plans.length)
210
+ throw new Error("At least one scheduler plan is required");
211
+ const actions = new Set(plans.map((plan) => plan.action));
212
+ if (actions.size !== 1)
213
+ throw new Error("A scheduler bundle cannot mix schedule and unschedule plans");
214
+ await recoverPendingTransactions();
215
+ const paths = [
216
+ ...new Set(plans.flatMap((plan) => plan.files.map((file) => file.path))),
217
+ ];
218
+ const snapshot = await createSnapshot(paths, { persist: false });
219
+ const transaction = await beginTransaction(snapshot, paths);
220
+ try {
221
+ await markTransactionCommitting(transaction);
222
+ if (plans[0].action === "schedule") {
223
+ for (const directory of new Set(plans.flatMap((plan) => plan.directories)))
224
+ await ensureDirectory(directory);
225
+ for (const file of plans.flatMap((plan) => plan.files)) {
226
+ await ensureDirectory(dirname(file.path));
227
+ await writeFileAtomically(file.path, file.content);
228
+ }
229
+ }
230
+ for (const item of plans.flatMap((plan) => plan.applyCommands))
231
+ try {
232
+ await runner(item.command, item.args);
233
+ }
234
+ catch (error) {
235
+ if (!item.allowFailure)
236
+ throw error;
237
+ }
238
+ if (plans[0].action === "unschedule")
239
+ for (const file of plans.flatMap((plan) => plan.files))
240
+ await rm(file.path, { force: true });
241
+ await completeTransaction(transaction);
242
+ }
243
+ catch (error) {
244
+ for (const item of plans.flatMap((plan) => plan.rollbackCommands).reverse())
245
+ try {
246
+ await runner(item.command, item.args);
247
+ }
248
+ catch {
249
+ /* filesystem/state rollback remains authoritative */
250
+ }
251
+ await rollbackTransaction(transaction);
252
+ throw error;
253
+ }
254
+ return snapshot.id;
255
+ }
256
+ export function formatNativeScheduler(plan) {
257
+ return [
258
+ `${plan.action === "schedule" ? "Schedule" : "Unschedule"} daily read-only ${plan.job === "updates" ? "update check" : "candidate discovery"} — ${plan.platform} at ${plan.time}`,
259
+ `Command: ${plan.command.join(" ")}`,
260
+ ...plan.files.map((file) => `File: ${file.path}`),
261
+ ...plan.applyCommands.map((item) => `Native action: ${item.command} ${item.args.join(" ")}`),
262
+ `Guarantee: the scheduled command is ${plan.command.slice(2).join(" ")}; it can only report updates or queue candidates and cannot apply changes.`,
263
+ ].join("\n");
264
+ }
@@ -0,0 +1,85 @@
1
+ import { dirname } from "node:path";
2
+ import { writeFileAtomically } from "./atomic-file.js";
3
+ import { ensureDirectory } from "./paths.js";
4
+ import { readInstallState } from "./state.js";
5
+ export function parsePrivacySafeLoadoutReport(value) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value))
7
+ throw new Error("Privacy-safe Loadout report must be an object");
8
+ const report = value;
9
+ if (report.schemaVersion !== 1 ||
10
+ typeof report.generatedAt !== "string" ||
11
+ !Number.isFinite(Date.parse(report.generatedAt)) ||
12
+ !Array.isArray(report.packages) ||
13
+ !Array.isArray(report.mcp) ||
14
+ !report.privacy ||
15
+ !Array.isArray(report.privacy.excludes))
16
+ throw new Error("Privacy-safe Loadout report schema is invalid");
17
+ for (const item of report.packages) {
18
+ if (!item ||
19
+ typeof item !== "object" ||
20
+ typeof item.id !== "string" ||
21
+ !Array.isArray(item.agents) ||
22
+ !item.agents.every((agent) => typeof agent === "string") ||
23
+ ![
24
+ item.managedFiles,
25
+ item.activeSkills,
26
+ item.disabledSkills,
27
+ item.reviewedSkills,
28
+ item.unreviewedSkills,
29
+ ].every((count) => Number.isSafeInteger(count) && count >= 0))
30
+ throw new Error("Privacy-safe Loadout package entry is invalid");
31
+ }
32
+ if (!report.mcp.every((item) => item && typeof item === "object" && typeof item.packageId === "string") ||
33
+ !report.privacy.excludes.every((item) => typeof item === "string"))
34
+ throw new Error("Privacy-safe Loadout report entry is invalid");
35
+ return report;
36
+ }
37
+ export async function buildPrivacySafeReport() {
38
+ const state = await readInstallState();
39
+ return {
40
+ schemaVersion: 1,
41
+ generatedAt: new Date().toISOString(),
42
+ packages: state.installs
43
+ .map((install) => {
44
+ const activations = (state.activations ?? []).filter((item) => item.packageId === install.packageId);
45
+ return {
46
+ id: install.packageId,
47
+ ...(install.resolvedCommit ? { commit: install.resolvedCommit } : {}),
48
+ agents: [...install.targetAgents].sort(),
49
+ managedFiles: install.files.length,
50
+ activeSkills: activations.filter((item) => item.activationState === "active").length,
51
+ disabledSkills: activations.filter((item) => item.activationState === "disabled").length,
52
+ reviewedSkills: activations.filter((item) => item.reviewState === "reviewed").length,
53
+ unreviewedSkills: activations.filter((item) => item.reviewState !== "reviewed").length,
54
+ };
55
+ })
56
+ .sort((left, right) => left.id.localeCompare(right.id)),
57
+ mcp: (state.mcpInstalls ?? [])
58
+ .map((item) => ({ packageId: item.packageId }))
59
+ .filter((item, index, values) => values.findIndex((candidate) => candidate.packageId === item.packageId) === index)
60
+ .sort((left, right) => left.packageId.localeCompare(right.packageId)),
61
+ privacy: {
62
+ excludes: [
63
+ "usernames",
64
+ "absolute paths",
65
+ "project names",
66
+ "repository names",
67
+ "prompts",
68
+ "source code",
69
+ "filenames",
70
+ "credential values",
71
+ ],
72
+ },
73
+ };
74
+ }
75
+ export async function writePrivacySafeReport(path, report) {
76
+ await ensureDirectory(dirname(path));
77
+ await writeFileAtomically(path, `${JSON.stringify(report, null, 2)}\n`);
78
+ }
79
+ export function formatPrivacySafeReport(report) {
80
+ return [
81
+ `Shareable Loadout report: ${report.packages.length} package(s), ${report.mcp.length} MCP entry/entries`,
82
+ ...report.packages.map((item) => `${item.id} — ${item.commit?.slice(0, 12) ?? "local"} — agents:${item.agents.join(",")} — skills:${item.activeSkills} active/${item.disabledSkills} disabled`),
83
+ "Privacy: no local paths, project names, source filenames/content, prompts, or credential values.",
84
+ ].join("\n");
85
+ }
@@ -0,0 +1,115 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ function canonical(value) {
5
+ if (value === null ||
6
+ typeof value === "boolean" ||
7
+ typeof value === "number" ||
8
+ typeof value === "string")
9
+ return JSON.stringify(value);
10
+ if (Array.isArray(value))
11
+ return `[${value.map(canonical).join(",")}]`;
12
+ if (typeof value === "object")
13
+ return `{${Object.entries(value)
14
+ .filter(([, entry]) => entry !== undefined)
15
+ .sort(([a], [b]) => a.localeCompare(b))
16
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
17
+ .join(",")}}`;
18
+ throw new Error(`Cannot sign unsupported value type: ${typeof value}`);
19
+ }
20
+ function fingerprint(publicKey) {
21
+ return `sha256:${createHash("sha256")
22
+ .update(publicKey.export({ type: "spki", format: "der" }))
23
+ .digest("hex")}`;
24
+ }
25
+ export function publicKeyFingerprint(publicKeyPem) {
26
+ const publicKey = createPublicKey(publicKeyPem);
27
+ if (publicKey.asymmetricKeyType !== "ed25519")
28
+ throw new Error("Signing public key must be Ed25519");
29
+ return fingerprint(publicKey);
30
+ }
31
+ export function signPayload(payload, privateKeyPem, createdAt = new Date().toISOString()) {
32
+ const privateKey = createPrivateKey(privateKeyPem);
33
+ const publicKey = createPublicKey(privateKey);
34
+ if (privateKey.asymmetricKeyType !== "ed25519")
35
+ throw new Error("Signing key must be Ed25519");
36
+ const message = Buffer.from(canonical({
37
+ schemaVersion: 1,
38
+ algorithm: "Ed25519",
39
+ createdAt,
40
+ publicKeyFingerprint: fingerprint(publicKey),
41
+ payload,
42
+ }));
43
+ return {
44
+ schemaVersion: 1,
45
+ algorithm: "Ed25519",
46
+ createdAt,
47
+ publicKeyFingerprint: fingerprint(publicKey),
48
+ payload,
49
+ signature: sign(null, message, privateKey).toString("base64"),
50
+ };
51
+ }
52
+ export function verifyEnvelope(envelope, publicKeyPem) {
53
+ if (envelope.schemaVersion !== 1 ||
54
+ envelope.algorithm !== "Ed25519" ||
55
+ typeof envelope.signature !== "string")
56
+ return { valid: false, fingerprint: "invalid" };
57
+ const publicKey = createPublicKey(publicKeyPem);
58
+ const keyFingerprint = fingerprint(publicKey);
59
+ if (publicKey.asymmetricKeyType !== "ed25519")
60
+ return { valid: false, fingerprint: keyFingerprint };
61
+ if (envelope.publicKeyFingerprint !== keyFingerprint)
62
+ return { valid: false, fingerprint: keyFingerprint };
63
+ const message = Buffer.from(canonical({
64
+ schemaVersion: envelope.schemaVersion,
65
+ algorithm: envelope.algorithm,
66
+ createdAt: envelope.createdAt,
67
+ publicKeyFingerprint: envelope.publicKeyFingerprint,
68
+ payload: envelope.payload,
69
+ }));
70
+ let signature;
71
+ try {
72
+ signature = Buffer.from(envelope.signature, "base64");
73
+ }
74
+ catch {
75
+ return { valid: false, fingerprint: keyFingerprint };
76
+ }
77
+ return {
78
+ valid: verify(null, message, publicKey, signature),
79
+ fingerprint: keyFingerprint,
80
+ };
81
+ }
82
+ export async function generateSigningKeys(privatePath, publicPath) {
83
+ const pair = generateKeyPairSync("ed25519");
84
+ const privatePem = pair.privateKey
85
+ .export({ type: "pkcs8", format: "pem" })
86
+ .toString();
87
+ const publicPem = pair.publicKey
88
+ .export({ type: "spki", format: "pem" })
89
+ .toString();
90
+ const privateKey = resolve(privatePath);
91
+ const publicKey = resolve(publicPath);
92
+ await Promise.all([
93
+ mkdir(dirname(privateKey), { recursive: true }),
94
+ mkdir(dirname(publicKey), { recursive: true }),
95
+ ]);
96
+ await writeFile(privateKey, privatePem, { mode: 0o600, flag: "wx" });
97
+ await writeFile(publicKey, publicPem, { mode: 0o644, flag: "wx" });
98
+ return { privateKey, publicKey, fingerprint: fingerprint(pair.publicKey) };
99
+ }
100
+ export async function signJsonFile(input, privateKeyPath, output) {
101
+ const payload = JSON.parse(await readFile(resolve(input), "utf8"));
102
+ const privateKey = await readFile(resolve(privateKeyPath), "utf8");
103
+ const envelope = signPayload(payload, privateKey);
104
+ const target = resolve(output);
105
+ await mkdir(dirname(target), { recursive: true });
106
+ await writeFile(target, `${JSON.stringify(envelope, null, 2)}\n`, {
107
+ flag: "wx",
108
+ });
109
+ return envelope;
110
+ }
111
+ export async function verifyJsonFile(input, publicKeyPath) {
112
+ const envelope = JSON.parse(await readFile(resolve(input), "utf8"));
113
+ const publicKey = await readFile(resolve(publicKeyPath), "utf8");
114
+ return verifyEnvelope(envelope, publicKey);
115
+ }