mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efd26bc660 | |||
| 15f6c8b8da | |||
| ee2e947435 | |||
| 6676546a4b | |||
| d143a83d5b | |||
| e69e23e4a0 | |||
| 81cd2561dc | |||
| b0ceb74462 | |||
| ee5eb70070 | |||
| dde844dbe1 | |||
| 05bc0399f3 | |||
| 3409de774c | |||
| 175ffc452b | |||
| 544df749af | |||
| 56c8d7e985 |
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
+22
-6
@@ -143,13 +143,27 @@ based on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
Plan Mode's default tool restrictions are managed by the [policy engine] and
|
||||
defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
|
||||
enforces the read-only state, but you can customize these rules by creating your
|
||||
own policies in your `~/.gemini/policies/` directory (Tier 2).
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
#### Example: Automatically approve read-only MCP tools
|
||||
|
||||
By default, read-only MCP tools require user confirmation in Plan Mode. You can
|
||||
use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for
|
||||
your specific environment.
|
||||
|
||||
`~/.gemini/policies/mcp-read-only.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
mcpName = "*"
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Allow git commands in Plan Mode
|
||||
|
||||
@@ -243,3 +257,5 @@ argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
|
||||
[`plan.toml`]:
|
||||
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
|
||||
|
||||
@@ -112,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
- <a id="mcp-servers"></a>`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
|
||||
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -170,6 +170,20 @@ messages and how to resolve them.
|
||||
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
|
||||
continues, open a new terminal window or restart your IDE.
|
||||
|
||||
### Manual PID override
|
||||
|
||||
If automatic IDE detection fails, or if you are running Gemini CLI in a
|
||||
standalone terminal and want to manually associate it with a specific IDE
|
||||
instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the
|
||||
process ID (PID) of your IDE.
|
||||
|
||||
```bash
|
||||
export GEMINI_CLI_IDE_PID=12345
|
||||
```
|
||||
|
||||
When this variable is set, Gemini CLI will skip automatic detection and attempt
|
||||
to connect using the provided PID.
|
||||
|
||||
### Configuration errors
|
||||
|
||||
- **Message:**
|
||||
|
||||
@@ -873,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):
|
||||
@@ -1266,6 +1274,11 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Specifies the default Gemini model to use.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
|
||||
- **`GEMINI_CLI_IDE_PID`**:
|
||||
- Manually specifies the PID of the IDE process to use for integration. This
|
||||
is useful when running Gemini CLI in a standalone terminal while still
|
||||
wanting to associate it with a specific IDE instance.
|
||||
- Overrides the automatic IDE detection logic.
|
||||
- **`GEMINI_CLI_HOME`**:
|
||||
- Specifies the root directory for Gemini CLI's user-level configuration and
|
||||
storage.
|
||||
|
||||
@@ -205,6 +205,10 @@ toolName = "run_shell_command"
|
||||
# to form a composite name like "mcpName__toolName".
|
||||
mcpName = "my-custom-server"
|
||||
|
||||
# (Optional) Metadata hints provided by the tool. A rule matches if all
|
||||
# key-value pairs provided here are present in the tool's annotations.
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
|
||||
# (Optional) A regex to match against the tool's arguments.
|
||||
argsPattern = '"command":"(git|npm)'
|
||||
|
||||
|
||||
+13
-12
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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}]}]}
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
||||
@@ -878,6 +878,7 @@ export async function loadCliConfig(
|
||||
agents: refreshedSettings.merged.agents,
|
||||
};
|
||||
},
|
||||
enableConseca: settings.security?.enableConseca,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -530,6 +530,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
for (const subdir of fs.readdirSync(extensionsDir)) {
|
||||
if (subdir === '.env') continue;
|
||||
const extensionDir = path.join(extensionsDir, subdir);
|
||||
await this.loadExtension(extensionDir);
|
||||
}
|
||||
|
||||
@@ -280,6 +280,17 @@ describe('extension tests', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore .env directory in extensions folder', async () => {
|
||||
// Create a .env directory
|
||||
const envDir = path.join(userExtensionsDir, '.env');
|
||||
fs.mkdirSync(envDir);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
expect(extensions).toEqual([]);
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
expect(debugLogger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should annotate disabled extensions', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
|
||||
@@ -468,6 +468,32 @@ describe('extensionSettings', () => {
|
||||
expect(mockIsAvailable).toHaveBeenCalled();
|
||||
expect(mockListSecrets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if .env is a directory when prompting for settings', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
const envFilePath = path.join(extensionDir, '.env');
|
||||
if (fs.existsSync(envFilePath)) {
|
||||
fs.unlinkSync(envFilePath);
|
||||
}
|
||||
fs.mkdirSync(envFilePath);
|
||||
mockRequestSetting.mockResolvedValue('new-value');
|
||||
|
||||
await expect(
|
||||
maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
`Cannot update user-scoped settings because "${envFilePath}" is a directory.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForSetting', () => {
|
||||
@@ -590,6 +616,23 @@ describe('extensionSettings', () => {
|
||||
SENSITIVE_VAR: 'workspace-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore .env if it is a directory', async () => {
|
||||
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
||||
if (fs.existsSync(userEnvPath)) {
|
||||
fs.unlinkSync(userEnvPath);
|
||||
}
|
||||
fs.mkdirSync(userEnvPath);
|
||||
|
||||
const contents = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnvContents (merged)', () => {
|
||||
@@ -890,5 +933,27 @@ describe('extensionSettings', () => {
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1="value with \\"quotes\\""');
|
||||
});
|
||||
|
||||
it('should throw error if .env is a directory when updating a non-sensitive setting', async () => {
|
||||
const envFilePath = path.join(extensionDir, '.env');
|
||||
if (fs.existsSync(envFilePath)) {
|
||||
fs.unlinkSync(envFilePath);
|
||||
}
|
||||
fs.mkdirSync(envFilePath);
|
||||
mockRequestSetting.mockResolvedValue('new-value');
|
||||
|
||||
await expect(
|
||||
updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
`Cannot update user-scoped settings because "${envFilePath}" is a directory.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,15 @@ export async function maybePromptForSettings(
|
||||
|
||||
const envContent = formatEnvContent(nonSensitiveSettings);
|
||||
|
||||
if (
|
||||
fsSync.existsSync(envFilePath) &&
|
||||
fsSync.statSync(envFilePath).isDirectory()
|
||||
) {
|
||||
throw new Error(
|
||||
`Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(envFilePath, envContent);
|
||||
}
|
||||
|
||||
@@ -172,7 +181,7 @@ export async function getScopedEnvContents(
|
||||
);
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
let customEnv: Record<string, string> = {};
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) {
|
||||
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
|
||||
customEnv = dotenv.parse(envFile);
|
||||
}
|
||||
@@ -258,6 +267,16 @@ export async function updateSetting(
|
||||
// For non-sensitive settings, we need to read the existing .env file,
|
||||
// update the value, and write it back, preserving any other values.
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
|
||||
if (
|
||||
fsSync.existsSync(envFilePath) &&
|
||||
fsSync.statSync(envFilePath).isDirectory()
|
||||
) {
|
||||
throw new Error(
|
||||
`Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
let envContent = '';
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
envContent = await fs.readFile(envFilePath, 'utf-8');
|
||||
@@ -323,7 +342,7 @@ async function clearSettings(
|
||||
envFilePath: string,
|
||||
keychain: KeychainTokenStorage,
|
||||
) {
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
}
|
||||
if (!(await keychain.isAvailable())) {
|
||||
|
||||
@@ -1493,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -393,9 +393,11 @@ export const render = (
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
if (isInkRenderMetrics(metrics)) {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
}
|
||||
const output = isInkRenderMetrics(metrics) ? metrics.output : '...';
|
||||
const staticOutput = isInkRenderMetrics(metrics)
|
||||
? (metrics.staticOutput ?? '')
|
||||
: '';
|
||||
stdout.onRender(staticOutput, output);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,12 @@ export const Colors: ColorsTheme = {
|
||||
get DarkGray() {
|
||||
return themeManager.getColors().DarkGray;
|
||||
},
|
||||
get InputBackground() {
|
||||
return themeManager.getColors().InputBackground;
|
||||
},
|
||||
get MessageBackground() {
|
||||
return themeManager.getColors().MessageBackground;
|
||||
},
|
||||
get GradientColors() {
|
||||
return themeManager.getActiveTheme().colors.GradientColors;
|
||||
},
|
||||
|
||||
@@ -96,6 +96,8 @@ describe('<Header />', () => {
|
||||
},
|
||||
background: {
|
||||
primary: '',
|
||||
message: '',
|
||||
input: '',
|
||||
diff: { added: '', removed: '' },
|
||||
},
|
||||
border: {
|
||||
|
||||
@@ -56,10 +56,6 @@ import {
|
||||
} from '../utils/commandUtils.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
} from '../constants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
@@ -226,7 +222,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
shortcutsHelpVisible,
|
||||
hintMode,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||||
@@ -1422,14 +1417,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={
|
||||
hintMode ? theme.text.accent : theme.text.secondary
|
||||
}
|
||||
backgroundOpacity={
|
||||
showCursor
|
||||
? DEFAULT_INPUT_BACKGROUND_OPACITY
|
||||
: DEFAULT_BACKGROUND_OPACITY
|
||||
}
|
||||
backgroundBaseColor={theme.background.input}
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ShortcutsHelp: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<SectionHeader title="Shortcuts (for more, see /help)" />
|
||||
<SectionHeader title=" Shortcuts" subtitle=" See /help for more" />
|
||||
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
|
||||
{itemsForDisplay.map((item, index) => (
|
||||
<Box
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
"────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode
|
||||
@ select file or folder
|
||||
Esc Esc clear & rewind
|
||||
@@ -16,7 +17,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
"────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode
|
||||
@ select file or folder
|
||||
Esc Esc clear & rewind
|
||||
@@ -31,7 +33,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
|
||||
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
@@ -40,7 +43,8 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
|
||||
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
|
||||
@@ -58,7 +58,10 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
borderColor,
|
||||
|
||||
borderDimColor,
|
||||
|
||||
isExpandable,
|
||||
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const {
|
||||
activePtyId: activeShellPtyId,
|
||||
@@ -129,6 +132,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
|
||||
<FocusHint
|
||||
|
||||
@@ -520,4 +520,77 @@ describe('ToolConfirmationMessage', () => {
|
||||
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(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
<>
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
</>
|
||||
{hasMcpToolDetails && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>MCP Tool Details:</Text>
|
||||
{isMcpToolDetailsExpanded ? (
|
||||
<>
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to collapse MCP tool details)
|
||||
</Text>
|
||||
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to expand MCP tool details)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<
|
||||
<MaxSizedBox
|
||||
maxHeight={availableBodyContentHeight()}
|
||||
maxWidth={terminalWidth}
|
||||
overflowDirection="top"
|
||||
overflowDirection={bodyOverflowDirection}
|
||||
>
|
||||
{bodyContent}
|
||||
</MaxSizedBox>
|
||||
|
||||
@@ -57,6 +57,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
config,
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
@@ -93,6 +94,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
emphasis={emphasis}
|
||||
progressMessage={progressMessage}
|
||||
progressPercent={progressPercent}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
<FocusHint
|
||||
shouldShowFocusHint={shouldShowFocusHint}
|
||||
|
||||
@@ -189,6 +189,7 @@ type ToolInfoProps = {
|
||||
emphasis: TextEmphasis;
|
||||
progressMessage?: string;
|
||||
progressPercent?: number;
|
||||
originalRequestName?: string;
|
||||
};
|
||||
|
||||
export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
@@ -198,6 +199,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
emphasis,
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const status = mapCoreStatusToDisplayStatus(coreStatus);
|
||||
const nameColor = React.useMemo<string>(() => {
|
||||
@@ -242,6 +244,12 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>
|
||||
{originalRequestName && originalRequestName !== name && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(redirection from {originalRequestName})
|
||||
</Text>
|
||||
)}
|
||||
{!isCompletedAskUser && (
|
||||
<>
|
||||
{' '}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
calculateTransformedLine,
|
||||
} from '../shared/text-buffer.js';
|
||||
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
|
||||
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
|
||||
interface UserMessageProps {
|
||||
@@ -52,8 +51,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={theme.text.secondary}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
backgroundBaseColor={theme.background.message}
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -8,7 +8,6 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
|
||||
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
|
||||
interface UserShellMessageProps {
|
||||
@@ -28,8 +27,8 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={theme.text.secondary}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
backgroundBaseColor={theme.background.message}
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -30,9 +30,15 @@ describe('<SectionHeader />', () => {
|
||||
title: 'Narrow Container',
|
||||
width: 25,
|
||||
},
|
||||
])('$description', async ({ title, width }) => {
|
||||
{
|
||||
description: 'renders correctly with a subtitle',
|
||||
title: 'Shortcuts',
|
||||
subtitle: ' See /help for more',
|
||||
width: 40,
|
||||
},
|
||||
])('$description', async ({ title, subtitle, width }) => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<SectionHeader title={title} />,
|
||||
<SectionHeader title={title} subtitle={subtitle} />,
|
||||
{ width },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -8,16 +8,13 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
<Box width="100%" flexDirection="row" overflow="hidden">
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{`── ${title}`}
|
||||
</Text>
|
||||
export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
|
||||
title,
|
||||
subtitle,
|
||||
}) => (
|
||||
<Box width="100%" flexDirection="column" overflow="hidden">
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
minWidth={2}
|
||||
marginLeft={1}
|
||||
width="100%"
|
||||
borderStyle="single"
|
||||
borderTop
|
||||
borderBottom={false}
|
||||
@@ -25,5 +22,15 @@ export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
borderRight={false}
|
||||
borderColor={theme.text.secondary}
|
||||
/>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `
|
||||
"── Narrow Container ─────
|
||||
"─────────────────────────
|
||||
Narrow Container
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
|
||||
"── Very Long Hea… ──
|
||||
"────────────────────
|
||||
Very Long Header Ti…
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `
|
||||
"── My Header ───────────────────────────
|
||||
"────────────────────────────────────────
|
||||
My Header
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly with a subtitle' 1`] = `
|
||||
"────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -37,7 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
|
||||
|
||||
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
|
||||
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
|
||||
export const DEFAULT_BORDER_OPACITY = 0.2;
|
||||
export const DEFAULT_BORDER_OPACITY = 0.4;
|
||||
|
||||
export const KEYBOARD_SHORTCUTS_URL =
|
||||
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ export function mapToDisplay(
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
approvalMode: call.approvalMode,
|
||||
originalRequestName: call.request.originalRequestName,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
|
||||
createKey('l', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Shell commands
|
||||
{
|
||||
command: Command.REVERSE_SEARCH,
|
||||
|
||||
@@ -24,6 +24,8 @@ const noColorColorsTheme: ColorsTheme = {
|
||||
Comment: '',
|
||||
Gray: '',
|
||||
DarkGray: '',
|
||||
InputBackground: '',
|
||||
MessageBackground: '',
|
||||
};
|
||||
|
||||
const noColorSemanticColors: SemanticColors = {
|
||||
@@ -36,6 +38,8 @@ const noColorSemanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: '',
|
||||
message: '',
|
||||
input: '',
|
||||
diff: {
|
||||
added: '',
|
||||
removed: '',
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SemanticColors {
|
||||
};
|
||||
background: {
|
||||
primary: string;
|
||||
message: string;
|
||||
input: string;
|
||||
diff: {
|
||||
added: string;
|
||||
removed: string;
|
||||
@@ -48,13 +50,15 @@ export const lightSemanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: lightTheme.Background,
|
||||
message: lightTheme.MessageBackground!,
|
||||
input: lightTheme.InputBackground!,
|
||||
diff: {
|
||||
added: lightTheme.DiffAdded,
|
||||
removed: lightTheme.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: lightTheme.Gray,
|
||||
default: lightTheme.DarkGray,
|
||||
focused: lightTheme.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
@@ -80,13 +84,15 @@ export const darkSemanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: darkTheme.Background,
|
||||
message: darkTheme.MessageBackground!,
|
||||
input: darkTheme.InputBackground!,
|
||||
diff: {
|
||||
added: darkTheme.DiffAdded,
|
||||
removed: darkTheme.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: darkTheme.Gray,
|
||||
default: darkTheme.DarkGray,
|
||||
focused: darkTheme.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: '#002b36',
|
||||
message: '#073642',
|
||||
input: '#073642',
|
||||
diff: {
|
||||
added: '#00382f',
|
||||
removed: '#3d0115',
|
||||
|
||||
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: '#fdf6e3',
|
||||
message: '#eee8d5',
|
||||
input: '#eee8d5',
|
||||
diff: {
|
||||
added: '#d7f2d7',
|
||||
removed: '#f2d7d7',
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
getThemeTypeFromBackgroundColor,
|
||||
resolveColor,
|
||||
} from './color-utils.js';
|
||||
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
} from '../constants.js';
|
||||
import { ANSI } from './ansi.js';
|
||||
import { ANSILight } from './ansi-light.js';
|
||||
import { NoColorTheme } from './no-color.js';
|
||||
@@ -310,7 +314,21 @@ class ThemeManager {
|
||||
this.cachedColors = {
|
||||
...colors,
|
||||
Background: this.terminalBackground,
|
||||
DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
|
||||
DarkGray: interpolateColor(
|
||||
this.terminalBackground,
|
||||
colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
InputBackground: interpolateColor(
|
||||
this.terminalBackground,
|
||||
colors.Gray,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
this.terminalBackground,
|
||||
colors.Gray,
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
this.cachedColors = colors;
|
||||
@@ -336,27 +354,22 @@ class ThemeManager {
|
||||
this.terminalBackground &&
|
||||
this.isThemeCompatible(activeTheme, this.terminalBackground)
|
||||
) {
|
||||
const colors = this.getColors();
|
||||
this.cachedSemanticColors = {
|
||||
...semanticColors,
|
||||
background: {
|
||||
...semanticColors.background,
|
||||
primary: this.terminalBackground,
|
||||
message: colors.MessageBackground!,
|
||||
input: colors.InputBackground!,
|
||||
},
|
||||
border: {
|
||||
...semanticColors.border,
|
||||
default: interpolateColor(
|
||||
this.terminalBackground,
|
||||
activeTheme.colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
default: colors.DarkGray,
|
||||
},
|
||||
ui: {
|
||||
...semanticColors.ui,
|
||||
dark: interpolateColor(
|
||||
activeTheme.colors.Gray,
|
||||
this.terminalBackground,
|
||||
0.5,
|
||||
),
|
||||
dark: colors.DarkGray,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -37,11 +37,11 @@ describe('createCustomTheme', () => {
|
||||
|
||||
it('should interpolate DarkGray when not provided', () => {
|
||||
const theme = createCustomTheme(baseTheme);
|
||||
// Interpolate between Gray (#cccccc) and Background (#000000) at 0.5
|
||||
// Interpolate between Background (#000000) and Gray (#cccccc) at 0.4
|
||||
// #cccccc is RGB(204, 204, 204)
|
||||
// #000000 is RGB(0, 0, 0)
|
||||
// Midpoint is RGB(102, 102, 102) which is #666666
|
||||
expect(theme.colors.DarkGray).toBe('#666666');
|
||||
// Result is RGB(82, 82, 82) which is #525252
|
||||
expect(theme.colors.DarkGray).toBe('#525252');
|
||||
});
|
||||
|
||||
it('should use provided DarkGray', () => {
|
||||
@@ -64,8 +64,8 @@ describe('createCustomTheme', () => {
|
||||
},
|
||||
};
|
||||
const theme = createCustomTheme(customTheme);
|
||||
// Should be interpolated between #cccccc and #000000 at 0.5 -> #666666
|
||||
expect(theme.colors.DarkGray).toBe('#666666');
|
||||
// Should be interpolated between #000000 and #cccccc at 0.4 -> #525252
|
||||
expect(theme.colors.DarkGray).toBe('#525252');
|
||||
});
|
||||
|
||||
it('should prefer text.secondary over Gray for interpolation', () => {
|
||||
@@ -81,8 +81,8 @@ describe('createCustomTheme', () => {
|
||||
},
|
||||
};
|
||||
const theme = createCustomTheme(customTheme);
|
||||
// Interpolate between #cccccc and #000000 -> #666666
|
||||
expect(theme.colors.DarkGray).toBe('#666666');
|
||||
// Interpolate between #000000 and #cccccc -> #525252
|
||||
expect(theme.colors.DarkGray).toBe('#525252');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
} from './color-utils.js';
|
||||
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
} from '../constants.js';
|
||||
|
||||
export type { CustomTheme };
|
||||
|
||||
@@ -37,6 +41,8 @@ export interface ColorsTheme {
|
||||
Comment: string;
|
||||
Gray: string;
|
||||
DarkGray: string;
|
||||
InputBackground?: string;
|
||||
MessageBackground?: string;
|
||||
GradientColors?: string[];
|
||||
}
|
||||
|
||||
@@ -55,7 +61,17 @@ export const lightTheme: ColorsTheme = {
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#008000',
|
||||
Gray: '#97a0b0',
|
||||
DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
|
||||
DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
|
||||
InputBackground: interpolateColor(
|
||||
'#FAFAFA',
|
||||
'#97a0b0',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
'#FAFAFA',
|
||||
'#97a0b0',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
};
|
||||
|
||||
@@ -74,7 +90,17 @@ export const darkTheme: ColorsTheme = {
|
||||
DiffRemoved: '#430000',
|
||||
Comment: '#6C7086',
|
||||
Gray: '#6C7086',
|
||||
DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
|
||||
DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
|
||||
InputBackground: interpolateColor(
|
||||
'#1E1E2E',
|
||||
'#6C7086',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
'#1E1E2E',
|
||||
'#6C7086',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
};
|
||||
|
||||
@@ -94,6 +120,8 @@ export const ansiTheme: ColorsTheme = {
|
||||
Comment: 'gray',
|
||||
Gray: 'gray',
|
||||
DarkGray: 'gray',
|
||||
InputBackground: 'black',
|
||||
MessageBackground: 'black',
|
||||
};
|
||||
|
||||
export class Theme {
|
||||
@@ -131,17 +159,27 @@ export class Theme {
|
||||
},
|
||||
background: {
|
||||
primary: this.colors.Background,
|
||||
message:
|
||||
this.colors.MessageBackground ??
|
||||
interpolateColor(
|
||||
this.colors.Background,
|
||||
this.colors.Gray,
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
input:
|
||||
this.colors.InputBackground ??
|
||||
interpolateColor(
|
||||
this.colors.Background,
|
||||
this.colors.Gray,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
diff: {
|
||||
added: this.colors.DiffAdded,
|
||||
removed: this.colors.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: interpolateColor(
|
||||
this.colors.Background,
|
||||
this.colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
default: this.colors.DarkGray,
|
||||
focused: this.colors.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
@@ -242,10 +280,20 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
DarkGray:
|
||||
customTheme.DarkGray ??
|
||||
interpolateColor(
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
customTheme.background?.primary ?? customTheme.Background ?? '',
|
||||
0.5,
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
InputBackground: interpolateColor(
|
||||
customTheme.background?.primary ?? customTheme.Background ?? '',
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
customTheme.background?.primary ?? customTheme.Background ?? '',
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
|
||||
};
|
||||
|
||||
@@ -400,19 +448,15 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
},
|
||||
background: {
|
||||
primary: customTheme.background?.primary ?? colors.Background,
|
||||
message: colors.MessageBackground!,
|
||||
input: colors.InputBackground!,
|
||||
diff: {
|
||||
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
|
||||
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default:
|
||||
customTheme.border?.default ??
|
||||
interpolateColor(
|
||||
colors.Background,
|
||||
colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
default: colors.DarkGray,
|
||||
focused: customTheme.border?.focused ?? colors.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface IndividualToolCallDisplay {
|
||||
approvalMode?: ApprovalMode;
|
||||
progressMessage?: string;
|
||||
progressPercent?: number;
|
||||
originalRequestName?: string;
|
||||
}
|
||||
|
||||
export interface CompressionProps {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -513,6 +518,7 @@ export interface ConfigParameters {
|
||||
adminSkillsEnabled?: boolean;
|
||||
agents?: AgentSettings;
|
||||
}>;
|
||||
enableConseca?: boolean;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -540,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 */
|
||||
@@ -868,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();
|
||||
@@ -1568,7 +1597,9 @@ export class Config {
|
||||
*
|
||||
* May change over time.
|
||||
*/
|
||||
getExcludeTools(): Set<string> | undefined {
|
||||
getExcludeTools(
|
||||
toolMetadata?: Map<string, Record<string, unknown>>,
|
||||
): Set<string> | undefined {
|
||||
// Right now this is present for backward compatibility with settings.json exclude
|
||||
const excludeToolsSet = new Set([...(this.excludeTools ?? [])]);
|
||||
for (const extension of this.getExtensionLoader().getExtensions()) {
|
||||
@@ -1580,7 +1611,7 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
const policyExclusions = this.policyEngine.getExcludedTools();
|
||||
const policyExclusions = this.policyEngine.getExcludedTools(toolMetadata);
|
||||
for (const tool of policyExclusions) {
|
||||
excludeToolsSet.add(tool);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,29 @@ describe('MessageBus', () => {
|
||||
expect(requestHandler).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it('should forward toolAnnotations to policyEngine.check', async () => {
|
||||
const checkSpy = vi.spyOn(policyEngine, 'check').mockResolvedValue({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
});
|
||||
|
||||
const annotations = { readOnlyHint: true };
|
||||
const request: ToolConfirmationRequest = {
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
toolCall: { name: 'test-tool', args: {} },
|
||||
correlationId: '123',
|
||||
serverName: 'test-server',
|
||||
toolAnnotations: annotations,
|
||||
};
|
||||
|
||||
await messageBus.publish(request);
|
||||
|
||||
expect(checkSpy).toHaveBeenCalledWith(
|
||||
{ name: 'test-tool', args: {} },
|
||||
'test-server',
|
||||
annotations,
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit other message types directly', async () => {
|
||||
const successHandler = vi.fn();
|
||||
messageBus.subscribe(
|
||||
|
||||
@@ -55,6 +55,7 @@ export class MessageBus extends EventEmitter {
|
||||
const { decision } = await this.policyEngine.check(
|
||||
message.toolCall,
|
||||
message.serverName,
|
||||
message.toolAnnotations,
|
||||
);
|
||||
|
||||
switch (decision) {
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface ToolConfirmationRequest {
|
||||
toolCall: FunctionCall;
|
||||
correlationId: string;
|
||||
serverName?: string;
|
||||
/**
|
||||
* Optional tool annotations (e.g., readOnlyHint, destructiveHint) from MCP.
|
||||
*/
|
||||
toolAnnotations?: Record<string, unknown>;
|
||||
/**
|
||||
* Optional rich details for the confirmation UI (diffs, counts, etc.)
|
||||
*/
|
||||
@@ -95,6 +99,9 @@ export type SerializableConfirmationDetails =
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolDisplayName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolDescription?: string;
|
||||
toolParameterSchema?: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'ask_user';
|
||||
|
||||
@@ -75,6 +75,7 @@ export async function executeToolWithHooks(
|
||||
shellExecutionConfig?: ShellExecutionConfig,
|
||||
setPidCallback?: (pid: number) => void,
|
||||
config?: Config,
|
||||
originalRequestName?: string,
|
||||
): Promise<ToolResult> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const toolInput = (invocation.params || {}) as Record<string, unknown>;
|
||||
@@ -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;
|
||||
|
||||
@@ -1926,13 +1926,14 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
isModifiableSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should pass serverName to policy engine for DiscoveredMCPTool', async () => {
|
||||
it('should pass serverName and toolAnnotations to policy engine for DiscoveredMCPTool', async () => {
|
||||
const mockMcpTool = {
|
||||
tool: async () => ({ functionDeclarations: [] }),
|
||||
callTool: async () => [],
|
||||
};
|
||||
const serverName = 'test-server';
|
||||
const toolName = 'test-tool';
|
||||
const annotations = { readOnlyHint: true };
|
||||
const mcpTool = new DiscoveredMCPTool(
|
||||
mockMcpTool as unknown as CallableTool,
|
||||
serverName,
|
||||
@@ -1940,6 +1941,13 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
'description',
|
||||
{ type: 'object', properties: {} },
|
||||
createMockMessageBus() as unknown as MessageBus,
|
||||
undefined, // trust
|
||||
true, // isReadOnly
|
||||
undefined, // nameOverride
|
||||
undefined, // cliConfig
|
||||
undefined, // extensionName
|
||||
undefined, // extensionId
|
||||
annotations, // toolAnnotations
|
||||
);
|
||||
|
||||
const mockToolRegistry = {
|
||||
@@ -1989,6 +1997,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
expect(mockPolicyEngineCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: toolName }),
|
||||
serverName,
|
||||
annotations,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -624,10 +624,11 @@ export class CoreToolScheduler {
|
||||
toolCall.tool instanceof DiscoveredMCPTool
|
||||
? toolCall.tool.serverName
|
||||
: undefined;
|
||||
const toolAnnotations = toolCall.tool.toolAnnotations;
|
||||
|
||||
const { decision, rule } = await this.config
|
||||
.getPolicyEngine()
|
||||
.check(toolCallForPolicy, serverName);
|
||||
.check(toolCallForPolicy, serverName, toolAnnotations);
|
||||
|
||||
if (decision === PolicyDecision.DENY) {
|
||||
const { errorMessage, errorType } = getPolicyDenialError(
|
||||
|
||||
@@ -76,12 +76,16 @@ export class HookEventHandler {
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
mcpContext?: McpToolContext,
|
||||
originalRequestName?: string,
|
||||
): Promise<AggregatedHookResult> {
|
||||
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<string, unknown>,
|
||||
toolResponse: Record<string, unknown>,
|
||||
mcpContext?: McpToolContext,
|
||||
originalRequestName?: string,
|
||||
): Promise<AggregatedHookResult> {
|
||||
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 };
|
||||
|
||||
@@ -368,12 +368,14 @@ export class HookSystem {
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
mcpContext?: McpToolContext,
|
||||
originalRequestName?: string,
|
||||
): Promise<DefaultHookOutput | undefined> {
|
||||
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<DefaultHookOutput | undefined> {
|
||||
try {
|
||||
const result = await this.hookEventHandler.fireAfterToolEvent(
|
||||
@@ -398,6 +401,7 @@ export class HookSystem {
|
||||
toolInput,
|
||||
toolResponse as Record<string, unknown>,
|
||||
mcpContext,
|
||||
originalRequestName,
|
||||
);
|
||||
return result.finalOutput;
|
||||
} catch (error) {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
| 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<string, unknown> };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,6 +457,7 @@ export interface BeforeToolInput extends HookInput {
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
mcp_context?: McpToolContext; // Only present for MCP tools
|
||||
original_request_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,6 +478,7 @@ export interface AfterToolInput extends HookInput {
|
||||
tool_input: Record<string, unknown>;
|
||||
tool_response: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,49 @@ describe('getIdeProcessInfo', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('GEMINI_CLI_IDE_PID override', () => {
|
||||
it('should use GEMINI_CLI_IDE_PID and fetch command on Unix', async () => {
|
||||
(os.platform as Mock).mockReturnValue('linux');
|
||||
vi.stubEnv('GEMINI_CLI_IDE_PID', '12345');
|
||||
mockedExec.mockResolvedValueOnce({ stdout: '0 my-ide-command' }); // getProcessInfo result
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
|
||||
expect(result).toEqual({ pid: 12345, command: 'my-ide-command' });
|
||||
expect(mockedExec).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ps -o ppid=,command= -p 12345'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use GEMINI_CLI_IDE_PID and fetch command on Windows', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
vi.stubEnv('GEMINI_CLI_IDE_PID', '54321');
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 54321,
|
||||
ParentProcessId: 0,
|
||||
Name: 'Code.exe',
|
||||
CommandLine: 'C:\\Program Files\\VSCode\\Code.exe',
|
||||
},
|
||||
];
|
||||
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
|
||||
expect(result).toEqual({
|
||||
pid: 54321,
|
||||
command: 'C:\\Program Files\\VSCode\\Code.exe',
|
||||
});
|
||||
expect(mockedExec).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine',
|
||||
),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on Unix', () => {
|
||||
|
||||
@@ -208,6 +208,13 @@ async function getIdeProcessInfoForWindows(): Promise<{
|
||||
* to identify the main application process (e.g., the main VS Code window
|
||||
* process).
|
||||
*
|
||||
* This function can be overridden by setting the `GEMINI_CLI_IDE_PID`
|
||||
* environment variable. This is useful for launching Gemini CLI in a
|
||||
* standalone terminal while still connecting to an IDE instance.
|
||||
*
|
||||
* If `GEMINI_CLI_IDE_PID` is set, the function uses that PID and fetches
|
||||
* the command for it.
|
||||
*
|
||||
* If the IDE process cannot be reliably identified, it will return the
|
||||
* top-level ancestor process ID and command as a fallback.
|
||||
*
|
||||
@@ -219,6 +226,19 @@ export async function getIdeProcessInfo(): Promise<{
|
||||
}> {
|
||||
const platform = os.platform();
|
||||
|
||||
if (process.env['GEMINI_CLI_IDE_PID']) {
|
||||
const idePid = parseInt(process.env['GEMINI_CLI_IDE_PID'], 10);
|
||||
if (!isNaN(idePid) && idePid > 0) {
|
||||
if (platform === 'win32') {
|
||||
const processMap = await getProcessTableWindows();
|
||||
const proc = processMap.get(idePid);
|
||||
return { pid: idePid, command: proc?.command || '' };
|
||||
}
|
||||
const { command } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command };
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return getIdeProcessInfoForWindows();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[[safety_checker]]
|
||||
toolName = "*"
|
||||
priority = 100
|
||||
[safety_checker.checker]
|
||||
type = "in-process"
|
||||
name = "conseca"
|
||||
@@ -36,6 +36,13 @@ deny_message = "You are in Plan Mode with access to read-only tools. Execution o
|
||||
|
||||
# Explicitly Allow Read-Only Tools in Plan mode.
|
||||
|
||||
[[rule]]
|
||||
mcpName = "*"
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
decision = "ask_user"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
[[rule]]
|
||||
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
|
||||
decision = "allow"
|
||||
|
||||
@@ -2375,6 +2375,75 @@ describe('PolicyEngine', () => {
|
||||
expect(Array.from(excluded).sort()).toEqual(expected.sort());
|
||||
},
|
||||
);
|
||||
|
||||
it('should skip annotation-based rules when no metadata is provided', () => {
|
||||
engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolAnnotations: { destructiveHint: true },
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const excluded = engine.getExcludedTools();
|
||||
expect(Array.from(excluded)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude tools matching annotation-based DENY rule when metadata is provided', () => {
|
||||
engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolAnnotations: { destructiveHint: true },
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const metadata = new Map<string, Record<string, unknown>>([
|
||||
['dangerous_tool', { destructiveHint: true }],
|
||||
['safe_tool', { readOnlyHint: true }],
|
||||
]);
|
||||
const excluded = engine.getExcludedTools(metadata);
|
||||
expect(Array.from(excluded)).toEqual(['dangerous_tool']);
|
||||
});
|
||||
|
||||
it('should NOT exclude tools whose annotations do not match', () => {
|
||||
engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolAnnotations: { destructiveHint: true },
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const metadata = new Map<string, Record<string, unknown>>([
|
||||
['safe_tool', { readOnlyHint: true }],
|
||||
]);
|
||||
const excluded = engine.getExcludedTools(metadata);
|
||||
expect(Array.from(excluded)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude tools matching both toolName pattern AND annotations', () => {
|
||||
engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: 'server__*',
|
||||
toolAnnotations: { destructiveHint: true },
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const metadata = new Map<string, Record<string, unknown>>([
|
||||
['server__dangerous_tool', { destructiveHint: true }],
|
||||
['other__dangerous_tool', { destructiveHint: true }],
|
||||
['server__safe_tool', { readOnlyHint: true }],
|
||||
]);
|
||||
const excluded = engine.getExcludedTools(metadata);
|
||||
expect(Array.from(excluded)).toEqual(['server__dangerous_tool']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('YOLO mode with ask_user tool', () => {
|
||||
|
||||
@@ -113,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)
|
||||
@@ -624,8 +627,15 @@ export class PolicyEngine {
|
||||
* 1. Global rules (no argsPattern)
|
||||
* 2. Priority order (higher priority wins)
|
||||
* 3. Non-interactive mode (ASK_USER becomes DENY)
|
||||
* 4. Annotation-based rules (when toolMetadata is provided)
|
||||
*
|
||||
* @param toolMetadata Optional map of tool names to their annotations.
|
||||
* When provided, annotation-based rules can match tools by their metadata.
|
||||
* When not provided, rules with toolAnnotations are skipped (conservative fallback).
|
||||
*/
|
||||
getExcludedTools(): Set<string> {
|
||||
getExcludedTools(
|
||||
toolMetadata?: Map<string, Record<string, unknown>>,
|
||||
): Set<string> {
|
||||
const excludedTools = new Set<string>();
|
||||
const processedTools = new Set<string>();
|
||||
let globalVerdict: PolicyDecision | undefined;
|
||||
@@ -645,6 +655,53 @@ export class PolicyEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle annotation-based rules
|
||||
if (rule.toolAnnotations) {
|
||||
if (!toolMetadata) {
|
||||
// Without metadata, we can't evaluate annotation rules — skip (conservative fallback)
|
||||
continue;
|
||||
}
|
||||
// Iterate over all known tools and check if their annotations match this rule
|
||||
for (const [toolName, annotations] of toolMetadata) {
|
||||
if (processedTools.has(toolName)) {
|
||||
continue;
|
||||
}
|
||||
// Check if annotations match the rule's toolAnnotations (partial match)
|
||||
let annotationsMatch = true;
|
||||
for (const [key, value] of Object.entries(rule.toolAnnotations)) {
|
||||
if (annotations[key] !== value) {
|
||||
annotationsMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!annotationsMatch) {
|
||||
continue;
|
||||
}
|
||||
// Check if the tool name matches the rule's toolName pattern (if any)
|
||||
if (rule.toolName) {
|
||||
if (isWildcardPattern(rule.toolName)) {
|
||||
if (!matchesWildcard(rule.toolName, toolName, undefined)) {
|
||||
continue;
|
||||
}
|
||||
} else if (toolName !== rule.toolName) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Determine decision considering global verdict
|
||||
let decision: PolicyDecision;
|
||||
if (globalVerdict !== undefined) {
|
||||
decision = globalVerdict;
|
||||
} else {
|
||||
decision = rule.decision;
|
||||
}
|
||||
if (decision === PolicyDecision.DENY) {
|
||||
excludedTools.add(toolName);
|
||||
}
|
||||
processedTools.add(toolName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle Global Rules
|
||||
if (!rule.toolName) {
|
||||
if (globalVerdict === undefined) {
|
||||
|
||||
@@ -609,6 +609,118 @@ priority = 100
|
||||
});
|
||||
|
||||
describe('Built-in Plan Mode Policy', () => {
|
||||
it('should allow MCP tools with readOnlyHint annotation in Plan Mode (ASK_USER, not DENY)', async () => {
|
||||
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
|
||||
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
|
||||
const tempPolicyDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'plan-annotation-test-'),
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), fileContent);
|
||||
const getPolicyTier = () => 1; // Default tier
|
||||
|
||||
// 1. Load the actual Plan Mode policies
|
||||
const result = await loadPoliciesFromToml(
|
||||
[tempPolicyDir],
|
||||
getPolicyTier,
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
// Verify annotation rule was loaded correctly
|
||||
const annotationRule = result.rules.find(
|
||||
(r) => r.toolAnnotations !== undefined,
|
||||
);
|
||||
expect(
|
||||
annotationRule,
|
||||
'Should have loaded a rule with toolAnnotations',
|
||||
).toBeDefined();
|
||||
expect(annotationRule!.toolName).toBe('*__*');
|
||||
expect(annotationRule!.toolAnnotations).toEqual({
|
||||
readOnlyHint: true,
|
||||
});
|
||||
expect(annotationRule!.decision).toBe(PolicyDecision.ASK_USER);
|
||||
// Priority 70 in tier 1 => 1.070
|
||||
expect(annotationRule!.priority).toBe(1.07);
|
||||
|
||||
// Verify deny rule was loaded correctly
|
||||
const denyRule = result.rules.find(
|
||||
(r) =>
|
||||
r.decision === PolicyDecision.DENY &&
|
||||
r.toolName === undefined &&
|
||||
r.denyMessage?.includes('Plan Mode'),
|
||||
);
|
||||
expect(
|
||||
denyRule,
|
||||
'Should have loaded the catch-all deny rule',
|
||||
).toBeDefined();
|
||||
// Priority 60 in tier 1 => 1.060
|
||||
expect(denyRule!.priority).toBe(1.06);
|
||||
|
||||
// 2. Initialize Policy Engine in Plan Mode
|
||||
const engine = new PolicyEngine({
|
||||
rules: result.rules,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
});
|
||||
|
||||
// 3. MCP tool with readOnlyHint=true and serverName should get ASK_USER
|
||||
const askResult = await engine.check(
|
||||
{ name: 'github__list_issues' },
|
||||
'github',
|
||||
{ readOnlyHint: true },
|
||||
);
|
||||
expect(
|
||||
askResult.decision,
|
||||
'MCP tool with readOnlyHint=true should be ASK_USER, not DENY',
|
||||
).toBe(PolicyDecision.ASK_USER);
|
||||
|
||||
// 4. MCP tool WITHOUT annotations should be DENIED
|
||||
const denyResult = await engine.check(
|
||||
{ name: 'github__create_issue' },
|
||||
'github',
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
denyResult.decision,
|
||||
'MCP tool without annotations should be DENIED in Plan Mode',
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// 5. MCP tool with readOnlyHint=false should also be DENIED
|
||||
const denyResult2 = await engine.check(
|
||||
{ name: 'github__delete_issue' },
|
||||
'github',
|
||||
{ readOnlyHint: false },
|
||||
);
|
||||
expect(
|
||||
denyResult2.decision,
|
||||
'MCP tool with readOnlyHint=false should be DENIED in Plan Mode',
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// 6. Test with qualified tool name format (server__tool) but no separate serverName
|
||||
const qualifiedResult = await engine.check(
|
||||
{ name: 'github__list_repos' },
|
||||
undefined,
|
||||
{ readOnlyHint: true },
|
||||
);
|
||||
expect(
|
||||
qualifiedResult.decision,
|
||||
'Qualified MCP tool name with readOnlyHint=true should be ASK_USER even without separate serverName',
|
||||
).toBe(PolicyDecision.ASK_USER);
|
||||
|
||||
// 7. Non-MCP tool (no server context) should be DENIED despite having annotations
|
||||
const builtinResult = await engine.check(
|
||||
{ name: 'some_random_tool' },
|
||||
undefined,
|
||||
{ readOnlyHint: true },
|
||||
);
|
||||
expect(
|
||||
builtinResult.decision,
|
||||
'Non-MCP tool should be DENIED even with readOnlyHint (no server context for *__* match)',
|
||||
).toBe(PolicyDecision.DENY);
|
||||
} finally {
|
||||
await fs.rm(tempPolicyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should override default subagent rules when in Plan Mode', async () => {
|
||||
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
|
||||
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface ExternalCheckerConfig {
|
||||
|
||||
export enum InProcessCheckerType {
|
||||
ALLOWED_PATH = 'allowed-path',
|
||||
CONSECA = 'conseca',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<SafetyCheckResult> {
|
||||
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<SecurityPolicy> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<SafetyCheckResult> {
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<PolicyGenerationResult> {
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, ToolPolicy>;
|
||||
@@ -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<Config>;
|
||||
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: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, InProcessChecker>
|
||||
| undefined;
|
||||
|
||||
private static getBuiltInInProcessCheckers(): Map<string, InProcessChecker> {
|
||||
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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,11 @@ describe('policy.ts', () => {
|
||||
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
|
||||
{ name: 'test-tool', args: {} },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass serverName for MCP tools', async () => {
|
||||
it('should pass serverName and toolAnnotations for MCP tools', async () => {
|
||||
const mockPolicyEngine = {
|
||||
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW }),
|
||||
} as unknown as Mocked<PolicyEngine>;
|
||||
@@ -73,6 +74,7 @@ describe('policy.ts', () => {
|
||||
|
||||
const mcpTool = Object.create(DiscoveredMCPTool.prototype);
|
||||
mcpTool.serverName = 'my-server';
|
||||
mcpTool._toolAnnotations = { readOnlyHint: true };
|
||||
|
||||
const toolCall = {
|
||||
request: { name: 'mcp-tool', args: {} },
|
||||
@@ -83,6 +85,7 @@ describe('policy.ts', () => {
|
||||
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
|
||||
{ name: 'mcp-tool', args: {} },
|
||||
'my-server',
|
||||
{ readOnlyHint: true },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,11 +54,14 @@ export async function checkPolicy(
|
||||
? toolCall.tool.serverName
|
||||
: undefined;
|
||||
|
||||
const toolAnnotations = toolCall.tool.toolAnnotations;
|
||||
|
||||
const result = await config
|
||||
.getPolicyEngine()
|
||||
.check(
|
||||
{ name: toolCall.request.name, args: toolCall.request.args },
|
||||
serverName,
|
||||
toolAnnotations,
|
||||
);
|
||||
|
||||
const { decision } = result;
|
||||
|
||||
@@ -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<SchedulerStateManager>;
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
): Promise<boolean> {
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SuccessfulToolCall> {
|
||||
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<string, unknown> },
|
||||
): 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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface ToolCallRequestInfo {
|
||||
callId: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
}
|
||||
|
||||
/** Request to execute another tool immediately after a completed one. */
|
||||
export interface TailToolCallRequest {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn> };
|
||||
let mockClearcutLogger: {
|
||||
enqueueLogEvent: ReturnType<typeof vi.fn>;
|
||||
createLogEvent: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user