system_logs: first variant-based tool + OR match support
Adds system_logs with two variants:
- journalctl (systemd hosts): unit, since, lines, grep
- dmesg (any Linux): source, lines, grep
Both declare sudo: true — privilege escalation is handled by dispatch.
Also adds OR support in match conditions: array values mean any must
pass. Combined with AND across keys:
{"binary": ["journalctl", "systemd-cat"], "os": "linux"}
= (journalctl OR systemd-cat) AND linux
This commit is contained in:
parent
5c7df3a9fe
commit
5955562c1d
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
107
toolsrv/meta.go
107
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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue