@gajae-code/coding-agent 0.5.4 → 0.6.1

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 (155) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/web-search-cli.d.ts +12 -0
  3. package/dist/types/commands/rlm.d.ts +10 -0
  4. package/dist/types/commands/web-search.d.ts +54 -0
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-profiles.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +3 -0
  8. package/dist/types/config/models-config-schema.d.ts +3 -0
  9. package/dist/types/config/settings-schema.d.ts +61 -3
  10. package/dist/types/edit/notebook.d.ts +3 -0
  11. package/dist/types/eval/py/executor.d.ts +3 -0
  12. package/dist/types/eval/py/kernel.d.ts +3 -1
  13. package/dist/types/eval/py/runtime.d.ts +9 -1
  14. package/dist/types/exec/bash-executor.d.ts +4 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  16. package/dist/types/extensibility/custom-tools/wrapper.d.ts +1 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  18. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -0
  19. package/dist/types/gjc-runtime/launch-tmux.d.ts +6 -0
  20. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +14 -0
  21. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  22. package/dist/types/gjc-runtime/tmux-gc.d.ts +3 -3
  23. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -0
  24. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +18 -0
  25. package/dist/types/goals/state.d.ts +1 -1
  26. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  27. package/dist/types/main.d.ts +11 -0
  28. package/dist/types/modes/components/custom-editor.d.ts +4 -2
  29. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +12 -0
  30. package/dist/types/modes/components/model-selector.d.ts +5 -2
  31. package/dist/types/modes/components/status-line.d.ts +4 -1
  32. package/dist/types/modes/controllers/input-controller.d.ts +3 -0
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  34. package/dist/types/modes/print-mode.d.ts +6 -0
  35. package/dist/types/modes/rpc/rpc-client.d.ts +21 -0
  36. package/dist/types/modes/rpc/rpc-socket-security.d.ts +7 -0
  37. package/dist/types/modes/rpc/rpc-types.d.ts +13 -0
  38. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +2 -0
  39. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +1 -0
  40. package/dist/types/rlm/artifacts.d.ts +9 -0
  41. package/dist/types/rlm/complete-research-tool.d.ts +35 -0
  42. package/dist/types/rlm/data-context.d.ts +6 -0
  43. package/dist/types/rlm/index.d.ts +35 -0
  44. package/dist/types/rlm/notebook.d.ts +12 -0
  45. package/dist/types/rlm/preset.d.ts +23 -0
  46. package/dist/types/rlm/python-tool.d.ts +16 -0
  47. package/dist/types/rlm/report.d.ts +14 -0
  48. package/dist/types/rlm/types.d.ts +37 -0
  49. package/dist/types/sdk.d.ts +7 -0
  50. package/dist/types/session/agent-session.d.ts +21 -0
  51. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -1
  52. package/dist/types/tools/browser/attach.d.ts +19 -3
  53. package/dist/types/tools/browser/registry.d.ts +15 -0
  54. package/dist/types/tools/browser/render.d.ts +3 -0
  55. package/dist/types/tools/browser.d.ts +18 -1
  56. package/dist/types/tools/computer/render.d.ts +17 -0
  57. package/dist/types/tools/computer.d.ts +465 -0
  58. package/dist/types/tools/index.d.ts +24 -1
  59. package/dist/types/tools/job.d.ts +13 -0
  60. package/dist/types/tools/tool-timeouts.d.ts +5 -0
  61. package/dist/types/web/search/index.d.ts +32 -2
  62. package/dist/types/web/search/providers/base.d.ts +22 -0
  63. package/dist/types/web/search/providers/xai.d.ts +64 -0
  64. package/dist/types/web/search/types.d.ts +11 -3
  65. package/package.json +7 -7
  66. package/src/cli/web-search-cli.ts +123 -8
  67. package/src/cli.ts +2 -0
  68. package/src/commands/rlm.ts +19 -0
  69. package/src/commands/web-search.ts +66 -0
  70. package/src/config/keybindings.ts +11 -0
  71. package/src/config/model-profiles.ts +11 -3
  72. package/src/config/model-registry.ts +55 -1
  73. package/src/config/models-config-schema.ts +1 -0
  74. package/src/config/settings-schema.ts +67 -1
  75. package/src/edit/notebook.ts +6 -2
  76. package/src/eval/py/executor.ts +8 -1
  77. package/src/eval/py/kernel.ts +9 -4
  78. package/src/eval/py/runtime.ts +153 -32
  79. package/src/exec/bash-executor.ts +10 -4
  80. package/src/extensibility/custom-tools/types.ts +2 -0
  81. package/src/extensibility/custom-tools/wrapper.ts +2 -0
  82. package/src/extensibility/extensions/types.ts +2 -0
  83. package/src/extensibility/extensions/wrapper.ts +1 -0
  84. package/src/gjc-runtime/launch-tmux.ts +129 -1
  85. package/src/gjc-runtime/session-state-sidecar.ts +61 -1
  86. package/src/gjc-runtime/tmux-common.ts +26 -2
  87. package/src/gjc-runtime/tmux-gc.ts +40 -27
  88. package/src/gjc-runtime/tmux-sessions.ts +13 -1
  89. package/src/gjc-runtime/ultragoal-runtime.ts +340 -18
  90. package/src/goals/runtime.ts +4 -3
  91. package/src/goals/state.ts +1 -1
  92. package/src/goals/tools/goal-tool.ts +16 -3
  93. package/src/internal-urls/docs-index.generated.ts +13 -9
  94. package/src/main.ts +28 -3
  95. package/src/modes/components/custom-editor.ts +13 -4
  96. package/src/modes/components/custom-model-preset-wizard.ts +293 -0
  97. package/src/modes/components/hook-selector.ts +1 -1
  98. package/src/modes/components/model-selector.ts +72 -29
  99. package/src/modes/components/skill-message.ts +62 -8
  100. package/src/modes/components/status-line.ts +13 -1
  101. package/src/modes/controllers/input-controller.ts +60 -11
  102. package/src/modes/controllers/selector-controller.ts +39 -0
  103. package/src/modes/interactive-mode.ts +1 -1
  104. package/src/modes/print-mode.ts +14 -4
  105. package/src/modes/rpc/rpc-client.ts +250 -80
  106. package/src/modes/rpc/rpc-mode.ts +6 -12
  107. package/src/modes/rpc/rpc-socket-security.ts +103 -0
  108. package/src/modes/rpc/rpc-types.ts +10 -0
  109. package/src/modes/shared/agent-wire/command-dispatch.ts +7 -0
  110. package/src/modes/shared/agent-wire/command-validation.ts +1 -0
  111. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  112. package/src/modes/shared/agent-wire/unattended-session.ts +9 -0
  113. package/src/modes/utils/hotkeys-markdown.ts +4 -2
  114. package/src/modes/utils/ui-helpers.ts +2 -2
  115. package/src/prompts/goals/goal-continuation.md +1 -0
  116. package/src/prompts/goals/goal-mode-active.md +1 -0
  117. package/src/prompts/system/rlm-report-command.md +1 -0
  118. package/src/prompts/system/rlm-research.md +23 -0
  119. package/src/prompts/tools/bash.md +23 -2
  120. package/src/prompts/tools/browser.md +7 -3
  121. package/src/prompts/tools/computer.md +74 -0
  122. package/src/prompts/tools/goal.md +3 -0
  123. package/src/prompts/tools/job.md +9 -1
  124. package/src/prompts/tools/web-search.md +7 -0
  125. package/src/rlm/artifacts.ts +60 -0
  126. package/src/rlm/complete-research-tool.ts +163 -0
  127. package/src/rlm/data-context.ts +26 -0
  128. package/src/rlm/index.ts +339 -0
  129. package/src/rlm/notebook.ts +108 -0
  130. package/src/rlm/preset.ts +76 -0
  131. package/src/rlm/python-tool.ts +68 -0
  132. package/src/rlm/report.ts +70 -0
  133. package/src/rlm/types.ts +40 -0
  134. package/src/sdk.ts +12 -0
  135. package/src/session/agent-session.ts +48 -3
  136. package/src/slash-commands/builtin-registry.ts +17 -0
  137. package/src/tools/bash-allowed-prefixes.ts +84 -1
  138. package/src/tools/bash.ts +80 -13
  139. package/src/tools/browser/attach.ts +103 -3
  140. package/src/tools/browser/registry.ts +176 -2
  141. package/src/tools/browser/render.ts +9 -1
  142. package/src/tools/browser.ts +33 -0
  143. package/src/tools/computer/render.ts +78 -0
  144. package/src/tools/computer.ts +640 -0
  145. package/src/tools/index.ts +41 -1
  146. package/src/tools/job.ts +88 -5
  147. package/src/tools/json-tree.ts +42 -29
  148. package/src/tools/renderers.ts +2 -0
  149. package/src/tools/tool-timeouts.ts +1 -0
  150. package/src/web/search/index.ts +27 -2
  151. package/src/web/search/provider.ts +16 -1
  152. package/src/web/search/providers/base.ts +22 -0
  153. package/src/web/search/providers/xai.ts +511 -0
  154. package/src/web/search/render.ts +7 -0
  155. package/src/web/search/types.ts +11 -1
package/src/sdk.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
48
48
  import { Settings, type SkillsSettings } from "./config/settings";
49
49
  import { CursorExecHandlers } from "./cursor";
50
+ import type { BashRestrictionProfile } from "./tools/bash-allowed-prefixes";
50
51
  import "./discovery";
51
52
  import { resolveConfigValue } from "./config/resolve-config-value";
52
53
  import { getEmbeddedDefaultGjcSkills } from "./defaults/gjc-defaults";
@@ -310,6 +311,12 @@ export interface CreateAgentSessionOptions {
310
311
  agentDisplayName?: string;
311
312
  /** Optional restricted bash command prefixes for read-only role agents. */
312
313
  bashAllowedPrefixes?: string[];
314
+ /** Restriction policy paired with bashAllowedPrefixes. */
315
+ bashRestrictionProfile?: BashRestrictionProfile;
316
+ /** Optional per-session restriction for goal tool operations. */
317
+ goalToolAllowedOps?: readonly ("create" | "get" | "complete" | "resume" | "drop" | "pause")[];
318
+ /** Optional per-session allowlist for tools exposed through search_tool_bm25. */
319
+ discoverableToolAllowedNames?: readonly string[];
313
320
  /** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
314
321
  agentRegistry?: AgentRegistry;
315
322
  /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
@@ -619,6 +626,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
619
626
  label: tool.label,
620
627
  description: tool.description,
621
628
  parameters: tool.parameters,
629
+ concurrency: tool.concurrency,
622
630
  hidden: tool.hidden,
623
631
  deferrable: tool.deferrable,
624
632
  mcpServerName: tool.mcpServerName,
@@ -1219,6 +1227,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1219
1227
  },
1220
1228
  getAgentId: () => resolvedAgentId,
1221
1229
  bashAllowedPrefixes: options.bashAllowedPrefixes,
1230
+ bashRestrictionProfile: options.bashRestrictionProfile,
1231
+ goalToolAllowedOps: options.goalToolAllowedOps,
1232
+ discoverableToolAllowedNames: options.discoverableToolAllowedNames,
1222
1233
  getToolByName: name => session?.getToolByName(name),
1223
1234
  agentRegistry,
1224
1235
  getSessionSpawns: () => options.spawns ?? "*",
@@ -2028,6 +2039,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2028
2039
  rebuildSystemPrompt,
2029
2040
  reloadSshTool,
2030
2041
  requestedToolNames: requestedToolNameSet,
2042
+ discoverableToolAllowedNames: options.discoverableToolAllowedNames,
2031
2043
  getMcpServerInstructions: mcpManager
2032
2044
  ? () => {
2033
2045
  const raw = mcpManager.getServerInstructions();
@@ -388,6 +388,8 @@ export interface AgentSessionConfig {
388
388
  /** Rebuild the SSH tool from current capability discovery results. */
389
389
  reloadSshTool?: () => Promise<AgentTool | null>;
390
390
  requestedToolNames?: ReadonlySet<string>;
391
+ /** Optional per-session allowlist for tools exposed through search_tool_bm25. */
392
+ discoverableToolAllowedNames?: readonly string[];
391
393
  /**
392
394
  * Optional accessor for live MCP server instructions. Read by the session's
393
395
  * `rebuildSystemPrompt`-skip optimization to detect server-side instruction
@@ -944,6 +946,7 @@ export class AgentSession {
944
946
  // Bash execution state
945
947
  #bashAbortControllers = new Set<AbortController>();
946
948
  #pendingBashMessages: BashExecutionMessage[] = [];
949
+ #foregroundBashBackgroundRequestHandler: (() => void) | undefined;
947
950
 
948
951
  // Python execution state
949
952
  #evalAbortControllers = new Set<AbortController>();
@@ -1016,6 +1019,7 @@ export class AgentSession {
1016
1019
  // Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
1017
1020
  #discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
1018
1021
  #selectedDiscoveredToolNames = new Set<string>();
1022
+ #discoverableToolAllowedNames: ReadonlySet<string> | undefined;
1019
1023
  #rpcHostToolNames = new Set<string>();
1020
1024
  #gjcSubskillToolNames = new Set<string>();
1021
1025
  #gjcSubskillToolSignature: string | undefined;
@@ -1224,6 +1228,9 @@ export class AgentSession {
1224
1228
  this.#reloadSshTool = config.reloadSshTool;
1225
1229
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
1226
1230
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
1231
+ this.#discoverableToolAllowedNames = config.discoverableToolAllowedNames
1232
+ ? new Set(config.discoverableToolAllowedNames.map(name => name.toLowerCase()))
1233
+ : undefined;
1227
1234
  this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
1228
1235
  this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
1229
1236
  this.#defaultSelectedMCPServerNames = new Set(config.defaultSelectedMCPServerNames ?? []);
@@ -3429,6 +3436,42 @@ export class AgentSession {
3429
3436
  return this.#toolRegistry.get(name);
3430
3437
  }
3431
3438
 
3439
+ /**
3440
+ * Register a UI/control-plane request handler for a currently foregrounded
3441
+ * managed bash execution. This is intentionally narrower than generic
3442
+ * process/job control: unsupported tool types simply do not register a
3443
+ * handler, so Ctrl+B-style folding fails closed instead of aborting or
3444
+ * shell-suspending arbitrary work.
3445
+ */
3446
+ registerForegroundBashBackgroundRequestHandler(handler: () => void): () => void {
3447
+ this.#foregroundBashBackgroundRequestHandler = handler;
3448
+ return () => {
3449
+ if (this.#foregroundBashBackgroundRequestHandler === handler) {
3450
+ this.#foregroundBashBackgroundRequestHandler = undefined;
3451
+ }
3452
+ };
3453
+ }
3454
+
3455
+ /**
3456
+ * Returns whether a managed foreground bash call is currently backgroundable.
3457
+ * UI key handlers use this to avoid consuming normal editor shortcuts when
3458
+ * no fold target exists.
3459
+ */
3460
+ hasForegroundBashBackgroundRequestHandler(): boolean {
3461
+ return this.#foregroundBashBackgroundRequestHandler !== undefined;
3462
+ }
3463
+
3464
+ /**
3465
+ * Ask the active managed foreground bash call to return as a background job.
3466
+ * Returns false when no supported foreground tool is currently backgroundable.
3467
+ */
3468
+ requestForegroundBashBackground(): boolean {
3469
+ const handler = this.#foregroundBashBackgroundRequestHandler;
3470
+ if (!handler) return false;
3471
+ handler();
3472
+ return true;
3473
+ }
3474
+
3432
3475
  /**
3433
3476
  * Get all configured tool names (built-in via --tools or default, plus custom tools).
3434
3477
  */
@@ -3553,6 +3596,7 @@ export class AgentSession {
3553
3596
  for (const tool of this.#toolRegistry.values()) {
3554
3597
  if (tool.loadMode !== "discoverable") continue;
3555
3598
  if (activeNames.has(tool.name)) continue;
3599
+ if (this.#discoverableToolAllowedNames && !this.#discoverableToolAllowedNames.has(tool.name)) continue;
3556
3600
  const collected = collectDiscoverableTools([tool], { source: "builtin" });
3557
3601
  result.push(...collected);
3558
3602
  }
@@ -3599,6 +3643,7 @@ export class AgentSession {
3599
3643
  const currentActiveNames = new Set(this.getActiveToolNames());
3600
3644
  const newlyAdded: string[] = [];
3601
3645
  for (const name of nonMcpNames) {
3646
+ if (this.#discoverableToolAllowedNames && !this.#discoverableToolAllowedNames.has(name)) continue;
3602
3647
  if (this.#toolRegistry.has(name) && !currentActiveNames.has(name)) {
3603
3648
  newlyAdded.push(name);
3604
3649
  this.#selectedDiscoveredToolNames.add(name);
@@ -5804,9 +5849,9 @@ export class AgentSession {
5804
5849
  );
5805
5850
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
5806
5851
 
5807
- // Re-apply thinking for the newly selected model. Prefer the model's
5808
- // configured defaultLevel; otherwise preserve the current level.
5809
- this.setThinkingLevel(model.thinking?.defaultLevel ?? this.thinkingLevel);
5852
+ // Apply the explicitly selected thinking level when the selector supplies one;
5853
+ // otherwise prefer the model's configured defaultLevel, then preserve the current level.
5854
+ this.setThinkingLevel(options?.thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
5810
5855
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
5811
5856
  }
5812
5857
 
@@ -449,6 +449,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
449
449
  runtime.ctx.editor.setText("");
450
450
  },
451
451
  },
452
+ {
453
+ name: "copy",
454
+ description: "Copy last response as markdown",
455
+ // Public `/copy` is strict zero-argument, but `allowArgs` lets the
456
+ // TUI dispatcher route `/copy <arg>` here so it can be rejected locally
457
+ // instead of falling through as a model prompt.
458
+ allowArgs: true,
459
+ handleTui: (command, runtime) => {
460
+ if (command.args.trim().length > 0) {
461
+ runtime.ctx.showError("Usage: /copy");
462
+ runtime.ctx.editor.setText("");
463
+ return;
464
+ }
465
+ runtime.ctx.handleCopyCommand(undefined);
466
+ runtime.ctx.editor.setText("");
467
+ },
468
+ },
452
469
  {
453
470
  name: "dump",
454
471
  description: "Copy session transcript to clipboard",
@@ -3,11 +3,18 @@ export interface BashAllowedPrefixesCheck {
3
3
  reason?: string;
4
4
  }
5
5
 
6
+ export type BashRestrictionProfile = "workflow" | "read-only";
7
+
8
+ export interface BashRestrictionOptions {
9
+ profile?: BashRestrictionProfile;
10
+ }
11
+
6
12
  const SHELL_CONTROL_CHARS = new Set([";", "|", "&", "<", ">", "(", ")"]);
7
13
  const UNSAFE_UNQUOTED_EXPANSION_CHARS = new Set(["$", "*", "?", "[", "]", "{", "}", "~"]);
8
14
  const STATE_FLAGS_WITH_VALUES = new Set(["--input", "--mode", "--session-id", "--thread-id", "--turn-id", "--to"]);
9
15
  const STATE_ACTIONS = new Set(["read", "write", "clear", "contract", "handoff"]);
10
16
  const ALLOWED_STATE_ACTIONS = new Set(["read", "write", "contract"]);
17
+ const READ_ONLY_COMMANDS = new Set(["grep", "rg", "tree", "ls", "pwd", "wc", "du", "file", "stat"]);
11
18
 
12
19
  function parseShellWords(command: string): { words: string[]; reason?: string } {
13
20
  const words: string[] = [];
@@ -118,6 +125,59 @@ function parseStateAction(words: readonly string[]): string | undefined {
118
125
  return third ? undefined : second;
119
126
  }
120
127
 
128
+ function optionWords(words: readonly string[]): string[] {
129
+ const options: string[] = [];
130
+ for (const word of words.slice(1)) {
131
+ if (word === "--") break;
132
+ options.push(word);
133
+ }
134
+ return options;
135
+ }
136
+
137
+ function isLongOption(word: string, option: string): boolean {
138
+ return word === option || word.startsWith(`${option}=`);
139
+ }
140
+
141
+ function hasShortOption(word: string, option: string): boolean {
142
+ return word.startsWith("-") && !word.startsWith("--") && word.slice(1).includes(option);
143
+ }
144
+ function shellQuote(value: string): string {
145
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
146
+ }
147
+
148
+ function resolvedExternalCommand(command: string): string | undefined {
149
+ return Bun.which(command) ?? undefined;
150
+ }
151
+
152
+ function validateReadOnlyCommand(words: readonly string[]): BashAllowedPrefixesCheck {
153
+ const command = words[0];
154
+ if (!command || !READ_ONLY_COMMANDS.has(command)) {
155
+ return { allowed: false, reason: "read-only bash only allows approved inspection commands" };
156
+ }
157
+
158
+ const options = optionWords(words);
159
+ if (command === "rg") {
160
+ for (const option of options) {
161
+ if (isLongOption(option, "--pre") || isLongOption(option, "--pre-glob")) {
162
+ return { allowed: false, reason: "read-only bash does not allow ripgrep preprocessors" };
163
+ }
164
+ if (isLongOption(option, "--search-zip") || hasShortOption(option, "z")) {
165
+ return { allowed: false, reason: "read-only bash does not allow ripgrep compressed-file subprocesses" };
166
+ }
167
+ }
168
+ }
169
+
170
+ if (command === "tree") {
171
+ for (const option of options) {
172
+ if (isLongOption(option, "--output") || hasShortOption(option, "o")) {
173
+ return { allowed: false, reason: "read-only bash does not allow tree output-file writes" };
174
+ }
175
+ }
176
+ }
177
+
178
+ return { allowed: true };
179
+ }
180
+
121
181
  function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixesCheck {
122
182
  if (words[0] !== "gjc") return { allowed: true };
123
183
 
@@ -145,9 +205,29 @@ function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixe
145
205
  return { allowed: true };
146
206
  }
147
207
 
208
+ function commandAllowedPrefixesReason(normalizedPrefixes: readonly string[], options: BashRestrictionOptions): string {
209
+ const prefixList = normalizedPrefixes.join(", ");
210
+ return options.profile === "read-only"
211
+ ? `read-only bash only allows commands starting with: ${prefixList}`
212
+ : `restricted role-agent bash only allows commands starting with: ${prefixList}`;
213
+ }
214
+
215
+ export function normalizeReadOnlyBashCommand(command: string): string | undefined {
216
+ const parsed = parseShellWords(command.trim());
217
+ if (parsed.reason || parsed.words.length === 0) return undefined;
218
+ const validation = validateReadOnlyCommand(parsed.words);
219
+ if (!validation.allowed) return undefined;
220
+ const [head, ...rest] = parsed.words;
221
+ if (!head) return undefined;
222
+ const resolvedHead = resolvedExternalCommand(head);
223
+ if (!resolvedHead) return undefined;
224
+ return [shellQuote(resolvedHead), ...rest.map(shellQuote)].join(" ");
225
+ }
226
+
148
227
  export function checkBashAllowedPrefixes(
149
228
  command: string,
150
229
  allowedPrefixes: readonly string[] | undefined,
230
+ options: BashRestrictionOptions = {},
151
231
  ): BashAllowedPrefixesCheck {
152
232
  const normalizedPrefixes = allowedPrefixes?.map(prefix => prefix.trim()).filter(Boolean) ?? [];
153
233
  if (normalizedPrefixes.length === 0) return { allowed: true };
@@ -161,9 +241,12 @@ export function checkBashAllowedPrefixes(
161
241
  if (!matched) {
162
242
  return {
163
243
  allowed: false,
164
- reason: `restricted role-agent bash only allows commands starting with: ${normalizedPrefixes.join(", ")}`,
244
+ reason: commandAllowedPrefixesReason(normalizedPrefixes, options),
165
245
  };
166
246
  }
167
247
 
248
+ if (options.profile === "read-only") {
249
+ return validateReadOnlyCommand(parsed.words);
250
+ }
168
251
  return validateMatchedGjcCommand(parsed.words);
169
252
  }
package/src/tools/bash.ts CHANGED
@@ -19,7 +19,7 @@ import { renderStatusLine } from "../tui";
19
19
  import { CachedOutputBlock } from "../tui/output-block";
20
20
  import { getSixelLineMask } from "../utils/sixel";
21
21
  import type { ToolSession } from ".";
22
- import { checkBashAllowedPrefixes } from "./bash-allowed-prefixes";
22
+ import { checkBashAllowedPrefixes, normalizeReadOnlyBashCommand } from "./bash-allowed-prefixes";
23
23
  import { applyBashFixups } from "./bash-command-fixup";
24
24
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
25
25
  import { checkBashInterception } from "./bash-interceptor";
@@ -36,6 +36,12 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
36
36
 
37
37
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
38
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
39
+ const READ_ONLY_BASH_ENV: Record<string, string> = {
40
+ GREP_OPTIONS: "",
41
+ GREP_COLOR: "",
42
+ GREP_COLORS: "",
43
+ RIPGREP_CONFIG_PATH: "",
44
+ };
39
45
 
40
46
  export async function saveBashOriginalArtifactForTests(
41
47
  session: ToolSession,
@@ -261,6 +267,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
261
267
  hasSearch: this.session.settings.get("search.enabled"),
262
268
  hasFind: this.session.settings.get("find.enabled"),
263
269
  restrictedAllowedPrefixes: this.session.bashAllowedPrefixes,
270
+ restrictionProfile: this.session.bashRestrictionProfile,
264
271
  });
265
272
  }
266
273
 
@@ -363,6 +370,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
363
370
  const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
364
371
  let latestText = "";
365
372
  let backgrounded = options.startBackgrounded;
373
+ const runningDetails = (jobId: string): Record<string, unknown> | undefined =>
374
+ backgrounded ? { async: { state: "running", jobId, type: "bash" } } : undefined;
375
+ const completedDetails = (jobId: string): Record<string, unknown> | undefined =>
376
+ backgrounded ? { async: { state: "completed", jobId, type: "bash" } } : undefined;
377
+ const failedDetails = (jobId: string): Record<string, unknown> | undefined =>
378
+ backgrounded ? { async: { state: "failed", jobId, type: "bash" } } : undefined;
366
379
  const completion = Promise.withResolvers<ManagedBashJobCompletion>();
367
380
 
368
381
  const jobId = manager.register(
@@ -381,10 +394,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
381
394
  artifactPath,
382
395
  artifactId,
383
396
  oneShot: true,
397
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
398
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
384
399
  onChunk: chunk => {
385
400
  tailBuffer.append(chunk);
386
401
  latestText = tailBuffer.text();
387
- void reportProgress(latestText, { async: { state: "running", jobId, type: "bash" } });
402
+ void reportProgress(latestText, runningDetails(jobId));
388
403
  },
389
404
  onRawChunk: chunk => {
390
405
  // Forward the unthrottled sanitized chunk to the async-job
@@ -402,13 +417,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
402
417
  const finalText = this.#extractTextResult(finalResult);
403
418
  latestText = finalText;
404
419
  completion.resolve({ kind: "completed", result: finalResult });
405
- await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
420
+ await reportProgress(finalText, completedDetails(jobId));
406
421
  return finalText;
407
422
  } catch (error) {
408
423
  const message = error instanceof Error ? error.message : String(error);
409
424
  latestText = message;
410
425
  completion.resolve({ kind: "failed", error });
411
- await reportProgress(message, { async: { state: "failed", jobId, type: "bash" } });
426
+ await reportProgress(message, failedDetails(jobId));
412
427
  throw error;
413
428
  }
414
429
  },
@@ -439,6 +454,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
439
454
  job: ManagedBashJobHandle,
440
455
  thresholdMs: number,
441
456
  signal?: AbortSignal,
457
+ backgroundRequest?: Promise<void>,
442
458
  ): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
443
459
  if (signal?.aborted) {
444
460
  return { kind: "aborted" };
@@ -448,6 +464,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
448
464
  job.completion,
449
465
  Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })),
450
466
  ];
467
+ if (backgroundRequest) {
468
+ waiters.push(backgroundRequest.then(() => ({ kind: "running" as const })));
469
+ }
451
470
 
452
471
  if (!signal) {
453
472
  return await Promise.race(waiters);
@@ -510,23 +529,37 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
510
529
 
511
530
  const rawCommand = input.command;
512
531
  const allowedPrefixes = this.session.bashAllowedPrefixes;
532
+ if (
533
+ (this.session.bashRestrictionProfile === "read-only" || (allowedPrefixes && allowedPrefixes.length > 0)) &&
534
+ env &&
535
+ Object.keys(env).length > 0
536
+ ) {
537
+ const mode = this.session.bashRestrictionProfile === "read-only" ? "Read-only" : "Restricted role-agent";
538
+ throw new ToolError(`${mode} bash does not allow per-command env overrides.`);
539
+ }
513
540
  if (allowedPrefixes && allowedPrefixes.length > 0) {
514
- if (env && Object.keys(env).length > 0) {
515
- throw new ToolError("Restricted role-agent bash does not allow per-command env overrides.");
516
- }
517
541
  const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
518
542
  for (const commandToCheck of commandsToCheck) {
519
- const allowlist = checkBashAllowedPrefixes(commandToCheck, allowedPrefixes);
543
+ const allowlist = checkBashAllowedPrefixes(commandToCheck, allowedPrefixes, {
544
+ profile: this.session.bashRestrictionProfile,
545
+ });
520
546
  if (!allowlist.allowed) {
521
547
  throw new ToolError(allowlist.reason ?? "Command blocked by restricted role-agent bash allowlist.");
522
548
  }
523
549
  }
524
550
  }
551
+ if (this.session.bashRestrictionProfile === "read-only") {
552
+ const normalizedReadOnlyCommand = normalizeReadOnlyBashCommand(command);
553
+ if (!normalizedReadOnlyCommand) {
554
+ throw new ToolError("Read-only bash command could not be normalized safely.");
555
+ }
556
+ command = normalizedReadOnlyCommand;
557
+ }
525
558
 
526
559
  // Check both the original command and the cwd-normalized command so
527
560
  // leading `cd ... &&` wrappers do not hide either shell-navigation rules
528
561
  // or the dedicated-tool command that follows the directory change.
529
- if (this.session.settings.get("bashInterceptor.enabled")) {
562
+ if (this.session.bashRestrictionProfile !== "read-only" && this.session.settings.get("bashInterceptor.enabled")) {
530
563
  const rules = this.session.settings.getBashInterceptorRules();
531
564
  const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
532
565
  for (const commandToCheck of commandsToCheck) {
@@ -545,7 +578,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
545
578
  getSessionId: this.session.getSessionId,
546
579
  },
547
580
  };
548
- command = await expandInternalUrls(command, { ...internalUrlOptions, ensureLocalParentDirs: true });
581
+ command = await expandInternalUrls(command, {
582
+ ...internalUrlOptions,
583
+ ensureLocalParentDirs: this.session.bashRestrictionProfile !== "read-only",
584
+ });
549
585
  const sessionFile = this.session.getSessionFile?.() ?? null;
550
586
  const expandedEnv = env
551
587
  ? Object.fromEntries(
@@ -568,6 +604,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
568
604
  cwd: this.session.cwd,
569
605
  }),
570
606
  ...expandedEnv,
607
+ ...(this.session.bashRestrictionProfile === "read-only" ? READ_ONLY_BASH_ENV : {}),
571
608
  ...(allowedPrefixes && allowedPrefixes.length > 0 ? { [GJC_RESTRICTED_ROLE_AGENT_BASH_ENV]: "1" } : {}),
572
609
  };
573
610
 
@@ -682,6 +719,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
682
719
  artifactPath,
683
720
  artifactId,
684
721
  oneShot: true,
722
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
723
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
685
724
  onChunk: chunk => {
686
725
  tailBuffer.append(chunk);
687
726
  void reportProgress(tailBuffer.text(), {
@@ -729,6 +768,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
729
768
  if (asyncRequested && !this.#asyncEnabled) {
730
769
  throw new ToolError("Async bash execution is disabled. Enable async.enabled to use async mode.");
731
770
  }
771
+ if (this.session.bashRestrictionProfile === "read-only" && pty) {
772
+ throw new ToolError("Read-only bash does not allow PTY mode.");
773
+ }
774
+
732
775
  const prepared = await this.#prepareBashExecution(
733
776
  { command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
734
777
  ctx,
@@ -787,7 +830,22 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
787
830
  notices: pendingNotices,
788
831
  });
789
832
  }
790
- const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
833
+ const backgroundRequest = Promise.withResolvers<void>();
834
+ const unregisterBackgroundRequest = this.session.registerForegroundBashBackgroundRequestHandler?.(() => {
835
+ job.setBackgrounded(true);
836
+ backgroundRequest.resolve();
837
+ });
838
+ let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
839
+ try {
840
+ waitResult = await this.#waitForManagedBashJob(
841
+ job,
842
+ autoBackgroundWaitMs,
843
+ signal,
844
+ backgroundRequest.promise,
845
+ );
846
+ } finally {
847
+ unregisterBackgroundRequest?.();
848
+ }
791
849
  if (waitResult.kind === "completed") {
792
850
  autoBgManager.acknowledgeDeliveries([job.jobId]);
793
851
  return waitResult.result;
@@ -810,7 +868,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
810
868
 
811
869
  // Route through the client terminal when the client advertises the terminal capability.
812
870
  // Skip when pty=true (PTY needs the local terminal UI).
813
- const clientBridge = this.session.getClientBridge?.();
871
+ const clientBridge =
872
+ this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
814
873
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
815
874
  const handle = await clientBridge.createTerminal({
816
875
  command,
@@ -983,7 +1042,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
983
1042
  // Allocate artifact for truncated output storage
984
1043
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
985
1044
 
986
- const interactiveUi = canUseInteractiveBashPty(pty, ctx) ? ctx?.ui : undefined;
1045
+ const interactiveUi =
1046
+ this.session.bashRestrictionProfile === "read-only"
1047
+ ? undefined
1048
+ : canUseInteractiveBashPty(pty, ctx)
1049
+ ? ctx?.ui
1050
+ : undefined;
987
1051
  const result: BashResult | BashInteractiveResult = interactiveUi
988
1052
  ? await runInteractiveBashPty(interactiveUi, {
989
1053
  command,
@@ -997,6 +1061,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
997
1061
  : await executeBash(command, {
998
1062
  cwd: commandCwd,
999
1063
  sessionKey: this.session.getSessionId?.() ?? undefined,
1064
+ oneShot: this.session.bashRestrictionProfile === "read-only",
1000
1065
  timeout: timeoutMs,
1001
1066
  signal,
1002
1067
  env: resolvedEnv,
@@ -1004,6 +1069,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1004
1069
  artifactId,
1005
1070
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
1006
1071
  onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
1072
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1073
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1007
1074
  });
1008
1075
  if (result.cancelled) {
1009
1076
  if (signal?.aborted) {
@@ -1,4 +1,5 @@
1
1
  import * as net from "node:net";
2
+ import * as path from "node:path";
2
3
  import { Process, ProcessStatus } from "@gajae-code/natives";
3
4
  import type { Browser, Page } from "puppeteer-core";
4
5
  import { ToolError, throwIfAborted } from "../tool-errors";
@@ -62,6 +63,17 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
62
63
  * accepts both `--flag=value` and `--flag value`). Returns null if absent or
63
64
  * malformed.
64
65
  */
66
+ export function findCdpPortInArgsForTest(args: string[]): number | null {
67
+ return findCdpPortInArgs(args);
68
+ }
69
+ export function findCdpAddressInArgsForTest(args: readonly string[]): string | null {
70
+ return findCdpAddressInArgs(args);
71
+ }
72
+
73
+ export function isSafeCdpAddressForTest(address: string | null): boolean {
74
+ return isSafeCdpAddress(address);
75
+ }
76
+
65
77
  function findCdpPortInArgs(args: string[]): number | null {
66
78
  for (const arg of args) {
67
79
  const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
@@ -79,6 +91,41 @@ function findCdpPortInArgs(args: string[]): number | null {
79
91
  return null;
80
92
  }
81
93
 
94
+ function findArgValue(args: readonly string[], name: string): string | null {
95
+ const prefix = `${name}=`;
96
+ for (const arg of args) {
97
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
98
+ }
99
+ for (let i = 0; i < args.length - 1; i++) {
100
+ if (args[i] === name) return args[i + 1] ?? null;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function normalizeProfilePath(input: string): string {
106
+ return path.resolve(input);
107
+ }
108
+
109
+ export function argsMatchChromeProfileForTest(
110
+ args: readonly string[],
111
+ profile: { userDataDir: string; profileDirectory: string },
112
+ ): boolean {
113
+ return argsMatchChromeProfile(args, profile);
114
+ }
115
+
116
+ function argsMatchChromeProfile(
117
+ args: readonly string[],
118
+ profile: { userDataDir: string; profileDirectory: string },
119
+ ): boolean {
120
+ const userDataDir = findArgValue(args, "--user-data-dir");
121
+ const profileDirectory = findArgValue(args, "--profile-directory") ?? "Default";
122
+ return (
123
+ userDataDir !== null &&
124
+ normalizeProfilePath(userDataDir) === normalizeProfilePath(profile.userDataDir) &&
125
+ profileDirectory === profile.profileDirectory
126
+ );
127
+ }
128
+
82
129
  /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
83
130
  async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
84
131
  const probeTimeout = AbortSignal.timeout(1500);
@@ -93,10 +140,26 @@ async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean>
93
140
  }
94
141
 
95
142
  /**
96
- * If any running instance of `exe` was launched with `--remote-debugging-port`
97
- * and that endpoint actually answers, return it so attach can reuse it instead
98
- * of killing and respawning. Idempotent re-attaches are the common case.
143
+ * Chromium binds remote debugging to loopback by default when no address is
144
+ * provided. Reuse only loopback CDP listeners; a matching profile launched with
145
+ * --remote-debugging-address=0.0.0.0, ::, or any LAN/public address is unsafe.
99
146
  */
147
+ function findCdpAddressInArgs(args: readonly string[]): string | null {
148
+ return findArgValue(args, "--remote-debugging-address");
149
+ }
150
+
151
+ function isSafeCdpAddress(address: string | null): boolean {
152
+ if (address === null || address.trim() === "") return true;
153
+ const normalized = address.trim().toLowerCase();
154
+ return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1" || normalized === "[::1]";
155
+ }
156
+
157
+ function unsafeCdpAddressReason(address: string): string {
158
+ return `Refusing to reuse Chrome profile CDP endpoint because --remote-debugging-address=${JSON.stringify(
159
+ address,
160
+ )} is not a loopback-only address. Restart Chrome with --remote-debugging-address=127.0.0.1 or omit the address flag.`;
161
+ }
162
+
100
163
  export async function findReusableCdp(
101
164
  exe: string,
102
165
  signal?: AbortSignal,
@@ -111,6 +174,8 @@ export async function findReusableCdp(
111
174
  }
112
175
  const port = findCdpPortInArgs(args);
113
176
  if (port === null) continue;
177
+ const address = findCdpAddressInArgs(args);
178
+ if (!isSafeCdpAddress(address)) continue;
114
179
  if (await probeCdpAt(port, signal)) {
115
180
  return { cdpUrl: `http://127.0.0.1:${port}`, pid: proc.pid };
116
181
  }
@@ -118,6 +183,41 @@ export async function findReusableCdp(
118
183
  return null;
119
184
  }
120
185
 
186
+ export interface RunningChromeProfile {
187
+ pid: number;
188
+ cdpUrl: string | null;
189
+ unsafeCdpReason?: string;
190
+ }
191
+
192
+ export async function findRunningChromeProfile(
193
+ exe: string,
194
+ profile: { userDataDir: string; profileDirectory: string },
195
+ signal?: AbortSignal,
196
+ ): Promise<RunningChromeProfile | null> {
197
+ const candidates = Process.fromPath(exe).filter(p => p.status() === ProcessStatus.Running);
198
+ for (const proc of candidates) {
199
+ let args: string[];
200
+ try {
201
+ args = proc.args();
202
+ } catch {
203
+ continue;
204
+ }
205
+ if (!argsMatchChromeProfile(args, profile)) continue;
206
+ const port = findCdpPortInArgs(args);
207
+ if (port !== null) {
208
+ const address = findCdpAddressInArgs(args);
209
+ if (!isSafeCdpAddress(address)) {
210
+ return { pid: proc.pid, cdpUrl: null, unsafeCdpReason: unsafeCdpAddressReason(address ?? "") };
211
+ }
212
+ if (await probeCdpAt(port, signal)) {
213
+ return { pid: proc.pid, cdpUrl: `http://127.0.0.1:${port}` };
214
+ }
215
+ }
216
+ return { pid: proc.pid, cdpUrl: null };
217
+ }
218
+ return null;
219
+ }
220
+
121
221
  /**
122
222
  * Pick the best page target on an attached browser. Without a matcher, prefer
123
223
  * a page that doesn't look like a helper window (devtools, request handler,