diff --git a/.github/scripts/pr-triage.sh b/.github/scripts/pr-triage.sh
index e6521376ce..92200ee4d2 100755
--- a/.github/scripts/pr-triage.sh
+++ b/.github/scripts/pr-triage.sh
@@ -22,7 +22,7 @@ get_issue_labels() {
# Check cache
case "${ISSUE_LABELS_CACHE_FLAT}" in
*"|${ISSUE_NUM}:"*)
- local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
+ local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
echo "${suffix%%|*}"
return
;;
diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml
index 487225d452..4b37d0e109 100644
--- a/.github/workflows/chained_e2e.yml
+++ b/.github/workflows/chained_e2e.yml
@@ -224,8 +224,6 @@ jobs:
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
- continue-on-error: true
-
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -315,6 +313,7 @@ jobs:
needs:
- 'e2e_linux'
- 'e2e_mac'
+ - 'e2e_windows'
- 'evals'
- 'merge_queue_skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -323,6 +322,7 @@ jobs:
run: |
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
${{ needs.e2e_mac.result }} != 'success' || \
+ ${{ needs.e2e_windows.result }} != 'success' || \
${{ needs.evals.result }} != 'success' ]]; then
echo "One or more E2E jobs failed."
exit 1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f9714df99..dd7288cde5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -360,7 +360,6 @@ jobs:
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
- continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
@@ -458,6 +457,7 @@ jobs:
- 'link_checker'
- 'test_linux'
- 'test_mac'
+ - 'test_windows'
- 'codeql'
- 'bundle_size'
runs-on: 'gemini-cli-ubuntu-16-core'
@@ -468,6 +468,7 @@ jobs:
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
+ (${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
echo "One or more CI jobs failed."
diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml
index b7a375d836..6f6767ebfe 100644
--- a/.github/workflows/evals-nightly.yml
+++ b/.github/workflows/evals-nightly.yml
@@ -27,6 +27,7 @@ jobs:
fail-fast: false
matrix:
model:
+ - 'gemini-3.1-pro-preview-customtools'
- 'gemini-3-pro-preview'
- 'gemini-3-flash-preview'
- 'gemini-2.5-pro'
diff --git a/.github/workflows/pr-rate-limiter.yaml b/.github/workflows/pr-rate-limiter.yaml
new file mode 100644
index 0000000000..c703279532
--- /dev/null
+++ b/.github/workflows/pr-rate-limiter.yaml
@@ -0,0 +1,29 @@
+# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
+
+name: 'PR rate limiter'
+
+permissions: {}
+
+on:
+ pull_request_target:
+ types:
+ - 'opened'
+ - 'reopened'
+
+jobs:
+ limit:
+ runs-on: 'gemini-cli-ubuntu-16-core'
+ permissions:
+ contents: 'read'
+ pull-requests: 'write'
+ steps:
+ - name: 'Limit open pull requests per user'
+ uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
+ with:
+ except-author-associations: 'MEMBER,OWNER,COLLABORATOR'
+ comment-limit: 8
+ comment: >
+ You already have 7 pull requests open. Please work on getting
+ existing PRs merged before opening more.
+ close-limit: 8
+ close: true
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index 111728ea59..0b20ce31f2 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -29,6 +29,7 @@ they appear in the UI.
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
+| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
@@ -111,14 +112,15 @@ they appear in the UI.
### Security
-| UI Label | Setting | Description | Default |
-| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
-| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
-| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
-| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
-| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
-| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
-| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
+| UI Label | Setting | Description | Default |
+| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
+| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
+| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
+| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
+| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
+| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
+| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
+| Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` |
### Advanced
diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md
index b4a0df7336..d36df94d78 100644
--- a/docs/extensions/reference.md
+++ b/docs/extensions/reference.md
@@ -116,7 +116,9 @@ The manifest file defines the extension's behavior and configuration.
"description": "My awesome extension",
"mcpServers": {
"my-server": {
- "command": "node my-server.js"
+ "command": "node",
+ "args": ["${extensionPath}/my-server.js"],
+ "cwd": "${extensionPath}"
}
},
"contextFileName": "GEMINI.md",
@@ -124,19 +126,41 @@ The manifest file defines the extension's behavior and configuration.
}
```
-- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
- and dashes. This name must match the extension's directory name.
-- `version`: The current version of the extension.
-- `description`: A short summary shown in the extension gallery.
-- `mcpServers`: A map of Model Context Protocol (MCP)
- servers. Extension servers follow the same format as standard
- [CLI configuration](../reference/configuration.md).
-- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
- also be an array of strings to load multiple context files.
-- `excludeTools`: An array of tools to block from the model. You can restrict
- specific arguments, such as `run_shell_command(rm -rf)`.
-- `themes`: An optional list of themes provided by the extension. See
- [Themes](../cli/themes.md) for more information.
+- `name`: The name of the extension. This is used to uniquely identify the
+ extension and for conflict resolution when extension commands have the same
+ name as user or project commands. The name should be lowercase or numbers and
+ use dashes instead of underscores or spaces. This is how users will refer to
+ your extension in the CLI. Note that we expect this name to match the
+ extension directory name.
+- `version`: The version of the extension.
+- `description`: A short description of the extension. This will be displayed on
+ [geminicli.com/extensions](https://geminicli.com/extensions).
+- `mcpServers`: A map of MCP servers to settings. The key is the name of the
+ server, and the value is the server configuration. These servers will be
+ loaded on startup just like MCP servers defined in a
+ [`settings.json` file](../reference/configuration.md). If both an extension
+ and a `settings.json` file define an MCP server with the same name, the server
+ defined in the `settings.json` file takes precedence.
+ - Note that all MCP server configuration options are supported except for
+ `trust`.
+ - For portability, you should use `${extensionPath}` to refer to files within
+ your extension directory.
+ - Separate your executable and its arguments using `command` and `args`
+ instead of putting them both in `command`.
+- `contextFileName`: The name of the file that contains the context for the
+ extension. This will be used to load the context from the extension directory.
+ If this property is not used but a `GEMINI.md` file is present in your
+ extension directory, then that file will be loaded.
+- `excludeTools`: An array of tool names to exclude from the model. You can also
+ specify command-specific restrictions for tools that support it, like the
+ `run_shell_command` tool. For example,
+ `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
+ command. Note that this differs from the MCP server `excludeTools`
+ functionality, which can be listed in the MCP server config.
+
+When Gemini CLI starts, it loads all the extensions and merges their
+configurations. If there are any conflicts, the workspace configuration takes
+precedence.
### Extension settings
diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md
index 452edb378d..9b7226ac05 100644
--- a/docs/hooks/reference.md
+++ b/docs/hooks/reference.md
@@ -98,6 +98,8 @@ and parameter rewriting.
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
+ - `original_request_name`: (`string`) The original name of the tool being
+ called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
@@ -120,12 +122,18 @@ hiding sensitive output from the agent.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
+ - `original_request_name`: (`string`) The original name of the tool being
+ called, if this is a tail tool call.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
+ - `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`)
+ A request to execute another tool immediately after this one. The result of
+ this "tail call" will replace the original tool's response. Ideal for
+ programmatic tool routing.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index b069b03fc2..ba22eb802f 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -142,6 +142,11 @@ their corresponding top-level category object in your `settings.json` file.
request" errors.
- **Default:** `false`
+- **`general.maxAttempts`** (number):
+ - **Description:** Maximum number of attempts for requests to the main chat
+ model. Cannot exceed 10.
+ - **Default:** `10`
+
- **`general.debugKeystrokeLogging`** (boolean):
- **Description:** Enable debug logging of keystrokes to the console.
- **Default:** `false`
@@ -868,6 +873,14 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
+- **`security.enableConseca`** (boolean):
+ - **Description:** Enable the context-aware security checker. This feature
+ uses an LLM to dynamically generate and enforce security policies for tool
+ use based on your prompt, providing an additional layer of protection
+ against unintended actions.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
#### `advanced`
- **`advanced.autoConfigureMemory`** (boolean):
diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md
index 09726432fd..22ce748918 100644
--- a/docs/tools/mcp-server.md
+++ b/docs/tools/mcp-server.md
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
- **`args`** (string[]): Command-line arguments for Stdio transport
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
- **`env`** (object): Environment variables for the server process. Values can
- reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
+ reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
+ platforms), or `%VAR_NAME%` (Windows only).
- **`cwd`** (string): Working directory for Stdio transport
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
10 minutes)
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
Service Account to impersonate. Used with
`authProviderType: 'service_account_impersonation'`.
+### Environment variable expansion
+
+Gemini CLI automatically expands environment variables in the `env` block of
+your MCP server configuration. This allows you to securely reference variables
+defined in your shell or environment without hardcoding sensitive information
+directly in your `settings.json` file.
+
+The expansion utility supports:
+
+- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
+ all platforms)
+- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
+
+If a variable is not defined in the current environment, it resolves to an empty
+string.
+
+**Example:**
+
+```json
+"env": {
+ "API_KEY": "$MY_EXTERNAL_TOKEN",
+ "LOG_LEVEL": "$LOG_LEVEL",
+ "TEMP_DIR": "%TEMP%"
+}
+```
+
+### Security and environment sanitization
+
+To protect your credentials, Gemini CLI performs environment sanitization when
+spawning MCP server processes.
+
+#### Automatic redaction
+
+By default, the CLI redacts sensitive environment variables from the base
+environment (inherited from the host process) to prevent unintended exposure to
+third-party MCP servers. This includes:
+
+- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
+- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
+ `*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
+- Certificates and private key patterns.
+
+#### Explicit overrides
+
+If an environment variable must be passed to an MCP server, you must explicitly
+state it in the `env` property of the server configuration in `settings.json`.
+Explicitly defined variables (including those from extensions) are trusted and
+are **not** subjected to the automatic redaction process.
+
+This follows the security principle that if a variable is explicitly configured
+by the user for a specific server, it constitutes informed consent to share that
+specific data with that server.
+
+> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
+> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
+> securely pull the value from your host environment at runtime.
+
### OAuth support for remote MCP servers
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
@@ -738,7 +796,9 @@ The MCP integration tracks several states:
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
cautiously and only for servers you completely control
- **Access tokens:** Be security-aware when configuring environment variables
- containing API keys or tokens
+ containing API keys or tokens. See
+ [Security and environment sanitization](#security-and-environment-sanitization)
+ for details on how Gemini CLI protects your credentials.
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
available within the sandbox environment
- **Private data:** Using broadly scoped personal access tokens can lead to
diff --git a/evals/frugalReads.eval.ts b/evals/frugalReads.eval.ts
index 55a73f85e2..47578039a6 100644
--- a/evals/frugalReads.eval.ts
+++ b/evals/frugalReads.eval.ts
@@ -78,22 +78,23 @@ describe('Frugal reads eval', () => {
).toBe(true);
let totalLinesRead = 0;
- const readRanges: { offset: number; limit: number }[] = [];
+ const readRanges: { start_line: number; end_line: number }[] = [];
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
- args.limit,
- 'Agent read the entire file (missing limit) instead of using ranged read',
+ args.end_line,
+ 'Agent read the entire file (missing end_line) instead of using ranged read',
).toBeDefined();
- const limit = args.limit;
- const offset = args.offset ?? 0;
- totalLinesRead += limit;
- readRanges.push({ offset, limit });
+ const end_line = args.end_line;
+ const start_line = args.start_line ?? 1;
+ const linesRead = end_line - start_line + 1;
+ totalLinesRead += linesRead;
+ readRanges.push({ start_line, end_line });
- expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
+ expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
1001,
);
}
@@ -108,7 +109,7 @@ describe('Frugal reads eval', () => {
const errorLines = [500, 510, 520];
for (const line of errorLines) {
const covered = readRanges.some(
- (range) => line >= range.offset && line < range.offset + range.limit,
+ (range) => line >= range.start_line && line <= range.end_line,
);
expect(covered, `Agent should have read around line ${line}`).toBe(
true,
@@ -191,8 +192,8 @@ describe('Frugal reads eval', () => {
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
- args.limit,
- 'Agent should have used ranged read (limit) to save tokens',
+ args.end_line,
+ 'Agent should have used ranged read (end_line) to save tokens',
).toBeDefined();
}
},
@@ -253,7 +254,7 @@ describe('Frugal reads eval', () => {
// and just read the whole file to be efficient with tool calls.
const readEntireFile = targetFileReads.some((call) => {
const args = JSON.parse(call.toolRequest.args);
- return args.limit === undefined;
+ return args.end_line === undefined;
});
expect(
diff --git a/evals/frugalSearch.eval.ts b/evals/frugalSearch.eval.ts
index 8805a6a8ed..1c49fc2ed4 100644
--- a/evals/frugalSearch.eval.ts
+++ b/evals/frugalSearch.eval.ts
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
const args = getParams(call);
return (
args.file_path === 'src/legacy_processor.ts' &&
- (args.limit === undefined || args.limit === null)
+ (args.end_line === undefined || args.end_line === null)
);
});
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
if (
call.toolRequest.name === 'read_file' &&
args.file_path === 'src/legacy_processor.ts' &&
- args.limit !== undefined
+ args.end_line !== undefined
) {
return true;
}
diff --git a/evals/interactive-hang.eval.ts b/evals/interactive-hang.eval.ts
index 43b49759bb..0cf56acf98 100644
--- a/evals/interactive-hang.eval.ts
+++ b/evals/interactive-hang.eval.ts
@@ -56,7 +56,7 @@ describe('interactive_commands', () => {
const scaffoldCall = logs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
- /npm (init|create)|npx create-|yarn create|pnpm create/.test(
+ /npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test(
l.toolRequest.args,
),
);
diff --git a/integration-tests/hooks-system.tail-tool-call.responses b/integration-tests/hooks-system.tail-tool-call.responses
new file mode 100644
index 0000000000..13dc3fde4d
--- /dev/null
+++ b/integration-tests/hooks-system.tail-tool-call.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
\ No newline at end of file
diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts
index 2db1019c5f..479851957b 100644
--- a/integration-tests/hooks-system.test.ts
+++ b/integration-tests/hooks-system.test.ts
@@ -286,6 +286,113 @@ describe('Hooks System Integration', () => {
});
});
+ describe('Command Hooks - Tail Tool Calls', () => {
+ it('should execute a tail tool call from AfterTool hooks and replace original response', async () => {
+ // Create a script that acts as the hook.
+ // It will trigger on "read_file" and issue a tail call to "write_file".
+ rig.setup('should execute a tail tool call from AfterTool hooks', {
+ fakeResponsesPath: join(
+ import.meta.dirname,
+ 'hooks-system.tail-tool-call.responses',
+ ),
+ });
+
+ const hookOutput = {
+ decision: 'allow',
+ hookSpecificOutput: {
+ hookEventName: 'AfterTool',
+ tailToolCallRequest: {
+ name: 'write_file',
+ args: {
+ file_path: 'tail-called-file.txt',
+ content: 'Content from tail call',
+ },
+ },
+ },
+ };
+
+ const hookScript = `console.log(JSON.stringify(${JSON.stringify(
+ hookOutput,
+ )})); process.exit(0);`;
+
+ const scriptPath = join(rig.testDir!, 'tail_call_hook.js');
+ writeFileSync(scriptPath, hookScript);
+ const commandPath = scriptPath.replace(/\\/g, '/');
+
+ rig.setup('should execute a tail tool call from AfterTool hooks', {
+ fakeResponsesPath: join(
+ import.meta.dirname,
+ 'hooks-system.tail-tool-call.responses',
+ ),
+ settings: {
+ hooksConfig: {
+ enabled: true,
+ },
+ hooks: {
+ AfterTool: [
+ {
+ matcher: 'read_file',
+ hooks: [
+ {
+ type: 'command',
+ command: `node "${commandPath}"`,
+ timeout: 5000,
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+
+ // Create a test file to trigger the read_file tool
+ rig.createFile('original.txt', 'Original content');
+
+ const cliOutput = await rig.run({
+ args: 'Read original.txt', // Fake responses should trigger read_file on this
+ });
+
+ // 1. Verify that write_file was called (as a tail call replacing read_file)
+ // Since read_file was replaced before finalizing, it will not appear in the tool logs.
+ const foundWriteFile = await rig.waitForToolCall('write_file');
+ expect(foundWriteFile).toBeTruthy();
+
+ // Ensure hook logs are flushed and the final LLM response is received.
+ // The mock LLM is configured to respond with "Tail call completed successfully."
+ expect(cliOutput).toContain('Tail call completed successfully.');
+
+ // Ensure telemetry is written to disk
+ await rig.waitForTelemetryReady();
+
+ // Read hook logs to debug
+ const hookLogs = rig.readHookLogs();
+ const relevantHookLog = hookLogs.find(
+ (l) => l.hookCall.hook_event_name === 'AfterTool',
+ );
+
+ expect(relevantHookLog).toBeDefined();
+
+ // 2. Verify write_file was executed.
+ // In non-interactive mode, the CLI deduplicates tool execution logs by callId.
+ // Since a tail call reuses the original callId, "Tool: write_file" is not printed.
+ // Instead, we verify the side-effect (file creation) and the telemetry log.
+
+ // 3. Verify the tail-called tool actually wrote the file
+ const modifiedContent = rig.readFile('tail-called-file.txt');
+ expect(modifiedContent).toBe('Content from tail call');
+
+ // 4. Verify telemetry for the final tool call.
+ // The original 'read_file' call is replaced, so only 'write_file' is finalized and logged.
+ const toolLogs = rig.readToolLogs();
+ const successfulTools = toolLogs.filter((t) => t.toolRequest.success);
+ expect(
+ successfulTools.some((t) => t.toolRequest.name === 'write_file'),
+ ).toBeTruthy();
+ // The original request name should be preserved in the log payload if possible,
+ // but the executed tool name is 'write_file'.
+ });
+ });
+
describe('BeforeModel Hooks - LLM Request Modification', () => {
it('should modify LLM requests with BeforeModel hooks', async () => {
// Create a hook script that replaces the LLM request with a modified version
diff --git a/package-lock.json b/package-lock.json
index 0bfce7daa0..f58bb26483 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -997,9 +997,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
- "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1029,9 +1029,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.12.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
- "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1039,13 +1039,13 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.20.1",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz",
- "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==",
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^2.1.6",
+ "@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
},
@@ -1054,19 +1054,22 @@
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.2.3",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
- "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==",
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
- "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1077,20 +1080,20 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
- "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
+ "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.3",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -1114,9 +1117,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.29.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
- "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
+ "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1127,9 +1130,9 @@
}
},
"node_modules/@eslint/object-schema": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
- "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1137,32 +1140,19 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
- "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.15.2",
+ "@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
- "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
- "version": "0.15.2",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
- "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@types/json-schema": "^7.0.15"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
"node_modules/@google-cloud/common": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz",
@@ -1343,9 +1333,9 @@
}
},
"node_modules/@google-cloud/storage": {
- "version": "7.17.0",
- "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz",
- "integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==",
+ "version": "7.19.0",
+ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
+ "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/paginator": "^5.0.0",
@@ -1354,7 +1344,7 @@
"abort-controller": "^3.0.0",
"async-retry": "^1.3.3",
"duplexify": "^4.1.3",
- "fast-xml-parser": "^4.4.1",
+ "fast-xml-parser": "^5.3.4",
"gaxios": "^6.0.2",
"google-auth-library": "^9.6.3",
"html-entities": "^2.5.2",
@@ -1761,27 +1751,6 @@
}
}
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
- "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2165,9 +2134,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -3445,9 +3414,9 @@
}
},
"node_modules/@secretlint/config-loader/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4353,21 +4322,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz",
- "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
+ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/type-utils": "8.35.0",
- "@typescript-eslint/utils": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/type-utils": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4377,9 +4345,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.35.0",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "@typescript-eslint/parser": "^8.56.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -4393,17 +4361,17 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz",
- "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
+ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/typescript-estree": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4413,20 +4381,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz",
- "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
+ "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.35.0",
- "@typescript-eslint/types": "^8.35.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.56.1",
+ "@typescript-eslint/types": "^8.56.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4436,18 +4404,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz",
- "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
+ "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0"
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4458,9 +4426,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz",
- "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
+ "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4471,20 +4439,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz",
- "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
+ "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/typescript-estree": "8.35.0",
- "@typescript-eslint/utils": "8.35.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4494,14 +4463,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz",
- "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
+ "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4513,22 +4482,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz",
- "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
+ "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.35.0",
- "@typescript-eslint/tsconfig-utils": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/visitor-keys": "8.35.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.56.1",
+ "@typescript-eslint/tsconfig-utils": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4538,46 +4506,59 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz",
- "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
+ "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.35.0",
- "@typescript-eslint/types": "8.35.0",
- "@typescript-eslint/typescript-estree": "8.35.0"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4587,19 +4568,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz",
- "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
+ "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.0",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.56.1",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4609,6 +4590,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz",
@@ -4685,174 +4679,6 @@
}
}
},
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
- "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.47.0",
- "@typescript-eslint/types": "^8.47.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
- "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/visitor-keys": "8.47.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
- "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
- "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
- "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/project-service": "8.47.0",
- "@typescript-eslint/tsconfig-utils": "8.47.0",
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/visitor-keys": "8.47.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
- "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.47.0",
- "@typescript-eslint/types": "8.47.0",
- "@typescript-eslint/typescript-estree": "8.47.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.47.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
- "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.47.0",
- "eslint-visitor-keys": "^4.2.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -4962,17 +4788,17 @@
}
},
"node_modules/@vscode/vsce": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
- "integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz",
+ "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.1.0",
- "@secretlint/node": "^10.1.1",
- "@secretlint/secretlint-formatter-sarif": "^10.1.1",
- "@secretlint/secretlint-rule-no-dotenv": "^10.1.1",
- "@secretlint/secretlint-rule-preset-recommend": "^10.1.1",
+ "@secretlint/node": "^10.1.2",
+ "@secretlint/secretlint-formatter-sarif": "^10.1.2",
+ "@secretlint/secretlint-rule-no-dotenv": "^10.1.2",
+ "@secretlint/secretlint-rule-preset-recommend": "^10.1.2",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
@@ -4989,7 +4815,7 @@
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
- "secretlint": "^10.1.1",
+ "secretlint": "^10.1.2",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
@@ -5153,6 +4979,70 @@
"win32"
]
},
+ "node_modules/@vscode/vsce/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -5359,9 +5249,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5393,9 +5283,9 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -7185,9 +7075,9 @@
}
},
"node_modules/depcheck/node_modules/minimatch": {
- "version": "7.4.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz",
- "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==",
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.7.tgz",
+ "integrity": "sha512-t3SrsBRdssa8F/nFEadAxveFpnbhlbq7FiizzOMqx69w9EbmNEzcKiPkc60udvrOkWsTMm6jmnQP1c5rbdVfSA==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -7432,9 +7322,36 @@
}
},
"node_modules/dotenv": {
- "version": "17.1.0",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz",
- "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==",
+ "version": "17.2.4",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
+ "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "12.0.3",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
+ "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dotenv": "^16.4.5"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand/node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -7858,25 +7775,24 @@
}
},
"node_modules/eslint": {
- "version": "9.29.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz",
- "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
+ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.20.1",
- "@eslint/config-helpers": "^0.2.1",
- "@eslint/core": "^0.14.0",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.29.0",
- "@eslint/plugin-kit": "^0.3.1",
+ "@eslint/js": "9.39.3",
+ "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
- "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
@@ -8570,9 +8486,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
- "version": "4.5.3",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
- "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz",
+ "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==",
"funding": [
{
"type": "github",
@@ -8581,7 +8497,7 @@
],
"license": "MIT",
"dependencies": {
- "strnum": "^1.1.1"
+ "strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -9195,16 +9111,37 @@
"tslib": "2"
}
},
- "node_modules/glob/node_modules/minimatch": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
- "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
- "license": "BlueOak-1.0.0",
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "license": "MIT",
"dependencies": {
- "@isaacs/brace-expansion": "^5.0.0"
+ "balanced-match": "^4.0.2"
},
"engines": {
- "node": "20 || >=22"
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -9516,13 +9453,6 @@
"node": ">=10"
}
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/graphql": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
@@ -9675,9 +9605,9 @@
}
},
"node_modules/hono": {
- "version": "4.11.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
- "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
+ "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -10945,6 +10875,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
@@ -11838,9 +11769,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
+ "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -14385,9 +14316,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -15179,9 +15110,9 @@
}
},
"node_modules/strnum": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
- "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"funding": [
{
"type": "github",
@@ -15333,9 +15264,9 @@
}
},
"node_modules/systeminformation": {
- "version": "5.30.2",
- "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.2.tgz",
- "integrity": "sha512-Rrt5oFTWluUVuPlbtn3o9ja+nvjdF3Um4DG0KxqfYvpzcx7Q9plZBTjJiJy9mAouua4+OI7IUGBaG9Zyt9NgxA==",
+ "version": "5.31.1",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.1.tgz",
+ "integrity": "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA==",
"license": "MIT",
"os": [
"darwin",
@@ -15376,9 +15307,9 @@
}
},
"node_modules/table/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15588,41 +15519,54 @@
}
},
"node_modules/test-exclude": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
- "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
- "minimatch": "^9.0.4"
+ "minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
+ "node_modules/test-exclude/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/test-exclude/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -15871,9 +15815,9 @@
}
},
"node_modules/ts-api-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
- "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16130,15 +16074,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.35.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz",
- "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
+ "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.35.0",
- "@typescript-eslint/parser": "8.35.0",
- "@typescript-eslint/utils": "8.35.0"
+ "@typescript-eslint/eslint-plugin": "8.56.1",
+ "@typescript-eslint/parser": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -16148,8 +16093,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
@@ -17400,6 +17345,8 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
+ "dotenv": "^17.2.4",
+ "dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
@@ -17419,6 +17366,7 @@
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
+ "strip-json-comments": "^3.1.1",
"systeminformation": "^5.25.11",
"tree-sitter-bash": "^0.25.0",
"undici": "^7.10.0",
@@ -17474,9 +17422,9 @@
}
},
"packages/core/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -17581,6 +17529,12 @@
"node": ">= 4"
}
},
+ "packages/core/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"packages/core/node_modules/mime": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts
index b0522a945f..e2287a2562 100644
--- a/packages/a2a-server/src/agent/executor.ts
+++ b/packages/a2a-server/src/agent/executor.ts
@@ -29,6 +29,8 @@ import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
+ getContextIdFromMetadata,
+ getAgentSettingsFromMetadata,
} from '../types.js';
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
@@ -117,8 +119,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (metadata['_contextId'] as string) || sdkTask.contextId;
+ getContextIdFromMetadata(metadata) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
contextId,
@@ -141,8 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const agentSettings = agentSettingsInput || ({} as AgentSettings);
+ const agentSettings: AgentSettings = agentSettingsInput || {
+ kind: CoderAgentEvent.StateAgentSettingsEvent,
+ workspacePath: process.cwd(),
+ };
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
@@ -292,8 +295,7 @@ export class CoderAgentExecutor implements AgentExecutor {
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (sdkTask?.metadata?.['_contextId'] as string) ||
+ getContextIdFromMetadata(sdkTask?.metadata) ||
uuidv4();
logger.info(
@@ -388,10 +390,7 @@ export class CoderAgentExecutor implements AgentExecutor {
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const agentSettings = userMessage.metadata?.[
- 'coderAgent'
- ] as AgentSettings;
+ const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
try {
wrapper = await this.createTask(
taskId,
diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts
index 39cfe5eb74..81987a780b 100644
--- a/packages/a2a-server/src/agent/task.test.ts
+++ b/packages/a2a-server/src/agent/task.test.ts
@@ -513,7 +513,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
- confirmationDetails: { onConfirm: onConfirmSpy },
+ confirmationDetails: {
+ type: 'edit',
+ onConfirm: onConfirmSpy,
+ },
},
] as unknown as ToolCall[];
@@ -533,7 +536,10 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
- confirmationDetails: { onConfirm: onConfirmSpy },
+ confirmationDetails: {
+ type: 'edit',
+ onConfirm: onConfirmSpy,
+ },
},
] as unknown as ToolCall[];
diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts
index bc8cd121a9..c91ef72781 100644
--- a/packages/a2a-server/src/agent/task.ts
+++ b/packages/a2a-server/src/agent/task.ts
@@ -59,6 +59,33 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
type UnionKeys = T extends T ? keyof T : never;
+type ConfirmationType = ToolCallConfirmationDetails['type'];
+
+const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
+ 'edit',
+ 'exec',
+ 'mcp',
+ 'info',
+ 'ask_user',
+ 'exit_plan_mode',
+] as const;
+
+function isToolCallConfirmationDetails(
+ value: unknown,
+): value is ToolCallConfirmationDetails {
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ !('onConfirm' in value) ||
+ typeof value.onConfirm !== 'function' ||
+ !('type' in value) ||
+ typeof value.type !== 'string'
+ ) {
+ return false;
+ }
+ return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
+}
+
export class Task {
id: string;
contextId: string;
@@ -376,11 +403,10 @@ export class Task {
}
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
- this.pendingToolConfirmationDetails.set(
- tc.request.callId,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- tc.confirmationDetails as ToolCallConfirmationDetails,
- );
+ const details = tc.confirmationDetails;
+ if (isToolCallConfirmationDetails(details)) {
+ this.pendingToolConfirmationDetails.set(tc.request.callId, details);
+ }
}
// Only send an update if the status has actually changed.
@@ -412,11 +438,12 @@ export class Task {
);
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
- // eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
- (tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
- ToolConfirmationOutcome.ProceedOnce,
- );
- this.pendingToolConfirmationDetails.delete(tc.request.callId);
+ const details = tc.confirmationDetails;
+ if (isToolCallConfirmationDetails(details)) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
+ this.pendingToolConfirmationDetails.delete(tc.request.callId);
+ }
}
});
return;
@@ -466,15 +493,13 @@ export class Task {
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys,
>(from: T, ...fields: K[]): Partial {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const ret = {} as Pick;
+ const ret: Partial = {};
for (const field of fields) {
- if (field in from) {
+ if (field in from && from[field] !== undefined) {
ret[field] = from[field];
}
}
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- return ret as Partial;
+ return ret;
}
private toolStatusMessage(
@@ -485,8 +510,11 @@ export class Task {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necessary
- // properties/avoid methods causing circular reference errors)
- const serializableToolCall: Partial = this._pickFields(
+ // properties/avoid methods causing circular reference errors).
+ // Type allows tool to be Partial for serialization.
+ const serializableToolCall: Partial> & {
+ tool?: Partial;
+ } = this._pickFields(
tc,
'request',
'status',
@@ -496,8 +524,7 @@ export class Task {
);
if (tc.tool) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- serializableToolCall.tool = this._pickFields(
+ const toolFields = this._pickFields(
tc.tool,
'name',
'displayName',
@@ -507,7 +534,8 @@ export class Task {
'canUpdateOutput',
'schema',
'parameterSchema',
- ) as AnyDeclarativeTool;
+ );
+ serializableToolCall.tool = toolFields;
}
messageParts.push({
@@ -530,8 +558,15 @@ export class Task {
old_string: string,
new_string: string,
): Promise {
+ // Validate path to prevent path traversal vulnerabilities
+ const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
+ const pathError = this.config.validatePathAccess(resolvedPath, 'read');
+ if (pathError) {
+ throw new Error(`Path validation failed: ${pathError}`);
+ }
+
try {
- const currentContent = await fs.readFile(file_path, 'utf8');
+ const currentContent = await fs.readFile(resolvedPath, 'utf8');
return this._applyReplacement(
currentContent,
old_string,
@@ -625,15 +660,32 @@ export class Task {
request.args['old_string'] &&
request.args['new_string']
) {
- const newContent = await this.getProposedContent(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- request.args['file_path'] as string,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- request.args['old_string'] as string,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- request.args['new_string'] as string,
- );
- return { ...request, args: { ...request.args, newContent } };
+ const filePath = request.args['file_path'];
+ const oldString = request.args['old_string'];
+ const newString = request.args['new_string'];
+ if (
+ typeof filePath === 'string' &&
+ typeof oldString === 'string' &&
+ typeof newString === 'string'
+ ) {
+ // Resolve and validate path to prevent path traversal (user-controlled file_path).
+ const resolvedPath = path.resolve(
+ this.config.getTargetDir(),
+ filePath,
+ );
+ const pathError = this.config.validatePathAccess(
+ resolvedPath,
+ 'read',
+ );
+ if (!pathError) {
+ const newContent = await this.getProposedContent(
+ resolvedPath,
+ oldString,
+ newString,
+ );
+ return { ...request, args: { ...request.args, newContent } };
+ }
+ }
}
return request;
}),
@@ -725,10 +777,17 @@ export class Task {
break;
case GeminiEventType.Error:
default: {
- // Block scope for lexical declaration
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
- const errorMessage = errorEvent.value?.error
+ // Use type guard instead of unsafe type assertion
+ let errorEvent: ServerGeminiErrorEvent | undefined;
+ if (
+ event.type === GeminiEventType.Error &&
+ event.value &&
+ typeof event.value === 'object' &&
+ 'error' in event.value
+ ) {
+ errorEvent = event;
+ }
+ const errorMessage = errorEvent?.value?.error
? getErrorMessage(errorEvent.value.error)
: 'Unknown error from LLM stream';
logger.error(
@@ -737,7 +796,7 @@ export class Task {
);
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
- if (errorEvent.value?.error) {
+ if (errorEvent?.value?.error) {
errMessage = parseAndFormatApiError(errorEvent.value.error);
}
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
@@ -814,12 +873,11 @@ export class Task {
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
- const payload = part.data['newContent']
- ? ({
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- newContent: part.data['newContent'] as string,
- } as ToolConfirmationPayload)
- : undefined;
+ const newContent = part.data['newContent'];
+ const payload =
+ typeof newContent === 'string'
+ ? ({ newContent } as ToolConfirmationPayload)
+ : undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
await confirmationDetails.onConfirm(confirmationOutcome, payload);
diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts
index 0ed6a67994..bce233c9dd 100644
--- a/packages/a2a-server/src/types.ts
+++ b/packages/a2a-server/src/types.ts
@@ -122,11 +122,60 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
export const METADATA_KEY = '__persistedState';
+function isAgentSettings(value: unknown): value is AgentSettings {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'kind' in value &&
+ value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
+ 'workspacePath' in value &&
+ typeof value.workspacePath === 'string'
+ );
+}
+
+function isPersistedStateMetadata(
+ value: unknown,
+): value is PersistedStateMetadata {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ '_agentSettings' in value &&
+ '_taskState' in value &&
+ isAgentSettings(value._agentSettings)
+ );
+}
+
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
+ const state = metadata?.[METADATA_KEY];
+ if (isPersistedStateMetadata(state)) {
+ return state;
+ }
+ return undefined;
+}
+
+export function getContextIdFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): string | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const contextId = metadata['_contextId'];
+ return typeof contextId === 'string' ? contextId : undefined;
+}
+
+export function getAgentSettingsFromMetadata(
+ metadata: PersistedTaskMetadata | undefined,
+): AgentSettings | undefined {
+ if (!metadata) {
+ return undefined;
+ }
+ const coderAgent = metadata['coderAgent'];
+ if (isAgentSettings(coderAgent)) {
+ return coderAgent;
+ }
+ return undefined;
}
export function setPersistedState(
diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts
index 86d0d4a4bd..9cb0657c7a 100644
--- a/packages/a2a-server/src/utils/testing_utils.ts
+++ b/packages/a2a-server/src/utils/testing_utils.ts
@@ -71,6 +71,7 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getGitService: vi.fn(),
+ validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 50e0c2059d..3e0fd4b913 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -878,6 +878,7 @@ export async function loadCliConfig(
agents: refreshedSettings.merged.agents,
};
},
+ enableConseca: settings.security?.enableConseca,
});
}
diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts
index 6847865434..1d7573337e 100644
--- a/packages/cli/src/config/policy-engine.integration.test.ts
+++ b/packages/cli/src/config/policy-engine.integration.test.ts
@@ -352,6 +352,38 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.DENY);
});
+ it('should correctly match tool annotations', async () => {
+ const settings: Settings = {};
+
+ const config = await createPolicyEngineConfig(
+ settings,
+ ApprovalMode.DEFAULT,
+ );
+
+ // Add a manual rule with annotations to the config
+ config.rules = config.rules || [];
+ config.rules.push({
+ toolAnnotations: { readOnlyHint: true },
+ decision: PolicyDecision.ALLOW,
+ priority: 10,
+ });
+
+ const engine = new PolicyEngine(config);
+
+ // A tool with readOnlyHint=true should be ALLOWED
+ const roCall = { name: 'some_tool', args: {} };
+ const roMeta = { readOnlyHint: true };
+ expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
+ PolicyDecision.ALLOW,
+ );
+
+ // A tool without the hint (or with false) should follow default decision (ASK_USER)
+ const rwMeta = { readOnlyHint: false };
+ expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
+ PolicyDecision.ASK_USER,
+ );
+ });
+
describe.each(['write_file', 'replace'])(
'Plan Mode policy for %s',
(toolName) => {
diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts
index a0e687388d..1a773d56a7 100644
--- a/packages/cli/src/config/policy.test.ts
+++ b/packages/cli/src/config/policy.test.ts
@@ -142,4 +142,48 @@ describe('resolveWorkspacePolicyState', () => {
expect.stringContaining('Automatically accepting and loading'),
);
});
+
+ it('should not return workspace policies if cwd is the home directory', async () => {
+ const policiesDir = path.join(tempDir, '.gemini', 'policies');
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+
+ // Run from HOME directory (tempDir is mocked as HOME in beforeEach)
+ const result = await resolveWorkspacePolicyState({
+ cwd: tempDir,
+ trustedFolder: true,
+ interactive: true,
+ });
+
+ expect(result.workspacePoliciesDir).toBeUndefined();
+ expect(result.policyUpdateConfirmationRequest).toBeUndefined();
+ });
+
+ it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
+ const policiesDir = path.join(tempDir, '.gemini', 'policies');
+ fs.mkdirSync(policiesDir, { recursive: true });
+ fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+
+ // Create a symlink to the home directory
+ const symlinkDir = path.join(
+ os.tmpdir(),
+ `gemini-cli-symlink-${Date.now()}`,
+ );
+ fs.symlinkSync(tempDir, symlinkDir, 'dir');
+
+ try {
+ // Run from symlink to HOME directory
+ const result = await resolveWorkspacePolicyState({
+ cwd: symlinkDir,
+ trustedFolder: true,
+ interactive: true,
+ });
+
+ expect(result.workspacePoliciesDir).toBeUndefined();
+ expect(result.policyUpdateConfirmationRequest).toBeUndefined();
+ } finally {
+ // Clean up symlink
+ fs.unlinkSync(symlinkDir);
+ }
+ });
});
diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts
index ef6164efb7..3b85d0b4b6 100644
--- a/packages/cli/src/config/policy.ts
+++ b/packages/cli/src/config/policy.ts
@@ -67,9 +67,15 @@ export async function resolveWorkspacePolicyState(options: {
| undefined;
if (trustedFolder) {
- const potentialWorkspacePoliciesDir = new Storage(
- cwd,
- ).getWorkspacePoliciesDir();
+ const storage = new Storage(cwd);
+
+ // If we are in the home directory (or rather, our target Gemini dir is the global one),
+ // don't treat it as a workspace to avoid loading global policies twice.
+ if (storage.isWorkspaceHomeDir()) {
+ return { workspacePoliciesDir: undefined };
+ }
+
+ const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity(
'workspace',
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index 7b341b3ee0..6b2f18bb58 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -79,6 +79,7 @@ import {
import {
FatalConfigError,
GEMINI_DIR,
+ Storage,
type MCPServerConfig,
} from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -126,6 +127,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal();
const os = await import('node:os');
+ const pathMod = await import('node:path');
+ const fsMod = await import('node:fs');
+
+ // Helper to resolve paths using the test's mocked environment
+ const testResolve = (p: string | undefined) => {
+ if (!p) return '';
+ try {
+ // Use the mocked fs.realpathSync if available, otherwise fallback
+ return fsMod.realpathSync(pathMod.resolve(p));
+ } catch {
+ return pathMod.resolve(p);
+ }
+ };
+
+ // Create a smarter mock for isWorkspaceHomeDir
+ vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
+ function (this: Storage) {
+ const target = testResolve(pathMod.dirname(this.getGeminiDir()));
+ // Pick up the mocked home directory specifically from the 'os' mock
+ const home = testResolve(os.homedir());
+ return actual.normalizePath(target) === actual.normalizePath(home);
+ },
+ );
+
return {
...actual,
coreEvents: mockCoreEvents,
@@ -1491,20 +1516,29 @@ describe('Settings Loading and Merging', () => {
return pStr;
});
+ // Force the storage check to return true for this specific test
+ const isWorkspaceHomeDirSpy = vi
+ .spyOn(Storage.prototype, 'isWorkspaceHomeDir')
+ .mockReturnValue(true);
+
(mockFsExistsSync as Mock).mockImplementation(
(p: string) =>
// Only return true for workspace settings path to see if it gets loaded
p === mockWorkspaceSettingsPath,
);
- const settings = loadSettings(mockSymlinkDir);
+ try {
+ const settings = loadSettings(mockSymlinkDir);
- // Verify that even though the file exists, it was NOT loaded because realpath matched home
- expect(fs.readFileSync).not.toHaveBeenCalledWith(
- mockWorkspaceSettingsPath,
- 'utf-8',
- );
- expect(settings.workspace.settings).toEqual({});
+ // Verify that even though the file exists, it was NOT loaded because realpath matched home
+ expect(fs.readFileSync).not.toHaveBeenCalledWith(
+ mockWorkspaceSettingsPath,
+ 'utf-8',
+ );
+ expect(settings.workspace.settings).toEqual({});
+ } finally {
+ isWorkspaceHomeDirSpy.mockRestore();
+ }
});
});
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 2f6f2f7450..c3f7c447eb 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -637,24 +637,8 @@ export function loadSettings(
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
- // Resolve paths to their canonical representation to handle symlinks
- const resolvedWorkspaceDir = path.resolve(workspaceDir);
- const resolvedHomeDir = path.resolve(homedir());
-
- let realWorkspaceDir = resolvedWorkspaceDir;
- try {
- // fs.realpathSync gets the "true" path, resolving any symlinks
- realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
- } catch (_e) {
- // This is okay. The path might not exist yet, and that's a valid state.
- }
-
- // We expect homedir to always exist and be resolvable.
- const realHomeDir = fs.realpathSync(resolvedHomeDir);
-
- const workspaceSettingsPath = new Storage(
- workspaceDir,
- ).getWorkspaceSettingsPath();
+ const storage = new Storage(workspaceDir);
+ const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
@@ -712,7 +696,7 @@ export function loadSettings(
settings: {} as Settings,
rawJson: undefined,
};
- if (realWorkspaceDir !== realHomeDir) {
+ if (!storage.isWorkspaceHomeDir()) {
workspaceResult = load(workspaceSettingsPath);
}
@@ -800,11 +784,11 @@ export function loadSettings(
readOnly: false,
},
{
- path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
+ path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
settings: workspaceSettings,
originalSettings: workspaceOriginalSettings,
rawJson: workspaceResult.rawJson,
- readOnly: realWorkspaceDir === realHomeDir,
+ readOnly: storage.isWorkspaceHomeDir(),
},
isTrusted,
settingsErrors,
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 17c51d4e21..5c04cea9b5 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -297,6 +297,16 @@ const SETTINGS_SCHEMA = {
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
},
+ maxAttempts: {
+ type: 'number',
+ label: 'Max Chat Model Attempts',
+ category: 'General',
+ requiresRestart: false,
+ default: 10,
+ description:
+ 'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
+ showInDialog: true,
+ },
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -1483,6 +1493,16 @@ const SETTINGS_SCHEMA = {
},
},
},
+ enableConseca: {
+ type: 'boolean',
+ label: 'Enable Context-Aware Security',
+ category: 'Security',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.',
+ showInDialog: true,
+ },
},
},
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index 143e8319a3..cc06cb141d 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -182,7 +182,7 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toContain('15%');
+ expect(lastFrame()).toContain('85%');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
diff --git a/packages/cli/src/ui/components/QuotaDisplay.test.tsx b/packages/cli/src/ui/components/QuotaDisplay.test.tsx
index 150eb7097c..5a8b8c5bf8 100644
--- a/packages/cli/src/ui/components/QuotaDisplay.test.tsx
+++ b/packages/cli/src/ui/components/QuotaDisplay.test.tsx
@@ -5,10 +5,20 @@
*/
import { render } from '../../test-utils/render.js';
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
+ beforeEach(() => {
+ vi.stubEnv('TZ', 'America/Los_Angeles');
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-03-02T20:29:00.000Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllEnvs();
+ });
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
,
@@ -36,7 +46,7 @@ describe('QuotaDisplay', () => {
unmount();
});
- it('should not render when usage > 20%', async () => {
+ it('should not render when usage < 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
,
);
@@ -45,7 +55,7 @@ describe('QuotaDisplay', () => {
unmount();
});
- it('should render yellow when usage < 20%', async () => {
+ it('should render warning when used >= 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
,
);
@@ -54,7 +64,7 @@ describe('QuotaDisplay', () => {
unmount();
});
- it('should render red when usage < 5%', async () => {
+ it('should render critical when used >= 95%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
,
);
diff --git a/packages/cli/src/ui/components/QuotaDisplay.tsx b/packages/cli/src/ui/components/QuotaDisplay.tsx
index d20291580a..f4ee143f2d 100644
--- a/packages/cli/src/ui/components/QuotaDisplay.tsx
+++ b/packages/cli/src/ui/components/QuotaDisplay.tsx
@@ -7,9 +7,9 @@
import type React from 'react';
import { Text } from 'ink';
import {
- getStatusColor,
- QUOTA_THRESHOLD_HIGH,
- QUOTA_THRESHOLD_MEDIUM,
+ getUsedStatusColor,
+ QUOTA_USED_WARNING_THRESHOLD,
+ QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { formatResetTime } from '../utils/formatters.js';
@@ -30,26 +30,24 @@ export const QuotaDisplay: React.FC = ({
return null;
}
- const percentage = (remaining / limit) * 100;
+ const usedPercentage = 100 - (remaining / limit) * 100;
- if (percentage > QUOTA_THRESHOLD_HIGH) {
+ if (usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
return null;
}
- const color = getStatusColor(percentage, {
- green: QUOTA_THRESHOLD_HIGH,
- yellow: QUOTA_THRESHOLD_MEDIUM,
+ const color = getUsedStatusColor(usedPercentage, {
+ warning: QUOTA_USED_WARNING_THRESHOLD,
+ critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
- const resetInfo =
- !terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
-
if (remaining === 0) {
+ const resetMsg = resetTime
+ ? `, resets in ${formatResetTime(resetTime, true)}`
+ : '';
return (
- {terse
- ? 'Limit reached'
- : `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
+ {terse ? 'Limit reached' : `Limit reached${resetMsg}`}
);
}
@@ -57,8 +55,10 @@ export const QuotaDisplay: React.FC = ({
return (
{terse
- ? `${percentage.toFixed(0)}%`
- : `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
+ ? `${usedPercentage.toFixed(0)}%`
+ : `${usedPercentage.toFixed(0)}% used${
+ resetTime ? ` (Limit resets in ${formatResetTime(resetTime)})` : ''
+ }`}
);
};
diff --git a/packages/cli/src/ui/components/QuotaStatsInfo.tsx b/packages/cli/src/ui/components/QuotaStatsInfo.tsx
index 22325db147..8028500233 100644
--- a/packages/cli/src/ui/components/QuotaStatsInfo.tsx
+++ b/packages/cli/src/ui/components/QuotaStatsInfo.tsx
@@ -9,9 +9,9 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatResetTime } from '../utils/formatters.js';
import {
- getStatusColor,
- QUOTA_THRESHOLD_HIGH,
- QUOTA_THRESHOLD_MEDIUM,
+ getUsedStatusColor,
+ QUOTA_USED_WARNING_THRESHOLD,
+ QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
interface QuotaStatsInfoProps {
@@ -31,19 +31,24 @@ export const QuotaStatsInfo: React.FC = ({
return null;
}
- const percentage = (remaining / limit) * 100;
- const color = getStatusColor(percentage, {
- green: QUOTA_THRESHOLD_HIGH,
- yellow: QUOTA_THRESHOLD_MEDIUM,
+ const usedPercentage = 100 - (remaining / limit) * 100;
+ const color = getUsedStatusColor(usedPercentage, {
+ warning: QUOTA_USED_WARNING_THRESHOLD,
+ critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
return (
{remaining === 0
- ? `Limit reached`
- : `${percentage.toFixed(0)}% usage remaining`}
- {resetTime && `, ${formatResetTime(resetTime)}`}
+ ? `Limit reached${
+ resetTime ? `, resets in ${formatResetTime(resetTime, true)}` : ''
+ }`
+ : `${usedPercentage.toFixed(0)}% used${
+ resetTime
+ ? ` (Limit resets in ${formatResetTime(resetTime)})`
+ : ''
+ }`}
{showDetails && (
<>
diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx
index af7e1b884d..6f7341965b 100644
--- a/packages/cli/src/ui/components/StatsDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx
@@ -465,9 +465,9 @@ describe('', () => {
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('Usage remaining');
- expect(output).toContain('75.0%');
- expect(output).toContain('resets in 1h 30m');
+ expect(output).toContain('Model usage');
+ expect(output).toContain('25% used');
+ expect(output).toContain('Limit resets in');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -521,8 +521,8 @@ describe('', () => {
await waitUntilReady();
const output = lastFrame();
- // (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
- expect(output).toContain('65% usage remaining');
+ // (1 - 710/1100) * 100 = 35.5%
+ expect(output).toContain('35% used');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
@@ -571,8 +571,8 @@ describe('', () => {
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('-'); // for requests
- expect(output).toContain('50.0%');
- expect(output).toContain('resets in 2h');
+ expect(output).toContain('50% used');
+ expect(output).toContain('Limit resets in');
expect(output).toMatchSnapshot();
vi.useRealTimers();
diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx
index d12dd4eb07..4b840eea74 100644
--- a/packages/cli/src/ui/components/StatsDisplay.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.tsx
@@ -19,6 +19,9 @@ import {
USER_AGREEMENT_RATE_MEDIUM,
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
+ getUsedStatusColor,
+ QUOTA_USED_WARNING_THRESHOLD,
+ QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
@@ -168,7 +171,26 @@ const ModelUsageTable: React.FC<{
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
- const usageLimitWidth = showQuotaColumn ? 28 : 0;
+ const usageLimitWidth = showQuotaColumn ? 85 : 0;
+
+ const renderProgressBar = (usedFraction: number, color: string) => {
+ const totalSteps = 20;
+ let filledSteps = Math.round(usedFraction * totalSteps);
+
+ // If something is used (fraction > 0) but rounds to 0, show 1 tick.
+ // If < 100% (fraction < 1) but rounds to 20, show 19 ticks.
+ if (usedFraction > 0 && usedFraction < 1) {
+ filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1);
+ }
+
+ const emptySteps = totalSteps - filledSteps;
+ return (
+
+ {'â–¬'.repeat(filledSteps)}
+ {'â–¬'.repeat(emptySteps)}
+
+ );
+ };
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
@@ -183,19 +205,21 @@ const ModelUsageTable: React.FC<{
: uncachedWidth + cachedWidth + outputTokensWidth);
const isAuto = currentModel && isAutoModel(currentModel);
- const modelUsageTitle = isAuto
- ? `${getDisplayString(currentModel)} Usage`
- : `Model Usage`;
+ const modelUsageTitle = isAuto ? (
+
+ Model Usage: {getDisplayString(currentModel)}
+
+ ) : (
+
+ Model Usage
+
+ );
return (
{/* Header */}
-
-
- {modelUsageTitle}
-
-
+ {modelUsageTitle}
{isAuto &&
@@ -270,10 +294,11 @@ const ModelUsageTable: React.FC<{
- Usage remaining
+ Model usage
)}
@@ -355,16 +380,62 @@ const ModelUsageTable: React.FC<{
- {row.bucket &&
- row.bucket.remainingFraction != null &&
- row.bucket.resetTime && (
-
- {(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
- {formatResetTime(row.bucket.resetTime)}
-
- )}
+ {row.bucket && row.bucket.remainingFraction != null && (
+
+ {(() => {
+ const actualUsedFraction = 1 - row.bucket.remainingFraction;
+ // If we have session activity but 0% server usage, show 0.1% as a hint.
+ const effectiveUsedFraction =
+ actualUsedFraction === 0 && row.isActive
+ ? 0.001
+ : actualUsedFraction;
+
+ const usedPercentage = effectiveUsedFraction * 100;
+
+ const statusColor =
+ getUsedStatusColor(usedPercentage, {
+ warning: QUOTA_USED_WARNING_THRESHOLD,
+ critical: QUOTA_USED_CRITICAL_THRESHOLD,
+ }) ??
+ (row.isActive ? theme.text.primary : theme.ui.comment);
+
+ const percentageText =
+ usedPercentage > 0 && usedPercentage < 1
+ ? `${usedPercentage.toFixed(1)}% used`
+ : `${usedPercentage.toFixed(0)}% used`;
+
+ return (
+ <>
+ {renderProgressBar(effectiveUsedFraction, statusColor)}
+
+
+ {row.bucket.remainingFraction === 0 ? (
+
+ Limit reached
+ {row.bucket.resetTime &&
+ `, resets in ${formatResetTime(row.bucket.resetTime, true)}`}
+
+ ) : (
+ <>
+ {percentageText}
+
+ {row.bucket.resetTime &&
+ formatResetTime(row.bucket.resetTime)
+ ? ` (Limit resets in ${formatResetTime(row.bucket.resetTime)})`
+ : ''}
+
+ >
+ )}
+
+
+ >
+ );
+ })()}
+
+ )}
))}
diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx
index ce5f094428..4e0402820f 100644
--- a/packages/cli/src/ui/components/StatusDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx
@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
- const originalEnv = process.env;
+ beforeEach(() => {
+ vi.stubEnv('GEMINI_SYSTEM_MD', '');
+ });
afterEach(() => {
- process.env = { ...originalEnv };
- delete process.env['GEMINI_SYSTEM_MD'];
+ vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
- process.env['GEMINI_SYSTEM_MD'] = 'true';
+ vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
index 414e8cfa8f..d843202420 100644
--- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
@@ -6,7 +6,7 @@ exports[` > displays "Limit reached" message when remaining is 0 1`] =
`;
exports[` > displays the usage indicator when usage is low 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%
+" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 85%
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
index b631f4e8ad..3f5af99dd9 100644
--- a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
-"15%
+"85%
"
`;
-exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
-"/stats 4% usage remaining
+exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
+"96% used
"
`;
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
"
`;
-exports[`QuotaDisplay > should render with reset time when provided 1`] = `
-"/stats 15% usage remaining, resets in 1h
+exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
+"85% used
"
`;
-exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
-"/stats 15% usage remaining
+exports[`QuotaDisplay > should render with reset time when provided 1`] = `
+"85% used (Limit resets in 1 hour at 1:29 PM PST)
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
index 09b45dfec7..e5a2a10cd6 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ > Apply To │
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
+│ Max Chat Model Attempts 10 │
+│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
+│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
-│ Keep chat history undefined │
-│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
-│ │
│ ▼ │
│ │
│ Apply To │
diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
index cc31c301ba..ec70cdaecd 100644
--- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
@@ -162,16 +162,16 @@ exports[` > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ auto Usage │
-│ 65% usage remaining │
+│ Model Usage: auto │
+│ 35% used │
│ Usage limit: 1,100 │
│ Usage limits span all sessions and reset daily. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
-│ Model Reqs Usage remaining │
-│ ──────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro - │
-│ gemini-2.5-flash - │
+│ Model Reqs Model usage │
+│ ────────────────────────────────────────────────────────────────────────────────────────────────│
+│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% used │
+│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% used │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -194,9 +194,9 @@ exports[` > Quota Display > renders quota information for unused
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
-│ Model Reqs Usage remaining │
-│ ──────────────────────────────────────────────────────────── │
-│ gemini-2.5-flash - 50.0% resets in 2h │
+│ Model Reqs Model usage │
+│ ────────────────────────────────────────────────────────────────────────────────────────────────│
+│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% used (Limit resets in 2 hours at 6:00 AM│
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -219,9 +219,9 @@ exports[` > Quota Display > renders quota information when quota
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage │
-│ Model Reqs Usage remaining │
-│ ──────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
+│ Model Reqs Model usage │
+│ ────────────────────────────────────────────────────────────────────────────────────────────────│
+│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% used (Limit resets in 1 hour 30 minutes │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
index 54abbc09d3..8e760b28e7 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -58,7 +58,10 @@ export const ShellToolMessage: React.FC = ({
borderColor,
borderDimColor,
+
isExpandable,
+
+ originalRequestName,
}) => {
const {
activePtyId: activeShellPtyId,
@@ -129,6 +132,7 @@ export const ShellToolMessage: React.FC = ({
status={status}
description={description}
emphasis={emphasis}
+ originalRequestName={originalRequestName}
/>
{
expect(output).toMatchSnapshot();
unmount();
});
+
+ it('should show MCP tool details expand hint for MCP confirmations', async () => {
+ const confirmationDetails: ToolCallConfirmationDetails = {
+ type: 'mcp',
+ title: 'Confirm MCP Tool',
+ serverName: 'test-server',
+ toolName: 'test-tool',
+ toolDisplayName: 'Test Tool',
+ toolArgs: {
+ url: 'https://www.google.co.jp',
+ },
+ toolDescription: 'Navigates browser to a URL.',
+ toolParameterSchema: {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ description: 'Destination URL',
+ },
+ },
+ required: ['url'],
+ },
+ onConfirm: vi.fn(),
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('MCP Tool Details:');
+ expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).not.toContain('https://www.google.co.jp');
+ expect(output).not.toContain('Navigates browser to a URL.');
+ unmount();
+ });
+
+ it('should omit empty MCP invocation arguments from details', async () => {
+ const confirmationDetails: ToolCallConfirmationDetails = {
+ type: 'mcp',
+ title: 'Confirm MCP Tool',
+ serverName: 'test-server',
+ toolName: 'test-tool',
+ toolDisplayName: 'Test Tool',
+ toolArgs: {},
+ toolDescription: 'No arguments required.',
+ onConfirm: vi.fn(),
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('MCP Tool Details:');
+ expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
+ expect(output).not.toContain('Invocation Arguments:');
+ unmount();
+ });
});
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index c4e73b73f6..9a49e2aa5a 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -5,7 +5,7 @@
*/
import type React from 'react';
-import { useMemo, useCallback } from 'react';
+import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -29,6 +29,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
+import { formatCommand } from '../../utils/keybindingUtils.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
@@ -64,6 +65,17 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
}) => {
const { confirm, isDiffingEnabled } = useToolActions();
+ const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
+ callId: string;
+ expanded: boolean;
+ }>({
+ callId,
+ expanded: false,
+ });
+ const isMcpToolDetailsExpanded =
+ mcpDetailsExpansionState.callId === callId
+ ? mcpDetailsExpansionState.expanded
+ : false;
const settings = useSettings();
const allowPermanentApproval =
@@ -86,9 +98,81 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
+ const mcpToolDetailsText = useMemo(() => {
+ if (confirmationDetails.type !== 'mcp') {
+ return null;
+ }
+
+ const detailsLines: string[] = [];
+ const hasNonEmptyToolArgs =
+ confirmationDetails.toolArgs !== undefined &&
+ !(
+ typeof confirmationDetails.toolArgs === 'object' &&
+ confirmationDetails.toolArgs !== null &&
+ Object.keys(confirmationDetails.toolArgs).length === 0
+ );
+ if (hasNonEmptyToolArgs) {
+ let argsText: string;
+ try {
+ argsText = stripUnsafeCharacters(
+ JSON.stringify(confirmationDetails.toolArgs, null, 2),
+ );
+ } catch {
+ argsText = '[unserializable arguments]';
+ }
+ detailsLines.push('Invocation Arguments:');
+ detailsLines.push(argsText);
+ }
+
+ const description = confirmationDetails.toolDescription?.trim();
+ if (description) {
+ if (detailsLines.length > 0) {
+ detailsLines.push('');
+ }
+ detailsLines.push('Description:');
+ detailsLines.push(stripUnsafeCharacters(description));
+ }
+
+ if (confirmationDetails.toolParameterSchema !== undefined) {
+ let schemaText: string;
+ try {
+ schemaText = stripUnsafeCharacters(
+ JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
+ );
+ } catch {
+ schemaText = '[unserializable schema]';
+ }
+ if (detailsLines.length > 0) {
+ detailsLines.push('');
+ }
+ detailsLines.push('Input Schema:');
+ detailsLines.push(schemaText);
+ }
+
+ if (detailsLines.length === 0) {
+ return null;
+ }
+
+ return detailsLines.join('\n');
+ }, [confirmationDetails]);
+
+ const hasMcpToolDetails = !!mcpToolDetailsText;
+ const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
+
useKeypress(
(key) => {
if (!isFocused) return false;
+ if (
+ confirmationDetails.type === 'mcp' &&
+ hasMcpToolDetails &&
+ keyMatchers[Command.SHOW_MORE_LINES](key)
+ ) {
+ setMcpDetailsExpansionState({
+ callId,
+ expanded: !isMcpToolDetailsExpanded,
+ });
+ return true;
+ }
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
@@ -100,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
}
return false;
},
- { isActive: isFocused },
+ { isActive: isFocused, priority: true },
);
const handleSelect = useCallback(
@@ -504,12 +588,31 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = (
-
- MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
-
-
- Tool: {sanitizeForDisplay(mcpProps.toolName)}
-
+ <>
+
+ MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
+
+
+ Tool: {sanitizeForDisplay(mcpProps.toolName)}
+
+ >
+ {hasMcpToolDetails && (
+
+ MCP Tool Details:
+ {isMcpToolDetailsExpanded ? (
+ <>
+
+ (press {expandDetailsHintKey} to collapse MCP tool details)
+
+ {mcpToolDetailsText}
+ >
+ ) : (
+
+ (press {expandDetailsHintKey} to expand MCP tool details)
+
+ )}
+
+ )}
);
}
@@ -522,8 +625,17 @@ export const ToolConfirmationMessage: React.FC<
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
+ isMcpToolDetailsExpanded,
+ hasMcpToolDetails,
+ mcpToolDetailsText,
+ expandDetailsHintKey,
]);
+ const bodyOverflowDirection: 'top' | 'bottom' =
+ confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
+ ? 'bottom'
+ : 'top';
+
if (confirmationDetails.type === 'edit') {
if (confirmationDetails.isModifying) {
return (
@@ -559,7 +671,7 @@ export const ToolConfirmationMessage: React.FC<
{bodyContent}
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 557e0bd857..709cb17f74 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -57,6 +57,7 @@ export const ToolMessage: React.FC = ({
config,
progressMessage,
progressPercent,
+ originalRequestName,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -93,6 +94,7 @@ export const ToolMessage: React.FC = ({
emphasis={emphasis}
progressMessage={progressMessage}
progressPercent={progressPercent}
+ originalRequestName={originalRequestName}
/>
= ({
@@ -198,6 +199,7 @@ export const ToolInfo: React.FC = ({
emphasis,
progressMessage,
progressPercent,
+ originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo(() => {
@@ -242,6 +244,12 @@ export const ToolInfo: React.FC = ({
{name}
+ {originalRequestName && originalRequestName !== name && (
+
+ {' '}
+ (redirection from {originalRequestName})
+
+ )}
{!isCompletedAskUser && (
<>
{' '}
diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts
index 241b5d94f0..c97f4a526d 100644
--- a/packages/cli/src/ui/hooks/toolMapping.test.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.test.ts
@@ -275,5 +275,20 @@ describe('toolMapping', () => {
expect(result.tools[0].resultDisplay).toBeUndefined();
expect(result.tools[0].status).toBe(CoreToolCallStatus.Scheduled);
});
+
+ it('propagates originalRequestName correctly', () => {
+ const toolCall: ScheduledToolCall = {
+ status: CoreToolCallStatus.Scheduled,
+ request: {
+ ...mockRequest,
+ originalRequestName: 'original_tool',
+ },
+ tool: mockTool,
+ invocation: mockInvocation,
+ };
+
+ const result = mapToDisplay(toolCall);
+ expect(result.tools[0].originalRequestName).toBe('original_tool');
+ });
});
});
diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts
index ded17f29a9..6f484d5d25 100644
--- a/packages/cli/src/ui/hooks/toolMapping.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.ts
@@ -107,6 +107,7 @@ export function mapToDisplay(
progressMessage,
progressPercent,
approvalMode: call.approvalMode,
+ originalRequestName: call.request.originalRequestName,
};
});
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts
index ddf43944f6..ca9df3d5d3 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts
@@ -13,6 +13,7 @@ import {
Scheduler,
type Config,
type MessageBus,
+ type ExecutingToolCall,
type CompletedToolCall,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
@@ -110,7 +111,7 @@ describe('useToolScheduler', () => {
tool: createMockTool(),
invocation: createMockInvocation(),
liveOutput: 'Loading...',
- };
+ } as ExecutingToolCall;
act(() => {
void mockMessageBus.publish({
@@ -405,4 +406,62 @@ describe('useToolScheduler', () => {
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
});
+
+ it('adapts success/error status to executing when a tail call is present', () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() =>
+ useToolScheduler(
+ vi.fn().mockResolvedValue(undefined),
+ mockConfig,
+ () => undefined,
+ ),
+ );
+
+ const startTime = Date.now();
+ vi.advanceTimersByTime(1000);
+
+ const mockToolCall = {
+ status: CoreToolCallStatus.Success as const,
+ request: {
+ callId: 'call-1',
+ name: 'test_tool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'p1',
+ },
+ tool: createMockTool(),
+ invocation: createMockInvocation(),
+ response: {
+ callId: 'call-1',
+ resultDisplay: 'OK',
+ responseParts: [],
+ error: undefined,
+ errorType: undefined,
+ },
+ tailToolCallRequest: {
+ name: 'tail_tool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: '123',
+ },
+ };
+
+ act(() => {
+ void mockMessageBus.publish({
+ type: MessageBusType.TOOL_CALLS_UPDATE,
+ toolCalls: [mockToolCall],
+ schedulerId: ROOT_SCHEDULER_ID,
+ } as ToolCallsUpdateMessage);
+ });
+
+ const [toolCalls, , , , , lastOutputTime] = result.current;
+
+ // Check if status has been adapted to 'executing'
+ expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
+
+ // Check if lastOutputTime was updated due to the transitional state
+ expect(lastOutputTime).toBeGreaterThan(startTime);
+
+ vi.useRealTimers();
+ });
});
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts
index 56b1622468..f09ed9b81f 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.ts
@@ -14,6 +14,7 @@ import {
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
+ CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
@@ -115,7 +116,16 @@ export function useToolScheduler(
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Update output timer for UI spinners (Side Effect)
- if (event.toolCalls.some((tc) => tc.status === 'executing')) {
+ const hasExecuting = event.toolCalls.some(
+ (tc) =>
+ tc.status === CoreToolCallStatus.Executing ||
+ ((tc.status === CoreToolCallStatus.Success ||
+ tc.status === CoreToolCallStatus.Error) &&
+ 'tailToolCallRequest' in tc &&
+ tc.tailToolCallRequest != null),
+ );
+
+ if (hasExecuting) {
setLastToolOutputTime(Date.now());
}
@@ -238,9 +248,23 @@ function adaptToolCalls(
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
+ let status = coreCall.status;
+ // If a tool call has completed but scheduled a tail call, it is in a transitional
+ // state. Force the UI to render it as "executing".
+ if (
+ (status === CoreToolCallStatus.Success ||
+ status === CoreToolCallStatus.Error) &&
+ 'tailToolCallRequest' in coreCall &&
+ coreCall.tailToolCallRequest != null
+ ) {
+ status = CoreToolCallStatus.Executing;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...coreCall,
+ status,
responseSubmittedToGemini,
- };
+ } as TrackedToolCall;
});
}
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index b2de83cd8b..763754ec95 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
createKey('l', { ctrl: true }),
],
},
-
// Shell commands
{
command: Command.REVERSE_SEARCH,
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 2d40f0a48c..68a029e267 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -110,6 +110,7 @@ export interface IndividualToolCallDisplay {
approvalMode?: ApprovalMode;
progressMessage?: string;
progressPercent?: number;
+ originalRequestName?: string;
}
export interface CompressionProps {
diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts
index e311aa4974..6da169788e 100644
--- a/packages/cli/src/ui/utils/displayUtils.ts
+++ b/packages/cli/src/ui/utils/displayUtils.ts
@@ -19,6 +19,9 @@ export const CACHE_EFFICIENCY_MEDIUM = 15;
export const QUOTA_THRESHOLD_HIGH = 20;
export const QUOTA_THRESHOLD_MEDIUM = 5;
+export const QUOTA_USED_WARNING_THRESHOLD = 80;
+export const QUOTA_USED_CRITICAL_THRESHOLD = 95;
+
// --- Color Logic ---
export const getStatusColor = (
value: number,
@@ -36,3 +39,19 @@ export const getStatusColor = (
}
return options.defaultColor ?? theme.status.error;
};
+
+/**
+ * Gets the status color based on "used" percentage (where higher is worse).
+ */
+export const getUsedStatusColor = (
+ usedPercentage: number,
+ thresholds: { warning: number; critical: number },
+) => {
+ if (usedPercentage >= thresholds.critical) {
+ return theme.status.error;
+ }
+ if (usedPercentage >= thresholds.warning) {
+ return theme.status.warning;
+ }
+ return undefined;
+};
diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts
index 3b4335bac9..f02c5b95bd 100644
--- a/packages/cli/src/ui/utils/formatters.ts
+++ b/packages/cli/src/ui/utils/formatters.ts
@@ -98,26 +98,44 @@ export function stripReferenceContent(text: string): string {
return text.replace(pattern, '').trim();
}
-export const formatResetTime = (resetTime: string): string => {
- const diff = new Date(resetTime).getTime() - Date.now();
+export const formatResetTime = (
+ resetTime: string | undefined,
+ terse = false,
+): string => {
+ if (!resetTime) return '';
+ const resetDate = new Date(resetTime);
+ if (isNaN(resetDate.getTime())) return '';
+
+ const diff = resetDate.getTime() - Date.now();
if (diff <= 0) return '';
const totalMinutes = Math.ceil(diff / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
- const fmt = (val: number, unit: 'hour' | 'minute') =>
- new Intl.NumberFormat('en', {
- style: 'unit',
- unit,
- unitDisplay: 'narrow',
- }).format(val);
-
- if (hours > 0 && minutes > 0) {
- return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
- } else if (hours > 0) {
- return `resets in ${fmt(hours, 'hour')}`;
+ if (terse) {
+ const hoursStr = hours > 0 ? `${hours}hr` : '';
+ const minutesStr = minutes > 0 ? `${minutes}m` : '';
+ return hoursStr && minutesStr
+ ? `${hoursStr} ${minutesStr}`
+ : hoursStr || minutesStr;
}
- return `resets in ${fmt(minutes, 'minute')}`;
+ let duration = '';
+ if (hours > 0) {
+ duration = `${hours} hour${hours > 1 ? 's' : ''}`;
+ if (minutes > 0) {
+ duration += ` ${minutes} minute${minutes > 1 ? 's' : ''}`;
+ }
+ } else {
+ duration = `${minutes} minute${minutes > 1 ? 's' : ''}`;
+ }
+
+ const timeStr = new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: 'numeric',
+ timeZoneName: 'short',
+ }).format(resetDate);
+
+ return `${duration} at ${timeStr}`;
};
diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts
index 9f1d268a91..a6f903fe49 100644
--- a/packages/cli/src/utils/activityLogger.ts
+++ b/packages/cli/src/utils/activityLogger.ts
@@ -22,13 +22,82 @@ import WebSocket from 'ws';
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
const MAX_BUFFER_SIZE = 100;
-/** Type guard: Array.isArray doesn't narrow readonly arrays in TS 5.8 */
function isHeaderRecord(
h: http.OutgoingHttpHeaders | readonly string[],
): h is http.OutgoingHttpHeaders {
return !Array.isArray(h);
}
+function isRequestOptions(value: unknown): value is http.RequestOptions {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !(value instanceof URL) &&
+ !Array.isArray(value)
+ );
+}
+
+function isIncomingMessageCallback(
+ value: unknown,
+): value is (res: http.IncomingMessage) => void {
+ return typeof value === 'function';
+}
+
+type HttpRequestArgs =
+ | []
+ | [
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ];
+
+function callHttpRequest(
+ originalFn: typeof http.request,
+ args: HttpRequestArgs,
+): http.ClientRequest {
+ if (args.length === 0) {
+ return originalFn({});
+ }
+ if (args.length === 1) {
+ const first = args[0];
+ if (typeof first === 'string' || first instanceof URL) {
+ return originalFn(first);
+ }
+ if (isRequestOptions(first)) {
+ return originalFn(first);
+ }
+ return originalFn({});
+ }
+ if (args.length === 2) {
+ const first = args[0];
+ const second = args[1];
+ if (typeof first === 'string' || first instanceof URL) {
+ if (isIncomingMessageCallback(second)) {
+ return originalFn(first, second);
+ }
+ if (isRequestOptions(second)) {
+ return originalFn(first, second);
+ }
+ }
+ if (isRequestOptions(first) && isIncomingMessageCallback(second)) {
+ return originalFn(first, second);
+ }
+ }
+ if (args.length === 3) {
+ const first = args[0];
+ const second = args[1];
+ const third = args[2];
+ if (
+ (typeof first === 'string' || first instanceof URL) &&
+ isRequestOptions(second) &&
+ isIncomingMessageCallback(third)
+ ) {
+ return originalFn(first, second, third);
+ }
+ }
+ return originalFn({});
+}
+
export interface NetworkLog {
id: string;
timestamp: number;
@@ -364,7 +433,7 @@ export class ActivityLogger extends EventEmitter {
const wrapRequest = (
originalFn: typeof http.request,
- args: unknown[],
+ args: HttpRequestArgs,
protocol: string,
) => {
const firstArg = args[0];
@@ -373,8 +442,10 @@ export class ActivityLogger extends EventEmitter {
options = firstArg;
} else if (firstArg instanceof URL) {
options = firstArg;
+ } else if (firstArg && typeof firstArg === 'object') {
+ options = isRequestOptions(firstArg) ? firstArg : {};
} else {
- options = (firstArg ?? {}) as http.RequestOptions;
+ options = {};
}
let url = '';
@@ -393,9 +464,9 @@ export class ActivityLogger extends EventEmitter {
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
}
- if (url.includes('127.0.0.1') || url.includes('localhost'))
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return originalFn.apply(http, args as any);
+ if (url.includes('127.0.0.1') || url.includes('localhost')) {
+ return callHttpRequest(originalFn, args);
+ }
const rawHeaders =
typeof options === 'object' &&
@@ -410,24 +481,23 @@ export class ActivityLogger extends EventEmitter {
if (headers[ACTIVITY_ID_HEADER]) {
delete headers[ACTIVITY_ID_HEADER];
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return originalFn.apply(http, args as any);
+ return callHttpRequest(originalFn, args);
}
const id = Math.random().toString(36).substring(7);
this.requestStartTimes.set(id, Date.now());
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- const req = originalFn.apply(http, args as any);
+ const req = callHttpRequest(originalFn, args);
const requestChunks: Buffer[] = [];
const oldWrite = req.write;
const oldEnd = req.end;
- req.write = function (chunk: unknown, ...etc: unknown[]) {
+ req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
if (chunk) {
const encoding =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
+ typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
+ ? etc[0]
+ : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -438,19 +508,21 @@ export class ActivityLogger extends EventEmitter {
),
);
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- return oldWrite.apply(this, [chunk, ...etc] as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
+ return (oldWrite as any).apply(this, [chunk, ...etc]);
};
req.end = function (
this: http.ClientRequest,
- chunk: unknown,
+ chunkOrCb?: string | Uint8Array | (() => void),
...etc: unknown[]
) {
- if (chunk && typeof chunk !== 'function') {
+ const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
+ if (chunk) {
const encoding =
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
+ typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
+ ? etc[0]
+ : undefined;
requestChunks.push(
Buffer.isBuffer(chunk)
? chunk
@@ -473,7 +545,7 @@ export class ActivityLogger extends EventEmitter {
pending: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
- return (oldEnd as any).apply(this, [chunk, ...etc]);
+ return (oldEnd as any).apply(this, [chunkOrCb, ...etc]);
};
req.on('response', (res: http.IncomingMessage) => {
@@ -545,12 +617,44 @@ export class ActivityLogger extends EventEmitter {
return req;
};
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- (http as any).request = (...args: unknown[]) =>
- wrapRequest(originalRequest, args, 'http:');
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
- (https as any).request = (...args: unknown[]) =>
- wrapRequest(originalHttpsRequest as typeof http.request, args, 'https:');
+ Object.defineProperty(http, 'request', {
+ value: (
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ): http.ClientRequest => {
+ const args: HttpRequestArgs =
+ callback !== undefined
+ ? [url, options, callback]
+ : options !== undefined
+ ? [url, options]
+ : [url];
+ return wrapRequest(originalRequest, args, 'http:');
+ },
+ writable: true,
+ configurable: true,
+ });
+ Object.defineProperty(https, 'request', {
+ value: (
+ url: string | URL | http.RequestOptions,
+ options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
+ callback?: (res: http.IncomingMessage) => void,
+ ): http.ClientRequest => {
+ const args: HttpRequestArgs =
+ callback !== undefined
+ ? [url, options, callback]
+ : options !== undefined
+ ? [url, options]
+ : [url];
+ return wrapRequest(
+ originalHttpsRequest as typeof http.request,
+ args,
+ 'https:',
+ );
+ },
+ writable: true,
+ configurable: true,
+ });
}
logConsole(payload: ConsoleLogPayload) {
diff --git a/packages/core/package.json b/packages/core/package.json
index e01efe9b3f..9995dabe18 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -53,6 +53,8 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"diff": "^8.0.3",
+ "dotenv": "^17.2.4",
+ "dotenv-expand": "^12.0.3",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
diff --git a/packages/core/src/availability/fallbackIntegration.test.ts b/packages/core/src/availability/fallbackIntegration.test.ts
index f9de1f3b2b..62174c9abb 100644
--- a/packages/core/src/availability/fallbackIntegration.test.ts
+++ b/packages/core/src/availability/fallbackIntegration.test.ts
@@ -47,7 +47,10 @@ describe('Fallback Integration', () => {
const requestedModel = PREVIEW_GEMINI_MODEL;
// 3. Apply model selection
- const result = applyModelSelection(config, { model: requestedModel });
+ const result = applyModelSelection(config, {
+ model: requestedModel,
+ isChatModel: true,
+ });
// 4. Expect fallback to Flash
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts
index 22b8a62700..2eb6129f61 100644
--- a/packages/core/src/availability/policyHelpers.test.ts
+++ b/packages/core/src/availability/policyHelpers.test.ts
@@ -222,7 +222,10 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-pro',
});
- const result = applyModelSelection(config, { model: 'gemini-pro' });
+ const result = applyModelSelection(config, {
+ model: 'gemini-pro',
+ isChatModel: true,
+ });
expect(result.model).toBe('gemini-pro');
expect(result.maxAttempts).toBeUndefined();
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
@@ -243,7 +246,10 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-flash',
});
- const result = applyModelSelection(config, { model: 'gemini-pro' });
+ const result = applyModelSelection(config, {
+ model: 'gemini-pro',
+ isChatModel: true,
+ });
expect(result.model).toBe('gemini-flash');
expect(result.config).toEqual({
@@ -253,14 +259,33 @@ describe('policyHelpers', () => {
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-pro',
+ isChatModel: true,
});
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-flash',
+ isChatModel: true,
});
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-flash');
});
- it('consumes sticky attempt if indicated', () => {
+ it('does not call setActiveModel if isChatModel is false', () => {
+ const config = createExtendedMockConfig();
+ mockModelConfigService.getResolvedConfig.mockReturnValue({
+ model: 'gemini-pro',
+ generateContentConfig: {},
+ });
+ mockAvailabilityService.selectFirstAvailable.mockReturnValue({
+ selectedModel: 'gemini-pro',
+ });
+
+ applyModelSelection(config, {
+ model: 'gemini-pro',
+ isChatModel: false,
+ });
+ expect(config.setActiveModel).not.toHaveBeenCalled();
+ });
+
+ it('consumes sticky attempt if indicated and isChatModel is true', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
@@ -271,10 +296,36 @@ describe('policyHelpers', () => {
attempts: 1,
});
- const result = applyModelSelection(config, { model: 'gemini-pro' });
+ const result = applyModelSelection(config, {
+ model: 'gemini-pro',
+ isChatModel: true,
+ });
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
'gemini-pro',
);
+ expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
+ expect(result.maxAttempts).toBe(1);
+ });
+
+ it('consumes sticky attempt if indicated but does not call setActiveModel if isChatModel is false', () => {
+ const config = createExtendedMockConfig();
+ mockModelConfigService.getResolvedConfig.mockReturnValue({
+ model: 'gemini-pro',
+ generateContentConfig: {},
+ });
+ mockAvailabilityService.selectFirstAvailable.mockReturnValue({
+ selectedModel: 'gemini-pro',
+ attempts: 1,
+ });
+
+ const result = applyModelSelection(config, {
+ model: 'gemini-pro',
+ isChatModel: false,
+ });
+ expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
+ 'gemini-pro',
+ );
+ expect(config.setActiveModel).not.toHaveBeenCalled();
expect(result.maxAttempts).toBe(1);
});
@@ -291,7 +342,7 @@ describe('policyHelpers', () => {
const result = applyModelSelection(
config,
- { model: 'gemini-pro' },
+ { model: 'gemini-pro', isChatModel: true },
{
consumeAttempt: false,
},
@@ -299,6 +350,7 @@ describe('policyHelpers', () => {
expect(
mockAvailabilityService.consumeStickyAttempt,
).not.toHaveBeenCalled();
+ expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});
});
diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts
index 456c8a855f..05c1dd19f9 100644
--- a/packages/core/src/availability/policyHelpers.ts
+++ b/packages/core/src/availability/policyHelpers.ts
@@ -214,7 +214,9 @@ export function applyModelSelection(
generateContentConfig = fallbackResolved.generateContentConfig;
}
- config.setActiveModel(finalModel);
+ if (modelConfigKey.isChatModel) {
+ config.setActiveModel(finalModel);
+ }
if (selection.attempts && options.consumeAttempt !== false) {
config.getModelAvailabilityService().consumeStickyAttempt(finalModel);
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index 7a008f12a6..b62b1a1fc7 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import type { ConfigParameters, SandboxConfig } from './config.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js';
+import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { ApprovalMode } from '../policy/types.js';
@@ -259,6 +260,29 @@ describe('Server Config (config.ts)', () => {
usageStatisticsEnabled: false,
};
+ describe('maxAttempts', () => {
+ it('should default to DEFAULT_MAX_ATTEMPTS', () => {
+ const config = new Config(baseParams);
+ expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
+ });
+
+ it('should use provided maxAttempts if <= DEFAULT_MAX_ATTEMPTS', () => {
+ const config = new Config({
+ ...baseParams,
+ maxAttempts: 5,
+ });
+ expect(config.getMaxAttempts()).toBe(5);
+ });
+
+ it('should cap maxAttempts at DEFAULT_MAX_ATTEMPTS', () => {
+ const config = new Config({
+ ...baseParams,
+ maxAttempts: 20,
+ });
+ expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
+ });
+ });
+
beforeEach(() => {
// Reset mocks if necessary
vi.clearAllMocks();
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 0f03c03db0..45a5f5fd75 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -132,6 +132,11 @@ import { UserHintService } from './userHintService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
+import { CheckerRunner } from '../safety/checker-runner.js';
+import { ContextBuilder } from '../safety/context-builder.js';
+import { CheckerRegistry } from '../safety/registry.js';
+import { ConsecaSafetyChecker } from '../safety/conseca/conseca.js';
+
export interface AccessibilitySettings {
/** @deprecated Use ui.loadingPhrases instead. */
enableLoadingPhrases?: boolean;
@@ -291,6 +296,7 @@ export interface ExtensionInstallMetadata {
allowPreRelease?: boolean;
}
+import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import type { FileFilteringOptions } from './constants.js';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
@@ -476,6 +482,7 @@ export interface ConfigParameters {
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
retryFetchErrors?: boolean;
+ maxAttempts?: number;
enableShellOutputEfficiency?: boolean;
shellToolInactivityTimeout?: number;
fakeResponses?: string;
@@ -511,6 +518,7 @@ export interface ConfigParameters {
adminSkillsEnabled?: boolean;
agents?: AgentSettings;
}>;
+ enableConseca?: boolean;
}
export class Config {
@@ -538,6 +546,7 @@ export class Config {
private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean;
private readonly question: string | undefined;
+ readonly enableConseca: boolean;
private readonly coreTools: string[] | undefined;
/** @deprecated Use Policy Engine instead */
@@ -657,6 +666,7 @@ export class Config {
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
+ private readonly maxAttempts: number;
private readonly enableShellOutputEfficiency: boolean;
private readonly shellToolInactivityTimeout: number;
readonly fakeResponses?: string;
@@ -865,13 +875,35 @@ export class Config {
this.recordResponses = params.recordResponses;
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
- this.policyEngine = new PolicyEngine({
- ...params.policyEngineConfig,
- approvalMode:
- params.approvalMode ?? params.policyEngineConfig?.approvalMode,
+ this.enableConseca = params.enableConseca ?? false;
+
+ // Initialize Safety Infrastructure
+ const contextBuilder = new ContextBuilder(this);
+ const checkersPath = this.targetDir;
+ // The checkersPath is used to resolve external checkers. Since we do not have any external checkers currently, it is set to the targetDir.
+ const checkerRegistry = new CheckerRegistry(checkersPath);
+ const checkerRunner = new CheckerRunner(contextBuilder, checkerRegistry, {
+ checkersPath,
+ timeout: 30000, // 30 seconds to allow for LLM-based checkers
});
this.policyUpdateConfirmationRequest =
params.policyUpdateConfirmationRequest;
+
+ this.policyEngine = new PolicyEngine(
+ {
+ ...params.policyEngineConfig,
+ approvalMode:
+ params.approvalMode ?? params.policyEngineConfig?.approvalMode,
+ },
+ checkerRunner,
+ );
+
+ // Register Conseca if enabled
+ if (this.enableConseca) {
+ debugLogger.log('[SAFETY] Registering Conseca Safety Checker');
+ ConsecaSafetyChecker.getInstance().setConfig(this);
+ }
+
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
this.skillManager = new SkillManager();
@@ -879,6 +911,10 @@ export class Config {
format: params.output?.format ?? OutputFormat.TEXT,
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
+ this.maxAttempts = Math.min(
+ params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
+ DEFAULT_MAX_ATTEMPTS,
+ );
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
@@ -2415,6 +2451,10 @@ export class Config {
return this.retryFetchErrors;
}
+ getMaxAttempts(): number {
+ return this.maxAttempts;
+ }
+
getEnableShellOutputEfficiency(): boolean {
return this.enableShellOutputEfficiency;
}
diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts
index 3099f39d1e..f66d60ef8b 100644
--- a/packages/core/src/config/storage.ts
+++ b/packages/core/src/config/storage.ts
@@ -14,6 +14,7 @@ import {
GOOGLE_ACCOUNTS_FILENAME,
isSubpath,
resolveToRealPath,
+ normalizePath,
} from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -142,6 +143,17 @@ export class Storage {
return path.join(this.targetDir, GEMINI_DIR);
}
+ /**
+ * Checks if the current workspace storage location is the same as the global/user storage location.
+ * This handles symlinks and platform-specific path normalization.
+ */
+ isWorkspaceHomeDir(): boolean {
+ return (
+ normalizePath(resolveToRealPath(this.targetDir)) ===
+ normalizePath(resolveToRealPath(homedir()))
+ );
+ }
+
getAgentsDir(): string {
return path.join(this.targetDir, AGENTS_DIR_NAME);
}
diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts
index 69aa98832e..e02c773070 100644
--- a/packages/core/src/confirmation-bus/types.ts
+++ b/packages/core/src/confirmation-bus/types.ts
@@ -95,6 +95,9 @@ export type SerializableConfirmationDetails =
serverName: string;
toolName: string;
toolDisplayName: string;
+ toolArgs?: Record;
+ toolDescription?: string;
+ toolParameterSchema?: unknown;
}
| {
type: 'ask_user';
diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts
index d067ec49ef..db1086fe81 100644
--- a/packages/core/src/core/baseLlmClient.test.ts
+++ b/packages/core/src/core/baseLlmClient.test.ts
@@ -641,7 +641,7 @@ describe('BaseLlmClient', () => {
);
contentOptions = {
- modelConfigKey: { model: 'test-model' },
+ modelConfigKey: { model: 'test-model', isChatModel: false },
contents: [{ role: 'user', parts: [{ text: 'Give me a color.' }] }],
abortSignal: abortController.signal,
promptId: 'content-prompt-id',
@@ -650,12 +650,17 @@ describe('BaseLlmClient', () => {
jsonOptions = {
...defaultOptions,
+ modelConfigKey: {
+ ...defaultOptions.modelConfigKey,
+ isChatModel: true,
+ },
promptId: 'json-prompt-id',
};
});
it('should mark model as healthy on success', async () => {
const successfulModel = 'gemini-pro';
+ mockConfig.getActiveModel.mockReturnValue(successfulModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: successfulModel,
skipped: [],
@@ -666,7 +671,7 @@ describe('BaseLlmClient', () => {
await client.generateContent({
...contentOptions,
- modelConfigKey: { model: successfulModel },
+ modelConfigKey: { model: successfulModel, isChatModel: false },
role: LlmRole.UTILITY_TOOL,
});
@@ -678,44 +683,55 @@ describe('BaseLlmClient', () => {
it('marks the final attempted model healthy after a retry with availability enabled', async () => {
const firstModel = 'gemini-pro';
const fallbackModel = 'gemini-flash';
+ let activeModel = firstModel;
+ mockConfig.getActiveModel.mockImplementation(() => activeModel);
+ mockConfig.setActiveModel.mockImplementation((m) => {
+ activeModel = m;
+ });
+
vi.mocked(mockAvailabilityService.selectFirstAvailable)
.mockReturnValueOnce({ selectedModel: firstModel, skipped: [] })
.mockReturnValueOnce({ selectedModel: fallbackModel, skipped: [] });
+ // Mock generateContent to fail once and then succeed
mockGenerateContent
- .mockResolvedValueOnce(createMockResponse('retry-me'))
+ .mockResolvedValueOnce(createMockResponse(''))
.mockResolvedValueOnce(createMockResponse('final-response'));
- // Run the real retryWithBackoff (with fake timers) to exercise the retry path
- vi.useFakeTimers();
+ // 1. First call starts. applyModelSelection(firstModel) -> currentModel = firstModel.
+ // 2. apiCall() runs. getActiveModel() === firstModel. call(firstModel). returns ''.
+ // 3. retry triggers.
+ // 4. Second call starts. applyModelSelection(firstModel).
+ // selectFirstAvailable -> fallbackModel.
+ // setActiveModel(fallbackModel) -> activeModel = fallbackModel.
+ // returns fallbackModel.
+ // 5. apiCall() runs. getActiveModel() === fallbackModel. call(fallbackModel). returns 'final-response'.
- const retryPromise = client.generateContent({
+ vi.mocked(retryWithBackoff).mockImplementation(async (fn) => {
+ // First call
+ let res = (await fn()) as GenerateContentResponse;
+ if (res.candidates?.[0]?.content?.parts?.[0]?.text === '') {
+ // Second call
+ activeModel = fallbackModel;
+ mockConfig.setActiveModel(fallbackModel);
+ res = (await fn()) as GenerateContentResponse;
+ }
+ mockAvailabilityService.markHealthy(activeModel);
+ return res;
+ });
+
+ const result = await client.generateContent({
...contentOptions,
- modelConfigKey: { model: firstModel },
+ modelConfigKey: { model: firstModel, isChatModel: true },
maxAttempts: 2,
role: LlmRole.UTILITY_TOOL,
});
- await vi.runAllTimersAsync();
- await retryPromise;
-
- await client.generateContent({
- ...contentOptions,
- modelConfigKey: { model: firstModel },
- maxAttempts: 2,
- role: LlmRole.UTILITY_TOOL,
- });
-
- expect(mockConfig.setActiveModel).toHaveBeenCalledWith(firstModel);
+ expect(result).toEqual(createMockResponse('final-response'));
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(fallbackModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
fallbackModel,
);
- expect(mockGenerateContent).toHaveBeenLastCalledWith(
- expect.objectContaining({ model: fallbackModel }),
- expect.any(String),
- LlmRole.UTILITY_TOOL,
- );
});
it('should consume sticky attempt if selection has attempts', async () => {
@@ -754,6 +770,7 @@ describe('BaseLlmClient', () => {
it('should mark healthy and honor availability selection when using generateJson', async () => {
const availableModel = 'gemini-json-pro';
+ mockConfig.getActiveModel.mockReturnValue(availableModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: availableModel,
skipped: [],
@@ -770,10 +787,15 @@ describe('BaseLlmClient', () => {
return result;
});
- const result = await client.generateJson(jsonOptions);
+ const result = await client.generateJson({
+ ...jsonOptions,
+ modelConfigKey: {
+ ...jsonOptions.modelConfigKey,
+ isChatModel: false,
+ },
+ });
expect(result).toEqual({ color: 'violet' });
- expect(mockConfig.setActiveModel).toHaveBeenCalledWith(availableModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
availableModel,
);
diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts
index 64442ac86e..0de4dd1e20 100644
--- a/packages/core/src/core/baseLlmClient.ts
+++ b/packages/core/src/core/baseLlmClient.ts
@@ -280,19 +280,22 @@ export class BaseLlmClient {
() => currentModel,
);
+ let initialActiveModel = this.config.getActiveModel();
+
try {
const apiCall = () => {
// Ensure we use the current active model
// in case a fallback occurred in a previous attempt.
const activeModel = this.config.getActiveModel();
- if (activeModel !== currentModel) {
- currentModel = activeModel;
+ if (activeModel !== initialActiveModel) {
+ initialActiveModel = activeModel;
// Re-resolve config if model changed during retry
- const { generateContentConfig } =
+ const { model: resolvedModel, generateContentConfig } =
this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: activeModel,
});
+ currentModel = resolvedModel;
currentGenerateContentConfig = generateContentConfig;
}
const finalConfig: GenerateContentConfig = {
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index 56447468bd..c94dd5c04d 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -957,17 +957,21 @@ export class GeminiClient {
() => currentAttemptModel,
);
+ let initialActiveModel = this.config.getActiveModel();
+
const apiCall = () => {
// AvailabilityService
const active = this.config.getActiveModel();
- if (active !== currentAttemptModel) {
- currentAttemptModel = active;
+ if (active !== initialActiveModel) {
+ initialActiveModel = active;
// Re-resolve config if model changed
- const newConfig = this.config.modelConfigService.getResolvedConfig({
- ...modelConfigKey,
- model: currentAttemptModel,
- });
- currentAttemptGenerateContentConfig = newConfig.generateContentConfig;
+ const { model: resolvedModel, generateContentConfig } =
+ this.config.modelConfigService.getResolvedConfig({
+ ...modelConfigKey,
+ model: active,
+ });
+ currentAttemptModel = resolvedModel;
+ currentAttemptGenerateContentConfig = generateContentConfig;
}
const requestConfig: GenerateContentConfig = {
diff --git a/packages/core/src/core/coreToolHookTriggers.ts b/packages/core/src/core/coreToolHookTriggers.ts
index cb98d3af20..9c83253903 100644
--- a/packages/core/src/core/coreToolHookTriggers.ts
+++ b/packages/core/src/core/coreToolHookTriggers.ts
@@ -75,6 +75,7 @@ export async function executeToolWithHooks(
shellExecutionConfig?: ShellExecutionConfig,
setPidCallback?: (pid: number) => void,
config?: Config,
+ originalRequestName?: string,
): Promise {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolInput = (invocation.params || {}) as Record;
@@ -90,6 +91,7 @@ export async function executeToolWithHooks(
toolName,
toolInput,
mcpContext,
+ originalRequestName,
);
// Check if hook requested to stop entire agent execution
@@ -196,6 +198,7 @@ export async function executeToolWithHooks(
error: toolResult.error,
},
mcpContext,
+ originalRequestName,
);
// Check if hook requested to stop entire agent execution
@@ -242,6 +245,12 @@ export async function executeToolWithHooks(
toolResult.llmContent = wrappedContext;
}
}
+
+ // Check if the hook requested a tail tool call
+ const tailToolCallRequest = afterOutput?.getTailToolCallRequest();
+ if (tailToolCallRequest) {
+ toolResult.tailToolCallRequest = tailToolCallRequest;
+ }
}
return toolResult;
diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts
index 8a6b3f8bc8..bfcb803a95 100644
--- a/packages/core/src/core/geminiChat.test.ts
+++ b/packages/core/src/core/geminiChat.test.ts
@@ -153,6 +153,7 @@ describe('GeminiChat', () => {
}),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
+ getMaxAttempts: vi.fn().mockReturnValue(10),
getUserTier: vi.fn().mockReturnValue(undefined),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts
index c9cb6cf8f2..b7319c8afd 100644
--- a/packages/core/src/core/geminiChat.ts
+++ b/packages/core/src/core/geminiChat.ts
@@ -18,11 +18,7 @@ import type {
} from '@google/genai';
import { toParts } from '../code_assist/converter.js';
import { createUserContent, FinishReason } from '@google/genai';
-import {
- retryWithBackoff,
- isRetryableError,
- DEFAULT_MAX_ATTEMPTS,
-} from '../utils/retry.js';
+import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
@@ -635,12 +631,12 @@ export class GeminiChat {
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: abortSignal,
- maxAttempts: availabilityMaxAttempts,
+ maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
getAvailabilityContext,
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
- maxAttempts: availabilityMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
+ maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
delayMs,
error: error instanceof Error ? error.message : String(error),
model: lastModelToUse,
diff --git a/packages/core/src/core/geminiChat_network_retry.test.ts b/packages/core/src/core/geminiChat_network_retry.test.ts
index 519ef3ee14..161cadaf52 100644
--- a/packages/core/src/core/geminiChat_network_retry.test.ts
+++ b/packages/core/src/core/geminiChat_network_retry.test.ts
@@ -94,6 +94,7 @@ describe('GeminiChat Network Retries', () => {
getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false), // Default false
+ getMaxAttempts: vi.fn().mockReturnValue(10),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => ({
model: modelConfigKey.model,
diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts
index 3301ffb69d..0e744c3be7 100644
--- a/packages/core/src/hooks/hookEventHandler.ts
+++ b/packages/core/src/hooks/hookEventHandler.ts
@@ -76,12 +76,16 @@ export class HookEventHandler {
toolName: string,
toolInput: Record,
mcpContext?: McpToolContext,
+ originalRequestName?: string,
): Promise {
const input: BeforeToolInput = {
...this.createBaseInput(HookEventName.BeforeTool),
tool_name: toolName,
tool_input: toolInput,
...(mcpContext && { mcp_context: mcpContext }),
+ ...(originalRequestName && {
+ original_request_name: originalRequestName,
+ }),
};
const context: HookEventContext = { toolName };
@@ -97,6 +101,7 @@ export class HookEventHandler {
toolInput: Record,
toolResponse: Record,
mcpContext?: McpToolContext,
+ originalRequestName?: string,
): Promise {
const input: AfterToolInput = {
...this.createBaseInput(HookEventName.AfterTool),
@@ -104,6 +109,9 @@ export class HookEventHandler {
tool_input: toolInput,
tool_response: toolResponse,
...(mcpContext && { mcp_context: mcpContext }),
+ ...(originalRequestName && {
+ original_request_name: originalRequestName,
+ }),
};
const context: HookEventContext = { toolName };
diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts
index 1d5f346210..56eb10b015 100644
--- a/packages/core/src/hooks/hookSystem.ts
+++ b/packages/core/src/hooks/hookSystem.ts
@@ -368,12 +368,14 @@ export class HookSystem {
toolName: string,
toolInput: Record,
mcpContext?: McpToolContext,
+ originalRequestName?: string,
): Promise {
try {
const result = await this.hookEventHandler.fireBeforeToolEvent(
toolName,
toolInput,
mcpContext,
+ originalRequestName,
);
return result.finalOutput;
} catch (error) {
@@ -391,6 +393,7 @@ export class HookSystem {
error: unknown;
},
mcpContext?: McpToolContext,
+ originalRequestName?: string,
): Promise {
try {
const result = await this.hookEventHandler.fireAfterToolEvent(
@@ -398,6 +401,7 @@ export class HookSystem {
toolInput,
toolResponse as Record,
mcpContext,
+ originalRequestName,
);
return result.finalOutput;
} catch (error) {
diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts
index b4a8ce27e8..ba579d81e6 100644
--- a/packages/core/src/hooks/types.ts
+++ b/packages/core/src/hooks/types.ts
@@ -253,6 +253,33 @@ export class DefaultHookOutput implements HookOutput {
shouldClearContext(): boolean {
return false;
}
+
+ /**
+ * Optional request to execute another tool immediately after this one.
+ * The result of this tail call will replace the original tool's response.
+ */
+ getTailToolCallRequest():
+ | {
+ name: string;
+ args: Record;
+ }
+ | undefined {
+ if (
+ this.hookSpecificOutput &&
+ 'tailToolCallRequest' in this.hookSpecificOutput
+ ) {
+ const request = this.hookSpecificOutput['tailToolCallRequest'];
+ if (
+ typeof request === 'object' &&
+ request !== null &&
+ !Array.isArray(request)
+ ) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ return request as { name: string; args: Record };
+ }
+ }
+ return undefined;
+ }
}
/**
@@ -430,6 +457,7 @@ export interface BeforeToolInput extends HookInput {
tool_name: string;
tool_input: Record;
mcp_context?: McpToolContext; // Only present for MCP tools
+ original_request_name?: string;
}
/**
@@ -450,6 +478,7 @@ export interface AfterToolInput extends HookInput {
tool_input: Record;
tool_response: Record;
mcp_context?: McpToolContext; // Only present for MCP tools
+ original_request_name?: string;
}
/**
@@ -459,6 +488,14 @@ export interface AfterToolOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'AfterTool';
additionalContext?: string;
+ /**
+ * Optional request to execute another tool immediately after this one.
+ * The result of this tail call will replace the original tool's response.
+ */
+ tailToolCallRequest?: {
+ name: string;
+ args: Record;
+ };
};
}
diff --git a/packages/core/src/mcp/token-storage/file-token-storage.test.ts b/packages/core/src/mcp/token-storage/file-token-storage.test.ts
index 48050f37e6..a2f080a652 100644
--- a/packages/core/src/mcp/token-storage/file-token-storage.test.ts
+++ b/packages/core/src/mcp/token-storage/file-token-storage.test.ts
@@ -17,6 +17,7 @@ vi.mock('node:fs', () => ({
writeFile: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn(),
+ rename: vi.fn(),
},
}));
@@ -38,6 +39,7 @@ describe('FileTokenStorage', () => {
writeFile: ReturnType;
unlink: ReturnType;
mkdir: ReturnType;
+ rename: ReturnType;
};
const existingCredentials: OAuthCredentials = {
serverName: 'existing-server',
@@ -105,12 +107,48 @@ describe('FileTokenStorage', () => {
expect(result).toEqual(credentials);
});
- it('should throw error for corrupted files', async () => {
+ it('should throw error with file path when file is corrupted', async () => {
mockFs.readFile.mockResolvedValue('corrupted-data');
- await expect(storage.getCredentials('test-server')).rejects.toThrow(
- 'Token file corrupted',
- );
+ try {
+ await storage.getCredentials('test-server');
+ expect.fail('Expected error to be thrown');
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ const err = error as Error;
+ expect(err.message).toContain('Corrupted token file detected at:');
+ expect(err.message).toContain('mcp-oauth-tokens-v2.json');
+ expect(err.message).toContain('delete or rename');
+ }
+ });
+ });
+
+ describe('auth type switching', () => {
+ it('should throw error when trying to save credentials with corrupted file', async () => {
+ // Simulate corrupted file on first read
+ mockFs.readFile.mockResolvedValue('corrupted-data');
+
+ // Try to save new credentials (simulating switch from OAuth to API key)
+ const newCredentials: OAuthCredentials = {
+ serverName: 'new-auth-server',
+ token: {
+ accessToken: 'new-api-key',
+ tokenType: 'ApiKey',
+ },
+ updatedAt: Date.now(),
+ };
+
+ // Should throw error with file path
+ try {
+ await storage.setCredentials(newCredentials);
+ expect.fail('Expected error to be thrown');
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ const err = error as Error;
+ expect(err.message).toContain('Corrupted token file detected at:');
+ expect(err.message).toContain('mcp-oauth-tokens-v2.json');
+ expect(err.message).toContain('delete or rename');
+ }
});
});
diff --git a/packages/core/src/mcp/token-storage/file-token-storage.ts b/packages/core/src/mcp/token-storage/file-token-storage.ts
index 0dbc31a308..97eae56194 100644
--- a/packages/core/src/mcp/token-storage/file-token-storage.ts
+++ b/packages/core/src/mcp/token-storage/file-token-storage.ts
@@ -87,7 +87,12 @@ export class FileTokenStorage extends BaseTokenStorage {
'Unsupported state or unable to authenticate data',
)
) {
- throw new Error('Token file corrupted');
+ // Decryption failed - this can happen when switching between auth types
+ // or if the file is genuinely corrupted.
+ throw new Error(
+ `Corrupted token file detected at: ${this.tokenFilePath}\n` +
+ `Please delete or rename this file to resolve the issue.`,
+ );
}
throw error;
}
diff --git a/packages/core/src/policy/policies/conseca.toml b/packages/core/src/policy/policies/conseca.toml
new file mode 100644
index 0000000000..48c7e1b1c3
--- /dev/null
+++ b/packages/core/src/policy/policies/conseca.toml
@@ -0,0 +1,6 @@
+[[safety_checker]]
+toolName = "*"
+priority = 100
+[safety_checker.checker]
+type = "in-process"
+name = "conseca"
diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts
index 121dfb7c0c..b972ce0e8f 100644
--- a/packages/core/src/policy/policy-engine.test.ts
+++ b/packages/core/src/policy/policy-engine.test.ts
@@ -2557,4 +2557,68 @@ describe('PolicyEngine', () => {
expect(checkers[0].priority).toBe(2.5);
});
});
+
+ describe('Tool Annotations', () => {
+ it('should match tools by semantic annotations', async () => {
+ engine = new PolicyEngine({
+ rules: [
+ {
+ toolAnnotations: { readOnlyHint: true },
+ decision: PolicyDecision.ALLOW,
+ priority: 10,
+ },
+ ],
+ defaultDecision: PolicyDecision.DENY,
+ });
+
+ const readOnlyTool = { name: 'read', args: {} };
+ const readOnlyMeta = { readOnlyHint: true, extra: 'info' };
+
+ const writeTool = { name: 'write', args: {} };
+ const writeMeta = { readOnlyHint: false };
+
+ expect(
+ (await engine.check(readOnlyTool, undefined, readOnlyMeta)).decision,
+ ).toBe(PolicyDecision.ALLOW);
+ expect(
+ (await engine.check(writeTool, undefined, writeMeta)).decision,
+ ).toBe(PolicyDecision.DENY);
+ expect((await engine.check(writeTool, undefined, {})).decision).toBe(
+ PolicyDecision.DENY,
+ );
+ });
+
+ it('should support scoped annotation rules', async () => {
+ engine = new PolicyEngine({
+ rules: [
+ {
+ toolName: '*__*',
+ toolAnnotations: { experimental: true },
+ decision: PolicyDecision.DENY,
+ priority: 20,
+ },
+ {
+ toolName: '*__*',
+ decision: PolicyDecision.ALLOW,
+ priority: 10,
+ },
+ ],
+ });
+
+ expect(
+ (
+ await engine.check({ name: 'mcp__test' }, 'mcp', {
+ experimental: true,
+ })
+ ).decision,
+ ).toBe(PolicyDecision.DENY);
+ expect(
+ (
+ await engine.check({ name: 'mcp__stable' }, 'mcp', {
+ experimental: false,
+ })
+ ).decision,
+ ).toBe(PolicyDecision.ALLOW);
+ });
+ });
});
diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts
index 0998ccb2b5..10cf468942 100644
--- a/packages/core/src/policy/policy-engine.ts
+++ b/packages/core/src/policy/policy-engine.ts
@@ -102,6 +102,7 @@ function ruleMatches(
stringifiedArgs: string | undefined,
serverName: string | undefined,
currentApprovalMode: ApprovalMode,
+ toolAnnotations?: Record,
): boolean {
// Check if rule applies to current approval mode
if (rule.modes && rule.modes.length > 0) {
@@ -112,7 +113,10 @@ function ruleMatches(
// Check tool name if specified
if (rule.toolName) {
- if (isWildcardPattern(rule.toolName)) {
+ // Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
+ if (rule.toolName === '*') {
+ // Match all tools
+ } else if (isWildcardPattern(rule.toolName)) {
if (
!toolCall.name ||
!matchesWildcard(rule.toolName, toolCall.name, serverName)
@@ -124,6 +128,18 @@ function ruleMatches(
}
}
+ // Check annotations if specified
+ if (rule.toolAnnotations) {
+ if (!toolAnnotations) {
+ return false;
+ }
+ for (const [key, value] of Object.entries(rule.toolAnnotations)) {
+ if (toolAnnotations[key] !== value) {
+ return false;
+ }
+ }
+ }
+
// Check args pattern if specified
if (rule.argsPattern) {
// If rule has an args pattern but tool has no args, no match
@@ -204,6 +220,7 @@ export class PolicyEngine {
dir_path: string | undefined,
allowRedirection?: boolean,
rule?: PolicyRule,
+ toolAnnotations?: Record,
): Promise {
if (!command) {
return {
@@ -294,6 +311,7 @@ export class PolicyEngine {
const subResult = await this.check(
{ name: toolName, args: { command: subCmd, dir_path } },
serverName,
+ toolAnnotations,
);
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
@@ -351,6 +369,7 @@ export class PolicyEngine {
async check(
toolCall: FunctionCall,
serverName: string | undefined,
+ toolAnnotations?: Record,
): Promise {
let stringifiedArgs: string | undefined;
// Compute stringified args once before the loop
@@ -403,7 +422,14 @@ export class PolicyEngine {
for (const rule of this.rules) {
const match = toolCallsToTry.some((tc) =>
- ruleMatches(rule, tc, stringifiedArgs, serverName, this.approvalMode),
+ ruleMatches(
+ rule,
+ tc,
+ stringifiedArgs,
+ serverName,
+ this.approvalMode,
+ toolAnnotations,
+ ),
);
if (match) {
@@ -420,6 +446,7 @@ export class PolicyEngine {
shellDirPath,
rule.allowRedirection,
rule,
+ toolAnnotations,
);
decision = shellResult.decision;
if (shellResult.rule) {
@@ -446,6 +473,9 @@ export class PolicyEngine {
this.defaultDecision,
serverName,
shellDirPath,
+ undefined,
+ undefined,
+ toolAnnotations,
);
decision = shellResult.decision;
matchedRule = shellResult.rule;
@@ -464,6 +494,7 @@ export class PolicyEngine {
stringifiedArgs,
serverName,
this.approvalMode,
+ toolAnnotations,
)
) {
debugLogger.debug(
diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts
index 1ea83775fe..785d56cf3e 100644
--- a/packages/core/src/policy/toml-loader.test.ts
+++ b/packages/core/src/policy/toml-loader.test.ts
@@ -89,6 +89,24 @@ priority = 100
expect(result.errors).toHaveLength(0);
});
+ it('should parse toolAnnotations from TOML', async () => {
+ const result = await runLoadPoliciesFromToml(`
+[[rule]]
+toolName = "annotated-tool"
+toolAnnotations = { readOnlyHint = true, custom = "value" }
+decision = "allow"
+priority = 70
+`);
+
+ expect(result.rules).toHaveLength(1);
+ expect(result.rules[0].toolName).toBe('annotated-tool');
+ expect(result.rules[0].toolAnnotations).toEqual({
+ readOnlyHint: true,
+ custom: 'value',
+ });
+ expect(result.errors).toHaveLength(0);
+ });
+
it('should transform mcpName = "*" to wildcard toolName', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts
index 7be3fe27dc..6b164d59b8 100644
--- a/packages/core/src/policy/toml-loader.ts
+++ b/packages/core/src/policy/toml-loader.ts
@@ -46,6 +46,7 @@ const PolicyRuleSchema = z.object({
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
}),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
+ toolAnnotations: z.record(z.any()).optional(),
allow_redirection: z.boolean().optional(),
deny_message: z.string().optional(),
});
@@ -61,6 +62,7 @@ const SafetyCheckerRuleSchema = z.object({
commandRegex: z.string().optional(),
priority: z.number().int().default(0),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
+ toolAnnotations: z.record(z.any()).optional(),
checker: z.discriminatedUnion('type', [
z.object({
type: z.literal('in-process'),
@@ -383,6 +385,7 @@ export async function loadPoliciesFromToml(
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: rule.modes,
+ toolAnnotations: rule.toolAnnotations,
allowRedirection: rule.allow_redirection,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
denyMessage: rule.deny_message,
@@ -467,6 +470,7 @@ export async function loadPoliciesFromToml(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
checker: checker.checker as SafetyCheckerConfig,
modes: checker.modes,
+ toolAnnotations: checker.toolAnnotations,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
};
diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts
index e8aa0e6dd1..18c621c176 100644
--- a/packages/core/src/policy/types.ts
+++ b/packages/core/src/policy/types.ts
@@ -78,6 +78,7 @@ export interface ExternalCheckerConfig {
export enum InProcessCheckerType {
ALLOWED_PATH = 'allowed-path',
+ CONSECA = 'conseca',
}
/**
@@ -115,6 +116,12 @@ export interface PolicyRule {
*/
argsPattern?: RegExp;
+ /**
+ * Metadata annotations provided by the tool (e.g. readOnlyHint).
+ * All keys and values in this record must match the tool's annotations.
+ */
+ toolAnnotations?: Record;
+
/**
* The decision to make when this rule matches.
*/
@@ -165,6 +172,12 @@ export interface SafetyCheckerRule {
*/
argsPattern?: RegExp;
+ /**
+ * Metadata annotations provided by the tool (e.g. readOnlyHint).
+ * All keys and values in this record must match the tool's annotations.
+ */
+ toolAnnotations?: Record;
+
/**
* Priority of this checker. Higher numbers run first.
* Default is 0.
diff --git a/packages/core/src/safety/conseca/conseca.test.ts b/packages/core/src/safety/conseca/conseca.test.ts
new file mode 100644
index 0000000000..8d871777de
--- /dev/null
+++ b/packages/core/src/safety/conseca/conseca.test.ts
@@ -0,0 +1,279 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { ConsecaSafetyChecker } from './conseca.js';
+import { SafetyCheckDecision } from '../protocol.js';
+import type { SafetyCheckInput } from '../protocol.js';
+import {
+ logConsecaPolicyGeneration,
+ logConsecaVerdict,
+} from '../../telemetry/index.js';
+import type { Config } from '../../config/config.js';
+import * as policyGenerator from './policy-generator.js';
+import * as policyEnforcer from './policy-enforcer.js';
+
+vi.mock('../../telemetry/index.js', () => ({
+ logConsecaPolicyGeneration: vi.fn(),
+ ConsecaPolicyGenerationEvent: vi.fn(),
+ logConsecaVerdict: vi.fn(),
+ ConsecaVerdictEvent: vi.fn(),
+}));
+
+vi.mock('./policy-generator.js');
+vi.mock('./policy-enforcer.js');
+
+describe('ConsecaSafetyChecker', () => {
+ let checker: ConsecaSafetyChecker;
+ let mockConfig: Config;
+
+ beforeEach(() => {
+ // Reset singleton instance to ensure clean state
+ ConsecaSafetyChecker.resetInstance();
+ // Get the fresh singleton instance
+ checker = ConsecaSafetyChecker.getInstance();
+
+ mockConfig = {
+ enableConseca: true,
+ getToolRegistry: vi.fn().mockReturnValue({
+ getFunctionDeclarations: vi.fn().mockReturnValue([]),
+ }),
+ } as unknown as Config;
+ checker.setConfig(mockConfig);
+ vi.clearAllMocks();
+
+ // Default mock implementations
+ vi.mocked(policyGenerator.generatePolicy).mockResolvedValue({ policy: {} });
+ vi.mocked(policyEnforcer.enforcePolicy).mockResolvedValue({
+ decision: SafetyCheckDecision.ALLOW,
+ });
+ });
+
+ it('should be a singleton', () => {
+ const instance1 = ConsecaSafetyChecker.getInstance();
+ const instance2 = ConsecaSafetyChecker.getInstance();
+ expect(instance1).toBe(instance2);
+ });
+
+ it('should return ALLOW when no user prompt is present in context', async () => {
+ const input: SafetyCheckInput = {
+ protocolVersion: '1.0.0',
+ toolCall: { name: 'testTool' },
+ context: {
+ environment: { cwd: '/tmp', workspaces: [] },
+ },
+ };
+
+ const result = await checker.check(input);
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ });
+
+ it('should return ALLOW if enableConseca is false', async () => {
+ const disabledConfig = {
+ enableConseca: false,
+ } as unknown as Config;
+ checker.setConfig(disabledConfig);
+
+ const input: SafetyCheckInput = {
+ protocolVersion: '1.0.0',
+ toolCall: { name: 'testTool' },
+ context: {
+ environment: { cwd: '/tmp', workspaces: [] },
+ },
+ };
+
+ const result = await checker.check(input);
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ expect(result.reason).toBe('Conseca is disabled');
+ expect(policyGenerator.generatePolicy).not.toHaveBeenCalled();
+ });
+
+ it('getPolicy should return cached policy if user prompt matches', async () => {
+ const mockPolicy = {
+ tool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ vi.mocked(policyGenerator.generatePolicy).mockResolvedValue({
+ policy: mockPolicy,
+ });
+
+ const policy1 = await checker.getPolicy('prompt', 'trusted', mockConfig);
+ const policy2 = await checker.getPolicy('prompt', 'trusted', mockConfig);
+
+ expect(policy1).toBe(mockPolicy);
+ expect(policy2).toBe(mockPolicy);
+ expect(policyGenerator.generatePolicy).toHaveBeenCalledTimes(1);
+ });
+
+ it('getPolicy should generate new policy if user prompt changes', async () => {
+ const mockPolicy1 = {
+ tool1: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ const mockPolicy2 = {
+ tool2: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ vi.mocked(policyGenerator.generatePolicy)
+ .mockResolvedValueOnce({ policy: mockPolicy1 })
+ .mockResolvedValueOnce({ policy: mockPolicy2 });
+
+ const policy1 = await checker.getPolicy('prompt1', 'trusted', mockConfig);
+ const policy2 = await checker.getPolicy('prompt2', 'trusted', mockConfig);
+
+ expect(policy1).toBe(mockPolicy1);
+ expect(policy2).toBe(mockPolicy2);
+ expect(policyGenerator.generatePolicy).toHaveBeenCalledTimes(2);
+ });
+
+ it('check should call getPolicy and enforcePolicy', async () => {
+ const mockPolicy = {
+ tool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ vi.mocked(policyGenerator.generatePolicy).mockResolvedValue({
+ policy: mockPolicy,
+ });
+ vi.mocked(policyEnforcer.enforcePolicy).mockResolvedValue({
+ decision: SafetyCheckDecision.ALLOW,
+ });
+
+ const input: SafetyCheckInput = {
+ protocolVersion: '1.0.0',
+ toolCall: { name: 'tool', args: {} },
+ context: {
+ environment: { cwd: '.', workspaces: [] },
+ history: {
+ turns: [
+ {
+ user: { text: 'user prompt' },
+ model: {},
+ },
+ ],
+ },
+ },
+ };
+
+ const result = await checker.check(input);
+
+ expect(policyGenerator.generatePolicy).toHaveBeenCalledWith(
+ 'user prompt',
+ expect.any(String),
+ mockConfig,
+ );
+ expect(policyEnforcer.enforcePolicy).toHaveBeenCalledWith(
+ mockPolicy,
+ input.toolCall,
+ mockConfig,
+ );
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ });
+
+ it('check should return ALLOW if no user prompt found (fallback)', async () => {
+ const input: SafetyCheckInput = {
+ protocolVersion: '1.0.0',
+ toolCall: { name: 'tool', args: {} },
+ context: {
+ environment: { cwd: '.', workspaces: [] },
+ },
+ };
+
+ const result = await checker.check(input);
+
+ expect(policyGenerator.generatePolicy).not.toHaveBeenCalled();
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ });
+
+ // Test state helpers
+ it('should expose current state via helpers', async () => {
+ const mockPolicy = {
+ tool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ vi.mocked(policyGenerator.generatePolicy).mockResolvedValue({
+ policy: mockPolicy,
+ });
+
+ await checker.getPolicy('prompt', 'trusted', mockConfig);
+
+ expect(checker.getCurrentPolicy()).toBe(mockPolicy);
+ expect(checker.getActiveUserPrompt()).toBe('prompt');
+ });
+ it('should log policy generation event when config is set', async () => {
+ const mockPolicy = {
+ tool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ vi.mocked(policyGenerator.generatePolicy).mockResolvedValue({
+ policy: mockPolicy,
+ });
+
+ await checker.getPolicy('telemetry_prompt', 'trusted', mockConfig);
+
+ expect(logConsecaPolicyGeneration).toHaveBeenCalledWith(
+ mockConfig,
+ expect.anything(),
+ );
+ });
+
+ it('should log verdict event on check', async () => {
+ const mockPolicy = {
+ tool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ vi.mocked(policyGenerator.generatePolicy).mockResolvedValue({
+ policy: mockPolicy,
+ });
+ vi.mocked(policyEnforcer.enforcePolicy).mockResolvedValue({
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'Allowed by policy',
+ });
+
+ const input: SafetyCheckInput = {
+ protocolVersion: '1.0.0',
+ toolCall: { name: 'tool', args: {} },
+ context: {
+ environment: { cwd: '.', workspaces: [] },
+ history: {
+ turns: [
+ {
+ user: { text: 'user prompt' },
+ model: {},
+ },
+ ],
+ },
+ },
+ };
+
+ await checker.check(input);
+
+ expect(logConsecaVerdict).toHaveBeenCalledWith(
+ mockConfig,
+ expect.anything(),
+ );
+ });
+});
diff --git a/packages/core/src/safety/conseca/conseca.ts b/packages/core/src/safety/conseca/conseca.ts
new file mode 100644
index 0000000000..4d837bbc47
--- /dev/null
+++ b/packages/core/src/safety/conseca/conseca.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { InProcessChecker } from '../built-in.js';
+import type { SafetyCheckInput, SafetyCheckResult } from '../protocol.js';
+import { SafetyCheckDecision } from '../protocol.js';
+
+import {
+ logConsecaPolicyGeneration,
+ ConsecaPolicyGenerationEvent,
+ logConsecaVerdict,
+ ConsecaVerdictEvent,
+} from '../../telemetry/index.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+import type { Config } from '../../config/config.js';
+
+import { generatePolicy } from './policy-generator.js';
+import { enforcePolicy } from './policy-enforcer.js';
+import type { SecurityPolicy } from './types.js';
+
+export class ConsecaSafetyChecker implements InProcessChecker {
+ private static instance: ConsecaSafetyChecker | undefined;
+ private currentPolicy: SecurityPolicy | null = null;
+ private activeUserPrompt: string | null = null;
+ private config: Config | null = null;
+
+ /**
+ * Private constructor to enforce singleton pattern.
+ * Use `getInstance()` to access the instance.
+ */
+ private constructor() {}
+
+ static getInstance(): ConsecaSafetyChecker {
+ if (!ConsecaSafetyChecker.instance) {
+ ConsecaSafetyChecker.instance = new ConsecaSafetyChecker();
+ }
+ return ConsecaSafetyChecker.instance;
+ }
+
+ /**
+ * Resets the singleton instance. Use only in tests.
+ */
+ static resetInstance(): void {
+ ConsecaSafetyChecker.instance = undefined;
+ }
+
+ setConfig(config: Config): void {
+ this.config = config;
+ }
+
+ async check(input: SafetyCheckInput): Promise {
+ debugLogger.debug(
+ `[Conseca] check called. History is: ${JSON.stringify(input.context.history)}`,
+ );
+
+ if (!this.config) {
+ debugLogger.debug('[Conseca] check failed: Config not initialized');
+ return {
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'Config not initialized',
+ };
+ }
+
+ if (!this.config.enableConseca) {
+ debugLogger.debug('[Conseca] check skipped: Conseca is not enabled.');
+ return {
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'Conseca is disabled',
+ };
+ }
+
+ const userPrompt = this.extractUserPrompt(input);
+ let trustedContent = '';
+
+ const toolRegistry = this.config.getToolRegistry();
+ if (toolRegistry) {
+ const tools = toolRegistry.getFunctionDeclarations();
+ trustedContent = JSON.stringify(tools, null, 2);
+ }
+
+ if (userPrompt) {
+ await this.getPolicy(userPrompt, trustedContent, this.config);
+ } else {
+ debugLogger.debug(
+ `[Conseca] Skipping policy generation because userPrompt is null`,
+ );
+ }
+
+ let result: SafetyCheckResult;
+
+ if (!this.currentPolicy) {
+ result = {
+ decision: SafetyCheckDecision.ALLOW, // Fallback if no policy generated yet
+ reason: 'No security policy generated.',
+ error: 'No security policy generated.',
+ };
+ } else {
+ result = await enforcePolicy(
+ this.currentPolicy,
+ input.toolCall,
+ this.config,
+ );
+ }
+
+ logConsecaVerdict(
+ this.config,
+ new ConsecaVerdictEvent(
+ userPrompt || '',
+ JSON.stringify(this.currentPolicy || {}),
+ JSON.stringify(input.toolCall),
+ result.decision,
+ result.reason || '',
+ 'error' in result ? result.error : undefined,
+ ),
+ );
+
+ return result;
+ }
+
+ async getPolicy(
+ userPrompt: string,
+ trustedContent: string,
+ config: Config,
+ ): Promise {
+ if (this.activeUserPrompt === userPrompt && this.currentPolicy) {
+ return this.currentPolicy;
+ }
+
+ const { policy, error } = await generatePolicy(
+ userPrompt,
+ trustedContent,
+ config,
+ );
+ this.currentPolicy = policy;
+ this.activeUserPrompt = userPrompt;
+
+ logConsecaPolicyGeneration(
+ config,
+ new ConsecaPolicyGenerationEvent(
+ userPrompt,
+ trustedContent,
+ JSON.stringify(policy),
+ error,
+ ),
+ );
+
+ return policy;
+ }
+
+ private extractUserPrompt(input: SafetyCheckInput): string | null {
+ const prompt = input.context.history?.turns.at(-1)?.user.text;
+ if (prompt) {
+ return prompt;
+ }
+ debugLogger.debug(`[Conseca] extractUserPrompt failed.`);
+ return null;
+ }
+
+ // Helper methods for testing state
+ getCurrentPolicy(): SecurityPolicy | null {
+ return this.currentPolicy;
+ }
+
+ getActiveUserPrompt(): string | null {
+ return this.activeUserPrompt;
+ }
+}
diff --git a/packages/core/src/safety/conseca/integration.test.ts b/packages/core/src/safety/conseca/integration.test.ts
new file mode 100644
index 0000000000..f970dfb0e2
--- /dev/null
+++ b/packages/core/src/safety/conseca/integration.test.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { ConsecaSafetyChecker } from './conseca.js';
+import { InProcessCheckerType } from '../../policy/types.js';
+import { CheckerRegistry } from '../registry.js';
+
+describe('Conseca Integration', () => {
+ it('should be registered and resolvable via CheckerRegistry', () => {
+ const registry = new CheckerRegistry('.');
+ const checker = registry.resolveInProcess(InProcessCheckerType.CONSECA);
+
+ expect(checker).toBeDefined();
+ expect(checker).toBeInstanceOf(ConsecaSafetyChecker);
+ expect(checker).toBe(ConsecaSafetyChecker.getInstance());
+ });
+});
diff --git a/packages/core/src/safety/conseca/policy-enforcer.test.ts b/packages/core/src/safety/conseca/policy-enforcer.test.ts
new file mode 100644
index 0000000000..496357531c
--- /dev/null
+++ b/packages/core/src/safety/conseca/policy-enforcer.test.ts
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { enforcePolicy } from './policy-enforcer.js';
+import type { Config } from '../../config/config.js';
+import type { ContentGenerator } from '../../core/contentGenerator.js';
+import { SafetyCheckDecision } from '../protocol.js';
+import type { FunctionCall } from '@google/genai';
+import { LlmRole } from '../../telemetry/index.js';
+
+describe('policy_enforcer', () => {
+ let mockConfig: Config;
+ let mockContentGenerator: ContentGenerator;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockContentGenerator = {
+ generateContent: vi.fn(),
+ } as unknown as ContentGenerator;
+
+ mockConfig = {
+ getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
+ } as unknown as Config;
+ });
+
+ it('should return ALLOW when content generator returns ALLOW', async () => {
+ mockContentGenerator.generateContent = vi.fn().mockResolvedValue({
+ candidates: [
+ {
+ content: {
+ parts: [
+ { text: JSON.stringify({ decision: 'allow', reason: 'Safe' }) },
+ ],
+ },
+ },
+ ],
+ });
+
+ const toolCall: FunctionCall = { name: 'testTool', args: {} };
+ const policy = {
+ testTool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ const result = await enforcePolicy(policy, toolCall, mockConfig);
+
+ expect(mockConfig.getContentGenerator).toHaveBeenCalled();
+ expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: expect.any(String),
+ config: expect.objectContaining({
+ responseMimeType: 'application/json',
+ responseSchema: expect.any(Object),
+ }),
+ contents: expect.arrayContaining([
+ expect.objectContaining({
+ role: 'user',
+ parts: expect.arrayContaining([
+ expect.objectContaining({
+ text: expect.stringContaining('Security Policy:'),
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ 'conseca-policy-enforcement',
+ LlmRole.SUBAGENT,
+ );
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ });
+
+ it('should handle missing content generator gracefully (error case)', async () => {
+ vi.mocked(mockConfig.getContentGenerator).mockReturnValue(
+ undefined as unknown as ContentGenerator,
+ );
+
+ const toolCall: FunctionCall = { name: 'testTool', args: {} };
+ const policy = {
+ testTool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ const result = await enforcePolicy(policy, toolCall, mockConfig);
+
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ });
+
+ it('should ALLOW if tool name is missing with the reason and error as tool name is missing', async () => {
+ const toolCall = { args: {} } as FunctionCall;
+ const policy = {};
+ const result = await enforcePolicy(policy, toolCall, mockConfig);
+
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ expect(result.reason).toBe('Tool name is missing');
+ if (result.decision === SafetyCheckDecision.ALLOW) {
+ expect(result.error).toBe('Tool name is missing');
+ }
+ });
+
+ it('should handle empty policy by checking with LLM (fail-open/check behavior)', async () => {
+ // Even if policy is empty for the tool, we currently send it to LLM.
+ // The LLM might ALLOW or DENY based on its own judgment of "no policy".
+ // We simulate the LLM allowing the action to match the current fail-open strategy.
+ mockContentGenerator.generateContent = vi.fn().mockResolvedValue({
+ candidates: [
+ {
+ content: {
+ parts: [
+ {
+ text: JSON.stringify({
+ decision: 'allow',
+ reason: 'No restrictions',
+ }),
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ const toolCall: FunctionCall = { name: 'unknownTool', args: {} };
+ const policy = {}; // Empty policy
+ const result = await enforcePolicy(policy, toolCall, mockConfig);
+
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ expect(mockContentGenerator.generateContent).toHaveBeenCalled();
+ if (result.decision === SafetyCheckDecision.ALLOW) {
+ expect(result.error).toBeUndefined();
+ }
+ });
+
+ it('should handle malformed JSON response from LLM by failing open (ALLOW)', async () => {
+ mockContentGenerator.generateContent = vi.fn().mockResolvedValue({
+ candidates: [
+ {
+ content: {
+ parts: [{ text: 'This is not JSON' }],
+ },
+ },
+ ],
+ });
+
+ const toolCall: FunctionCall = { name: 'testTool', args: {} };
+ const policy = {
+ testTool: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ const result = await enforcePolicy(policy, toolCall, mockConfig);
+
+ expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
+ expect(result.reason).toContain('JSON Parse Error');
+ if (result.decision === SafetyCheckDecision.ALLOW) {
+ expect(result.error).toContain('JSON Parse Error');
+ }
+ });
+});
diff --git a/packages/core/src/safety/conseca/policy-enforcer.ts b/packages/core/src/safety/conseca/policy-enforcer.ts
new file mode 100644
index 0000000000..89f56baed7
--- /dev/null
+++ b/packages/core/src/safety/conseca/policy-enforcer.ts
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Config } from '../../config/config.js';
+import type { FunctionCall } from '@google/genai';
+import { SafetyCheckDecision, type SafetyCheckResult } from '../protocol.js';
+import type { SecurityPolicy } from './types.js';
+import { getResponseText } from '../../utils/partUtils.js';
+import { safeTemplateReplace } from '../../utils/textUtils.js';
+
+import { DEFAULT_GEMINI_FLASH_MODEL } from '../../config/models.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+
+import { LlmRole } from '../../telemetry/index.js';
+
+const CONSECA_ENFORCEMENT_PROMPT = `
+You are a security enforcement engine. Your goal is to check if a specific tool call complies with a given security policy.
+
+Input:
+1. **Security Policy:** A set of rules defining allowed and denied actions for this specific tool.
+2. **Tool Call:** The actual function call the system intends to execute.
+
+Security Policy:
+{{policy}}
+
+Tool Call:
+{{tool_call}}
+
+Evaluate the tool call against the policy.
+1. Check if the tool is allowed.
+2. Check if the arguments match the constraints.
+3. Output a JSON object with:
+ - "decision": "allow", "deny", or "ask_user".
+ - "reason": A brief explanation.
+
+Output strictly JSON.
+`;
+
+import { z } from 'zod';
+import { zodToJsonSchema } from 'zod-to-json-schema';
+
+const EnforcementResultSchema = z.object({
+ decision: z.enum(['allow', 'deny', 'ask_user']),
+ reason: z.string(),
+});
+
+/**
+ * Enforces the security policy for a given tool call.
+ */
+export async function enforcePolicy(
+ policy: SecurityPolicy,
+ toolCall: FunctionCall,
+ config: Config,
+): Promise {
+ const model = DEFAULT_GEMINI_FLASH_MODEL;
+ const contentGenerator = config.getContentGenerator();
+
+ if (!contentGenerator) {
+ return {
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'Content generator not initialized',
+ error: 'Content generator not initialized',
+ };
+ }
+
+ const toolName = toolCall.name;
+ // If tool name is missing, we cannot enforce the policy. Allow by default.
+ if (!toolName) {
+ return {
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'Tool name is missing',
+ error: 'Tool name is missing',
+ };
+ }
+
+ const toolPolicyStr = JSON.stringify(policy[toolName] || {}, null, 2);
+ const toolCallStr = JSON.stringify(toolCall, null, 2);
+ debugLogger.debug(
+ `[Conseca] Enforcing policy for tool: ${toolName}`,
+ toolCall,
+ toolPolicyStr,
+ toolCallStr,
+ );
+
+ try {
+ const result = await contentGenerator.generateContent(
+ {
+ model,
+ config: {
+ responseMimeType: 'application/json',
+ responseSchema: zodToJsonSchema(EnforcementResultSchema, {
+ target: 'openApi3',
+ }),
+ },
+ contents: [
+ {
+ role: 'user',
+ parts: [
+ {
+ text: safeTemplateReplace(CONSECA_ENFORCEMENT_PROMPT, {
+ policy: toolPolicyStr,
+ tool_call: toolCallStr,
+ }),
+ },
+ ],
+ },
+ ],
+ },
+ 'conseca-policy-enforcement',
+ LlmRole.SUBAGENT,
+ );
+
+ const responseText = getResponseText(result);
+ debugLogger.debug(`[Conseca] Enforcement Raw Response: ${responseText}`);
+
+ if (!responseText) {
+ return {
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'Empty response from policy enforcer',
+ error: 'Empty response from policy enforcer',
+ };
+ }
+
+ try {
+ const parsed = EnforcementResultSchema.parse(JSON.parse(responseText));
+ debugLogger.debug(`[Conseca] Enforcement Parsed:`, parsed);
+
+ let decision: SafetyCheckDecision;
+ switch (parsed.decision) {
+ case 'allow':
+ decision = SafetyCheckDecision.ALLOW;
+ break;
+ case 'ask_user':
+ decision = SafetyCheckDecision.ASK_USER;
+ break;
+ case 'deny':
+ default:
+ decision = SafetyCheckDecision.DENY;
+ break;
+ }
+
+ return {
+ decision,
+ reason: parsed.reason,
+ };
+ } catch (parseError) {
+ return {
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'JSON Parse Error in enforcement response',
+ error: `JSON Parse Error: ${parseError instanceof Error ? parseError.message : String(parseError)}. Raw: ${responseText}`,
+ };
+ }
+ } catch (error) {
+ debugLogger.error('Policy enforcement failed:', error);
+ return {
+ decision: SafetyCheckDecision.ALLOW,
+ reason: 'Policy enforcement failed',
+ error: `Policy enforcement failed: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+}
diff --git a/packages/core/src/safety/conseca/policy-generator.test.ts b/packages/core/src/safety/conseca/policy-generator.test.ts
new file mode 100644
index 0000000000..122d8b0a27
--- /dev/null
+++ b/packages/core/src/safety/conseca/policy-generator.test.ts
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { generatePolicy } from './policy-generator.js';
+import { SafetyCheckDecision } from '../protocol.js';
+import type { Config } from '../../config/config.js';
+import type { ContentGenerator } from '../../core/contentGenerator.js';
+import { LlmRole } from '../../telemetry/index.js';
+
+describe('policy_generator', () => {
+ let mockConfig: Config;
+ let mockContentGenerator: ContentGenerator;
+
+ beforeEach(() => {
+ mockContentGenerator = {
+ generateContent: vi.fn(),
+ } as unknown as ContentGenerator;
+
+ mockConfig = {
+ getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
+ } as unknown as Config;
+ });
+
+ it('should return a policy object when content generator is available', async () => {
+ const mockPolicy = {
+ read_file: {
+ permissions: SafetyCheckDecision.ALLOW,
+ constraints: 'None',
+ rationale: 'Test',
+ },
+ };
+ mockContentGenerator.generateContent = vi.fn().mockResolvedValue({
+ candidates: [
+ {
+ content: {
+ parts: [
+ {
+ text: JSON.stringify({
+ policies: [
+ {
+ tool_name: 'read_file',
+ policy: mockPolicy.read_file,
+ },
+ ],
+ }),
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ const result = await generatePolicy(
+ 'test prompt',
+ 'trusted content',
+ mockConfig,
+ );
+
+ expect(mockConfig.getContentGenerator).toHaveBeenCalled();
+ expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ model: expect.any(String),
+ config: expect.objectContaining({
+ responseMimeType: 'application/json',
+ responseSchema: expect.any(Object),
+ }),
+ contents: expect.any(Array),
+ }),
+ 'conseca-policy-generation',
+ LlmRole.SUBAGENT,
+ );
+ expect(result.policy).toEqual(mockPolicy);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('should handle missing content generator gracefully', async () => {
+ vi.mocked(mockConfig.getContentGenerator).mockReturnValue(
+ undefined as unknown as ContentGenerator,
+ );
+
+ const result = await generatePolicy(
+ 'test prompt',
+ 'trusted content',
+ mockConfig,
+ );
+
+ expect(result.policy).toEqual({});
+ expect(result.error).toBe('Content generator not initialized');
+ });
+ it('should prevent template injection (double interpolation)', async () => {
+ mockContentGenerator.generateContent = vi.fn().mockResolvedValue({});
+
+ const userPrompt = '{{trusted_content}}';
+ const trustedContent = 'SECRET_DATA';
+
+ await generatePolicy(userPrompt, trustedContent, mockConfig);
+
+ const generateContentCall = vi.mocked(mockContentGenerator.generateContent)
+ .mock.calls[0];
+ const request = generateContentCall[0] as {
+ contents: Array<{ parts: Array<{ text: string }> }>;
+ };
+ const promptText = request.contents[0].parts[0].text;
+
+ // The user prompt should contain the literal placeholder, NOT the secret data
+ expect(promptText).toContain('User Prompt: "{{trusted_content}}"');
+ expect(promptText).not.toContain('User Prompt: "SECRET_DATA"');
+
+ // The trusted tools section SHOULD contain the secret data
+ expect(promptText).toContain('Trusted Tools (Context):\nSECRET_DATA');
+ });
+});
diff --git a/packages/core/src/safety/conseca/policy-generator.ts b/packages/core/src/safety/conseca/policy-generator.ts
new file mode 100644
index 0000000000..6778a9da78
--- /dev/null
+++ b/packages/core/src/safety/conseca/policy-generator.ts
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Config } from '../../config/config.js';
+import type { SecurityPolicy } from './types.js';
+import { getResponseText } from '../../utils/partUtils.js';
+import { safeTemplateReplace } from '../../utils/textUtils.js';
+import { DEFAULT_GEMINI_FLASH_MODEL } from '../../config/models.js';
+import { debugLogger } from '../../utils/debugLogger.js';
+import { SafetyCheckDecision } from '../protocol.js';
+
+import { LlmRole } from '../../telemetry/index.js';
+
+const CONSECA_POLICY_GENERATION_PROMPT = `
+You are a security expert responsible for generating fine-grained security policies for a large language model integrated into a command-line tool. Your role is to act as a "policy generator" that creates temporary, context-specific rules based on a user's prompt and the tools available to the main LLM.
+
+Your primary goal is to enforce the principle of least privilege. The policies you create should be as restrictive as possible while still allowing the main LLM to complete the user's requested task.
+
+For each tool that is relevant to the user's prompt, you must generate a policy object.
+
+### Output Format
+You must return a JSON object with a "policies" key, which is an array of objects. Each object must have:
+- "tool_name": The name of the tool.
+- "policy": An object with:
+ - "permissions": "allow" | "deny" | "ask_user"
+ - "constraints": A detailed description of conditions (e.g. allowed files, arguments).
+ - "rationale": Explanation for the policy.
+
+Example JSON:
+\`\`\`json
+{
+ "policies": [
+ {
+ "tool_name": "read_file",
+ "policy": {
+ "permissions": "allow",
+ "constraints": "Only allow reading 'main.py'.",
+ "rationale": "User asked to read main.py"
+ }
+ },
+ {
+ "tool_name": "run_shell_command",
+ "policy": {
+ "permissions": "deny",
+ "constraints": "None",
+ "rationale": "Shell commands are not needed for this task"
+ }
+ }
+ ]
+}
+\`\`\`
+
+### Guiding Principles:
+1. **Permissions:**
+ * **allow:** Required tools for the task.
+ * **deny:** Tools clearly outside the scope.
+ * **ask_user:** Destructive actions or ambiguity.
+
+2. **Constraints:**
+ * Be specific! Restrict file paths, command arguments, etc.
+
+3. **Rationale:**
+ * Reference the user's prompt.
+
+User Prompt: "{{user_prompt}}"
+
+Trusted Tools (Context):
+{{trusted_content}}
+`;
+
+import { z } from 'zod';
+import { zodToJsonSchema } from 'zod-to-json-schema';
+
+const ToolPolicySchema = z.object({
+ permissions: z.nativeEnum(SafetyCheckDecision),
+ constraints: z.string(),
+ rationale: z.string(),
+});
+
+const SecurityPolicyResponseSchema = z.object({
+ policies: z.array(
+ z.object({
+ tool_name: z.string(),
+ policy: ToolPolicySchema,
+ }),
+ ),
+});
+
+export interface PolicyGenerationResult {
+ policy: SecurityPolicy;
+ error?: string;
+}
+
+/**
+ * Generates a security policy for the given user prompt and trusted content.
+ */
+export async function generatePolicy(
+ userPrompt: string,
+ trustedContent: string,
+ config: Config,
+): Promise {
+ const model = DEFAULT_GEMINI_FLASH_MODEL;
+ const contentGenerator = config.getContentGenerator();
+
+ if (!contentGenerator) {
+ return { policy: {}, error: 'Content generator not initialized' };
+ }
+
+ try {
+ const result = await contentGenerator.generateContent(
+ {
+ model,
+ config: {
+ responseMimeType: 'application/json',
+ responseSchema: zodToJsonSchema(SecurityPolicyResponseSchema, {
+ target: 'openApi3',
+ }),
+ },
+ contents: [
+ {
+ role: 'user',
+ parts: [
+ {
+ text: safeTemplateReplace(CONSECA_POLICY_GENERATION_PROMPT, {
+ user_prompt: userPrompt,
+ trusted_content: trustedContent,
+ }),
+ },
+ ],
+ },
+ ],
+ },
+ 'conseca-policy-generation',
+ LlmRole.SUBAGENT,
+ );
+
+ const responseText = getResponseText(result);
+ debugLogger.debug(
+ `[Conseca] Policy Generation Raw Response: ${responseText}`,
+ );
+
+ if (!responseText) {
+ return { policy: {}, error: 'Empty response from policy generator' };
+ }
+
+ try {
+ const parsed = SecurityPolicyResponseSchema.parse(
+ JSON.parse(responseText),
+ );
+ const policiesList = parsed.policies;
+ const policy: SecurityPolicy = {};
+ for (const item of policiesList) {
+ policy[item.tool_name] = item.policy;
+ }
+
+ debugLogger.debug(`[Conseca] Policy Generation Parsed:`, policy);
+ return { policy };
+ } catch (parseError) {
+ debugLogger.debug(
+ `[Conseca] Policy Generation JSON Parse Error:`,
+ parseError,
+ );
+ return {
+ policy: {},
+ error: `JSON Parse Error: ${parseError instanceof Error ? parseError.message : String(parseError)}. Raw: ${responseText}`,
+ };
+ }
+ } catch (error) {
+ debugLogger.error('Policy generation failed:', error);
+ return {
+ policy: {},
+ error: `Policy generation failed: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+}
diff --git a/packages/core/src/safety/conseca/types.ts b/packages/core/src/safety/conseca/types.ts
new file mode 100644
index 0000000000..70e1678bc2
--- /dev/null
+++ b/packages/core/src/safety/conseca/types.ts
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { SafetyCheckDecision } from '../protocol.js';
+
+export interface ToolPolicy {
+ permissions: SafetyCheckDecision;
+ constraints: string;
+ rationale: string;
+}
+
+/**
+ * A map of tool names to their specific security policies.
+ */
+export type SecurityPolicy = Record;
diff --git a/packages/core/src/safety/context-builder.test.ts b/packages/core/src/safety/context-builder.test.ts
index 3ee9da432c..56ceee15ef 100644
--- a/packages/core/src/safety/context-builder.test.ts
+++ b/packages/core/src/safety/context-builder.test.ts
@@ -7,50 +7,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContextBuilder } from './context-builder.js';
import type { Config } from '../config/config.js';
-import type { ConversationTurn } from './protocol.js';
+import type { Content, FunctionCall } from '@google/genai';
describe('ContextBuilder', () => {
let contextBuilder: ContextBuilder;
- let mockConfig: Config;
- const mockHistory: ConversationTurn[] = [
- { user: { text: 'hello' }, model: { text: 'hi' } },
- ];
+ let mockConfig: Partial;
+ let mockHistory: Content[];
const mockCwd = '/home/user/project';
const mockWorkspaces = ['/home/user/project'];
beforeEach(() => {
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
+ mockHistory = [];
+
mockConfig = {
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: vi.fn().mockReturnValue(mockWorkspaces),
}),
- apiKey: 'secret-api-key',
- somePublicConfig: 'public-value',
- nested: {
- secretToken: 'hidden',
- public: 'visible',
- },
- } as unknown as Config;
- contextBuilder = new ContextBuilder(mockConfig, mockHistory);
+ getQuestion: vi.fn().mockReturnValue('mock question'),
+ getGeminiClient: vi.fn().mockReturnValue({
+ getHistory: vi.fn().mockImplementation(() => mockHistory),
+ }),
+ };
+ contextBuilder = new ContextBuilder(mockConfig as unknown as Config);
});
- it('should build full context with all fields', () => {
+ it('should build full context with empty history', () => {
+ mockHistory = [];
+ // Should inject current question
const context = contextBuilder.buildFullContext();
- expect(context.environment.cwd).toBe(mockCwd);
- expect(context.environment.workspaces).toEqual(mockWorkspaces);
- expect(context.history?.turns).toEqual(mockHistory);
+ expect(context.history?.turns).toEqual([
+ {
+ user: { text: 'mock question' },
+ model: {},
+ },
+ ]);
});
- it('should build minimal context with only required keys', () => {
+ it('should build full context with existing history (User -> Model)', () => {
+ mockHistory = [
+ { role: 'user', parts: [{ text: 'Hello' }] },
+ { role: 'model', parts: [{ text: 'Hi there' }] },
+ ];
+ // Should NOT inject current question if history exists
+ const context = contextBuilder.buildFullContext();
+ expect(context.history?.turns).toHaveLength(1);
+ expect(context.history?.turns[0]).toEqual({
+ user: { text: 'Hello' },
+ model: { text: 'Hi there', toolCalls: [] },
+ });
+ });
+
+ it('should handle history with tool calls', () => {
+ const mockToolCall: FunctionCall = {
+ id: 'call_1',
+ name: 'list_files',
+ args: { path: '.' },
+ };
+ mockHistory = [
+ { role: 'user', parts: [{ text: 'List files' }] },
+ {
+ role: 'model',
+ parts: [
+ { text: 'Sure, listing files.' },
+ { functionCall: mockToolCall },
+ ],
+ },
+ ];
+
+ const context = contextBuilder.buildFullContext();
+ expect(context.history?.turns).toHaveLength(1);
+ expect(context.history?.turns[0].model.toolCalls).toEqual([mockToolCall]);
+ expect(context.history?.turns[0].model.text).toBe('Sure, listing files.');
+ });
+
+ it('should handle orphan model response (Model starts conversation)', () => {
+ mockHistory = [
+ { role: 'model', parts: [{ text: 'Welcome!' }] },
+ { role: 'user', parts: [{ text: 'Thanks' }] },
+ ];
+
+ const context = contextBuilder.buildFullContext();
+ // 1. Orphan model response -> Turn 1: User="" Model="Welcome!"
+ // 2. User "Thanks" -> Turn 2: User="Thanks" Model={} (pending)
+ expect(context.history?.turns).toHaveLength(2);
+ expect(context.history?.turns[0]).toEqual({
+ user: { text: '' },
+ model: { text: 'Welcome!', toolCalls: [] },
+ });
+ expect(context.history?.turns[1]).toEqual({
+ user: { text: 'Thanks' },
+ model: {},
+ });
+ });
+
+ it('should handle multiple user turns in a row', () => {
+ mockHistory = [
+ { role: 'user', parts: [{ text: 'Q1' }] },
+ { role: 'user', parts: [{ text: 'Q2' }] },
+ { role: 'model', parts: [{ text: 'A2' }] },
+ ];
+
+ const context = contextBuilder.buildFullContext();
+ // 1. "Q1" -> Turn 1: User="Q1" Model={}
+ // 2. "Q2" -> Turn 2: User="Q2" Model="A2"
+ expect(context.history?.turns).toHaveLength(2);
+ expect(context.history?.turns[0]).toEqual({
+ user: { text: 'Q1' },
+ model: {},
+ });
+ expect(context.history?.turns[1]).toEqual({
+ user: { text: 'Q2' },
+ model: { text: 'A2', toolCalls: [] },
+ });
+ });
+
+ it('should build minimal context', () => {
+ mockHistory = [{ role: 'user', parts: [{ text: 'test' }] }];
const context = contextBuilder.buildMinimalContext(['environment']);
+
expect(context).toHaveProperty('environment');
- expect(context).not.toHaveProperty('config');
expect(context).not.toHaveProperty('history');
});
- it('should handle missing history', () => {
- contextBuilder = new ContextBuilder(mockConfig);
+ it('should handle undefined parts gracefully', () => {
+ mockHistory = [
+ { role: 'user', parts: undefined as unknown as [] },
+ { role: 'model', parts: undefined as unknown as [] },
+ ];
const context = contextBuilder.buildFullContext();
- expect(context.history?.turns).toEqual([]);
+ expect(context.history?.turns).toHaveLength(1);
+ expect(context.history?.turns[0]).toEqual({
+ user: { text: '' },
+ model: { text: '', toolCalls: [] },
+ });
});
});
diff --git a/packages/core/src/safety/context-builder.ts b/packages/core/src/safety/context-builder.ts
index f857104197..c7b33f5e2f 100644
--- a/packages/core/src/safety/context-builder.ts
+++ b/packages/core/src/safety/context-builder.ts
@@ -6,20 +6,39 @@
import type { SafetyCheckInput, ConversationTurn } from './protocol.js';
import type { Config } from '../config/config.js';
+import { debugLogger } from '../utils/debugLogger.js';
+import type { Content, FunctionCall } from '@google/genai';
/**
* Builds context objects for safety checkers, ensuring sensitive data is filtered.
*/
export class ContextBuilder {
- constructor(
- private readonly config: Config,
- private readonly conversationHistory: ConversationTurn[] = [],
- ) {}
+ constructor(private readonly config: Config) {}
/**
* Builds the full context object with all available data.
*/
buildFullContext(): SafetyCheckInput['context'] {
+ const clientHistory = this.config.getGeminiClient()?.getHistory() || [];
+ const history = this.convertHistoryToTurns(clientHistory);
+
+ debugLogger.debug(
+ `[ContextBuilder] buildFullContext called. Converted history length: ${history.length}`,
+ );
+
+ // ContextBuilder's responsibility is to provide the *current* context.
+ // If the conversation hasn't started (history is empty), we check if there's a pending question.
+ // However, if the history is NOT empty, we trust it reflects the true state.
+ const currentQuestion = this.config.getQuestion();
+ if (currentQuestion && history.length === 0) {
+ history.push({
+ user: {
+ text: currentQuestion,
+ },
+ model: {},
+ });
+ }
+
return {
environment: {
cwd: process.cwd(),
@@ -29,7 +48,7 @@ export class ContextBuilder {
.getDirectories() as string[],
},
history: {
- turns: this.conversationHistory,
+ turns: history,
},
};
}
@@ -53,4 +72,51 @@ export class ContextBuilder {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return minimalContext as SafetyCheckInput['context'];
}
+
+ // Helper to convert Google GenAI Content[] to Safety Protocol ConversationTurn[]
+ private convertHistoryToTurns(history: Content[]): ConversationTurn[] {
+ const turns: ConversationTurn[] = [];
+ let currentUserRequest: { text: string } | undefined;
+
+ for (const content of history) {
+ if (content.role === 'user') {
+ if (currentUserRequest) {
+ // Previous user turn didn't have a matching model response (or it was filtered out)
+ // Push it as a turn with empty model response
+ turns.push({ user: currentUserRequest, model: {} });
+ }
+ currentUserRequest = {
+ text: content.parts?.map((p) => p.text).join('') || '',
+ };
+ } else if (content.role === 'model') {
+ const modelResponse = {
+ text:
+ content.parts
+ ?.filter((p) => p.text)
+ .map((p) => p.text)
+ .join('') || '',
+ toolCalls:
+ content.parts
+ ?.filter((p) => 'functionCall' in p)
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ .map((p) => p.functionCall as FunctionCall) || [],
+ };
+
+ if (currentUserRequest) {
+ turns.push({ user: currentUserRequest, model: modelResponse });
+ currentUserRequest = undefined;
+ } else {
+ // Model response without preceding user request.
+ // This creates a turn with empty user text.
+ turns.push({ user: { text: '' }, model: modelResponse });
+ }
+ }
+ }
+
+ if (currentUserRequest) {
+ turns.push({ user: currentUserRequest, model: {} });
+ }
+
+ return turns;
+ }
}
diff --git a/packages/core/src/safety/protocol.ts b/packages/core/src/safety/protocol.ts
index 5028bd6897..6bc16d746c 100644
--- a/packages/core/src/safety/protocol.ts
+++ b/packages/core/src/safety/protocol.ts
@@ -89,6 +89,10 @@ export type SafetyCheckResult =
* This will be shown to the user.
*/
reason?: string;
+ /**
+ * Optional error message if the decision was made due to a system failure (fail-open).
+ */
+ error?: string;
}
| {
decision: SafetyCheckDecision.DENY;
diff --git a/packages/core/src/safety/registry.test.ts b/packages/core/src/safety/registry.test.ts
index b0f9d26744..81c9a36eff 100644
--- a/packages/core/src/safety/registry.test.ts
+++ b/packages/core/src/safety/registry.test.ts
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { CheckerRegistry } from './registry.js';
import { InProcessCheckerType } from '../policy/types.js';
import { AllowedPathChecker } from './built-in.js';
+import { ConsecaSafetyChecker } from './conseca/conseca.js';
describe('CheckerRegistry', () => {
let registry: CheckerRegistry;
@@ -18,10 +19,15 @@ describe('CheckerRegistry', () => {
});
it('should resolve built-in in-process checkers', () => {
- const checker = registry.resolveInProcess(
+ const allowedPathChecker = registry.resolveInProcess(
InProcessCheckerType.ALLOWED_PATH,
);
- expect(checker).toBeInstanceOf(AllowedPathChecker);
+ expect(allowedPathChecker).toBeInstanceOf(AllowedPathChecker);
+
+ const consecaChecker = registry.resolveInProcess(
+ InProcessCheckerType.CONSECA,
+ );
+ expect(consecaChecker).toBeInstanceOf(ConsecaSafetyChecker);
});
it('should throw for unknown in-process checkers', () => {
diff --git a/packages/core/src/safety/registry.ts b/packages/core/src/safety/registry.ts
index 2775a82fd4..9fe391a9a9 100644
--- a/packages/core/src/safety/registry.ts
+++ b/packages/core/src/safety/registry.ts
@@ -9,6 +9,8 @@ import * as fs from 'node:fs';
import { type InProcessChecker, AllowedPathChecker } from './built-in.js';
import { InProcessCheckerType } from '../policy/types.js';
+import { ConsecaSafetyChecker } from './conseca/conseca.js';
+
/**
* Registry for managing safety checker resolution.
*/
@@ -17,10 +19,22 @@ export class CheckerRegistry {
// No external built-ins for now
]);
- private static readonly BUILT_IN_IN_PROCESS_CHECKERS = new Map<
- string,
- InProcessChecker
- >([[InProcessCheckerType.ALLOWED_PATH, new AllowedPathChecker()]]);
+ private static BUILT_IN_IN_PROCESS_CHECKERS:
+ | Map
+ | undefined;
+
+ private static getBuiltInInProcessCheckers(): Map {
+ if (!CheckerRegistry.BUILT_IN_IN_PROCESS_CHECKERS) {
+ CheckerRegistry.BUILT_IN_IN_PROCESS_CHECKERS = new Map<
+ string,
+ InProcessChecker
+ >([
+ [InProcessCheckerType.ALLOWED_PATH, new AllowedPathChecker()],
+ [InProcessCheckerType.CONSECA, ConsecaSafetyChecker.getInstance()],
+ ]);
+ }
+ return CheckerRegistry.BUILT_IN_IN_PROCESS_CHECKERS;
+ }
// Regex to validate checker names (alphanumeric and hyphens only)
private static readonly VALID_NAME_PATTERN = /^[a-z0-9-]+$/;
@@ -58,14 +72,14 @@ export class CheckerRegistry {
throw new Error(`Invalid checker name "${name}".`);
}
- const checker = CheckerRegistry.BUILT_IN_IN_PROCESS_CHECKERS.get(name);
+ const checker = CheckerRegistry.getBuiltInInProcessCheckers().get(name);
if (checker) {
return checker;
}
throw new Error(
`Unknown in-process checker "${name}". Available: ${Array.from(
- CheckerRegistry.BUILT_IN_IN_PROCESS_CHECKERS.keys(),
+ CheckerRegistry.getBuiltInInProcessCheckers().keys(),
).join(', ')}`,
);
}
@@ -77,7 +91,7 @@ export class CheckerRegistry {
static getBuiltInCheckers(): string[] {
return [
...Array.from(this.BUILT_IN_EXTERNAL_CHECKERS.keys()),
- ...Array.from(this.BUILT_IN_IN_PROCESS_CHECKERS.keys()),
+ ...Array.from(this.getBuiltInInProcessCheckers().keys()),
];
}
}
diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts
index 61699d07a6..97ab4bfcd4 100644
--- a/packages/core/src/scheduler/scheduler.test.ts
+++ b/packages/core/src/scheduler/scheduler.test.ts
@@ -201,6 +201,12 @@ describe('Scheduler (Orchestrator)', () => {
mockQueue.length = 0;
}),
clearBatch: vi.fn(),
+ replaceActiveCallWithTailCall: vi.fn((id: string, nextCall: ToolCall) => {
+ if (mockActiveCallsMap.has(id)) {
+ mockActiveCallsMap.delete(id);
+ mockQueue.unshift(nextCall);
+ }
+ }),
} as unknown as Mocked;
// Define getters for accessors idiomatically
@@ -1006,6 +1012,113 @@ describe('Scheduler (Orchestrator)', () => {
const result = await (scheduler as any)._processNextItem(signal);
expect(result).toBe(false);
});
+
+ describe('Tail Calls', () => {
+ it('should replace the active call with a new tool call and re-run the loop when tail call is requested', async () => {
+ // Setup: Tool A will return a success with a tail call request to Tool B
+ const mockResponse = {
+ callId: 'call-1',
+ responseParts: [],
+ } as unknown as ToolCallResponseInfo;
+
+ mockExecutor.execute
+ .mockResolvedValueOnce({
+ status: 'success',
+ response: mockResponse,
+ tailToolCallRequest: {
+ name: 'tool-b',
+ args: { key: 'value' },
+ },
+ request: req1,
+ } as unknown as SuccessfulToolCall)
+ .mockResolvedValueOnce({
+ status: 'success',
+ response: mockResponse,
+ request: {
+ ...req1,
+ name: 'tool-b',
+ args: { key: 'value' },
+ originalRequestName: 'test-tool',
+ },
+ } as unknown as SuccessfulToolCall);
+
+ const mockToolB = {
+ name: 'tool-b',
+ build: vi.fn().mockReturnValue({}),
+ } as unknown as AnyDeclarativeTool;
+
+ vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockToolB);
+
+ await scheduler.schedule(req1, signal);
+
+ // Assert: The state manager is instructed to replace the call
+ expect(
+ mockStateManager.replaceActiveCallWithTailCall,
+ ).toHaveBeenCalledWith(
+ 'call-1',
+ expect.objectContaining({
+ request: expect.objectContaining({
+ callId: 'call-1',
+ name: 'tool-b',
+ args: { key: 'value' },
+ originalRequestName: 'test-tool', // Preserves original name
+ }),
+ tool: mockToolB,
+ }),
+ );
+
+ // Assert: The executor should be called twice (once for Tool A, once for Tool B)
+ expect(mockExecutor.execute).toHaveBeenCalledTimes(2);
+ });
+
+ it('should inject an errored tool call if the tail tool is not found', async () => {
+ const mockResponse = {
+ callId: 'call-1',
+ responseParts: [],
+ } as unknown as ToolCallResponseInfo;
+
+ mockExecutor.execute.mockResolvedValue({
+ status: 'success',
+ response: mockResponse,
+ tailToolCallRequest: {
+ name: 'missing-tool',
+ args: {},
+ },
+ request: req1,
+ } as unknown as SuccessfulToolCall);
+
+ // Tool registry returns undefined for missing-tool, but valid tool for test-tool
+ vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => {
+ if (name === 'test-tool') {
+ return {
+ name: 'test-tool',
+ build: vi.fn().mockReturnValue({}),
+ } as unknown as AnyDeclarativeTool;
+ }
+ return undefined;
+ });
+
+ await scheduler.schedule(req1, signal);
+
+ // Assert: Replaces active call with an errored call
+ expect(
+ mockStateManager.replaceActiveCallWithTailCall,
+ ).toHaveBeenCalledWith(
+ 'call-1',
+ expect.objectContaining({
+ status: 'error',
+ request: expect.objectContaining({
+ callId: 'call-1',
+ name: 'missing-tool', // Name of the failed tail call
+ originalRequestName: 'test-tool',
+ }),
+ response: expect.objectContaining({
+ errorType: ToolErrorType.TOOL_NOT_REGISTERED,
+ }),
+ }),
+ );
+ });
+ });
});
describe('Tool Call Context Propagation', () => {
diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts
index 3ee55975f1..0733370645 100644
--- a/packages/core/src/scheduler/scheduler.ts
+++ b/packages/core/src/scheduler/scheduler.ts
@@ -19,6 +19,7 @@ import {
type ExecutingToolCall,
type ValidatingToolCall,
type ErroredToolCall,
+ type SuccessfulToolCall,
CoreToolCallStatus,
type ScheduledToolCall,
} from './types.js';
@@ -446,13 +447,16 @@ export class Scheduler {
c.status === CoreToolCallStatus.Scheduled || this.isTerminal(c.status),
);
+ let madeProgress = false;
if (allReady && scheduledCalls.length > 0) {
- await Promise.all(scheduledCalls.map((c) => this._execute(c, signal)));
+ const execResults = await Promise.all(
+ scheduledCalls.map((c) => this._execute(c, signal)),
+ );
+ madeProgress = execResults.some((res) => res);
}
// 3. Finalize terminal calls
activeCalls = this.state.allActiveCalls;
- let madeProgress = false;
for (const call of activeCalls) {
if (this.isTerminal(call.status)) {
this.state.finalizeCall(call.request.callId);
@@ -595,12 +599,12 @@ export class Scheduler {
// --- Sub-phase Handlers ---
/**
- * Executes the tool and records the result.
+ * Executes the tool and records the result. Returns true if a new tool call was added.
*/
private async _execute(
toolCall: ScheduledToolCall,
signal: AbortSignal,
- ): Promise {
+ ): Promise {
const callId = toolCall.request.callId;
if (signal.aborted) {
this.state.updateStatus(
@@ -608,7 +612,7 @@ export class Scheduler {
CoreToolCallStatus.Cancelled,
'Operation cancelled',
);
- return;
+ return false;
}
this.state.updateStatus(callId, CoreToolCallStatus.Executing);
@@ -642,6 +646,64 @@ export class Scheduler {
}),
);
+ if (
+ (result.status === CoreToolCallStatus.Success ||
+ result.status === CoreToolCallStatus.Error) &&
+ result.tailToolCallRequest
+ ) {
+ // Log the intermediate tool call before it gets replaced.
+ const intermediateCall: SuccessfulToolCall | ErroredToolCall = {
+ request: activeCall.request,
+ tool: activeCall.tool,
+ invocation: activeCall.invocation,
+ status: result.status,
+ response: result.response,
+ durationMs: activeCall.startTime
+ ? Date.now() - activeCall.startTime
+ : undefined,
+ outcome: activeCall.outcome,
+ schedulerId: this.schedulerId,
+ };
+ logToolCall(this.config, new ToolCallEvent(intermediateCall));
+
+ const tailRequest = result.tailToolCallRequest;
+ const originalCallId = result.request.callId;
+ const originalRequestName =
+ result.request.originalRequestName || result.request.name;
+
+ const newTool = this.config.getToolRegistry().getTool(tailRequest.name);
+
+ const newRequest: ToolCallRequestInfo = {
+ callId: originalCallId,
+ name: tailRequest.name,
+ args: tailRequest.args,
+ originalRequestName,
+ isClientInitiated: result.request.isClientInitiated,
+ prompt_id: result.request.prompt_id,
+ schedulerId: this.schedulerId,
+ };
+
+ if (!newTool) {
+ // Enqueue an errored tool call
+ const errorCall = this._createToolNotFoundErroredToolCall(
+ newRequest,
+ this.config.getToolRegistry().getAllToolNames(),
+ );
+ this.state.replaceActiveCallWithTailCall(callId, errorCall);
+ } else {
+ // Enqueue a validating tool call for the new tail tool
+ const validatingCall = this._validateAndCreateToolCall(
+ newRequest,
+ newTool,
+ activeCall.approvalMode ?? this.config.getApprovalMode(),
+ );
+ this.state.replaceActiveCallWithTailCall(callId, validatingCall);
+ }
+
+ // Loop continues, picking up the new tail call at the front of the queue.
+ return true;
+ }
+
if (result.status === CoreToolCallStatus.Success) {
this.state.updateStatus(
callId,
@@ -661,6 +723,7 @@ export class Scheduler {
result.response,
);
}
+ return false;
}
private _processNextInRequestQueue() {
diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts
index fb16125340..fe727f6dd3 100644
--- a/packages/core/src/scheduler/state-manager.ts
+++ b/packages/core/src/scheduler/state-manager.ts
@@ -187,6 +187,19 @@ export class SchedulerStateManager {
this.emitUpdate();
}
+ /**
+ * Replaces the currently active call with a new call, placing the new call
+ * at the front of the queue to be processed immediately in the next tick.
+ * Used for Tail Calls to chain execution without finalizing the original call.
+ */
+ replaceActiveCallWithTailCall(callId: string, nextCall: ToolCall): void {
+ if (this.activeCalls.has(callId)) {
+ this.activeCalls.delete(callId);
+ this.queue.unshift(nextCall);
+ this.emitUpdate();
+ }
+ }
+
cancelAllQueued(reason: string): void {
if (this.queue.length === 0) {
return;
diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts
index 1cbee019c6..29db841aac 100644
--- a/packages/core/src/scheduler/tool-executor.test.ts
+++ b/packages/core/src/scheduler/tool-executor.test.ts
@@ -252,7 +252,17 @@ describe('ToolExecutor', () => {
// 2. Mock executeToolWithHooks to trigger the PID callback
const testPid = 12345;
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
- async (_inv, _name, _sig, _tool, _liveCb, _shellCfg, setPidCallback) => {
+ async (
+ _inv,
+ _name,
+ _sig,
+ _tool,
+ _liveCb,
+ _shellCfg,
+ setPidCallback,
+ _config,
+ _originalRequestName,
+ ) => {
// Simulate the shell tool reporting a PID
if (setPidCallback) {
setPidCallback(testPid);
diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts
index b94b0e5184..9ae00b24a7 100644
--- a/packages/core/src/scheduler/tool-executor.ts
+++ b/packages/core/src/scheduler/tool-executor.ts
@@ -99,6 +99,7 @@ export class ToolExecutor {
shellExecutionConfig,
setPidCallback,
this.config,
+ request.originalRequestName,
);
} else {
promise = executeToolWithHooks(
@@ -110,6 +111,7 @@ export class ToolExecutor {
shellExecutionConfig,
undefined,
this.config,
+ request.originalRequestName,
);
}
@@ -133,6 +135,7 @@ export class ToolExecutor {
new Error(toolResult.error.message),
toolResult.error.type,
displayText,
+ toolResult.tailToolCallRequest,
);
}
} catch (executionError: unknown) {
@@ -204,7 +207,7 @@ export class ToolExecutor {
): Promise {
let content = toolResult.llmContent;
let outputFile: string | undefined;
- const toolName = call.request.name;
+ const toolName = call.request.originalRequestName || call.request.name;
const callId = call.request.callId;
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
@@ -268,6 +271,7 @@ export class ToolExecutor {
startTime,
endTime: Date.now(),
outcome: call.outcome,
+ tailToolCallRequest: toolResult.tailToolCallRequest,
};
}
@@ -276,6 +280,7 @@ export class ToolExecutor {
error: Error,
errorType?: ToolErrorType,
returnDisplay?: string,
+ tailToolCallRequest?: { name: string; args: Record },
): ErroredToolCall {
const response = this.createErrorResponse(
call.request,
@@ -289,11 +294,12 @@ export class ToolExecutor {
status: CoreToolCallStatus.Error,
request: call.request,
response,
- tool: call.tool,
+ tool: 'tool' in call ? call.tool : undefined,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
+ tailToolCallRequest,
};
}
@@ -311,7 +317,7 @@ export class ToolExecutor {
{
functionResponse: {
id: request.callId,
- name: request.name,
+ name: request.originalRequestName || request.name,
response: { error: error.message },
},
},
diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts
index 5fe6028bac..6486c04997 100644
--- a/packages/core/src/scheduler/types.ts
+++ b/packages/core/src/scheduler/types.ts
@@ -36,6 +36,11 @@ export interface ToolCallRequestInfo {
callId: string;
name: string;
args: Record;
+ /**
+ * The original name of the tool requested by the model.
+ * This is used for tail calls to ensure the final response retains the original name.
+ */
+ originalRequestName?: string;
isClientInitiated: boolean;
prompt_id: string;
checkpoint?: string;
@@ -58,6 +63,12 @@ export interface ToolCallResponseInfo {
data?: Record;
}
+/** Request to execute another tool immediately after a completed one. */
+export interface TailToolCallRequest {
+ name: string;
+ args: Record;
+}
+
export type ValidatingToolCall = {
status: CoreToolCallStatus.Validating;
request: ToolCallRequestInfo;
@@ -91,6 +102,7 @@ export type ErroredToolCall = {
outcome?: ToolConfirmationOutcome;
schedulerId?: string;
approvalMode?: ApprovalMode;
+ tailToolCallRequest?: TailToolCallRequest;
};
export type SuccessfulToolCall = {
@@ -105,6 +117,7 @@ export type SuccessfulToolCall = {
outcome?: ToolConfirmationOutcome;
schedulerId?: string;
approvalMode?: ApprovalMode;
+ tailToolCallRequest?: TailToolCallRequest;
};
export type ExecutingToolCall = {
@@ -120,6 +133,7 @@ export type ExecutingToolCall = {
pid?: number;
schedulerId?: string;
approvalMode?: ApprovalMode;
+ tailToolCallRequest?: TailToolCallRequest;
};
export type CancelledToolCall = {
diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts
index 9a0900d86d..7838d985f1 100644
--- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts
+++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts
@@ -115,6 +115,8 @@ export enum EventNames {
TOOL_OUTPUT_MASKING = 'tool_output_masking',
KEYCHAIN_AVAILABILITY = 'keychain_availability',
TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization',
+ CONSECA_POLICY_GENERATION = 'conseca_policy_generation',
+ CONSECA_VERDICT = 'conseca_verdict',
}
export interface LogResponse {
diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts
index fc7edc6dff..4d3bc27d27 100644
--- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts
+++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
- // Next ID: 159
+ // Next ID: 167
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -605,4 +605,30 @@ export enum EventMetadataKey {
// Logs whether the token storage type was forced by an environment variable.
GEMINI_CLI_TOKEN_STORAGE_FORCED = 158,
+ // Conseca Event Keys
+ // ==========================================================================
+
+ // Logs the policy generation event.
+ CONSECA_POLICY_GENERATION = 159,
+
+ // Logs the verdict event.
+ CONSECA_VERDICT = 160,
+
+ // Logs the generated policy content.
+ CONSECA_GENERATED_POLICY = 161,
+
+ // Logs the verdict result (e.g. ALLOW/BLOCK).
+ CONSECA_VERDICT_RESULT = 162,
+
+ // Logs the verdict rationale.
+ CONSECA_VERDICT_RATIONALE = 163,
+
+ // Logs the trusted content used.
+ CONSECA_TRUSTED_CONTENT = 164,
+
+ // Logs the user prompt for Conseca events.
+ CONSECA_USER_PROMPT = 165,
+
+ // Logs the error message for Conseca events.
+ CONSECA_ERROR = 166,
}
diff --git a/packages/core/src/telemetry/conseca-logger.test.ts b/packages/core/src/telemetry/conseca-logger.test.ts
new file mode 100644
index 0000000000..0ad482ed92
--- /dev/null
+++ b/packages/core/src/telemetry/conseca-logger.test.ts
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { logs, type Logger } from '@opentelemetry/api-logs';
+import {
+ logConsecaPolicyGeneration,
+ logConsecaVerdict,
+} from './conseca-logger.js';
+import {
+ ConsecaPolicyGenerationEvent,
+ ConsecaVerdictEvent,
+ EVENT_CONSECA_POLICY_GENERATION,
+ EVENT_CONSECA_VERDICT,
+} from './types.js';
+import type { Config } from '../config/config.js';
+import * as sdk from './sdk.js';
+import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
+
+vi.mock('@opentelemetry/api-logs');
+vi.mock('./sdk.js');
+vi.mock('./clearcut-logger/clearcut-logger.js');
+
+describe('conseca-logger', () => {
+ let mockConfig: Config;
+ let mockLogger: { emit: ReturnType };
+ let mockClearcutLogger: {
+ enqueueLogEvent: ReturnType;
+ createLogEvent: ReturnType;
+ };
+
+ beforeEach(() => {
+ mockConfig = {
+ getTelemetryEnabled: vi.fn().mockReturnValue(true),
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
+ getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(true),
+ isInteractive: vi.fn().mockReturnValue(true),
+ getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
+ } as unknown as Config;
+
+ mockLogger = {
+ emit: vi.fn(),
+ };
+ vi.mocked(logs.getLogger).mockReturnValue(mockLogger as unknown as Logger);
+ vi.mocked(sdk.isTelemetrySdkInitialized).mockReturnValue(true);
+
+ mockClearcutLogger = {
+ enqueueLogEvent: vi.fn(),
+ createLogEvent: vi.fn().mockReturnValue({ event_name: 'test' }),
+ };
+ vi.mocked(ClearcutLogger.getInstance).mockReturnValue(
+ mockClearcutLogger as unknown as ClearcutLogger,
+ );
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should log policy generation event to OTEL and Clearcut', () => {
+ const event = new ConsecaPolicyGenerationEvent(
+ 'user prompt',
+ 'trusted content',
+ 'generated policy',
+ );
+
+ logConsecaPolicyGeneration(mockConfig, event);
+
+ // Verify OTEL
+ expect(logs.getLogger).toHaveBeenCalled();
+ expect(mockLogger.emit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: 'Conseca Policy Generation.',
+ attributes: expect.objectContaining({
+ 'event.name': EVENT_CONSECA_POLICY_GENERATION,
+ }),
+ }),
+ );
+
+ // Verify Clearcut
+ expect(ClearcutLogger.getInstance).toHaveBeenCalledWith(mockConfig);
+ expect(mockClearcutLogger.createLogEvent).toHaveBeenCalled();
+ expect(mockClearcutLogger.enqueueLogEvent).toHaveBeenCalled();
+ });
+
+ it('should log policy generation error to Clearcut', () => {
+ const event = new ConsecaPolicyGenerationEvent(
+ 'user prompt',
+ 'trusted content',
+ '{}',
+ 'some error',
+ );
+
+ logConsecaPolicyGeneration(mockConfig, event);
+
+ expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.objectContaining({
+ value: 'some error',
+ }),
+ ]),
+ );
+ });
+
+ it('should log verdict event to OTEL and Clearcut', () => {
+ const event = new ConsecaVerdictEvent(
+ 'user prompt',
+ 'policy',
+ 'tool call',
+ 'ALLOW',
+ 'rationale',
+ );
+
+ logConsecaVerdict(mockConfig, event);
+
+ // Verify OTEL
+ expect(logs.getLogger).toHaveBeenCalled();
+ expect(mockLogger.emit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: 'Conseca Verdict: ALLOW.',
+ attributes: expect.objectContaining({
+ 'event.name': EVENT_CONSECA_VERDICT,
+ }),
+ }),
+ );
+
+ // Verify Clearcut
+ expect(ClearcutLogger.getInstance).toHaveBeenCalledWith(mockConfig);
+ expect(mockClearcutLogger.createLogEvent).toHaveBeenCalled();
+ expect(mockClearcutLogger.enqueueLogEvent).toHaveBeenCalled();
+ });
+
+ it('should not log if SDK is not initialized', () => {
+ vi.mocked(sdk.isTelemetrySdkInitialized).mockReturnValue(false);
+ const event = new ConsecaPolicyGenerationEvent('a', 'b', 'c');
+
+ logConsecaPolicyGeneration(mockConfig, event);
+
+ expect(mockLogger.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/telemetry/conseca-logger.ts b/packages/core/src/telemetry/conseca-logger.ts
new file mode 100644
index 0000000000..41f1ac3d15
--- /dev/null
+++ b/packages/core/src/telemetry/conseca-logger.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { LogRecord } from '@opentelemetry/api-logs';
+import { logs } from '@opentelemetry/api-logs';
+import type { Config } from '../config/config.js';
+import { SERVICE_NAME } from './constants.js';
+import { isTelemetrySdkInitialized } from './sdk.js';
+import {
+ ClearcutLogger,
+ EventNames,
+} from './clearcut-logger/clearcut-logger.js';
+import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js';
+import { safeJsonStringify } from '../utils/safeJsonStringify.js';
+import type {
+ ConsecaPolicyGenerationEvent,
+ ConsecaVerdictEvent,
+} from './types.js';
+import { debugLogger } from '../utils/debugLogger.js';
+
+export function logConsecaPolicyGeneration(
+ config: Config,
+ event: ConsecaPolicyGenerationEvent,
+): void {
+ debugLogger.debug('Conseca Policy Generation Event:', event);
+ const clearcutLogger = ClearcutLogger.getInstance(config);
+ if (clearcutLogger) {
+ const data = [
+ {
+ gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
+ value: safeJsonStringify(event.user_prompt),
+ },
+ {
+ gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT,
+ value: safeJsonStringify(event.trusted_content),
+ },
+ {
+ gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
+ value: safeJsonStringify(event.policy),
+ },
+ ];
+
+ if (event.error) {
+ data.push({
+ gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
+ value: event.error,
+ });
+ }
+
+ clearcutLogger.enqueueLogEvent(
+ clearcutLogger.createLogEvent(EventNames.CONSECA_POLICY_GENERATION, data),
+ );
+ }
+
+ if (!isTelemetrySdkInitialized()) return;
+
+ const logger = logs.getLogger(SERVICE_NAME);
+ const logRecord: LogRecord = {
+ body: event.toLogBody(),
+ attributes: event.toOpenTelemetryAttributes(config),
+ };
+ logger.emit(logRecord);
+}
+
+export function logConsecaVerdict(
+ config: Config,
+ event: ConsecaVerdictEvent,
+): void {
+ debugLogger.debug('Conseca Verdict Event:', event);
+ const clearcutLogger = ClearcutLogger.getInstance(config);
+ if (clearcutLogger) {
+ const data = [
+ {
+ gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
+ value: safeJsonStringify(event.user_prompt),
+ },
+ {
+ gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
+ value: safeJsonStringify(event.policy),
+ },
+ {
+ gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME,
+ value: safeJsonStringify(event.tool_call),
+ },
+ {
+ gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT,
+ value: safeJsonStringify(event.verdict),
+ },
+ {
+ gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE,
+ value: event.verdict_rationale,
+ },
+ ];
+
+ if (event.error) {
+ data.push({
+ gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
+ value: event.error,
+ });
+ }
+
+ clearcutLogger.enqueueLogEvent(
+ clearcutLogger.createLogEvent(EventNames.CONSECA_VERDICT, data),
+ );
+ }
+
+ if (!isTelemetrySdkInitialized()) return;
+
+ const logger = logs.getLogger(SERVICE_NAME);
+ const logRecord: LogRecord = {
+ body: event.toLogBody(),
+ attributes: event.toOpenTelemetryAttributes(config),
+ };
+ logger.emit(logRecord);
+}
diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts
index 2b09fde334..0523ae709d 100644
--- a/packages/core/src/telemetry/index.ts
+++ b/packages/core/src/telemetry/index.ts
@@ -48,6 +48,10 @@ export {
logWebFetchFallbackAttempt,
logRewind,
} from './loggers.js';
+export {
+ logConsecaPolicyGeneration,
+ logConsecaVerdict,
+} from './conseca-logger.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
SlashCommandStatus,
@@ -64,6 +68,8 @@ export {
WebFetchFallbackAttemptEvent,
ToolCallDecision,
RewindEvent,
+ ConsecaPolicyGenerationEvent,
+ ConsecaVerdictEvent,
} from './types.js';
export { LlmRole } from './llmRole.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts
index e1a4079f3d..47837f0620 100644
--- a/packages/core/src/telemetry/types.ts
+++ b/packages/core/src/telemetry/types.ts
@@ -879,6 +879,105 @@ export class NextSpeakerCheckEvent implements BaseTelemetryEvent {
}
}
+export const EVENT_CONSECA_POLICY_GENERATION =
+ 'gemini_cli.conseca.policy_generation';
+export class ConsecaPolicyGenerationEvent implements BaseTelemetryEvent {
+ 'event.name': 'conseca_policy_generation';
+ 'event.timestamp': string;
+ user_prompt: string;
+ trusted_content: string;
+ policy: string;
+ error?: string;
+
+ constructor(
+ user_prompt: string,
+ trusted_content: string,
+ policy: string,
+ error?: string,
+ ) {
+ this['event.name'] = 'conseca_policy_generation';
+ this['event.timestamp'] = new Date().toISOString();
+ this.user_prompt = user_prompt;
+ this.trusted_content = trusted_content;
+ this.policy = policy;
+ this.error = error;
+ }
+
+ toOpenTelemetryAttributes(config: Config): LogAttributes {
+ const attributes: LogAttributes = {
+ ...getCommonAttributes(config),
+ 'event.name': EVENT_CONSECA_POLICY_GENERATION,
+ 'event.timestamp': this['event.timestamp'],
+ user_prompt: this.user_prompt,
+ trusted_content: this.trusted_content,
+ policy: this.policy,
+ };
+
+ if (this.error) {
+ attributes['error'] = this.error;
+ }
+
+ return attributes;
+ }
+
+ toLogBody(): string {
+ return `Conseca Policy Generation.`;
+ }
+}
+
+export const EVENT_CONSECA_VERDICT = 'gemini_cli.conseca.verdict';
+export class ConsecaVerdictEvent implements BaseTelemetryEvent {
+ 'event.name': 'conseca_verdict';
+ 'event.timestamp': string;
+ user_prompt: string;
+ policy: string;
+ tool_call: string;
+ verdict: string;
+ verdict_rationale: string;
+ error?: string;
+
+ constructor(
+ user_prompt: string,
+ policy: string,
+ tool_call: string,
+ verdict: string,
+ verdict_rationale: string,
+ error?: string,
+ ) {
+ this['event.name'] = 'conseca_verdict';
+ this['event.timestamp'] = new Date().toISOString();
+ this.user_prompt = user_prompt;
+ this.policy = policy;
+ this.tool_call = tool_call;
+ this.verdict = verdict;
+ this.verdict_rationale = verdict_rationale;
+ this.error = error;
+ }
+
+ toOpenTelemetryAttributes(config: Config): LogAttributes {
+ const attributes: LogAttributes = {
+ ...getCommonAttributes(config),
+ 'event.name': EVENT_CONSECA_VERDICT,
+ 'event.timestamp': this['event.timestamp'],
+ user_prompt: this.user_prompt,
+ policy: this.policy,
+ tool_call: this.tool_call,
+ verdict: this.verdict,
+ verdict_rationale: this.verdict_rationale,
+ };
+
+ if (this.error) {
+ attributes['error'] = this.error;
+ }
+
+ return attributes;
+ }
+
+ toLogBody(): string {
+ return `Conseca Verdict: ${this.verdict}.`;
+ }
+}
+
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
export interface SlashCommandEvent extends BaseTelemetryEvent {
'event.name': 'slash_command';
diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts
index 3e592825dd..39d6c0c04b 100644
--- a/packages/core/src/tools/mcp-client.test.ts
+++ b/packages/core/src/tools/mcp-client.test.ts
@@ -1704,6 +1704,40 @@ describe('mcp-client', () => {
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined();
});
+ it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
+ const mockedTransport = vi
+ .spyOn(SdkClientStdioLib, 'StdioClientTransport')
+ .mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
+
+ const originalEnv = process.env;
+ process.env = {
+ ...originalEnv,
+ GEMINI_TEST_VAR: 'expanded-value',
+ };
+
+ try {
+ await createTransport(
+ 'test-server',
+ {
+ command: 'test-command',
+ env: {
+ TEST_EXPANDED: 'Value is $GEMINI_TEST_VAR',
+ SECRET_KEY: 'intentional-secret-123',
+ },
+ },
+ false,
+ EMPTY_CONFIG,
+ );
+
+ const callArgs = mockedTransport.mock.calls[0][0];
+ expect(callArgs.env).toBeDefined();
+ expect(callArgs.env!['TEST_EXPANDED']).toBe('Value is expanded-value');
+ expect(callArgs.env!['SECRET_KEY']).toBe('intentional-secret-123');
+ } finally {
+ process.env = originalEnv;
+ }
+ });
+
describe('useGoogleCredentialProvider', () => {
beforeEach(() => {
// Mock GoogleAuth client
diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts
index 5e802e8157..58b211f46e 100644
--- a/packages/core/src/tools/mcp-client.ts
+++ b/packages/core/src/tools/mcp-client.ts
@@ -70,6 +70,7 @@ import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from '../services/environmentSanitization.js';
+import { expandEnvVars } from '../utils/envExpansion.js';
import {
GEMINI_CLI_IDENTIFICATION_ENV_VAR,
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
@@ -783,9 +784,16 @@ function createTransportRequestInit(
mcpServerConfig: MCPServerConfig,
headers: Record,
): RequestInit {
+ const expandedHeaders: Record = {};
+ if (mcpServerConfig.headers) {
+ for (const [key, value] of Object.entries(mcpServerConfig.headers)) {
+ expandedHeaders[key] = expandEnvVars(value, process.env);
+ }
+ }
+
return {
headers: {
- ...mcpServerConfig.headers,
+ ...expandedHeaders,
...headers,
},
};
@@ -1970,15 +1978,33 @@ export async function createTransport(
}
if (mcpServerConfig.command) {
+ // 1. Sanitize the base process environment to prevent unintended leaks of system-wide secrets.
+ const sanitizedEnv = sanitizeEnvironment(process.env, {
+ ...sanitizationConfig,
+ enableEnvironmentVariableRedaction: true,
+ });
+
+ const finalEnv: Record = {
+ [GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
+ GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
+ };
+ for (const [key, value] of Object.entries(sanitizedEnv)) {
+ if (value !== undefined) {
+ finalEnv[key] = value;
+ }
+ }
+
+ // Expand and merge explicit environment variables from the MCP configuration.
+ if (mcpServerConfig.env) {
+ for (const [key, value] of Object.entries(mcpServerConfig.env)) {
+ finalEnv[key] = expandEnvVars(value, process.env);
+ }
+ }
+
let transport: Transport = new StdioClientTransport({
command: mcpServerConfig.command,
args: mcpServerConfig.args || [],
- env: {
- ...sanitizeEnvironment(process.env, sanitizationConfig),
- ...(mcpServerConfig.env || {}),
- [GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
- GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
- } as Record,
+ env: finalEnv,
cwd: mcpServerConfig.cwd,
stderr: 'pipe',
});
diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts
index 6faa30c673..f80eebe272 100644
--- a/packages/core/src/tools/mcp-tool.ts
+++ b/packages/core/src/tools/mcp-tool.ts
@@ -80,6 +80,8 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
readonly trust?: boolean,
params: ToolParams = {},
private readonly cliConfig?: Config,
+ private readonly toolDescription?: string,
+ private readonly toolParameterSchema?: unknown,
) {
// Use composite format for policy checks: serverName__toolName
// This enables server wildcards (e.g., "google-workspace__*")
@@ -123,6 +125,9 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
serverName: this.serverName,
toolName: this.serverToolName, // Display original tool name in confirmation
toolDisplayName: this.displayName, // Display global registry name exposed to model and user
+ toolArgs: this.params,
+ toolDescription: this.toolDescription,
+ toolParameterSchema: this.toolParameterSchema,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey);
@@ -317,6 +322,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.trust,
params,
this.cliConfig,
+ this.description,
+ this.parameterSchema,
);
}
}
diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts
index 608f405029..e06dff160e 100644
--- a/packages/core/src/tools/tools.ts
+++ b/packages/core/src/tools/tools.ts
@@ -579,6 +579,15 @@ export interface ToolResult {
* Optional data payload for passing structured information back to the caller.
*/
data?: Record;
+
+ /**
+ * Optional request to execute another tool immediately after this one.
+ * The result of this tail call will replace the original tool's response.
+ */
+ tailToolCallRequest?: {
+ name: string;
+ args: Record;
+ };
}
/**
@@ -757,6 +766,9 @@ export interface ToolMcpConfirmationDetails {
serverName: string;
toolName: string;
toolDisplayName: string;
+ toolArgs?: Record;
+ toolDescription?: string;
+ toolParameterSchema?: unknown;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise;
}
diff --git a/packages/core/src/utils/envExpansion.test.ts b/packages/core/src/utils/envExpansion.test.ts
new file mode 100644
index 0000000000..e130a5d9de
--- /dev/null
+++ b/packages/core/src/utils/envExpansion.test.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { expandEnvVars } from './envExpansion.js';
+
+describe('expandEnvVars', () => {
+ const defaultEnv = {
+ USER: 'morty',
+ HOME: '/home/morty',
+ TEMP: 'C:\\Temp',
+ EMPTY: '',
+ };
+
+ describe('POSIX behavior (non-Windows)', () => {
+ beforeEach(() => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it.each([
+ ['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'],
+ [
+ '${VAR} (POSIX)',
+ 'Welcome to ${HOME}',
+ defaultEnv,
+ 'Welcome to /home/morty',
+ ],
+ [
+ 'should NOT expand %VAR% on non-Windows',
+ 'Data in %TEMP%',
+ defaultEnv,
+ 'Data in %TEMP%',
+ ],
+ [
+ 'mixed formats (only POSIX expanded)',
+ '$USER lives in ${HOME} on %TEMP%',
+ defaultEnv,
+ 'morty lives in /home/morty on %TEMP%',
+ ],
+ [
+ 'missing variables (POSIX only)',
+ 'Missing $UNDEFINED and ${NONE} and %MISSING%',
+ defaultEnv,
+ 'Missing and and %MISSING%',
+ ],
+ [
+ 'empty or undefined values',
+ 'Value is "$EMPTY"',
+ defaultEnv,
+ 'Value is ""',
+ ],
+ [
+ 'original string if no variables',
+ 'No vars here',
+ defaultEnv,
+ 'No vars here',
+ ],
+ ['literal values like "1234"', '1234', defaultEnv, '1234'],
+ ['empty input string', '', defaultEnv, ''],
+ [
+ 'complex paths',
+ '${HOME}/bin:$PATH',
+ { ...defaultEnv, PATH: '/usr/bin' },
+ '/home/morty/bin:/usr/bin',
+ ],
+ ])('should handle %s', (_, input, env, expected) => {
+ expect(expandEnvVars(input, env)).toBe(expected);
+ });
+ });
+
+ describe('Windows behavior', () => {
+ beforeEach(() => {
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it.each([
+ ['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'],
+ [
+ '${VAR} (POSIX)',
+ 'Welcome to ${HOME}',
+ defaultEnv,
+ 'Welcome to /home/morty',
+ ],
+ [
+ 'should expand %VAR% on Windows',
+ 'Data in %TEMP%',
+ defaultEnv,
+ 'Data in C:\\Temp',
+ ],
+ [
+ 'mixed formats (both expanded)',
+ '$USER lives in ${HOME} on %TEMP%',
+ defaultEnv,
+ 'morty lives in /home/morty on C:\\Temp',
+ ],
+ [
+ 'missing variables (all expanded to empty)',
+ 'Missing $UNDEFINED and ${NONE} and %MISSING%',
+ defaultEnv,
+ 'Missing and and ',
+ ],
+ ])('should handle %s', (_, input, env, expected) => {
+ expect(expandEnvVars(input, env)).toBe(expected);
+ });
+ });
+});
diff --git a/packages/core/src/utils/envExpansion.ts b/packages/core/src/utils/envExpansion.ts
new file mode 100644
index 0000000000..938d439ac5
--- /dev/null
+++ b/packages/core/src/utils/envExpansion.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { expand } from 'dotenv-expand';
+
+/**
+ * Expands environment variables in a string using the provided environment record.
+ * Uses the standard `dotenv-expand` library to handle expansion consistently with
+ * other tools.
+ *
+ * Supports POSIX/Bash syntax ($VAR, ${VAR}).
+ * Note: Windows syntax (%VAR%) is not natively supported by dotenv-expand.
+ *
+ * @param str - The string containing environment variable placeholders.
+ * @param env - A record of environment variable names and their values.
+ * @returns The string with environment variables expanded. Missing variables resolve to an empty string.
+ */
+export function expandEnvVars(
+ str: string,
+ env: Record,
+): string {
+ if (!str) return str;
+
+ // 1. Pre-process Windows-style variables (%VAR%) since dotenv-expand only handles POSIX ($VAR).
+ // We only do this on Windows to limit the blast radius and avoid conflicts with other
+ // systems where % might be a literal character (e.g. in URLs or shell commands).
+ const isWindows = process.platform === 'win32';
+ const processedStr = isWindows
+ ? str.replace(/%(\w+)%/g, (_, name) => env[name] ?? '')
+ : str;
+
+ // 2. Use dotenv-expand for POSIX/Bash syntax ($VAR, ${VAR}).
+ // dotenv-expand is designed to process an object of key-value pairs (like a .env file).
+ // To expand a single string, we wrap it in an object with a temporary key.
+ const dummyKey = '__GCLI_EXPAND_TARGET__';
+
+ // Filter out undefined values to satisfy the Record requirement safely
+ const processEnv: Record = {};
+ for (const [key, value] of Object.entries(env)) {
+ if (value !== undefined) {
+ processEnv[key] = value;
+ }
+ }
+
+ const result = expand({
+ parsed: { [dummyKey]: processedStr },
+ processEnv,
+ });
+
+ return result.parsed?.[dummyKey] ?? '';
+}
diff --git a/packages/core/src/utils/textUtils.test.ts b/packages/core/src/utils/textUtils.test.ts
index 4a2c319b87..00143b99e3 100644
--- a/packages/core/src/utils/textUtils.test.ts
+++ b/packages/core/src/utils/textUtils.test.ts
@@ -5,7 +5,11 @@
*/
import { describe, it, expect } from 'vitest';
-import { safeLiteralReplace, truncateString } from './textUtils.js';
+import {
+ safeLiteralReplace,
+ truncateString,
+ safeTemplateReplace,
+} from './textUtils.js';
describe('safeLiteralReplace', () => {
it('returns original string when oldString empty or not found', () => {
@@ -99,3 +103,60 @@ describe('truncateString', () => {
expect(truncateString('', 5)).toBe('');
});
});
+
+describe('safeTemplateReplace', () => {
+ it('replaces all occurrences of known keys', () => {
+ const tmpl = 'Hello {{name}}, welcome to {{place}}. {{name}} is happy.';
+ const replacements = { name: 'Alice', place: 'Wonderland' };
+ expect(safeTemplateReplace(tmpl, replacements)).toBe(
+ 'Hello Alice, welcome to Wonderland. Alice is happy.',
+ );
+ });
+
+ it('ignores keys not present in replacements', () => {
+ const tmpl = 'Hello {{name}}, welcome to {{unknown}}.';
+ const replacements = { name: 'Bob' };
+ expect(safeTemplateReplace(tmpl, replacements)).toBe(
+ 'Hello Bob, welcome to {{unknown}}.',
+ );
+ });
+
+ it('ignores extra keys in replacements', () => {
+ const tmpl = 'Hello {{name}}';
+ const replacements = { name: 'Charlie', age: '30' };
+ expect(safeTemplateReplace(tmpl, replacements)).toBe('Hello Charlie');
+ });
+
+ it('handles empty template', () => {
+ expect(safeTemplateReplace('', { key: 'val' })).toBe('');
+ });
+
+ it('handles template with no placeholders', () => {
+ expect(safeTemplateReplace('No keys here', { key: 'val' })).toBe(
+ 'No keys here',
+ );
+ });
+
+ it('prevents double interpolation (security check)', () => {
+ const tmpl = 'User said: {{userInput}}';
+ const replacements = {
+ userInput: '{{secret}}',
+ secret: 'super_secret_value',
+ };
+ expect(safeTemplateReplace(tmpl, replacements)).toBe(
+ 'User said: {{secret}}',
+ );
+ });
+
+ it('handles values with $ signs correctly (no regex group substitution)', () => {
+ const tmpl = 'Price: {{price}}';
+ const replacements = { price: '$100' };
+ expect(safeTemplateReplace(tmpl, replacements)).toBe('Price: $100');
+ });
+
+ it('treats special replacement patterns (e.g. "$&") as literal strings', () => {
+ const tmpl = 'Value: {{val}}';
+ const replacements = { val: '$&' };
+ expect(safeTemplateReplace(tmpl, replacements)).toBe('Value: $&');
+ });
+});
diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts
index 525637f1e2..1066896bc4 100644
--- a/packages/core/src/utils/textUtils.ts
+++ b/packages/core/src/utils/textUtils.ts
@@ -82,3 +82,24 @@ export function truncateString(
}
return str.slice(0, maxLength) + suffix;
}
+
+/**
+ * Safely replaces placeholders in a template string with values from a replacements object.
+ * This performs a single-pass replacement to prevent double-interpolation attacks.
+ *
+ * @param template The template string containing {{key}} placeholders.
+ * @param replacements A record of keys to their replacement values.
+ * @returns The resulting string with placeholders replaced.
+ */
+export function safeTemplateReplace(
+ template: string,
+ replacements: Record,
+): string {
+ // Regex to match {{key}} in the template string. The regex enforces string naming rules.
+ const placeHolderRegex = /\{\{(\w+)\}\}/g;
+ return template.replace(placeHolderRegex, (match, key) =>
+ Object.prototype.hasOwnProperty.call(replacements, key)
+ ? replacements[key]
+ : match,
+ );
+}
diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt
index a7f3f12f9d..de4b73d8c9 100644
--- a/packages/vscode-ide-companion/NOTICES.txt
+++ b/packages/vscode-ide-companion/NOTICES.txt
@@ -34,7 +34,7 @@ SOFTWARE.
License text not found.
============================================================
-ajv@6.12.6
+ajv@6.14.0
(https://github.com/ajv-validator/ajv.git)
The MIT License (MIT)
@@ -2241,7 +2241,7 @@ THE SOFTWARE.
============================================================
-hono@4.11.9
+hono@4.12.2
(git+https://github.com/honojs/hono.git)
MIT License
diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json
index 9cc8f1f71b..7517b47584 100644
--- a/schemas/settings.schema.json
+++ b/schemas/settings.schema.json
@@ -128,6 +128,13 @@
"default": false,
"type": "boolean"
},
+ "maxAttempts": {
+ "title": "Max Chat Model Attempts",
+ "description": "Maximum number of attempts for requests to the main chat model. Cannot exceed 10.",
+ "markdownDescription": "Maximum number of attempts for requests to the main chat model. Cannot exceed 10.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `10`",
+ "default": 10,
+ "type": "number"
+ },
"debugKeystrokeLogging": {
"title": "Debug Keystroke Logging",
"description": "Enable debug logging of keystrokes to the console.",
@@ -1472,6 +1479,13 @@
}
},
"additionalProperties": false
+ },
+ "enableConseca": {
+ "title": "Enable Context-Aware Security",
+ "description": "Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.",
+ "markdownDescription": "Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`",
+ "default": false,
+ "type": "boolean"
}
},
"additionalProperties": false
diff --git a/scripts/lint.js b/scripts/lint.js
index 08ad893242..049e89fca1 100644
--- a/scripts/lint.js
+++ b/scripts/lint.js
@@ -45,6 +45,13 @@ function getPlatformArch() {
shellcheck: 'darwin.aarch64',
};
}
+ if (platform === 'win32' && arch === 'x64') {
+ return {
+ actionlint: 'windows_amd64',
+ // shellcheck is not used for Windows since it uses the .zip release
+ // which has a consistent name across architectures
+ };
+ }
throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`);
}
@@ -58,10 +65,52 @@ const pythonVenvPythonPath = join(
process.platform === 'win32' ? 'python.exe' : 'python',
);
-const yamllintCheck =
- process.platform === 'win32'
- ? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
- : `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
+const isWindows = process.platform === 'win32';
+
+const actionlintCheck = isWindows
+ ? `where actionlint 2>nul`
+ : 'command -v actionlint';
+
+const actionlintInstaller = isWindows
+ ? `powershell -Command "` +
+ `New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` +
+ `Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` +
+ `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
+ `[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"`
+ : `
+ mkdir -p "${TEMP_DIR}/actionlint"
+ curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
+ tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
+ `;
+
+const shellcheckCheck = isWindows
+ ? `where shellcheck 2>nul`
+ : 'command -v shellcheck';
+
+const shellcheckInstaller = isWindows
+ ? `powershell -Command "` +
+ `Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` +
+ `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
+ `[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"`
+ : `
+ mkdir -p "${TEMP_DIR}/shellcheck"
+ curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
+ tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
+ `;
+
+const yamllintCheck = isWindows
+ ? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
+ : `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
+
+const yamllintInstaller = isWindows
+ ? `python -m venv "${PYTHON_VENV_PATH}" && ` +
+ `"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` +
+ `"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple`
+ : `
+ python3 -m venv "${PYTHON_VENV_PATH}" && \
+ "${pythonVenvPythonPath}" -m pip install --upgrade pip && \
+ "${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
+ `;
/**
* @typedef {{
@@ -76,12 +125,8 @@ const yamllintCheck =
*/
const LINTERS = {
actionlint: {
- check: 'command -v actionlint',
- installer: `
- mkdir -p "${TEMP_DIR}/actionlint"
- curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
- tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
- `,
+ check: actionlintCheck,
+ installer: actionlintInstaller,
run: `
actionlint \
-color \
@@ -92,12 +137,8 @@ const LINTERS = {
`,
},
shellcheck: {
- check: 'command -v shellcheck',
- installer: `
- mkdir -p "${TEMP_DIR}/shellcheck"
- curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
- tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
- `,
+ check: shellcheckCheck,
+ installer: shellcheckInstaller,
run: `
git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
@@ -112,11 +153,7 @@ const LINTERS = {
},
yamllint: {
check: yamllintCheck,
- installer: `
- python3 -m venv "${PYTHON_VENV_PATH}" && \
- "${pythonVenvPythonPath}" -m pip install --upgrade pip && \
- "${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
- `,
+ installer: yamllintInstaller,
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
},
};
@@ -125,8 +162,20 @@ function runCommand(command, stdio = 'inherit') {
try {
const env = { ...process.env };
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
- env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${PYTHON_VENV_PATH}/bin:${env.PATH}`;
- execSync(command, { stdio, env });
+ const sep = isWindows ? ';' : ':';
+ const pythonBin = isWindows
+ ? join(PYTHON_VENV_PATH, 'Scripts')
+ : join(PYTHON_VENV_PATH, 'bin');
+ // Windows sometimes uses 'Path' instead of 'PATH'
+ const pathKey = 'Path' in env ? 'Path' : 'PATH';
+ env[pathKey] = [
+ nodeBin,
+ join(TEMP_DIR, 'actionlint'),
+ join(TEMP_DIR, 'shellcheck'),
+ pythonBin,
+ env[pathKey],
+ ].join(sep);
+ execSync(command, { stdio, env, shell: true });
return true;
} catch (_e) {
return false;