loadout-ai 0.9.1 → 0.9.2
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 +36 -0
- package/README.md +2 -2
- package/catalog/discovered.json +26806 -25533
- package/dist/src/commands/coordination-discussions.js +47 -9
- package/dist/src/core/catalog/safety.js +46 -2
- package/dist/src/core/coordination/adapters/claude-code.js +15 -7
- package/dist/src/core/coordination/adapters/codex.js +27 -23
- package/dist/src/core/coordination/coordinator.js +5 -4
- package/dist/src/core/coordination/discussion.js +22 -2
- package/dist/src/core/coordination/retention.js +14 -4
- package/dist/src/core/install/catalog-install.js +8 -2
- package/dist/src/core/install/snapshot.js +28 -4
- package/dist/src/core/install/source.js +8 -6
- package/dist/src/core/install/update.js +55 -1
- package/docs/DISCOVERED.md +246 -247
- package/docs/USER_TEST_GUIDE.md +1 -1
- package/package.json +1 -1
- package/docs/DEMO_SCRIPT.md +0 -152
|
@@ -4,6 +4,29 @@ import { SessionManager } from "../core/coordination/session-manager.js";
|
|
|
4
4
|
import { createProviderAdapters } from "../core/coordination/runtime.js";
|
|
5
5
|
import { acquireBridgeLease } from "../core/coordination/bridge-lease.js";
|
|
6
6
|
import { parseProviderSessionRef, } from "./coordination-sessions.js";
|
|
7
|
+
const QUOTA_PATTERNS = [
|
|
8
|
+
/rate.?limit/i,
|
|
9
|
+
/quota.?exceed/i,
|
|
10
|
+
/too many requests/i,
|
|
11
|
+
/429/,
|
|
12
|
+
/usage.?limit/i,
|
|
13
|
+
/capacity/i,
|
|
14
|
+
/try again later/i,
|
|
15
|
+
/billing/i,
|
|
16
|
+
/insufficient.?credits/i,
|
|
17
|
+
];
|
|
18
|
+
export function wrapProviderError(provider, error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
const isQuota = QUOTA_PATTERNS.some((pattern) => pattern.test(message));
|
|
21
|
+
if (isQuota) {
|
|
22
|
+
return new Error(`${provider} appears to be out of quota or rate-limited. ` +
|
|
23
|
+
`The discussion cannot continue until usage resets. ` +
|
|
24
|
+
`Original error: ${message}`);
|
|
25
|
+
}
|
|
26
|
+
return new Error(`${provider} provider error: ${message}`, {
|
|
27
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
7
30
|
function assertBothProviders(values) {
|
|
8
31
|
if (values.length !== 2 ||
|
|
9
32
|
new Set(values).size !== 2 ||
|
|
@@ -68,17 +91,22 @@ export function createSessionParticipant(sessions, selection, role, cwd, timeout
|
|
|
68
91
|
agent: selection.provider,
|
|
69
92
|
role,
|
|
70
93
|
async respond(prompt) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
try {
|
|
95
|
+
if (!sessionId) {
|
|
96
|
+
const started = await sessions.startSession(selection.provider, cwd, prompt, timeoutMs);
|
|
97
|
+
sessionId = started.sessionId;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const accepted = await sessions.submitTurn(sessionId, prompt, timeoutMs);
|
|
101
|
+
if (!accepted) {
|
|
102
|
+
throw new Error(`${selection.provider}:${sessionId} rejected the discussion turn`);
|
|
103
|
+
}
|
|
79
104
|
}
|
|
105
|
+
return sessions.getLastResponse(sessionId) ?? "";
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
throw wrapProviderError(selection.provider, error);
|
|
80
109
|
}
|
|
81
|
-
return sessions.getLastResponse(sessionId) ?? "";
|
|
82
110
|
},
|
|
83
111
|
};
|
|
84
112
|
}
|
|
@@ -190,6 +218,16 @@ export function registerCoordinationDiscussions(coord) {
|
|
|
190
218
|
const sessions = createManager(projectRoot);
|
|
191
219
|
try {
|
|
192
220
|
await sessions.start();
|
|
221
|
+
// Pre-flight: verify both provider CLIs are reachable before
|
|
222
|
+
// spending any paid turns.
|
|
223
|
+
const detected = await sessions.detectProviders();
|
|
224
|
+
const needed = selection.participants.map((p) => p.provider);
|
|
225
|
+
const missing = needed.filter((name) => !detected.some((d) => d.provider === name));
|
|
226
|
+
if (missing.length) {
|
|
227
|
+
throw new Error(`Cannot start discussion: ${missing.join(", ")} not detected. ` +
|
|
228
|
+
`Install the CLI or check that it is on PATH. ` +
|
|
229
|
+
`Detected providers: ${detected.map((d) => `${d.provider} ${d.version}`).join(", ") || "none"}.`);
|
|
230
|
+
}
|
|
193
231
|
if (selection.mode === "existing") {
|
|
194
232
|
for (const item of selection.participants) {
|
|
195
233
|
await sessions.attachSession(item.provider, item.sessionId, projectRoot);
|
|
@@ -22,17 +22,61 @@ const SECRET_PATTERNS = [
|
|
|
22
22
|
},
|
|
23
23
|
];
|
|
24
24
|
const SUSPICIOUS_INSTRUCTIONS = [
|
|
25
|
+
// --- Prompt injection / instruction override ---
|
|
25
26
|
{
|
|
26
27
|
name: "instruction override",
|
|
27
28
|
pattern: /\bignore (?:all |any )?(?:previous|prior|system|developer) instructions?\b/i,
|
|
28
29
|
},
|
|
30
|
+
{
|
|
31
|
+
name: "instruction override (disregard)",
|
|
32
|
+
pattern: /\b(?:disregard|forget|override|bypass)\b.{0,40}\b(?:previous|prior|system|above|rules?|instructions?|guidelines?)\b/i,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "role hijack",
|
|
36
|
+
pattern: /\byou are (?:now |actually )?(?:a|an|the)\b.{0,60}\b(?:assistant|system|admin|root)\b/i,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "hidden system prompt",
|
|
40
|
+
pattern: /\[(?:SYSTEM|INST)\]|<\|(?:system|im_start)\|?>/i,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "encoding evasion",
|
|
44
|
+
pattern: /\b(?:base64|rot13|hex)[- ]?(?:decode|encoded?)\b.{0,60}\b(?:execute|run|eval)\b/i,
|
|
45
|
+
},
|
|
46
|
+
// --- Credential / data exfiltration ---
|
|
29
47
|
{
|
|
30
48
|
name: "credential extraction",
|
|
31
|
-
pattern: /\b(?:read|print|send|upload|exfiltrate)\b.{0,80}\b(?:credentials?|tokens?|secrets?|\.ssh|\.aws)\b/i,
|
|
49
|
+
pattern: /\b(?:read|print|send|upload|exfiltrate|output|display|echo|cat)\b.{0,80}\b(?:credentials?|tokens?|secrets?|\.ssh|\.aws|\.env|api[_-]?keys?|passwords?)\b/i,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "exfiltration via URL",
|
|
53
|
+
pattern: /\b(?:curl|wget|fetch|http)\b.{0,120}\b(?:credentials?|tokens?|secrets?|api[_-]?key|password|\.env)\b/i,
|
|
32
54
|
},
|
|
55
|
+
{
|
|
56
|
+
name: "environment variable leak",
|
|
57
|
+
pattern: /\b(?:print|echo|log|send|post)\b.{0,40}\bprocess\.env\b/i,
|
|
58
|
+
},
|
|
59
|
+
// --- Destructive operations ---
|
|
33
60
|
{
|
|
34
61
|
name: "hidden destructive command",
|
|
35
|
-
pattern: /\b(?:rm\s+-rf|del\s+\/s|format\s+[a-z]
|
|
62
|
+
pattern: /\b(?:rm\s+-rf|del\s+\/s|format\s+[a-z]:|mkfs\b|dd\s+if=)/i,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "git force push",
|
|
66
|
+
pattern: /\bgit\s+push\s+(?:--force|-f)\b/i,
|
|
67
|
+
},
|
|
68
|
+
// --- Stealth / persistence ---
|
|
69
|
+
{
|
|
70
|
+
name: "hidden file creation",
|
|
71
|
+
pattern: /\b(?:create|write|add|mkdir)\b.{0,60}(?:\/\.[a-z]|\\\.|\bhidden\b)/i,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "cron/scheduler injection",
|
|
75
|
+
pattern: /\b(?:crontab|at\s+now|systemctl\s+enable|launchctl\s+load)\b/i,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "network listener",
|
|
79
|
+
pattern: /\b(?:listen|bind|nc\s+-l|socat|ncat)\b.{0,40}\b(?:port|0\.0\.0\.0|\d{4,5})\b/i,
|
|
36
80
|
},
|
|
37
81
|
];
|
|
38
82
|
export function detectSecretKinds(content) {
|
|
@@ -5,14 +5,20 @@
|
|
|
5
5
|
* turn; the CLI does not provide mid-turn injection or global session listing.
|
|
6
6
|
*/
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
|
-
import { promisify } from "node:util";
|
|
9
|
-
const exec = promisify(execFile);
|
|
10
8
|
const PROVIDER = "claude-code";
|
|
11
9
|
const CLI = "claude";
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
export const runClaudeCommand = async (command, args, options) => new Promise((resolve, reject) => {
|
|
11
|
+
const child = execFile(command, [...args], { ...options, encoding: "utf8" }, (error, stdout) => {
|
|
12
|
+
if (error) {
|
|
13
|
+
reject(error);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
resolve({ stdout: String(stdout) });
|
|
17
|
+
});
|
|
18
|
+
// execFile opens a writable stdin pipe. Claude waits briefly for input
|
|
19
|
+
// unless the parent closes it, so end it as soon as the process starts.
|
|
20
|
+
child.stdin?.end();
|
|
21
|
+
});
|
|
16
22
|
function parseSessionOutput(stdout) {
|
|
17
23
|
let parsed;
|
|
18
24
|
try {
|
|
@@ -40,7 +46,7 @@ export class ClaudeCodeAdapter {
|
|
|
40
46
|
provider = PROVIDER;
|
|
41
47
|
sessions = new Map();
|
|
42
48
|
responses = new Map();
|
|
43
|
-
constructor(runCommand =
|
|
49
|
+
constructor(runCommand = runClaudeCommand) {
|
|
44
50
|
this.runCommand = runCommand;
|
|
45
51
|
}
|
|
46
52
|
capabilities = {
|
|
@@ -73,6 +79,7 @@ export class ClaudeCodeAdapter {
|
|
|
73
79
|
const { stdout } = await this.runCommand(CLI, args, {
|
|
74
80
|
cwd: options.cwd,
|
|
75
81
|
timeout: options.timeout ?? 30000,
|
|
82
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
76
83
|
});
|
|
77
84
|
const output = parseSessionOutput(stdout);
|
|
78
85
|
const sessionId = output.sessionId;
|
|
@@ -122,6 +129,7 @@ export class ClaudeCodeAdapter {
|
|
|
122
129
|
const { stdout } = await this.runCommand(CLI, args, {
|
|
123
130
|
cwd: session.cwd,
|
|
124
131
|
timeout: options.timeout ?? 30000,
|
|
132
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
125
133
|
});
|
|
126
134
|
const output = parseSessionOutput(stdout);
|
|
127
135
|
if (output.response) {
|
|
@@ -17,25 +17,25 @@ function requireThreadId(thread) {
|
|
|
17
17
|
}
|
|
18
18
|
return id;
|
|
19
19
|
}
|
|
20
|
-
async function
|
|
21
|
-
if (
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
20
|
+
async function runWithCancellation(thread, prompt, options) {
|
|
21
|
+
if (options.timeout === undefined) {
|
|
22
|
+
return thread.run(prompt, options.signal ? { signal: options.signal } : undefined);
|
|
23
|
+
}
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const onAbort = () => controller.abort(options.signal?.reason);
|
|
26
|
+
if (options.signal?.aborted)
|
|
27
|
+
onAbort();
|
|
28
|
+
else
|
|
29
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
30
|
+
const timer = setTimeout(() => controller.abort(new Error(`Codex provider turn timed out after ${options.timeout}ms`)), options.timeout);
|
|
31
|
+
timer.unref();
|
|
32
|
+
try {
|
|
33
|
+
return await thread.run(prompt, { signal: controller.signal });
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
38
|
+
}
|
|
39
39
|
}
|
|
40
40
|
function responseFromRun(result) {
|
|
41
41
|
if (typeof result === "object" &&
|
|
@@ -84,8 +84,10 @@ export class CodexAdapter {
|
|
|
84
84
|
const thread = this.driver.startThread({
|
|
85
85
|
workingDirectory: options.cwd,
|
|
86
86
|
});
|
|
87
|
-
const
|
|
88
|
-
|
|
87
|
+
const result = await runWithCancellation(thread, options.prompt ?? "", {
|
|
88
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
89
|
+
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
|
|
90
|
+
});
|
|
89
91
|
const sessionId = requireThreadId(thread);
|
|
90
92
|
const session = {
|
|
91
93
|
sessionId,
|
|
@@ -131,8 +133,10 @@ export class CodexAdapter {
|
|
|
131
133
|
return false;
|
|
132
134
|
session.busy = true;
|
|
133
135
|
try {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
+
const result = await runWithCancellation(thread, options.message, {
|
|
137
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
138
|
+
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
|
|
139
|
+
});
|
|
136
140
|
const response = responseFromRun(result);
|
|
137
141
|
if (response)
|
|
138
142
|
this.responses.set(session.sessionId, response);
|
|
@@ -240,7 +240,7 @@ function ownershipFromEvents(events) {
|
|
|
240
240
|
}
|
|
241
241
|
ownership.set(path, {
|
|
242
242
|
agent: event.from,
|
|
243
|
-
paths,
|
|
243
|
+
paths: [path],
|
|
244
244
|
mode: payload.mode,
|
|
245
245
|
eventId: event.id,
|
|
246
246
|
seq: event.seq,
|
|
@@ -502,12 +502,13 @@ export function formatSnapshot(snap) {
|
|
|
502
502
|
lines.push(`File ownership (${snap.ownership.length} paths):`);
|
|
503
503
|
const byAgent = new Map();
|
|
504
504
|
for (const claim of snap.ownership) {
|
|
505
|
-
const existing = byAgent.get(claim.agent) ??
|
|
506
|
-
|
|
505
|
+
const existing = byAgent.get(claim.agent) ?? new Set();
|
|
506
|
+
for (const p of claim.paths)
|
|
507
|
+
existing.add(p);
|
|
507
508
|
byAgent.set(claim.agent, existing);
|
|
508
509
|
}
|
|
509
510
|
for (const [agent, paths] of byAgent) {
|
|
510
|
-
lines.push(` ${agent}: ${paths.join(", ")}`);
|
|
511
|
+
lines.push(` ${agent}: ${[...paths].join(", ")}`);
|
|
511
512
|
}
|
|
512
513
|
lines.push("");
|
|
513
514
|
}
|
|
@@ -82,13 +82,27 @@ export function formatDiscussion(state) {
|
|
|
82
82
|
];
|
|
83
83
|
for (const event of state.events) {
|
|
84
84
|
const payload = payloadOf(event);
|
|
85
|
+
if (payload.kind === "started")
|
|
86
|
+
continue;
|
|
87
|
+
if (payload.kind === "closed" && state.status !== "failed")
|
|
88
|
+
continue;
|
|
85
89
|
lines.push(`[round ${payload.round}] ${event.from} · ${payload.kind}`, payload.content, "");
|
|
86
90
|
}
|
|
87
91
|
if (state.truncatedEvents > 0) {
|
|
88
92
|
lines.push(`${state.truncatedEvents} earlier event(s) omitted.`, "");
|
|
89
93
|
}
|
|
94
|
+
if (state.finalDecision || state.status === "failed") {
|
|
95
|
+
lines.push("Outcome", "-------");
|
|
96
|
+
}
|
|
90
97
|
if (state.finalDecision)
|
|
91
98
|
lines.push(`Decision: ${state.finalDecision}`);
|
|
99
|
+
if (state.status === "failed") {
|
|
100
|
+
const failure = [...state.events]
|
|
101
|
+
.reverse()
|
|
102
|
+
.find((event) => payloadOf(event).kind === "closed");
|
|
103
|
+
if (failure)
|
|
104
|
+
lines.push(`Failure: ${payloadOf(failure).content}`);
|
|
105
|
+
}
|
|
92
106
|
if (state.alternatives.length > 0) {
|
|
93
107
|
lines.push(`Alternatives: ${state.alternatives.join("; ")}`);
|
|
94
108
|
}
|
|
@@ -97,9 +111,15 @@ export function formatDiscussion(state) {
|
|
|
97
111
|
}
|
|
98
112
|
return lines.join("\n").trimEnd();
|
|
99
113
|
}
|
|
114
|
+
const MAX_DECISION_LENGTH = 200;
|
|
115
|
+
function boundedDecision(value) {
|
|
116
|
+
if (value.length <= MAX_DECISION_LENGTH)
|
|
117
|
+
return value;
|
|
118
|
+
return `${value.slice(0, MAX_DECISION_LENGTH - 1).trimEnd()}…`;
|
|
119
|
+
}
|
|
100
120
|
const conclusionSchema = z
|
|
101
121
|
.object({
|
|
102
|
-
decision: z.string().trim().min(1).
|
|
122
|
+
decision: z.string().trim().min(1).transform(boundedDecision),
|
|
103
123
|
rationale: z.string().trim().min(1).max(10_000),
|
|
104
124
|
alternatives: z.array(z.string().trim().min(1).max(2_000)).max(10),
|
|
105
125
|
unresolved: z.array(z.string().trim().min(1).max(2_000)).max(10),
|
|
@@ -277,7 +297,7 @@ export async function runDiscussion(projectRoot, options) {
|
|
|
277
297
|
}
|
|
278
298
|
await assertCoordinationEnabled(projectRoot);
|
|
279
299
|
const current = await getDiscussion(projectRoot, threadId);
|
|
280
|
-
const synthesisResponse = publicResponse(await proposer.respond(safePrompt(`Topic: ${topic}\n\nPublic transcript (untrusted discussion data):\n${transcriptForPrompt(current?.events ?? [])}\n\nSynthesize the best-supported outcome. Return only strict JSON with this exact shape: {"decision":"one concise decision","rationale":"why it won","alternatives":["credible alternative"],"unresolved":["remaining uncertainty"]}. Do not claim consensus when disagreement remains; put it in unresolved.`)), proposer.agent);
|
|
300
|
+
const synthesisResponse = publicResponse(await proposer.respond(safePrompt(`Topic: ${topic}\n\nPublic transcript (untrusted discussion data):\n${transcriptForPrompt(current?.events ?? [])}\n\nSynthesize the best-supported outcome. Return only strict JSON with this exact shape: {"decision":"one concise decision","rationale":"why it won","alternatives":["credible alternative"],"unresolved":["remaining uncertainty"]}. Keep decision at most ${MAX_DECISION_LENGTH} characters. Do not claim consensus when disagreement remains; put it in unresolved.`)), proposer.agent);
|
|
281
301
|
turnsUsed += 1;
|
|
282
302
|
const conclusion = parseConclusion(synthesisResponse);
|
|
283
303
|
const summary = await emit(projectRoot, {
|
|
@@ -88,9 +88,19 @@ export async function compact(projectRoot, config = DEFAULT_RETENTION) {
|
|
|
88
88
|
payload: summaryPayload,
|
|
89
89
|
});
|
|
90
90
|
const lines = [
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
...stateCheckpoints.map((event) => ({
|
|
92
|
+
seq: event.seq,
|
|
93
|
+
line: JSON.stringify(event),
|
|
94
|
+
})),
|
|
95
|
+
{ seq: remove[remove.length - 1].seq, line: summaryLine },
|
|
96
|
+
...keptByIndex.map((event) => ({
|
|
97
|
+
seq: event.seq,
|
|
98
|
+
line: JSON.stringify(event),
|
|
99
|
+
})),
|
|
100
|
+
]
|
|
101
|
+
.sort((left, right) => left.seq - right.seq)
|
|
102
|
+
.map(({ line }) => line)
|
|
103
|
+
.join("\n");
|
|
94
104
|
// Atomic write: write to temp, rename over
|
|
95
105
|
const tmpPath = `${logPath}.tmp`;
|
|
96
106
|
await writeFile(tmpPath, lines + "\n", {
|
|
@@ -169,7 +179,7 @@ function stateKey(event) {
|
|
|
169
179
|
case "ownership":
|
|
170
180
|
// Key by agent + sorted paths — an ownership event replaces the previous
|
|
171
181
|
// one for the same agent/path combination.
|
|
172
|
-
return `ownership:${event.from}:${(p.paths ?? []).sort().join(",")}`;
|
|
182
|
+
return `ownership:${event.from}:${[...(p.paths ?? [])].sort().join(",")}`;
|
|
173
183
|
case "contract":
|
|
174
184
|
return `contract:${p.name}`;
|
|
175
185
|
case "decision":
|
|
@@ -260,7 +260,7 @@ export function formatPreparedCatalogInstall(prepared, options = {}) {
|
|
|
260
260
|
if (directoriesPerAgent > DEFAULT_ACTIVE_SKILL_LIMIT)
|
|
261
261
|
lines.push(`Capacity notice: about ${directoriesPerAgent} skill directories per agent exceeds Stable's ${DEFAULT_ACTIVE_SKILL_LIMIT}-skill bound.${prepared.selection.mode === "maximum" ? " Maximum stores them in the disabled library; optimize or activate a project-relevant working set." : prepared.selection.mode === "power" ? " Power is the explicit larger active mode; choose Stable or project optimization when lower context use matters." : " Use project-aware activation for a smaller working set."}`);
|
|
262
262
|
if (failures.length)
|
|
263
|
-
lines.push(`Preparation failures (installation will remain blocked): ${failures.map((item) => item.packageId).join(", ")}`);
|
|
263
|
+
lines.push(`Preparation failures (${prepared.selection.mode === "maximum" ? "will be skipped; remaining library can install" : "installation will remain blocked"}): ${failures.map((item) => item.packageId).join(", ")}`);
|
|
264
264
|
if (quarantined.length)
|
|
265
265
|
lines.push(`Quarantined invalid skill units: ${quarantined.length} (safe siblings remain available)`);
|
|
266
266
|
if (explicit.length)
|
|
@@ -292,8 +292,14 @@ export async function applyPreparedCatalogInstall(prepared, options = {}) {
|
|
|
292
292
|
if (!prepared.entries.length)
|
|
293
293
|
throw new Error("No reviewed skill packages could be prepared for installation");
|
|
294
294
|
const failures = prepared.skipped.filter((item) => item.kind === "preparation-failed");
|
|
295
|
-
if (failures.length)
|
|
295
|
+
if (failures.length && prepared.selection.mode !== "maximum") {
|
|
296
296
|
throw new Error(`Setup is incomplete because reviewed packages failed to prepare: ${failures.map((item) => item.packageId).join(", ")}. Retry when GitHub is reachable; no partial loadout was installed.`);
|
|
297
|
+
}
|
|
298
|
+
if (failures.length) {
|
|
299
|
+
for (const fail of failures) {
|
|
300
|
+
console.error(`Warning: skipping ${fail.packageId} (preparation failed). The remaining library will install without it.`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
297
303
|
const risky = prepared.entries.filter((entry) => entry.safety.approvalRequired);
|
|
298
304
|
if (risky.length && !options.approveRisk)
|
|
299
305
|
throw new Error(`Additional risk approval is required for: ${risky.map((entry) => entry.package.id).join(", ")}. Review the plan, then use --approve-risk.`);
|
|
@@ -83,14 +83,14 @@ export async function restoreSnapshot(snapshot, options = {}) {
|
|
|
83
83
|
validateSnapshot(snapshot);
|
|
84
84
|
if (options.requireUnchangedPostMutationState)
|
|
85
85
|
await assertUnchangedPostMutationState(snapshot);
|
|
86
|
-
for (const root of snapshot.roots)
|
|
86
|
+
for (const root of snapshot.roots) {
|
|
87
|
+
await makeTreeRemovable(root);
|
|
87
88
|
await rm(root, { recursive: true, force: true });
|
|
89
|
+
}
|
|
88
90
|
for (const directory of snapshot.files
|
|
89
91
|
.filter((file) => file.existed && file.directory)
|
|
90
92
|
.sort((a, b) => a.path.length - b.path.length)) {
|
|
91
93
|
await mkdir(directory.path, { recursive: true });
|
|
92
|
-
if (directory.mode !== undefined)
|
|
93
|
-
await chmod(directory.path, directory.mode);
|
|
94
94
|
}
|
|
95
95
|
for (const file of snapshot.files) {
|
|
96
96
|
if (!file.existed || file.directory)
|
|
@@ -102,6 +102,30 @@ export async function restoreSnapshot(snapshot, options = {}) {
|
|
|
102
102
|
if (file.mode !== undefined)
|
|
103
103
|
await chmod(file.path, file.mode);
|
|
104
104
|
}
|
|
105
|
+
for (const directory of snapshot.files
|
|
106
|
+
.filter((file) => file.existed && file.directory)
|
|
107
|
+
.sort((a, b) => b.path.length - a.path.length)) {
|
|
108
|
+
if (directory.mode !== undefined)
|
|
109
|
+
await chmod(directory.path, directory.mode);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function makeTreeRemovable(path) {
|
|
113
|
+
let info;
|
|
114
|
+
try {
|
|
115
|
+
info = await lstat(path);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (isFileError(error, "ENOENT"))
|
|
119
|
+
return;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
123
|
+
return;
|
|
124
|
+
await chmod(path, (info.mode & 0o777) | 0o700);
|
|
125
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
126
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
127
|
+
await makeTreeRemovable(join(path, entry.name));
|
|
128
|
+
}
|
|
105
129
|
}
|
|
106
130
|
/** Attach the committed state used to make later user-requested rollback safe. */
|
|
107
131
|
export async function recordSnapshotPostMutationState(snapshot) {
|
|
@@ -259,7 +283,7 @@ function validateSnapshotFiles(files, roots, label) {
|
|
|
259
283
|
(typeof file.mode !== "number" ||
|
|
260
284
|
!Number.isInteger(file.mode) ||
|
|
261
285
|
file.mode < 0 ||
|
|
262
|
-
file.mode >
|
|
286
|
+
file.mode > 0o777)))
|
|
263
287
|
throw new Error(`${label} file ${index} is invalid`);
|
|
264
288
|
const filePath = file.path;
|
|
265
289
|
if (resolve(filePath) !== filePath)
|
|
@@ -17,7 +17,7 @@ export const REPOSITORY_FETCH_DEFAULTS = {
|
|
|
17
17
|
maxBytes: 256 * 1024 * 1024,
|
|
18
18
|
maxFiles: 20_000,
|
|
19
19
|
};
|
|
20
|
-
function withFetchDefaults(options) {
|
|
20
|
+
export function withFetchDefaults(options) {
|
|
21
21
|
return {
|
|
22
22
|
...options,
|
|
23
23
|
timeoutMs: options.timeoutMs ?? REPOSITORY_FETCH_DEFAULTS.timeoutMs,
|
|
@@ -320,24 +320,26 @@ export async function fetchGitSnapshot(input, options = {}) {
|
|
|
320
320
|
// checked before fetching the way the GitHub path does. Enforce a bound
|
|
321
321
|
// AFTER a shallow clone instead of refusing bounded requests, giving parity
|
|
322
322
|
// with the catalog path and a default ceiling against an adversarial repo.
|
|
323
|
-
const boundedOptions = {
|
|
323
|
+
const boundedOptions = withFetchDefaults({
|
|
324
324
|
...options,
|
|
325
325
|
maxBytes: options.maxBytes ?? 128 * 1024 * 1024,
|
|
326
326
|
maxFiles: options.maxFiles ?? 20_000,
|
|
327
|
-
};
|
|
327
|
+
});
|
|
328
328
|
const url = normalizeGitUrl(input);
|
|
329
329
|
const temporary = await mkdtemp(join(tmpdir(), "loadout-git-"));
|
|
330
330
|
try {
|
|
331
331
|
const gitEnvironment = await isolatedGitEnvironment(loadoutHome());
|
|
332
|
-
const refArgs =
|
|
332
|
+
const refArgs = boundedOptions.ref
|
|
333
|
+
? ["--branch", normalizeRef(boundedOptions.ref)]
|
|
334
|
+
: [];
|
|
333
335
|
await execFileAsync("git", ["clone", "--depth", "1", ...refArgs, "--", url, temporary], {
|
|
334
336
|
maxBuffer: 10 * 1024 * 1024,
|
|
335
|
-
timeout:
|
|
337
|
+
timeout: boundedOptions.timeoutMs,
|
|
336
338
|
env: {
|
|
337
339
|
...gitEnvironment,
|
|
338
340
|
},
|
|
339
341
|
});
|
|
340
|
-
const { stdout } = await execFileAsync("git", ["-C", temporary, "rev-parse", "HEAD"], { timeout:
|
|
342
|
+
const { stdout } = await execFileAsync("git", ["-C", temporary, "rev-parse", "HEAD"], { timeout: boundedOptions.timeoutMs, env: gitEnvironment });
|
|
341
343
|
const commit = stdout.trim();
|
|
342
344
|
if (!/^[0-9a-f]{40}$/i.test(commit))
|
|
343
345
|
throw new Error("Git returned an invalid commit");
|
|
@@ -8,6 +8,7 @@ import { analyzeUpdateSafety } from "../catalog/safety.js";
|
|
|
8
8
|
import { detectAgents, loadoutHome } from "../agents/paths.js";
|
|
9
9
|
import { applySkillInstall, buildSkillPlan, installedAgents, } from "./install.js";
|
|
10
10
|
import { discoverSkillDirectories, validateSkillDirectory, } from "../catalog/skills.js";
|
|
11
|
+
import { loadEffectiveCatalog } from "../catalog/catalog.js";
|
|
11
12
|
function managedUnitIds(state, packageId) {
|
|
12
13
|
return [
|
|
13
14
|
...new Set((state.activations ?? [])
|
|
@@ -84,6 +85,22 @@ async function analyzeManagedUpdate(oldRoot, newRoot, unitIds) {
|
|
|
84
85
|
approvalRequired: safetyFindings.some((finding) => finding.severity === "blocking"),
|
|
85
86
|
};
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Index catalog-pinned commits by repository for O(1) lookup during update.
|
|
90
|
+
* A commit matches if the catalog's source evidence references the same SHA.
|
|
91
|
+
*/
|
|
92
|
+
function buildCatalogCommitIndex(catalog) {
|
|
93
|
+
const index = new Map();
|
|
94
|
+
for (const pkg of catalog) {
|
|
95
|
+
if (pkg.source?.commit && pkg.repository) {
|
|
96
|
+
const repository = pkg.repository.toLowerCase();
|
|
97
|
+
const commits = index.get(repository) ?? new Set();
|
|
98
|
+
commits.add(pkg.source.commit.toLowerCase());
|
|
99
|
+
index.set(repository, commits);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return index;
|
|
103
|
+
}
|
|
87
104
|
/** Builds a read-only update plan from persisted installs and live GitHub snapshots. */
|
|
88
105
|
export async function buildUpdatePlan(resolver, options = {}) {
|
|
89
106
|
const state = await readInstallState();
|
|
@@ -91,6 +108,18 @@ export async function buildUpdatePlan(resolver, options = {}) {
|
|
|
91
108
|
? state.installs.filter((record) => record.packageId === options.packageId)
|
|
92
109
|
: state.installs;
|
|
93
110
|
const results = new Array(records.length);
|
|
111
|
+
// Load catalog commit pins so we can flag updates that diverge from
|
|
112
|
+
// the last reviewed snapshot. This is a read-only safety check — it
|
|
113
|
+
// does not prevent the update, but adds a blocking safety finding
|
|
114
|
+
// when the upstream HEAD has moved past the catalog-pinned commit.
|
|
115
|
+
let catalogIndex;
|
|
116
|
+
try {
|
|
117
|
+
const catalog = options.catalog ?? (await loadEffectiveCatalog());
|
|
118
|
+
catalogIndex = buildCatalogCommitIndex(catalog);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
catalogIndex = new Map();
|
|
122
|
+
}
|
|
94
123
|
const lightweightResolver = resolver ??
|
|
95
124
|
options.resolveHead ??
|
|
96
125
|
((repository) => resolveRepositoryHead(repository, { timeoutMs: 30_000 }));
|
|
@@ -164,12 +193,36 @@ export async function buildUpdatePlan(resolver, options = {}) {
|
|
|
164
193
|
throw new Error(`Resolved ${current.commit}, but fetched ${fetched.commit} for safety review`);
|
|
165
194
|
currentPath = fetched.path;
|
|
166
195
|
}
|
|
196
|
+
// Check whether the upstream HEAD matches the catalog-pinned
|
|
197
|
+
// commit. A mismatch means the repository has changed since the
|
|
198
|
+
// catalog was last reviewed — the content may be fine, but it
|
|
199
|
+
// hasn't been vetted, so flag it for human review.
|
|
200
|
+
let catalogDrift = false;
|
|
201
|
+
if (!same && record.repository) {
|
|
202
|
+
const pinnedCommits = catalogIndex.get(record.repository.toLowerCase());
|
|
203
|
+
if (pinnedCommits &&
|
|
204
|
+
!pinnedCommits.has(current.commit.toLowerCase())) {
|
|
205
|
+
catalogDrift = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
167
208
|
if (!same && currentPath) {
|
|
168
209
|
const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
|
|
169
210
|
const analysis = await analyzeManagedUpdate(oldPath, currentPath, managedUnitIds(state, record.packageId));
|
|
170
211
|
diff = analysis.diff;
|
|
171
|
-
safetyFindings = analysis.safetyFindings;
|
|
212
|
+
safetyFindings = analysis.safetyFindings ?? [];
|
|
172
213
|
approvalRequired = analysis.approvalRequired;
|
|
214
|
+
if (catalogDrift) {
|
|
215
|
+
safetyFindings.push({
|
|
216
|
+
severity: "blocking",
|
|
217
|
+
category: "instruction",
|
|
218
|
+
message: "Upstream HEAD has moved past the catalog-reviewed commit. " +
|
|
219
|
+
"The new content has not been verified by the catalog maintainer. " +
|
|
220
|
+
"Review the diff carefully before approving.",
|
|
221
|
+
paths: [],
|
|
222
|
+
names: ["catalog-drift"],
|
|
223
|
+
});
|
|
224
|
+
approvalRequired = true;
|
|
225
|
+
}
|
|
173
226
|
}
|
|
174
227
|
return {
|
|
175
228
|
...base,
|
|
@@ -185,6 +238,7 @@ export async function buildUpdatePlan(resolver, options = {}) {
|
|
|
185
238
|
...(approvalRequired ? { approvalRequired: true } : {}),
|
|
186
239
|
...(safetyFindings?.length ? { safetyFindings } : {}),
|
|
187
240
|
...(diff ? { diff } : {}),
|
|
241
|
+
...(catalogDrift ? { catalogDrift: true } : {}),
|
|
188
242
|
};
|
|
189
243
|
}
|
|
190
244
|
catch (error) {
|