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/.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 "), "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/\n") case "apt-cache": b.WriteString(" apt-cache search (or install apt-file for exact file search)\n") case "dnf", "yum": fmt.Fprintf(&b, " %s provides '*/bin/'\n", m) case "pacman": b.WriteString(" pacman -F \n") case "zypper": b.WriteString(" zypper search --provides \n") case "apk": b.WriteString(" apk search cmd:\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()} }