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

974 lines
29 KiB
Go

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