@bike4mind/cli 3.2.0 → 3.3.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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { $ as PermissionDeniedError, A as ApiKeyType, C as htmlFirstH1, Ct as obfuscateApiKey, Dt as signImageUrl, Et as secureParameters, G as LOCATION_MAP_LANGUAGE, Gt as WEBSITE_URL, It as withRetry, L as ChatModels, N as BedrockEmbeddingModel, Ot as splitEquals, S as htmlErrorTitle, Wt as APP_NAME, X as OllamaEmbeddingModel, Y as NotFoundError, Z as OpenAIEmbeddingModel, a as commandTextOf, b as SESSION_ID_PATTERN, bt as isRetryableError, c as writePathOf, ct as assertAuthOriginMatchesApiKey, gt as googleMapsSearchUrl, it as SupportedFabFileMimeTypes, mt as getMcpProviderMetadata, o as isReadOnlyTool, ot as VoyageAIEmbeddingModel, q as McpServerName, r as loadContextFiles, st as WORK_ITEM_STATUSES, tt as SEARCH_RESULT_CARDS_LANGUAGE, w as escapeArtifactBodyJson, x as isValidSessionId, y as logger, yt as isPlaceholderImageSigningSecret } from "./ConfigStore-v0wgf0_j.mjs";
2
+ import { At as splitEquals, C as htmlErrorTitle, J as McpServerName, K as LOCATION_MAP_LANGUAGE, Kt as APP_NAME, Ot as secureParameters, P as BedrockEmbeddingModel, Q as OpenAIEmbeddingModel, R as ChatModels, Rt as withRetry, S as isValidSessionId, St as isRetryableError, T as escapeArtifactBodyJson, Tt as obfuscateApiKey, X as NotFoundError, Z as OllamaEmbeddingModel, a as commandTextOf, at as SupportedFabFileMimeTypes, b as logger, ct as VoyageAIEmbeddingModel, et as PermissionDeniedError, gt as getMcpProviderMetadata, j as ApiKeyType, kt as signImageUrl, l as writePathOf, lt as WORK_ITEM_STATUSES, nt as SEARCH_RESULT_CARDS_LANGUAGE, o as isReadOnlyTool, ot as UnprocessableEntityError, qt as WEBSITE_URL, r as loadContextFiles, s as sandboxedCommandArg, ut as assertAuthOriginMatchesApiKey, vt as googleMapsSearchUrl, w as htmlFirstH1, x as SESSION_ID_PATTERN, xt as isPlaceholderImageSigningSecret } from "./ConfigStore-DUATzLxB.mjs";
3
3
  import { n as isPathAllowed, t as assertPathAllowed } from "./pathValidation-BRqf4HFX-CHwtwp3O.mjs";
4
4
  import { n as isTerminalShellStatus, t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
5
- import { n as B4mStreamEndedError, r as createClient, t as B4mApiError } from "./dist-5DDaX8tB.mjs";
5
+ import { n as B4mStreamEndedError, r as createClient, t as B4mApiError } from "./dist-DPP7UmK9.mjs";
6
6
  import { createHash, randomBytes } from "crypto";
7
7
  import { existsSync, lstatSync, promises, readdirSync, realpathSync, rmSync, statSync } from "fs";
8
8
  import os, { homedir } from "os";
@@ -2363,7 +2363,7 @@ new Map([
2363
2363
  ...Object.values(OLLAMA_EMBEDDING_MODEL_MAP)
2364
2364
  ].map((info) => [info.model, info.dimensions[0]]));
2365
2365
  //#endregion
2366
- //#region ../../b4m-core/services/dist/webfetch-D2pxGurU.mjs
2366
+ //#region ../../b4m-core/services/dist/webfetch-BDmPyiMm.mjs
2367
2367
  const htmlToMarkdown = (html, _isArxiv = false) => {
2368
2368
  const turndownService = new turndown({
2369
2369
  headingStyle: "atx",
@@ -2655,6 +2655,13 @@ function webFetchBody(result) {
2655
2655
  if (result.extractedChars === 0 && result.offset > 0) return `[web_fetch: offset ${result.offset} is at or beyond the end of the content (~${result.originalChars} chars); nothing further to read.]`;
2656
2656
  return result.truncated ? result.markdown + truncationMarker(result) : result.markdown;
2657
2657
  }
2658
+ /**
2659
+ * Leads an indexed turn's fetched page with the number the turn gave it (ToolContext.numberCitables).
2660
+ * A directive for the same reason as INDEXED_TOOL_CITATION_RULE.
2661
+ */
2662
+ function webFetchCitationLine(citationIndex) {
2663
+ return `This page is source [${citationIndex}]. Cite content from it ONLY as [${citationIndex}], placed immediately after the claim it supports.\n\n`;
2664
+ }
2658
2665
  function isPdfUrl(url) {
2659
2666
  try {
2660
2667
  const { pathname } = new URL(url);
@@ -2704,6 +2711,35 @@ async function probeLlmsTxt(pageUrl) {
2704
2711
  return full ?? index;
2705
2712
  }
2706
2713
  /**
2714
+ * Firecrawl refused the target site itself (its 403 "we do not support this site"). A property of
2715
+ * the URL, not a fault of ours, so it is a 422: /api/tools/web-fetch logs it at warn instead of
2716
+ * paging, and the web_fetch tool turns it into a hint to use web_search. Only 403 - Firecrawl's
2717
+ * 401/402/429 are our key, credits or rate limit and must keep surfacing as server errors.
2718
+ */
2719
+ var UnsupportedScrapeTargetError = class extends UnprocessableEntityError {
2720
+ constructor(url, cause) {
2721
+ super(`This site can't be fetched: the web scraper does not support ${new URL(url).hostname}.`);
2722
+ this.name = "UnsupportedScrapeTargetError";
2723
+ this.cause = cause;
2724
+ }
2725
+ };
2726
+ /** Scrape after a 1s wait for client-side rendering, retrying without the action where Firecrawl rejects actions. */
2727
+ async function scrapeAfterRenderWait(app, url, params) {
2728
+ try {
2729
+ return await app.scrapeUrl(url, {
2730
+ ...params,
2731
+ actions: [{
2732
+ type: "wait",
2733
+ milliseconds: 1e3
2734
+ }]
2735
+ });
2736
+ } catch (scrapeError) {
2737
+ if (!(scrapeError instanceof Error ? scrapeError.message : "").includes("Actions are not supported")) throw scrapeError;
2738
+ Logger.globalInstance.log("📥 WebFetch Tool: Actions not supported, retrying without actions...");
2739
+ return app.scrapeUrl(url, params);
2740
+ }
2741
+ }
2742
+ /**
2707
2743
  * Fetch URL content using Firecrawl (shared function, no ToolContext)
2708
2744
  * Pattern follows serpApiSearch from websearch tool
2709
2745
  *
@@ -2742,20 +2778,11 @@ async function firecrawlFetch(adapters, url, options) {
2742
2778
  };
2743
2779
  const startedAt = Date.now();
2744
2780
  let result;
2745
- if (isPdf) result = await app.scrapeUrl(url, baseParams);
2746
- else try {
2747
- result = await app.scrapeUrl(url, {
2748
- ...baseParams,
2749
- actions: [{
2750
- type: "wait",
2751
- milliseconds: 1e3
2752
- }]
2753
- });
2781
+ try {
2782
+ result = isPdf ? await app.scrapeUrl(url, baseParams) : await scrapeAfterRenderWait(app, url, baseParams);
2754
2783
  } catch (scrapeError) {
2755
- if ((scrapeError instanceof Error ? scrapeError.message : "").includes("Actions are not supported")) {
2756
- Logger.globalInstance.log("📥 WebFetch Tool: Actions not supported, retrying without actions...");
2757
- result = await app.scrapeUrl(url, baseParams);
2758
- } else throw scrapeError;
2784
+ if (scrapeError instanceof FirecrawlError && scrapeError.statusCode === 403) throw new UnsupportedScrapeTargetError(url, scrapeError);
2785
+ throw scrapeError;
2759
2786
  }
2760
2787
  const durationMs = Date.now() - startedAt;
2761
2788
  if (!result || result.error) {
@@ -2830,19 +2857,27 @@ const webFetchTool = {
2830
2857
  cap: result.cap
2831
2858
  }
2832
2859
  };
2833
- await context.statusUpdate({ promptMeta: { citables: [citable] } }, "WebFetch completed successfully");
2860
+ const citationIndex = context.numberCitables?.([citable])?.[0].metadata?.citationIndex;
2861
+ await context.statusUpdate({ promptMeta: { citables: [citationIndex === void 0 ? citable : {
2862
+ ...citable,
2863
+ metadata: {
2864
+ ...citable.metadata,
2865
+ citationIndex
2866
+ }
2867
+ }] } }, "WebFetch completed successfully");
2834
2868
  Logger.globalInstance.log(`📚 WebFetch Tool: Stored citable source for ${params.url}`);
2835
- return webFetchBody(result);
2869
+ return (citationIndex === void 0 ? "" : webFetchCitationLine(citationIndex)) + webFetchBody(result);
2836
2870
  } catch (error) {
2837
2871
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
2838
- if (error instanceof FirecrawlError && [
2872
+ const firecrawlStatus = error instanceof UnsupportedScrapeTargetError ? 403 : error instanceof FirecrawlError ? error.statusCode : void 0;
2873
+ if (firecrawlStatus !== void 0 && [
2839
2874
  400,
2840
2875
  403,
2841
2876
  408,
2842
2877
  502,
2843
2878
  503,
2844
2879
  504
2845
- ].includes(error.statusCode)) {
2880
+ ].includes(firecrawlStatus)) {
2846
2881
  const { userMessage, statusLabel } = {
2847
2882
  400: {
2848
2883
  userMessage: `This site requires advanced browser features that are not available. Try using web_search to find the information instead.`,
@@ -2868,10 +2903,10 @@ const webFetchTool = {
2868
2903
  userMessage: `The website took too long to respond (Gateway Timeout). Try again later or use web_search to find the information instead.`,
2869
2904
  statusLabel: "gateway timeout"
2870
2905
  }
2871
- }[error.statusCode];
2906
+ }[firecrawlStatus];
2872
2907
  context.logger.warn("WebFetch expected Firecrawl failure", {
2873
2908
  url: params.url,
2874
- statusCode: error.statusCode,
2909
+ statusCode: firecrawlStatus,
2875
2910
  error: errorMessage
2876
2911
  });
2877
2912
  await context.statusUpdate({}, `WebFetch: ${statusLabel}`);
@@ -2987,7 +3022,16 @@ const WEB_SEARCH_MAP_PROMPT = [
2987
3022
  "explanation or citations. Omit it only if none of the places actually answer the question."
2988
3023
  ].join("\n");
2989
3024
  //#endregion
2990
- //#region ../../b4m-core/services/dist/websearch-Cak_rViw.mjs
3025
+ //#region ../../b4m-core/services/dist/websearch-Dm7iz09B.mjs
3026
+ /**
3027
+ * Shown to the model under tool results that carry the turn's shared numbers, so it cites them the
3028
+ * way forced retrieval's header asks it to cite the documents numbered before them. Keep it a directive:
3029
+ * on a tool-only turn there is no forced-retrieval header, and a purely descriptive label here was
3030
+ * tried and the model mostly stopped writing [N] markers at all.
3031
+ */
3032
+ const INDEXED_TOOL_CITATION_RULE = "Cite each of these sources ONLY by the bracketed number it is labelled with, placed immediately after the claim it supports. These numbers continue the sources already numbered this turn.";
3033
+ /** Each stamped citable's number, keyed by citable id, for labelling results that point at it. */
3034
+ const citationNumbersById = (citables) => new Map(citables.flatMap((citable) => citable.metadata?.citationIndex === void 0 ? [] : [[citable.id, citable.metadata.citationIndex]]));
2991
3035
  /**
2992
3036
  * Per-process cache for web-search results. Keyed on normalized query + result count, with a
2993
3037
  * short TTL so repeated/similar searches within a turn or across back-to-back turns reuse the
@@ -3667,15 +3711,19 @@ function describePlace(place) {
3667
3711
  place.address
3668
3712
  ].filter((part) => !!part).map(stripNewlines).join(" - ");
3669
3713
  }
3670
- function formatPlaceResults(places, anchor) {
3714
+ function formatPlaceResults(places, anchor, citationNumbers) {
3715
+ const numberOf = (place) => citationNumbers?.get(`place:${place.id}`);
3671
3716
  return [
3672
3717
  "Places found for this search (each has a map location; refer to it by its id):",
3673
3718
  "",
3674
- ...places.map((place, index) => `${index + 1}. ${describePlace(place)}\n id: ${stripNewlines(place.id)}`),
3719
+ ...places.map((place, index) => {
3720
+ const n = numberOf(place);
3721
+ return `${n !== void 0 ? `[${n}]` : `${index + 1}.`} ${describePlace(place)}\n id: ${stripNewlines(place.id)}`;
3722
+ }),
3675
3723
  ...anchor ? [
3676
3724
  "",
3677
3725
  "Anchor location (the place the user named):",
3678
- `${describePlace(anchor)}\n id: ${stripNewlines(anchor.id)}`
3726
+ `${numberOf(anchor) !== void 0 ? `[${numberOf(anchor)}] ` : ""}${describePlace(anchor)}\n id: ${stripNewlines(anchor.id)}`
3679
3727
  ] : []
3680
3728
  ].join("\n");
3681
3729
  }
@@ -3781,7 +3829,7 @@ async function searchWithHedge(lead, backup, query, numResults, searchOptions) {
3781
3829
  throw new Error(`Web search failed on both providers. ${lead.name}: ${errorMessage$1(leadError)}; ${backup.name}: ${errorMessage$1(backupError)}`);
3782
3830
  }
3783
3831
  }
3784
- async function performWebSearch(adapters, params, imageUrlSigningSecret = "", resolvedProviders) {
3832
+ async function performWebSearch(adapters, params, imageUrlSigningSecret = "", resolvedProviders, numberCitables) {
3785
3833
  Logger.globalInstance.log("🔍 WebSearch Tool: Starting search for query:", params.query);
3786
3834
  const [provider, backup] = await (resolvedProviders ?? resolveWebSearchProviders(adapters));
3787
3835
  if (!provider) {
@@ -3820,7 +3868,7 @@ async function performWebSearch(adapters, params, imageUrlSigningSecret = "", re
3820
3868
  if (placeResults.length) Logger.globalInstance.log(`WebSearch Tool: ${servedBy.name} found ${placeResults.length} places`);
3821
3869
  if (imageResults.length) Logger.globalInstance.log(`🖼️ WebSearch Tool: ${servedBy.name} found ${imageResults.length} images`);
3822
3870
  const withImages = shouldIncludeImages(results, wantsImages, imageResults);
3823
- const citables = results.map((result, index) => ({
3871
+ const unnumberedCitables = results.map((result, index) => ({
3824
3872
  id: result.url,
3825
3873
  type: "web_url",
3826
3874
  title: result.title,
@@ -3838,15 +3886,18 @@ async function performWebSearch(adapters, params, imageUrlSigningSecret = "", re
3838
3886
  } : {}
3839
3887
  }
3840
3888
  }));
3841
- if (withPlaces) for (const place of anchor ? [anchor, ...places] : places) citables.push(placeCitable(place, imageUrlSigningSecret, canSignImages));
3889
+ if (withPlaces) for (const place of anchor ? [anchor, ...places] : places) unnumberedCitables.push(placeCitable(place, imageUrlSigningSecret, canSignImages));
3890
+ const numberedCitables = numberCitables?.(unnumberedCitables);
3891
+ const citables = numberedCitables ?? unnumberedCitables;
3892
+ const resultLabel = (index) => numberedCitables ? `[${numberedCitables[index].metadata?.citationIndex}]` : `${index + 1}.`;
3842
3893
  const formattedResults = results.map((result, index) => {
3843
3894
  const imageLine = withImages && result.images?.length ? `Images: ${result.images.map((url) => signImageUrl(url, imageUrlSigningSecret)).join(" | ")}\n` : "";
3844
- return `${index + 1}. **${stripNewlines(result.title)}**\n${stripNewlines(result.snippet)}\n` + imageLine + `Source: [${safeHostname(result.url)}](${result.url})\n`;
3895
+ return `${resultLabel(index)} **${stripNewlines(result.title)}**\n${stripNewlines(result.snippet)}\n` + imageLine + `Source: [${safeHostname(result.url)}](${result.url})\n`;
3845
3896
  }).join("\n");
3846
3897
  const imageSection = withImages && imageResults.length ? `\n${formatImageResults(imageResults, imageUrlSigningSecret)}\n` : "";
3847
- const placeSection = withPlaces ? `\n${formatPlaceResults(places, anchor)}\n` : "";
3898
+ const placeSection = withPlaces ? `\n${formatPlaceResults(places, anchor, numberedCitables ? citationNumbersById(numberedCitables) : void 0)}\n` : "";
3848
3899
  return {
3849
- formattedResults: (formattedResults ? `Here's what I found from searching the web:\n\n${formattedResults}` : imageSection || placeSection ? "Here's what I found from searching the web:\n" : "No results found from web search.") + imageSection + placeSection + (withImages ? `\n${WEB_SEARCH_CARDS_PROMPT}` : "") + (withPlaces ? `\n${WEB_SEARCH_MAP_PROMPT}` : ""),
3900
+ formattedResults: (formattedResults ? `Here's what I found from searching the web:\n\n${formattedResults}` + (numberedCitables ? `\n${INDEXED_TOOL_CITATION_RULE}\n` : "") : imageSection || placeSection ? "Here's what I found from searching the web:\n" : "No results found from web search.") + imageSection + placeSection + (withImages ? `\n${WEB_SEARCH_CARDS_PROMPT}` : "") + (withPlaces ? `\n${WEB_SEARCH_MAP_PROMPT}` : ""),
3850
3901
  citables
3851
3902
  };
3852
3903
  } catch (error) {
@@ -3869,7 +3920,7 @@ const webSearchTool = {
3869
3920
  throw error;
3870
3921
  });
3871
3922
  const startedAt = Date.now();
3872
- const { formattedResults, citables } = await performWebSearch({ db: context.db }, params, config?.imageUrlSigningSecret, providersPromise);
3923
+ const { formattedResults, citables } = await performWebSearch({ db: context.db }, params, config?.imageUrlSigningSecret, providersPromise, context.numberCitables);
3873
3924
  context.logger.log("🔍 WebSearch Tool: search timing", {
3874
3925
  callNumber,
3875
3926
  query: params.query,
@@ -4130,7 +4181,7 @@ async function getModelForWrite(user, modelId, deps) {
4130
4181
  return isModelOwner(model, user) ? model : null;
4131
4182
  }
4132
4183
  //#endregion
4133
- //#region ../../b4m-core/services/dist/toolGenerators-BE8RB3Ga.mjs
4184
+ //#region ../../b4m-core/services/dist/toolGenerators-BVxSjEv_.mjs
4134
4185
  const diceRoll = async (parameters) => {
4135
4186
  if (!parameters?.sides || !parameters?.times) throw new Error("Tool dice roll: Missing required parameters");
4136
4187
  return sum(times(parameters.times, () => random(1, parameters.sides))).toString();
@@ -4844,7 +4895,7 @@ const askUserQuestionTool = {
4844
4895
  * re-export them without pulling the full tool graph. `index.ts` re-exports them
4845
4896
  * so the server barrel's public API is unchanged.
4846
4897
  */
4847
- const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, attachedFileIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionReaderConsentDatalakeTags, sessionLakeScopeExplicit, sessionIncludeLibraryFiles, sessionPreauthorizedLakeIds, organizationId, authOrigin, apiKeyId, questId, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
4898
+ const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, attachedFileIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionReaderConsentDatalakeTags, sessionLakeScopeExplicit, sessionIncludeLibraryFiles, sessionPreauthorizedLakeIds, organizationId, authOrigin, apiKeyId, questId, numberCitables, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
4848
4899
  assertAuthOriginMatchesApiKey(authOrigin, apiKeyId);
4849
4900
  const context = {
4850
4901
  userId,
@@ -4880,7 +4931,8 @@ const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inl
4880
4931
  codeMinifier,
4881
4932
  availableModels,
4882
4933
  onToolLlmUsage,
4883
- getAbortSignal
4934
+ getAbortSignal,
4935
+ numberCitables
4884
4936
  };
4885
4937
  return Object.entries(tools).reduce((acc, [key, tool]) => {
4886
4938
  const built = tool.implementation(context, config[key]);
@@ -7868,9 +7920,16 @@ const DEFAULT_PAGE_LIMIT = 100;
7868
7920
  var RemoteSkillSource = class {
7869
7921
  constructor(apiClient, options = {}) {
7870
7922
  this.apiClient = apiClient;
7923
+ this.forceNext = false;
7871
7924
  this.cacheFilePath = options.cacheFilePath ?? path.join(os.homedir(), ".bike4mind", "cache", "remote-skills.json");
7872
7925
  this.freshTtlMs = options.freshTtlMs ?? DEFAULT_FRESH_TTL_MS;
7873
7926
  this.pageLimit = options.pageLimit ?? DEFAULT_PAGE_LIMIT;
7927
+ this.includeImports = options.includeImports ?? false;
7928
+ }
7929
+ /** Make the next `fetchSkills` go to the network even inside the TTL. The cache stays as the
7930
+ * offline fallback. For a host that just changed the account's skills itself. */
7931
+ invalidate() {
7932
+ this.forceNext = true;
7874
7933
  }
7875
7934
  /**
7876
7935
  * Returns the user's remote skills mapped to `CustomCommand`. Uses an
@@ -7879,7 +7938,9 @@ var RemoteSkillSource = class {
7879
7938
  */
7880
7939
  async fetchSkills() {
7881
7940
  const cached = await this.readCache();
7882
- if (cached && this.isFresh(cached)) return this.mapAndFilter(cached.skills);
7941
+ const forced = this.forceNext;
7942
+ this.forceNext = false;
7943
+ if (cached && !forced && this.isFresh(cached)) return this.mapAndFilter(cached.skills);
7883
7944
  try {
7884
7945
  const skills = (await this.apiClient.get(`/api/skills?limit=${this.pageLimit}`))?.data ?? [];
7885
7946
  if (skills.length > 0 || !cached || cached.skills.length === 0) await this.writeCache({
@@ -7901,6 +7962,7 @@ var RemoteSkillSource = class {
7901
7962
  * only visible cross-account via `isGlobalRead`, and
7902
7963
  * - a Skill Market import: the user owns the copy but someone else wrote it, and local commands run
7903
7964
  * as trusted instructions next to shell/file tools (the web app wraps it as untrusted instead).
7965
+ * With `includeImports` it is kept as `untrusted: true`, for a host that wraps it the same way.
7904
7966
  * Own, system (no `userId`), and org-scoped (server-gated, no `userId`) skills pass.
7905
7967
  *
7906
7968
  * Unauthenticated contract (fail-closed, intentional): when getCurrentUser()
@@ -7914,7 +7976,7 @@ var RemoteSkillSource = class {
7914
7976
  return skills.filter((skill) => {
7915
7977
  if (isReservedCommandName(skill.name)) return false;
7916
7978
  if (skill.userId && skill.userId !== currentUserId) return false;
7917
- if (skill.importedFrom) return false;
7979
+ if (skill.importedFrom && !this.includeImports) return false;
7918
7980
  return true;
7919
7981
  }).map((skill) => this.toCustomCommand(skill));
7920
7982
  }
@@ -7965,6 +8027,7 @@ var RemoteSkillSource = class {
7965
8027
  argumentHint: skill.argumentHint,
7966
8028
  allowedTools: skill.allowedTools,
7967
8029
  disableModelInvocation: skill.disableModelInvocation,
8030
+ ...skill.importedFrom ? { untrusted: true } : {},
7968
8031
  source: "remote",
7969
8032
  filePath: `b4m:/api/skills/${skill.id}`
7970
8033
  };
@@ -11427,12 +11490,15 @@ z$1.object({
11427
11490
  //#endregion
11428
11491
  //#region src/tools/gateway/stages/sandbox.ts
11429
11492
  /**
11430
- * Decide whether a bash_execute call runs inside the OS sandbox, and rewrite its args to do so.
11431
- * Any other tool, or a call with no command or no orchestrator, is left unchanged.
11493
+ * Decide whether a call runs inside the OS sandbox, and rewrite its args to do so. Only a tool
11494
+ * whose descriptor declares `sandbox` is wrapped; any other tool, or a call with no command or
11495
+ * no orchestrator, is left unchanged.
11432
11496
  */
11433
- function prepareSandbox(toolName, args, sandboxOrchestrator, allowedDirectories) {
11434
- if (toolName !== "bash_execute" || !args?.command || !sandboxOrchestrator) return { kind: "unchanged" };
11435
- const command = args.command;
11497
+ function prepareSandbox(descriptor, args, sandboxOrchestrator, allowedDirectories) {
11498
+ const commandArg = sandboxedCommandArg(descriptor);
11499
+ if (!commandArg || !args || !sandboxOrchestrator) return { kind: "unchanged" };
11500
+ const command = args[commandArg];
11501
+ if (typeof command !== "string" || !command) return { kind: "unchanged" };
11436
11502
  let cwd = args.cwd ? path.resolve(process.cwd(), args.cwd) : process.cwd();
11437
11503
  try {
11438
11504
  cwd = realpathSync(cwd);
@@ -11451,15 +11517,13 @@ function prepareSandbox(toolName, args, sandboxOrchestrator, allowedDirectories)
11451
11517
  };
11452
11518
  }
11453
11519
  sandboxOrchestrator.recordSandboxed();
11454
- const cleanupPaths = decision.wrappedCommand.cleanupPaths;
11455
11520
  return {
11456
11521
  kind: "sandboxed",
11457
- cleanupPaths,
11522
+ cleanupPaths: decision.wrappedCommand.cleanupPaths,
11458
11523
  args: {
11459
11524
  ...args,
11460
11525
  cwd,
11461
- command: decision.wrappedCommand.commandString,
11462
- _sandboxCleanup: cleanupPaths
11526
+ [commandArg]: decision.wrappedCommand.commandString
11463
11527
  }
11464
11528
  };
11465
11529
  }
@@ -11493,23 +11557,6 @@ function cleanupSandboxFiles(paths) {
11493
11557
  } catch {}
11494
11558
  }
11495
11559
  /**
11496
- * Detect whether a tool result indicates a sandbox-specific runtime failure
11497
- * worth offering an unsandboxed retry for. Matches the sandbox markers
11498
- * (`sandbox-exec:`, `bwrap:`) AND a bare "Operation not permitted": on macOS a
11499
- * real Seatbelt denial is the denied syscall's own EPERM, printed by the tool
11500
- * that hit it with NO `sandbox-exec:` prefix (that prefix only appears when the
11501
- * profile fails to load) - a write denial reads `touch: <path>: Operation not
11502
- * permitted`, and a blocked connect surfaces the same EPERM. Matching only the
11503
- * markers would never fire for those, silently dropping the recovery offer and
11504
- * the `/sandbox:network on` tip. The known false positive (e.g. `kill` on a
11505
- * foreign pid) is accepted: it only costs a deniable retry prompt, whereas a
11506
- * false negative removes the recovery path entirely.
11507
- */
11508
- function isSandboxFailure(isSandboxed, result) {
11509
- if (!isSandboxed) return false;
11510
- return result.includes("sandbox-exec:") || result.includes("bwrap:") || result.includes("Operation not permitted");
11511
- }
11512
- /**
11513
11560
  * Parse sandbox runtime stderr for violations and record them.
11514
11561
  * Fire-and-forget - errors are silently swallowed.
11515
11562
  */
@@ -13045,25 +13092,119 @@ function getProcessHooks() {
13045
13092
  return cached;
13046
13093
  }
13047
13094
  //#endregion
13095
+ //#region src/tools/gateway/stages/sandboxEscalation.ts
13096
+ const SNIPPET_LENGTH = 200;
13097
+ const EXIT_CODE_LINE = /^Exit code:\s*(-?\d+)\s*$/gm;
13098
+ const LAUNCHER_MARKER = /^(?:sandbox-exec|bwrap):/m;
13099
+ const EPERM_TEXT = "Operation not permitted";
13100
+ const NETWORK_TIP = "If this was a network call, prefer '/sandbox:network on' to allow filtered egress.";
13101
+ /**
13102
+ * Detect a sandbox denial in a tool result. Both evidence kinds - a launcher marker
13103
+ * (`sandbox-exec:`, `bwrap:` at the start of a line) and the EPERM text a Seatbelt denial
13104
+ * surfaces on macOS - require a non-zero exit: a command that printed either and still exited
13105
+ * 0 (e.g. a tolerated `kill`, or output that merely quotes the launcher) was not stopped.
13106
+ */
13107
+ function detectSandboxDenial(sandboxed, result) {
13108
+ if (!sandboxed || !exitedNonZero(result)) return null;
13109
+ const marker = LAUNCHER_MARKER.exec(result);
13110
+ if (marker) return {
13111
+ reason: "launcher",
13112
+ snippet: snippetAt(result, marker.index)
13113
+ };
13114
+ const epermIndex = result.indexOf(EPERM_TEXT);
13115
+ if (epermIndex === -1) return null;
13116
+ return {
13117
+ reason: "eperm",
13118
+ snippet: snippetAt(result, epermIndex)
13119
+ };
13120
+ }
13121
+ /**
13122
+ * The tool's exit code is non-zero. The last "Exit code:" line is the tool's own (the command
13123
+ * may have printed earlier ones); a command that ends its own output with such a line can still
13124
+ * be mistaken for a failure, which costs an extra question, never a silent unsandboxed run.
13125
+ */
13126
+ function exitedNonZero(result) {
13127
+ const exitCodes = [...result.matchAll(EXIT_CODE_LINE)];
13128
+ const exitCode = exitCodes[exitCodes.length - 1]?.[1];
13129
+ return exitCode !== void 0 && Number(exitCode) !== 0;
13130
+ }
13131
+ /** The error text from the start of the line holding `index`, so long stdout never hides it. */
13132
+ function snippetAt(result, index) {
13133
+ const lineStart = result.lastIndexOf("\n", index) + 1;
13134
+ return result.slice(lineStart, lineStart + SNIPPET_LENGTH);
13135
+ }
13136
+ /** A bash session (background, or promoted after yield_time_ms) may still be running. */
13137
+ function isSessionRun(args) {
13138
+ return args.run_in_background === true || typeof args.yield_time_ms === "number";
13139
+ }
13140
+ /**
13141
+ * The sandbox stage of execution: run the attempt, record violations, and if the OS sandbox
13142
+ * denied it, ask before retrying outside the sandbox. The question is its own prompt: auto-accept,
13143
+ * session trust, trusted tools and the host allowlist never approve it, and when nobody can
13144
+ * answer, the model gets the sandbox error back. Session runs (background or yield_time_ms) are
13145
+ * never escalated: the sandboxed process may still be running.
13146
+ */
13147
+ async function runSandboxed(input) {
13148
+ const { descriptor, rawArgs, execArgs, isSandboxed, attempt, permissionManager, sandboxOrchestrator } = input;
13149
+ const result = await attempt(execArgs);
13150
+ await captureViolations(isSandboxed, result, commandTextOf(descriptor, rawArgs), sandboxOrchestrator);
13151
+ const commandArg = sandboxedCommandArg(descriptor);
13152
+ const command = commandTextOf(descriptor, rawArgs);
13153
+ if (!commandArg || command === void 0 || isSessionRun(execArgs)) return result;
13154
+ const denial = detectSandboxDenial(isSandboxed, result);
13155
+ if (!denial) return result;
13156
+ if (sandboxOrchestrator?.getConfig().allowUnsandboxedCommands === false) return `${result}\n\nThe command was denied by the OS sandbox and running it outside the sandbox is disabled. ${NETWORK_TIP}`;
13157
+ const cwd = typeof execArgs.cwd === "string" ? execArgs.cwd : process.cwd();
13158
+ const isHighRisk = assessCommandRisk(descriptor, rawArgs)?.level === "high";
13159
+ const approvalKey = JSON.stringify([cwd, command]);
13160
+ if (isHighRisk || !permissionManager.isSandboxEscalationApproved(approvalKey)) {
13161
+ const response = await input.showPermissionPrompt(input.toolName, rawArgs, escalationPreview(denial, isHighRisk), "sandbox-escalation");
13162
+ if (response.action === "deny") return `${result}\n\n${NETWORK_TIP}`;
13163
+ if (response.action === "allow-session" && !isHighRisk) permissionManager.approveSandboxEscalation(approvalKey);
13164
+ }
13165
+ sandboxOrchestrator?.recordUnsandboxed();
13166
+ return attempt({
13167
+ ...execArgs,
13168
+ [commandArg]: command,
13169
+ cwd
13170
+ });
13171
+ }
13172
+ function escalationPreview(denial, isHighRisk) {
13173
+ return `🛑 SANDBOX BLOCKED - This command was denied by the OS sandbox.
13174
+
13175
+ - The sandbox prevented this operation because it violates filesystem or network restrictions.
13176
+ - ${NETWORK_TIP}\n- You can retry without the sandbox, but the command will run with full system access.\n` + (isHighRisk ? `- This command is high risk, so a session approval is not remembered: you will be asked again.\n` : "") + `\n@@Error Details@@\n${denial.snippet}`;
13177
+ }
13178
+ //#endregion
13048
13179
  //#region src/tools/gateway/stages/execution.ts
13049
13180
  /**
13050
- * Run an approved call: checkpoint, execute (ToolRouter redacts and caps the output), capture
13051
- * sandbox violations, offer retry on a sandbox failure or a path denial, then fire the
13052
- * post-tool signals.
13181
+ * Run an approved call: checkpoint, execute through the sandbox stage (ToolRouter redacts and
13182
+ * caps the output; a sandbox denial asks before retrying unsandboxed), offer retry on a path
13183
+ * denial, then fire the post-tool signals.
13053
13184
  */
13054
13185
  async function executeAndRecord(input) {
13055
13186
  const { toolName, descriptor, rawArgs, execArgs, isSandboxed, originalFn, apiClient, showPermissionPrompt } = input;
13056
13187
  await checkpointBeforeWrite(input.checkpointStore, toolName, writePathOf(descriptor, rawArgs));
13057
- let result;
13058
- try {
13059
- result = await executeWithFuzzyConfirmation(toolName, execArgs, apiClient, originalFn, showPermissionPrompt);
13060
- } catch (err) {
13061
- const msg = err instanceof Error ? err.message : String(err);
13062
- if (!isPathAccessDenial(msg)) throw err;
13063
- result = msg;
13064
- }
13065
- await captureViolations(isSandboxed, result, rawArgs?.command, input.sandboxOrchestrator);
13066
- result = await retrySandboxFailure(isSandboxed, result, toolName, rawArgs, apiClient, originalFn, showPermissionPrompt);
13188
+ const attempt = async (args) => {
13189
+ try {
13190
+ return await executeWithFuzzyConfirmation(toolName, args, apiClient, originalFn, showPermissionPrompt);
13191
+ } catch (err) {
13192
+ const msg = err instanceof Error ? err.message : String(err);
13193
+ if (!isPathAccessDenial(msg)) throw err;
13194
+ return msg;
13195
+ }
13196
+ };
13197
+ let result = await runSandboxed({
13198
+ toolName,
13199
+ descriptor,
13200
+ rawArgs,
13201
+ execArgs,
13202
+ isSandboxed,
13203
+ attempt,
13204
+ permissionManager: input.permissionManager,
13205
+ showPermissionPrompt,
13206
+ sandboxOrchestrator: input.sandboxOrchestrator
13207
+ });
13067
13208
  result = await retryPathAccessDenial(result, toolName, execArgs, input.allowedDirectories, input.configStore, apiClient, originalFn, showPermissionPrompt);
13068
13209
  notifyFileChanged(input.agentContext, writePathOf(descriptor, execArgs), result);
13069
13210
  getProcessHooks()?.firePostToolUse(toolName);
@@ -13087,21 +13228,6 @@ function notifyFileChanged(agentContext, writePath, result) {
13087
13228
  agentContext.onFileChanged(writePath);
13088
13229
  }
13089
13230
  /**
13090
- * If the result looks like a sandbox runtime failure, prompt the user to retry
13091
- * the command unsandboxed. Returns the retry result or the original result.
13092
- */
13093
- async function retrySandboxFailure(isSandboxed, result, toolName, originalArgs, apiClient, originalFn, showPermissionPrompt) {
13094
- if (!isSandboxFailure(isSandboxed, result)) return result;
13095
- if ((await showPermissionPrompt(toolName, originalArgs, `🛑 SANDBOX BLOCKED - This command was denied by the OS sandbox.
13096
-
13097
- - The sandbox prevented this operation because it violates filesystem or network restrictions.
13098
- - If this was a network call, prefer '/sandbox:network on' to allow filtered egress instead of full access.
13099
- - You can retry without the sandbox, but the command will run with full system access.
13100
-
13101
- @@Error Details@@\n${result.slice(0, 200)}`)).action !== "deny") return executeTool(toolName, originalArgs, apiClient, originalFn);
13102
- return result;
13103
- }
13104
- /**
13105
13231
  * Matches the three shapes of filesystem allow-list denial emitted by core:
13106
13232
  * - file tools: `Access denied: Cannot <op> files outside allowed directories.`
13107
13233
  * - glob_files: `Access denied: Cannot search outside allowed directories.` (no "files")
@@ -13194,7 +13320,7 @@ function wrapToolWithPermission(tool, deps) {
13194
13320
  toolFn: async (input) => {
13195
13321
  const args = input;
13196
13322
  const descriptor = permissionManager.catalog.resolve(toolName);
13197
- const sandbox = prepareSandbox(toolName, args, deps.sandboxOrchestrator, allowedDirectories);
13323
+ const sandbox = prepareSandbox(descriptor, args, deps.sandboxOrchestrator, allowedDirectories);
13198
13324
  if (sandbox.kind === "blocked") return sandbox.message;
13199
13325
  const isSandboxed = sandbox.kind === "sandboxed";
13200
13326
  const effectiveArgs = isSandboxed ? sandbox.args : args;
@@ -13209,6 +13335,7 @@ function wrapToolWithPermission(tool, deps) {
13209
13335
  originalFn,
13210
13336
  apiClient: deps.apiClient,
13211
13337
  showPermissionPrompt,
13338
+ permissionManager,
13212
13339
  agentContext: deps.agentContext,
13213
13340
  configStore: deps.configStore,
13214
13341
  sandboxOrchestrator: deps.sandboxOrchestrator,
@@ -14195,6 +14322,7 @@ var PermissionManager = class {
14195
14322
  this.deniedTools = /* @__PURE__ */ new Set();
14196
14323
  this._sandboxMode = "disabled";
14197
14324
  this._sandboxActive = false;
14325
+ this.sandboxEscalationApprovals = /* @__PURE__ */ new Set();
14198
14326
  this.trustedTools = new Set(trustedTools);
14199
14327
  this.deniedTools = new Set(deniedTools);
14200
14328
  }
@@ -14220,14 +14348,14 @@ var PermissionManager = class {
14220
14348
  *
14221
14349
  * @param toolName - The tool being checked
14222
14350
  * @param options.isSandboxed - If true, the command will run inside the OS-level sandbox.
14223
- * In auto-allow mode, sandboxed bash_execute commands skip the permission prompt
14351
+ * In auto-allow mode, sandboxed commands (a core tool whose descriptor declares `sandbox`) skip the permission prompt
14224
14352
  * because the sandbox provides the security boundary.
14225
14353
  */
14226
14354
  needsPermission(toolName, options) {
14227
14355
  const category = this.catalog.category(toolName);
14228
14356
  if (this.deniedTools.has(toolName)) return true;
14229
14357
  if (category === "auto_approve") return false;
14230
- if (options?.isSandboxed && toolName === "bash_execute" && this.isSandboxAutoAllow()) return false;
14358
+ if (options?.isSandboxed && sandboxedCommandArg(this.catalog.resolve(toolName)) !== void 0 && this.isSandboxAutoAllow()) return false;
14231
14359
  if (this.sessionTrustedTools.has(toolName)) return false;
14232
14360
  if (category === "prompt_always") return true;
14233
14361
  return !this.trustedTools.has(toolName);
@@ -14300,11 +14428,19 @@ var PermissionManager = class {
14300
14428
  isSessionTrusted(toolName) {
14301
14429
  return this.sessionTrustedTools.has(toolName);
14302
14430
  }
14431
+ /** Remember a session-scoped approval to run one command outside the sandbox. Never persisted. */
14432
+ approveSandboxEscalation(key) {
14433
+ this.sandboxEscalationApprovals.add(key);
14434
+ }
14435
+ isSandboxEscalationApproved(key) {
14436
+ return this.sandboxEscalationApprovals.has(key);
14437
+ }
14303
14438
  /**
14304
14439
  * Clear all session-scoped trust (called on exit or session reset)
14305
14440
  */
14306
14441
  clearSessionTrust() {
14307
14442
  this.sessionTrustedTools.clear();
14443
+ this.sandboxEscalationApprovals.clear();
14308
14444
  }
14309
14445
  /**
14310
14446
  * Clear all trusted tools