loadout-ai 0.7.0 → 0.8.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/CHANGELOG.md +72 -0
- package/README.md +33 -33
- package/catalog/discovered.json +26880 -24184
- package/dist/src/cli.js +5 -0
- package/dist/src/commands/catalog.js +103 -116
- package/dist/src/core/agents/agent-inspection.js +26 -4
- package/dist/src/core/catalog/registry.js +58 -10
- package/dist/src/core/catalog/safety.js +36 -7
- package/dist/src/core/install/source.js +21 -7
- package/dist/src/core/reporting/cli-guide.js +3 -3
- package/dist/src/core/reporting/completion.js +42 -95
- package/dist/src/core/reporting/doctor.js +3 -5
- package/dist/src/core/routing/handoff.js +94 -58
- package/dist/src/core/routing/policy.js +147 -0
- package/dist/src/core/routing/route.js +25 -153
- package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
- package/docs/CATALOG.md +1 -1
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
- package/docs/DISCOVERED.md +252 -251
- package/docs/FEATURE_TEST_MATRIX.md +7 -260
- package/docs/GITHUB_AUTHORIZATION.md +5 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
- package/docs/RELEASE_REVIEW.md +0 -1
- package/package.json +6 -4
- package/skills/loadout-router/SKILL.md +43 -78
- package/MASTER_PLAN.md +0 -2207
- package/docs/ACTIVE_SET.md +0 -53
- package/docs/COMPATIBILITY_POLICY.md +0 -22
- package/docs/CONVERSION_AND_SANDBOX.md +0 -27
- package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
- package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
- package/docs/PROVIDER_CONFIGURATION.md +0 -45
- package/docs/README_RESEARCH.md +0 -36
- package/docs/REPOSITORY_STABILIZATION.md +0 -190
- package/docs/SAFE_UPDATE_DEMO.md +0 -25
- package/docs/SCHEMA_DECISIONS.md +0 -25
- package/docs/SUBMISSION_COPY.md +0 -90
- package/docs/TEAM_POLICY.md +0 -18
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
package/dist/src/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, CommanderError } from "commander";
|
|
3
3
|
import { HIDDEN_FROM_FIRST_SCREEN } from "./core/reporting/cli-guide.js";
|
|
4
|
+
import { registerCompletionCommands } from "./core/reporting/completion.js";
|
|
4
5
|
import { recoverPendingTransactions } from "./core/install/transaction.js";
|
|
5
6
|
import { LOADOUT_VERSION, printBeginnerGuide, runWizard, } from "./commands/support.js";
|
|
6
7
|
import { registerSetup } from "./commands/setup.js";
|
|
@@ -34,6 +35,10 @@ registerInventory(program);
|
|
|
34
35
|
registerCatalog(program);
|
|
35
36
|
registerMcp(program);
|
|
36
37
|
registerLifecycle(program);
|
|
38
|
+
registerCompletionCommands(program.commands.map((command) => ({
|
|
39
|
+
name: command.name(),
|
|
40
|
+
subcommands: command.commands.map((sub) => sub.name()),
|
|
41
|
+
})));
|
|
37
42
|
for (const command of program.commands)
|
|
38
43
|
// Commander supports `hidden` when a command is created. These commands are
|
|
39
44
|
// registered by separate feature blocks, so use the same runtime flag here
|
|
@@ -2,9 +2,11 @@ import { explainCatalogScore, loadEffectiveCatalog, loadCatalog, promoteCatalogC
|
|
|
2
2
|
import { detectAgents, parseAgentSelection } from "../core/agents/paths.js";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
|
-
import {
|
|
5
|
+
import { formatModelCatalog, MODEL_CATALOG } from "../core/routing/route.js";
|
|
6
|
+
import { BUCKETS, formatAnswer, formatPolicy, guessBucket, policyPath, readPolicy, resolveRoute, setRule, writePolicy, } from "../core/routing/policy.js";
|
|
7
|
+
import { rm } from "node:fs/promises";
|
|
6
8
|
import { applyFirstPartySkill, FIRST_PARTY_SKILLS, formatFirstPartySkillList, formatFirstPartySkillPlan, installedFirstPartySkills, planFirstPartySkill, removeFirstPartySkill, } from "../core/routing/first-party-skills.js";
|
|
7
|
-
import { applyPickup, formatHandoffStatus, formatInbox,
|
|
9
|
+
import { applyPickup, formatHandoffStatus, formatInbox, getHandoffState, initHandoff, isHandoffInitialized, isPickupTarget, markDone, planPickup, readInbox, sendHandoff, } from "../core/routing/handoff.js";
|
|
8
10
|
import { catalogTrustStage, formatCatalogTrustStage, } from "../core/catalog/profiles.js";
|
|
9
11
|
import { discoverHackerNewsRepositories } from "../core/discovery/community.js";
|
|
10
12
|
import { discoverPrivateRepositories } from "../core/discovery/private-discovery.js";
|
|
@@ -656,144 +658,129 @@ export function registerCatalog(program) {
|
|
|
656
658
|
});
|
|
657
659
|
const handoff = program
|
|
658
660
|
.command("handoff")
|
|
659
|
-
.description("
|
|
661
|
+
.description("Hand a task to your other agent, or see what has been handed to you");
|
|
660
662
|
handoff
|
|
661
|
-
.
|
|
662
|
-
.
|
|
663
|
-
.
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
663
|
+
.argument("[agent]", "who should do it, for example codex")
|
|
664
|
+
.argument("[task...]", "what they should do")
|
|
665
|
+
.option("--context <text>", "anything they need that is not in the task")
|
|
666
|
+
.option("--from <agent>", "who is sending", "user")
|
|
667
|
+
.option("--done <id>", "mark a task finished")
|
|
668
|
+
.option("--json", "emit machine-readable JSON")
|
|
669
|
+
.action(async (agent, taskWords, options) => {
|
|
670
|
+
const cwd = process.cwd();
|
|
671
|
+
if (options.done) {
|
|
672
|
+
const message = await markDone(cwd, options.done);
|
|
673
|
+
console.log(options.json
|
|
674
|
+
? JSON.stringify(message, null, 2)
|
|
675
|
+
: `Marked ${options.done} done.`);
|
|
667
676
|
return;
|
|
668
677
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
678
|
+
// No agent named: show what is waiting, for everyone.
|
|
679
|
+
if (!agent) {
|
|
680
|
+
const state = await getHandoffState(cwd);
|
|
681
|
+
console.log(options.json
|
|
682
|
+
? JSON.stringify(state, null, 2)
|
|
683
|
+
: formatHandoffStatus(state));
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
// An agent with no task means "show me my inbox".
|
|
687
|
+
const task = taskWords.join(" ").trim();
|
|
688
|
+
if (!task) {
|
|
689
|
+
const messages = await readInbox(cwd, agent);
|
|
690
|
+
console.log(options.json
|
|
691
|
+
? JSON.stringify(messages, null, 2)
|
|
692
|
+
: formatInbox(agent, messages));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
// Sending is the common case, so it sets itself up rather than failing
|
|
696
|
+
// with instructions to run two other commands first.
|
|
697
|
+
const setup = [];
|
|
698
|
+
if (!(await isHandoffInitialized(cwd))) {
|
|
699
|
+
await initHandoff(cwd);
|
|
700
|
+
setup.push("created .handoff/");
|
|
701
|
+
}
|
|
702
|
+
for (const target of [options.from, agent]) {
|
|
703
|
+
if (!isPickupTarget(target))
|
|
704
|
+
continue;
|
|
705
|
+
const plan = await planPickup(cwd, target);
|
|
706
|
+
if (!plan.replacing) {
|
|
707
|
+
await applyPickup(plan);
|
|
708
|
+
setup.push(`told ${target} to check its inbox`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const message = await sendHandoff(cwd, agent, task, {
|
|
684
712
|
from: options.from,
|
|
685
|
-
|
|
686
|
-
context: options.context,
|
|
713
|
+
...(options.context ? { context: options.context } : {}),
|
|
687
714
|
});
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
: `Sent ${message.type} ${message.id} → ${message.to}: ${message.description}`);
|
|
691
|
-
});
|
|
692
|
-
handoff
|
|
693
|
-
.command("done")
|
|
694
|
-
.description("Mark a handoff task as completed")
|
|
695
|
-
.argument("<id>", "message id to mark done")
|
|
696
|
-
.option("--json", "emit machine-readable JSON")
|
|
697
|
-
.action(async (id, options) => {
|
|
698
|
-
const message = await markDone(process.cwd(), id);
|
|
699
|
-
console.log(options.json
|
|
700
|
-
? JSON.stringify(message, null, 2)
|
|
701
|
-
: `Marked ${id} as done`);
|
|
702
|
-
});
|
|
703
|
-
handoff
|
|
704
|
-
.command("inbox")
|
|
705
|
-
.description("Show pending tasks addressed to one agent, as instructions it can act on")
|
|
706
|
-
.argument("<agent>", "agent reading its inbox (e.g. claude-code, codex)")
|
|
707
|
-
.option("--json", "emit machine-readable JSON")
|
|
708
|
-
.action(async (agent, options) => {
|
|
709
|
-
const messages = await readInbox(process.cwd(), agent);
|
|
710
|
-
console.log(options.json
|
|
711
|
-
? JSON.stringify(messages, null, 2)
|
|
712
|
-
: formatInbox(agent, messages));
|
|
713
|
-
});
|
|
714
|
-
handoff
|
|
715
|
-
.command("pickup")
|
|
716
|
-
.description("Teach agents to check their handoff inbox by adding a managed block to CLAUDE.md / AGENTS.md")
|
|
717
|
-
.option("--agents <ids>", "comma-separated agents to instruct", "claude-code,codex")
|
|
718
|
-
.option("--yes", "write the files after previewing")
|
|
719
|
-
.action(async (options) => {
|
|
720
|
-
const agents = options.agents
|
|
721
|
-
.split(",")
|
|
722
|
-
.map((value) => value.trim())
|
|
723
|
-
.filter(Boolean);
|
|
724
|
-
const plans = [];
|
|
725
|
-
for (const agent of agents)
|
|
726
|
-
plans.push(await planPickup(process.cwd(), agent));
|
|
727
|
-
console.log(formatPickupPlan(plans));
|
|
728
|
-
if (!options.yes) {
|
|
729
|
-
console.log("\nPreview only. Re-run with --yes to write these files.");
|
|
715
|
+
if (options.json) {
|
|
716
|
+
console.log(JSON.stringify({ message, setup }, null, 2));
|
|
730
717
|
return;
|
|
731
718
|
}
|
|
732
|
-
for (const
|
|
733
|
-
|
|
734
|
-
console.log(
|
|
735
|
-
|
|
736
|
-
handoff
|
|
737
|
-
.command("status")
|
|
738
|
-
.description("Show pending and completed handoff tasks")
|
|
739
|
-
.option("--json", "emit machine-readable JSON")
|
|
740
|
-
.action(async (options) => {
|
|
741
|
-
const state = await getHandoffState(process.cwd());
|
|
742
|
-
console.log(options.json
|
|
743
|
-
? JSON.stringify(state, null, 2)
|
|
744
|
-
: formatHandoffStatus(state));
|
|
719
|
+
for (const line of setup)
|
|
720
|
+
console.log(` ${line}`);
|
|
721
|
+
console.log(`Sent to ${agent}: ${task}`);
|
|
722
|
+
console.log(`It will pick this up next session, or now with: loadout handoff ${agent}`);
|
|
745
723
|
});
|
|
746
724
|
const route = program
|
|
747
725
|
.command("route")
|
|
748
|
-
.description("
|
|
726
|
+
.description("Which model to use for a task, according to a routing policy you own");
|
|
749
727
|
route
|
|
750
|
-
.argument("[description...]", "
|
|
751
|
-
.option("--
|
|
752
|
-
.option("--
|
|
753
|
-
.option("--
|
|
754
|
-
.option("--
|
|
755
|
-
.option("--
|
|
756
|
-
.option("--tier <tier>", "filter models by tier (frontier, standard, fast)")
|
|
728
|
+
.argument("[description...]", "what you are about to do")
|
|
729
|
+
.option("--bucket <bucket>", "state the bucket yourself: hard, normal, or cheap")
|
|
730
|
+
.option("--set <bucket=model>", "change the model for one bucket and save")
|
|
731
|
+
.option("--save", "write the current defaults to disk so you can edit them")
|
|
732
|
+
.option("--reset", "delete your policy and go back to the defaults")
|
|
733
|
+
.option("--models", "list the model ids you can name in a policy")
|
|
757
734
|
.option("--json", "emit machine-readable JSON")
|
|
758
735
|
.action(async (descriptionWords, options) => {
|
|
759
736
|
if (options.models) {
|
|
760
737
|
console.log(options.json
|
|
761
|
-
? JSON.stringify(
|
|
762
|
-
: formatModelCatalog(
|
|
763
|
-
provider: options.provider,
|
|
764
|
-
tier: options.tier,
|
|
765
|
-
}));
|
|
738
|
+
? JSON.stringify(MODEL_CATALOG, null, 2)
|
|
739
|
+
: formatModelCatalog());
|
|
766
740
|
return;
|
|
767
741
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
742
|
+
const detected = await detectAgents();
|
|
743
|
+
const installed = detected
|
|
744
|
+
.filter((agent) => agent.installed)
|
|
745
|
+
.map((agent) => agent.id);
|
|
746
|
+
if (options.reset) {
|
|
747
|
+
await rm(policyPath(), { force: true });
|
|
748
|
+
console.log("Removed your routing policy; defaults apply again.");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (options.set) {
|
|
752
|
+
const separator = options.set.indexOf("=");
|
|
753
|
+
if (separator <= 0)
|
|
754
|
+
throw new Error("--set expects <bucket>=<model>, for example normal=gpt-5.6-terra");
|
|
755
|
+
const bucket = options.set.slice(0, separator).trim();
|
|
756
|
+
if (!BUCKETS.includes(bucket))
|
|
757
|
+
throw new Error(`Unknown bucket '${bucket}'. Valid: ${BUCKETS.join(", ")}`);
|
|
758
|
+
const updated = await setRule(bucket, options.set.slice(separator + 1).trim(), installed);
|
|
759
|
+
console.log(formatPolicy(updated, "file"));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const { policy, source } = await readPolicy(installed);
|
|
763
|
+
if (options.save) {
|
|
764
|
+
const path = await writePolicy(policy);
|
|
765
|
+
console.log(`Saved your routing policy to ${path}\n`);
|
|
766
|
+
console.log(formatPolicy(policy, "file"));
|
|
772
767
|
return;
|
|
773
768
|
}
|
|
774
769
|
const description = descriptionWords.join(" ").trim();
|
|
775
|
-
if (!description && !options.
|
|
770
|
+
if (!description && !options.bucket) {
|
|
776
771
|
console.log(options.json
|
|
777
|
-
? JSON.stringify(
|
|
778
|
-
:
|
|
772
|
+
? JSON.stringify({ policy, source }, null, 2)
|
|
773
|
+
: formatPolicy(policy, source));
|
|
779
774
|
return;
|
|
780
775
|
}
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
:
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const detected = await detectAgents();
|
|
787
|
-
const installedAgents = detected
|
|
788
|
-
.filter((agent) => agent.installed)
|
|
789
|
-
.map((agent) => agent.id);
|
|
776
|
+
const stated = options.bucket;
|
|
777
|
+
if (stated && !BUCKETS.includes(stated))
|
|
778
|
+
throw new Error(`Unknown bucket '${stated}'. Valid: ${BUCKETS.join(", ")}`);
|
|
779
|
+
const bucket = stated ?? guessBucket(description);
|
|
780
|
+
const answer = resolveRoute(policy, bucket, installed, !stated);
|
|
790
781
|
console.log(options.json
|
|
791
|
-
? JSON.stringify(
|
|
792
|
-
:
|
|
793
|
-
installedAgents,
|
|
794
|
-
...(description ? { description } : {}),
|
|
795
|
-
handoffReady: await isHandoffInitialized(process.cwd()),
|
|
796
|
-
}));
|
|
782
|
+
? JSON.stringify(answer, null, 2)
|
|
783
|
+
: formatAnswer(answer, description || undefined));
|
|
797
784
|
});
|
|
798
785
|
program
|
|
799
786
|
.command("completion")
|
|
@@ -136,6 +136,30 @@ export async function inspectAgent(agent) {
|
|
|
136
136
|
export async function inspectAgents(agents) {
|
|
137
137
|
return Promise.all(agents.map(inspectAgent));
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Count installable units rather than raw filesystem entries. A skill is one
|
|
141
|
+
* directory containing SKILL.md, so counting every nested file reports a number
|
|
142
|
+
* many times larger than the number of skills the user actually has.
|
|
143
|
+
*/
|
|
144
|
+
export function countComponentUnits(component) {
|
|
145
|
+
if (!component.scanned || !component.directoryExists)
|
|
146
|
+
return 0;
|
|
147
|
+
const skillRoots = new Set();
|
|
148
|
+
let looseFiles = 0;
|
|
149
|
+
for (const entry of component.entries) {
|
|
150
|
+
if (entry.kind !== "file")
|
|
151
|
+
continue;
|
|
152
|
+
const segments = entry.path.split("/");
|
|
153
|
+
const name = segments[segments.length - 1];
|
|
154
|
+
if (name === "SKILL.md") {
|
|
155
|
+
// Depth 1 means SKILL.md sits at the component root, which is one unit.
|
|
156
|
+
skillRoots.add(segments.slice(0, -1).join("/") || ".");
|
|
157
|
+
}
|
|
158
|
+
else if (segments.length === 1)
|
|
159
|
+
looseFiles += 1;
|
|
160
|
+
}
|
|
161
|
+
return skillRoots.size || looseFiles;
|
|
162
|
+
}
|
|
139
163
|
/**
|
|
140
164
|
* One agent's inventory. The default is a two-line summary: what is installed
|
|
141
165
|
* and which component kinds the adapter supports. Adapter internals — the
|
|
@@ -155,16 +179,14 @@ export function formatAgentInventory(inventory, options = {}) {
|
|
|
155
179
|
}
|
|
156
180
|
}
|
|
157
181
|
else {
|
|
158
|
-
const items = inventory.components
|
|
159
|
-
.filter((component) => component.scanned && component.directoryExists)
|
|
160
|
-
.reduce((total, component) => total + component.entries.length, 0);
|
|
182
|
+
const items = inventory.components.reduce((total, component) => total + countComponentUnits(component), 0);
|
|
161
183
|
const supported = inventory.components
|
|
162
184
|
.filter((component) => component.compatibility !== "unsupported")
|
|
163
185
|
.map((component) => component.type);
|
|
164
186
|
const skills = inventory.components.find((component) => component.type === "skill");
|
|
165
187
|
if (skills?.directory)
|
|
166
188
|
lines.push(` ${skills.directory}`);
|
|
167
|
-
lines.push(` ${items}
|
|
189
|
+
lines.push(` ${items} skills | supports: ${supported.join(", ")}`);
|
|
168
190
|
}
|
|
169
191
|
for (const warning of inventory.warnings)
|
|
170
192
|
lines.push(` ! ${warning}`);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { cp, lstat, mkdir, readFile, readdir, rm, writeFile, } from "node:fs/promises";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { loadoutHome } from "../agents/paths.js";
|
|
5
5
|
import { analyzeUpdateSafety } from "./safety.js";
|
|
6
6
|
const NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
7
|
+
/** A SHA-256 digest, and nothing that could traverse a path. */
|
|
8
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
7
9
|
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
8
10
|
export function parsePackageDescriptor(value) {
|
|
9
11
|
if (!value || typeof value !== "object")
|
|
@@ -266,18 +268,54 @@ function registryEndpoint(base, path) {
|
|
|
266
268
|
endpoint.hash = "";
|
|
267
269
|
return endpoint;
|
|
268
270
|
}
|
|
271
|
+
/** Registry responses are untrusted input: bound the wait and the body. */
|
|
272
|
+
const REGISTRY_TIMEOUT_MS = 30_000;
|
|
273
|
+
const REGISTRY_MAX_BYTES = 32 * 1024 * 1024;
|
|
274
|
+
async function registryJson(input, init = {}) {
|
|
275
|
+
const response = await fetch(input, {
|
|
276
|
+
...init,
|
|
277
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
278
|
+
});
|
|
279
|
+
// Trust the declared length when present, but still cap what is read: a
|
|
280
|
+
// server may understate or omit it.
|
|
281
|
+
const declared = Number(response.headers.get("content-length"));
|
|
282
|
+
if (Number.isFinite(declared) && declared > REGISTRY_MAX_BYTES)
|
|
283
|
+
throw new Error("Remote registry response exceeds the size limit");
|
|
284
|
+
const body = await response.arrayBuffer();
|
|
285
|
+
if (body.byteLength > REGISTRY_MAX_BYTES)
|
|
286
|
+
throw new Error("Remote registry response exceeds the size limit");
|
|
287
|
+
const text = new TextDecoder().decode(body);
|
|
288
|
+
let value;
|
|
289
|
+
try {
|
|
290
|
+
value = JSON.parse(text);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
throw new Error("Remote registry returned a malformed JSON response");
|
|
294
|
+
}
|
|
295
|
+
return { ok: response.ok, status: response.status, value };
|
|
296
|
+
}
|
|
269
297
|
export async function fetchRemoteRegistryPackage(registry, name, version) {
|
|
270
298
|
if (!NAME.test(name) || !VERSION.test(version))
|
|
271
299
|
throw new Error("Invalid remote registry package name or version");
|
|
272
300
|
const base = registryUrl(registry);
|
|
273
|
-
const response = await
|
|
301
|
+
const response = await registryJson(registryEndpoint(base, `/v1/packages/${encodeURIComponent(name)}/${encodeURIComponent(version)}`));
|
|
274
302
|
if (!response.ok)
|
|
275
303
|
throw new Error(`Remote registry returned ${response.status}`);
|
|
276
|
-
const bundle =
|
|
304
|
+
const bundle = response.value;
|
|
305
|
+
// `digest` arrives from a remote server and is used to build a path that is
|
|
306
|
+
// later deleted recursively. Validate its shape before it can become one:
|
|
307
|
+
// a value such as "../../.." would otherwise escape the cache root.
|
|
308
|
+
if (typeof bundle?.digest !== "string" || !DIGEST.test(bundle.digest))
|
|
309
|
+
throw new Error("Remote registry returned a bundle whose digest is not a SHA-256 hex string");
|
|
277
310
|
const key = createHash("sha256")
|
|
278
311
|
.update(base.origin + base.pathname)
|
|
279
312
|
.digest("hex");
|
|
280
|
-
const
|
|
313
|
+
const root = join(loadoutHome(), "cache", "registry", key, name, version);
|
|
314
|
+
const target = resolve(root, bundle.digest);
|
|
315
|
+
// Belt and braces: even with a validated digest, never operate on a path
|
|
316
|
+
// that resolved outside the directory it was meant to live in.
|
|
317
|
+
if (target !== root && !target.startsWith(root + sep))
|
|
318
|
+
throw new Error("Remote registry bundle resolved outside the cache root");
|
|
281
319
|
try {
|
|
282
320
|
const packed = await packPackage(target);
|
|
283
321
|
if (packed.digest === bundle.digest)
|
|
@@ -286,9 +324,19 @@ export async function fetchRemoteRegistryPackage(registry, name, version) {
|
|
|
286
324
|
catch {
|
|
287
325
|
/* import below */
|
|
288
326
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
await
|
|
327
|
+
// Import into a scratch directory and swap it in, so a failed or malicious
|
|
328
|
+
// import cannot leave the cache holding a half-written package.
|
|
329
|
+
await mkdir(root, { recursive: true });
|
|
330
|
+
const staging = `${target}.incoming-${randomUUID().slice(0, 8)}`;
|
|
331
|
+
try {
|
|
332
|
+
await importRegistryBundle(bundle, staging);
|
|
333
|
+
await rm(target, { recursive: true, force: true });
|
|
334
|
+
await rename(staging, target);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
await rm(staging, { recursive: true, force: true });
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
292
340
|
return { path: target, digest: bundle.digest };
|
|
293
341
|
}
|
|
294
342
|
export async function publishRemotePackage(root, registry, token, options = {}) {
|
|
@@ -296,7 +344,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
|
|
|
296
344
|
throw new Error("Remote registry token is required");
|
|
297
345
|
const bundle = await createRegistryBundle(root);
|
|
298
346
|
const base = registryUrl(registry);
|
|
299
|
-
const response = await
|
|
347
|
+
const response = await registryJson(registryEndpoint(base, "/v1/packages"), {
|
|
300
348
|
method: "POST",
|
|
301
349
|
headers: {
|
|
302
350
|
authorization: `Bearer ${token}`,
|
|
@@ -305,7 +353,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
|
|
|
305
353
|
},
|
|
306
354
|
body: JSON.stringify(bundle),
|
|
307
355
|
});
|
|
308
|
-
const result =
|
|
356
|
+
const result = response.value;
|
|
309
357
|
if (!response.ok)
|
|
310
358
|
throw new Error(result.error ?? `Remote registry returned ${response.status}`);
|
|
311
359
|
return {
|
|
@@ -38,27 +38,37 @@ const SUSPICIOUS_INSTRUCTIONS = [
|
|
|
38
38
|
export function detectSecretKinds(content) {
|
|
39
39
|
return SECRET_PATTERNS.filter((check) => check.pattern.test(content)).map((check) => check.name);
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
const MAX_INSPECTABLE_BYTES = 2_000_000;
|
|
42
|
+
const MAX_DEPTH = 10;
|
|
43
|
+
async function files(root, gaps = []) {
|
|
42
44
|
const result = new Map();
|
|
43
45
|
const base = resolve(root);
|
|
44
46
|
try {
|
|
45
47
|
const rootInfo = await stat(base);
|
|
46
|
-
if (rootInfo.isFile()
|
|
48
|
+
if (rootInfo.isFile()) {
|
|
49
|
+
if (rootInfo.size > MAX_INSPECTABLE_BYTES) {
|
|
50
|
+
gaps.push({ path: basename(base), reason: "too-large" });
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
47
53
|
result.set(basename(base), await readFile(base));
|
|
48
54
|
return result;
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
catch {
|
|
58
|
+
gaps.push({ path: basename(base), reason: "unreadable" });
|
|
52
59
|
return result;
|
|
53
60
|
}
|
|
54
61
|
async function visit(directory, depth) {
|
|
55
|
-
if (depth >
|
|
62
|
+
if (depth > MAX_DEPTH) {
|
|
63
|
+
gaps.push({ path: relative(base, directory), reason: "too-deep" });
|
|
56
64
|
return;
|
|
65
|
+
}
|
|
57
66
|
let entries;
|
|
58
67
|
try {
|
|
59
68
|
entries = await readdir(directory, { withFileTypes: true });
|
|
60
69
|
}
|
|
61
70
|
catch {
|
|
71
|
+
gaps.push({ path: relative(base, directory), reason: "unreadable" });
|
|
62
72
|
return;
|
|
63
73
|
}
|
|
64
74
|
for (const entry of entries) {
|
|
@@ -73,14 +83,19 @@ async function files(root) {
|
|
|
73
83
|
}
|
|
74
84
|
if (!entry.isFile())
|
|
75
85
|
continue;
|
|
86
|
+
const shown = relative(base, absolute).split("\\").join("/");
|
|
76
87
|
try {
|
|
77
88
|
const info = await stat(absolute);
|
|
78
|
-
if (info.size >
|
|
89
|
+
if (info.size > MAX_INSPECTABLE_BYTES) {
|
|
90
|
+
gaps.push({ path: shown, reason: "too-large" });
|
|
79
91
|
continue;
|
|
80
|
-
|
|
92
|
+
}
|
|
93
|
+
result.set(shown, await readFile(absolute));
|
|
81
94
|
}
|
|
82
95
|
catch {
|
|
83
|
-
|
|
96
|
+
// The repository may change while being inspected; either way this
|
|
97
|
+
// file was not reviewed, so record it rather than passing it silently.
|
|
98
|
+
gaps.push({ path: shown, reason: "unreadable" });
|
|
84
99
|
}
|
|
85
100
|
}
|
|
86
101
|
}
|
|
@@ -131,8 +146,9 @@ function collectEnvironmentNames(content) {
|
|
|
131
146
|
}
|
|
132
147
|
/** Analyze changed repository files without running package code or revealing secret values. */
|
|
133
148
|
export async function analyzeUpdateSafety(oldPath, newPath) {
|
|
149
|
+
const gaps = [];
|
|
134
150
|
const oldFiles = oldPath ? await files(oldPath) : new Map();
|
|
135
|
-
const newFiles = await files(newPath);
|
|
151
|
+
const newFiles = await files(newPath, gaps);
|
|
136
152
|
const changed = changedPaths(oldFiles, newFiles);
|
|
137
153
|
const findings = [];
|
|
138
154
|
const domains = new Set();
|
|
@@ -227,6 +243,19 @@ export async function analyzeUpdateSafety(oldPath, newPath) {
|
|
|
227
243
|
paths: [...new Set(instructionPaths)].sort(),
|
|
228
244
|
names: [...instructionKinds].sort(),
|
|
229
245
|
});
|
|
246
|
+
// Fail closed. Anything the scan could not open is exactly what a reviewer
|
|
247
|
+
// needs told about, so an uninspectable file requires approval rather than
|
|
248
|
+
// producing a clean report.
|
|
249
|
+
if (gaps.length) {
|
|
250
|
+
const reasons = [...new Set(gaps.map((gap) => gap.reason))].sort();
|
|
251
|
+
findings.push({
|
|
252
|
+
severity: "blocking",
|
|
253
|
+
category: "uninspectable",
|
|
254
|
+
message: `Safety scan could not inspect ${gaps.length} path(s) (${reasons.join(", ")}); they were not reviewed.`,
|
|
255
|
+
paths: [...new Set(gaps.map((gap) => gap.path))].sort(),
|
|
256
|
+
names: reasons,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
230
259
|
return {
|
|
231
260
|
approvalRequired: findings.some((finding) => finding.severity === "blocking"),
|
|
232
261
|
findings,
|
|
@@ -6,9 +6,26 @@ import { execFile } from "node:child_process";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { loadoutHome, ensureDirectory } from "../agents/paths.js";
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
|
+
/**
|
|
10
|
+
* Bounds apply to every fetch, not only to callers who remember to ask. An
|
|
11
|
+
* unbounded clone of an untrusted repository can hang a Git process forever or
|
|
12
|
+
* fill the disk, so callers may raise a limit but none may omit one.
|
|
13
|
+
*/
|
|
14
|
+
export const REPOSITORY_FETCH_DEFAULTS = {
|
|
15
|
+
/** A reviewed skill repository clones well inside this. */
|
|
16
|
+
timeoutMs: 120_000,
|
|
17
|
+
maxBytes: 256 * 1024 * 1024,
|
|
18
|
+
maxFiles: 20_000,
|
|
19
|
+
};
|
|
20
|
+
function withFetchDefaults(options) {
|
|
21
|
+
return {
|
|
22
|
+
...options,
|
|
23
|
+
timeoutMs: options.timeoutMs ?? REPOSITORY_FETCH_DEFAULTS.timeoutMs,
|
|
24
|
+
maxBytes: options.maxBytes ?? REPOSITORY_FETCH_DEFAULTS.maxBytes,
|
|
25
|
+
maxFiles: options.maxFiles ?? REPOSITORY_FETCH_DEFAULTS.maxFiles,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
9
28
|
async function enforceRepositoryBounds(root, options) {
|
|
10
|
-
if (options.maxBytes === undefined && options.maxFiles === undefined)
|
|
11
|
-
return;
|
|
12
29
|
let bytes = 0;
|
|
13
30
|
let files = 0;
|
|
14
31
|
const pending = [root];
|
|
@@ -35,8 +52,6 @@ async function enforceRepositoryBounds(root, options) {
|
|
|
35
52
|
}
|
|
36
53
|
}
|
|
37
54
|
export function validateGitHubTreeBounds(value, options) {
|
|
38
|
-
if (options.maxBytes === undefined && options.maxFiles === undefined)
|
|
39
|
-
return;
|
|
40
55
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
41
56
|
throw new Error("GitHub tree preflight returned an invalid response");
|
|
42
57
|
const response = value;
|
|
@@ -63,8 +78,6 @@ export function validateGitHubTreeBounds(value, options) {
|
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
async function enforceGitHubTreeBounds(repository, commit, options) {
|
|
66
|
-
if (options.maxBytes === undefined && options.maxFiles === undefined)
|
|
67
|
-
return;
|
|
68
81
|
const signal = AbortSignal.timeout(options.timeoutMs ?? 120_000);
|
|
69
82
|
const token = process.env.GITHUB_TOKEN?.trim();
|
|
70
83
|
const response = await (options.fetcher ?? fetch)(`https://api.github.com/repos/${repository}/git/trees/${commit}?recursive=1`, {
|
|
@@ -181,7 +194,8 @@ function normalizeRef(ref) {
|
|
|
181
194
|
throw new Error(`Invalid Git ref: ${ref}`);
|
|
182
195
|
return ref;
|
|
183
196
|
}
|
|
184
|
-
export async function fetchRepositorySnapshot(input,
|
|
197
|
+
export async function fetchRepositorySnapshot(input, requested = {}) {
|
|
198
|
+
const options = withFetchDefaults(requested);
|
|
185
199
|
const repository = normalizeRepository(input);
|
|
186
200
|
const gitEnvironment = await isolatedGitEnvironment(loadoutHome());
|
|
187
201
|
const ref = options.ref ? normalizeRef(options.ref) : undefined;
|
|
@@ -16,9 +16,9 @@ IN A PROJECT
|
|
|
16
16
|
|
|
17
17
|
WHILE YOU WORK
|
|
18
18
|
|
|
19
|
-
loadout route
|
|
20
|
-
loadout route
|
|
21
|
-
loadout handoff
|
|
19
|
+
loadout route your model policy: hard / normal / cheap
|
|
20
|
+
loadout route <describe a task> which of those it looks like, and what to use
|
|
21
|
+
loadout handoff codex "..." give a task to your other agent
|
|
22
22
|
|
|
23
23
|
USE IT INSIDE YOUR AGENT
|
|
24
24
|
|