detached e5fc4d2

Merge PowerShell tool and settings into main

commit
e5fc4d224fe8cb7715b23bf23f55208f8882807b
author
patriceckhart · · 2026-09-06 18:13 UTC
merge of
9ef467d + cfaaf8e · diff against the first parent
signature
unsigned
15 files · +656 -15
M README.md +20 -1
@@ -247,7 +247,25 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
247247 - `edit`: one or more exact-match replacements in an existing file.
248248 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
249249
250-When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd.
250+When the sandbox is on (see `/jail`), filesystem tools refuse paths outside the session cwd and `bash` applies best-effort command checks. Jail mode is an accident-prevention guardrail, not a security boundary.
251+
252+### PowerShell tool (Windows)
253+
254+The optional `powershell` tool executes native PowerShell commands in the session cwd. On Windows, open `/settings` and toggle **PowerShell tool** to enable or disable it alongside the default tools. It is off by default, persists as `powershell_enabled` in `$ZOT_HOME/config.json`, and updates the live tool list without a restart. The saved preference also applies to other modes on Windows.
255+
256+For a one-run tool selection, enable it instead of `bash`:
257+
258+```powershell
259+zot --tools read,write,edit,glob,powershell
260+```
261+
262+Include `bash` in that list to enable both. Explicit `--tools` and `--no-tools` override the saved preference and disable the settings toggle for that run. The toggle is hidden on non-Windows platforms. `!` shell escapes remain unchanged (`cmd /C` on Windows).
263+
264+PowerShell execution prefers `pwsh.exe` on `PATH`, falling back to `powershell.exe`. It uses `-NoProfile -NonInteractive -ExecutionPolicy Bypass`, with UTF-8 output and encoded command transport to preserve Unicode and quoting. Administrator-enforced execution policies can still take precedence. Missing executables and non-Windows execution return explicit errors.
265+
266+The tool accepts `command` and an optional `timeout` in seconds, with merged streaming stdout/stderr, exit status, and the same output limits as `bash`. On Windows, cancellation terminates the shell and bounds output draining, but does not guarantee termination of descendant processes.
267+
268+PowerShell is blocked while `/jail` is active because the existing shell checks do not parse PowerShell syntax. Packaged agents require `permissions.bash.mode: "ask"` (launch-time consent); `none`, `allowlist`, and unknown modes deny PowerShell execution. Bash allowlists are not applied to PowerShell.
251269
252270 ## Modes
253271
@@ -403,6 +421,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
403421 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
404422 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
405423 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
424+- **PowerShell tool** (Windows only): enable or disable native PowerShell alongside the default tools. Off by default; saved as `powershell_enabled` and applied without restarting. Explicit `--tools` or `--no-tools` takes precedence and disables this toggle for the run. Execution is still blocked while jailed and remains subject to packaged-agent permissions.
406425 - **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn (adjustable via `openrouter_server_tool_call_limit` in `config.json`). `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges.
407426
408427 **config.json example:**
@@ -247,7 +247,25 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
247 - `edit`: one or more exact-match replacements in an existing file.247 - `edit`: one or more exact-match replacements in an existing file.
248 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.248 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
249 249
250-When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd.250+When the sandbox is on (see `/jail`), filesystem tools refuse paths outside the session cwd and `bash` applies best-effort command checks. Jail mode is an accident-prevention guardrail, not a security boundary.
251+
252+### PowerShell tool (Windows)
253+
254+The optional `powershell` tool executes native PowerShell commands in the session cwd. On Windows, open `/settings` and toggle **PowerShell tool** to enable or disable it alongside the default tools. It is off by default, persists as `powershell_enabled` in `$ZOT_HOME/config.json`, and updates the live tool list without a restart. The saved preference also applies to other modes on Windows.
255+
256+For a one-run tool selection, enable it instead of `bash`:
257+
258+```powershell
259+zot --tools read,write,edit,glob,powershell
260+```
261+
262+Include `bash` in that list to enable both. Explicit `--tools` and `--no-tools` override the saved preference and disable the settings toggle for that run. The toggle is hidden on non-Windows platforms. `!` shell escapes remain unchanged (`cmd /C` on Windows).
263+
264+PowerShell execution prefers `pwsh.exe` on `PATH`, falling back to `powershell.exe`. It uses `-NoProfile -NonInteractive -ExecutionPolicy Bypass`, with UTF-8 output and encoded command transport to preserve Unicode and quoting. Administrator-enforced execution policies can still take precedence. Missing executables and non-Windows execution return explicit errors.
265+
266+The tool accepts `command` and an optional `timeout` in seconds, with merged streaming stdout/stderr, exit status, and the same output limits as `bash`. On Windows, cancellation terminates the shell and bounds output draining, but does not guarantee termination of descendant processes.
267+
268+PowerShell is blocked while `/jail` is active because the existing shell checks do not parse PowerShell syntax. Packaged agents require `permissions.bash.mode: "ask"` (launch-time consent); `none`, `allowlist`, and unknown modes deny PowerShell execution. Bash allowlists are not applied to PowerShell.
251 269
252 ## Modes270 ## Modes
253 271
@@ -403,6 +421,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
403 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.421 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
404 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.422 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
405 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.423 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
424+- **PowerShell tool** (Windows only): enable or disable native PowerShell alongside the default tools. Off by default; saved as `powershell_enabled` and applied without restarting. Explicit `--tools` or `--no-tools` takes precedence and disables this toggle for the run. Execution is still blocked while jailed and remains subject to packaged-agent permissions.
406 - **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn (adjustable via `openrouter_server_tool_call_limit` in `config.json`). `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges.425 - **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn (adjustable via `openrouter_server_tool_call_limit` in `config.json`). `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges.
407 426
408 **config.json example:**427 **config.json example:**
M docs/zotfiles.md +2 -0
@@ -401,6 +401,8 @@ Bash is available after the user approves the agent at launch. Consent for an ag
401401
402402 In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation.
403403
404+The optional Windows `powershell` tool also requires `bash.mode: "ask"` for packaged agents. It is denied in all other permission modes and while jailed. Bash allowlists cannot safely validate PowerShell syntax. Enable the tool via the Windows-only **PowerShell tool** toggle in `/settings` or select it explicitly with `--tools read,write,edit,glob,powershell`; see [PowerShell tool](../README.md#powershell-tool-windows).
405+
404406 ### `allowlist`
405407
406408 ```json
@@ -401,6 +401,8 @@ Bash is available after the user approves the agent at launch. Consent for an ag
401 401
402 In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation.402 In the current local runtime, `ask` is launch-time capability consent. It does not open a separate confirmation dialog for every individual bash invocation.
403 403
404+The optional Windows `powershell` tool also requires `bash.mode: "ask"` for packaged agents. It is denied in all other permission modes and while jailed. Bash allowlists cannot safely validate PowerShell syntax. Enable the tool via the Windows-only **PowerShell tool** toggle in `/settings` or select it explicitly with `--tools read,write,edit,glob,powershell`; see [PowerShell tool](../README.md#powershell-tool-windows).
405+
404 ### `allowlist`406 ### `allowlist`
405 407
406 ```json408 ```json
M packages/agent/build.go +14 -2
@@ -578,7 +578,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
578578 if args.PermissionSet != nil {
579579 sandbox.SetPermissions(args.PermissionSet)
580580 }
581- reg := buildToolRegistry(args, args.CWD, sandbox)
581+ reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS)
582582 if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
583583 reg = withOpenRouterServerTools(reg)
584584 }
@@ -997,6 +997,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
997997 v.Sandbox = s
998998 case *tools.BashTool:
999999 v.Sandbox = s
1000+ case *tools.PowerShellTool:
1001+ v.Sandbox = s
10001002 case *tools.GlobTool:
10011003 v.Sandbox = s
10021004 }
@@ -1064,6 +1066,14 @@ func withOpenRouterServerTools(reg core.Registry) core.Registry {
10641066 return out
10651067 }
10661068
1069+func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry {
1070+ reg := buildToolRegistry(args, cwd, sandbox)
1071+ if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled {
1072+ reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1073+ }
1074+ return reg
1075+}
1076+
10671077 func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
10681078 if args.NoTools {
10691079 return core.Registry{}
@@ -1082,6 +1092,8 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
10821092 }
10831093 return reg
10841094 }
1095+ // PowerShell is explicitly selected, never added to the default tool set.
1096+ all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
10851097 for _, name := range args.Tools {
10861098 if t, ok := all[name]; ok {
10871099 reg[name] = t
@@ -1091,7 +1103,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
10911103 }
10921104
10931105 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1094- order := []string{"read", "write", "edit", "bash", "glob"}
1106+ order := []string{"read", "write", "edit", "bash", "powershell", "glob"}
10951107 var out []ToolSummary
10961108 for _, name := range order {
10971109 if t, ok := reg[name]; ok {
@@ -578,7 +578,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
578 if args.PermissionSet != nil {578 if args.PermissionSet != nil {
579 sandbox.SetPermissions(args.PermissionSet)579 sandbox.SetPermissions(args.PermissionSet)
580 }580 }
581- reg := buildToolRegistry(args, args.CWD, sandbox)581+ reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS)
582 if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {582 if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
583 reg = withOpenRouterServerTools(reg)583 reg = withOpenRouterServerTools(reg)
584 }584 }
@@ -997,6 +997,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
997 v.Sandbox = s997 v.Sandbox = s
998 case *tools.BashTool:998 case *tools.BashTool:
999 v.Sandbox = s999 v.Sandbox = s
1000+ case *tools.PowerShellTool:
1001+ v.Sandbox = s
1000 case *tools.GlobTool:1002 case *tools.GlobTool:
1001 v.Sandbox = s1003 v.Sandbox = s
1002 }1004 }
@@ -1064,6 +1066,14 @@ func withOpenRouterServerTools(reg core.Registry) core.Registry {
1064 return out1066 return out
1065 }1067 }
1066 1068
1069+func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry {
1070+ reg := buildToolRegistry(args, cwd, sandbox)
1071+ if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled {
1072+ reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1073+ }
1074+ return reg
1075+}
1076+
1067 func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {1077 func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
1068 if args.NoTools {1078 if args.NoTools {
1069 return core.Registry{}1079 return core.Registry{}
@@ -1082,6 +1092,8 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
1082 }1092 }
1083 return reg1093 return reg
1084 }1094 }
1095+ // PowerShell is explicitly selected, never added to the default tool set.
1096+ all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1085 for _, name := range args.Tools {1097 for _, name := range args.Tools {
1086 if t, ok := all[name]; ok {1098 if t, ok := all[name]; ok {
1087 reg[name] = t1099 reg[name] = t
@@ -1091,7 +1103,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Regis
1091 }1103 }
1092 1104
1093 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1105 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1094- order := []string{"read", "write", "edit", "bash", "glob"}1106+ order := []string{"read", "write", "edit", "bash", "powershell", "glob"}
1095 var out []ToolSummary1107 var out []ToolSummary
1096 for _, name := range order {1108 for _, name := range order {
1097 if t, ok := reg[name]; ok {1109 if t, ok := reg[name]; ok {
M packages/agent/cli.go +2 -0
@@ -1280,6 +1280,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12801280 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
12811281 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
12821282 JailByDefault: initialCfg.JailByDefault,
1283+ PowerShellEnabled: initialCfg.PowerShellEnabled,
1284+ ToolSelectionExplicit: len(args.Tools) > 0,
12831285 OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled,
12841286 OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit,
12851287 NoTools: args.NoTools,
@@ -1280,6 +1280,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1280 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1280 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1281 AutoCompactThreshold: initialCfg.AutoCompactThreshold,1281 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1282 JailByDefault: initialCfg.JailByDefault,1282 JailByDefault: initialCfg.JailByDefault,
1283+ PowerShellEnabled: initialCfg.PowerShellEnabled,
1284+ ToolSelectionExplicit: len(args.Tools) > 0,
1283 OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled,1285 OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled,
1284 OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit,1286 OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit,
1285 NoTools: args.NoTools,1287 NoTools: args.NoTools,
M packages/agent/config.go +4 -0
@@ -68,6 +68,10 @@ type Config struct {
6868 // The live session can still override this with /jail or /unjail.
6969 JailByDefault *bool `json:"jail_by_default,omitempty"`
7070
71+ // PowerShellEnabled adds the optional PowerShell tool to the default tool
72+ // set on Windows. Explicit --tools and --no-tools override this preference.
73+ PowerShellEnabled *bool `json:"powershell_enabled,omitempty"`
74+
7175 // OpenRouterServerToolsEnabled advertises OpenRouter server tools
7276 // (web search, datetime, advisor, and others) when the provider is OpenRouter.
7377 // Default off: users explicitly opt in from /settings.
@@ -68,6 +68,10 @@ type Config struct {
68 // The live session can still override this with /jail or /unjail.68 // The live session can still override this with /jail or /unjail.
69 JailByDefault *bool `json:"jail_by_default,omitempty"`69 JailByDefault *bool `json:"jail_by_default,omitempty"`
70 70
71+ // PowerShellEnabled adds the optional PowerShell tool to the default tool
72+ // set on Windows. Explicit --tools and --no-tools override this preference.
73+ PowerShellEnabled *bool `json:"powershell_enabled,omitempty"`
74+
71 // OpenRouterServerToolsEnabled advertises OpenRouter server tools75 // OpenRouterServerToolsEnabled advertises OpenRouter server tools
72 // (web search, datetime, advisor, and others) when the provider is OpenRouter.76 // (web search, datetime, advisor, and others) when the provider is OpenRouter.
73 // Default off: users explicitly opt in from /settings.77 // Default off: users explicitly opt in from /settings.
M packages/agent/modes/interactive.go +9 -0
@@ -73,6 +73,12 @@ type InteractiveConfig struct {
7373 // The current sandbox may differ after a session-scoped /jail or /unjail.
7474 JailByDefault *bool
7575
76+ // PowerShellEnabled mirrors the Windows-only default tool preference.
77+ PowerShellEnabled *bool
78+
79+ // ToolSelectionExplicit preserves an explicit --tools selection.
80+ ToolSelectionExplicit bool
81+
7682 // OpenRouterServerToolsEnabled mirrors the persisted preference.
7783 // nil means the default, off.
7884 OpenRouterServerToolsEnabled *bool
@@ -3621,6 +3627,7 @@ func (i *Interactive) openSettingsDialog() {
36213627 children: quickItems,
36223628 })
36233629 }
3630+ items = i.appendPowerShellSetting(items, runtime.GOOS)
36243631 i.settingsDialog.Open(items)
36253632 }
36263633
@@ -3912,6 +3919,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
39123919 i.statusOK = "jail by default " + onOff(value)
39133920 i.statusErr = ""
39143921 i.mu.Unlock()
3922+ case "powershell_enabled":
3923+ i.applyPowerShellSetting(value, runtime.GOOS)
39153924 case "openrouter_server_tools_enabled":
39163925 val := value
39173926 i.cfg.OpenRouterServerToolsEnabled = &val
@@ -73,6 +73,12 @@ type InteractiveConfig struct {
73 // The current sandbox may differ after a session-scoped /jail or /unjail.73 // The current sandbox may differ after a session-scoped /jail or /unjail.
74 JailByDefault *bool74 JailByDefault *bool
75 75
76+ // PowerShellEnabled mirrors the Windows-only default tool preference.
77+ PowerShellEnabled *bool
78+
79+ // ToolSelectionExplicit preserves an explicit --tools selection.
80+ ToolSelectionExplicit bool
81+
76 // OpenRouterServerToolsEnabled mirrors the persisted preference.82 // OpenRouterServerToolsEnabled mirrors the persisted preference.
77 // nil means the default, off.83 // nil means the default, off.
78 OpenRouterServerToolsEnabled *bool84 OpenRouterServerToolsEnabled *bool
@@ -3621,6 +3627,7 @@ func (i *Interactive) openSettingsDialog() {
3621 children: quickItems,3627 children: quickItems,
3622 })3628 })
3623 }3629 }
3630+ items = i.appendPowerShellSetting(items, runtime.GOOS)
3624 i.settingsDialog.Open(items)3631 i.settingsDialog.Open(items)
3625 }3632 }
3626 3633
@@ -3912,6 +3919,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3912 i.statusOK = "jail by default " + onOff(value)3919 i.statusOK = "jail by default " + onOff(value)
3913 i.statusErr = ""3920 i.statusErr = ""
3914 i.mu.Unlock()3921 i.mu.Unlock()
3922+ case "powershell_enabled":
3923+ i.applyPowerShellSetting(value, runtime.GOOS)
3915 case "openrouter_server_tools_enabled":3924 case "openrouter_server_tools_enabled":
3916 val := value3925 val := value
3917 i.cfg.OpenRouterServerToolsEnabled = &val3926 i.cfg.OpenRouterServerToolsEnabled = &val
A packages/agent/modes/powershell_setting.go +97 -0
@@ -0,0 +1,97 @@
1+package modes
2+
3+import (
4+ "fmt"
5+
6+ "github.com/patriceckhart/zot/packages/agent/tools"
7+ "github.com/patriceckhart/zot/packages/core"
8+)
9+
10+// Optional so existing SettingsStore implementations remain compatible.
11+type powerShellSettingsStore interface {
12+ SetPowerShell(enabled bool) error
13+}
14+
15+func (i *Interactive) powerShellSettingHint() string {
16+ switch {
17+ case i.cfg.NoTools:
18+ return "disabled for this run by --no-tools"
19+ case i.cfg.ToolSelectionExplicit:
20+ return "controlled for this run by --tools"
21+ }
22+ if i.agent != nil {
23+ if tool := i.agent.Tools["powershell"]; tool != nil {
24+ if _, builtin := tool.(*tools.PowerShellTool); !builtin {
25+ return "controlled by a custom tool"
26+ }
27+ }
28+ }
29+ return ""
30+}
31+
32+func (i *Interactive) appendPowerShellSetting(items []settingsItem, goos string) []settingsItem {
33+ if goos != "windows" {
34+ return items
35+ }
36+ hint := i.powerShellSettingHint()
37+ value := i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled
38+ if hint != "" && i.agent != nil {
39+ value = i.agent.Tools["powershell"] != nil
40+ }
41+ return append(items, settingsItem{
42+ key: "powershell_enabled", label: "PowerShell tool",
43+ desc: "enable native PowerShell alongside the default tools; execution remains blocked while jailed",
44+ value: value, disabled: hint != "", hint: hint,
45+ })
46+}
47+
48+func (i *Interactive) applyPowerShellSetting(value bool, goos string) {
49+ var err error
50+ switch {
51+ case goos != "windows":
52+ err = fmt.Errorf("PowerShell is only available on Windows")
53+ case i.powerShellSettingHint() != "":
54+ err = fmt.Errorf("%s", i.powerShellSettingHint())
55+ default:
56+ if i.cfg.SettingsStore != nil {
57+ if store, ok := i.cfg.SettingsStore.(powerShellSettingsStore); ok {
58+ err = store.SetPowerShell(value)
59+ } else {
60+ err = fmt.Errorf("settings store does not support PowerShell")
61+ }
62+ }
63+ }
64+ if err != nil {
65+ i.mu.Lock()
66+ i.statusErr = "settings: " + err.Error()
67+ i.statusOK = ""
68+ i.mu.Unlock()
69+ // Restore the row that the dialog optimistically toggled.
70+ if i.settingsDialog != nil {
71+ for idx := range i.settingsDialog.items {
72+ if i.settingsDialog.items[idx].key == "powershell_enabled" {
73+ i.settingsDialog.items[idx].value = i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled
74+ }
75+ }
76+ }
77+ return
78+ }
79+
80+ i.cfg.PowerShellEnabled = &value
81+ if i.agent != nil {
82+ next := core.Registry{}
83+ for name, tool := range i.agent.Tools {
84+ if name != "powershell" {
85+ next[name] = tool
86+ }
87+ }
88+ if value {
89+ next["powershell"] = &tools.PowerShellTool{CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox}
90+ }
91+ i.agent.SetTools(next)
92+ }
93+ i.mu.Lock()
94+ i.statusOK = "PowerShell tool " + onOff(value)
95+ i.statusErr = ""
96+ i.mu.Unlock()
97+}
@@ -0,0 +1,97 @@
1+package modes
2+
3+import (
4+ "fmt"
5+
6+ "github.com/patriceckhart/zot/packages/agent/tools"
7+ "github.com/patriceckhart/zot/packages/core"
8+)
9+
10+// Optional so existing SettingsStore implementations remain compatible.
11+type powerShellSettingsStore interface {
12+ SetPowerShell(enabled bool) error
13+}
14+
15+func (i *Interactive) powerShellSettingHint() string {
16+ switch {
17+ case i.cfg.NoTools:
18+ return "disabled for this run by --no-tools"
19+ case i.cfg.ToolSelectionExplicit:
20+ return "controlled for this run by --tools"
21+ }
22+ if i.agent != nil {
23+ if tool := i.agent.Tools["powershell"]; tool != nil {
24+ if _, builtin := tool.(*tools.PowerShellTool); !builtin {
25+ return "controlled by a custom tool"
26+ }
27+ }
28+ }
29+ return ""
30+}
31+
32+func (i *Interactive) appendPowerShellSetting(items []settingsItem, goos string) []settingsItem {
33+ if goos != "windows" {
34+ return items
35+ }
36+ hint := i.powerShellSettingHint()
37+ value := i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled
38+ if hint != "" && i.agent != nil {
39+ value = i.agent.Tools["powershell"] != nil
40+ }
41+ return append(items, settingsItem{
42+ key: "powershell_enabled", label: "PowerShell tool",
43+ desc: "enable native PowerShell alongside the default tools; execution remains blocked while jailed",
44+ value: value, disabled: hint != "", hint: hint,
45+ })
46+}
47+
48+func (i *Interactive) applyPowerShellSetting(value bool, goos string) {
49+ var err error
50+ switch {
51+ case goos != "windows":
52+ err = fmt.Errorf("PowerShell is only available on Windows")
53+ case i.powerShellSettingHint() != "":
54+ err = fmt.Errorf("%s", i.powerShellSettingHint())
55+ default:
56+ if i.cfg.SettingsStore != nil {
57+ if store, ok := i.cfg.SettingsStore.(powerShellSettingsStore); ok {
58+ err = store.SetPowerShell(value)
59+ } else {
60+ err = fmt.Errorf("settings store does not support PowerShell")
61+ }
62+ }
63+ }
64+ if err != nil {
65+ i.mu.Lock()
66+ i.statusErr = "settings: " + err.Error()
67+ i.statusOK = ""
68+ i.mu.Unlock()
69+ // Restore the row that the dialog optimistically toggled.
70+ if i.settingsDialog != nil {
71+ for idx := range i.settingsDialog.items {
72+ if i.settingsDialog.items[idx].key == "powershell_enabled" {
73+ i.settingsDialog.items[idx].value = i.cfg.PowerShellEnabled != nil && *i.cfg.PowerShellEnabled
74+ }
75+ }
76+ }
77+ return
78+ }
79+
80+ i.cfg.PowerShellEnabled = &value
81+ if i.agent != nil {
82+ next := core.Registry{}
83+ for name, tool := range i.agent.Tools {
84+ if name != "powershell" {
85+ next[name] = tool
86+ }
87+ }
88+ if value {
89+ next["powershell"] = &tools.PowerShellTool{CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox}
90+ }
91+ i.agent.SetTools(next)
92+ }
93+ i.mu.Lock()
94+ i.statusOK = "PowerShell tool " + onOff(value)
95+ i.statusErr = ""
96+ i.mu.Unlock()
97+}
A packages/agent/modes/powershell_setting_test.go +132 -0
@@ -0,0 +1,132 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/tools"
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/tui"
10+)
11+
12+type testPowerShellStore struct {
13+ SettingsStore
14+ values []bool
15+ err error
16+}
17+
18+func (s *testPowerShellStore) SetPowerShell(value bool) error {
19+ if s.err != nil {
20+ return s.err
21+ }
22+ s.values = append(s.values, value)
23+ return nil
24+}
25+
26+func TestPowerShellSettingLiveToggle(t *testing.T) {
27+ cwd := t.TempDir()
28+ sb := tools.NewSandbox(cwd)
29+ store := &testPowerShellStore{}
30+ read := &tools.ReadTool{CWD: cwd}
31+ original := core.Registry{"read": read, "custom": read}
32+ i := &Interactive{
33+ cfg: InteractiveConfig{CWD: cwd, Sandbox: sb, SettingsStore: store},
34+ agent: core.NewAgent(nil, "", "", original),
35+ }
36+ for _, value := range []bool{true, false} {
37+ i.applyPowerShellSetting(value, "windows")
38+ if i.statusErr != "" {
39+ t.Fatal(i.statusErr)
40+ }
41+ if i.cfg.PowerShellEnabled == nil || *i.cfg.PowerShellEnabled != value {
42+ t.Fatal("preference not updated")
43+ }
44+ if (i.agent.Tools["powershell"] != nil) != value {
45+ t.Fatal("live registry not updated")
46+ }
47+ if value {
48+ ps := i.agent.Tools["powershell"].(*tools.PowerShellTool)
49+ if ps.CWD != cwd || ps.Sandbox != sb {
50+ t.Fatal("tool lost session context")
51+ }
52+ }
53+ if i.agent.Tools["custom"] != read || i.agent.Tools["read"] != read {
54+ t.Fatal("unrelated tools changed")
55+ }
56+ if original["powershell"] != nil {
57+ t.Fatal("original registry mutated")
58+ }
59+ }
60+ if len(store.values) != 2 || !store.values[0] || store.values[1] {
61+ t.Fatalf("saved: %v", store.values)
62+ }
63+}
64+
65+func TestPowerShellSettingFailurePreservesState(t *testing.T) {
66+ store := &testPowerShellStore{err: errors.New("save failed")}
67+ i := &Interactive{
68+ cfg: InteractiveConfig{SettingsStore: store},
69+ agent: core.NewAgent(nil, "", "", core.Registry{}),
70+ settingsDialog: newSettingsDialog(),
71+ }
72+ i.settingsDialog.Open(i.appendPowerShellSetting(nil, "windows"))
73+ action := i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
74+ if !action.Toggle || !action.Value {
75+ t.Fatal("dialog did not enable")
76+ }
77+ i.applyPowerShellSetting(action.Value, "windows")
78+ if i.statusErr != "settings: save failed" {
79+ t.Fatalf("error: %s", i.statusErr)
80+ }
81+ if i.cfg.PowerShellEnabled != nil || i.agent.Tools["powershell"] != nil || i.settingsDialog.items[0].value {
82+ t.Fatal("failed save changed state")
83+ }
84+}
85+
86+func TestPowerShellSettingPlatformAndOverrides(t *testing.T) {
87+ for _, tc := range []struct {
88+ name, goos string
89+ cfg InteractiveConfig
90+ custom bool
91+ }{
92+ {"non-Windows", "darwin", InteractiveConfig{}, false},
93+ {"no tools", "windows", InteractiveConfig{NoTools: true}, false},
94+ {"explicit tools", "windows", InteractiveConfig{ToolSelectionExplicit: true}, false},
95+ {"custom tool", "windows", InteractiveConfig{}, true},
96+ } {
97+ t.Run(tc.name, func(t *testing.T) {
98+ store := &testPowerShellStore{}
99+ tc.cfg.SettingsStore = store
100+ reg := core.Registry{}
101+ if tc.custom {
102+ reg["powershell"] = &tools.ReadTool{}
103+ }
104+ i := &Interactive{cfg: tc.cfg, agent: core.NewAgent(nil, "", "", reg)}
105+ items := i.appendPowerShellSetting(nil, tc.goos)
106+ if tc.goos != "windows" {
107+ if len(items) != 0 {
108+ t.Fatal("setting shown on non-Windows")
109+ }
110+ } else if len(items) != 1 || !items[0].disabled || items[0].hint == "" {
111+ t.Fatalf("setting: %+v", items)
112+ }
113+ i.applyPowerShellSetting(true, tc.goos)
114+ if i.statusErr == "" || len(store.values) != 0 || i.cfg.PowerShellEnabled != nil {
115+ t.Fatal("override not respected")
116+ }
117+ if i.agent.Tools["powershell"] != reg["powershell"] {
118+ t.Fatal("registry changed")
119+ }
120+ })
121+ }
122+}
123+
124+func TestPowerShellSettingReflectsPreference(t *testing.T) {
125+ for _, enabled := range []bool{true, false} {
126+ i := &Interactive{cfg: InteractiveConfig{PowerShellEnabled: &enabled}}
127+ items := i.appendPowerShellSetting(nil, "windows")
128+ if len(items) != 1 || items[0].value != enabled || items[0].disabled {
129+ t.Fatalf("setting: %+v", items)
130+ }
131+ }
132+}
@@ -0,0 +1,132 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/tools"
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/tui"
10+)
11+
12+type testPowerShellStore struct {
13+ SettingsStore
14+ values []bool
15+ err error
16+}
17+
18+func (s *testPowerShellStore) SetPowerShell(value bool) error {
19+ if s.err != nil {
20+ return s.err
21+ }
22+ s.values = append(s.values, value)
23+ return nil
24+}
25+
26+func TestPowerShellSettingLiveToggle(t *testing.T) {
27+ cwd := t.TempDir()
28+ sb := tools.NewSandbox(cwd)
29+ store := &testPowerShellStore{}
30+ read := &tools.ReadTool{CWD: cwd}
31+ original := core.Registry{"read": read, "custom": read}
32+ i := &Interactive{
33+ cfg: InteractiveConfig{CWD: cwd, Sandbox: sb, SettingsStore: store},
34+ agent: core.NewAgent(nil, "", "", original),
35+ }
36+ for _, value := range []bool{true, false} {
37+ i.applyPowerShellSetting(value, "windows")
38+ if i.statusErr != "" {
39+ t.Fatal(i.statusErr)
40+ }
41+ if i.cfg.PowerShellEnabled == nil || *i.cfg.PowerShellEnabled != value {
42+ t.Fatal("preference not updated")
43+ }
44+ if (i.agent.Tools["powershell"] != nil) != value {
45+ t.Fatal("live registry not updated")
46+ }
47+ if value {
48+ ps := i.agent.Tools["powershell"].(*tools.PowerShellTool)
49+ if ps.CWD != cwd || ps.Sandbox != sb {
50+ t.Fatal("tool lost session context")
51+ }
52+ }
53+ if i.agent.Tools["custom"] != read || i.agent.Tools["read"] != read {
54+ t.Fatal("unrelated tools changed")
55+ }
56+ if original["powershell"] != nil {
57+ t.Fatal("original registry mutated")
58+ }
59+ }
60+ if len(store.values) != 2 || !store.values[0] || store.values[1] {
61+ t.Fatalf("saved: %v", store.values)
62+ }
63+}
64+
65+func TestPowerShellSettingFailurePreservesState(t *testing.T) {
66+ store := &testPowerShellStore{err: errors.New("save failed")}
67+ i := &Interactive{
68+ cfg: InteractiveConfig{SettingsStore: store},
69+ agent: core.NewAgent(nil, "", "", core.Registry{}),
70+ settingsDialog: newSettingsDialog(),
71+ }
72+ i.settingsDialog.Open(i.appendPowerShellSetting(nil, "windows"))
73+ action := i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
74+ if !action.Toggle || !action.Value {
75+ t.Fatal("dialog did not enable")
76+ }
77+ i.applyPowerShellSetting(action.Value, "windows")
78+ if i.statusErr != "settings: save failed" {
79+ t.Fatalf("error: %s", i.statusErr)
80+ }
81+ if i.cfg.PowerShellEnabled != nil || i.agent.Tools["powershell"] != nil || i.settingsDialog.items[0].value {
82+ t.Fatal("failed save changed state")
83+ }
84+}
85+
86+func TestPowerShellSettingPlatformAndOverrides(t *testing.T) {
87+ for _, tc := range []struct {
88+ name, goos string
89+ cfg InteractiveConfig
90+ custom bool
91+ }{
92+ {"non-Windows", "darwin", InteractiveConfig{}, false},
93+ {"no tools", "windows", InteractiveConfig{NoTools: true}, false},
94+ {"explicit tools", "windows", InteractiveConfig{ToolSelectionExplicit: true}, false},
95+ {"custom tool", "windows", InteractiveConfig{}, true},
96+ } {
97+ t.Run(tc.name, func(t *testing.T) {
98+ store := &testPowerShellStore{}
99+ tc.cfg.SettingsStore = store
100+ reg := core.Registry{}
101+ if tc.custom {
102+ reg["powershell"] = &tools.ReadTool{}
103+ }
104+ i := &Interactive{cfg: tc.cfg, agent: core.NewAgent(nil, "", "", reg)}
105+ items := i.appendPowerShellSetting(nil, tc.goos)
106+ if tc.goos != "windows" {
107+ if len(items) != 0 {
108+ t.Fatal("setting shown on non-Windows")
109+ }
110+ } else if len(items) != 1 || !items[0].disabled || items[0].hint == "" {
111+ t.Fatalf("setting: %+v", items)
112+ }
113+ i.applyPowerShellSetting(true, tc.goos)
114+ if i.statusErr == "" || len(store.values) != 0 || i.cfg.PowerShellEnabled != nil {
115+ t.Fatal("override not respected")
116+ }
117+ if i.agent.Tools["powershell"] != reg["powershell"] {
118+ t.Fatal("registry changed")
119+ }
120+ })
121+ }
122+}
123+
124+func TestPowerShellSettingReflectsPreference(t *testing.T) {
125+ for _, enabled := range []bool{true, false} {
126+ i := &Interactive{cfg: InteractiveConfig{PowerShellEnabled: &enabled}}
127+ items := i.appendPowerShellSetting(nil, "windows")
128+ if len(items) != 1 || items[0].value != enabled || items[0].disabled {
129+ t.Fatalf("setting: %+v", items)
130+ }
131+ }
132+}
A packages/agent/powershell_test.go +100 -0
@@ -0,0 +1,100 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/tools"
7+)
8+
9+func TestPowerShellConfigToolSelection(t *testing.T) {
10+ enabled, disabled := true, false
11+ for _, tc := range []struct {
12+ name, goos string
13+ preference *bool
14+ args Args
15+ want bool
16+ }{
17+ {"default", "windows", nil, Args{}, false},
18+ {"enabled", "windows", &enabled, Args{}, true},
19+ {"disabled", "windows", &disabled, Args{}, false},
20+ {"other OS", "darwin", &enabled, Args{}, false},
21+ {"no tools", "windows", &enabled, Args{NoTools: true}, false},
22+ {"explicit exclusion", "windows", &enabled, Args{Tools: []string{"read"}}, false},
23+ {"explicit inclusion", "windows", &disabled, Args{Tools: []string{"powershell"}}, true},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ reg := buildConfiguredToolRegistry(tc.args, t.TempDir(), nil, Config{PowerShellEnabled: tc.preference}, tc.goos)
27+ if (reg["powershell"] != nil) != tc.want {
28+ t.Fatalf("registry: %v", reg)
29+ }
30+ if tc.name == "enabled" && reg["bash"] == nil {
31+ t.Fatal("setting removed bash")
32+ }
33+ })
34+ }
35+}
36+
37+func TestPowerShellSettingPersistence(t *testing.T) {
38+ t.Setenv("ZOT_HOME", t.TempDir())
39+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
40+ t.Fatal(err)
41+ }
42+ for _, enabled := range []bool{true, false} {
43+ if err := (configSettingsStore{}).SetPowerShell(enabled); err != nil {
44+ t.Fatal(err)
45+ }
46+ cfg, err := LoadConfig()
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if cfg.PowerShellEnabled == nil || *cfg.PowerShellEnabled != enabled || cfg.Theme != "dark" {
51+ t.Fatalf("config not preserved: %+v", cfg)
52+ }
53+ reg := buildConfiguredToolRegistry(Args{}, t.TempDir(), nil, cfg, "windows")
54+ if (reg["powershell"] != nil) != enabled {
55+ t.Fatal("saved preference not applied")
56+ }
57+ }
58+}
59+
60+func TestPowerShellToolSelection(t *testing.T) {
61+ cwd := t.TempDir()
62+ sb := tools.NewSandbox(cwd)
63+ if _, ok := buildToolRegistry(Args{}, cwd, sb)["powershell"]; ok {
64+ t.Fatal("PowerShell enabled by default")
65+ }
66+ args, err := ParseArgs([]string{"--tools", "read,powershell,glob"})
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ reg := buildToolRegistry(args, cwd, sb)
71+ ps, ok := reg["powershell"].(*tools.PowerShellTool)
72+ if !ok || ps.CWD != cwd || ps.Sandbox != sb {
73+ t.Fatalf("tool: %#v", reg["powershell"])
74+ }
75+ if len(reg) != 3 || reg["bash"] != nil {
76+ t.Fatalf("registry: %v", reg)
77+ }
78+ found := false
79+ for _, summary := range toolSummaries(reg, args) {
80+ if summary.Name == "powershell" {
81+ found = true
82+ }
83+ }
84+ if !found {
85+ t.Fatal("PowerShell missing from prompt summaries")
86+ }
87+ replacement := tools.NewSandbox(cwd)
88+ r := &Resolved{ToolRegistry: reg, Sandbox: sb}
89+ r.UseSandbox(replacement)
90+ if ps.Sandbox != replacement {
91+ t.Fatal("sandbox not replaced")
92+ }
93+ if len(buildToolRegistry(Args{NoTools: true, Tools: []string{"powershell"}}, cwd, sb)) != 0 {
94+ t.Fatal("--no-tools ignored")
95+ }
96+ both := buildToolRegistry(Args{Tools: []string{"bash", "powershell"}}, cwd, sb)
97+ if both["bash"] == nil || both["powershell"] == nil {
98+ t.Fatal("cannot select both shells")
99+ }
100+}
@@ -0,0 +1,100 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/tools"
7+)
8+
9+func TestPowerShellConfigToolSelection(t *testing.T) {
10+ enabled, disabled := true, false
11+ for _, tc := range []struct {
12+ name, goos string
13+ preference *bool
14+ args Args
15+ want bool
16+ }{
17+ {"default", "windows", nil, Args{}, false},
18+ {"enabled", "windows", &enabled, Args{}, true},
19+ {"disabled", "windows", &disabled, Args{}, false},
20+ {"other OS", "darwin", &enabled, Args{}, false},
21+ {"no tools", "windows", &enabled, Args{NoTools: true}, false},
22+ {"explicit exclusion", "windows", &enabled, Args{Tools: []string{"read"}}, false},
23+ {"explicit inclusion", "windows", &disabled, Args{Tools: []string{"powershell"}}, true},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ reg := buildConfiguredToolRegistry(tc.args, t.TempDir(), nil, Config{PowerShellEnabled: tc.preference}, tc.goos)
27+ if (reg["powershell"] != nil) != tc.want {
28+ t.Fatalf("registry: %v", reg)
29+ }
30+ if tc.name == "enabled" && reg["bash"] == nil {
31+ t.Fatal("setting removed bash")
32+ }
33+ })
34+ }
35+}
36+
37+func TestPowerShellSettingPersistence(t *testing.T) {
38+ t.Setenv("ZOT_HOME", t.TempDir())
39+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
40+ t.Fatal(err)
41+ }
42+ for _, enabled := range []bool{true, false} {
43+ if err := (configSettingsStore{}).SetPowerShell(enabled); err != nil {
44+ t.Fatal(err)
45+ }
46+ cfg, err := LoadConfig()
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if cfg.PowerShellEnabled == nil || *cfg.PowerShellEnabled != enabled || cfg.Theme != "dark" {
51+ t.Fatalf("config not preserved: %+v", cfg)
52+ }
53+ reg := buildConfiguredToolRegistry(Args{}, t.TempDir(), nil, cfg, "windows")
54+ if (reg["powershell"] != nil) != enabled {
55+ t.Fatal("saved preference not applied")
56+ }
57+ }
58+}
59+
60+func TestPowerShellToolSelection(t *testing.T) {
61+ cwd := t.TempDir()
62+ sb := tools.NewSandbox(cwd)
63+ if _, ok := buildToolRegistry(Args{}, cwd, sb)["powershell"]; ok {
64+ t.Fatal("PowerShell enabled by default")
65+ }
66+ args, err := ParseArgs([]string{"--tools", "read,powershell,glob"})
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ reg := buildToolRegistry(args, cwd, sb)
71+ ps, ok := reg["powershell"].(*tools.PowerShellTool)
72+ if !ok || ps.CWD != cwd || ps.Sandbox != sb {
73+ t.Fatalf("tool: %#v", reg["powershell"])
74+ }
75+ if len(reg) != 3 || reg["bash"] != nil {
76+ t.Fatalf("registry: %v", reg)
77+ }
78+ found := false
79+ for _, summary := range toolSummaries(reg, args) {
80+ if summary.Name == "powershell" {
81+ found = true
82+ }
83+ }
84+ if !found {
85+ t.Fatal("PowerShell missing from prompt summaries")
86+ }
87+ replacement := tools.NewSandbox(cwd)
88+ r := &Resolved{ToolRegistry: reg, Sandbox: sb}
89+ r.UseSandbox(replacement)
90+ if ps.Sandbox != replacement {
91+ t.Fatal("sandbox not replaced")
92+ }
93+ if len(buildToolRegistry(Args{NoTools: true, Tools: []string{"powershell"}}, cwd, sb)) != 0 {
94+ t.Fatal("--no-tools ignored")
95+ }
96+ both := buildToolRegistry(Args{Tools: []string{"bash", "powershell"}}, cwd, sb)
97+ if both["bash"] == nil || both["powershell"] == nil {
98+ t.Fatal("cannot select both shells")
99+ }
100+}
M packages/agent/settings_store.go +9 -0
@@ -68,6 +68,15 @@ func (configSettingsStore) SetJailByDefault(enabled bool) error {
6868 return SaveConfig(cfg)
6969 }
7070
71+func (configSettingsStore) SetPowerShell(enabled bool) error {
72+ cfg, err := LoadConfig()
73+ if err != nil {
74+ return err
75+ }
76+ cfg.PowerShellEnabled = &enabled
77+ return SaveConfig(cfg)
78+}
79+
7180 func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error {
7281 cfg, err := LoadConfig()
7382 if err != nil {
@@ -68,6 +68,15 @@ func (configSettingsStore) SetJailByDefault(enabled bool) error {
68 return SaveConfig(cfg)68 return SaveConfig(cfg)
69 }69 }
70 70
71+func (configSettingsStore) SetPowerShell(enabled bool) error {
72+ cfg, err := LoadConfig()
73+ if err != nil {
74+ return err
75+ }
76+ cfg.PowerShellEnabled = &enabled
77+ return SaveConfig(cfg)
78+}
79+
71 func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error {80 func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error {
72 cfg, err := LoadConfig()81 cfg, err := LoadConfig()
73 if err != nil {82 if err != nil {
M packages/agent/tools/bash.go +23 -8
@@ -42,6 +42,18 @@ func (t *BashTool) Description() string { return shellDescription(currentShe
4242 func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
4343
4444 func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
45+ return executeShell(ctx, raw, progress, t.CWD, "$", func(command string) error {
46+ if err := t.Sandbox.CheckCommand(command); err != nil {
47+ return err
48+ }
49+ return t.Sandbox.CheckBashPermission(command)
50+ }, func(ctx context.Context, command string) (*exec.Cmd, error) {
51+ return newShellCmd(ctx, command), nil
52+ })
53+}
54+
55+// executeShell shares output streaming, timeouts and result formatting across shells.
56+func executeShell(ctx context.Context, raw json.RawMessage, progress func(string), cwd, prompt string, check func(string) error, newCommand func(context.Context, string) (*exec.Cmd, error)) (core.ToolResult, error) {
4557 var a bashArgs
4658 if err := json.Unmarshal(raw, &a); err != nil {
4759 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
@@ -49,13 +61,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
4961 if strings.TrimSpace(a.Command) == "" {
5062 return core.ToolResult{}, fmt.Errorf("command is required")
5163 }
52- if err := t.Sandbox.CheckCommand(a.Command); err != nil {
64+ if err := check(a.Command); err != nil {
5365 return core.ToolResult{}, err
5466 }
55- if err := t.Sandbox.CheckBashPermission(a.Command); err != nil {
56- return core.ToolResult{}, err
57- }
58- cwd := t.CWD
5967 if cwd == "" {
6068 cwd, _ = os.Getwd()
6169 }
@@ -68,13 +76,20 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
6876 }
6977
7078 start := time.Now()
71- cmd := newShellCmd(runCtx, a.Command)
79+ cmd, err := newCommand(runCtx, a.Command)
80+ if err != nil {
81+ return core.ToolResult{}, err
82+ }
7283 cmd.Dir = cwd
7384 cmd.Env = os.Environ()
85+ // Bound output draining if descendants retain handles after cancellation.
86+ cmd.WaitDelay = 2 * time.Second
7487 setProcessGroup(cmd)
7588
7689 // Capture merged stdout+stderr with line-by-line streaming.
7790 pr, pw := io.Pipe()
91+ defer pr.Close()
92+ defer pw.Close()
7893 cmd.Stdout = pw
7994 cmd.Stderr = pw
8095
@@ -155,7 +170,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
155170 // a human would see if they ran the command themselves, which
156171 // makes the model's reasoning about it more natural too.
157172 var sb strings.Builder
158- fmt.Fprintf(&sb, "$ %s\n", a.Command)
173+ fmt.Fprintf(&sb, "%s %s\n", prompt, a.Command)
159174 if trimmed != "" {
160175 sb.WriteString("\n")
161176 sb.WriteString(trimmed)
@@ -185,7 +200,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
185200 }
186201 fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))
187202
188- isErr := exitCode != 0 || ctx.Err() != nil
203+ isErr := exitCode != 0 || runCtx.Err() != nil
189204 return core.ToolResult{
190205 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
191206 IsError: isErr,
@@ -42,6 +42,18 @@ func (t *BashTool) Description() string { return shellDescription(currentShe
42 func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }42 func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
43 43
44 func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {44 func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
45+ return executeShell(ctx, raw, progress, t.CWD, "$", func(command string) error {
46+ if err := t.Sandbox.CheckCommand(command); err != nil {
47+ return err
48+ }
49+ return t.Sandbox.CheckBashPermission(command)
50+ }, func(ctx context.Context, command string) (*exec.Cmd, error) {
51+ return newShellCmd(ctx, command), nil
52+ })
53+}
54+
55+// executeShell shares output streaming, timeouts and result formatting across shells.
56+func executeShell(ctx context.Context, raw json.RawMessage, progress func(string), cwd, prompt string, check func(string) error, newCommand func(context.Context, string) (*exec.Cmd, error)) (core.ToolResult, error) {
45 var a bashArgs57 var a bashArgs
46 if err := json.Unmarshal(raw, &a); err != nil {58 if err := json.Unmarshal(raw, &a); err != nil {
47 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)59 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
@@ -49,13 +61,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
49 if strings.TrimSpace(a.Command) == "" {61 if strings.TrimSpace(a.Command) == "" {
50 return core.ToolResult{}, fmt.Errorf("command is required")62 return core.ToolResult{}, fmt.Errorf("command is required")
51 }63 }
52- if err := t.Sandbox.CheckCommand(a.Command); err != nil {64+ if err := check(a.Command); err != nil {
53 return core.ToolResult{}, err65 return core.ToolResult{}, err
54 }66 }
55- if err := t.Sandbox.CheckBashPermission(a.Command); err != nil {
56- return core.ToolResult{}, err
57- }
58- cwd := t.CWD
59 if cwd == "" {67 if cwd == "" {
60 cwd, _ = os.Getwd()68 cwd, _ = os.Getwd()
61 }69 }
@@ -68,13 +76,20 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
68 }76 }
69 77
70 start := time.Now()78 start := time.Now()
71- cmd := newShellCmd(runCtx, a.Command)79+ cmd, err := newCommand(runCtx, a.Command)
80+ if err != nil {
81+ return core.ToolResult{}, err
82+ }
72 cmd.Dir = cwd83 cmd.Dir = cwd
73 cmd.Env = os.Environ()84 cmd.Env = os.Environ()
85+ // Bound output draining if descendants retain handles after cancellation.
86+ cmd.WaitDelay = 2 * time.Second
74 setProcessGroup(cmd)87 setProcessGroup(cmd)
75 88
76 // Capture merged stdout+stderr with line-by-line streaming.89 // Capture merged stdout+stderr with line-by-line streaming.
77 pr, pw := io.Pipe()90 pr, pw := io.Pipe()
91+ defer pr.Close()
92+ defer pw.Close()
78 cmd.Stdout = pw93 cmd.Stdout = pw
79 cmd.Stderr = pw94 cmd.Stderr = pw
80 95
@@ -155,7 +170,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
155 // a human would see if they ran the command themselves, which170 // a human would see if they ran the command themselves, which
156 // makes the model's reasoning about it more natural too.171 // makes the model's reasoning about it more natural too.
157 var sb strings.Builder172 var sb strings.Builder
158- fmt.Fprintf(&sb, "$ %s\n", a.Command)173+ fmt.Fprintf(&sb, "%s %s\n", prompt, a.Command)
159 if trimmed != "" {174 if trimmed != "" {
160 sb.WriteString("\n")175 sb.WriteString("\n")
161 sb.WriteString(trimmed)176 sb.WriteString(trimmed)
@@ -185,7 +200,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
185 }200 }
186 fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))201 fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))
187 202
188- isErr := exitCode != 0 || ctx.Err() != nil203+ isErr := exitCode != 0 || runCtx.Err() != nil
189 return core.ToolResult{204 return core.ToolResult{
190 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},205 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
191 IsError: isErr,206 IsError: isErr,
A packages/agent/tools/powershell.go +75 -0
@@ -0,0 +1,75 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "encoding/binary"
7+ "encoding/json"
8+ "fmt"
9+ "os/exec"
10+ "runtime"
11+ "strings"
12+ "unicode/utf16"
13+
14+ "github.com/patriceckhart/zot/packages/core"
15+)
16+
17+// PowerShellTool provides opt-in native command execution on Windows.
18+type PowerShellTool struct {
19+ CWD string
20+ Sandbox *Sandbox
21+}
22+
23+func (t *PowerShellTool) Name() string { return "powershell" }
24+func (t *PowerShellTool) Description() string {
25+ return "Run a native PowerShell command on Windows (pwsh.exe, falling back to powershell.exe), without profiles or interactive input. stdout+stderr merged. Optional timeout in seconds."
26+}
27+func (t *PowerShellTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
28+
29+func (t *PowerShellTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
30+ return executeShell(ctx, raw, progress, t.CWD, "PS>", t.checkPermission, func(ctx context.Context, command string) (*exec.Cmd, error) {
31+ path, err := resolvePowerShell(runtime.GOOS, exec.LookPath)
32+ if err != nil {
33+ return nil, err
34+ }
35+ return exec.CommandContext(ctx, path, powerShellArgs(command)...), nil
36+ })
37+}
38+
39+func (t *PowerShellTool) checkPermission(_ string) error {
40+ // The existing shell heuristics parse neither PowerShell expressions nor
41+ // cmdlets. Do not treat them as enforcement for a different language.
42+ if t.Sandbox.Locked() {
43+ return fmt.Errorf("jailed: PowerShell execution is unavailable while jailed")
44+ }
45+ if t.Sandbox != nil && t.Sandbox.Permissions != nil {
46+ if strings.ToLower(strings.TrimSpace(t.Sandbox.Permissions.Bash.Mode)) != "ask" {
47+ return fmt.Errorf("permission denied: PowerShell requires bash permission mode ask; bash allowlists do not apply to PowerShell")
48+ }
49+ }
50+ return nil
51+}
52+
53+func resolvePowerShell(goos string, lookPath func(string) (string, error)) (string, error) {
54+ if goos != "windows" {
55+ return "", fmt.Errorf("the powershell tool is only available on Windows")
56+ }
57+ for _, name := range []string{"pwsh.exe", "powershell.exe"} {
58+ if path, err := lookPath(name); err == nil {
59+ return path, nil
60+ }
61+ }
62+ return "", fmt.Errorf("PowerShell not found: install PowerShell or add pwsh.exe or powershell.exe to PATH")
63+}
64+
65+func powerShellArgs(command string) []string {
66+ // EncodedCommand preserves quotes, multiline scripts and Unicode through
67+ // Windows command-line parsing. PowerShell expects UTF-16LE here.
68+ script := "[Console]::OutputEncoding = $OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n" + command
69+ units := utf16.Encode([]rune(script))
70+ buf := make([]byte, len(units)*2)
71+ for i, unit := range units {
72+ binary.LittleEndian.PutUint16(buf[i*2:], unit)
73+ }
74+ return []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", base64.StdEncoding.EncodeToString(buf)}
75+}
@@ -0,0 +1,75 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "encoding/binary"
7+ "encoding/json"
8+ "fmt"
9+ "os/exec"
10+ "runtime"
11+ "strings"
12+ "unicode/utf16"
13+
14+ "github.com/patriceckhart/zot/packages/core"
15+)
16+
17+// PowerShellTool provides opt-in native command execution on Windows.
18+type PowerShellTool struct {
19+ CWD string
20+ Sandbox *Sandbox
21+}
22+
23+func (t *PowerShellTool) Name() string { return "powershell" }
24+func (t *PowerShellTool) Description() string {
25+ return "Run a native PowerShell command on Windows (pwsh.exe, falling back to powershell.exe), without profiles or interactive input. stdout+stderr merged. Optional timeout in seconds."
26+}
27+func (t *PowerShellTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
28+
29+func (t *PowerShellTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
30+ return executeShell(ctx, raw, progress, t.CWD, "PS>", t.checkPermission, func(ctx context.Context, command string) (*exec.Cmd, error) {
31+ path, err := resolvePowerShell(runtime.GOOS, exec.LookPath)
32+ if err != nil {
33+ return nil, err
34+ }
35+ return exec.CommandContext(ctx, path, powerShellArgs(command)...), nil
36+ })
37+}
38+
39+func (t *PowerShellTool) checkPermission(_ string) error {
40+ // The existing shell heuristics parse neither PowerShell expressions nor
41+ // cmdlets. Do not treat them as enforcement for a different language.
42+ if t.Sandbox.Locked() {
43+ return fmt.Errorf("jailed: PowerShell execution is unavailable while jailed")
44+ }
45+ if t.Sandbox != nil && t.Sandbox.Permissions != nil {
46+ if strings.ToLower(strings.TrimSpace(t.Sandbox.Permissions.Bash.Mode)) != "ask" {
47+ return fmt.Errorf("permission denied: PowerShell requires bash permission mode ask; bash allowlists do not apply to PowerShell")
48+ }
49+ }
50+ return nil
51+}
52+
53+func resolvePowerShell(goos string, lookPath func(string) (string, error)) (string, error) {
54+ if goos != "windows" {
55+ return "", fmt.Errorf("the powershell tool is only available on Windows")
56+ }
57+ for _, name := range []string{"pwsh.exe", "powershell.exe"} {
58+ if path, err := lookPath(name); err == nil {
59+ return path, nil
60+ }
61+ }
62+ return "", fmt.Errorf("PowerShell not found: install PowerShell or add pwsh.exe or powershell.exe to PATH")
63+}
64+
65+func powerShellArgs(command string) []string {
66+ // EncodedCommand preserves quotes, multiline scripts and Unicode through
67+ // Windows command-line parsing. PowerShell expects UTF-16LE here.
68+ script := "[Console]::OutputEncoding = $OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n" + command
69+ units := utf16.Encode([]rune(script))
70+ buf := make([]byte, len(units)*2)
71+ for i, unit := range units {
72+ binary.LittleEndian.PutUint16(buf[i*2:], unit)
73+ }
74+ return []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", base64.StdEncoding.EncodeToString(buf)}
75+}
A packages/agent/tools/powershell_test.go +150 -0
@@ -0,0 +1,150 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "encoding/binary"
7+ "encoding/json"
8+ "fmt"
9+ "os/exec"
10+ "reflect"
11+ "runtime"
12+ "strings"
13+ "testing"
14+ "time"
15+ "unicode/utf16"
16+
17+ "github.com/patriceckhart/zot/packages/provider"
18+)
19+
20+func TestResolvePowerShell(t *testing.T) {
21+ for _, tc := range []struct{ name, goos, available, want string }{
22+ {"preferred", "windows", "pwsh.exe", "pwsh.exe"},
23+ {"fallback", "windows", "powershell.exe", "powershell.exe"},
24+ {"missing", "windows", "", ""},
25+ {"unsupported", "linux", "pwsh.exe", ""},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ var calls []string
29+ got, err := resolvePowerShell(tc.goos, func(name string) (string, error) {
30+ calls = append(calls, name)
31+ if name == tc.available {
32+ return name, nil
33+ }
34+ return "", fmt.Errorf("not found")
35+ })
36+ if got != tc.want || (err != nil) != (tc.want == "") {
37+ t.Fatalf("got %q, %v", got, err)
38+ }
39+ if tc.goos != "windows" && len(calls) != 0 {
40+ t.Fatal("searched on unsupported OS")
41+ }
42+ if tc.name == "preferred" && len(calls) != 1 {
43+ t.Fatal("did not prefer pwsh")
44+ }
45+ })
46+ }
47+}
48+
49+func TestPowerShellArgs(t *testing.T) {
50+ command := "Write-Output 'Grüße 世界'\nWrite-Output \"quoted\""
51+ args := powerShellArgs(command)
52+ want := []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand"}
53+ if !reflect.DeepEqual(args[:len(args)-1], want) {
54+ t.Fatalf("args: %v", args)
55+ }
56+ data, err := base64.StdEncoding.DecodeString(args[len(args)-1])
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ units := make([]uint16, len(data)/2)
61+ for i := range units {
62+ units[i] = binary.LittleEndian.Uint16(data[i*2:])
63+ }
64+ script := string(utf16.Decode(units))
65+ if !strings.HasSuffix(script, "\n"+command) || !strings.Contains(script, "UTF8Encoding") {
66+ t.Fatalf("script: %q", script)
67+ }
68+}
69+
70+func TestPowerShellPermissions(t *testing.T) {
71+ sb := NewSandbox(t.TempDir())
72+ tool := &PowerShellTool{Sandbox: sb}
73+ if err := tool.checkPermission(""); err != nil {
74+ t.Fatal(err)
75+ }
76+ sb.Lock()
77+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil); err == nil || !strings.Contains(err.Error(), "jailed") {
78+ t.Fatalf("got %v", err)
79+ }
80+ sb.Unlock()
81+ sb.Permissions = &PermissionSet{}
82+ for _, mode := range []string{"", "none", "allowlist", "invalid", "ask"} {
83+ sb.Permissions.Bash.Mode = mode
84+ sb.Permissions.Bash.Allow = []string{"Get-Location"}
85+ if err := tool.checkPermission("Get-Location"); (err == nil) != (mode == "ask") {
86+ t.Fatalf("mode %q: %v", mode, err)
87+ }
88+ }
89+}
90+
91+func TestPowerShellInvalidArgs(t *testing.T) {
92+ tool := &PowerShellTool{}
93+ for _, raw := range []string{`{`, `{}`, `{"command":" "}`, `{"command":123}`} {
94+ if _, err := tool.Execute(context.Background(), json.RawMessage(raw), nil); err == nil {
95+ t.Fatalf("accepted %s", raw)
96+ }
97+ }
98+ if runtime.GOOS != "windows" {
99+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil)
100+ if err == nil || !strings.Contains(err.Error(), "only available on Windows") {
101+ t.Fatalf("got %v", err)
102+ }
103+ }
104+}
105+
106+func TestPowerShellExecution(t *testing.T) {
107+ if runtime.GOOS != "windows" {
108+ t.Skip("native Windows execution")
109+ }
110+ if _, err := resolvePowerShell(runtime.GOOS, exec.LookPath); err != nil {
111+ t.Skip(err)
112+ }
113+ cwd := t.TempDir()
114+ tool := &PowerShellTool{CWD: cwd}
115+ for _, tc := range []struct {
116+ name, command, want string
117+ failure bool
118+ timeout int
119+ }{
120+ {"unicode and quoting", "Write-Output 'Grüße 世界'; Write-Output '\"quoted\"'", "Grüße 世界", false, 0},
121+ {"cwd", "(Get-Location).Path", cwd, false, 0},
122+ {"stderr", "[Console]::Error.WriteLine('stderr output')", "stderr output", false, 0},
123+ {"exit", "exit 7", "[exit 7]", true, 0},
124+ {"error", "throw 'failure'", "failure", true, 0},
125+ {"timeout", "Start-Sleep -Seconds 60", "", true, 1},
126+ } {
127+ t.Run(tc.name, func(t *testing.T) {
128+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
129+ defer cancel()
130+ var streamed strings.Builder
131+ res, err := tool.Execute(ctx, mustJSON(t, map[string]any{"command": tc.command, "timeout": tc.timeout}), func(s string) { streamed.WriteString(s) })
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ text := res.Content[0].(provider.TextBlock).Text
136+ if res.IsError != tc.failure || !strings.Contains(text, tc.want) || !strings.HasPrefix(text, "PS> ") {
137+ t.Fatalf("result: %+v, %s", res, text)
138+ }
139+ if tc.name == "unicode and quoting" && !strings.Contains(streamed.String(), "Grüße 世界") {
140+ t.Fatalf("stream: %q", streamed.String())
141+ }
142+ })
143+ }
144+ ctx, cancel := context.WithCancel(context.Background())
145+ cancel()
146+ res, err := tool.Execute(ctx, json.RawMessage(`{"command":"Get-Location"}`), nil)
147+ if err == nil && !res.IsError {
148+ t.Fatal("cancellation succeeded")
149+ }
150+}
@@ -0,0 +1,150 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "encoding/binary"
7+ "encoding/json"
8+ "fmt"
9+ "os/exec"
10+ "reflect"
11+ "runtime"
12+ "strings"
13+ "testing"
14+ "time"
15+ "unicode/utf16"
16+
17+ "github.com/patriceckhart/zot/packages/provider"
18+)
19+
20+func TestResolvePowerShell(t *testing.T) {
21+ for _, tc := range []struct{ name, goos, available, want string }{
22+ {"preferred", "windows", "pwsh.exe", "pwsh.exe"},
23+ {"fallback", "windows", "powershell.exe", "powershell.exe"},
24+ {"missing", "windows", "", ""},
25+ {"unsupported", "linux", "pwsh.exe", ""},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ var calls []string
29+ got, err := resolvePowerShell(tc.goos, func(name string) (string, error) {
30+ calls = append(calls, name)
31+ if name == tc.available {
32+ return name, nil
33+ }
34+ return "", fmt.Errorf("not found")
35+ })
36+ if got != tc.want || (err != nil) != (tc.want == "") {
37+ t.Fatalf("got %q, %v", got, err)
38+ }
39+ if tc.goos != "windows" && len(calls) != 0 {
40+ t.Fatal("searched on unsupported OS")
41+ }
42+ if tc.name == "preferred" && len(calls) != 1 {
43+ t.Fatal("did not prefer pwsh")
44+ }
45+ })
46+ }
47+}
48+
49+func TestPowerShellArgs(t *testing.T) {
50+ command := "Write-Output 'Grüße 世界'\nWrite-Output \"quoted\""
51+ args := powerShellArgs(command)
52+ want := []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand"}
53+ if !reflect.DeepEqual(args[:len(args)-1], want) {
54+ t.Fatalf("args: %v", args)
55+ }
56+ data, err := base64.StdEncoding.DecodeString(args[len(args)-1])
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ units := make([]uint16, len(data)/2)
61+ for i := range units {
62+ units[i] = binary.LittleEndian.Uint16(data[i*2:])
63+ }
64+ script := string(utf16.Decode(units))
65+ if !strings.HasSuffix(script, "\n"+command) || !strings.Contains(script, "UTF8Encoding") {
66+ t.Fatalf("script: %q", script)
67+ }
68+}
69+
70+func TestPowerShellPermissions(t *testing.T) {
71+ sb := NewSandbox(t.TempDir())
72+ tool := &PowerShellTool{Sandbox: sb}
73+ if err := tool.checkPermission(""); err != nil {
74+ t.Fatal(err)
75+ }
76+ sb.Lock()
77+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil); err == nil || !strings.Contains(err.Error(), "jailed") {
78+ t.Fatalf("got %v", err)
79+ }
80+ sb.Unlock()
81+ sb.Permissions = &PermissionSet{}
82+ for _, mode := range []string{"", "none", "allowlist", "invalid", "ask"} {
83+ sb.Permissions.Bash.Mode = mode
84+ sb.Permissions.Bash.Allow = []string{"Get-Location"}
85+ if err := tool.checkPermission("Get-Location"); (err == nil) != (mode == "ask") {
86+ t.Fatalf("mode %q: %v", mode, err)
87+ }
88+ }
89+}
90+
91+func TestPowerShellInvalidArgs(t *testing.T) {
92+ tool := &PowerShellTool{}
93+ for _, raw := range []string{`{`, `{}`, `{"command":" "}`, `{"command":123}`} {
94+ if _, err := tool.Execute(context.Background(), json.RawMessage(raw), nil); err == nil {
95+ t.Fatalf("accepted %s", raw)
96+ }
97+ }
98+ if runtime.GOOS != "windows" {
99+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"Get-Location"}`), nil)
100+ if err == nil || !strings.Contains(err.Error(), "only available on Windows") {
101+ t.Fatalf("got %v", err)
102+ }
103+ }
104+}
105+
106+func TestPowerShellExecution(t *testing.T) {
107+ if runtime.GOOS != "windows" {
108+ t.Skip("native Windows execution")
109+ }
110+ if _, err := resolvePowerShell(runtime.GOOS, exec.LookPath); err != nil {
111+ t.Skip(err)
112+ }
113+ cwd := t.TempDir()
114+ tool := &PowerShellTool{CWD: cwd}
115+ for _, tc := range []struct {
116+ name, command, want string
117+ failure bool
118+ timeout int
119+ }{
120+ {"unicode and quoting", "Write-Output 'Grüße 世界'; Write-Output '\"quoted\"'", "Grüße 世界", false, 0},
121+ {"cwd", "(Get-Location).Path", cwd, false, 0},
122+ {"stderr", "[Console]::Error.WriteLine('stderr output')", "stderr output", false, 0},
123+ {"exit", "exit 7", "[exit 7]", true, 0},
124+ {"error", "throw 'failure'", "failure", true, 0},
125+ {"timeout", "Start-Sleep -Seconds 60", "", true, 1},
126+ } {
127+ t.Run(tc.name, func(t *testing.T) {
128+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
129+ defer cancel()
130+ var streamed strings.Builder
131+ res, err := tool.Execute(ctx, mustJSON(t, map[string]any{"command": tc.command, "timeout": tc.timeout}), func(s string) { streamed.WriteString(s) })
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ text := res.Content[0].(provider.TextBlock).Text
136+ if res.IsError != tc.failure || !strings.Contains(text, tc.want) || !strings.HasPrefix(text, "PS> ") {
137+ t.Fatalf("result: %+v, %s", res, text)
138+ }
139+ if tc.name == "unicode and quoting" && !strings.Contains(streamed.String(), "Grüße 世界") {
140+ t.Fatalf("stream: %q", streamed.String())
141+ }
142+ })
143+ }
144+ ctx, cancel := context.WithCancel(context.Background())
145+ cancel()
146+ res, err := tool.Execute(ctx, json.RawMessage(`{"command":"Get-Location"}`), nil)
147+ if err == nil && !res.IsError {
148+ t.Fatal("cancellation succeeded")
149+ }
150+}
M packages/tui/view.go +8 -4
@@ -1051,22 +1051,26 @@ func (v *View) renderLiveToolContent(tc ToolCallView, width int) []string {
10511051 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}
10521052 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
10531053 return body
1054- case "bash", "Bash":
1054+ case "bash", "Bash", "powershell", "PowerShell":
10551055 command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
10561056 if !ok || strings.TrimSpace(command) == "" {
10571057 return nil
10581058 }
1059- return v.renderLiveBashCommand(command, width)
1059+ prompt := "$ "
1060+ if strings.EqualFold(tc.Name, "powershell") {
1061+ prompt = "PS> "
1062+ }
1063+ return v.renderLiveShellCommand(command, width, prompt)
10601064 }
10611065 return nil
10621066 }
10631067
1064-func (v *View) renderLiveBashCommand(command string, width int) []string {
1068+func (v *View) renderLiveShellCommand(command string, width int, promptText string) []string {
10651069 inner := toolBoxBodyRenderWidth(width)
10661070 if v.FlatTools || v.CompactMode {
10671071 inner = flatToolBodyRenderWidth(width)
10681072 }
1069- prompt := v.Theme.FG256(v.Theme.Muted, "$ ")
1073+ prompt := v.Theme.FG256(v.Theme.Muted, promptText)
10701074 var out []string
10711075 for i, line := range strings.Split(command, "\n") {
10721076 firstPrefix := " "
@@ -1051,22 +1051,26 @@ func (v *View) renderLiveToolContent(tc ToolCallView, width int) []string {
1051 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}1051 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}
1052 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)1052 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
1053 return body1053 return body
1054- case "bash", "Bash":1054+ case "bash", "Bash", "powershell", "PowerShell":
1055 command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")1055 command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
1056 if !ok || strings.TrimSpace(command) == "" {1056 if !ok || strings.TrimSpace(command) == "" {
1057 return nil1057 return nil
1058 }1058 }
1059- return v.renderLiveBashCommand(command, width)1059+ prompt := "$ "
1060+ if strings.EqualFold(tc.Name, "powershell") {
1061+ prompt = "PS> "
1062+ }
1063+ return v.renderLiveShellCommand(command, width, prompt)
1060 }1064 }
1061 return nil1065 return nil
1062 }1066 }
1063 1067
1064-func (v *View) renderLiveBashCommand(command string, width int) []string {1068+func (v *View) renderLiveShellCommand(command string, width int, promptText string) []string {
1065 inner := toolBoxBodyRenderWidth(width)1069 inner := toolBoxBodyRenderWidth(width)
1066 if v.FlatTools || v.CompactMode {1070 if v.FlatTools || v.CompactMode {
1067 inner = flatToolBodyRenderWidth(width)1071 inner = flatToolBodyRenderWidth(width)
1068 }1072 }
1069- prompt := v.Theme.FG256(v.Theme.Muted, "$ ")1073+ prompt := v.Theme.FG256(v.Theme.Muted, promptText)
1070 var out []string1074 var out []string
1071 for i, line := range strings.Split(command, "\n") {1075 for i, line := range strings.Split(command, "\n") {
1072 firstPrefix := " "1076 firstPrefix := " "
M packages/tui/view_tool_overlay_test.go +11 -0
@@ -82,6 +82,17 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
8282 }
8383 }
8484
85+func TestLiveToolOverlayShowsPowerShellCommand(t *testing.T) {
86+ args := json.RawMessage(`{"command":"Get-ChildItem -Force"}`)
87+ v := View{Theme: Dark, ToolCalls: []ToolCallView{{
88+ ID: "shell_1", Name: "powershell", RawJSONBuf: string(args),
89+ }}}
90+ plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
91+ if !strings.Contains(plain, "PS> Get-ChildItem -Force") {
92+ t.Fatalf("PowerShell command missing from live preview: %s", plain)
93+ }
94+}
95+
8596 func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
8697 v := View{Theme: Dark}
8798 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)
@@ -82,6 +82,17 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) {
82 }82 }
83 }83 }
84 84
85+func TestLiveToolOverlayShowsPowerShellCommand(t *testing.T) {
86+ args := json.RawMessage(`{"command":"Get-ChildItem -Force"}`)
87+ v := View{Theme: Dark, ToolCalls: []ToolCallView{{
88+ ID: "shell_1", Name: "powershell", RawJSONBuf: string(args),
89+ }}}
90+ plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
91+ if !strings.Contains(plain, "PS> Get-ChildItem -Force") {
92+ t.Fatalf("PowerShell command missing from live preview: %s", plain)
93+ }
94+}
95+
85 func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {96 func TestLiveToolOverlayHeightDoesNotShrinkMidStream(t *testing.T) {
86 v := View{Theme: Dark}97 v := View{Theme: Dark}
87 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)98 tall := json.RawMessage(`{"command":"line1\nline2\nline3\nline4\nline5"}`)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close