package tools import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/user" "path/filepath" "sort" "strconv" "strings" "syscall" "sysagent/internal/fsutil" "sysagent/internal/patch" "sysagent/internal/policy" ) const ( readDefaultLines = 500 readMaxLines = 5000 readMaxBytes = 64 << 10 writeMaxBytes = 32 << 20 listMaxEntries = 1000 ) // fileChange agrupa lo que una herramienta de escritura necesita hacer // antes de modificar un archivo: comprobar la política, decidir si hace // falta backup (§29) y registrar el cambio (§88). type fileChange struct { env *Env path string // absoluta reason string res Result } // prepare comprueba la política y crea el backup si corresponde. // El backup se hace para archivos existentes fuera de PROJECT_ROOT cuando // BACKUP_BEFORE_SYSTEM_EDIT=true. func (fc *fileChange) prepare(op policy.Op) error { if err := fc.env.Guard.CheckPath(op, fc.path, ""); err != nil { return err } if fc.env.DryRun { return nil } return fc.backupIfNeeded() } func (fc *fileChange) backupIfNeeded() error { if !fc.env.Policy.BackupBeforeSystemEdit { return nil } real := policy.RealPath(fc.path, true) if fc.env.Guard.Inside(real) { return nil } st, err := os.Stat(real) if err != nil || !st.Mode().IsRegular() { return nil // no existe: no hay nada que respaldar } b, err := fsutil.BackupFile(real, fc.env.now()) if err != nil { return fmt.Errorf("backup required by BACKUP_BEFORE_SYSTEM_EDIT failed: %w", err) } fc.res.Backups = append(fc.res.Backups, b) return nil } // done construye el Result de éxito, incluyendo información de rollback (§63). func (fc *fileChange) done(summary, detail string) Result { r := fc.res r.OK = true r.ExitCode = 0 r.Summary = summary r.FilesChanged = append(r.FilesChanged, fc.path) var b strings.Builder b.WriteString(detail) for _, bk := range r.Backups { fmt.Fprintf(&b, "\nbackup: %s\nrollback: copy_file src=%s dst=%s overwrite=true", bk, bk, fc.path) } r.Output = b.String() if fc.reason != "" { r.Audit = append(r.Audit, "reason", fc.reason) } return r } func parseMode(s string) (os.FileMode, error) { if s == "" { return 0, nil } n, err := strconv.ParseUint(strings.TrimPrefix(s, "0o"), 8, 32) if err != nil || n > 0o7777 { return 0, fmt.Errorf("invalid octal mode %q (example: \"0644\")", s) } return os.FileMode(n), nil } // validateSpec permite validar un archivo antes de aplicarlo (§29, §30). // En los argumentos, "{file}" se sustituye por la ruta del temporal. type validateSpec struct { Command string `json:"command"` Args []string `json:"args"` } func (v *validateSpec) run(ctx context.Context, env *Env) func(string) error { if v == nil || v.Command == "" { return nil } return func(tmp string) error { args := make([]string, len(v.Args)) for i, a := range v.Args { args[i] = strings.ReplaceAll(a, "{file}", tmp) } // Primero se valida con marcadores, luego se sustituyen los secretos. expanded := make([]string, len(args)) for i, a := range args { e, err := env.Secrets.Expand(a) if err != nil { return err } expanded[i] = e } argv := append([]string{v.Command}, args...) if _, err := env.Guard.CheckArgv(argv, env.Root); err != nil { return err } r := RunCommand(ctx, CommandSpec{Name: v.Command, Args: expanded, Dir: env.Root, Timeout: env.Policy.DefaultCommandTimeout, TmpDir: tmpDir(env.Root)}) if r.ExitCode != 0 || r.Err != nil { return fmt.Errorf("%s exited %d: %s", quoteArgv(argv), r.ExitCode, strings.TrimSpace(clip(r.Stderr+r.Stdout, 2000))) } return nil } } var validateSchema = map[string]any{ "type": "object", "description": "optional command run on the new content BEFORE it replaces the file; {file} is replaced by the temporary file path, e.g. {\"command\":\"nginx\",\"args\":[\"-t\",\"-c\",\"{file}\"]}. If it fails, the original file is left untouched.", "properties": map[string]any{ "command": str("validator program"), "args": strList("arguments; use {file} for the temporary file"), }, "required": []string{"command"}, "additionalProperties": false, } // ---- read_file ---- type readFileTool struct{} type readFileArgs struct { Path string `json:"path"` Offset int `json:"offset"` Limit int `json:"limit"` } func (readFileTool) Name() string { return "read_file" } func (readFileTool) Description() string { return "Read a text file with line numbers. Use offset/limit (in lines) for large files. Relative paths are relative to PROJECT_ROOT." } func (readFileTool) Schema() map[string]any { return obj([]string{"path"}, map[string]any{ "path": str("file path"), "offset": num("first line to return, 1-based (default 1)"), "limit": num("maximum number of lines (default 500, max 5000)"), }) } func (readFileTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a readFileArgs if err := decode(raw, &a); err != nil { return fail("read_file", err) } path := env.Guard.Resolve(a.Path, "") if err := env.Guard.CheckPath(policy.OpRead, path, ""); err != nil { return fail("read "+path, err) } f, err := os.Open(path) if err != nil { return fail("read "+path, err) } defer f.Close() st, err := f.Stat() if err != nil { return fail("read "+path, err) } if st.IsDir() { return fail("read "+path, fmt.Errorf("%s is a directory; use list_dir", path)) } br := bufio.NewReader(f) if peek, _ := br.Peek(8192); bytes.IndexByte(peek, 0) >= 0 { return Result{OK: true, ExitCode: 0, Summary: "read " + path + " (binary)", Output: fmt.Sprintf("%s is a binary file (%d bytes); use exec with strings, xxd or file to inspect it", path, st.Size())} } offset := max(a.Offset, 1) limit := a.Limit if limit <= 0 { limit = readDefaultLines } limit = min(limit, readMaxLines) var b strings.Builder lineNo, shown := 0, 0 truncatedBytes := false sc := bufio.NewScanner(br) sc.Buffer(make([]byte, 64*1024), 16<<20) for sc.Scan() { lineNo++ if lineNo < offset || shown >= limit { continue } if b.Len() > readMaxBytes { truncatedBytes = true continue } fmt.Fprintf(&b, "%6d\t%s\n", lineNo, sc.Text()) shown++ } if err := sc.Err(); err != nil { return fail("read "+path, err) } header := fmt.Sprintf("file: %s (%d bytes, %d lines)\n", path, st.Size(), lineNo) if offset > 1 || offset+shown-1 < lineNo { header += fmt.Sprintf("showing lines %d-%d; use offset/limit to read more\n", offset, offset+shown-1) } if truncatedBytes { header += "output capped at 64 KiB; use a smaller limit\n" } return Result{OK: true, ExitCode: 0, Summary: fmt.Sprintf("read %s (%d lines)", path, shown), Output: header + b.String()} } // ---- write_file ---- type writeFileTool struct{} type writeFileArgs struct { Path string `json:"path"` Content string `json:"content"` Mode string `json:"mode"` CreateDirs bool `json:"create_dirs"` Validate *validateSpec `json:"validate"` Reason string `json:"reason"` } func (writeFileTool) Name() string { return "write_file" } func (writeFileTool) Description() string { return "Create or fully replace a file atomically (temp file + rename). Existing system files are backed up first when the policy says so. Prefer edit_file for small changes to existing files." } func (writeFileTool) Schema() map[string]any { return obj([]string{"path", "content"}, map[string]any{ "path": str("file path"), "content": str("complete new content"), "mode": str("octal permissions for new files, e.g. \"0644\" (existing files keep theirs)"), "create_dirs": boolean("create missing parent directories"), "validate": validateSchema, "reason": str("why this change is made (recorded in the audit log)"), }) } func (writeFileTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result { var a writeFileArgs if err := decode(raw, &a); err != nil { return fail("write_file", err) } return writeContent(ctx, env, a.Path, []byte(a.Content), a.Mode, a.CreateDirs, a.Validate, a.Reason, "write") } // writeContent es el camino común de write_file, edit_file y append_file. func writeContent(ctx context.Context, env *Env, p string, data []byte, modeStr string, createDirs bool, v *validateSpec, reason, verb string) Result { path := env.Guard.Resolve(p, "") expanded, err := env.Secrets.Expand(string(data)) if err != nil { return fail(verb+" "+path, err) } data = []byte(expanded) if len(data) > writeMaxBytes { return fail(verb+" "+path, fmt.Errorf("content too large (%d bytes, max %d)", len(data), writeMaxBytes)) } mode, err := parseMode(modeStr) if err != nil { return fail(verb+" "+path, err) } // Idempotencia (§53): no reescribir si el contenido ya es el deseado. if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, data) { if st, err := os.Stat(path); err == nil && (mode == 0 || st.Mode().Perm() == mode) { if err := env.Guard.CheckPath(policy.OpWrite, path, ""); err != nil { return fail(verb+" "+path, err) } return Result{OK: true, ExitCode: 0, Summary: path + " unchanged", Output: path + " already has the requested content; nothing written"} } } fc := &fileChange{env: env, path: path, reason: reason} if err := fc.prepare(policy.OpWrite); err != nil { return fail(verb+" "+path, err) } _, statErr := os.Stat(path) existed := statErr == nil oldContent, _ := os.ReadFile(path) if env.DryRun { return dryRun(fmt.Sprintf("%s %d bytes to %s (existing file: %t)", verb, len(data), path, existed)) } if createDirs { if err := env.Guard.CheckPath(policy.OpWrite, filepath.Dir(path), ""); err != nil { return fail(verb+" "+path, err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fail(verb+" "+path, err) } } if err := fsutil.WriteAtomic(path, data, mode, v.run(ctx, env)); err != nil { return fail(verb+" "+path, err) } state := "created" if existed { state = "replaced" } detail := fmt.Sprintf("%s: %s (%d bytes)", path, state, len(data)) if v != nil && v.Command != "" { detail += "\nvalidation passed: " + v.Command } // Registro del cambio (§88): diff exacto entre el valor anterior y el nuevo. var change string if existed && !isBinary(oldContent) && !isBinary(data) { if d, err := patch.Unified(path+" (before)", path+" (after)", string(oldContent), string(data), 2); err == nil && d != "" { change = d detail += "\nchange:\n" + clip(d, 4000) } } res := fc.done(fmt.Sprintf("%s %s (%d bytes)", state, path, len(data)), detail) res.Diff = change if change != "" { res.Audit = append(res.Audit, "diff", clip(change, 2000)) } return res } // ---- edit_file ---- type editFileTool struct{} type editFileArgs struct { Path string `json:"path"` OldString string `json:"old_string"` NewString string `json:"new_string"` ReplaceAll bool `json:"replace_all"` Validate *validateSpec `json:"validate"` Reason string `json:"reason"` } func (editFileTool) Name() string { return "edit_file" } func (editFileTool) Description() string { return "Replace an exact text fragment in an existing file (atomic, with backup and optional validation). old_string must match exactly once unless replace_all is true." } func (editFileTool) Schema() map[string]any { return obj([]string{"path", "old_string", "new_string"}, map[string]any{ "path": str("file path"), "old_string": str("exact text to replace, including whitespace"), "new_string": str("replacement text"), "replace_all": boolean("replace every occurrence"), "validate": validateSchema, "reason": str("why this change is made (recorded in the audit log)"), }) } func (editFileTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result { var a editFileArgs if err := decode(raw, &a); err != nil { return fail("edit_file", err) } path := env.Guard.Resolve(a.Path, "") if a.OldString == "" { return fail("edit "+path, errors.New("old_string must not be empty")) } for _, sp := range []*string{&a.OldString, &a.NewString} { v, err := env.Secrets.Expand(*sp) if err != nil { return fail("edit "+path, err) } *sp = v } if err := env.Guard.CheckPath(policy.OpRead, path, ""); err != nil { return fail("edit "+path, err) } old, err := os.ReadFile(path) if err != nil { return fail("edit "+path, err) } n := strings.Count(string(old), a.OldString) switch { case n == 0: return fail("edit "+path, errors.New("old_string not found; read the file again and copy the exact text")) case n > 1 && !a.ReplaceAll: return fail("edit "+path, fmt.Errorf("old_string found %d times; add more context or set replace_all", n)) } updated := strings.ReplaceAll(string(old), a.OldString, a.NewString) res := writeContent(ctx, env, path, []byte(updated), "", false, a.Validate, a.Reason, "edit") if res.OK && !env.DryRun { res.Output += fmt.Sprintf("\nreplacements: %d", n) } return res } // ---- append_file ---- type appendFileTool struct{} type appendFileArgs struct { Path string `json:"path"` Content string `json:"content"` Reason string `json:"reason"` } func (appendFileTool) Name() string { return "append_file" } func (appendFileTool) Description() string { return "Append text to a file (created if missing). Done atomically with backup like write_file." } func (appendFileTool) Schema() map[string]any { return obj([]string{"path", "content"}, map[string]any{ "path": str("file path"), "content": str("text to append"), "reason": str("why this change is made (recorded in the audit log)"), }) } func (appendFileTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result { var a appendFileArgs if err := decode(raw, &a); err != nil { return fail("append_file", err) } path := env.Guard.Resolve(a.Path, "") if err := env.Guard.CheckPath(policy.OpWrite, path, ""); err != nil { return fail("append "+path, err) } old, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return fail("append "+path, err) } return writeContent(ctx, env, path, append(old, a.Content...), "", false, nil, a.Reason, "append") } // ---- copy_file ---- type copyFileTool struct{} type copyMoveArgs struct { Src string `json:"src"` Dst string `json:"dst"` Overwrite bool `json:"overwrite"` Reason string `json:"reason"` } func (copyFileTool) Name() string { return "copy_file" } func (copyFileTool) Description() string { return "Copy a regular file (atomic, keeps permissions). Use it to restore backups." } func (copyFileTool) Schema() map[string]any { return obj([]string{"src", "dst"}, map[string]any{ "src": str("source file"), "dst": str("destination file path"), "overwrite": boolean("replace dst if it exists"), "reason": str("why (audit log)"), }) } func (copyFileTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a copyMoveArgs if err := decode(raw, &a); err != nil { return fail("copy_file", err) } src, dst := env.Guard.Resolve(a.Src, ""), env.Guard.Resolve(a.Dst, "") label := "copy " + src + " -> " + dst if err := env.Guard.CheckPath(policy.OpRead, src, ""); err != nil { return fail(label, err) } if st, err := os.Stat(dst); err == nil { if st.IsDir() { dst = filepath.Join(dst, filepath.Base(src)) } else if !a.Overwrite { return fail(label, fmt.Errorf("%s exists; set overwrite=true to replace it", dst)) } } fc := &fileChange{env: env, path: dst, reason: a.Reason} if err := fc.prepare(policy.OpWrite); err != nil { return fail(label, err) } if env.DryRun { return dryRun(label) } if err := fsutil.CopyFile(src, dst); err != nil { return fail(label, err) } return fc.done(label, "copied "+src+" to "+dst) } // ---- move_file ---- type moveFileTool struct{} func (moveFileTool) Name() string { return "move_file" } func (moveFileTool) Description() string { return "Move or rename a file or directory." } func (moveFileTool) Schema() map[string]any { return obj([]string{"src", "dst"}, map[string]any{ "src": str("source path"), "dst": str("destination path"), "overwrite": boolean("replace dst if it is an existing file"), "reason": str("why (audit log)"), }) } func (moveFileTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a copyMoveArgs if err := decode(raw, &a); err != nil { return fail("move_file", err) } src, dst := env.Guard.Resolve(a.Src, ""), env.Guard.Resolve(a.Dst, "") label := "move " + src + " -> " + dst // Mover modifica el directorio de origen: se trata como escritura sobre // el propio enlace/archivo (no se siguen symlinks del último componente). if err := env.Guard.CheckPath(policy.OpWrite, filepath.Dir(src), ""); err != nil { return fail(label, err) } if err := env.Guard.CheckPath(policy.OpWrite, src, ""); err != nil { return fail(label, err) } if _, err := os.Lstat(src); err != nil { return fail(label, err) } if st, err := os.Stat(dst); err == nil { if st.IsDir() { dst = filepath.Join(dst, filepath.Base(src)) } else if !a.Overwrite { return fail(label, fmt.Errorf("%s exists; set overwrite=true to replace it", dst)) } } fc := &fileChange{env: env, path: dst, reason: a.Reason} if err := fc.prepare(policy.OpWrite); err != nil { return fail(label, err) } if env.DryRun { return dryRun(label) } if err := os.Rename(src, dst); err != nil { var le *os.LinkError if errors.As(err, &le) && errors.Is(le.Err, syscall.EXDEV) { // Distinto filesystem: copiar y borrar (solo archivos). if err := fsutil.CopyFile(src, dst); err != nil { return fail(label, err) } if err := os.Remove(src); err != nil { return fail(label, fmt.Errorf("copied but failed to remove source: %w", err)) } } else { return fail(label, err) } } res := fc.done(label, "moved "+src+" to "+dst) res.FilesChanged = append(res.FilesChanged, src) return res } // ---- remove_file ---- type removeFileTool struct{} type removeArgs struct { Path string `json:"path"` Recursive bool `json:"recursive"` Reason string `json:"reason"` } func (removeFileTool) Name() string { return "remove_file" } func (removeFileTool) Description() string { return "Delete a file, symlink or empty directory (recursive=true for directory trees). Requires ALLOW_DELETE. Files outside PROJECT_ROOT are backed up first when the policy says so." } func (removeFileTool) Schema() map[string]any { return obj([]string{"path"}, map[string]any{ "path": str("path to delete"), "recursive": boolean("delete a directory and all its contents"), "reason": str("why (audit log)"), }) } func (removeFileTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a removeArgs if err := decode(raw, &a); err != nil { return fail("remove_file", err) } path := env.Guard.Resolve(a.Path, "") label := "remove " + path st, err := os.Lstat(path) if err != nil { return fail(label, err) } fc := &fileChange{env: env, path: path, reason: a.Reason} if err := env.Guard.CheckPath(policy.OpDelete, path, ""); err != nil { return fail(label, err) } if st.IsDir() && !a.Recursive { entries, _ := os.ReadDir(path) if len(entries) > 0 { return fail(label, fmt.Errorf("%s is a non-empty directory; set recursive=true", path)) } } if env.DryRun { return dryRun(fmt.Sprintf("delete %s (directory: %t, recursive: %t)", path, st.IsDir(), a.Recursive)) } if st.Mode().IsRegular() { if err := fc.backupIfNeeded(); err != nil { return fail(label, err) } } if a.Recursive { err = os.RemoveAll(path) } else { err = os.Remove(path) } if err != nil { return fail(label, err) } detail := "deleted " + path if st.IsDir() { detail += " (directory, no backup)" } return fc.done(label, detail) } // ---- mkdir ---- type mkdirTool struct{} type mkdirArgs struct { Path string `json:"path"` Parents bool `json:"parents"` Mode string `json:"mode"` } func (mkdirTool) Name() string { return "mkdir" } func (mkdirTool) Description() string { return "Create a directory (parents=true like mkdir -p)." } func (mkdirTool) Schema() map[string]any { return obj([]string{"path"}, map[string]any{ "path": str("directory path"), "parents": boolean("create missing parents; no error if it exists"), "mode": str("octal permissions, default \"0755\""), }) } func (mkdirTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a mkdirArgs if err := decode(raw, &a); err != nil { return fail("mkdir", err) } path := env.Guard.Resolve(a.Path, "") mode, err := parseMode(a.Mode) if err != nil { return fail("mkdir "+path, err) } if mode == 0 { mode = 0o755 } if err := env.Guard.CheckPath(policy.OpWrite, path, ""); err != nil { return fail("mkdir "+path, err) } if st, err := os.Stat(path); err == nil && st.IsDir() { return Result{OK: true, ExitCode: 0, Summary: path + " exists", Output: path + " already exists"} } if env.DryRun { return dryRun(fmt.Sprintf("create directory %s mode %04o", path, mode)) } if a.Parents { err = os.MkdirAll(path, mode) } else { err = os.Mkdir(path, mode) } if err != nil { return fail("mkdir "+path, err) } return Result{OK: true, ExitCode: 0, Summary: "mkdir " + path, Output: "created " + path, FilesChanged: []string{path}} } // ---- list_dir ---- type listDirTool struct{} type listDirArgs struct { Path string `json:"path"` All bool `json:"all"` } func (listDirTool) Name() string { return "list_dir" } func (listDirTool) Description() string { return "List a directory: type, permissions, owner, size, modification time, name." } func (listDirTool) Schema() map[string]any { return obj(nil, map[string]any{ "path": str("directory (default PROJECT_ROOT)"), "all": boolean("include hidden entries"), }) } func (listDirTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a listDirArgs if err := decode(raw, &a); err != nil { return fail("list_dir", err) } path := env.Guard.Resolve(a.Path, "") if err := env.Guard.CheckPath(policy.OpRead, path, ""); err != nil { return fail("list "+path, err) } entries, err := os.ReadDir(path) if err != nil { return fail("list "+path, err) } sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) var b strings.Builder fmt.Fprintf(&b, "directory: %s\n", path) n := 0 for _, e := range entries { if !a.All && strings.HasPrefix(e.Name(), ".") { continue } if n >= listMaxEntries { fmt.Fprintf(&b, "[... more entries omitted; %d total ...]\n", len(entries)) break } n++ info, err := e.Info() if err != nil { fmt.Fprintf(&b, "? %s (%v)\n", e.Name(), err) continue } name := e.Name() if info.Mode()&os.ModeSymlink != 0 { if t, err := os.Readlink(filepath.Join(path, name)); err == nil { name += " -> " + t } } fmt.Fprintf(&b, "%s %s %10d %s %s\n", info.Mode(), owner(info), info.Size(), info.ModTime().Format("2006-01-02 15:04"), name) } return Result{OK: true, ExitCode: 0, Summary: fmt.Sprintf("list %s (%d entries)", path, n), Output: b.String()} } // owner devuelve "usuario:grupo" (o los números si no se pueden resolver). func owner(info os.FileInfo) string { sys, ok := info.Sys().(*syscall.Stat_t) if !ok { return "?:?" } u, g := strconv.Itoa(int(sys.Uid)), strconv.Itoa(int(sys.Gid)) if uu, err := user.LookupId(u); err == nil { u = uu.Username } if gg, err := user.LookupGroupId(g); err == nil { g = gg.Name } return u + ":" + g } // ---- stat ---- type statTool struct{} type pathArgs struct { Path string `json:"path"` } func (statTool) Name() string { return "stat" } func (statTool) Description() string { return "Show file metadata: whether it exists, type, permissions, owner, size, mtime, symlink target." } func (statTool) Schema() map[string]any { return obj([]string{"path"}, map[string]any{"path": str("path")}) } func (statTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a pathArgs if err := decode(raw, &a); err != nil { return fail("stat", err) } path := env.Guard.Resolve(a.Path, "") if err := env.Guard.CheckPath(policy.OpRead, path, ""); err != nil { return fail("stat "+path, err) } info, err := os.Lstat(path) if os.IsNotExist(err) { return Result{OK: true, ExitCode: 0, Summary: "stat " + path + ": not found", Output: "path: " + path + "\nexists: false"} } if err != nil { return fail("stat "+path, err) } kind := "file" switch { case info.IsDir(): kind = "directory" case info.Mode()&os.ModeSymlink != 0: kind = "symlink" case !info.Mode().IsRegular(): kind = "special" } var b strings.Builder fmt.Fprintf(&b, "path: %s\nexists: true\ntype: %s\nmode: %s (%04o)\nowner: %s\nsize: %d\nmtime: %s\n", path, kind, info.Mode(), info.Mode().Perm(), owner(info), info.Size(), info.ModTime().Format("2006-01-02T15:04:05Z07:00")) if kind == "symlink" { t, _ := os.Readlink(path) fmt.Fprintf(&b, "target: %s\n", t) } return Result{OK: true, ExitCode: 0, Summary: "stat " + path, Output: b.String()} } // ---- chmod ---- type chmodTool struct{} type chmodArgs struct { Path string `json:"path"` Mode string `json:"mode"` Reason string `json:"reason"` } func (chmodTool) Name() string { return "chmod" } func (chmodTool) Description() string { return "Change permissions of a path (octal mode)." } func (chmodTool) Schema() map[string]any { return obj([]string{"path", "mode"}, map[string]any{ "path": str("path"), "mode": str("octal mode, e.g. \"0600\""), "reason": str("why (audit log)"), }) } func (chmodTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a chmodArgs if err := decode(raw, &a); err != nil { return fail("chmod", err) } path := env.Guard.Resolve(a.Path, "") mode, err := parseMode(a.Mode) if err != nil || a.Mode == "" { return fail("chmod "+path, fmt.Errorf("mode is required: %v", err)) } if env.Policy.Mode == policy.ModeGuarded && !env.Guard.Inside(policy.RealPath(path, true)) { return fail("chmod "+path, &policy.DeniedError{Reason: "guarded mode: changing permissions outside PROJECT_ROOT requires EXECUTION_MODE=privileged"}) } if err := env.Guard.CheckPath(policy.OpWrite, path, ""); err != nil { return fail("chmod "+path, err) } st, err := os.Stat(path) if err != nil { return fail("chmod "+path, err) } oldMode := st.Mode().Perm() | st.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) if env.DryRun { return dryRun(fmt.Sprintf("chmod %s from %04o to %04o", path, oldMode, mode)) } if err := os.Chmod(path, goMode(mode)); err != nil { return fail("chmod "+path, err) } return Result{OK: true, ExitCode: 0, Summary: fmt.Sprintf("chmod %04o %s", mode, path), Output: fmt.Sprintf("%s: mode %04o -> %04o\nrollback: chmod path=%s mode=%04o", path, oldMode.Perm(), mode, path, oldMode.Perm()), FilesChanged: []string{path}, Audit: []string{"old_mode", fmt.Sprintf("%04o", oldMode.Perm()), "new_mode", fmt.Sprintf("%04o", mode), "reason", a.Reason}} } // goMode convierte bits Unix (incluidos setuid/setgid/sticky) a os.FileMode. func goMode(m os.FileMode) os.FileMode { out := m & 0o777 if m&0o4000 != 0 { out |= os.ModeSetuid } if m&0o2000 != 0 { out |= os.ModeSetgid } if m&0o1000 != 0 { out |= os.ModeSticky } return out } // ---- chown ---- type chownTool struct{} type chownArgs struct { Path string `json:"path"` User string `json:"user"` Group string `json:"group"` Reason string `json:"reason"` } func (chownTool) Name() string { return "chown" } func (chownTool) Description() string { return "Change owner and/or group of a path (names or numeric ids)." } func (chownTool) Schema() map[string]any { return obj([]string{"path"}, map[string]any{ "path": str("path"), "user": str("new owner (empty = unchanged)"), "group": str("new group (empty = unchanged)"), "reason": str("why (audit log)"), }) } func (chownTool) Run(_ context.Context, env *Env, raw json.RawMessage) Result { var a chownArgs if err := decode(raw, &a); err != nil { return fail("chown", err) } path := env.Guard.Resolve(a.Path, "") if a.User == "" && a.Group == "" { return fail("chown "+path, errors.New("user or group is required")) } if env.Policy.Mode == policy.ModeGuarded && !env.Guard.Inside(policy.RealPath(path, true)) { return fail("chown "+path, &policy.DeniedError{Reason: "guarded mode: changing ownership outside PROJECT_ROOT requires EXECUTION_MODE=privileged"}) } if err := env.Guard.CheckPath(policy.OpWrite, path, ""); err != nil { return fail("chown "+path, err) } uid, gid := -1, -1 if a.User != "" { id, err := lookupID(a.User, false) if err != nil { return fail("chown "+path, err) } uid = id } if a.Group != "" { id, err := lookupID(a.Group, true) if err != nil { return fail("chown "+path, err) } gid = id } info, err := os.Lstat(path) if err != nil { return fail("chown "+path, err) } old := owner(info) if env.DryRun { return dryRun(fmt.Sprintf("chown %s from %s to %s:%s", path, old, a.User, a.Group)) } if err := os.Chown(path, uid, gid); err != nil { return fail("chown "+path, err) } return Result{OK: true, ExitCode: 0, Summary: fmt.Sprintf("chown %s:%s %s", a.User, a.Group, path), Output: fmt.Sprintf("%s: owner %s -> %s:%s\nrollback: restore owner %s", path, old, a.User, a.Group, old), FilesChanged: []string{path}, Audit: []string{"old_owner", old, "reason", a.Reason}} } func lookupID(name string, group bool) (int, error) { if n, err := strconv.Atoi(name); err == nil { return n, nil } if group { g, err := user.LookupGroup(name) if err != nil { return 0, fmt.Errorf("unknown group %q: %w", name, err) } return strconv.Atoi(g.Gid) } u, err := user.Lookup(name) if err != nil { return 0, fmt.Errorf("unknown user %q: %w", name, err) } return strconv.Atoi(u.Uid) }