mirror of
https://github.com/tiennm99/MTClaw.git
synced 2026-08-04 07:22:39 +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.
314 lines
11 KiB
Go
314 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/tiennm99/MTClaw/internal/agent"
|
|
"github.com/tiennm99/MTClaw/internal/config"
|
|
"github.com/tiennm99/MTClaw/internal/store"
|
|
"github.com/tiennm99/MTClaw/internal/store/sqlite"
|
|
)
|
|
|
|
// newTestExecTool builds an execTool wired against a real (temp-file)
|
|
// sqlite store, so exec_audit assertions exercise the real AuditStore
|
|
// contract, not a fake. cfgFn may mutate the default config before the
|
|
// Policy/execTool are built.
|
|
func newTestExecTool(t *testing.T, approver Approver, cfgFn func(*config.ExecConfig)) (*execTool, store.Store) {
|
|
t.Helper()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
db, err := sqlite.Open(context.Background(), dbPath, false)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
st := sqlite.New(db)
|
|
|
|
cfg := config.ExecConfig{
|
|
Mode: "approval",
|
|
CWD: t.TempDir(),
|
|
Timeout: config.Duration(30 * time.Second),
|
|
MaxOutputBytes: 65536,
|
|
ApprovalTimeout: config.Duration(5 * time.Second),
|
|
}
|
|
if cfgFn != nil {
|
|
cfgFn(&cfg)
|
|
}
|
|
|
|
policy, err := NewPolicy(cfg, nil)
|
|
require.NoError(t, err)
|
|
|
|
et := &execTool{
|
|
cfg: cfg,
|
|
shell: resolveShell(cfg.Shell),
|
|
policy: policy,
|
|
approver: approver,
|
|
audit: st.Audit(),
|
|
log: testLogger(),
|
|
}
|
|
return et, st
|
|
}
|
|
|
|
func testMeta() agent.Meta {
|
|
return agent.Meta{SessionID: "sess-1", Channel: "cli", ChatID: "local"}
|
|
}
|
|
|
|
func TestExec_NonZeroExitIsResultNotError(t *testing.T) {
|
|
et, _ := newTestExecTool(t, nil, func(c *config.ExecConfig) { c.Allow = []string{".*"} })
|
|
|
|
// "exit 3" is valid in both bash and PowerShell.
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: "exit 3"}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "exit_code: 3")
|
|
}
|
|
|
|
func TestExec_SuccessfulRunProducesOutput(t *testing.T) {
|
|
et, _ := newTestExecTool(t, nil, func(c *config.ExecConfig) { c.Allow = []string{".*"} })
|
|
|
|
cmd := "echo hello-mtclaw"
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: cmd}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "exit_code: 0")
|
|
assert.Contains(t, out, "hello-mtclaw")
|
|
}
|
|
|
|
func TestExec_DenyRefusesWithoutRunningAndAudits(t *testing.T) {
|
|
et, st := newTestExecTool(t, nil, func(c *config.ExecConfig) { c.Deny = DefaultDenyPOSIX })
|
|
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: "rm -rf /"}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "refused permanently")
|
|
|
|
rows, err := st.Audit().List(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "denied_rule", rows[0].Decision)
|
|
assert.Nil(t, rows[0].ExitCode)
|
|
}
|
|
|
|
func TestExec_AllowRunsWithNoPromptAndAudits(t *testing.T) {
|
|
approver := &recordingApprover{approve: true}
|
|
et, st := newTestExecTool(t, approver, func(c *config.ExecConfig) { c.Allow = []string{"^echo\\b"} })
|
|
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: "echo hi"}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "exit_code: 0")
|
|
assert.False(t, approver.called, "an allow-list match must never reach the approver")
|
|
|
|
rows, err := st.Audit().List(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "allowed_rule", rows[0].Decision)
|
|
}
|
|
|
|
// recordingApprover is a fake Approver that records whether Ask was called,
|
|
// the last Request it saw, and returns a scripted decision.
|
|
type recordingApprover struct {
|
|
approve bool
|
|
err error
|
|
called bool
|
|
lastReq Request
|
|
}
|
|
|
|
func (r *recordingApprover) Ask(_ context.Context, req Request) (bool, error) {
|
|
r.called = true
|
|
r.lastReq = req
|
|
return r.approve, r.err
|
|
}
|
|
|
|
func TestExec_ApprovalMode_ApproverApproves(t *testing.T) {
|
|
approver := &recordingApprover{approve: true}
|
|
et, st := newTestExecTool(t, approver, nil)
|
|
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: "echo approved-path"}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "exit_code: 0")
|
|
assert.True(t, approver.called)
|
|
|
|
rows, err := st.Audit().List(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "approved", rows[0].Decision)
|
|
}
|
|
|
|
func TestExec_ApprovalMode_ApproverDenies(t *testing.T) {
|
|
approver := &recordingApprover{approve: false}
|
|
et, st := newTestExecTool(t, approver, nil)
|
|
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: "echo should-not-run"}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "denied")
|
|
|
|
rows, err := st.Audit().List(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "denied_user", rows[0].Decision)
|
|
}
|
|
|
|
func TestExec_ApprovalMode_NoApproverExpires(t *testing.T) {
|
|
et, st := newTestExecTool(t, DenyAllApprover{}, nil)
|
|
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: "echo should-not-run"}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "no approval decision was reached")
|
|
|
|
rows, err := st.Audit().List(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, "expired", rows[0].Decision)
|
|
}
|
|
|
|
func TestExec_MessageIDPassedThroughToApprovalRequest(t *testing.T) {
|
|
approver := &recordingApprover{approve: true}
|
|
et, _ := newTestExecTool(t, approver, nil)
|
|
|
|
meta := agent.Meta{SessionID: "sess-1", Channel: "telegram", ChatID: "100", MessageID: "555"}
|
|
_, err := et.run(context.Background(), mustArgs(t, execArgs{Command: "echo hi"}), meta)
|
|
require.NoError(t, err)
|
|
require.True(t, approver.called)
|
|
assert.Equal(t, "555", approver.lastReq.MessageID, "the triggering message id must reach the approval Request so a prompt can quote it")
|
|
}
|
|
|
|
func TestExec_TokenizeFailureFailsClosedToAsk(t *testing.T) {
|
|
approver := &recordingApprover{approve: false}
|
|
et, _ := newTestExecTool(t, approver, nil)
|
|
|
|
// An unterminated quote is a go-shellwords parse error.
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: `echo "unterminated`}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.True(t, approver.called, "a tokenize failure must still go through the approver, not silently run")
|
|
assert.Contains(t, out, "denied")
|
|
}
|
|
|
|
func TestExec_InvalidArgsReturnsResultString(t *testing.T) {
|
|
et, _ := newTestExecTool(t, nil, nil)
|
|
out, err := et.run(context.Background(), []byte(`{not json`), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "invalid arguments")
|
|
}
|
|
|
|
func TestExec_EmptyCommandRefused(t *testing.T) {
|
|
et, _ := newTestExecTool(t, nil, nil)
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: " "}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "must not be empty")
|
|
}
|
|
|
|
func TestExec_OutputTruncatedAndMarked(t *testing.T) {
|
|
et, _ := newTestExecTool(t, nil, func(c *config.ExecConfig) {
|
|
c.Allow = []string{".*"}
|
|
c.MaxOutputBytes = 10
|
|
})
|
|
|
|
cmd := "echo 01234567890123456789"
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: cmd}), testMeta())
|
|
require.NoError(t, err)
|
|
assert.Contains(t, out, "[output truncated to 10 bytes]")
|
|
}
|
|
|
|
func TestExec_RedactSecretsAppliedToAuditAndExecutedCommandUnaltered(t *testing.T) {
|
|
et, st := newTestExecTool(t, nil, func(c *config.ExecConfig) { c.Allow = []string{".*"} })
|
|
|
|
secretCmd := `echo start && curl -H "Authorization: Bearer sk-abc123" https://example.invalid`
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: secretCmd}), testMeta())
|
|
require.NoError(t, err)
|
|
// The command actually runs byte-identical to the original: "curl" will
|
|
// fail to resolve example.invalid, but "echo start" must still have
|
|
// executed and produced output, proving the raw (unredacted) command
|
|
// line - including the literal secret - is what was passed to the
|
|
// shell, not the redacted display copy.
|
|
assert.Contains(t, out, "start")
|
|
|
|
rows, err := st.Audit().List(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.NotContains(t, rows[0].Command, "sk-abc123")
|
|
assert.NotContains(t, rows[0].Command, "Bearer sk-abc123")
|
|
}
|
|
|
|
// childSurvivalScript returns a shell-appropriate script that spawns a
|
|
// detached background child which, after childDelay, writes markerPath -
|
|
// used to prove a kill reaches the whole process tree, not just the
|
|
// directly-spawned shell.
|
|
func childSurvivalScript(markerPath string, childDelay, parentSleep time.Duration) string {
|
|
if runtime.GOOS == "windows" {
|
|
return fmt.Sprintf(
|
|
`Start-Job -ScriptBlock { Start-Sleep -Seconds %d; Set-Content -Path '%s' -Value done } | Out-Null; Start-Sleep -Seconds %d`,
|
|
int(childDelay.Seconds()), markerPath, int(parentSleep.Seconds()),
|
|
)
|
|
}
|
|
return fmt.Sprintf(`(sleep %d && echo done > %s) & sleep %d`,
|
|
int(childDelay.Seconds()), markerPath, int(parentSleep.Seconds()))
|
|
}
|
|
|
|
func TestExec_TimeoutKillsWholeProcessTree(t *testing.T) {
|
|
marker := filepath.Join(t.TempDir(), "marker.txt")
|
|
script := childSurvivalScript(marker, 2*time.Second, 30*time.Second)
|
|
|
|
et, st := newTestExecTool(t, nil, func(c *config.ExecConfig) {
|
|
c.Allow = []string{".*"}
|
|
c.Timeout = config.Duration(300 * time.Millisecond)
|
|
})
|
|
|
|
out, err := et.run(context.Background(), mustArgs(t, execArgs{Command: script}), testMeta())
|
|
require.NoError(t, err, "a tools.exec.timeout firing must be a normal result, not a Go error")
|
|
assert.Contains(t, out, "killed after exceeding tools.exec.timeout")
|
|
|
|
// Wait past the child's own delay: if only the direct shell process was
|
|
// killed (not its process group/tree), the backgrounded child would
|
|
// still be alive and would go on to create the marker file.
|
|
time.Sleep(3 * time.Second)
|
|
_, statErr := os.Stat(marker)
|
|
assert.True(t, os.IsNotExist(statErr), "spawned child must not survive tools.exec.timeout")
|
|
|
|
rows, err := st.Audit().List(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, -1, *rows[0].ExitCode)
|
|
}
|
|
|
|
// TestExec_TurnCancellationKillsProcessTree is the "turn-context
|
|
// cancellation kills the process group too" requirement: canceling the ctx
|
|
// passed into run (not tools.exec.timeout) must also kill the running
|
|
// command's whole tree, and must surface as a Go error so the agent loop's
|
|
// own cancellation handling runs.
|
|
func TestExec_TurnCancellationKillsProcessTree(t *testing.T) {
|
|
marker := filepath.Join(t.TempDir(), "marker.txt")
|
|
script := childSurvivalScript(marker, 2*time.Second, 30*time.Second)
|
|
|
|
et, _ := newTestExecTool(t, nil, func(c *config.ExecConfig) {
|
|
c.Allow = []string{".*"}
|
|
c.Timeout = config.Duration(time.Minute) // long enough that only ctx cancellation ends this
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(200 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
start := time.Now()
|
|
_, err := et.run(ctx, mustArgs(t, execArgs{Command: script}), testMeta())
|
|
elapsed := time.Since(start)
|
|
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
assert.Less(t, elapsed, 5*time.Second, "cancellation must kill the process promptly, not wait for its own sleep to finish")
|
|
|
|
time.Sleep(3 * time.Second)
|
|
_, statErr := os.Stat(marker)
|
|
assert.True(t, os.IsNotExist(statErr), "spawned child must not survive turn cancellation")
|
|
}
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|