974 lines
29 KiB
Go
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)
|
|
}
|