go/fs.go
Snider 2dff772a40 feat: implement RFC plans 1-5 — Registry[T], Action/Task, Process, primitives
Plans 1-5 complete for core/go scope. 456 tests, 84.4% coverage, 100% AX-7 naming.

Critical bugs (Plan 1):
- P4-3+P7-3: ACTION broadcast calls all handlers with panic recovery
- P7-2+P7-4: RunE() with defer ServiceShutdown, Run() delegates
- P3-1: Startable/Stoppable return Result (breaking, clean)
- P9-1: Zero os/exec — App.Find() rewritten with os.Stat+PATH
- I3: Embed() removed, I15: New() comment fixed
- I9: CommandLifecycle removed → Command.Managed field

Registry[T] (Plan 2):
- Universal thread-safe named collection with 3 lock modes
- All 5 registries migrated: services, commands, drive, data, lock
- Insertion order preserved (fixes P4-1)
- c.RegistryOf("name") cross-cutting accessor

Action/Task system (Plan 3):
- Action type with Run()/Exists(), ActionHandler signature
- c.Action("name") dual-purpose accessor (register/invoke)
- TaskDef with Steps — sequential chain, async dispatch, previous-input piping
- Panic recovery on all Action execution
- broadcast() internal, ACTION() sugar

Process primitive (Plan 4):
- c.Process() returns Action sugar — Run/RunIn/RunWithEnv/Start/Kill/Exists
- No deps added — delegates to c.Action("process.*")
- Permission-by-registration: no handler = no capability

Missing primitives (Plan 5):
- core.ID() — atomic counter + crypto/rand suffix
- ValidateName() / SanitisePath() — reusable validation
- Fs.WriteAtomic() — write-to-temp-then-rename
- Fs.NewUnrestricted() / Fs.Root() — legitimate sandbox bypass
- AX-7: 456/456 tests renamed to TestFile_Function_{Good,Bad,Ugly}

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-25 15:18:25 +00:00

353 lines
8.6 KiB
Go

// Sandboxed local filesystem I/O for the Core framework.
package core
import (
"os"
"os/user"
"path/filepath"
"time"
)
// Fs is a sandboxed local filesystem backend.
type Fs struct {
root string
}
// New initialises an Fs with the given root directory.
// Root "/" means unrestricted access. Empty root defaults to "/".
//
// fs := (&core.Fs{}).New("/")
func (m *Fs) New(root string) *Fs {
if root == "" {
root = "/"
}
m.root = root
return m
}
// NewUnrestricted returns a new Fs with root "/", granting full filesystem access.
// Use this instead of unsafe.Pointer to bypass the sandbox.
//
// fs := c.Fs().NewUnrestricted()
// fs.Read("/etc/hostname") // works — no sandbox
func (m *Fs) NewUnrestricted() *Fs {
return (&Fs{}).New("/")
}
// Root returns the sandbox root path.
//
// root := c.Fs().Root() // e.g. "/home/agent/.core"
func (m *Fs) Root() string {
if m.root == "" {
return "/"
}
return m.root
}
// path sanitises and returns the full path.
// Absolute paths are sandboxed under root (unless root is "/").
// Empty root defaults to "/" — the zero value of Fs is usable.
func (m *Fs) path(p string) string {
root := m.root
if root == "" {
root = "/"
}
if p == "" {
return root
}
// If the path is relative and the medium is rooted at "/",
// treat it as relative to the current working directory.
// This makes io.Local behave more like the standard 'os' package.
if root == "/" && !filepath.IsAbs(p) {
cwd, _ := os.Getwd()
return filepath.Join(cwd, p)
}
// Use filepath.Clean with a leading slash to resolve all .. and . internally
// before joining with the root. This is a standard way to sandbox paths.
clean := filepath.Clean("/" + p)
// If root is "/", allow absolute paths through
if root == "/" {
return clean
}
// Strip leading "/" so Join works correctly with root
return filepath.Join(root, clean[1:])
}
// validatePath ensures the path is within the sandbox, following symlinks if they exist.
func (m *Fs) validatePath(p string) Result {
root := m.root
if root == "" {
root = "/"
}
if root == "/" {
return Result{m.path(p), true}
}
// Split the cleaned path into components
parts := Split(filepath.Clean("/"+p), string(os.PathSeparator))
current := root
for _, part := range parts {
if part == "" {
continue
}
next := filepath.Join(current, part)
realNext, err := filepath.EvalSymlinks(next)
if err != nil {
if os.IsNotExist(err) {
// Part doesn't exist, we can't follow symlinks anymore.
// Since the path is already Cleaned and current is safe,
// appending a component to current will not escape.
current = next
continue
}
return Result{err, false}
}
// Verify the resolved part is still within the root
rel, err := filepath.Rel(root, realNext)
if err != nil || HasPrefix(rel, "..") {
// Security event: sandbox escape attempt
username := "unknown"
if u, err := user.Current(); err == nil {
username = u.Username
}
Print(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s",
time.Now().Format(time.RFC3339), root, p, realNext, username)
if err == nil {
err = E("fs.validatePath", Concat("sandbox escape: ", p, " resolves outside ", m.root), nil)
}
return Result{err, false}
}
current = realNext
}
return Result{current, true}
}
// Read returns file contents as string.
func (m *Fs) Read(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
data, err := os.ReadFile(vp.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{string(data), true}
}
// Write saves content to file, creating parent directories as needed.
// Files are created with mode 0644. For sensitive files (keys, secrets),
// use WriteMode with 0600.
func (m *Fs) Write(p, content string) Result {
return m.WriteMode(p, content, 0644)
}
// WriteMode saves content to file with explicit permissions.
// Use 0600 for sensitive files (encryption output, private keys, auth hashes).
func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
if err := os.WriteFile(full, []byte(content), mode); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// WriteAtomic writes content by writing to a temp file then renaming.
// Rename is atomic on POSIX — concurrent readers never see a partial file.
// Use this for status files, config, or any file read from multiple goroutines.
//
// r := fs.WriteAtomic("/status.json", jsonData)
func (m *Fs) WriteAtomic(p, content string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
tmp := full + ".tmp." + shortRand()
if err := os.WriteFile(tmp, []byte(content), 0644); err != nil {
return Result{err, false}
}
if err := os.Rename(tmp, full); err != nil {
os.Remove(tmp)
return Result{err, false}
}
return Result{OK: true}
}
// EnsureDir creates directory if it doesn't exist.
func (m *Fs) EnsureDir(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
if err := os.MkdirAll(vp.Value.(string), 0755); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// IsDir returns true if path is a directory.
func (m *Fs) IsDir(p string) bool {
if p == "" {
return false
}
vp := m.validatePath(p)
if !vp.OK {
return false
}
info, err := os.Stat(vp.Value.(string))
return err == nil && info.IsDir()
}
// IsFile returns true if path is a regular file.
func (m *Fs) IsFile(p string) bool {
if p == "" {
return false
}
vp := m.validatePath(p)
if !vp.OK {
return false
}
info, err := os.Stat(vp.Value.(string))
return err == nil && info.Mode().IsRegular()
}
// Exists returns true if path exists.
func (m *Fs) Exists(p string) bool {
vp := m.validatePath(p)
if !vp.OK {
return false
}
_, err := os.Stat(vp.Value.(string))
return err == nil
}
// List returns directory entries.
func (m *Fs) List(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.New(os.ReadDir(vp.Value.(string)))
}
// Stat returns file info.
func (m *Fs) Stat(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.New(os.Stat(vp.Value.(string)))
}
// Open opens the named file for reading.
func (m *Fs) Open(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.New(os.Open(vp.Value.(string)))
}
// Create creates or truncates the named file.
func (m *Fs) Create(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
return Result{}.New(os.Create(full))
}
// Append opens the named file for appending, creating it if it doesn't exist.
func (m *Fs) Append(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
return Result{}.New(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644))
}
// ReadStream returns a reader for the file content.
func (m *Fs) ReadStream(path string) Result {
return m.Open(path)
}
// WriteStream returns a writer for the file content.
func (m *Fs) WriteStream(path string) Result {
return m.Create(path)
}
// Delete removes a file or empty directory.
func (m *Fs) Delete(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if full == "/" || full == os.Getenv("HOME") {
return Result{E("fs.Delete", Concat("refusing to delete protected path: ", full), nil), false}
}
if err := os.Remove(full); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// DeleteAll removes a file or directory recursively.
func (m *Fs) DeleteAll(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if full == "/" || full == os.Getenv("HOME") {
return Result{E("fs.DeleteAll", Concat("refusing to delete protected path: ", full), nil), false}
}
if err := os.RemoveAll(full); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// Rename moves a file or directory.
func (m *Fs) Rename(oldPath, newPath string) Result {
oldVp := m.validatePath(oldPath)
if !oldVp.OK {
return oldVp
}
newVp := m.validatePath(newPath)
if !newVp.OK {
return newVp
}
if err := os.Rename(oldVp.Value.(string), newVp.Value.(string)); err != nil {
return Result{err, false}
}
return Result{OK: true}
}