strip execute_code to bash-only; remove language detection; run promoted tools directly

This commit is contained in:
ollie 2026-07-28 22:59:44 +02:00
parent 6bd77276ee
commit 1b05433a5b
3 changed files with 39 additions and 251 deletions

View File

@ -39,14 +39,10 @@ func resolveCodeStep(s CodeStep) (code, language string, trusted bool, err error
if terr != nil {
return "", "", false, terr
}
language = detectLanguage(toolCode)
language = "bash"
code = toolCode
if len(s.Args) > 0 {
code = injectArgs(language, s.Tool, s.Args, toolCode)
switch language {
case "awk", "sed", "jq", "ed", "expect", "bc":
language = "bash"
}
}
return code, language, true, nil
}
@ -79,33 +75,9 @@ var bashPatterns = []*regexp.Regexp{
regexp.MustCompile(`\beval\s+".*\$`),
}
// pythonPatterns apply to python3/python.
var pythonPatterns = []*regexp.Regexp{
regexp.MustCompile(`shutil\.rmtree\s*\(\s*['"/]`),
regexp.MustCompile(`(os\.system|subprocess\.(call|run|popen))\s*\(.*\brm\s+-[a-z]*r[a-z]*f`),
regexp.MustCompile(`os\.(remove|unlink)\s*\(\s*['"]/(etc|usr|bin|sbin|lib|boot)`),
}
// perlPatterns apply to perl.
var perlPatterns = []*regexp.Regexp{
regexp.MustCompile(`(system|exec)\s*\(.*\brm\s+-[a-z]*r[a-z]*f`),
regexp.MustCompile("`.+rm\\s+-[a-z]*r[a-z]*f"), // backtick execution
regexp.MustCompile(`unlink\s+glob\s*\(['"]/(etc|usr|bin|sbin|lib|boot)`),
}
// luaPatterns apply to lua.
var luaPatterns = []*regexp.Regexp{
regexp.MustCompile(`(os\.execute|io\.popen)\s*\(.*\brm\s+-[a-z]*r[a-z]*f`),
regexp.MustCompile(`os\.remove\s*\(\s*['"]/(etc|usr|bin|sbin|lib|boot)`),
}
var languagePatterns = map[string][]*regexp.Regexp{
"bash": bashPatterns,
"": bashPatterns,
"python3": pythonPatterns,
"python": pythonPatterns,
"perl": perlPatterns,
"lua": luaPatterns,
"bash": bashPatterns,
"": bashPatterns,
}
// Dispatch routes a named execute tool call.

View File

@ -148,7 +148,7 @@ Each step is one of:
- {detach: true, code} — start process and immediately background it; returns PID
- {parallel: [{code/language}...]} — concurrent fan-out; outputs concatenated in submission order
Supported languages: bash, python3, perl, lua, awk, sed, jq, ed, expect, bc.
Only bash is supported.
timeout applies to each step independently (default: 30s). A failed step aborts.
Examples:
@ -270,12 +270,16 @@ func (e *Server) SetToolRegistry(r *Registry, sessionID string) {
e.sessionID = sessionID
}
// callPromotedTool executes a tool promoted via the registry.
// callPromotedTool executes a tool promoted via the registry by running the
// script file directly as an executable.
func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
script, err := ReadTool(tool)
if err != nil {
return nil, fmt.Errorf("read tool %s: %w", tool, err)
// Resolve script path.
if strings.Contains(tool, "/") || strings.Contains(tool, "..") {
return nil, fmt.Errorf("invalid tool name")
}
path := filepath.Join(ToolsPath(), tool)
// Parse positional args.
var positional []string
var argMap map[string]interface{}
if err := json.Unmarshal(args, &argMap); err == nil {
@ -285,78 +289,36 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra
positional = append(positional, fmt.Sprintf("%v", v))
}
}
} else {
positional = orderArgsBySchema(script, argMap)
}
}
lang := DetectLanguage(script)
code := InjectArgs(lang, tool, positional, script)
result, err := e.Execute(ctx, code, lang, 30, "default", true)
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Execute the script directly — shebang handles the interpreter.
e.wdMu.RLock()
workDir := e.cwd
e.wdMu.RUnlock()
if workDir == "" {
workDir, _ = os.Getwd()
}
cmd := exec.CommandContext(ctx, path, positional...)
cmd.Dir = workDir
output, err := cmd.CombinedOutput()
if err != nil {
return json.Marshal(map[string]interface{}{
"isError": true,
"content": []map[string]string{{"type": "text", "text": err.Error()}},
"content": []map[string]string{{"type": "text", "text": string(output) + ": " + err.Error()}},
})
}
return json.Marshal(map[string]interface{}{
"content": []map[string]string{{"type": "text", "text": result}},
"content": []map[string]string{{"type": "text", "text": string(output)}},
})
}
// orderArgsBySchema extracts values from argMap in schema property order.
// Uses json.Decoder to preserve JSON key ordering.
func orderArgsBySchema(script string, argMap map[string]interface{}) []string {
schema := ExtractArgsSchema(script)
if schema == nil {
var out []string
for _, v := range argMap {
out = append(out, fmt.Sprintf("%v", v))
}
return out
}
// Parse the schema to find the "properties" key
var raw map[string]json.RawMessage
if err := json.Unmarshal(schema, &raw); err != nil {
var out []string
for _, v := range argMap {
out = append(out, fmt.Sprintf("%v", v))
}
return out
}
props, ok := raw["properties"]
if !ok || len(props) == 0 {
var out []string
for _, v := range argMap {
out = append(out, fmt.Sprintf("%v", v))
}
return out
}
// Decode properties sub-object preserving key order using json.Decoder
dec := json.NewDecoder(bytes.NewReader(props))
tok, err := dec.Token()
if err != nil || tok != json.Delim('{') {
var out []string
for _, v := range argMap {
out = append(out, fmt.Sprintf("%v", v))
}
return out
}
var out []string
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
break
}
key := fmt.Sprintf("%v", keyTok)
// Skip the value
var val json.RawMessage
dec.Decode(&val)
if v, ok := argMap[key]; ok {
out = append(out, fmt.Sprintf("%v", v))
}
}
return out
}
// Close is called when the session ends. Calls OnClose hook if registered.
func (e *Server) Close() {
@ -585,13 +547,7 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
timeout = 30
}
// Domain-specific tools (awk, sed, jq, ed, bc, expect) skip validation —
// they are constrained by design and the sandbox handles OS-level restrictions.
// General-purpose languages get universal + language-specific pattern checks.
_, isDomainSpecific := map[string]struct{}{
"awk": {}, "sed": {}, "jq": {}, "ed": {}, "bc": {}, "expect": {},
}[language]
if !trusted && !isDomainSpecific {
if !trusted {
if err := e.ValidateCode(code, language); err != nil {
return "", err
}
@ -632,30 +588,9 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
switch language {
case "bash", "":
interpreter = []string{"bash", "-c", code}
case "python3", "python":
interpreter = []string{"python3", "-c", code}
case "perl":
interpreter = []string{"perl", "-e", code}
case "lua":
interpreter = []string{"lua", "-e", code}
case "awk":
interpreter = []string{"gawk", code}
case "sed":
interpreter = []string{"sed", "-e", code}
case "jq":
interpreter = []string{"jq", code}
case "ed":
interpreter = []string{"ed", "-s"}
codeStdin = code
case "expect":
interpreter = []string{"expect", "-"}
codeStdin = code
case "bc":
interpreter = []string{"bc", "-l"}
codeStdin = code
default:
cancel()
return "", fmt.Errorf("unsupported language: %s (supported: bash, python3, perl, awk, sed, ed, jq, expect, bc, lua)", language)
return "", fmt.Errorf("unsupported language: %s (only bash is supported)", language)
}
e.envMu.RLock()
envMap := make(map[string]string, len(e.envExtra))

View File

@ -36,114 +36,15 @@ func PluginsPath() string {
return paths.CfgDir() + "/scripts/x"
}
// detectLanguage infers the script language from the shebang line.
// Returns "python3", "perl", "awk", "sed", "ed", or "bash".
func detectLanguage(code string) string {
line, _, _ := strings.Cut(code, "\n")
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "#!") {
return "bash"
}
fields := strings.Fields(strings.TrimPrefix(line, "#!"))
if len(fields) == 0 {
return "bash"
}
// When the interpreter is /usr/bin/env, the actual interpreter is the next argument.
names := fields
if filepath.Base(fields[0]) == "env" && len(fields) > 1 {
names = fields[1:]
}
switch filepath.Base(names[0]) {
case "python", "python3":
return "python3"
case "perl":
return "perl"
case "awk", "gawk":
return "awk"
case "sed", "gsed":
return "sed"
case "ed":
return "ed"
case "jq":
return "jq"
case "expect":
return "expect"
case "bc":
return "bc"
case "lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4":
return "lua"
}
return "bash"
}
// DetectLanguage is the exported version of detectLanguage.
func DetectLanguage(code string) string {
return detectLanguage(code)
}
// injectArgs prepends language-appropriate argument binding to code.
// injectArgs prepends argument binding to code.
func injectArgs(language, name string, args []string, code string) string {
switch language {
case "python3":
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = fmt.Sprintf("%q", a)
}
return fmt.Sprintf("import sys\nsys.argv = [%q, %s]\n%s", name, strings.Join(quoted, ", "), code)
case "perl":
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = "'" + strings.ReplaceAll(a, "'", "\\'") + "'"
}
return fmt.Sprintf("@ARGV = (%s);\n%s", strings.Join(quoted, ", "), code)
case "awk":
// awk args are input filenames; produce a bash snippet: gawk -e $'prog' -- file ...
fileArgs := make([]string, len(args))
for i, a := range args {
fileArgs[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("gawk -e $'%s' -- %s", ansiCEscape(code), strings.Join(fileArgs, " "))
case "sed":
// sed args are input filenames (use -i in the script for in-place editing).
fileArgs := make([]string, len(args))
for i, a := range args {
fileArgs[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("sed -e $'%s' %s", ansiCEscape(code), strings.Join(fileArgs, " "))
case "ed":
// ed reads commands from stdin; first arg is the file to edit.
file := ""
if len(args) > 0 {
file = " '" + strings.ReplaceAll(args[0], "'", "'\\''") + "'"
}
return fmt.Sprintf("printf '%%s' $'%s' | ed -s%s", ansiCEscape(code), file)
case "jq":
// jq args are JSON input files; filter is the program.
fileArgs := make([]string, len(args))
for i, a := range args {
fileArgs[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("jq $'%s' %s", ansiCEscape(code), strings.Join(fileArgs, " "))
case "expect":
// expect reads script from stdin via expect -.
return fmt.Sprintf("printf '%%s' $'%s' | expect -", ansiCEscape(code))
case "bc":
// bc reads from stdin; -ql for quiet mode + math library.
return fmt.Sprintf("printf '%%s' $'%s' | bc -ql", ansiCEscape(code))
case "lua":
// Inject args as the arg table, matching lua's scriptfile convention.
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = fmt.Sprintf("%q", a)
}
return fmt.Sprintf("arg={%s}\n%s", strings.Join(quoted, ", "), code)
default: // bash
escaped := make([]string, len(args))
for i, a := range args {
escaped[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("set -- %s\n%s", strings.Join(escaped, " "), code)
escaped := make([]string, len(args))
for i, a := range args {
escaped[i] = "'" + strings.ReplaceAll(a, "'", "'\\''") + "'"
}
return fmt.Sprintf("set -- %s\n%s", strings.Join(escaped, " "), code)
}
// InjectArgs is the exported version of injectArgs.
@ -151,27 +52,7 @@ func InjectArgs(language, name string, args []string, code string) string {
return injectArgs(language, name, args, code)
}
// ansiCEscape escapes a string for embedding in a bash $'...' literal.
func ansiCEscape(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '\\':
b.WriteString(`\\`)
case '\'':
b.WriteString(`\'`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
b.WriteRune(r)
}
}
return b.String()
}
// ExtractPrompt parses the ollie:prompt ... ollie:end block from a tool
// script's header comments. Returns the prompt text with comment prefixes