mirror of
https://github.com/tiennm99/MTClaw.git
synced 2026-08-04 05:22:48 +00:00
YAML config with env-indirected secrets, SQLite persistence, OpenAI provider, think/act/observe agent loop, fs/web_fetch/exec tools behind deny->allow->mode policy with approval flows, Telegram long-polling channel with gating and inline approvals, gateway with per-session serialization + instance lock + graceful shutdown, cron scheduler.
296 lines
11 KiB
Go
296 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
shellwords "github.com/mattn/go-shellwords"
|
|
|
|
"github.com/tiennm99/MTClaw/internal/agent"
|
|
"github.com/tiennm99/MTClaw/internal/config"
|
|
"github.com/tiennm99/MTClaw/internal/provider"
|
|
"github.com/tiennm99/MTClaw/internal/store"
|
|
)
|
|
|
|
// execWaitDelay bounds how long cmd.Wait() will wait for the process to
|
|
// actually exit after Cancel (killProcessTree) has been invoked, before
|
|
// giving up and returning anyway. It exists so a process that ignores
|
|
// SIGKILL's effect on its pipes (rare, but not impossible) cannot hang the
|
|
// tool call forever.
|
|
const execWaitDelay = 3 * time.Second
|
|
|
|
func execToolSpec() provider.ToolSpec {
|
|
return provider.ToolSpec{
|
|
Name: "exec",
|
|
Description: "Run a shell command under the configured policy: deny-listed commands are refused permanently, allow-listed commands run immediately, and everything else is gated by the configured mode (approval prompt, or an auto-mode risk classifier). A non-zero exit code is a normal result, not a failure.",
|
|
Schema: objectSchema(map[string]any{"command": stringProp("the shell command line to run")}, "command"),
|
|
}
|
|
}
|
|
|
|
type execTool struct {
|
|
cfg config.ExecConfig
|
|
shell []string
|
|
policy *Policy
|
|
approver Approver
|
|
audit store.AuditStore
|
|
log *slog.Logger
|
|
}
|
|
|
|
func registerExecTool(r *Registry, cfg config.Config, st store.Store, approver Approver, log *slog.Logger) error {
|
|
execCfg := cfg.Tools.Exec
|
|
|
|
var classifier Classifier
|
|
if execCfg.Mode == "auto" {
|
|
model := execCfg.Auto.Model
|
|
if model == "" {
|
|
model = cfg.Agent.Model
|
|
}
|
|
c, err := NewLLMClassifier(cfg.OpenAI, model)
|
|
if err != nil {
|
|
return fmt.Errorf("tools: registering exec tool: %w", err)
|
|
}
|
|
classifier = c
|
|
}
|
|
|
|
policy, err := NewPolicy(execCfg, classifier)
|
|
if err != nil {
|
|
return fmt.Errorf("tools: registering exec tool: %w", err)
|
|
}
|
|
|
|
et := &execTool{
|
|
cfg: execCfg,
|
|
shell: resolveShell(execCfg.Shell),
|
|
policy: policy,
|
|
approver: approver,
|
|
audit: st.Audit(),
|
|
log: log,
|
|
}
|
|
r.Register("exec", Tool{Spec: execToolSpec(), Run: et.run})
|
|
return nil
|
|
}
|
|
|
|
// resolveShell returns configured, or the platform default when configured
|
|
// is empty: [/bin/bash -lc] on POSIX, [powershell -NoProfile -Command] on
|
|
// Windows. The command line is always passed as shell[1:] plus one final
|
|
// argument (the raw command string) - it is never tokenized into argv for
|
|
// the OS to exec directly, matching how a user would type it at that
|
|
// shell's own prompt.
|
|
func resolveShell(configured []string) []string {
|
|
if len(configured) > 0 {
|
|
return configured
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
return []string{"powershell", "-NoProfile", "-Command"}
|
|
}
|
|
return []string{"/bin/bash", "-lc"}
|
|
}
|
|
|
|
type execArgs struct {
|
|
Command string `json:"command"`
|
|
}
|
|
|
|
// run is the exec tool's entry point: tokenize (fail closed on error) ->
|
|
// Policy.Evaluate -> act on the verdict. It never returns a Go error except
|
|
// when the outer ctx itself ends mid-command or mid-approval-wait, in which
|
|
// case the agent loop's own cancellation handling must run.
|
|
func (e *execTool) run(ctx context.Context, args json.RawMessage, meta agent.Meta) (string, error) {
|
|
var a execArgs
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return fmt.Sprintf("exec: invalid arguments: %v", err), nil
|
|
}
|
|
rawCmd := strings.TrimSpace(a.Command)
|
|
if rawCmd == "" {
|
|
return "exec: command must not be empty", nil
|
|
}
|
|
displayCmd := RedactSecrets(rawCmd)
|
|
|
|
// Tokenizing is step zero of the decision pipeline, ahead of the
|
|
// deny-list: a command go-shellwords cannot even parse is treated as
|
|
// ambiguous and fails closed to VerdictAsk, exactly like a classifier
|
|
// error would.
|
|
var decision Decision
|
|
if _, tokErr := shellwords.Parse(rawCmd); tokErr != nil {
|
|
decision = Decision{
|
|
Verdict: VerdictAsk,
|
|
Audit: "approval",
|
|
Reason: fmt.Sprintf("command could not be tokenized (%v); failing closed", tokErr),
|
|
}
|
|
} else {
|
|
decision = e.policy.Evaluate(ctx, rawCmd)
|
|
}
|
|
|
|
switch decision.Verdict {
|
|
case VerdictRefuse:
|
|
e.writeAudit(ctx, meta.SessionID, displayCmd, decision.Audit, decision.Rule, nil, nil, false)
|
|
return fmt.Sprintf("exec: refused permanently by policy (deny rule: %s). This is not retryable by rephrasing the command; tell the user it was blocked and why.", decision.Rule), nil
|
|
case VerdictRun:
|
|
return e.execute(ctx, meta, rawCmd, displayCmd, decision.Audit, decision.Rule)
|
|
case VerdictAsk:
|
|
return e.ask(ctx, meta, rawCmd, displayCmd, decision.Reason)
|
|
default:
|
|
return "exec: internal policy error: unrecognized verdict", nil
|
|
}
|
|
}
|
|
|
|
// ask waits on the approver, then records the resulting exec_audit row
|
|
// itself (execute records its own row on the approved path, so ask never
|
|
// double-writes). The approvals table row is owned by the Approver
|
|
// implementation, not by exec: the Telegram approver must create its own row
|
|
// (the row id is its inline-button callback payload), and creating a second
|
|
// one here would double-book every prompt. Terminal and deny-all approvals
|
|
// keep no approvals row at all - exec_audit is their durable record.
|
|
func (e *execTool) ask(ctx context.Context, meta agent.Meta, rawCmd, displayCmd, reason string) (string, error) {
|
|
req := Request{
|
|
SessionID: meta.SessionID,
|
|
Channel: meta.Channel,
|
|
ChatID: meta.ChatID,
|
|
ThreadID: meta.ThreadID,
|
|
Tool: "exec",
|
|
Command: displayCmd,
|
|
Reason: reason,
|
|
MessageID: meta.MessageID,
|
|
}
|
|
approved, askErr := e.approver.Ask(ctx, req)
|
|
|
|
if errors.Is(askErr, context.Canceled) {
|
|
// The caller's own ctx ended (typically the turn being canceled),
|
|
// not the approver's timeout: propagate so the agent loop's
|
|
// cancellation path runs, same as a canceled exec run would.
|
|
e.writeAudit(ctx, meta.SessionID, displayCmd, "expired", "", nil, nil, false)
|
|
return "exec: approval wait canceled because the turn ended", ctx.Err()
|
|
}
|
|
|
|
var label, modelMsg string
|
|
switch {
|
|
case askErr != nil:
|
|
// Approver timeout (context.DeadlineExceeded) or no interactive
|
|
// approver at all (ErrNoApprover): both fail closed the same way.
|
|
label = "expired"
|
|
modelMsg = fmt.Sprintf("exec: no approval decision was reached (%v); command refused. Do not retry immediately.", askErr)
|
|
case !approved:
|
|
label = "denied_user"
|
|
modelMsg = "exec: the command was denied by the approver. Do not retry it; explain to the user that it was refused."
|
|
default:
|
|
label = "approved"
|
|
}
|
|
|
|
if label != "approved" {
|
|
e.writeAudit(ctx, meta.SessionID, displayCmd, label, "", nil, nil, false)
|
|
return modelMsg, nil
|
|
}
|
|
|
|
return e.execute(ctx, meta, rawCmd, displayCmd, "approved", "")
|
|
}
|
|
|
|
// execute runs rawCmd under e.shell, bounded by e.cfg.Timeout as an
|
|
// additional bound derived from ctx (not a replacement for it), and kills
|
|
// the whole process tree on either bound firing. A non-zero exit code is a
|
|
// normal result; only the caller's own ctx ending mid-run is surfaced as a
|
|
// Go error, so the agent loop's cancellation handling can flush and abort
|
|
// the turn instead of the exec tool silently swallowing it.
|
|
func (e *execTool) execute(ctx context.Context, meta agent.Meta, rawCmd, displayCmd, decision, rule string) (string, error) {
|
|
runCtx, cancel := context.WithTimeout(ctx, e.cfg.Timeout.Std())
|
|
defer cancel()
|
|
|
|
fullArgs := append(append([]string{}, e.shell[1:]...), rawCmd)
|
|
cmd := exec.CommandContext(runCtx, e.shell[0], fullArgs...)
|
|
cmd.Dir = e.cfg.CWD
|
|
cmd.WaitDelay = execWaitDelay
|
|
setProcessGroup(cmd)
|
|
cmd.Cancel = func() error {
|
|
killProcessTree(cmd)
|
|
return nil
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &out
|
|
|
|
start := time.Now()
|
|
runErr := cmd.Run()
|
|
durationMS := time.Since(start).Milliseconds()
|
|
|
|
output := out.Bytes()
|
|
truncated := len(output) > e.cfg.MaxOutputBytes
|
|
if truncated {
|
|
output = output[:e.cfg.MaxOutputBytes]
|
|
}
|
|
|
|
var exitErr *exec.ExitError
|
|
switch {
|
|
case runErr == nil:
|
|
return e.finishExecute(ctx, meta, decision, rule, displayCmd, 0, durationMS, truncated, output, "")
|
|
|
|
case ctx.Err() != nil:
|
|
// The caller's own context ended (turn canceled, or its own
|
|
// deadline elapsed) - checked before the exitErr case below because
|
|
// a process killed by our own SIGKILL/taskkill still surfaces as a
|
|
// normal-looking *exec.ExitError with a non-zero code, which would
|
|
// otherwise be indistinguishable from a genuine non-zero exit.
|
|
// Audit best-effort and propagate so the agent loop's cancellation
|
|
// handling runs.
|
|
e.writeAudit(context.WithoutCancel(ctx), meta.SessionID, displayCmd, decision, rule, nil, &durationMS, truncated)
|
|
return "exec: command canceled because the turn ended", ctx.Err()
|
|
|
|
case errors.Is(runCtx.Err(), context.DeadlineExceeded):
|
|
// Only this call's own tools.exec.timeout fired; the turn itself
|
|
// (ctx) is still alive, so this is a normal result, not an error.
|
|
return e.finishExecute(ctx, meta, decision, rule, displayCmd, -1, durationMS, truncated, output, "killed after exceeding tools.exec.timeout")
|
|
|
|
case errors.As(runErr, &exitErr):
|
|
return e.finishExecute(ctx, meta, decision, rule, displayCmd, exitErr.ExitCode(), durationMS, truncated, output, "")
|
|
|
|
default:
|
|
e.writeAudit(ctx, meta.SessionID, displayCmd, decision, rule, nil, &durationMS, truncated)
|
|
return fmt.Sprintf("exec: command failed to run: %v", runErr), nil
|
|
}
|
|
}
|
|
|
|
func (e *execTool) finishExecute(ctx context.Context, meta agent.Meta, decision, rule, displayCmd string, exitCode int, durationMS int64, truncated bool, output []byte, statusNote string) (string, error) {
|
|
e.writeAudit(ctx, meta.SessionID, displayCmd, decision, rule, &exitCode, &durationMS, truncated)
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "exit_code: %d\nduration_ms: %d\n", exitCode, durationMS)
|
|
if statusNote != "" {
|
|
fmt.Fprintf(&b, "status: %s\n", statusNote)
|
|
}
|
|
if truncated {
|
|
fmt.Fprintf(&b, "[output truncated to %d bytes]\n", e.cfg.MaxOutputBytes)
|
|
}
|
|
b.WriteString("output:\n")
|
|
b.Write(output)
|
|
return b.String(), nil
|
|
}
|
|
|
|
// writeAudit appends one exec_audit row on a detached context (so a turn
|
|
// cancellation in progress does not also lose the audit write) and only
|
|
// logs, never fails the tool call, on a store error: an audit gap must
|
|
// never turn into a user-visible tool failure or a second decision.
|
|
func (e *execTool) writeAudit(ctx context.Context, sessionID, command, decision, rule string, exitCode *int, durationMS *int64, truncated bool) {
|
|
if e.audit == nil {
|
|
return
|
|
}
|
|
bg := context.WithoutCancel(ctx)
|
|
row := &store.ExecAudit{
|
|
SessionID: sessionID,
|
|
Command: command,
|
|
CWD: e.cfg.CWD,
|
|
Decision: decision,
|
|
Rule: rule,
|
|
ExitCode: exitCode,
|
|
DurationMS: durationMS,
|
|
Truncated: truncated,
|
|
}
|
|
if err := e.audit.Append(bg, row); err != nil {
|
|
e.log.Error("tools: append exec_audit row failed", "session_id", sessionID, "decision", decision, "error", err)
|
|
}
|
|
}
|