diff --git a/data/tools/system_logs.meta b/data/tools/system_logs.meta new file mode 100644 index 0000000..6f14e77 --- /dev/null +++ b/data/tools/system_logs.meta @@ -0,0 +1,38 @@ +{ + "description": "Read system logs.", + "readOnly": true, + "tier": "cold", + "variants": [ + { + "match": {"binary": "journalctl"}, + "description": "Read system logs (journald).", + "prompt": "## system_logs\n\nRead systemd journal entries.\n\n**Args**: `lines` (default: 30), `unit`, `since`, `grep`\n\n```\nsystem_logs(unit=\"sshd\", since=\"1 hour ago\")\nsystem_logs(lines=\"50\", grep=\"error\")\n```", + "args": { + "type": "object", + "properties": { + "lines": {"type": "string", "description": "Number of lines (default: 30)"}, + "unit": {"type": "string", "description": "Systemd unit name"}, + "since": {"type": "string", "description": "Time filter (e.g. '1 hour ago', 'today')"}, + "grep": {"type": "string", "description": "Filter output by pattern"} + } + }, + "cmd": "system_logs_journald", + "sudo": true + }, + { + "match": {"binary": "dmesg"}, + "description": "Read system logs (dmesg + log files).", + "prompt": "## system_logs\n\nRead kernel ring buffer and log files.\n\n**Args**: `lines` (default: 30), `source` (dmesg or a log path), `grep`\n\n```\nsystem_logs(source=\"dmesg\", lines=\"50\")\nsystem_logs(source=\"/var/log/nginx/error.log\", grep=\"500\")\n```", + "args": { + "type": "object", + "properties": { + "lines": {"type": "string", "description": "Number of lines (default: 30)"}, + "source": {"type": "string", "description": "Log source: 'dmesg' or absolute path (default: dmesg)"}, + "grep": {"type": "string", "description": "Filter output by pattern"} + } + }, + "cmd": "system_logs_dmesg", + "sudo": true + } + ] +} diff --git a/data/tools/system_logs_dmesg b/data/tools/system_logs_dmesg new file mode 100755 index 0000000..e4b6194 --- /dev/null +++ b/data/tools/system_logs_dmesg @@ -0,0 +1,26 @@ +#!/bin/sh +# system_logs_dmesg — read kernel ring buffer or log files +set -e +input=$(cat) +lines=$(echo "$input" | jq -r '.lines // "30"') +source=$(echo "$input" | jq -r '.source // "dmesg"') +grep_pat=$(echo "$input" | jq -r '.grep // empty') + +if [ "$source" = "dmesg" ]; then + if [ -n "$grep_pat" ]; then + dmesg --time-format iso | tail -n "$lines" | grep -i "$grep_pat" + else + dmesg --time-format iso | tail -n "$lines" + fi +else + # Absolute path to a log file + if [ ! -f "$source" ]; then + echo "error: log file not found: $source" >&2 + exit 1 + fi + if [ -n "$grep_pat" ]; then + tail -n "$lines" "$source" | grep -i "$grep_pat" + else + tail -n "$lines" "$source" + fi +fi diff --git a/data/tools/system_logs_journald b/data/tools/system_logs_journald new file mode 100755 index 0000000..bf5340a --- /dev/null +++ b/data/tools/system_logs_journald @@ -0,0 +1,15 @@ +#!/bin/sh +# system_logs_journald — read systemd journal entries +set -e +input=$(cat) +lines=$(echo "$input" | jq -r '.lines // "30"') +unit=$(echo "$input" | jq -r '.unit // empty') +since=$(echo "$input" | jq -r '.since // empty') +grep_pat=$(echo "$input" | jq -r '.grep // empty') + +args="--no-pager -n $lines --output=short-iso" +[ -n "$unit" ] && args="$args -u $unit" +[ -n "$since" ] && args="$args --since=$since" +[ -n "$grep_pat" ] && args="$args --grep=$grep_pat" + +eval journalctl $args diff --git a/toolsrv/meta.go b/toolsrv/meta.go index 4c046c2..27426d6 100644 --- a/toolsrv/meta.go +++ b/toolsrv/meta.go @@ -17,6 +17,7 @@ type MetaFile struct { Tier string `json:"tier,omitempty"` ReadOnly bool `json:"readOnly,omitempty"` Cmd string `json:"cmd,omitempty"` + Sudo bool `json:"sudo,omitempty"` Variants []Variant `json:"variants,omitempty"` } @@ -24,13 +25,14 @@ type MetaFile struct { // match conditions are all satisfied determines the tool's schema, // documentation, and executable. type Variant struct { - Match map[string]string `json:"match"` - Description string `json:"description,omitempty"` - Prompt string `json:"prompt,omitempty"` - Args json.RawMessage `json:"args,omitempty"` - Tier string `json:"tier,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - Cmd string `json:"cmd,omitempty"` + Match map[string]any `json:"match"` + Description string `json:"description,omitempty"` + Prompt string `json:"prompt,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Tier string `json:"tier,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + Cmd string `json:"cmd,omitempty"` + Sudo bool `json:"sudo,omitempty"` } // LoadMetaFile reads and parses a .meta sidecar file for the given tool name. @@ -78,6 +80,9 @@ func (m *MetaFile) Resolve() *MetaFile { if v.ReadOnly { resolved.ReadOnly = true } + if v.Sudo { + resolved.Sudo = true + } if v.Cmd != "" { resolved.Cmd = v.Cmd } @@ -90,44 +95,66 @@ func (m *MetaFile) Resolve() *MetaFile { // matchesMet returns true if all conditions in the match map are satisfied. // Supported keys: binary, file, os, arch, env, nenv. -func matchesMet(m map[string]string) bool { - for key, val := range m { - switch key { - case "binary": - if filepath.IsAbs(val) { - if _, err := os.Stat(val); err != nil { - return false - } - } else { - if _, err := exec.LookPath(val); err != nil { - return false - } - } - case "file": - if _, err := os.Stat(val); err != nil { - return false - } - case "os": - if runtime.GOOS != val { - return false - } - case "arch": - if runtime.GOARCH != val { - return false - } - case "env": - if os.Getenv(val) == "" { - return false - } - case "nenv": - if os.Getenv(val) != "" { - return false - } +// Values can be a string (single check) or []string (any must pass = OR). +func matchesMet(m map[string]any) bool { + for key, raw := range m { + vals := toStrings(raw) + if !anyMatch(key, vals) { + return false } } return true } +func toStrings(v any) []string { + switch val := v.(type) { + case string: + return []string{val} + case []any: + out := make([]string, 0, len(val)) + for _, item := range val { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + +func anyMatch(key string, vals []string) bool { + for _, val := range vals { + if checkCondition(key, val) { + return true + } + } + return false +} + +func checkCondition(key, val string) bool { + switch key { + case "binary": + if filepath.IsAbs(val) { + _, err := os.Stat(val) + return err == nil + } + _, err := exec.LookPath(val) + return err == nil + case "file": + _, err := os.Stat(val) + return err == nil + case "os": + return runtime.GOOS == val + case "arch": + return runtime.GOARCH == val + case "env": + return os.Getenv(val) != "" + case "nenv": + return os.Getenv(val) == "" + } + return false +} + // ToolInfoFromMeta builds a ToolInfo from a MetaFile. // Applies variant resolution first. Returns zero ToolInfo if no variant matches. func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo { diff --git a/toolsrv/server.go b/toolsrv/server.go index f38bcd7..24f6830 100644 --- a/toolsrv/server.go +++ b/toolsrv/server.go @@ -319,12 +319,29 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra } } + // Check if tool requires sudo (from .meta). + needsSudo := false + if m, err := LoadMetaFile(tool); err == nil && m != nil { + resolved := m.Resolve() + if resolved != nil && resolved.Sudo { + needsSudo = true + elevated = true // sudo implies elevation + } + } + // The tool script receives JSON args on stdin. code := path stdinData := string(args) var result string - if elevated { + if needsSudo { + e.wdMu.RLock() + workDir := e.cwd + e.wdMu.RUnlock() + // sudo -n: non-interactive (NOPASSWD). Full credential broker TBD. + sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | sudo -n %s\n%s\nOLLIE_EOF", code, stdinData) + result, err = e.executeElevated(ctx, sudoCode, workDir, 30) + } else if elevated { e.wdMu.RLock() workDir := e.cwd e.wdMu.RUnlock()