@nathapp/nax-agent 0.91.6 → 0.91.7-canary.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/coding-tools/coding-tool-extras.d.ts +42 -0
  3. package/dist/coding-tools/coding-tool-extras.js +31 -0
  4. package/dist/coding-tools/coding-tool-support.d.ts +6 -0
  5. package/dist/coding-tools/coding-tool-support.js +7 -1
  6. package/dist/config/catalog-overrides.d.ts +5 -0
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +2 -1
  9. package/dist/internal/argv-exec.d.ts +2 -0
  10. package/dist/internal/argv-exec.js +72 -7
  11. package/dist/mcp/connect.js +26 -3
  12. package/dist/mcp/types.d.ts +7 -0
  13. package/dist/native/models.js +1 -0
  14. package/dist/native/session/loop-events/types.d.ts +5 -0
  15. package/dist/native/session-adapter.js +16 -5
  16. package/dist/permissions/approval-memory.d.ts +18 -0
  17. package/dist/permissions/approval-memory.js +88 -0
  18. package/dist/permissions/index.d.ts +1 -0
  19. package/dist/permissions/index.js +1 -0
  20. package/dist/permissions/types.d.ts +6 -0
  21. package/dist/sandbox/claude-settings.d.ts +2 -1
  22. package/dist/sandbox/claude-settings.js +7 -3
  23. package/dist/sandbox/defaults.js +1 -0
  24. package/dist/sandbox/launcher.js +3 -0
  25. package/dist/sandbox/types.d.ts +2 -0
  26. package/dist/session/agent-session-command.d.ts +19 -0
  27. package/dist/session/agent-session-command.js +38 -0
  28. package/dist/session/agent-session-types.d.ts +9 -0
  29. package/dist/session/agent-session.js +49 -1
  30. package/dist/session/native-backend-options.d.ts +18 -2
  31. package/dist/session/native-backend-options.js +26 -3
  32. package/dist/session/native-backend.js +42 -4
  33. package/dist/session/session-ask-link.d.ts +6 -0
  34. package/dist/session/session-ask-link.js +5 -0
  35. package/dist/session/session-ask-port.js +4 -0
  36. package/dist/session/session-backend.d.ts +21 -1
  37. package/dist/session/session-tool-support.d.ts +39 -3
  38. package/dist/session/session-tool-support.js +54 -10
  39. package/dist/session-store/index.d.ts +3 -0
  40. package/dist/session-store/index.js +3 -0
  41. package/dist/session-store/session-lock-guard.d.ts +6 -0
  42. package/dist/session-store/session-lock-guard.js +85 -0
  43. package/dist/session-store/session-lock.d.ts +39 -0
  44. package/dist/session-store/session-lock.js +175 -0
  45. package/dist/session-store/session-meta.d.ts +27 -0
  46. package/dist/session-store/session-meta.js +20 -0
  47. package/dist/session-store/storage.d.ts +39 -0
  48. package/dist/session-store/storage.js +164 -0
  49. package/dist/tools/ask-request.d.ts +18 -0
  50. package/dist/tools/ask-request.js +30 -0
  51. package/dist/tools/bash.d.ts +9 -0
  52. package/dist/tools/bash.js +61 -16
  53. package/dist/tools/git.js +10 -3
  54. package/dist/tools/policy-match.d.ts +2 -2
  55. package/dist/tools/policy-match.js +9 -6
  56. package/dist/tools/policy-paths-branch.js +4 -1
  57. package/dist/tools/provider-advertise.d.ts +7 -0
  58. package/dist/tools/provider-advertise.js +13 -1
  59. package/dist/tools/provider-types.d.ts +6 -0
  60. package/dist/tools/run-command-exec.js +12 -0
  61. package/dist/tools/run-command-surface.d.ts +28 -0
  62. package/dist/tools/run-command-surface.js +109 -0
  63. package/dist/tools/run-command.d.ts +17 -16
  64. package/dist/tools/run-command.js +48 -68
  65. package/dist/tools/runtime-calltool.d.ts +100 -0
  66. package/dist/tools/runtime-calltool.js +112 -3
  67. package/dist/tools/runtime.d.ts +11 -0
  68. package/dist/tools/runtime.js +36 -18
  69. package/dist/tools/types.d.ts +8 -0
  70. package/package.json +3 -3
@@ -25,6 +25,24 @@ export interface AskSummary {
25
25
  * what approval needs; the payload is what the audit sink already holds.
26
26
  */
27
27
  export declare function askSummary(tool: string, scope: ToolScope, input: Record<string, unknown>): AskSummary;
28
+ /**
29
+ * The reason clause an unsandboxed approval carries (US-004).
30
+ *
31
+ * The reason is model-authored and travels to a human, so it is masked through
32
+ * `maskForPrompt` and cut to `MAX_ASK_SUMMARY_CHARS` — in that order, for the
33
+ * same reason `askSummary` masks before it cuts: cutting first can split a
34
+ * secret so that no pattern matches what is left. A reason whose masked form
35
+ * would hide shell syntax is WITHHELD rather than denying the ask (the command
36
+ * itself is already vetted by `askSummary`); the person still learns the call
37
+ * wants to run outside the sandbox, which is the fact the suffix exists for.
38
+ *
39
+ * A blank or whitespace-only reason takes the SAME fallback as an absent one:
40
+ * the model wrote nothing that could explain anything, and the approver must
41
+ * never be shown the dangling clause the prefix alone renders.
42
+ */
43
+ export declare function unsandboxedReasonSuffix(input: {
44
+ readonly unsandboxedReason?: unknown;
45
+ }): string;
28
46
  /**
29
47
  * The reason a `denied:ask` ledger row carries, chosen by WHO refused.
30
48
  *
@@ -37,6 +37,36 @@ export function askSummary(tool, scope, input) {
37
37
  return { summary: `${tool} [arguments withheld: contains a secret]`, unshowable: true };
38
38
  return { summary: masked.masked.slice(0, MAX_ASK_SUMMARY_CHARS), unshowable: false };
39
39
  }
40
+ /** The clause `unsandboxedReasonSuffix` renders, before the reason itself. */
41
+ const UNSANDBOXED_PREFIX = " -- runs OUTSIDE the sandbox: ";
42
+ /** What the suffix says when the model gave no usable reason. */
43
+ const NO_REASON = "no reason given";
44
+ /** What the suffix says instead of a reason that cannot be shown safely. */
45
+ const REASON_WITHHELD = "reason withheld";
46
+ /**
47
+ * The reason clause an unsandboxed approval carries (US-004).
48
+ *
49
+ * The reason is model-authored and travels to a human, so it is masked through
50
+ * `maskForPrompt` and cut to `MAX_ASK_SUMMARY_CHARS` — in that order, for the
51
+ * same reason `askSummary` masks before it cuts: cutting first can split a
52
+ * secret so that no pattern matches what is left. A reason whose masked form
53
+ * would hide shell syntax is WITHHELD rather than denying the ask (the command
54
+ * itself is already vetted by `askSummary`); the person still learns the call
55
+ * wants to run outside the sandbox, which is the fact the suffix exists for.
56
+ *
57
+ * A blank or whitespace-only reason takes the SAME fallback as an absent one:
58
+ * the model wrote nothing that could explain anything, and the approver must
59
+ * never be shown the dangling clause the prefix alone renders.
60
+ */
61
+ export function unsandboxedReasonSuffix(input) {
62
+ const raw = input.unsandboxedReason;
63
+ if (typeof raw !== "string" || raw.trim() === "")
64
+ return `${UNSANDBOXED_PREFIX}${NO_REASON}`;
65
+ const masked = maskForPrompt(raw);
66
+ if (!masked.ok)
67
+ return `${UNSANDBOXED_PREFIX}${REASON_WITHHELD}`;
68
+ return `${UNSANDBOXED_PREFIX}${masked.masked.slice(0, MAX_ASK_SUMMARY_CHARS)}`;
69
+ }
40
70
  /**
41
71
  * The reason a `denied:ask` ledger row carries, chosen by WHO refused.
42
72
  *
@@ -61,6 +61,15 @@ export interface BashToolOptions {
61
61
  * description, so the agent always knows whether it is sandboxed.
62
62
  */
63
63
  readonly launcher?: CommandLauncher;
64
+ /**
65
+ * US-003: whether this session offers the per-call `unsandboxed` argument
66
+ * (`NativeBackendOptions.allowUnsandboxedCommands`, threaded down through
67
+ * `buildSessionToolSupport`). It shapes the ADVERTISEMENT only -- the schema
68
+ * and one description sentence. `run` never reads it: the tool only honours
69
+ * the flag on the call, so a session that did not advertise the argument
70
+ * cannot be steered into running unwrapped by a stray input field.
71
+ */
72
+ readonly unsandboxedCommands?: boolean;
64
73
  }
65
74
  /**
66
75
  * Injectable spawn seam, mirroring `_argvExecDeps`, plus the platform seam
@@ -179,7 +179,7 @@ function bashToolDescription(shell, opts) {
179
179
  * therefore never the string the policy judged, which is exactly why
180
180
  * `interceptShell` re-validates the provider's answer rather than trusting it.
181
181
  */
182
- async function launchIntercepted({ command, shell, opts, ctx, timeoutMs, }) {
182
+ async function launchIntercepted({ command, shell, opts, ctx, timeoutMs, unwrapped, }) {
183
183
  // The run's interceptor arrives on the context (S1 spec port 7), immutable
184
184
  // for this call, so the launch below and `postProcess` see the same object.
185
185
  const interceptor = ctx.interceptor;
@@ -205,6 +205,9 @@ async function launchIntercepted({ command, shell, opts, ctx, timeoutMs, }) {
205
205
  spec: { kind: "shell", shell, command: intercepted.command },
206
206
  root: ctx.root,
207
207
  ...common,
208
+ // US-003: the key is present only for an exact `true` on the call, so
209
+ // every other request stays byte-identical to what US-002 shipped.
210
+ ...(unwrapped ? { unwrapped: true } : {}),
208
211
  })
209
212
  : { ...(await _bashToolDeps.runArgv({ argv: executed, ...common })), executed, sandbox: undefined };
210
213
  if (!intercepted.rewritten)
@@ -223,6 +226,51 @@ async function launchIntercepted({ command, shell, opts, ctx, timeoutMs, }) {
223
226
  return { launched, stdout: launched.stdout };
224
227
  }
225
228
  }
229
+ /**
230
+ * US-003: the one sentence an opted-in session appends to its description, so
231
+ * the model knows the argument exists and what it is for. Absent from every
232
+ * description when the option is off, which keeps those byte-identical.
233
+ */
234
+ const UNSANDBOXED_ARGUMENT_SENTENCE = 'Set "unsandboxed": true with an "unsandboxedReason" only when the command needs the network, a local socket or a device the sandbox blocks.';
235
+ /**
236
+ * US-003: the description with the opt-in sentence appended.
237
+ *
238
+ * A separate function rather than a final ternary inside `bashToolDescription`
239
+ * so every existing variant keeps its own wording verbatim, and the separator
240
+ * is chosen here: most variants already end on a space, the sandbox-sentence
241
+ * ones do not, and a description must never end on two spaces or none.
242
+ */
243
+ function withUnsandboxedSentence(description, opts) {
244
+ if (opts.unsandboxedCommands !== true)
245
+ return description;
246
+ const separator = description.endsWith(" ") ? "" : " ";
247
+ return `${description}${separator}${UNSANDBOXED_ARGUMENT_SENTENCE}`;
248
+ }
249
+ /**
250
+ * US-003: the input schema, plus the two argument fields when the session opted
251
+ * in. Built here rather than inline in `createBashTool` so the schema's shape
252
+ * has one owner, and the off-form is exactly the three properties it always was.
253
+ *
254
+ * Neither added field is `required`: the escape is per call, and a call without
255
+ * it runs exactly as it does today.
256
+ */
257
+ function bashToolInputSchema(opts) {
258
+ return {
259
+ type: "object",
260
+ properties: {
261
+ command: { type: "string", description: 'The shell command to run, e.g. "bun test src/a.test.ts"' },
262
+ timeoutMs: {
263
+ type: "number",
264
+ description: `Deadline in milliseconds (default and maximum ${BASH_TIMEOUT_MS}).`,
265
+ },
266
+ description: { type: "string", description: "One short line on what this command is for." },
267
+ ...(opts.unsandboxedCommands === true
268
+ ? { unsandboxed: { type: "boolean" }, unsandboxedReason: { type: "string" } }
269
+ : {}),
270
+ },
271
+ required: ["command"],
272
+ };
273
+ }
226
274
  export function createBashTool(opts = {}) {
227
275
  const shell = opts.shell ?? DEFAULT_BASH_SHELL;
228
276
  return {
@@ -231,23 +279,16 @@ export function createBashTool(opts = {}) {
231
279
  // not a fault worth an operator's attention — the same reason RunCommand
232
280
  // sets this.
233
281
  routineErrors: true,
234
- description: bashToolDescription(shell, opts),
235
- inputSchema: {
236
- type: "object",
237
- properties: {
238
- command: { type: "string", description: 'The shell command to run, e.g. "bun test src/a.test.ts"' },
239
- timeoutMs: {
240
- type: "number",
241
- description: `Deadline in milliseconds (default and maximum ${BASH_TIMEOUT_MS}).`,
242
- },
243
- description: { type: "string", description: "One short line on what this command is for." },
244
- },
245
- required: ["command"],
246
- },
282
+ description: withUnsandboxedSentence(bashToolDescription(shell, opts), opts),
283
+ inputSchema: bashToolInputSchema(opts),
247
284
  // `commandField` is what routes this call to the Bash branch of the policy
248
285
  // (src/tools/policy-bash.ts). No pathFields: the paths are inside the
249
286
  // command string, where only that branch can see them.
250
- scope: { pathFields: [], commandField: "command" },
287
+ //
288
+ // US-003: `unsandboxedField` is declared unconditionally, so the runtime can
289
+ // see the field whether or not this session advertises it -- the option
290
+ // shapes the schema, never the scope.
291
+ scope: { pathFields: [], commandField: "command", unsandboxedField: "unsandboxed" },
251
292
  async run(input, ctx) {
252
293
  const command = input.command;
253
294
  if (typeof command !== "string" || command.trim() === "") {
@@ -256,7 +297,11 @@ export function createBashTool(opts = {}) {
256
297
  const requested = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : BASH_TIMEOUT_MS;
257
298
  const timeoutMs = Math.min(Math.max(Math.trunc(requested), MIN_BASH_TIMEOUT_MS), BASH_TIMEOUT_MS);
258
299
  try {
259
- const { launched, stdout } = await launchIntercepted({ command, shell, opts, ctx, timeoutMs });
300
+ // US-003: exact `true` only. The tool never consults `opts.unsandboxedCommands`
301
+ // here: whether the argument is ADVERTISED is the option's business, and what a
302
+ // given call asked for is the call's.
303
+ const unwrapped = input.unsandboxed === true;
304
+ const { launched, stdout } = await launchIntercepted({ command, shell, opts, ctx, timeoutMs, unwrapped });
260
305
  // Same framing ladder as RunCommand (Exec) via the shared
261
306
  // `formatExecBody`: an aborted call opens with the cancellation line
262
307
  // and surfaces whatever partial output the readers captured before the
package/dist/tools/git.js CHANGED
@@ -171,8 +171,15 @@ function buildGitRootArgv(input) {
171
171
  }
172
172
  return ["rev-parse", "--show-toplevel"];
173
173
  }
174
+ /**
175
+ * `blame` rejects exclude pathspecs (exit 128). On `log` and `show` an exclude
176
+ * filters COMMITS, not just files: a commit that only touches `.nax/` is
177
+ * dropped, so `log HEAD -1` reported an older commit as HEAD and `show HEAD`
178
+ * printed nothing (#2693). Only `diff` and `status` list files.
179
+ */
180
+ const GIT_EXCLUDE_EXEMPT_VERBS = ["blame", "log", "show"];
174
181
  function defaultGitExcludes(subcommand, excludes) {
175
- return subcommand === "blame" ? [] : excludes;
182
+ return GIT_EXCLUDE_EXEMPT_VERBS.includes(subcommand) ? [] : excludes;
176
183
  }
177
184
  /**
178
185
  * `defaultExcludes`: pathspecs appended to an unscoped call (the host's own
@@ -292,8 +299,8 @@ export function buildGitArgv(input, defaultExcludes = []) {
292
299
  // on this DEFAULT branch: a caller that names a path under .nax/ gets it --
293
300
  // the tool is read-only, and silently returning nothing for an explicitly
294
301
  // requested path would be a worse failure than the one being fixed.
295
- // `blame` is exempt because git rejects exclude pathspecs on it and exits
296
- // 128; do not "unify" it back in.
302
+ // `blame`, `log` and `show` are exempt (GIT_EXCLUDE_EXEMPT_VERBS); do not
303
+ // "unify" them back in.
297
304
  argv.push(...defaultGitExcludes(subcommand, defaultExcludes));
298
305
  return argv;
299
306
  }
@@ -20,7 +20,7 @@ import type { ToolGrant } from "./types.ts";
20
20
  * -- but it made a *scoped* grant wider than its author wrote, which is the one
21
21
  * thing a scoped profile exists to prevent.
22
22
  */
23
- export declare function globToRegExp(pattern: string): RegExp;
23
+ export declare function globToRegExp(pattern: string, ignoreCase?: boolean): RegExp;
24
24
  /** A compiled glob, keeping its source so verb names can be told from paths. */
25
25
  export interface CompiledPattern {
26
26
  readonly source: string;
@@ -55,7 +55,7 @@ export declare function matchedGlobSource(patterns: readonly CompiledPattern[],
55
55
  * is exactly bun, argv[1] starts with add", never "the space-joined argv
56
56
  * matches this glob".
57
57
  */
58
- export declare function compileArgvPattern(pattern: string): readonly CompiledPattern[];
58
+ export declare function compileArgvPattern(pattern: string, ignoreCase?: boolean): readonly CompiledPattern[];
59
59
  /** A grant pattern's tokens are a PREFIX of argv — trailing argv tokens
60
60
  * (the package name, flags, ...) are the payload a prefix grant like
61
61
  * `bun add*` does not itself constrain. */
@@ -19,7 +19,7 @@
19
19
  * -- but it made a *scoped* grant wider than its author wrote, which is the one
20
20
  * thing a scoped profile exists to prevent.
21
21
  */
22
- export function globToRegExp(pattern) {
22
+ export function globToRegExp(pattern, ignoreCase = false) {
23
23
  let out = "";
24
24
  for (let i = 0; i < pattern.length; i += 1) {
25
25
  const char = pattern[i];
@@ -46,7 +46,7 @@ export function globToRegExp(pattern) {
46
46
  }
47
47
  out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
48
48
  }
49
- return new RegExp(`^${out}$`);
49
+ return new RegExp(`^${out}$`, ignoreCase ? "i" : "");
50
50
  }
51
51
  export function matchesAny(patterns, value) {
52
52
  return patterns.some((p) => p.re.test(value));
@@ -64,11 +64,11 @@ export function matchedGlobSource(patterns, value) {
64
64
  * is exactly bun, argv[1] starts with add", never "the space-joined argv
65
65
  * matches this glob".
66
66
  */
67
- export function compileArgvPattern(pattern) {
67
+ export function compileArgvPattern(pattern, ignoreCase = false) {
68
68
  return pattern
69
69
  .split(/\s+/)
70
70
  .filter((token) => token.length > 0)
71
- .map((token) => ({ source: token, re: globToRegExp(token) }));
71
+ .map((token) => ({ source: token, re: globToRegExp(token, ignoreCase) }));
72
72
  }
73
73
  /** A grant pattern's tokens are a PREFIX of argv — trailing argv tokens
74
74
  * (the package name, flags, ...) are the payload a prefix grant like
@@ -96,8 +96,11 @@ export function compileRuleMap(rules) {
96
96
  const nonWildcard = patterns.filter((p) => p !== "*");
97
97
  map.set(rule.tool, {
98
98
  unconditional: patterns.includes("*"),
99
- matchers: nonWildcard.map((source) => ({ source, re: globToRegExp(source) })),
100
- argvPatterns: nonWildcard.map((source) => compileArgvPattern(source)),
99
+ // Case-insensitive, unlike grants: on a case-insensitive filesystem `.ENV` and `.env` are one
100
+ // file, and a deny/ask rule must not be walked past by respelling it (nax#1972, as denyPaths).
101
+ // Over-matching only asks or refuses more, the direction these lists should fail in.
102
+ matchers: nonWildcard.map((source) => ({ source, re: globToRegExp(source, true) })),
103
+ argvPatterns: nonWildcard.map((source) => compileArgvPattern(source, true)),
101
104
  raw: patterns,
102
105
  rulePatterns: [...(existing?.rulePatterns ?? []), rule.patterns],
103
106
  });
@@ -46,7 +46,10 @@ function entersGitMetadata(root, resolved) {
46
46
  const rel = relative(realOrRaw(root), resolved);
47
47
  if (rel === "" || rel.startsWith(".."))
48
48
  return false;
49
- return rel.split(sep).includes(".git");
49
+ // Case-folded: on a case-insensitive filesystem (macOS, Windows defaults) `.GIT/config` IS
50
+ // `.git/config`, and the canonical path keeps the caller's spelling. Over-refusing a genuinely
51
+ // distinct `.GIT` on a case-sensitive filesystem is the direction this ban should fail in.
52
+ return rel.split(sep).some((segment) => segment.toLowerCase() === ".git");
50
53
  }
51
54
  /**
52
55
  * Absolute, symlink-resolved form of `candidate` if it lies inside `root`,
@@ -53,6 +53,13 @@ export interface ResolvedProviderTools {
53
53
  providerId: string;
54
54
  text: string;
55
55
  }[];
56
+ /**
57
+ * Advertised (namespaced) names of tools from a `discovered` provider whose
58
+ * `readOnly` is not `true`, in advertise order: the ones an ask-profile
59
+ * session puts to the person before running. A `static` provider's tools
60
+ * never appear (their schemas are authored in this repo). ABSENT when empty.
61
+ */
62
+ readonly askTools?: readonly string[];
56
63
  }
57
64
  export declare function resolveProviderTools(providers: readonly ToolProvider[], stage: string, workdir: string, options?: {
58
65
  /**
@@ -35,6 +35,14 @@ async function instructionsFor(provider, workdir) {
35
35
  return undefined;
36
36
  }
37
37
  }
38
+ /** `{ [key]: list }` for a non-empty list, `{}` otherwise: the key is absent, never `[]`. */
39
+ function whenNonEmpty(key, list) {
40
+ return list.length > 0 ? { [key]: list } : {};
41
+ }
42
+ /** A discovered tool its server did not mark read-only; a static provider's tools never ask. */
43
+ function asksBeforeRunning(provider, tool) {
44
+ return provider.kind === "discovered" && tool.readOnly !== true;
45
+ }
38
46
  export async function resolveProviderTools(providers, stage, workdir, options) {
39
47
  const tools = [];
40
48
  const entries = [];
@@ -43,6 +51,7 @@ export async function resolveProviderTools(providers, stage, workdir, options) {
43
51
  const withheld = [];
44
52
  const providerIdByTool = new Map();
45
53
  const instructions = [];
54
+ const askTools = [];
46
55
  for (const provider of providers) {
47
56
  if (!providerAttachesTo(provider, stage)) {
48
57
  skipped.push({ providerId: provider.id, reason: "stage-mismatch", stages: provider.stages });
@@ -60,6 +69,8 @@ export async function resolveProviderTools(providers, stage, workdir, options) {
60
69
  const adapted = adaptProviderTool(provider.id, tool);
61
70
  tools.push(adapted);
62
71
  providerIdByTool.set(adapted.name, provider.id);
72
+ if (asksBeforeRunning(provider, tool))
73
+ askTools.push(adapted.name);
63
74
  localNames.push(tool.localName);
64
75
  }
65
76
  entries.push({ providerId: provider.id, localNames });
@@ -84,7 +95,8 @@ export async function resolveProviderTools(providers, stage, workdir, options) {
84
95
  withheld,
85
96
  providerIdByTool,
86
97
  entries,
87
- ...(instructions.length > 0 ? { instructions } : {}),
98
+ ...whenNonEmpty("instructions", instructions),
99
+ ...whenNonEmpty("askTools", askTools),
88
100
  };
89
101
  }
90
102
  /**
@@ -18,6 +18,12 @@ export interface ProviderTool {
18
18
  readonly localName: string;
19
19
  readonly description: string;
20
20
  readonly inputSchema: JSONSchema;
21
+ /**
22
+ * The provider's own claim that the tool does not modify anything. Only a
23
+ * `discovered` provider sets it (an MCP server's `readOnlyHint`); it is
24
+ * trusted as stated and decides whether an ask-profile session asks first.
25
+ */
26
+ readonly readOnly?: boolean;
21
27
  run(input: Record<string, unknown>, ctx: ToolRunContext): Promise<ToolResult>;
22
28
  }
23
29
  export interface ToolProvider {
@@ -71,6 +71,10 @@ async function launchExec(req) {
71
71
  stripEnvVars: req.stripEnvVars,
72
72
  ...(req.mergedEnv !== undefined ? { env: req.mergedEnv } : {}),
73
73
  ...(req.signal !== undefined ? { signal: req.signal } : {}),
74
+ // US-008: the key is present only for an exact `true` on the call, so
75
+ // every other request stays byte-identical to what US-002 shipped -- and
76
+ // the launcher, not this branch, decides what "unwrapped" means.
77
+ ...(req.unwrapped ? { unwrapped: true } : {}),
74
78
  });
75
79
  }
76
80
  return {
@@ -133,6 +137,14 @@ export async function runExecBranch(input, ctx, opts) {
133
137
  mergedEnv,
134
138
  stripEnvVars: opts.stripEnvVars ?? [],
135
139
  signal: ctx.signal,
140
+ // US-008: exact `true` only, read off the CALL. Whether the session
141
+ // offers the argument, and whether the request is permitted at all, was
142
+ // settled before `run` was reached (the runtime's `resolveUnsandboxedCall`
143
+ // and its ask), so re-reading `opts.exec.unsandboxedCommands` here would
144
+ // be a second authorization gate in the one place that cannot see the
145
+ // approval. A `"true"` string is not an escape request: it is the ordinary
146
+ // call it reads as.
147
+ unwrapped: input.unsandboxed === true,
136
148
  });
137
149
  // US-001: match Bash's framing so an aborted Exec is rendered as an
138
150
  // error with the cancellation banner (AC14 mirror) and an orphansKilled
@@ -0,0 +1,28 @@
1
+ import type { CodingTool } from "./registry.ts";
2
+ import type { RunCommandExecOptions } from "./run-command.ts";
3
+ /**
4
+ * US-006: the platform seam `execSandboxNote` reads to decide whether the
5
+ * description carries the macOS ps note.
6
+ *
7
+ * Kept out of `src/tools/index.ts` on purpose: `src/index.ts` takes named
8
+ * exports from that barrel, so a seam there would reach the package's public
9
+ * `.` entry, where `_` names are forbidden. It DOES reach `./internal`, because
10
+ * `src/internal.ts` star-exports `run-command.ts` — which is exactly where a
11
+ * `_*Deps` seam belongs (nax-only, outside semver), and why adding it needed
12
+ * the one-line api/ snapshot update. Tests reach it through
13
+ * `#src/tools/run-command`.
14
+ */
15
+ export declare const _runCommandToolDeps: {
16
+ platform: () => NodeJS.Platform;
17
+ };
18
+ export declare function runCommandDescription(commandDescriptions: string, exec: RunCommandExecOptions | undefined): string;
19
+ /**
20
+ * The tool's input schema. Each of the three conditional groups (declared
21
+ * fields, argv fields, the opt-in escape argument) has one owner here, so
22
+ * `createRunCommandTool` carries no schema branch of its own.
23
+ *
24
+ * With exec available, neither field is unconditionally required — `run()`
25
+ * enforces "exactly one of command or argv" at call time, which a static JSON
26
+ * Schema `required` list cannot express as an either/or.
27
+ */
28
+ export declare function runCommandInputSchema(keys: readonly string[], exec: RunCommandExecOptions | undefined): CodingTool["inputSchema"];
@@ -0,0 +1,109 @@
1
+ import { sandboxSentence, unsandboxedSentence } from "../sandbox/index.js";
2
+ import { describeExecAllowlist } from "./exec-allowlist-text.js";
3
+ /**
4
+ * US-006: the platform seam `execSandboxNote` reads to decide whether the
5
+ * description carries the macOS ps note.
6
+ *
7
+ * Kept out of `src/tools/index.ts` on purpose: `src/index.ts` takes named
8
+ * exports from that barrel, so a seam there would reach the package's public
9
+ * `.` entry, where `_` names are forbidden. It DOES reach `./internal`, because
10
+ * `src/internal.ts` star-exports `run-command.ts` — which is exactly where a
11
+ * `_*Deps` seam belongs (nax-only, outside semver), and why adding it needed
12
+ * the one-line api/ snapshot update. Tests reach it through
13
+ * `#src/tools/run-command`.
14
+ */
15
+ export const _runCommandToolDeps = {
16
+ platform: () => process.platform,
17
+ };
18
+ function execSandboxNote(launcher) {
19
+ const state = launcher?.state;
20
+ if (state?.kind === "available")
21
+ return ` An "argv" call runs ${sandboxSentence(state.network, state.sharedTmp !== false, _runCommandToolDeps.platform())}`;
22
+ if (state?.kind === "unavailable")
23
+ return ` ${unsandboxedSentence(state.reason)}`;
24
+ return "";
25
+ }
26
+ /**
27
+ * US-008: the one sentence an opted-in session appends to an Exec-capable
28
+ * description. Worded for the argv branch ("the argv", not "the command") so it
29
+ * reads as the argv branch's own argument — the declared branch refuses the
30
+ * flag by name, and a shared sentence would promise a capability half the tool
31
+ * rejects.
32
+ */
33
+ const UNSANDBOXED_ARGV_SENTENCE = 'Set "unsandboxed": true with an "unsandboxedReason" only when the argv needs the network, a local socket or a device the sandbox blocks.';
34
+ /**
35
+ * US-008: the description with the opt-in sentence appended, or the description
36
+ * unchanged. A separate function for the same reason `withUnsandboxedSentence`
37
+ * is one in bash.ts: the separator is chosen here, so the off-form stays
38
+ * byte-identical to what shipped, and no caller has to end its own sentence
39
+ * with a space to keep the result readable.
40
+ */
41
+ function withUnsandboxedArgvSentence(description, exec) {
42
+ if (exec.unsandboxedCommands !== true)
43
+ return description;
44
+ const separator = description.endsWith(" ") ? "" : " ";
45
+ return `${description}${separator}${UNSANDBOXED_ARGV_SENTENCE}`;
46
+ }
47
+ function execDescription(commandDescriptions, exec) {
48
+ return withUnsandboxedArgvSentence(`Two ways to run something. (1) Run one of this project's declared commands: ${commandDescriptions} — supply "command" and, optionally, "values" for its placeholders. (2) Run an allowlisted external command via "argv" (an array, e.g. ["bun","add","left-pad"]) — no shell, so no quoting and no shell metacharacters; ${describeExecAllowlist(exec.patterns)}. Supply exactly one of "command" or "argv".${execSandboxNote(exec.launcher)}`, exec);
49
+ }
50
+ export function runCommandDescription(commandDescriptions, exec) {
51
+ if (exec === undefined) {
52
+ return `Run one of this project's declared commands: ${commandDescriptions}. Supply values for its placeholders; you cannot write a command of your own.`;
53
+ }
54
+ return execDescription(commandDescriptions, exec);
55
+ }
56
+ /** The declared branch's two fields, always present. */
57
+ function declaredInputProperties(keys) {
58
+ return {
59
+ command: { type: "string", enum: keys, description: "Which declared command to run" },
60
+ values: {
61
+ type: "object",
62
+ description: 'Values for the command\'s placeholders, e.g. { files: "a.test.ts" }. A path placeholder takes several paths separated by spaces, e.g. { files: "a.test.ts b.test.ts" }, or as an array of paths, e.g. { files: ["test/a.test.ts", "test/b.test.ts"] } -- one call, not one per file.',
63
+ },
64
+ };
65
+ }
66
+ /** The argv branch's two fields, present only when `exec` is available. */
67
+ function argvInputProperties() {
68
+ return {
69
+ argv: {
70
+ type: "array",
71
+ items: { type: "string" },
72
+ description: 'Argv for an allowlisted external command, e.g. ["bun","add","left-pad"]. No shell: each element is one literal argument. Supply exactly one of "command" or "argv".',
73
+ },
74
+ target: {
75
+ type: "string",
76
+ enum: ["repoRoot", "package"],
77
+ description: 'Working directory for "argv": this package (default) or the repo root.',
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * US-008: the escape argument's two fields, only when the session opted in.
83
+ * Neither is `required`: the escape is per call, and a call without it runs
84
+ * exactly as it does today.
85
+ */
86
+ function unsandboxedArgvProperties(exec) {
87
+ if (exec.unsandboxedCommands !== true)
88
+ return {};
89
+ return { unsandboxed: { type: "boolean" }, unsandboxedReason: { type: "string" } };
90
+ }
91
+ /**
92
+ * The tool's input schema. Each of the three conditional groups (declared
93
+ * fields, argv fields, the opt-in escape argument) has one owner here, so
94
+ * `createRunCommandTool` carries no schema branch of its own.
95
+ *
96
+ * With exec available, neither field is unconditionally required — `run()`
97
+ * enforces "exactly one of command or argv" at call time, which a static JSON
98
+ * Schema `required` list cannot express as an either/or.
99
+ */
100
+ export function runCommandInputSchema(keys, exec) {
101
+ return {
102
+ type: "object",
103
+ properties: {
104
+ ...declaredInputProperties(keys),
105
+ ...(exec === undefined ? {} : { ...argvInputProperties(), ...unsandboxedArgvProperties(exec) }),
106
+ },
107
+ ...(exec === undefined ? { required: ["command"] } : {}),
108
+ };
109
+ }
@@ -1,6 +1,7 @@
1
1
  import type { QualityCommandSpec } from "#src/internal/command-spec/index";
2
- import { type CommandLauncher } from "../sandbox/index.ts";
2
+ import type { CommandLauncher } from "../sandbox/index.ts";
3
3
  import type { CodingTool } from "./registry.ts";
4
+ export { _runCommandToolDeps } from "./run-command-surface.ts";
4
5
  /**
5
6
  * Context for RunCommand's allowlisted, model-authored argv branch (`Exec`).
6
7
  *
@@ -27,6 +28,21 @@ export interface RunCommandExecOptions {
27
28
  readonly patterns: readonly string[];
28
29
  /** P4: how the argv runs. Absent = direct runArgv (unit tests). */
29
30
  readonly launcher?: CommandLauncher;
31
+ /**
32
+ * US-008: whether this session offers the per-call `unsandboxed` argument on
33
+ * the argv branch (`NativeBackendOptions.allowUnsandboxedCommands`, threaded
34
+ * down through `buildDeclaredCommandTools`). It shapes the ADVERTISEMENT only
35
+ * -- the two schema fields and one description sentence.
36
+ *
37
+ * `runExecBranch` never reads it: an `argv` call carrying `unsandboxed: true`
38
+ * runs unwrapped whenever the RUNTIME let the call through, which is where
39
+ * "is this permitted" is decided (US-005/US-006, `resolveUnsandboxedCall` in
40
+ * `src/tools/runtime-calltool.ts`). Making the option a second gate here
41
+ * would put the same decision in two places, and a session that simply did
42
+ * not advertise the field could then be steered into running unwrapped by a
43
+ * stray input key.
44
+ */
45
+ readonly unsandboxedCommands?: boolean;
30
46
  }
31
47
  /** What RunCommand hands the runner for one declared command. */
32
48
  export interface DeclaredCommandRequest {
@@ -104,19 +120,4 @@ export declare function substituteCommand(template: string, values: Record<strin
104
120
  export declare function substituteCommandSpec(spec: QualityCommandSpec, values: Record<string, string | readonly string[]>): QualityCommandSpec | {
105
121
  error: string;
106
122
  };
107
- /**
108
- * US-006: the platform seam `execSandboxNote` reads to decide whether the
109
- * description carries the macOS ps note.
110
- *
111
- * Kept out of `src/tools/index.ts` on purpose: `src/index.ts` takes named
112
- * exports from that barrel, so a seam there would reach the package's public
113
- * `.` entry, where `_` names are forbidden. It DOES reach `./internal`, because
114
- * `src/internal.ts` star-exports this module — which is exactly where a
115
- * `_*Deps` seam belongs (nax-only, outside semver), and why adding it here
116
- * needed the one-line api/ snapshot update. Tests reach it through
117
- * `#src/tools/run-command`.
118
- */
119
- export declare const _runCommandToolDeps: {
120
- platform: () => NodeJS.Platform;
121
- };
122
123
  export declare function createRunCommandTool(declared: ReadonlyMap<string, QualityCommandSpec>, opts?: RunCommandToolOptions): CodingTool;