Files
iasysadmin/internal/tools/patchtools.go
T
2026-09-27 16:04:02 -06:00

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()}
}