diff --git a/.objgit/hooks/receive-pack b/.objgit/hooks/receive-pack index 04c173b..5fc22df 100755 --- a/.objgit/hooks/receive-pack +++ b/.objgit/hooks/receive-pack @@ -8,8 +8,8 @@ # # Execution environment (see docs/usage/hooks.md): # * It runs in a kefka virtual shell, NOT a real OS shell. Only kefka's -# built-in commands are available (coreutils: cat, ls, echo, printf, head, -# tail, cut, sort, uniq, wc, sha256sum, grep, ...). There is no PATH to +# registered commands are available (cat, ls, printf, head, tail, cut, +# sort, uniq, wc, sha256sum, jo, jq, rg, ...). There is no PATH to # system binaries, no network, and no `git`. # * /src is a READ-ONLY checkout of the pushed commit. The working directory # starts here. diff --git a/cmd/objgitd/hooks.go b/cmd/objgitd/hooks.go index 31d23a3..b4d9d46 100644 --- a/cmd/objgitd/hooks.go +++ b/cmd/objgitd/hooks.go @@ -29,6 +29,8 @@ import ( "mvdan.cc/sh/v3/syntax" "tangled.org/xeiaso.net/kefka/command/registry" "tangled.org/xeiaso.net/kefka/command/registry/coreutils" + "tangled.org/xeiaso.net/kefka/command/registry/uutils" + "tangled.org/xeiaso.net/kefka/command/registry/wasmprog" ) // receivePackHook names the hook that a push runs, and so its script at @@ -280,6 +282,8 @@ func newHookShell(tree *object.Tree, changes hookChanges, env []string, stdin io reg := registry.New() coreutils.Register(reg) + wasmprog.Register(reg) + uutils.Register(reg) if err := reg.Chdir(fsys, "/src"); err != nil { return nil, fmt.Errorf("chdir /src: %w", err) } diff --git a/cmd/objgitd/hooks_test.go b/cmd/objgitd/hooks_test.go index f9ffb4d..872fda4 100644 --- a/cmd/objgitd/hooks_test.go +++ b/cmd/objgitd/hooks_test.go @@ -81,6 +81,11 @@ func TestReceivePackHook(t *testing.T) { // read-only error, so WROTE_SRC must never print. hook := strings.Join([]string{ "cat README.md", + "cd sub && cat note.txt", + "printf hi | base32", + `printf '{"value":42}' | jq -r '.value'`, + "cp /src/README.md /tmp/copied && cat /tmp/copied", + "if cp /src/README.md /src/copied; then echo WROTE_SRC_CP; fi", `echo "cwd=$PWD"`, "echo built > /tmp/out", "cat /tmp/out", @@ -88,6 +93,7 @@ func TestReceivePackHook(t *testing.T) { "echo WROTE_SRC", }, "\n") + "\n" writeFile(t, filepath.Join(work, "README.md"), "hello from repo\n") + writeFile(t, filepath.Join(work, "sub", "note.txt"), "from subdir\n") writeFile(t, filepath.Join(work, ".objgit", "hooks", "receive-pack"), hook) runGit(t, work, "add", ".") runGit(t, work, "commit", "-m", "with hook") @@ -102,7 +108,7 @@ func TestReceivePackHook(t *testing.T) { t.Fatalf("hook did not run; logs:\n%s", logs) } // /src is readable and /tmp is writable; their output streamed to the client. - for _, want := range []string{"hello from repo", "cwd=/src", "built"} { + for _, want := range []string{"hello from repo", "from subdir", "NBUQ====", "42", "cwd=/src", "built"} { if !strings.Contains(pushOut, want) { t.Errorf("push output missing streamed hook output %q; output:\n%s", want, pushOut) } diff --git a/docs/architecture/hooks.md b/docs/architecture/hooks.md index 6faba85..693dbba 100644 --- a/docs/architecture/hooks.md +++ b/docs/architecture/hooks.md @@ -57,7 +57,7 @@ carries its own hook. The script runs in a **kefka** virtual shell (`tangled.org/xeiaso.net/kefka`). kefka is _not_ an OS sandbox. It is an `mvdan.cc/sh` interpreter wired to a `billy.Filesystem`, plus a fixed registry -of commands, which here is coreutils only. +of Go coreutils, WASM programs, and WASM-backed uutils. The sandbox filesystem is an `internal/mountfs` composite of two mounts: @@ -71,6 +71,9 @@ A write outside `/tmp` fails, and a redirect into `/src` aborts the script. `internal/kefkash` vendors the unexported `billysh` handler wiring from kefka. Its `OpenHandler` is adapted to permit writes, so `/tmp` redirections work. The filesystem is what enforces the read-only `/src`. +`internal/mountfs` also provides read-only directory handles for the WASI +programs, which need to open `/` and directories below it to resolve paths. +Open files report their full mounted path so WASI can stat them after open. `newHookShell` builds this sandbox, and `hookEnv` builds its environment. `loadHookChanges` gives both of them the file changes of the update. diff --git a/docs/plans/kefka-uutils.md b/docs/plans/kefka-uutils.md new file mode 100644 index 0000000..eda564f --- /dev/null +++ b/docs/plans/kefka-uutils.md @@ -0,0 +1,31 @@ +# Update kefka and add uutils to the hook shell + +## Context + +`objgitd` pins kefka to a May 2026 commit and registers only its Go +coreutils. Upstream `v0.0.8` moves common +coreutils to a WASM-backed uutils registry and exposes WASM programs through +`wasmprog`. Hooks and SSH `sh` share +`newHookShell`, so that function is the registration point for both. + +## Changes + +1. Pin kefka to `v0.0.8` and update the module sums. +2. Register wasmprog and uutils after coreutils, following kefka's own CLI. + The uutils registry provides commands such as `cat` and `ls` and adds + `base32`; wasmprog provides `jo`, `jq`, `python3`, `qjs`, and `rg`. +3. Let `mountfs` open read-only directory handles. WASI needs them to + resolve paths beneath `/`, including `/src` and its subdirectories. Return + mount-qualified file names so WASI can stat opened files. +4. Exercise a uutils-only command, `jq`, relative paths after `cd`, and the + read-only `/src` and writable `/tmp` mounts in the existing push test. +5. Update the hook documentation to describe the available commands. + +## Verification + +Run `go build ./...`, the hook and SSH protocol tests, then `go test ./...`. + +The tag points to commit `e1236bb201d5013afcfd3b71ce7259e2d42c7830`. +The public Go module proxy initially returned 404, but Go's normal direct +fallback fetched the tag. `go mod download` passed with an empty module cache +and the `v0.0.8` checksums in `go.sum`. diff --git a/docs/usage/hooks.md b/docs/usage/hooks.md index 3d03456..18e5031 100644 --- a/docs/usage/hooks.md +++ b/docs/usage/hooks.md @@ -47,11 +47,12 @@ Hooks run in [kefka](https://xeiaso.net/blog/2026/dancing-mad-sandboxing/), a virtual `bash` interpreter. **This is not a container, VM, or OS sandbox.** It is safe because of what it _cannot_ reach, not because of kernel isolation: -- **No system binaries.** Only kefka's built-in commands exist — roughly the - POSIX coreutils: `cat`, `ls`, `echo`, `printf`, `head`, `tail`, `cut`, `sort`, - `uniq`, `wc`, `tr`, `grep`, `sha256sum`, `base64`, `mkdir`, `cp`, `mv`, `rm`, - `touch`, `date`, `sleep`, `seq`, `expr`, and so on. There is no `git`, no - package manager, no compiler, no `curl`. +- **No system binaries.** Kefka provides Go commands, WASM-backed uutils, and + the WASM programs `jo`, `jq`, `python3` (also `python`), `qjs`, and `rg`. Available + coreutils include `cat`, `ls`, `printf`, `head`, `tail`, `cut`, `sort`, + `uniq`, `wc`, `tr`, `sha256sum`, `base64`, `base32`, `mkdir`, `cp`, `mv`, + `rm`, `touch`, `date`, `sleep`, `seq`, and `expr`. There is no `git`, + package manager, compiler, or `curl`. - **No network.** - **No host filesystem.** The only files a hook can see are the two mounts below. @@ -218,5 +219,6 @@ Hook failure is logged, but it cannot undo the accepted push. - No writable working tree: `/src` is strictly read-only and `/tmp` is the only scratch space. - No way to reject a push from a hook (it runs after the fact). -- No system tooling, network, or arbitrary executables — only kefka built-ins. +- No system tooling, network, or arbitrary executables — only kefka's + registered commands. - Hook output reaches the pusher only when the client negotiated sideband. diff --git a/go.mod b/go.mod index d6e8466..a0885be 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 mvdan.cc/sh/v3 v3.13.1 - tangled.org/xeiaso.net/kefka v0.0.6-0.20260528192045-e0a84e40ceb8 + tangled.org/xeiaso.net/kefka v0.0.8 ) require ( @@ -60,13 +60,13 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect - golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.8 honnef.co/go/tools v0.7.0 // indirect diff --git a/go.sum b/go.sum index 3bee081..e09b6f7 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tigrisdata/storage-go v0.6.0 h1:1Rxzxp/GAUqwFln8qMXYOQIa47IsT2aBNuxYc55o6D4= github.com/tigrisdata/storage-go v0.6.0/go.mod h1:l3u7N9LDIhv4lfpkEBJYzolWJ/SBb6WiBexgy/uq6iQ= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -164,5 +166,5 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= -tangled.org/xeiaso.net/kefka v0.0.6-0.20260528192045-e0a84e40ceb8 h1:R008zBlWio3SXRZUaEDDDzzEf82X6e6KLvqhNddXprI= -tangled.org/xeiaso.net/kefka v0.0.6-0.20260528192045-e0a84e40ceb8/go.mod h1:p474Le4nfwuwnuAWowRhXMsTYfkpOMscchuaFvDa2zM= +tangled.org/xeiaso.net/kefka v0.0.8 h1:RbiOJn+b+zxa4gNVkW1mSIBj0Qjbcji2eo7wdBf5Gu8= +tangled.org/xeiaso.net/kefka v0.0.8/go.mod h1:h2ZITASpfUqC6PzLUfYTTO9AKPAft45UvCyxe/LAN2k= diff --git a/internal/mountfs/mountfs.go b/internal/mountfs/mountfs.go index c854f96..0a4c272 100644 --- a/internal/mountfs/mountfs.go +++ b/internal/mountfs/mountfs.go @@ -13,6 +13,7 @@ import ( "path" "sort" "strings" + "syscall" "time" "github.com/go-git/go-billy/v6" @@ -69,11 +70,62 @@ func (f *FS) OpenFile(filename string, flag int, perm fs.FileMode) (billy.File, return nil, pathErr("open", filename, err) } if isRoot || isMount { - return nil, pathErr("open", filename, billy.ErrNotSupported) + return f.openDir(filename, flag) } - return sub.OpenFile(rel, flag, perm) + file, err := sub.OpenFile(rel, flag, perm) + if err == nil { + // The mounted filesystem may return only the basename from Name(). + // WASI uses it to stat the open file against this composite filesystem. + return namedFile{File: file, name: path.Clean("/" + filename)}, nil + } + if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_APPEND|os.O_TRUNC) != 0 { + return nil, err + } + // Some mounted filesystems cannot open directories. WASI needs directory + // handles to resolve paths beneath the preopened root. + if info, statErr := sub.Stat(rel); statErr == nil && info.IsDir() { + return f.openDir(filename, flag) + } + return nil, err +} + +type namedFile struct { + billy.File + name string } +func (f namedFile) Name() string { return f.name } + +func (f *FS) openDir(filename string, flag int) (billy.File, error) { + if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_APPEND|os.O_TRUNC) != 0 { + return nil, pathErr("open", filename, billy.ErrReadOnly) + } + return &dirFile{fsys: f, name: path.Clean("/" + filename)}, nil +} + +// dirFile is a read-only handle for a virtual or mounted directory. The WASI +// adapter uses its name to stat and list entries through the composite FS. +type dirFile struct { + fsys *FS + name string +} + +func (d *dirFile) Name() string { return d.name } +func (d *dirFile) Stat() (fs.FileInfo, error) { return d.fsys.Stat(d.name) } +func (d *dirFile) Close() error { return nil } +func (d *dirFile) Read([]byte) (int, error) { return 0, syscall.EISDIR } +func (d *dirFile) ReadAt([]byte, int64) (int, error) { + return 0, syscall.EISDIR +} +func (d *dirFile) Write([]byte) (int, error) { return 0, billy.ErrReadOnly } +func (d *dirFile) WriteAt([]byte, int64) (int, error) { + return 0, billy.ErrReadOnly +} +func (d *dirFile) Seek(int64, int) (int64, error) { return 0, syscall.EISDIR } +func (d *dirFile) Truncate(int64) error { return billy.ErrReadOnly } + +var _ billy.File = (*dirFile)(nil) + func (f *FS) Stat(filename string) (fs.FileInfo, error) { sub, rel, isRoot, isMount, err := f.route(filename) if err != nil {