412 lines
13 KiB
Go
412 lines
13 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"sysagent/internal/fsutil"
|
|
"sysagent/internal/patch"
|
|
"sysagent/internal/policy"
|
|
)
|
|
|
|
// patchesDir es donde se guardan parches y copias de originales (§86).
|
|
func patchesDir(root string) string { return filepath.Join(root, ".agent", "patches") }
|
|
|
|
// ---- apply_patch ----
|
|
|
|
type applyPatchTool struct{}
|
|
|
|
type applyPatchArgs struct {
|
|
Patch string `json:"patch"`
|
|
PatchFile string `json:"patch_file"`
|
|
BaseDir string `json:"base_dir"`
|
|
Strip *int `json:"strip"`
|
|
CheckOnly bool `json:"check_only"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func (applyPatchTool) Name() string { return "apply_patch" }
|
|
func (applyPatchTool) Description() string {
|
|
return "Apply a unified diff (diff -u / git diff) to files under base_dir. All-or-nothing: every hunk of every file must apply before anything is written. The patch is archived in .agent/patches and original contents are kept for rollback. Use check_only to test first. Does not need the patch or git programs."
|
|
}
|
|
func (applyPatchTool) Schema() map[string]any {
|
|
return obj(nil, map[string]any{
|
|
"patch": str("unified diff text"),
|
|
"patch_file": str("path of a patch file (alternative to patch)"),
|
|
"base_dir": str("directory the paths in the patch are relative to (default PROJECT_ROOT)"),
|
|
"strip": num("leading path components to remove, like patch -pN (default: auto, 1 for a/ b/ git paths)"),
|
|
"check_only": boolean("only verify that the patch applies cleanly"),
|
|
"reason": str("why (audit log)"),
|
|
})
|
|
}
|
|
|
|
type patchedFile struct {
|
|
fp patch.FilePatch
|
|
path string
|
|
old string
|
|
new string
|
|
existed bool
|
|
mode os.FileMode
|
|
}
|
|
|
|
func (applyPatchTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result {
|
|
var a applyPatchArgs
|
|
if err := decode(raw, &a); err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
text := a.Patch
|
|
if a.PatchFile != "" {
|
|
if text != "" {
|
|
return fail("apply_patch", errors.New("give patch or patch_file, not both"))
|
|
}
|
|
p := env.Guard.Resolve(a.PatchFile, "")
|
|
if err := env.Guard.CheckPath(policy.OpRead, p, ""); err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
text = string(b)
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
return fail("apply_patch", errors.New("patch or patch_file is required"))
|
|
}
|
|
fps, err := patch.Parse(text)
|
|
if err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
base := env.Guard.Resolve(a.BaseDir, "")
|
|
strip := -1
|
|
if a.Strip != nil {
|
|
strip = *a.Strip
|
|
}
|
|
|
|
// Paso 1: calcular todo en memoria y comprobar la política.
|
|
var files []patchedFile
|
|
for _, fp := range fps {
|
|
rel := fp.Target(strip)
|
|
if rel == "" || strings.Contains("/"+rel+"/", "/../") {
|
|
return fail("apply_patch", fmt.Errorf("unsafe path in patch: %q", rel))
|
|
}
|
|
pf := patchedFile{fp: fp, path: filepath.Join(base, rel), mode: 0o644}
|
|
op := policy.OpWrite
|
|
if fp.IsDelete {
|
|
op = policy.OpDelete
|
|
}
|
|
if err := env.Guard.CheckPath(op, pf.path, ""); err != nil {
|
|
return fail("apply_patch "+pf.path, err)
|
|
}
|
|
if b, err := os.ReadFile(pf.path); err == nil {
|
|
pf.old, pf.existed = string(b), true
|
|
if st, err := os.Stat(pf.path); err == nil {
|
|
pf.mode = st.Mode().Perm()
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return fail("apply_patch "+pf.path, err)
|
|
} else if !fp.IsNew {
|
|
return fail("apply_patch "+pf.path, fmt.Errorf("%s does not exist (check base_dir and strip)", pf.path))
|
|
}
|
|
pf.new, err = fp.Apply(pf.old)
|
|
if err != nil {
|
|
return fail("apply_patch "+pf.path, fmt.Errorf("%s: %w", pf.path, err))
|
|
}
|
|
files = append(files, pf)
|
|
}
|
|
|
|
var summary []string
|
|
for _, f := range files {
|
|
action := "modify"
|
|
switch {
|
|
case f.fp.IsNew:
|
|
action = "create"
|
|
case f.fp.IsDelete:
|
|
action = "delete"
|
|
}
|
|
summary = append(summary, fmt.Sprintf("%s %s (%d hunks)", action, f.path, len(f.fp.Hunks)))
|
|
}
|
|
if a.CheckOnly || env.DryRun {
|
|
msg := "patch applies cleanly:\n" + strings.Join(summary, "\n")
|
|
if env.DryRun && !a.CheckOnly {
|
|
return dryRun(msg)
|
|
}
|
|
return Result{OK: true, ExitCode: 0, Summary: fmt.Sprintf("apply_patch check: %d files OK", len(files)), Output: msg}
|
|
}
|
|
|
|
// Paso 2: archivar el parche y los originales, luego escribir.
|
|
stamp := env.now().Format("20060102-150405")
|
|
arch := filepath.Join(patchesDir(env.Root), stamp)
|
|
for i := 1; ; i++ {
|
|
if _, err := os.Stat(arch); os.IsNotExist(err) {
|
|
break
|
|
}
|
|
arch = fmt.Sprintf("%s-%d", filepath.Join(patchesDir(env.Root), stamp), i)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(arch, "orig"), 0o700); err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
patchPath := filepath.Join(arch, "applied.patch")
|
|
if err := os.WriteFile(patchPath, []byte(text), 0o600); err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
res := Result{}
|
|
var rollback []string
|
|
for _, f := range files {
|
|
if !f.existed {
|
|
rollback = append(rollback, "remove_file path="+f.path)
|
|
continue
|
|
}
|
|
origCopy := filepath.Join(arch, "orig", strings.TrimPrefix(f.path, "/"))
|
|
if err := os.MkdirAll(filepath.Dir(origCopy), 0o700); err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
if err := os.WriteFile(origCopy, []byte(f.old), 0o600); err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
rollback = append(rollback, fmt.Sprintf("copy_file src=%s dst=%s overwrite=true", origCopy, f.path))
|
|
fc := &fileChange{env: env, path: f.path}
|
|
if err := fc.backupIfNeeded(); err != nil {
|
|
return fail("apply_patch", err)
|
|
}
|
|
res.Backups = append(res.Backups, fc.res.Backups...)
|
|
}
|
|
var written []string
|
|
for _, f := range files {
|
|
var err error
|
|
switch {
|
|
case f.fp.IsDelete:
|
|
err = os.Remove(f.path)
|
|
default:
|
|
if !f.existed {
|
|
if err = os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil {
|
|
break
|
|
}
|
|
}
|
|
err = fsutil.WriteAtomic(f.path, []byte(f.new), f.mode, nil)
|
|
}
|
|
if err != nil {
|
|
// Un fallo aquí (disco, permisos) deja el conjunto a medias: se
|
|
// informa exactamente qué se escribió y cómo revertirlo.
|
|
return Result{ExitCode: -1, Summary: "apply_patch partially applied", FilesChanged: written, Backups: res.Backups,
|
|
Output: fmt.Sprintf("error writing %s: %v\nalready written: %s\nrollback:\n%s", f.path, err, strings.Join(written, ", "), strings.Join(rollback, "\n"))}
|
|
}
|
|
written = append(written, f.path)
|
|
}
|
|
res.OK, res.ExitCode = true, 0
|
|
res.FilesChanged = written
|
|
res.Summary = fmt.Sprintf("apply_patch: %d files patched", len(files))
|
|
res.Output = fmt.Sprintf("applied:\n%s\narchived patch: %s\noriginals: %s\nrollback:\n%s", strings.Join(summary, "\n"), patchPath, filepath.Join(arch, "orig"), strings.Join(rollback, "\n"))
|
|
res.Audit = []string{"patch", patchPath, "reason", a.Reason}
|
|
return res
|
|
}
|
|
|
|
// ---- diff_files ----
|
|
|
|
type diffFilesTool struct{}
|
|
|
|
type diffFilesArgs struct {
|
|
Old string `json:"old"`
|
|
New string `json:"new"`
|
|
Context int `json:"context"`
|
|
SaveAs string `json:"save_as"`
|
|
}
|
|
|
|
func (diffFilesTool) Name() string { return "diff_files" }
|
|
func (diffFilesTool) Description() string {
|
|
return "Produce a unified diff between two files (e.g. a backup and the current file) to document a change exactly or to create your own patch. save_as stores it in .agent/patches/<name>.patch."
|
|
}
|
|
func (diffFilesTool) Schema() map[string]any {
|
|
return obj([]string{"old", "new"}, map[string]any{
|
|
"old": str("original file"),
|
|
"new": str("modified file"),
|
|
"context": num("context lines (default 3)"),
|
|
"save_as": str("optional name to save the diff under .agent/patches/"),
|
|
})
|
|
}
|
|
|
|
func (diffFilesTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result {
|
|
var a diffFilesArgs
|
|
if err := decode(raw, &a); err != nil {
|
|
return fail("diff_files", err)
|
|
}
|
|
oldPath, newPath := env.Guard.Resolve(a.Old, ""), env.Guard.Resolve(a.New, "")
|
|
label := "diff " + oldPath + " " + newPath
|
|
var contents [2]string
|
|
for i, p := range []string{oldPath, newPath} {
|
|
if err := env.Guard.CheckPath(policy.OpRead, p, ""); err != nil {
|
|
return fail(label, err)
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return fail(label, err)
|
|
}
|
|
if isBinary(b) {
|
|
return fail(label, fmt.Errorf("%s is binary", p))
|
|
}
|
|
contents[i] = string(b)
|
|
}
|
|
ctxLines := a.Context
|
|
if ctxLines <= 0 {
|
|
ctxLines = 3
|
|
}
|
|
d, err := patch.Unified(oldPath, newPath, contents[0], contents[1], ctxLines)
|
|
if err != nil {
|
|
return fail(label, err)
|
|
}
|
|
if d == "" {
|
|
return Result{OK: true, ExitCode: 0, Summary: label + " (identical)", Output: "files are identical"}
|
|
}
|
|
res := Result{OK: true, ExitCode: 0, Summary: label}
|
|
out := d
|
|
if a.SaveAs != "" {
|
|
name := filepath.Base(strings.TrimSuffix(a.SaveAs, ".patch")) + ".patch"
|
|
dest := filepath.Join(patchesDir(env.Root), name)
|
|
if env.DryRun {
|
|
return dryRun("save diff to " + dest)
|
|
}
|
|
if err := os.MkdirAll(patchesDir(env.Root), 0o700); err != nil {
|
|
return fail(label, err)
|
|
}
|
|
if err := fsutil.WriteAtomic(dest, []byte(d), 0o600, nil); err != nil {
|
|
return fail(label, err)
|
|
}
|
|
res.FilesChanged = []string{dest}
|
|
out = "saved: " + dest + "\n\n" + d
|
|
}
|
|
if len(out) > inlineLimit {
|
|
out = out[:inlineLimit] + "\n[... diff truncated; use save_as and read_file ...]"
|
|
}
|
|
res.Output = out
|
|
return res
|
|
}
|
|
|
|
// ---- register_tool ----
|
|
|
|
type registerToolTool struct{}
|
|
|
|
type registerToolArgs struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Description string `json:"description"`
|
|
Usage string `json:"usage"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
func (registerToolTool) Name() string { return "register_tool" }
|
|
func (registerToolTool) Description() string {
|
|
return "Document a helper tool you created (script or program, normally in PROJECT_ROOT/tools/) in memoria.md so it can be reused later instead of rewritten (§43). Requires ALLOW_SELF_TOOL_CREATION."
|
|
}
|
|
func (registerToolTool) Schema() map[string]any {
|
|
return obj([]string{"name", "path", "description", "usage"}, map[string]any{
|
|
"name": str("tool name, e.g. check_cert"),
|
|
"path": str("path of the tool"),
|
|
"description": str("what it does and why it exists"),
|
|
"usage": str("how to run it, e.g. tools/check_cert.sh <host> <port>"),
|
|
"language": str("Go, Python, Shell, ..."),
|
|
})
|
|
}
|
|
|
|
func (registerToolTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result {
|
|
var a registerToolArgs
|
|
if err := decode(raw, &a); err != nil {
|
|
return fail("register_tool", err)
|
|
}
|
|
label := "register_tool " + a.Name
|
|
if err := env.Guard.CheckCapability("register_tool", policy.CapSelfToolCreation); err != nil {
|
|
return fail(label, err)
|
|
}
|
|
path := env.Guard.Resolve(a.Path, "")
|
|
if err := env.Guard.CheckPath(policy.OpRead, path, ""); err != nil {
|
|
return fail(label, err)
|
|
}
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return fail(label, fmt.Errorf("tool not found: %w", err))
|
|
}
|
|
if env.Memory == nil {
|
|
return fail(label, errors.New("memory store not available"))
|
|
}
|
|
facts := []string{
|
|
"Ruta: " + path,
|
|
"Descripción: " + a.Description,
|
|
"Uso: " + a.Usage,
|
|
fmt.Sprintf("Ejecutable: %t", st.Mode().Perm()&0o111 != 0),
|
|
"Registrada: " + env.now().Format(time.RFC3339),
|
|
}
|
|
if a.Language != "" {
|
|
facts = append(facts, "Lenguaje: "+a.Language)
|
|
}
|
|
if env.DryRun {
|
|
return dryRun("register tool " + a.Name + " in memoria.md")
|
|
}
|
|
if _, err := env.Memory.Upsert("Herramientas", a.Name, facts, false); err != nil {
|
|
return fail(label, err)
|
|
}
|
|
return Result{OK: true, ExitCode: 0, Summary: label, FilesChanged: []string{env.Memory.Path()},
|
|
Output: "registered in memoria.md under Herramientas/" + a.Name}
|
|
}
|
|
|
|
// ---- find_program ----
|
|
|
|
type findProgramTool struct{}
|
|
|
|
type findProgramArgs struct {
|
|
Names []string `json:"names"`
|
|
}
|
|
|
|
func (findProgramTool) Name() string { return "find_program" }
|
|
func (findProgramTool) Description() string {
|
|
return "Check which programs are installed (like command -v) before relying on them, and suggest how to find the package that provides the missing ones. Read-only."
|
|
}
|
|
func (findProgramTool) Schema() map[string]any {
|
|
return obj([]string{"names"}, map[string]any{"names": strList("program names, e.g. [\"nft\",\"dig\",\"gcc\"]")})
|
|
}
|
|
|
|
func (findProgramTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result {
|
|
var a findProgramArgs
|
|
if err := decode(raw, &a); err != nil {
|
|
return fail("find_program", err)
|
|
}
|
|
if len(a.Names) == 0 {
|
|
return fail("find_program", errors.New("names is required"))
|
|
}
|
|
var b strings.Builder
|
|
var missing []string
|
|
for _, n := range a.Names {
|
|
if p, err := execLookPath(n); err == nil {
|
|
fmt.Fprintf(&b, "%s: %s\n", n, p)
|
|
} else {
|
|
fmt.Fprintf(&b, "%s: NOT FOUND\n", n)
|
|
missing = append(missing, n)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
b.WriteString("\nTo find the package that provides a missing program:\n")
|
|
mgrs := available("apt-file", "apt-cache", "dnf", "yum", "pacman", "zypper", "apk")
|
|
for _, m := range mgrs {
|
|
switch m {
|
|
case "apt-file":
|
|
b.WriteString(" apt-file search bin/<name>\n")
|
|
case "apt-cache":
|
|
b.WriteString(" apt-cache search <name> (or install apt-file for exact file search)\n")
|
|
case "dnf", "yum":
|
|
fmt.Fprintf(&b, " %s provides '*/bin/<name>'\n", m)
|
|
case "pacman":
|
|
b.WriteString(" pacman -F <name>\n")
|
|
case "zypper":
|
|
b.WriteString(" zypper search --provides <name>\n")
|
|
case "apk":
|
|
b.WriteString(" apk search cmd:<name>\n")
|
|
}
|
|
}
|
|
b.WriteString("Prefer distribution packages over upstream builds (§42, §45).\n")
|
|
}
|
|
return Result{OK: true, ExitCode: 0, Summary: fmt.Sprintf("find_program: %d/%d found", len(a.Names)-len(missing), len(a.Names)), Output: b.String()}
|
|
}
|