feat(exec): enable execution in config and update async callback handling
Signed-off-by: Boris Bliznioukov <blib@mail.com>
This commit is contained in:
parent
c1e33f6151
commit
6c28061d67
6 changed files with 62 additions and 45 deletions
|
|
@ -340,6 +340,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
|
"enabled": true,
|
||||||
"risk_threshold": "medium",
|
"risk_threshold": "medium",
|
||||||
"risk_overrides": {},
|
"risk_overrides": {},
|
||||||
"arg_modifiers": {},
|
"arg_modifiers": {},
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@
|
||||||
On Windows (`runtime.GOOS == "windows"`), the allowlist MUST additionally include: `PATHEXT`, `SYSTEMROOT`, `SYSTEMDRIVE`, `COMSPEC`, `APPDATA`, `USERPROFILE`, `HOMEDRIVE`, `HOMEPATH`. Without `SYSTEMROOT`, many Windows system calls fail. Without `PATHEXT`, executable lookup cannot probe extensions.
|
On Windows (`runtime.GOOS == "windows"`), the allowlist MUST additionally include: `PATHEXT`, `SYSTEMROOT`, `SYSTEMDRIVE`, `COMSPEC`, `APPDATA`, `USERPROFILE`, `HOMEDRIVE`, `HOMEPATH`. Without `SYSTEMROOT`, many Windows system calls fail. Without `PATHEXT`, executable lookup cannot probe extensions.
|
||||||
|
|
||||||
7a. The `pathAwareExecHandler` MUST resolve commands using the sanitized environment's PATH (not `os.Getenv`). The `lookPath` implementation MUST:
|
7a. The `pathAwareExecHandler` MUST resolve commands using the sanitized environment's PATH (not `os.Getenv`). The `lookPath` implementation MUST:
|
||||||
|
|
||||||
- Detect path-containing commands via `filepath.Base` (handles both `/` and `\`), not `strings.Contains(cmd, "/")`.
|
- Detect path-containing commands via `filepath.Base` (handles both `/` and `\`), not `strings.Contains(cmd, "/")`.
|
||||||
- On Windows: probe PATHEXT extensions (`.com`, `.exe`, `.bat`, `.cmd` by default) from the sanitized environment for each PATH directory. Accept any non-directory file (the executable bit is meaningless on Windows).
|
- On Windows: probe PATHEXT extensions (`.com`, `.exe`, `.bat`, `.cmd` by default) from the sanitized environment for each PATH directory. Accept any non-directory file (the executable bit is meaningless on Windows).
|
||||||
- On Unix: require the executable permission bit (`mode & 0o111 != 0`).
|
- On Unix: require the executable permission bit (`mode & 0o111 != 0`).
|
||||||
|
|
@ -140,7 +141,7 @@
|
||||||
|
|
||||||
12. The cron tool (`pkg/tools/cron.go`) MUST use the same `ExecTool` with the same guard system.
|
12. The cron tool (`pkg/tools/cron.go`) MUST use the same `ExecTool` with the same guard system.
|
||||||
|
|
||||||
13. The `ExecTool` MUST implement the `AsyncExecutor` interface (`ExecuteAsync(ctx, args, cb)`). When the LLM passes `background=true` and a `bus.MessageBus` was injected at construction, the command MUST be launched in a goroutine; the result is delivered via `bus.PublishInbound` with `Channel: "system"`, `SenderID: "exec:<cmd>"`, `ChatID: "<channel>:<chatID>"`. When `background=true` but no bus is available (e.g. cron-created instances), execution falls through to synchronous mode. Compile-time interface check: `var _ AsyncExecutor = (*ExecTool)(nil)`.
|
13. The `ExecTool` MUST implement the `AsyncExecutor` interface (`ExecuteAsync(ctx, args, cb)`). When the LLM passes `background=true` and a non-nil `AsyncCallback` is provided by the tool registry, the command MUST be launched in a goroutine; the result is delivered via the callback as a completed (non-async) `ToolResult` with `ForLLM`, `ForUser`, and `IsError` populated. When `background=true` but no callback is available (e.g. cron-created instances), execution falls through to synchronous mode. Compile-time interface check: `var _ AsyncExecutor = (*ExecTool)(nil)`.
|
||||||
|
|
||||||
14. The implementation MUST use a subpackage structure: `pkg/tools/shell/` contains the core logic (risk classifier, env sanitizer, sandbox, runner) and `pkg/tools/shell_tool.go` is the thin adapter implementing the `Tool` + `AsyncExecutor` interfaces. Tests live alongside their source in both locations.
|
14. The implementation MUST use a subpackage structure: `pkg/tools/shell/` contains the core logic (risk classifier, env sanitizer, sandbox, runner) and `pkg/tools/shell_tool.go` is the thin adapter implementing the `Tool` + `AsyncExecutor` interfaces. Tests live alongside their source in both locations.
|
||||||
|
|
||||||
|
|
@ -148,7 +149,7 @@
|
||||||
|
|
||||||
- **Config**: Old fields are silently ignored with a logged warning. No config version bump required. Add a migration note to `docs/tools_configuration.md`.
|
- **Config**: Old fields are silently ignored with a logged warning. No config version bump required. Add a migration note to `docs/tools_configuration.md`.
|
||||||
- **Behavioral**: Commands that previously passed regex checks but are genuinely dangerous (variable indirection bypasses) will now be blocked. This is intentional and constitutes the security fix.
|
- **Behavioral**: Commands that previously passed regex checks but are genuinely dangerous (variable indirection bypasses) will now be blocked. This is intentional and constitutes the security fix.
|
||||||
- **Backward compatibility**: The `Tool` interface (`Name`, `Description`, `Parameters`, `Execute`) is unchanged. `ExecTool` construction moved from `NewAgentInstance` to `registerSharedTools` (in `loop.go`) where the message bus is available for constructor injection.
|
- **Backward compatibility**: The `Tool` interface (`Name`, `Description`, `Parameters`, `Execute`) is unchanged. `ExecTool` construction moved from `NewAgentInstance` to the tool registration block in `loop.go` for consistency with other shared tools.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -204,8 +205,8 @@
|
||||||
### Immediate impacts
|
### Immediate impacts
|
||||||
|
|
||||||
| Who / what | Impact |
|
| Who / what | Impact |
|
||||||
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `pkg/tools/shell_tool.go` | Thin adapter: `ExecTool` struct implementing `Tool` + `AsyncExecutor`. Delegates to `pkg/tools/shell/` subpackage. Bus-injected for background result delivery. |
|
| `pkg/tools/shell_tool.go` | Thin adapter: `ExecTool` struct implementing `Tool` + `AsyncExecutor`. Delegates to `pkg/tools/shell/` subpackage. Background results delivered via registry `AsyncCallback`. |
|
||||||
| `pkg/tools/shell_tool_test.go` | Tests for `ExecTool` sync/async behavior and interface compliance. |
|
| `pkg/tools/shell_tool_test.go` | Tests for `ExecTool` sync/async behavior and interface compliance. |
|
||||||
| `pkg/tools/shell_process_unix.go`, `shell_process_windows.go` | Removed. Interpreter manages process lifecycle. |
|
| `pkg/tools/shell_process_unix.go`, `shell_process_windows.go` | Removed. Interpreter manages process lifecycle. |
|
||||||
| `pkg/config/config.go` (`ExecConfig`) | Three fields removed, four fields added. |
|
| `pkg/config/config.go` (`ExecConfig`) | Three fields removed, four fields added. |
|
||||||
|
|
@ -229,7 +230,7 @@
|
||||||
| `pkg/tools/shell/sandbox_test.go` | Redirect inside/outside workspace, symlink escape, safe-path exemption. |
|
| `pkg/tools/shell/sandbox_test.go` | Redirect inside/outside workspace, symlink escape, safe-path exemption. |
|
||||||
| `pkg/tools/shell/runner.go` | `Run` function: parser + interpreter + `ExecHandlers` middleware integration. |
|
| `pkg/tools/shell/runner.go` | `Run` function: parser + interpreter + `ExecHandlers` middleware integration. |
|
||||||
| `pkg/tools/shell/runner_test.go` | End-to-end runner tests: timeout, working dir, env sanitization, pipelines. |
|
| `pkg/tools/shell/runner_test.go` | End-to-end runner tests: timeout, working dir, env sanitization, pipelines. |
|
||||||
| `pkg/tools/shell_tool.go` | Adapter: `ExecTool` struct, `NewExecToolWithConfig(workDir, restrict, cfg, bus)`, `AsyncExecutor` impl, arg modifier wiring. |
|
| `pkg/tools/shell_tool.go` | Adapter: `ExecTool` struct, `NewExecToolWithConfig(workDir, restrict, cfg)`, `AsyncExecutor` impl, arg modifier wiring. |
|
||||||
| `pkg/tools/shell_tool_test.go` | `ExecTool` sync/async tests, interface compliance checks. |
|
| `pkg/tools/shell_tool_test.go` | `ExecTool` sync/async tests, interface compliance checks. |
|
||||||
| `pkg/tools/cron_exec_test.go` | AC-8: cron-originated `ExecTool` blocks dangerous commands identically to agent-created one; safe commands pass. |
|
| `pkg/tools/cron_exec_test.go` | AC-8: cron-originated `ExecTool` blocks dangerous commands identically to agent-created one; safe commands pass. |
|
||||||
|
|
||||||
|
|
@ -243,7 +244,7 @@
|
||||||
| 4 | Rewrite shell tool: parser + interpreter + middleware (`shell/runner.go` + `shell_tool.go`) | Maintainer | 5, 6 | **Done** |
|
| 4 | Rewrite shell tool: parser + interpreter + middleware (`shell/runner.go` + `shell_tool.go`) | Maintainer | 5, 6 | **Done** |
|
||||||
| 5 | Port all existing test cases + add bypass tests | Maintainer | 7 | **Done** |
|
| 5 | Port all existing test cases + add bypass tests | Maintainer | 7 | **Done** |
|
||||||
| 6 | Update `ExecConfig`, defaults, migration warning | Maintainer | 7 | **Done** |
|
| 6 | Update `ExecConfig`, defaults, migration warning | Maintainer | 7 | **Done** |
|
||||||
| 7 | Implement `AsyncExecutor` on `ExecTool` (bus-based background delivery) | Maintainer | — | **Done** |
|
| 7 | Implement `AsyncExecutor` on `ExecTool` (callback-based background delivery) | Maintainer | — | **Done** |
|
||||||
| 8 | Implement configurable `ArgModifiers` (user-defined, highest-match-wins) | Maintainer | — | **Done** |
|
| 8 | Implement configurable `ArgModifiers` (user-defined, highest-match-wins) | Maintainer | — | **Done** |
|
||||||
| 9 | Fix runner_test PATH resolution for external binaries in sandboxed interpreter | Maintainer | — | **Done** |
|
| 9 | Fix runner_test PATH resolution for external binaries in sandboxed interpreter | Maintainer | — | **Done** |
|
||||||
| 10 | Update cron tool, docs, config example | Maintainer | — | **Done** |
|
| 10 | Update cron tool, docs, config example | Maintainer | — | **Done** |
|
||||||
|
|
|
||||||
|
|
@ -138,20 +138,11 @@ parameter.
|
||||||
|
|
||||||
When the LLM passes `background: true`, the exec tool launches the command in a
|
When the LLM passes `background: true`, the exec tool launches the command in a
|
||||||
goroutine and immediately returns a confirmation to the agent. The result is
|
goroutine and immediately returns a confirmation to the agent. The result is
|
||||||
delivered asynchronously via `bus.PublishInbound` as a system-channel inbound
|
delivered asynchronously via the `AsyncCallback` provided by the tool registry.
|
||||||
message:
|
|
||||||
|
|
||||||
| Field | Value |
|
If no callback is available (e.g. cron-created instances using
|
||||||
| ---------- | -------------------------------------- |
|
`NewExecToolWithConfig`), `background: true` falls through to synchronous
|
||||||
| `Channel` | `"system"` |
|
execution.
|
||||||
| `SenderID` | `"exec:<command>"` |
|
|
||||||
| `ChatID` | `"<originChannel>:<originChatID>"` |
|
|
||||||
| `Content` | stdout/stderr output (or error string) |
|
|
||||||
|
|
||||||
This is the same delivery mechanism used by `SpawnTool` for subagent results.
|
|
||||||
|
|
||||||
If no message bus is available (e.g. cron-created instances), `background: true`
|
|
||||||
falls through to synchronous execution.
|
|
||||||
|
|
||||||
### Configuration Example
|
### Configuration Example
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,12 @@ func SandboxedOpenHandler(workspaceDir string) interp.OpenHandlerFunc {
|
||||||
absWorkspace = workspaceDir
|
absWorkspace = workspaceDir
|
||||||
}
|
}
|
||||||
// Resolve workspace symlinks for accurate escape detection.
|
// Resolve workspace symlinks for accurate escape detection.
|
||||||
absWorkspace, err = filepath.EvalSymlinks(absWorkspace)
|
if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
|
||||||
if err != nil {
|
absWorkspace = resolved
|
||||||
// Non-fatal: continue with absolute path. Realpath failures
|
|
||||||
// are caught per-file when the sandbox is actually used.
|
|
||||||
}
|
}
|
||||||
|
// Non-fatal: on EvalSymlinks failure, keep the original absolute
|
||||||
|
// path so sandbox checks still run. Per-file realpath failures
|
||||||
|
// are caught when the sandbox is actually used.
|
||||||
|
|
||||||
return func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) {
|
return func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) {
|
||||||
if isSafePath(path) {
|
if isSafePath(path) {
|
||||||
|
|
|
||||||
|
|
@ -158,15 +158,20 @@ func (t *ExecTool) ExecuteAsync(ctx context.Context, args map[string]any, cb Asy
|
||||||
go func() {
|
go func() {
|
||||||
result := shell.Run(ctx, cfg)
|
result := shell.Run(ctx, cfg)
|
||||||
|
|
||||||
content := fmt.Sprintf("Background command completed: `%s`\n\n%s",
|
var content string
|
||||||
utils.Truncate(cfg.Command, 100), result.Output)
|
|
||||||
if result.IsError {
|
if result.IsError {
|
||||||
content = fmt.Sprintf("Background command failed: `%s`\n\n%s",
|
content = fmt.Sprintf("Background command failed: `%s`\n\n%s",
|
||||||
utils.Truncate(cfg.Command, 100), result.Output)
|
utils.Truncate(cfg.Command, 100), result.Output)
|
||||||
|
} else {
|
||||||
|
content = fmt.Sprintf("Background command completed: `%s`\n\n%s",
|
||||||
|
utils.Truncate(cfg.Command, 100), result.Output)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cb != nil {
|
if cb != nil {
|
||||||
cb(context.Background(), AsyncResult(content))
|
cb(context.Background(), &ToolResult{
|
||||||
|
ForLLM: content,
|
||||||
|
IsError: result.IsError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return AsyncResult(fmt.Sprintf("Running `%s` in background", utils.Truncate(cfg.Command, 100)))
|
return AsyncResult(fmt.Sprintf("Running `%s` in background", utils.Truncate(cfg.Command, 100)))
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,20 @@ func TestExecTool_BackgroundWithCallback(t *testing.T) {
|
||||||
if cbResult == nil {
|
if cbResult == nil {
|
||||||
t.Fatal("callback was not invoked")
|
t.Fatal("callback was not invoked")
|
||||||
}
|
}
|
||||||
|
if cbResult.Async {
|
||||||
|
t.Error("callback result should not be async (it is a completion)")
|
||||||
|
}
|
||||||
|
if cbResult.IsError {
|
||||||
|
t.Errorf("callback result should not be an error: %s", cbResult.ForLLM)
|
||||||
|
}
|
||||||
if !strings.Contains(cbResult.ForLLM, "bg_output") {
|
if !strings.Contains(cbResult.ForLLM, "bg_output") {
|
||||||
t.Errorf("expected output in callback result: %s", cbResult.ForLLM)
|
t.Errorf("expected output in callback ForLLM: %s", cbResult.ForLLM)
|
||||||
}
|
}
|
||||||
if !strings.Contains(cbResult.ForLLM, "completed") {
|
if !strings.Contains(cbResult.ForLLM, "completed") {
|
||||||
t.Errorf("expected 'completed' in callback result: %s", cbResult.ForLLM)
|
t.Errorf("expected 'completed' in callback ForLLM: %s", cbResult.ForLLM)
|
||||||
|
}
|
||||||
|
if cbResult.ForUser == "" {
|
||||||
|
t.Error("callback ForUser should be populated for user notification")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,8 +124,17 @@ func TestExecTool_BackgroundBlockedCommand(t *testing.T) {
|
||||||
if cbResult == nil {
|
if cbResult == nil {
|
||||||
t.Fatal("callback was not invoked")
|
t.Fatal("callback was not invoked")
|
||||||
}
|
}
|
||||||
|
if cbResult.Async {
|
||||||
|
t.Error("callback result should not be async (it is a completion)")
|
||||||
|
}
|
||||||
|
if !cbResult.IsError {
|
||||||
|
t.Error("callback result should be an error for blocked commands")
|
||||||
|
}
|
||||||
if !strings.Contains(cbResult.ForLLM, "failed") {
|
if !strings.Contains(cbResult.ForLLM, "failed") {
|
||||||
t.Errorf("expected 'failed' in callback result: %s", cbResult.ForLLM)
|
t.Errorf("expected 'failed' in callback ForLLM: %s", cbResult.ForLLM)
|
||||||
|
}
|
||||||
|
if cbResult.ForUser == "" {
|
||||||
|
t.Error("callback ForUser should be populated for user notification")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue