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.
@@ -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
- if (!sessionId) {
72
- const started = await sessions.startSession(selection.provider, cwd, prompt, timeoutMs);
73
- sessionId = started.sessionId;
74
- }
75
- else {
76
- const accepted = await sessions.submitTurn(sessionId, prompt, timeoutMs);
77
- if (!accepted) {
78
- throw new Error(`${selection.provider}:${sessionId} rejected the discussion turn`);
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]:)\b/i,
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 defaultCommandDriver = async (command, args, options) => {
13
- const { stdout } = await exec(command, [...args], options);
14
- return { stdout: String(stdout) };
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 = defaultCommandDriver) {
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 withAbort(promise, thread, signal) {
21
- if (!signal)
22
- return promise;
23
- signal.throwIfAborted();
24
- return new Promise((resolve, reject) => {
25
- const onAbort = () => {
26
- thread.abort?.();
27
- reject(signal.reason ??
28
- new DOMException("The operation was aborted", "AbortError"));
29
- };
30
- signal.addEventListener("abort", onAbort, { once: true });
31
- promise.then((value) => {
32
- signal.removeEventListener("abort", onAbort);
33
- resolve(value);
34
- }, (error) => {
35
- signal.removeEventListener("abort", onAbort);
36
- reject(error);
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 runPromise = thread.run(options.prompt ?? "");
88
- const result = await withAbort(runPromise, thread, options.signal);
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 runPromise = thread.run(options.message);
135
- const result = await withAbort(runPromise, thread, options.signal);
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
- existing.push(...claim.paths);
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).max(200),
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
- summaryLine,
92
- ...finalRetained.map((e) => JSON.stringify(e)),
93
- ].join("\n");
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 > 0o7777)))
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 = options.ref ? ["--branch", normalizeRef(options.ref)] : [];
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: options.timeoutMs,
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: options.timeoutMs, env: gitEnvironment });
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) {