From aefa73aa9075fcca558b722b8903e3cdb2c200e9 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 28 Aug 2026 19:20:57 -0500 Subject: [PATCH] harness: LiteLLM client, Redmine intake, REPORT writeback, gated bash tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI-compatible chat client for the LiteLLM proxy (base_url normalization, Bearer auth, retry/backoff on 429/5xx/transport, usage accounting) - stdlib http only. Intake lists issues in scope from /issues.json with the task class read from a configurable custom field. Writeback lands each turn as REPORT---.md plus REPORT-latest.md (atomic rename) with model/tier/token telemetry. The bash tool ports maki's permission semantics without tree-sitter: segment-by-segment compound-command checks, deny beats allow, word-boundary "cmd *" matching, $()/backtick/subshell denied, output truncation, per-command timeout with process-group cleanup. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- internal/intake/demo.go | 19 +++ internal/intake/intake_test.go | 91 +++++++++++ internal/intake/redmine.go | 111 +++++++++++++ internal/llm/client.go | 183 +++++++++++++++++++++ internal/llm/client_test.go | 104 ++++++++++++ internal/task/task.go | 12 ++ internal/tools/bash.go | 263 ++++++++++++++++++++++++++++++ internal/tools/bash_test.go | 241 +++++++++++++++++++++++++++ internal/tools/process_other.go | 9 + internal/tools/process_unix.go | 24 +++ internal/writeback/report.go | 95 +++++++++++ internal/writeback/report_test.go | 78 +++++++++ 12 files changed, 1230 insertions(+) create mode 100644 internal/intake/demo.go create mode 100644 internal/intake/intake_test.go create mode 100644 internal/intake/redmine.go create mode 100644 internal/llm/client.go create mode 100644 internal/llm/client_test.go create mode 100644 internal/task/task.go create mode 100644 internal/tools/bash.go create mode 100644 internal/tools/bash_test.go create mode 100644 internal/tools/process_other.go create mode 100644 internal/tools/process_unix.go create mode 100644 internal/writeback/report.go create mode 100644 internal/writeback/report_test.go diff --git a/internal/intake/demo.go b/internal/intake/demo.go new file mode 100644 index 0000000..f8d2c2d --- /dev/null +++ b/internal/intake/demo.go @@ -0,0 +1,19 @@ +package intake + +import ( + "git.knownelement.com/reachableceo/MOPAC/harness/internal/config" + "git.knownelement.com/reachableceo/MOPAC/harness/internal/task" +) + +// DemoTask builds the MVP demo issue from the [demo] config section: the +// smoke path that exercises the whole loop (intake -> turn -> REPORT) without +// needing a reachable Redmine. +func DemoTask(d config.DemoConfig) task.Task { + return task.Task{ + ID: d.ID, + Subject: d.Subject, + Prompt: d.Prompt, + Class: d.Class, + Source: "demo", + } +} diff --git a/internal/intake/intake_test.go b/internal/intake/intake_test.go new file mode 100644 index 0000000..262cc6a --- /dev/null +++ b/internal/intake/intake_test.go @@ -0,0 +1,91 @@ +package intake + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "git.knownelement.com/reachableceo/MOPAC/harness/internal/config" +) + +func TestListTasks(t *testing.T) { + var gotPath string + var gotQuery url.Values + var gotKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + gotKey = r.Header.Get("X-Redmine-API-Key") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "issues": [ + { + "id": 421, + "subject": "Study crush notes", + "description": "Read the notes and summarize.", + "custom_fields": [{"name": "Class", "value": "study"}] + }, + { + "id": 422, + "subject": "No class field", + "description": "" + } + ] + }`)) + })) + defer srv.Close() + + cfg := config.RedmineConfig{ + URL: srv.URL, + ScopeQuery: "project=mopac&status_id=released", + ClassField: "Class", + DefaultClass: "primary", + Limit: 25, + } + tasks, err := NewRedmineClient(cfg, "rm-key").ListTasks(context.Background()) + if err != nil { + t.Fatalf("ListTasks: %v", err) + } + if gotPath != "/issues.json" { + t.Errorf("path = %s", gotPath) + } + if gotKey != "rm-key" { + t.Errorf("API key header = %q", gotKey) + } + if gotQuery.Get("project") != "mopac" || gotQuery.Get("limit") != "25" { + t.Errorf("query = %v", gotQuery) + } + if len(tasks) != 2 { + t.Fatalf("tasks = %d, want 2", len(tasks)) + } + if tasks[0].ID != "421" || tasks[0].Class != "study" || tasks[0].Source != "redmine" { + t.Errorf("task0 = %+v", tasks[0]) + } + // Missing class falls back to default; empty description falls back to subject. + if tasks[1].Class != "primary" || tasks[1].Prompt != "No class field" { + t.Errorf("task1 = %+v", tasks[1]) + } +} + +func TestListTasksHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + cfg := config.RedmineConfig{URL: srv.URL, ScopeQuery: "project=x", ClassField: "Class", DefaultClass: "primary"} + if _, err := NewRedmineClient(cfg, "k").ListTasks(context.Background()); err == nil { + t.Fatal("expected HTTP error") + } +} + +func TestDemoTask(t *testing.T) { + d := config.DemoConfig{ + ID: "demo-1", Subject: "MVP demo", Prompt: "tell me about yourself", Class: "primary", + } + tk := DemoTask(d) + if tk.Source != "demo" || tk.Prompt != "tell me about yourself" || tk.Class != "primary" { + t.Errorf("DemoTask = %+v", tk) + } +} diff --git a/internal/intake/redmine.go b/internal/intake/redmine.go new file mode 100644 index 0000000..de0ccae --- /dev/null +++ b/internal/intake/redmine.go @@ -0,0 +1,111 @@ +// Package intake turns released scope into TASK records: Redmine issues in +// scope today; local inbox files later. +package intake + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "git.knownelement.com/reachableceo/MOPAC/harness/internal/config" + "git.knownelement.com/reachableceo/MOPAC/harness/internal/task" +) + +// RedmineClient lists issues in the configured scope via /issues.json. +type RedmineClient struct { + cfg config.RedmineConfig + key string + http *http.Client + limit int +} + +// NewRedmineClient builds a client from config and a resolved API key. +func NewRedmineClient(cfg config.RedmineConfig, key string) *RedmineClient { + return &RedmineClient{ + cfg: cfg, + key: key, + http: &http.Client{Timeout: 30 * time.Second}, + limit: cfg.Limit, + } +} + +// ListTasks returns the issues in scope, in the order Redmine returns them. +// The task class comes from the configured custom field, falling back to the +// configured default class. +func (c *RedmineClient) ListTasks(ctx context.Context) ([]task.Task, error) { + q := c.cfg.ScopeQuery + if q == "" && c.cfg.ScopeQueryID > 0 { + q = "query_id=" + strconv.Itoa(c.cfg.ScopeQueryID) + } + if !strings.Contains(q, "limit=") && c.limit > 0 { + q += "&limit=" + strconv.Itoa(c.limit) + } + url := strings.TrimSuffix(c.cfg.URL, "/") + "/issues.json?" + q + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("X-Redmine-API-Key", c.key) + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("redmine request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, fmt.Errorf("redmine read: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("redmine HTTP %d: %s", resp.StatusCode, truncate(string(raw), 300)) + } + + var payload struct { + Issues []struct { + ID int `json:"id"` + Subject string `json:"subject"` + Description string `json:"description"` + CustomFields []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"custom_fields"` + } `json:"issues"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("redmine decode: %w", err) + } + + tasks := make([]task.Task, 0, len(payload.Issues)) + for _, is := range payload.Issues { + class := c.cfg.DefaultClass + for _, cf := range is.CustomFields { + if strings.EqualFold(cf.Name, c.cfg.ClassField) && cf.Value != "" { + class = cf.Value + } + } + prompt := is.Description + if prompt == "" { + prompt = is.Subject + } + tasks = append(tasks, task.Task{ + ID: strconv.Itoa(is.ID), + Subject: is.Subject, + Prompt: prompt, + Class: class, + Source: "redmine", + }) + } + return tasks, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..c33c817 --- /dev/null +++ b/internal/llm/client.go @@ -0,0 +1,183 @@ +// Package llm is a minimal OpenAI-compatible chat client pointed at the +// LiteLLM proxy. Non-streaming v1: requests carry the concrete model name +// resolved by the models router, with retry/backoff on 429/5xx/transport +// errors (the whole request is retried; stream-resume lands later). +package llm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function FunctionCall `json:"function"` +} + +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type Tool struct { + Type string `json:"type"` + Function ToolDefinition `json:"function"` +} + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` +} + +type ChatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +type ChatResponse struct { + Choices []Choice `json:"choices"` + Usage Usage `json:"usage"` +} + +type Choice struct { + Message Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// Client talks to one LiteLLM (or any OpenAI-compatible) endpoint. +type Client struct { + endpoint string + apiKey string + http *http.Client + maxRetries int +} + +// NewClient normalizes base_url (with or without a trailing /v1) and returns +// a client. apiKey is used only for the Authorization header; never log it. +func NewClient(baseURL, apiKey string, timeout time.Duration, maxRetries int) *Client { + endpoint := strings.TrimSuffix(baseURL, "/") + if !strings.HasSuffix(endpoint, "/v1") { + endpoint += "/v1" + } + endpoint += "/chat/completions" + return &Client{ + endpoint: endpoint, + apiKey: apiKey, + http: &http.Client{Timeout: timeout}, + maxRetries: maxRetries, + } +} + +// StatusError marks HTTP failures; Code makes 429/5xx retryable. +type StatusError struct { + Code int + Body string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("llm: HTTP %d: %s", e.Code, truncate(e.Body, 400)) +} + +// Chat performs one chat-completions round trip with retry/backoff. +func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("llm: marshal request: %w", err) + } + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff(attempt)): + } + } + resp, err := c.post(ctx, body) + if err == nil { + return resp, nil + } + lastErr = err + if !retryable(err) { + return nil, err + } + } + return nil, fmt.Errorf("llm: giving up after %d attempts: %w", c.maxRetries+1, lastErr) +} + +func (c *Client) post(ctx context.Context, body []byte) (*ChatResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + httpResp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("llm: transport: %w", err) + } + defer httpResp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(httpResp.Body, 4<<20)) + if err != nil { + return nil, fmt.Errorf("llm: read response: %w", err) + } + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, &StatusError{Code: httpResp.StatusCode, Body: string(raw)} + } + var out ChatResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("llm: decode response: %w", err) + } + if len(out.Choices) == 0 { + return nil, fmt.Errorf("llm: response has no choices: %s", truncate(string(raw), 400)) + } + return &out, nil +} + +func retryable(err error) bool { + var se *StatusError + if errors.As(err, &se) { + return se.Code == http.StatusTooManyRequests || se.Code >= 500 + } + // Transport-level failures (connection reset, unexpected EOF, ...) retry. + return true +} + +func backoff(attempt int) time.Duration { + d := 500 * time.Millisecond << (attempt - 1) + if d > 8*time.Second { + d = 8 * time.Second + } + return d +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go new file mode 100644 index 0000000..67cdd76 --- /dev/null +++ b/internal/llm/client_test.go @@ -0,0 +1,104 @@ +package llm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestEndpointNormalization(t *testing.T) { + cases := map[string]string{ + "http://h:4001": "http://h:4001/v1/chat/completions", + "http://h:4001/": "http://h:4001/v1/chat/completions", + "http://h:4001/v1": "http://h:4001/v1/chat/completions", + "http://h:4001/v1/": "http://h:4001/v1/chat/completions", + "http://h/prefix": "http://h/prefix/v1/chat/completions", + "http://h/prefix/v1/": "http://h/prefix/v1/chat/completions", + } + for in, want := range cases { + c := NewClient(in, "k", time.Second, 0) + if c.endpoint != want { + t.Errorf("NewClient(%q).endpoint = %q, want %q", in, c.endpoint, want) + } + } +} + +func TestChatSendsAuthAndModel(t *testing.T) { + var gotAuth, gotModel string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + var req ChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + gotModel = req.Model + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`)) + })) + defer srv.Close() + c := NewClient(srv.URL, "sk-test", time.Second, 0) + resp, err := c.Chat(context.Background(), ChatRequest{Model: "glm-5.3", Messages: []Message{{Role: "user", Content: "x"}}}) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if gotAuth != "Bearer sk-test" { + t.Errorf("auth = %q", gotAuth) + } + if gotModel != "glm-5.3" { + t.Errorf("model = %q", gotModel) + } + if resp.Choices[0].Message.Content != "hi" || resp.Usage.TotalTokens != 3 { + t.Errorf("resp = %+v", resp) + } +} + +func TestChatRetriesOn5xxThenSucceeds(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + http.Error(w, "upstream lost", http.StatusBadGateway) + return + } + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer srv.Close() + c := NewClient(srv.URL, "k", time.Second, 2) + resp, err := c.Chat(context.Background(), ChatRequest{Model: "m"}) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Choices[0].Message.Content != "ok" || calls.Load() != 2 { + t.Errorf("resp=%v calls=%d", resp, calls.Load()) + } +} + +func TestChatNoRetryOn4xx(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, `{"error":"bad model"}`, http.StatusBadRequest) + })) + defer srv.Close() + c := NewClient(srv.URL, "k", time.Second, 3) + _, err := c.Chat(context.Background(), ChatRequest{Model: "m"}) + if err == nil { + t.Fatal("expected error") + } + if calls.Load() != 1 { + t.Errorf("4xx must not retry, calls=%d", calls.Load()) + } +} + +func TestChatEmptyChoicesIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"choices":[]}`)) + })) + defer srv.Close() + c := NewClient(srv.URL, "k", time.Second, 0) + if _, err := c.Chat(context.Background(), ChatRequest{Model: "m"}); err == nil { + t.Fatal("expected empty-choices error") + } +} diff --git a/internal/task/task.go b/internal/task/task.go new file mode 100644 index 0000000..41393ec --- /dev/null +++ b/internal/task/task.go @@ -0,0 +1,12 @@ +// Package task defines the TASK record that flows through the conductor: +// intake produces them, the loop executes them, writeback reports on them. +package task + +// Task is one unit of released work. +type Task struct { + ID string `json:"id"` + Subject string `json:"subject"` + Prompt string `json:"prompt"` + Class string `json:"class"` + Source string `json:"source"` // "redmine" | "demo" +} diff --git a/internal/tools/bash.go b/internal/tools/bash.go new file mode 100644 index 0000000..5847ffb --- /dev/null +++ b/internal/tools/bash.go @@ -0,0 +1,263 @@ +// Package tools implements the harness tool palette. v0 ships bash: an +// allow-listed executor whose gate ports maki's permission semantics +// (segment-by-segment command matching, deny beats allow, subshells and +// command substitution denied outright in headless mode) without the +// tree-sitter dependency; the full parse lands with the permission layer. +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "git.knownelement.com/reachableceo/MOPAC/harness/internal/llm" +) + +// ErrDenied marks gate rejections; the conductor surfaces them to the model +// as tool results and counts them, they do not fail the turn. +var ErrDenied = errors.New("bash: denied by permission gate") + +// BashOptions configures the bash tool. +type BashOptions struct { + WorkRoot string + Allow []string + Deny []string + DefaultAllow bool + Timeout time.Duration + MaxOutputBytes int +} + +// BashTool is the allow-listed bash executor. +type BashTool struct { + opts BashOptions +} + +// NewBashTool validates options and prepares the work root. +func NewBashTool(opts BashOptions) (*BashTool, error) { + if opts.WorkRoot == "" { + opts.WorkRoot = "." + } + abs, err := filepath.Abs(opts.WorkRoot) + if err != nil { + return nil, fmt.Errorf("bash tool: %w", err) + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return nil, fmt.Errorf("bash tool: %w", err) + } + opts.WorkRoot = abs + if opts.Timeout <= 0 { + opts.Timeout = 60 * time.Second + } + if opts.MaxOutputBytes <= 0 { + opts.MaxOutputBytes = 100_000 + } + return &BashTool{opts: opts}, nil +} + +// Definition returns the OpenAI function tool schema advertised to the model. +func (b *BashTool) Definition() llm.Tool { + return llm.Tool{ + Type: "function", + Function: llm.ToolDefinition{ + Name: "bash", + Description: "Run a bash command from the task work root. Compound commands (&&, ||, ;, |) are checked segment by segment against an allow-list; command substitution, backticks, and subshells are always denied. Unmatched or denied commands fail.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "The bash command to run.", + }, + }, + "required": []string{"command"}, + }, + }, + } +} + +// Check returns nil when the command passes the gate. +func (b *BashTool) Check(command string) error { + if strings.TrimSpace(command) == "" { + return fmt.Errorf("%w: empty command", ErrDenied) + } + if hasComplexConstruct(command) { + return fmt.Errorf("%w: %q uses command substitution/subshell constructs, which are not decomposable", ErrDenied, command) + } + scopes := SplitCommand(command) + if len(scopes) == 0 { + return fmt.Errorf("%w: empty command", ErrDenied) + } + for _, s := range scopes { + if p := matchAny(b.opts.Deny, s); p != "" { + return fmt.Errorf("%w: segment %q matches deny rule %q", ErrDenied, s, p) + } + } + for _, s := range scopes { + if matchAny(b.opts.Allow, s) == "" && !b.opts.DefaultAllow { + return fmt.Errorf("%w: segment %q not covered by any allow rule (default deny)", ErrDenied, s) + } + } + return nil +} + +// Run gate-checks and executes one bash tool call. On success (and on +// non-zero exit) it returns the captured output; err is non-nil for gate +// rejections (ErrDenied), timeouts, and spawn failures. +func (b *BashTool) Run(ctx context.Context, args json.RawMessage) (string, error) { + var in struct { + Command string `json:"command"` + } + if len(args) == 0 { + return "", fmt.Errorf("bash: missing arguments") + } + if err := json.Unmarshal(args, &in); err != nil { + return "", fmt.Errorf("bash: bad arguments: %w", err) + } + if err := b.Check(in.Command); err != nil { + return "", err + } + + cctx, cancel := context.WithTimeout(ctx, b.opts.Timeout) + defer cancel() + cmd := exec.CommandContext(cctx, "bash", "-c", in.Command) + cmd.Dir = b.opts.WorkRoot + setPgroup(cmd) + out, err := cmd.CombinedOutput() + killGroup(cmd) // reap any grandchildren the direct kill missed + res := b.truncate(out) + if cctx.Err() == context.DeadlineExceeded { + return res, fmt.Errorf("bash: timed out after %s", b.opts.Timeout) + } + if err != nil { + return res, fmt.Errorf("bash: %w", err) + } + return res, nil +} + +func (b *BashTool) truncate(out []byte) string { + if len(out) <= b.opts.MaxOutputBytes { + return string(out) + } + cut := b.opts.MaxOutputBytes + for cut > 0 && !utf8.RuneStart(out[cut]) { + cut-- + } + return string(out[:cut]) + fmt.Sprintf("\n...[truncated %d bytes]", len(out)-cut) +} + +// hasComplexConstruct reports constructs the text-level decomposer cannot +// safely split: command/process substitution, backticks, subshells. In +// headless mode these deny (maki: force_prompt with no channel => deny). +func hasComplexConstruct(cmd string) bool { + return strings.Contains(cmd, "$(") || + strings.Contains(cmd, "`") || + strings.Contains(cmd, "<(") || + strings.Contains(cmd, ">(") || + strings.HasPrefix(strings.TrimSpace(cmd), "(") +} + +// SplitCommand decomposes a compound command into its segment scopes by +// splitting on &&, ||, ;, | and newlines outside quotes. Redirections stay +// part of their segment (they must be explicitly allowed by text). +func SplitCommand(cmd string) []string { + var out []string + var cur strings.Builder + inSingle, inDouble := false, false + runes := []rune(cmd) + flush := func() { + if s := strings.TrimSpace(cur.String()); s != "" { + out = append(out, s) + } + cur.Reset() + } + for i := 0; i < len(runes); i++ { + r := runes[i] + switch { + case inSingle: + if r == '\'' { + inSingle = false + } + cur.WriteRune(r) + case inDouble: + if r == '\\' && i+1 < len(runes) { + cur.WriteRune(r) + i++ + cur.WriteRune(runes[i]) + continue + } + if r == '"' { + inDouble = false + } + cur.WriteRune(r) + case r == '\'': + inSingle = true + cur.WriteRune(r) + case r == '"': + inDouble = true + cur.WriteRune(r) + case (r == '&' || r == '|') && i+1 < len(runes) && runes[i+1] == r: + flush() + i++ + case r == ';' || r == '|' || r == '\n': + flush() + default: + cur.WriteRune(r) + } + } + flush() + return out +} + +// matchAny returns the first pattern in list that matches scope, or "". +func matchAny(list []string, scope string) string { + for _, p := range list { + if ScopeMatches(p, scope) { + return p + } + } + return "" +} + +// ScopeMatches ports maki's scope matcher. Order matters: universal +// wildcards, then "/**" path prefixes, then " *" word-boundary command +// prefixes, then bare "*" raw prefixes, then exact match. +func ScopeMatches(pattern, scope string) bool { + switch { + case pattern == "*" || pattern == "/**" || pattern == "/*": + return true + case strings.HasSuffix(pattern, "/**"): + return hasPathPrefix(scope, strings.TrimSuffix(pattern, "/**")) + case strings.HasSuffix(pattern, " *"): + head := strings.TrimSuffix(pattern, " *") + return scope == head || strings.HasPrefix(scope, head+" ") + case strings.HasSuffix(pattern, "*"): + return strings.HasPrefix(scope, strings.TrimSuffix(pattern, "*")) + default: + return pattern == scope + } +} + +// hasPathPrefix reports whether scope names a path inside dir. v0 is lexical +// (Clean + Abs); symlink-aware canonicalization lands with the permission +// layer, as does write-root confinement. +func hasPathPrefix(scope, dir string) bool { + absScope, err := filepath.Abs(filepath.Clean(scope)) + if err != nil { + return false + } + absDir, err := filepath.Abs(filepath.Clean(dir)) + if err != nil { + return false + } + if absScope == absDir { + return true + } + return strings.HasPrefix(absScope, absDir+string(filepath.Separator)) +} diff --git a/internal/tools/bash_test.go b/internal/tools/bash_test.go new file mode 100644 index 0000000..6f0257a --- /dev/null +++ b/internal/tools/bash_test.go @@ -0,0 +1,241 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +func TestScopeMatches(t *testing.T) { + cases := []struct { + pattern string + scope string + want bool + }{ + // "cmd *" word-boundary prefix (maki semantics). + {"pwd *", "pwd", true}, + {"pwd *", "pwd -L", true}, + {"pwd *", "pwdx", false}, + {"pwd *", "pwd secret", true}, + {"git diff *", "git diff", true}, + {"git diff *", "git diff HEAD~1", true}, + {"git diff *", "git difftool", false}, + // Universal wildcards. + {"*", "anything at all", true}, + {"**", "anything", false}, // not a universal form; exact match + // Bare "*" raw prefix. + {"pfx*", "pfxthing", true}, + {"pfx*", "pfz", false}, + // Exact. + {"go version", "go version", true}, + {"go version", "go version -m", false}, + // Path prefix. + {"/tmp/work/**", "/tmp/work", true}, + {"/tmp/work/**", "/tmp/work/sub/f", true}, + {"/tmp/work/**", "/tmp/workx/f", false}, + {"/tmp/work/**", "/tmp/other", false}, + } + for _, tc := range cases { + if got := ScopeMatches(tc.pattern, tc.scope); got != tc.want { + t.Errorf("ScopeMatches(%q, %q) = %v, want %v", tc.pattern, tc.scope, got, tc.want) + } + } +} + +func TestSplitCommand(t *testing.T) { + cases := []struct { + cmd string + want []string + }{ + {"ls -la", []string{"ls -la"}}, + {"git diff && rm -rf /", []string{"git diff", "rm -rf /"}}, + {"a || b ; c", []string{"a", "b", "c"}}, + {"cat f | grep x", []string{"cat f", "grep x"}}, + {`echo "a && b"`, []string{`echo "a && b"`}}, + {`echo 'p | q'`, []string{`echo 'p | q'`}}, + {" ", nil}, + {"echo hi > out.txt", []string{"echo hi > out.txt"}}, + } + for _, tc := range cases { + got := SplitCommand(tc.cmd) + if len(got) != len(tc.want) { + t.Errorf("SplitCommand(%q) = %v, want %v", tc.cmd, got, tc.want) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("SplitCommand(%q)[%d] = %q, want %q", tc.cmd, i, got[i], tc.want[i]) + } + } + } +} + +func newTool(t *testing.T, allow, deny []string) *BashTool { + t.Helper() + b, err := NewBashTool(BashOptions{ + WorkRoot: t.TempDir(), + Allow: allow, + Deny: deny, + Timeout: 5 * time.Second, + MaxOutputBytes: 100_000, + }) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestCheckGate(t *testing.T) { + cases := []struct { + name string + allow []string + deny []string + cmd string + ok bool + want string // substring of denial reason when !ok + }{ + {"simple allow", []string{"echo *"}, nil, "echo hi", true, ""}, + {"exact allow", []string{"pwd"}, nil, "pwd", true, ""}, + {"uncovered", []string{"echo *"}, nil, "ls -la", false, "not covered by any allow rule"}, + {"compound all allowed", []string{"git *"}, nil, "git diff && git status", true, ""}, + {"compound one bad", []string{"git *"}, nil, "git diff && rm -rf /", false, `segment "rm -rf /"`}, + {"deny beats allow", []string{"*"}, []string{"sudo *"}, "sudo id", false, `matches deny rule "sudo *"`}, + {"deny beats allow compound", []string{"*"}, []string{"rm -rf *"}, "git status && rm -rf /", false, "deny rule"}, + {"pipe splits", []string{"cat *", "grep *"}, nil, "cat f | grep x", true, ""}, + {"pipe uncovered", []string{"cat *"}, nil, "cat f | grep x", false, `segment "grep x"`}, + {"command substitution denied", []string{"*"}, nil, "echo $(rm -rf /)", false, "not decomposable"}, + {"backticks denied", []string{"*"}, nil, "echo `id`", false, "not decomposable"}, + {"subshell denied", []string{"*"}, nil, "(cd / && rm -rf /)", false, "not decomposable"}, + {"empty", []string{"*"}, nil, " ", false, "empty command"}, + {"word boundary", []string{"pwd *"}, nil, "pwdx", false, "not covered"}, + {"redirect is part of scope", []string{"go *"}, nil, "go test > /tmp/out", true, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := newTool(t, tc.allow, tc.deny) + err := b.Check(tc.cmd) + if tc.ok { + if err != nil { + t.Fatalf("Check(%q) = %v, want nil", tc.cmd, err) + } + return + } + if !errors.Is(err, ErrDenied) { + t.Fatalf("Check(%q) = %v, want ErrDenied", tc.cmd, err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("denial %q does not contain %q", err, tc.want) + } + }) + } +} + +func TestDefaultAllow(t *testing.T) { + b, err := NewBashTool(BashOptions{ + WorkRoot: t.TempDir(), + Allow: nil, + DefaultAllow: true, + }) + if err != nil { + t.Fatal(err) + } + if err := b.Check("anything --at --all"); err != nil { + t.Errorf("default allow should pass uncovered scopes: %v", err) + } +} + +func TestRun(t *testing.T) { + t.Run("allowed echo", func(t *testing.T) { + b := newTool(t, []string{"echo *"}, nil) + out, err := b.Run(context.Background(), json.RawMessage(`{"command":"echo hello mopac"}`)) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !strings.Contains(out, "hello mopac") { + t.Errorf("output = %q", out) + } + }) + t.Run("denied returns ErrDenied without executing", func(t *testing.T) { + b := newTool(t, []string{"echo *"}, []string{"sudo *"}) + out, err := b.Run(context.Background(), json.RawMessage(`{"command":"sudo rm -rf /"}`)) + if !errors.Is(err, ErrDenied) { + t.Fatalf("err = %v, want ErrDenied", err) + } + if out != "" { + t.Errorf("denied run must not produce output, got %q", out) + } + }) + t.Run("nonzero exit reports output", func(t *testing.T) { + b := newTool(t, []string{"false"}, nil) + out, err := b.Run(context.Background(), json.RawMessage(`{"command":"false"}`)) + if err == nil { + t.Fatal("expected error for exit 1") + } + if out != "" { + t.Errorf("no output expected, got %q", out) + } + }) + t.Run("timeout kills command", func(t *testing.T) { + b, err := NewBashTool(BashOptions{ + WorkRoot: t.TempDir(), + Allow: []string{"sleep *"}, + Timeout: 300 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + start := time.Now() + _, err = b.Run(context.Background(), json.RawMessage(`{"command":"sleep 30"}`)) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("err = %v, want timeout", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("timeout did not fire, took %s", elapsed) + } + }) + t.Run("output truncation", func(t *testing.T) { + b, err := NewBashTool(BashOptions{ + WorkRoot: t.TempDir(), + Allow: []string{"echo *"}, + Timeout: 5 * time.Second, + MaxOutputBytes: 100, + }) + if err != nil { + t.Fatal(err) + } + out, err := b.Run(context.Background(), json.RawMessage(`{"command":"echo 012345678901234567890123456789012345678901456789012345678901234567890123456789012345678901234567890123456789"}`)) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !strings.Contains(out, "[truncated") { + t.Errorf("expected truncation marker in %q", out) + } + if len(out) > 200 { + t.Errorf("output not truncated: %d bytes", len(out)) + } + }) + t.Run("bad json args", func(t *testing.T) { + b := newTool(t, []string{"echo *"}, nil) + if _, err := b.Run(context.Background(), json.RawMessage(`{"command": 42}`)); err == nil { + t.Fatal("expected arg error") + } + }) +} + +func TestDefinitionSchema(t *testing.T) { + b := newTool(t, nil, nil) + def := b.Definition() + if def.Function.Name != "bash" || def.Type != "function" { + t.Errorf("definition = %+v", def) + } + if def.Function.Parameters["required"] == nil { + t.Error("parameters must mark command required") + } + // Sanity: the schema must marshal (it goes straight into requests). + if _, err := json.Marshal(def); err != nil { + t.Fatal(err) + } +} diff --git a/internal/tools/process_other.go b/internal/tools/process_other.go new file mode 100644 index 0000000..5c9eea1 --- /dev/null +++ b/internal/tools/process_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package tools + +import "os/exec" + +func setPgroup(c *exec.Cmd) {} + +func killGroup(c *exec.Cmd) {} diff --git a/internal/tools/process_unix.go b/internal/tools/process_unix.go new file mode 100644 index 0000000..b4a2eeb --- /dev/null +++ b/internal/tools/process_unix.go @@ -0,0 +1,24 @@ +//go:build unix + +package tools + +import ( + "os/exec" + "syscall" +) + +// setPgroup puts the bash child in its own process group so a timeout or +// cancel kills the whole tree, not just the shell (crush/maki gotcha: +// plain Kill orphans grandchildren). +func setPgroup(c *exec.Cmd) { + c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// killGroup SIGKILLs the child's process group after Wait; no-op if the +// child never started. +func killGroup(c *exec.Cmd) { + if c.Process == nil { + return + } + _ = syscall.Kill(-c.Process.Pid, syscall.SIGKILL) +} diff --git a/internal/writeback/report.go b/internal/writeback/report.go new file mode 100644 index 0000000..56537db --- /dev/null +++ b/internal/writeback/report.go @@ -0,0 +1,95 @@ +// Package writeback persists turn results as REPORT files. The REPORT is +// both the human-facing record and the telemetry trail (model, tier, class, +// tokens, rounds) that makes routing decisions auditable. +package writeback + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "git.knownelement.com/reachableceo/MOPAC/harness/internal/task" +) + +// Report is everything that lands in a REPORT file. +type Report struct { + Time time.Time + Vertical string + Task task.Task + Class string + Tier string + Model string + PromptTokens int + CompletionTokens int + TotalTokens int + Rounds int + ToolCalls int + Denied int + Duration time.Duration + StopReason string + Content string +} + +// Write renders r to /REPORT---.md and +// refreshes /REPORT-latest.md. Both writes are atomic (tmp + rename). +func Write(dir string, r Report) (string, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("writeback: %w", err) + } + name := fmt.Sprintf("REPORT-%s-%s-%s.md", + sanitize(r.Vertical), sanitize(r.Task.ID), r.Time.Format("20060102-150405")) + body := r.Render() + path := filepath.Join(dir, name) + if err := atomicWrite(path, body); err != nil { + return "", fmt.Errorf("writeback: %w", err) + } + if err := atomicWrite(filepath.Join(dir, "REPORT-latest.md"), body); err != nil { + return "", fmt.Errorf("writeback: %w", err) + } + return path, nil +} + +// Render formats the REPORT markdown. +func (r Report) Render() string { + var b strings.Builder + fmt.Fprintf(&b, "# REPORT - %s - %s: %s\n\n", r.Vertical, r.Task.ID, r.Task.Subject) + fmt.Fprintf(&b, "- date: %s\n", r.Time.Format(time.RFC3339)) + fmt.Fprintf(&b, "- task source: %s\n", r.Task.Source) + fmt.Fprintf(&b, "- model: %s (tier %s, class %s)\n", r.Model, r.Tier, r.Class) + fmt.Fprintf(&b, "- usage: prompt=%d completion=%d total=%d tokens\n", + r.PromptTokens, r.CompletionTokens, r.TotalTokens) + fmt.Fprintf(&b, "- turn: rounds=%d tool_calls=%d denied=%d stop=%s\n", + r.Rounds, r.ToolCalls, r.Denied, r.StopReason) + fmt.Fprintf(&b, "- duration: %s\n", r.Duration.Round(time.Millisecond)) + fmt.Fprintf(&b, "\n## Result\n\n") + content := r.Content + if content == "" { + content = "(no content produced)" + } + b.WriteString(content) + b.WriteString("\n") + return b.String() +} + +func atomicWrite(path, body string) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(body), 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func sanitize(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return b.String() +} diff --git a/internal/writeback/report_test.go b/internal/writeback/report_test.go new file mode 100644 index 0000000..83859e8 --- /dev/null +++ b/internal/writeback/report_test.go @@ -0,0 +1,78 @@ +package writeback + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.knownelement.com/reachableceo/MOPAC/harness/internal/task" +) + +func TestWriteProducesReportAndLatest(t *testing.T) { + dir := t.TempDir() + r := Report{ + Time: time.Date(2026, 8, 28, 19, 41, 2, 0, time.UTC), + Vertical: "demo-stack", + Task: task.Task{ID: "demo-1", Subject: "MVP demo", Prompt: "tell me about yourself", Class: "primary", Source: "demo"}, + Class: "primary", + Tier: "mopac-primary", + Model: "glm-5.3", + PromptTokens: 12, + CompletionTokens: 340, + TotalTokens: 352, + Rounds: 1, + StopReason: "complete", + Duration: 2 * time.Second, + Content: "I am GLM, a large language model.", + } + path, err := Write(dir, r) + if err != nil { + t.Fatalf("Write: %v", err) + } + want := filepath.Join(dir, "REPORT-demo-stack-demo-1-20260828-194102.md") + if path != want { + t.Errorf("path = %s, want %s", path, want) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := string(body) + for _, want := range []string{ + "model: glm-5.3 (tier mopac-primary, class primary)", + "usage: prompt=12 completion=340 total=352 tokens", + "rounds=1", + "## Result", + "I am GLM, a large language model.", + } { + if !strings.Contains(s, want) { + t.Errorf("REPORT missing %q", want) + } + } + latest, err := os.ReadFile(filepath.Join(dir, "REPORT-latest.md")) + if err != nil || len(latest) != len(body) { + t.Errorf("REPORT-latest.md missing or stale (err=%v)", err) + } + // No .tmp litter. + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Errorf("tmp file left behind: %s", e.Name()) + } + } +} + +func TestSanitize(t *testing.T) { + if got := sanitize("a/b c"); got != "a-b-c" { + t.Errorf("sanitize = %q", got) + } +} + +func TestEmptyContentPlaceholder(t *testing.T) { + r := Report{Time: time.Now(), Vertical: "v", Task: task.Task{ID: "x"}, Content: ""} + if !strings.Contains(r.Render(), "(no content produced)") { + t.Errorf("empty content should render a placeholder") + } +}