diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..b739b565 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267f..4f24565f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -20,8 +20,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write id-token: write actions: read # Required for Claude to read CI results on PRs steps: diff --git a/Dockerfile b/Dockerfile index fbbf5d37..563d0d3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,9 @@ RUN set -eux; \ if [ -n "$TAGS" ]; then TAGS="-tags $TAGS"; fi; \ CGO_ENABLED=0 GOOS=linux \ go build -ldflags="-s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=${VERSION}" \ - ${TAGS} -o /out/goclaw . + ${TAGS} -o /out/goclaw . && \ + CGO_ENABLED=0 GOOS=linux \ + go build -ldflags="-s -w" -o /out/pkg-helper ./cmd/pkg-helper # ── Stage 2: Runtime ── FROM alpine:3.22 @@ -45,21 +47,19 @@ ARG ENABLE_FULL_SKILLS=false # ENABLE_FULL_SKILLS=true pre-installs all skill deps (larger image, no on-demand install needed). # Otherwise, skill packages are installed on-demand via the admin UI. RUN set -eux; \ - apk add --no-cache ca-certificates wget; \ + apk add --no-cache ca-certificates wget su-exec; \ if [ "$ENABLE_SANDBOX" = "true" ]; then \ apk add --no-cache docker-cli; \ fi; \ if [ "$ENABLE_FULL_SKILLS" = "true" ]; then \ - apk add --no-cache python3 py3-pip nodejs npm pandoc github-cli doas; \ - echo "permit nopass goclaw as root cmd apk" > /etc/doas.d/goclaw.conf; \ + apk add --no-cache python3 py3-pip nodejs npm pandoc github-cli; \ pip3 install --no-cache-dir --break-system-packages \ pypdf openpyxl pandas python-pptx markitdown defusedxml lxml; \ npm install -g --cache /tmp/npm-cache docx pptxgenjs; \ rm -rf /tmp/npm-cache /root/.cache /var/cache/apk/*; \ else \ if [ "$ENABLE_PYTHON" = "true" ]; then \ - apk add --no-cache python3 py3-pip doas; \ - echo "permit nopass goclaw as root cmd apk" > /etc/doas.d/goclaw.conf; \ + apk add --no-cache python3 py3-pip; \ fi; \ if [ "$ENABLE_NODE" = "true" ]; then \ apk add --no-cache nodejs npm; \ @@ -72,14 +72,18 @@ WORKDIR /app # Copy binary, migrations, and bundled skills COPY --from=builder /out/goclaw /app/goclaw +COPY --from=builder /out/pkg-helper /app/pkg-helper COPY --from=builder /src/migrations/ /app/migrations/ COPY --from=builder /src/skills/ /app/bundled-skills/ COPY docker-entrypoint.sh /app/docker-entrypoint.sh -RUN chmod +x /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh && \ + chmod 755 /app/pkg-helper && chown root:root /app/pkg-helper -# Create data directories (owned by goclaw user) +# Create data directories (owned by goclaw user). +# Binaries and entrypoint stay root-owned (readable by all). RUN mkdir -p /app/workspace /app/data /app/skills /app/tsnet-state /app/.goclaw \ - && chown -R goclaw:goclaw /app + && chown -R goclaw:goclaw /app/workspace /app/data /app/skills /app/tsnet-state /app/.goclaw \ + && chown goclaw:goclaw /app/bundled-skills # Default environment ENV GOCLAW_CONFIG=/app/config.json \ @@ -90,7 +94,8 @@ ENV GOCLAW_CONFIG=/app/config.json \ GOCLAW_HOST=0.0.0.0 \ GOCLAW_PORT=18790 -USER goclaw +# Entrypoint runs as root to install persisted packages and start pkg-helper, +# then drops to goclaw user via su-exec before starting the app. EXPOSE 18790 diff --git a/cmd/gateway.go b/cmd/gateway.go index af3d7e37..0d0562a8 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -344,6 +344,9 @@ func runGateway() { server.SetUsageHandler(httpapi.NewUsageHandler(pgStores.Snapshots, pgStores.DB, cfg.Gateway.Token)) } + // Runtime package management (install/uninstall system/pip/npm packages) + server.SetPackagesHandler(httpapi.NewPackagesHandler(cfg.Gateway.Token)) + // API key management // API documentation (OpenAPI spec + Swagger UI at /docs) server.SetDocsHandler(httpapi.NewDocsHandler(cfg.Gateway.Token)) diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 370a316c..5904edd5 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -366,7 +366,7 @@ func registerACPFromConfig(registry *providers.Registry, cfg config.ACPConfig) { opts = append(opts, providers.WithACPPermMode(cfg.PermMode)) } registry.Register(providers.NewACPProvider( - cfg.Binary, cfg.Args, workDir, idleTTL, tools.DefaultDenyPatterns, opts..., + cfg.Binary, cfg.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), opts..., )) slog.Info("registered provider", "name", "acp", "binary", cfg.Binary) } @@ -409,7 +409,7 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) { workDir = defaultACPWorkDir() } registry.Register(providers.NewACPProvider( - binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns, + binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), providers.WithACPModel(p.Name), )) slog.Info("registered provider from DB", "name", p.Name, "type", "acp") diff --git a/cmd/pkg-helper/main.go b/cmd/pkg-helper/main.go new file mode 100644 index 00000000..43e38d12 --- /dev/null +++ b/cmd/pkg-helper/main.go @@ -0,0 +1,225 @@ +// pkg-helper is a root-privileged helper that listens on a Unix socket +// and executes apk add/del commands on behalf of the non-root app process. +// It is started by docker-entrypoint.sh before dropping privileges. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log/slog" + "net" + "os" + "os/exec" + "os/signal" + "regexp" + "strings" + "syscall" + "time" +) + +const socketPath = "/tmp/pkg.sock" + +// validPkgName allows alphanumeric, hyphens, underscores, dots, @, / (scoped npm). +// Rejects names starting with - to prevent argument injection. +var validPkgName = regexp.MustCompile(`^[a-zA-Z0-9@][a-zA-Z0-9._+\-/@]*$`) + +type request struct { + Action string `json:"action"` + Package string `json:"package"` +} + +type response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +func main() { + slog.Info("pkg-helper: starting", "socket", socketPath) + + // Remove stale socket. + os.Remove(socketPath) + + // Restrictive umask: socket created as 0660 (not default 0777). + oldMask := syscall.Umask(0117) + listener, err := net.Listen("unix", socketPath) + syscall.Umask(oldMask) + if err != nil { + slog.Error("pkg-helper: listen failed", "error", err) + os.Exit(1) + } + defer listener.Close() + + // Socket permissions: owner root, group goclaw (gid 1000), mode 0660. + // Chown requires CAP_CHOWN; if missing (misconfigured container), warn but continue + // since umask already set restrictive permissions. + if os.Getuid() == 0 { + if err := os.Chown(socketPath, 0, 1000); err != nil { + slog.Warn("pkg-helper: chown socket failed (missing CAP_CHOWN?)", "error", err) + } + } + if err := os.Chmod(socketPath, 0660); err != nil { + slog.Warn("pkg-helper: chmod socket failed", "error", err) + } + + // Graceful shutdown on SIGTERM/SIGINT. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sigCh + slog.Info("pkg-helper: shutting down") + listener.Close() + os.Remove(socketPath) + os.Exit(0) + }() + + const maxConns = 3 + sem := make(chan struct{}, maxConns) + + slog.Info("pkg-helper: ready") + + for { + conn, err := listener.Accept() + if err != nil { + break + } + select { + case sem <- struct{}{}: + go func(c net.Conn) { + defer func() { <-sem }() + c.SetDeadline(time.Now().Add(30 * time.Second)) //nolint:errcheck + handleConn(c) + }(conn) + default: + slog.Warn("pkg-helper: connection limit reached, rejecting") + conn.Close() + } + } +} + +func handleConn(conn net.Conn) { + defer conn.Close() + + scanner := bufio.NewScanner(conn) + encoder := json.NewEncoder(conn) + + for scanner.Scan() { + var req request + if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { + encoder.Encode(response{Error: "invalid json"}) //nolint:errcheck + continue + } + + resp := handleRequest(req) + encoder.Encode(resp) //nolint:errcheck + } +} + +func handleRequest(req request) response { + pkg := req.Package + if pkg == "" { + return response{Error: "package required"} + } + if !validPkgName.MatchString(pkg) { + return response{Error: "invalid package name"} + } + + switch req.Action { + case "install": + return doInstall(pkg) + case "uninstall": + return doUninstall(pkg) + default: + return response{Error: fmt.Sprintf("unknown action: %s", req.Action)} + } +} + +func doInstall(pkg string) response { + slog.Info("pkg-helper: installing", "package", pkg) + + cmd := exec.Command("apk", "add", "--no-cache", pkg) + out, err := cmd.CombinedOutput() + if err != nil { + msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) + slog.Error("pkg-helper: install failed", "package", pkg, "error", msg) + return response{Error: msg} + } + + persistAdd(pkg) + slog.Info("pkg-helper: installed", "package", pkg) + return response{OK: true} +} + +func doUninstall(pkg string) response { + slog.Info("pkg-helper: uninstalling", "package", pkg) + + cmd := exec.Command("apk", "del", pkg) + out, err := cmd.CombinedOutput() + if err != nil { + msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) + slog.Error("pkg-helper: uninstall failed", "package", pkg, "error", msg) + return response{Error: msg} + } + + persistRemove(pkg) + slog.Info("pkg-helper: uninstalled", "package", pkg) + return response{OK: true} +} + +// persistAdd appends a package name to the apk persist file (dedup check). +func persistAdd(pkg string) { + listFile := apkListFile() + + // Check if already persisted (avoid duplicates). + if data, err := os.ReadFile(listFile); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == pkg { + return // already persisted + } + } + } + + f, err := os.OpenFile(listFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) + if err != nil { + slog.Warn("pkg-helper: persist add failed", "error", err) + return + } + defer f.Close() + fmt.Fprintln(f, pkg) +} + +// persistRemove removes a package name from the apk persist file. +// Uses write-to-temp-then-rename for atomic update (avoids truncation on disk-full). +func persistRemove(pkg string) { + listFile := apkListFile() + data, err := os.ReadFile(listFile) + if err != nil { + return + } + + var kept []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line != "" && line != pkg { + kept = append(kept, line) + } + } + + tmpFile := listFile + ".tmp" + if err := os.WriteFile(tmpFile, []byte(strings.Join(kept, "\n")+"\n"), 0640); err != nil { + slog.Warn("pkg-helper: persist remove write failed", "error", err) + return + } + if err := os.Rename(tmpFile, listFile); err != nil { + slog.Warn("pkg-helper: persist remove rename failed", "error", err) + os.Remove(tmpFile) //nolint:errcheck + } +} + +func apkListFile() string { + runtimeDir := os.Getenv("RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = "/app/data/.runtime" + } + return runtimeDir + "/apk-packages" +} diff --git a/cmd/pkg-helper/main_test.go b/cmd/pkg-helper/main_test.go new file mode 100644 index 00000000..99bdaac8 --- /dev/null +++ b/cmd/pkg-helper/main_test.go @@ -0,0 +1,431 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// TestHandleRequest tests the request validation logic. +// Note: Command execution tests are not included here since apk is not available +// in unit test environments. Integration tests would handle actual execution. +func TestHandleRequest(t *testing.T) { + tests := []struct { + name string + req request + wantValidErr bool + errContains string + }{ + // Validation error cases + { + name: "missing package", + req: request{Action: "install", Package: ""}, + wantValidErr: true, + errContains: "package required", + }, + { + name: "invalid package name (starts with hyphen)", + req: request{Action: "install", Package: "-malicious"}, + wantValidErr: true, + errContains: "invalid package name", + }, + { + name: "invalid package name (contains semicolon)", + req: request{Action: "install", Package: "pkg; rm -rf"}, + wantValidErr: true, + errContains: "invalid package name", + }, + { + name: "invalid package name (contains space)", + req: request{Action: "install", Package: "pkg name"}, + wantValidErr: true, + errContains: "invalid package name", + }, + { + name: "unknown action", + req: request{Action: "unknown", Package: "curl"}, + wantValidErr: true, + errContains: "unknown action", + }, + // Validation pass cases (may fail at exec stage, but validation passes) + { + name: "valid install", + req: request{Action: "install", Package: "curl"}, + wantValidErr: false, + errContains: "", // Validation passed (no validation error) + }, + { + name: "valid uninstall", + req: request{Action: "uninstall", Package: "git"}, + wantValidErr: false, + errContains: "", + }, + { + name: "valid scoped npm package", + req: request{Action: "install", Package: "@scope/pkg"}, + wantValidErr: false, + errContains: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := handleRequest(tt.req) + + hasValidationErr := contains(resp.Error, "package required") || + contains(resp.Error, "invalid package name") || + contains(resp.Error, "unknown action") + + if hasValidationErr != tt.wantValidErr { + t.Errorf("validation error = %v, want %v (error: %q)", hasValidationErr, tt.wantValidErr, resp.Error) + } + + if tt.wantValidErr && tt.errContains != "" && !contains(resp.Error, tt.errContains) { + t.Errorf("error = %q, want to contain %q", resp.Error, tt.errContains) + } + }) + } +} + +// contains checks if a string contains a substring. +func contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// TestValidPkgName tests the package name validation regex. +func TestValidPkgName(t *testing.T) { + tests := []struct { + name string + valid bool + }{ + // Valid package names + {"github-cli", true}, + {"curl", true}, + {"python3", true}, + {"my_package", true}, + {"package.name", true}, + {"c++", true}, + {"@scope/pkg", true}, + {"pkg123", true}, + {"a", true}, + {"A", true}, + {"0abc", true}, // can start with number + {"abc123def", true}, + {"pkg-with-hyphens", true}, + {"pkg_with_underscores", true}, + {"pkg.with.dots", true}, + // Invalid package names + {"-invalid", false}, // starts with hyphen + {"--flag", false}, // starts with hyphen + {"pkg name", false}, // contains space + {"pkg;cmd", false}, // contains semicolon + {"pkg|cmd", false}, // contains pipe + {"pkg&cmd", false}, // contains ampersand + {"pkg`cmd`", false}, // contains backtick + {"pkg$var", false}, // contains dollar sign + {"pkgfile", false}, // contains angle bracket + {"pkg'quote", false}, // contains quote + {"pkg\"quote", false}, // contains quote + {"pkg(paren)", false}, // contains parens + {"", false}, // empty + {" curl", false}, // starts with space + {"curl ", false}, // ends with space + {"--index-url=evil", false}, // flag pattern + {"-u", false}, // short flag + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validPkgName.MatchString(tt.name) + if got != tt.valid { + t.Errorf("validPkgName.MatchString(%q) = %v, want %v", tt.name, got, tt.valid) + } + }) + } +} + +// TestHandleRequest_AllActionsValidated tests both install and uninstall actions. +func TestHandleRequest_AllActionsValidated(t *testing.T) { + tests := []struct { + action string + }{ + {"install"}, + {"uninstall"}, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + resp := handleRequest(request{ + Action: tt.action, + Package: "valid-package", + }) + + // Validation should pass (no "invalid package" or "package required" error) + if contains(resp.Error, "package required") || contains(resp.Error, "invalid package name") { + t.Errorf("action %q validation failed: %q", tt.action, resp.Error) + } + }) + } +} + +// TestHandleRequest_EmptyActionFails tests that empty action is rejected. +func TestHandleRequest_EmptyActionFails(t *testing.T) { + resp := handleRequest(request{ + Action: "", + Package: "curl", + }) + + if resp.Error == "" { + t.Error("empty action should fail validation") + } + if !contains(resp.Error, "unknown action") { + t.Errorf("error = %q, want to contain 'unknown action'", resp.Error) + } +} + +// TestHandleRequest_PackageValidationCatchesInjection tests shell injection attempts. +func TestHandleRequest_PackageValidationCatchesInjection(t *testing.T) { + injections := []string{ + "-malicious", + "pkg; rm -rf /", + "pkg && evil", + "pkg || evil", + "pkg | evil", + "pkg`evil`", + "pkg$(evil)", + "pkg\nevil", + "--allow-untrusted", + "--key=value", + } + + for _, inj := range injections { + t.Run(inj, func(t *testing.T) { + resp := handleRequest(request{ + Action: "install", + Package: inj, + }) + + if resp.OK || resp.Error == "" { + t.Errorf("injection %q should be rejected, got OK=%v err=%q", inj, resp.OK, resp.Error) + } + }) + } +} + +// TestRequest_JSON tests request struct JSON unmarshaling. +func TestRequest_JSON(t *testing.T) { + tests := []struct { + name string + json string + want request + wantErr bool + }{ + { + name: "valid request", + json: `{"action":"install","package":"curl"}`, + want: request{Action: "install", Package: "curl"}, + wantErr: false, + }, + { + name: "empty action", + json: `{"action":"","package":"curl"}`, + want: request{Action: "", Package: "curl"}, + wantErr: false, // JSON parsing succeeds, validation fails later + }, + { + name: "empty package", + json: `{"action":"install","package":""}`, + want: request{Action: "install", Package: ""}, + wantErr: false, // JSON parsing succeeds, validation fails later + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req request + err := unmarshalRequest(tt.json, &req) + + if (err != nil) != tt.wantErr { + t.Errorf("unmarshalRequest() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && (req.Action != tt.want.Action || req.Package != tt.want.Package) { + t.Errorf("unmarshalRequest() = %+v, want %+v", req, tt.want) + } + }) + } +} + +// unmarshalRequest is a helper for testing JSON unmarshaling. +func unmarshalRequest(jsonStr string, req *request) error { + return json.Unmarshal([]byte(jsonStr), req) +} + +// TestResponse_JSON tests response struct JSON marshaling. +func TestResponse_JSON(t *testing.T) { + tests := []struct { + name string + resp response + wantOK bool + wantErr string + omitErr bool + }{ + { + name: "success response", + resp: response{OK: true}, + wantOK: true, + wantErr: "", + omitErr: true, + }, + { + name: "error response", + resp: response{OK: false, Error: "package not found"}, + wantOK: false, + wantErr: "package not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := marshalResponse(tt.resp) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded response + if err := unmarshalResponse(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.OK != tt.wantOK { + t.Errorf("OK = %v, want %v", decoded.OK, tt.wantOK) + } + if decoded.Error != tt.wantErr { + t.Errorf("Error = %q, want %q", decoded.Error, tt.wantErr) + } + }) + } +} + +// marshalResponse marshals a response (for testing). +func marshalResponse(resp response) ([]byte, error) { + return json.Marshal(resp) +} + +// unmarshalResponse unmarshals a response (for testing). +func unmarshalResponse(data []byte, resp *response) error { + return json.Unmarshal(data, resp) +} + +// TestValidPkgNameRegex_Compliance tests compliance with validation rules. +func TestValidPkgNameRegex_Compliance(t *testing.T) { + // Test that regex enforces security constraints + tests := []struct { + name string + isValid bool + riskLevel string + }{ + // Safe names + {"curl", true, ""}, + {"github-cli", true, ""}, + {"python3", true, ""}, + {"@scope/pkg", true, ""}, + + // Attack patterns that MUST be rejected + {"-flag", false, "flag injection"}, + {"--option=value", false, "option injection"}, + {"pkg; cmd", false, "command injection"}, + {"pkg && cmd", false, "command injection"}, + {"pkg || cmd", false, "command injection"}, + {"pkg | cmd", false, "pipe injection"}, + {"pkg`cmd`", false, "command substitution"}, + {"pkg$(cmd)", false, "command substitution"}, + {"pkg${var}", false, "variable injection"}, + {"pkg'x'", false, "quote breaking"}, + {"pkg\"x\"", false, "quote breaking"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validPkgName.MatchString(tt.name) + if got != tt.isValid { + t.Errorf("validPkgName.MatchString(%q) = %v, want %v (risk: %s)", tt.name, got, tt.isValid, tt.riskLevel) + } + }) + } +} + +// TestHandleRequest_ErrorMessages tests that error messages are clear. +func TestHandleRequest_ErrorMessages(t *testing.T) { + tests := []struct { + name string + req request + wantErrText string + }{ + { + name: "missing package error text", + req: request{Action: "install", Package: ""}, + wantErrText: "package required", + }, + { + name: "invalid package error text", + req: request{Action: "install", Package: "-bad"}, + wantErrText: "invalid package name", + }, + { + name: "unknown action error text", + req: request{Action: "rebuild", Package: "curl"}, + wantErrText: "unknown action", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := handleRequest(tt.req) + if resp.Error == "" { + t.Errorf("handleRequest() error = empty, want %q", tt.wantErrText) + } + if !contains(resp.Error, tt.wantErrText) { + t.Errorf("handleRequest() error = %q, want to contain %q", resp.Error, tt.wantErrText) + } + }) + } +} + +// TestHandleRequest_SuccessPath tests that valid requests pass validation. +// Note: Actual apk command execution will fail in test environment (no apk available), +// but validation should pass. +func TestHandleRequest_SuccessPath(t *testing.T) { + tests := []struct { + action string + pkg string + }{ + {"install", "curl"}, + {"uninstall", "git"}, + {"install", "github-cli"}, + {"uninstall", "openssl"}, + } + + for _, tt := range tests { + t.Run(tt.action+"-"+tt.pkg, func(t *testing.T) { + resp := handleRequest(request{ + Action: tt.action, + Package: tt.pkg, + }) + + // Should not fail validation (may fail at exec stage without real apk) + // We're testing that validation logic passes, not that apk exists + validationErrors := []string{"package required", "invalid package name", "unknown action"} + for _, validErr := range validationErrors { + if contains(resp.Error, validErr) { + t.Errorf("handleRequest(%q, %q) failed validation: %q", tt.action, tt.pkg, resp.Error) + } + } + }) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ddfd9ff1..1d11eafe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,9 +40,13 @@ services: - goclaw-workspace:/app/workspace security_opt: - no-new-privileges:true + init: true cap_drop: - ALL - read_only: true + cap_add: + - SETUID + - SETGID + - CHOWN tmpfs: - /tmp:rw,noexec,nosuid,size=256m deploy: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 16e98509..6f14d58e 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -19,31 +19,87 @@ export NPM_CONFIG_PREFIX="$RUNTIME_DIR/npm-global" export NODE_PATH="/usr/local/lib/node_modules:$RUNTIME_DIR/npm-global/lib/node_modules:${NODE_PATH:-}" export PATH="$RUNTIME_DIR/npm-global/bin:$RUNTIME_DIR/pip/bin:$PATH" +# System packages: re-install on-demand packages persisted across recreates. +# In Docker: entrypoint runs as root (then drops via su-exec). +# Outside Docker: may run as non-root — skip privileged operations gracefully. +APK_LIST="$RUNTIME_DIR/apk-packages" +touch "$APK_LIST" 2>/dev/null || true +if [ "$(id -u)" = "0" ]; then + chown root:goclaw "$APK_LIST" 2>/dev/null || true + chmod 0640 "$APK_LIST" 2>/dev/null || true +fi +if [ -f "$APK_LIST" ] && [ -s "$APK_LIST" ]; then + echo "Re-installing persisted system packages..." + VALID_PKGS="" + while IFS= read -r pkg || [ -n "$pkg" ]; do + pkg="$(printf '%s' "$pkg" | tr -d '[:space:]')" + case "$pkg" in + [a-zA-Z0-9@]*) VALID_PKGS="$VALID_PKGS $pkg" ;; + "") ;; + *) echo "WARNING: skipping invalid package: $pkg" ;; + esac + done < "$APK_LIST" + if [ -n "$VALID_PKGS" ]; then + # shellcheck disable=SC2086 + apk add --no-cache $VALID_PKGS 2>/dev/null || \ + echo "Warning: some packages failed to install" + fi +fi + +# Start the root-privileged package helper (listens on /tmp/pkg.sock). +# Only in Docker (running as root). Outside Docker, pkg-helper is not available. +if [ -x /app/pkg-helper ] && [ "$(id -u)" = "0" ]; then + /app/pkg-helper & + PKG_PID=$! + for _i in 1 2 3 4; do + [ -S /tmp/pkg.sock ] && break + sleep 0.5 + done + if ! [ -S /tmp/pkg.sock ]; then + echo "ERROR: pkg-helper failed to start (PID $PKG_PID)" + kill "$PKG_PID" 2>/dev/null || true + fi +fi + +# Run command with privilege drop (su-exec in Docker, direct otherwise). +run_as_goclaw() { + if command -v su-exec >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then + exec su-exec goclaw "$@" + else + exec "$@" + fi +} + case "${1:-serve}" in serve) # Auto-upgrade (schema migrations + data hooks) before starting. if [ -n "$GOCLAW_POSTGRES_DSN" ]; then echo "Running database upgrade..." - /app/goclaw upgrade || \ - echo "Upgrade warning (may already be up-to-date)" + if command -v su-exec >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then + su-exec goclaw /app/goclaw upgrade || \ + echo "Upgrade warning (may already be up-to-date)" + else + /app/goclaw upgrade || \ + echo "Upgrade warning (may already be up-to-date)" + fi fi - exec /app/goclaw + run_as_goclaw /app/goclaw ;; upgrade) shift - exec /app/goclaw upgrade "$@" + run_as_goclaw /app/goclaw upgrade "$@" ;; migrate) shift - exec /app/goclaw migrate "$@" + run_as_goclaw /app/goclaw migrate "$@" ;; onboard) - exec /app/goclaw onboard + run_as_goclaw /app/goclaw onboard ;; version) - exec /app/goclaw version + run_as_goclaw /app/goclaw version ;; *) - exec /app/goclaw "$@" + run_as_goclaw /app/goclaw "$@" ;; esac diff --git a/docs/09-security.md b/docs/09-security.md index e8ca95c0..7a268889 100644 --- a/docs/09-security.md +++ b/docs/09-security.md @@ -131,6 +131,15 @@ All four filesystem tools (`read_file`, `write_file`, `list_files`, `edit`) impl The workspace is injected into tools via `WithToolWorkspace(ctx)` context injection. Tools read the workspace from context at execution time (fallback to the struct field for backward compatibility). User IDs are sanitized: anything outside `[a-zA-Z0-9_-]` becomes an underscore (`group:telegram:-1001234` → `group_telegram_-1001234`). +**Privilege separation for package management** -- System packages (apk) are installed via root-privileged helper: + +| Component | User | Scope | Socket | +|-----------|------|-------|--------| +| Main app | goclaw (1000) | All operations except system packages | N/A | +| pkg-helper | root | System package (apk) install/uninstall only | `/tmp/pkg.sock` (0660 root:goclaw) | + +The pkg-helper is started in `docker-entrypoint.sh` *before* privileges are dropped to goclaw. The main app connects to the Unix socket to request apk operations. System packages are persisted to `/app/data/.runtime/apk-packages` so they survive container recreation. Python and npm packages are installed directly by the goclaw user to writable runtime directories (`$PIP_TARGET`, `$NPM_CONFIG_PREFIX`). + **Docker sandbox** -- Container-based isolation for shell command execution: | Hardening | Configuration | @@ -148,7 +157,53 @@ The workspace is injected into tools via `WithToolWorkspace(ctx)` context inject --- -## 2. Encryption +## 2. Docker Entrypoint & Runtime Configuration + +GoClaw runs in a non-root container with three privilege levels: + +**Phase 1: Root (docker-entrypoint.sh)** +- Re-install persisted system packages from `/app/data/.runtime/apk-packages` +- Start `pkg-helper` (root-privileged service listening on `/tmp/pkg.sock`) +- Set up Python and Node.js runtime directories with proper env vars + +**Phase 2: Drop to goclaw user (su-exec)** +- Main app runs as `goclaw` (UID 1000) via `su-exec goclaw /app/goclaw` +- All agent operations execute in this context +- System package requests are delegated to pkg-helper via Unix socket + +**Phase 3: Optional sandbox (per-agent)** +- Exec operations can be sandboxed in Docker containers (configurable) +- Sandbox containers inherit resource limits and security options + +### Docker Compose Security + +| Config | Purpose | +|--------|---------| +| `cap_drop: ALL` | Remove all Linux capabilities | +| `cap_add: [SETUID, SETGID, CHOWN, DAC_OVERRIDE]` | Minimum required for su-exec and pkg-helper socket | +| `security_opt: no-new-privileges:true` | Prevent privilege escalation | +| `tmpfs: /tmp` | Writable /tmp (256MB, noexec, nosuid) | + +Docker-compose.yml mounts data volume at `/app/data`, which contains: +- config.json (runtime configuration) +- .runtime/apk-packages (persisted system packages list) +- .runtime/pip (pip install target directory) +- .runtime/npm-global (npm install prefix) +- skills/ (uploaded skills) + +### Runtime Directory Structure + +| Path | Owner | Purpose | +|------|-------|---------| +| `/app/data/.runtime/apk-packages` | 0666 (rw-rw-rw-) | Persisted apk package list, written by pkg-helper | +| `/app/data/.runtime/pip` | goclaw | Python packages installed via pip install --target | +| `/app/data/.runtime/npm-global` | goclaw | npm packages installed globally to prefix | +| `/app/data/.runtime/pip-cache` | goclaw | pip cache directory | +| `/tmp/pkg.sock` | 0660 (rw-rw----) | Unix socket: owner root, group goclaw | + +--- + +## 3. Encryption AES-256-GCM encryption for secrets stored in PostgreSQL. Key provided via `GOCLAW_ENCRYPTION_KEY` environment variable. @@ -164,7 +219,7 @@ Backward compatible: values without the `aes-gcm:` prefix are returned as plaint --- -## 3. Rate Limiting -- Gateway + Tool +## 4. Rate Limiting -- Gateway + Tool Protection at two levels: gateway-wide (per user/IP) and tool-level (per session). @@ -194,7 +249,7 @@ Gateway rate limiting applies to both WebSocket (`chat.send`) and HTTP (`/v1/cha --- -## 4. RBAC -- 3 Roles +## 5. RBAC -- 3 Roles Role-based access control for WebSocket RPC methods and HTTP API endpoints. Roles are hierarchical: higher levels include all permissions of lower levels. @@ -225,7 +280,7 @@ Token-based role assignment happens during the WebSocket `connect` handshake. Sc --- -## 5. Sandbox -- Container Lifecycle +## 6. Sandbox -- Container Lifecycle Docker-based code isolation for shell command execution. @@ -286,7 +341,7 @@ flowchart TD --- -## 6. API Key Security +## 7. API Key Security API keys are generated and stored securely. @@ -300,7 +355,7 @@ API keys are generated and stored securely. --- -## 7. Security Logging Convention +## 8. Security Logging Convention All security events use `slog.Warn` with a `security.*` prefix for consistent filtering and alerting. @@ -316,7 +371,23 @@ Filter all security events by grepping for the `security.` prefix in log output. --- -## 9. Group File Writer Restrictions +## 9. Hook Recursion Prevention + +The hook system (quality gates) can trigger infinite recursion: an agent evaluator delegates to a reviewer → delegation completes → fires quality gate → delegates to reviewer again → infinite loop. + +A context flag `hooks.WithSkipHooks(ctx, true)` prevents this. Three injection points set the flag: + +| Injection Point | Why | +|----------------|-----| +| Agent evaluator | Delegating to the reviewer for quality checks must not re-trigger gates | +| Evaluate-optimize loop | All internal generator/evaluator delegations skip gates | +| Agent eval callback (cmd layer) | When the hook engine itself triggers delegation | + +`DelegateManager.Delegate()` checks `hooks.SkipHooksFromContext(ctx)` before applying quality gates. If the flag is set, gates are skipped entirely. + +--- + +## 10. Group File Writer Restrictions In group chats (Telegram), write-sensitive operations are restricted to designated writers. This prevents unauthorized users from modifying agent files or resetting sessions in shared groups. @@ -348,7 +419,7 @@ Writers are managed via `/addwriter` (reply to a user's message) and `/removewri --- -## 10. Browser Pairing Security +## 11. Browser Pairing Security Browser pairing allows web UI clients to authenticate without full admin credentials. @@ -364,7 +435,7 @@ Browser pairing allows web UI clients to authenticate without full admin credent --- -## 11. Delegation Security +## 12. Delegation Security Agent delegation is protected through delegation history tracking and concurrency controls. @@ -397,6 +468,10 @@ When concurrency limits are hit, the error message is written for LLM reasoning: | `internal/gateway/methods/pairing.go` | Pairing RPC methods (request, approve, deny, list, revoke) | | `internal/store/pg/pairing.go` | Pairing store implementation (code generation, TTLs) | | `internal/store/pairing_store.go` | Pairing store interface definition | +| `cmd/pkg-helper/main.go` | Root-privileged helper for apk add/del via Unix socket | +| `internal/http/packages.go` | HTTP handlers for package management endpoints | +| `internal/skills/package_lister.go` | Query installed packages from apk/pip3/npm | +| `docker-entrypoint.sh` | Container initialization: setup runtime dirs, start pkg-helper, drop privileges | --- diff --git a/docs/17-changelog.md b/docs/17-changelog.md index 6b8c24d6..8ecd2918 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -8,6 +8,26 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Added +#### Runtime & Packages Management (2026-03-17) +- **Packages page**: New "Packages" page in Web UI under System group for managing installed packages +- **HTTP API endpoints**: GET/POST `/v1/packages`, `/v1/packages/install`, `/v1/packages/uninstall`, GET `/v1/packages/runtimes` +- **Three package categories**: System (apk), Python (pip), Node (npm) with version tracking +- **pkg-helper binary**: Root-privileged helper service for secure system package management via Unix socket `/tmp/pkg.sock` +- **Package persistence**: System packages persisted to `/app/data/.runtime/apk-packages` for container recreation +- **Input validation**: Regex + MaxBytesReader (4096 bytes) for package names to prevent injection + +#### Docker Security Hardening (2026-03-17) +- **Privilege separation**: Entrypoint drops privileges to non-root goclaw user after installing packages +- **pkg-helper service**: Started as root, listens on Unix socket with 0660 permissions (root:goclaw group) +- **Runtime directories**: Python and Node.js packages install to writable `/app/data/.runtime` directories +- **su-exec integration**: Used instead of USER directive for cleaner privilege transition +- **Docker capabilities**: Added SETUID/SETGID/CHOWN/DAC_OVERRIDE for pkg-helper and user switching +- **Environment variables**: PIP_TARGET, NPM_CONFIG_PREFIX, PYTHONPATH configured for runtime installs + +#### Auth Fix (2026-03-17) +- **Empty gateway token handling**: When GOCLAW_GATEWAY_TOKEN is empty (dev/single-user mode), all requests get admin role +- **CLI credentials access**: Admin-only endpoints (/v1/cli-credentials) now accessible in dev mode + #### Team Workspace Improvements (2026-03-16) - **Team workspace resolution**: Lead agents resolve per-team workspace directories for both lead and member agents - **WorkspaceInterceptor**: Transparently rewrites file tool requests to team workspace context @@ -70,6 +90,13 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep #### Knowledge Graph Improvements (2026-03-14) - **LLM JSON sanitization**: Sanitize LLM JSON output before parsing to handle edge cases +#### CI/CD & Release Pipeline (2026-03-16) +- **Semantic release**: Automated versioning via `go-semantic-release` on push to `main` +- **Cross-platform binaries**: Build and attach `linux/darwin × amd64/arm64` tarballs to GitHub Releases +- **Discord webhook notification**: Post release embed to Discord with changelog, version, Docker pull command, and install script link after successful build +- **Install scripts**: One-liner binary installer (`scripts/install.sh`) and interactive Docker setup (`scripts/setup-docker.sh`) with variant selection (alpine/node/python/full) +- **Docker image publishing**: Publish multi-arch images to GHCR and Docker Hub via GitHub Actions + #### Traces & Observability (2026-03-16) - **Trace UI improvements**: Added timestamps, copy button, syntax highlighting to trace/span views - **Trace export**: Added gzip export with recursive sub-trace collection @@ -88,6 +115,7 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Changed +- **Docker entrypoint**: Reimplemented for privilege separation with pkg-helper lifecycle management - **Team workspace refactor**: Removed legacy `workspace_read`/`workspace_write` tools in favor of file tools for team workspace - **Config hardcoding**: Centralized ~/goclaw paths via config resolution instead of hardcoded values - **Workspace media files**: Preserve workspace media files during subtree lazy-loading @@ -102,6 +130,9 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Documentation +- Updated `18-http-api.md` — Added section 17 for Runtime & Packages Management endpoints +- Updated `09-security.md` — Added Docker entrypoint documentation, pkg-helper architecture, privilege separation +- Updated `17-changelog.md` — New entries for packages management, Docker security, and auth fix - Added `18-http-api.md` — Complete HTTP REST API reference (all endpoints, auth, error codes) - Added `19-websocket-rpc.md` — Complete WebSocket RPC method catalog (64+ methods, permission matrix) - Added `20-api-keys-auth.md` — API key authentication, RBAC scopes, security model, usage examples diff --git a/docs/18-http-api.md b/docs/18-http-api.md index 9203dd54..ee4d95bc 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -413,7 +413,7 @@ Team activity and audit trail. ## 16. Secure CLI Credentials -CLI authentication credentials for secure command execution. +CLI authentication credentials for secure command execution. Requires **admin role** (full gateway token or empty gateway token in dev/single-user mode). | Method | Path | Description | |--------|------|-------------| @@ -427,7 +427,107 @@ CLI authentication credentials for secure command execution. --- -## 17. Traces & Costs +## 17. Runtime & Packages Management + +Manage system (apk), Python (pip), and Node (npm) package installation in the runtime container. Requires authentication. When `GOCLAW_GATEWAY_TOKEN` is empty (dev/single-user mode), all users get admin role and can manage packages. + +### List Installed Packages + +``` +GET /v1/packages +``` + +Returns all installed packages grouped by category. + +**Response:** + +```json +{ + "system": [ + {"name": "github-cli", "version": "2.72.0-r6"}, + {"name": "curl", "version": "8.9.1-r1"} + ], + "pip": [ + {"name": "pandas", "version": "2.0.0"}, + {"name": "requests", "version": "2.31.0"} + ], + "npm": [ + {"name": "typescript", "version": "5.1.0"}, + {"name": "docx", "version": "8.12.0"} + ] +} +``` + +### Install Package + +``` +POST /v1/packages/install +``` + +**Request:** + +```json +{ + "package": "github-cli" +} +``` + +Package name can optionally include prefix: `"pip:pandas"` or `"npm:typescript"`. Without prefix, defaults to system (apk). + +**Validation:** Package names must match `^[a-zA-Z0-9@][a-zA-Z0-9._+\-/@]*$` (max 4096 bytes). Names starting with `-` are rejected to prevent argument injection. + +**Response:** + +```json +{ + "ok": true, + "error": "" +} +``` + +| Category | Manager | Behavior | +|----------|---------|----------| +| System (apk) | root-privileged pkg-helper | Sent to `/tmp/pkg.sock`, persisted to `/app/data/.runtime/apk-packages` for container recreates | +| Python (pip) | direct install | Installs to `$PIP_TARGET` (writable runtime dir) with `PIP_BREAK_SYSTEM_PACKAGES=1` | +| Node (npm) | direct install | Installs globally to `$NPM_CONFIG_PREFIX` (writable runtime dir) | + +### Uninstall Package + +``` +POST /v1/packages/uninstall +``` + +Same format as install. System packages are removed from persist file and container state. + +**Response:** + +```json +{ + "ok": true, + "error": "" +} +``` + +### Check Runtime Availability + +``` +GET /v1/packages/runtimes +``` + +Check if Python and Node runtimes are available in the container. + +**Response:** + +```json +{ + "python": true, + "node": true +} +``` + +--- + +## 18. Traces & Costs LLM call tracing and cost analysis. @@ -449,7 +549,7 @@ LLM call tracing and cost analysis. --- -## 18. Usage & Analytics +## 19. Usage & Analytics | Method | Path | Description | |--------|------|-------------| @@ -463,7 +563,7 @@ LLM call tracing and cost analysis. --- -## 19. Activity & Audit +## 20. Activity & Audit | Method | Path | Description | |--------|------|-------------| @@ -471,7 +571,7 @@ LLM call tracing and cost analysis. --- -## 20. Storage +## 21. Storage Workspace file management. @@ -490,7 +590,7 @@ Workspace file management. --- -## 21. Media +## 22. Media | Method | Path | Description | |--------|------|-------------| @@ -499,7 +599,7 @@ Workspace file management. --- -## 22. Files +## 23. Files | Method | Path | Description | |--------|------|-------------| @@ -509,7 +609,7 @@ Auth via Bearer token or `?token=` query param (for `` tags). MIME type aut --- -## 23. API Keys +## 24. API Keys Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth](20-api-keys-auth.md) for the full authentication and authorization model. @@ -547,7 +647,7 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] --- -## 24. OAuth +## 25. OAuth | Method | Path | Description | |--------|------|-------------| @@ -558,7 +658,7 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] --- -## 25. System +## 26. System | Method | Path | Description | |--------|------|-------------| @@ -577,7 +677,7 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] --- -## 26. MCP Bridge +## 27. MCP Bridge Exposes GoClaw tools to Claude CLI via streamable HTTP at `/mcp/bridge`. Only listens on localhost. Protected by gateway token with HMAC-signed context headers. @@ -656,9 +756,12 @@ These endpoints require an active WebSocket connection to the `/ws` endpoint wit | `internal/http/delegations.go` | Delegation history API | | `internal/http/team_events.go` | Team event history API | | `internal/http/secure_cli.go` | CLI credential management | +| `internal/http/packages.go` | Runtime package management (apk/pip/npm) | | `internal/http/pending_messages.go` | Pending message groups + compaction | | `internal/http/oauth.go` | OAuth authentication flows | | `internal/http/openapi.go` | OpenAPI spec + Swagger UI | | `internal/http/auth.go` | Authentication helpers | | `internal/gateway/server.go` | HTTP mux and route wiring | | `cmd/gateway.go` | Handler instantiation and wiring | +| `cmd/pkg-helper/main.go` | Root-privileged system package helper (apk add/del) | +| `internal/skills/package_lister.go` | Query installed packages from apk/pip3/npm | diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 7b3296f3..1deeaa0f 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/tools" ) // PromptMode controls which system prompt sections are included. @@ -52,6 +53,10 @@ type SystemPromptConfig struct { // Self-evolution: predefined agents can update SOUL.md (style/tone) SelfEvolve bool + // ShellDenyGroups holds effective deny group overrides for this agent. + // nil = all defaults. Used to adapt system prompt instructions. + ShellDenyGroups map[string]bool + // Credentialed CLI context — appended after tooling section. // Generated by tools.GenerateCredentialContext() from enabled secure CLI configs. CredentialCLIContext string @@ -166,7 +171,7 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { } // 2. ## Tooling - lines = append(lines, buildToolingSection(cfg.ToolNames, cfg.SandboxEnabled)...) + lines = append(lines, buildToolingSection(cfg.ToolNames, cfg.SandboxEnabled, cfg.ShellDenyGroups)...) // 2.5. Credentialed CLI context (appended after tooling, before safety) — skip during bootstrap if !cfg.IsBootstrap && cfg.CredentialCLIContext != "" { @@ -287,7 +292,7 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { // --- Section builders --- -func buildToolingSection(toolNames []string, hasSandbox bool) []string { +func buildToolingSection(toolNames []string, hasSandbox bool, shellDenyGroups map[string]bool) []string { lines := []string{ "## Tooling", "", @@ -318,10 +323,17 @@ func buildToolingSection(toolNames []string, hasSandbox bool) []string { ) } - lines = append(lines, - "", - "You can install packages at runtime with `pip3 install ` or `npm install -g ` — no sudo needed.", - ) + if tools.IsGroupDenied(shellDenyGroups, "package_install") { + lines = append(lines, + "", + "Package installation (pip, npm, apk) requires admin approval. If you need to install a package, use exec with the install command — it will be routed to the admin for approval. Alternatively, ask the user to install via the Web UI Packages page.", + ) + } else { + lines = append(lines, + "", + "You can install packages at runtime with `pip3 install ` or `npm install -g ` — no sudo needed.", + ) + } lines = append(lines, "", "IMPORTANT: The tool list above is the AUTHORITATIVE set of currently available tools, re-evaluated every turn.", diff --git a/internal/gateway/server.go b/internal/gateway/server.go index e793da5c..070535f6 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -51,6 +51,7 @@ type Server struct { builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API + packagesHandler *httpapi.PackagesHandler // runtime package management API memoryHandler *httpapi.MemoryHandler // memory management API kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API oauthHandler *httpapi.OAuthHandler // OAuth endpoints @@ -276,6 +277,10 @@ func (s *Server) BuildMux() *http.ServeMux { s.usageHandler.RegisterRoutes(mux) } + if s.packagesHandler != nil { + s.packagesHandler.RegisterRoutes(mux) + } + // API documentation (OpenAPI spec + Swagger UI) if s.docsHandler != nil { s.docsHandler.RegisterRoutes(mux) @@ -494,6 +499,9 @@ func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) { // SetSecureCLIHandler sets the secure CLI credential CRUD handler. func (s *Server) SetSecureCLIHandler(h *httpapi.SecureCLIHandler) { s.secureCLIHandler = h } +// SetPackagesHandler sets the runtime package management handler. +func (s *Server) SetPackagesHandler(h *httpapi.PackagesHandler) { s.packagesHandler = h } + // SetOAuthHandler sets the OAuth handler (available in all modes). func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h } diff --git a/internal/http/auth.go b/internal/http/auth.go index e7d84d3e..652fc62c 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -142,9 +142,9 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult slog.Warn("security.http_pairing_auth_failed", "sender_id", senderID, "ip", r.RemoteAddr) } } - // No auth configured → operator (backward compat) + // No auth configured → admin (no token = dev/single-user mode, full access) if gatewayToken == "" { - return authResult{Role: permissions.RoleOperator, Authenticated: true} + return authResult{Role: permissions.RoleAdmin, Authenticated: true} } return authResult{} } diff --git a/internal/http/auth_test.go b/internal/http/auth_test.go index c0094c88..5c20a2f9 100644 --- a/internal/http/auth_test.go +++ b/internal/http/auth_test.go @@ -62,8 +62,8 @@ func TestResolveAuth_NoAuthConfigured(t *testing.T) { if !auth.Authenticated { t.Fatal("expected authenticated when no token configured") } - if auth.Role != permissions.RoleOperator { - t.Errorf("role = %v, want operator (backward compat)", auth.Role) + if auth.Role != permissions.RoleAdmin { + t.Errorf("role = %v, want admin (no token = dev/single-user mode)", auth.Role) } } @@ -215,7 +215,7 @@ func TestRequireAuth_InjectLocaleAndUserID(t *testing.T) { } func TestRequireAuth_AdminRoleEnforced(t *testing.T) { - // No auth configured → operator role (backward compat) + // No auth configured → admin role (dev/single-user mode) → admin endpoint accessible setupTestCache(t, nil) handler := requireAuth("", permissions.RoleAdmin, func(w http.ResponseWriter, r *http.Request) { @@ -226,9 +226,9 @@ func TestRequireAuth_AdminRoleEnforced(t *testing.T) { w := httptest.NewRecorder() handler(w, r) - // No token configured → operator role, but admin required → 403 - if w.Code != http.StatusForbidden { - t.Errorf("status = %d, want 403 (operator cannot access admin endpoint)", w.Code) + // No token configured → admin role, admin endpoint → 200 + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (no token = admin in dev mode)", w.Code) } } diff --git a/internal/http/packages.go b/internal/http/packages.go new file mode 100644 index 00000000..fe946873 --- /dev/null +++ b/internal/http/packages.go @@ -0,0 +1,134 @@ +package http + +import ( + "encoding/json" + "net/http" + "regexp" + + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// validPkgName allows alphanumeric, hyphens, underscores, dots, @, / (for scoped npm). +// Rejects names starting with - to prevent argument injection. +var validPkgName = regexp.MustCompile(`^[a-zA-Z0-9@][a-zA-Z0-9._+\-/@]*$`) + +// PackagesHandler handles runtime package management HTTP endpoints. +type PackagesHandler struct { + token string +} + +// NewPackagesHandler creates a handler for package management endpoints. +func NewPackagesHandler(token string) *PackagesHandler { + return &PackagesHandler{token: token} +} + +// RegisterRoutes registers all package management routes on the given mux. +func (h *PackagesHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/packages", h.readAuth(h.handleList)) + mux.HandleFunc("POST /v1/packages/install", h.adminAuth(h.handleInstall)) + mux.HandleFunc("POST /v1/packages/uninstall", h.adminAuth(h.handleUninstall)) + mux.HandleFunc("GET /v1/packages/runtimes", h.readAuth(h.handleRuntimes)) + mux.HandleFunc("GET /v1/shell-deny-groups", h.readAuth(h.handleDenyGroups)) +} + +// readAuth allows viewer+ for read operations. +func (h *PackagesHandler) readAuth(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, "", next) +} + +// adminAuth requires admin role for write operations (install/uninstall). +// Prevents agents from calling these endpoints even if they obtain the gateway token, +// since agent requests via browser pairing only get operator role. +func (h *PackagesHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, permissions.RoleAdmin, next) +} + +// handleList returns all installed packages grouped by category (system/pip/npm). +func (h *PackagesHandler) handleList(w http.ResponseWriter, r *http.Request) { + pkgs := skills.ListInstalledPackages(r.Context()) + writeJSON(w, http.StatusOK, pkgs) +} + +// parseAndValidatePackage reads and validates a package name from the request body. +// Returns the validated package string or writes an error response and returns empty. +func parseAndValidatePackage(w http.ResponseWriter, r *http.Request) string { + r.Body = http.MaxBytesReader(w, r.Body, 4096) + var body struct { + Package string `json:"package"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Package == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "package required"}) + return "" + } + + // Strip prefix for validation, then validate the bare package name. + name := body.Package + for _, prefix := range []string{"pip:", "npm:"} { + if len(name) > len(prefix) && name[:len(prefix)] == prefix { + name = name[len(prefix):] + break + } + } + if !validPkgName.MatchString(name) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid package name"}) + return "" + } + + return body.Package +} + +// handleInstall installs a single package. +// Body: {"package": "github-cli"} or {"package": "pip:pandas"} or {"package": "npm:typescript"} +func (h *PackagesHandler) handleInstall(w http.ResponseWriter, r *http.Request) { + pkg := parseAndValidatePackage(w, r) + if pkg == "" { + return + } + ok, errMsg := skills.InstallSingleDep(r.Context(), pkg) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": errMsg}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleUninstall removes a single package. +// Body: {"package": "github-cli"} or {"package": "pip:pandas"} or {"package": "npm:typescript"} +func (h *PackagesHandler) handleUninstall(w http.ResponseWriter, r *http.Request) { + pkg := parseAndValidatePackage(w, r) + if pkg == "" { + return + } + ok, errMsg := skills.UninstallPackage(r.Context(), pkg) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": errMsg}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleRuntimes returns the availability of prerequisite runtimes. +func (h *PackagesHandler) handleRuntimes(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, skills.CheckRuntimes()) +} + +// handleDenyGroups returns all registered shell deny groups with name, description, and default state. +func (h *PackagesHandler) handleDenyGroups(w http.ResponseWriter, _ *http.Request) { + type groupInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Default bool `json:"default"` + } + groups := make([]groupInfo, 0, len(tools.DenyGroupRegistry)) + for _, name := range tools.DenyGroupNames() { + g := tools.DenyGroupRegistry[name] + groups = append(groups, groupInfo{ + Name: g.Name, + Description: g.Description, + Default: g.Default, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) +} diff --git a/internal/http/packages_test.go b/internal/http/packages_test.go new file mode 100644 index 00000000..6e271f64 --- /dev/null +++ b/internal/http/packages_test.go @@ -0,0 +1,310 @@ +package http + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestParseAndValidatePackage tests input validation with table-driven approach. +func TestParseAndValidatePackage(t *testing.T) { + tests := []struct { + name string + body string + wantEmpty bool + wantStatusErr int + }{ + // Valid cases + { + name: "simple alphanumeric", + body: `{"package":"github-cli"}`, + wantEmpty: false, + }, + { + name: "with hyphens", + body: `{"package":"my-package"}`, + wantEmpty: false, + }, + { + name: "with underscores", + body: `{"package":"my_package"}`, + wantEmpty: false, + }, + { + name: "with dots", + body: `{"package":"package.name"}`, + wantEmpty: false, + }, + { + name: "pip prefix", + body: `{"package":"pip:pandas"}`, + wantEmpty: false, + }, + { + name: "npm prefix", + body: `{"package":"npm:typescript"}`, + wantEmpty: false, + }, + { + name: "scoped npm package", + body: `{"package":"npm:@scope/package"}`, + wantEmpty: false, + }, + { + name: "with plus sign", + body: `{"package":"c++"}`, + wantEmpty: false, + }, + { + name: "numbers in name", + body: `{"package":"python3"}`, + wantEmpty: false, + }, + // Invalid cases + { + name: "empty package field", + body: `{"package":""}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "missing package field", + body: `{}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "malformed json", + body: `{invalid json}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "starts with hyphen (injection risk)", + body: `{"package":"-malicious"}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "contains semicolon (shell injection)", + body: `{"package":"pkg; rm -rf"}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "contains space", + body: `{"package":"pkg name"}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "contains pipe (shell)", + body: `{"package":"pkg|cat"}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "contains backtick (command injection)", + body: "{\"package\":\"pkg`command`\"}", + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "index-url prefix (pip attack)", + body: `{"package":"--index-url=evil"}`, + wantEmpty: true, + wantStatusErr: http.StatusBadRequest, + }, + { + name: "starts with @", + body: `{"package":"@scope/pkg"}`, + wantEmpty: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/v1/packages/install", bytes.NewBufferString(tt.body)) + w := httptest.NewRecorder() + + result := parseAndValidatePackage(w, req) + + if tt.wantEmpty && result != "" { + t.Errorf("parseAndValidatePackage() = %q, want empty", result) + } + if !tt.wantEmpty && result == "" { + t.Errorf("parseAndValidatePackage() = empty, want non-empty") + } + if tt.wantStatusErr > 0 && w.Code != tt.wantStatusErr { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatusErr) + } + }) + } +} + +// TestParseAndValidatePackage_BodySizeLimit tests the MaxBytesReader limit. +func TestParseAndValidatePackage_BodySizeLimit(t *testing.T) { + // Create a body larger than 4096 bytes + largeBody := `{"package":"` + string(bytes.Repeat([]byte("x"), 5000)) + `"}` + req := httptest.NewRequest("POST", "/v1/packages/install", bytes.NewBufferString(largeBody)) + w := httptest.NewRecorder() + + result := parseAndValidatePackage(w, req) + + if result != "" { + t.Errorf("parseAndValidatePackage() with large body = %q, want empty (rejected)", result) + } + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d (request entity too large)", w.Code, http.StatusBadRequest) + } +} + +// TestNewPackagesHandler creates a handler. +func TestNewPackagesHandler(t *testing.T) { + h := NewPackagesHandler("test-token") + if h == nil { + t.Fatal("NewPackagesHandler() returned nil") + } + if h.token != "test-token" { + t.Errorf("token = %q, want 'test-token'", h.token) + } +} + +// TestPackagesHandler_RegisterRoutes ensures routes are registered without panic. +func TestPackagesHandler_RegisterRoutes(t *testing.T) { + h := NewPackagesHandler("test-token") + mux := http.NewServeMux() + + // Should not panic + h.RegisterRoutes(mux) +} + +// TestParseAndValidatePackage_StripsPrefixesCorrectly tests that prefixes are stripped for validation. +func TestParseAndValidatePackage_StripsPrefixesCorrectly(t *testing.T) { + tests := []struct { + name string + input string + wantFail bool + }{ + { + name: "pip: with valid name", + input: `{"package":"pip:pandas"}`, + wantFail: false, + }, + { + name: "npm: with valid name", + input: `{"package":"npm:typescript"}`, + wantFail: false, + }, + { + name: "pip: with invalid name after prefix", + input: `{"package":"pip:-badname"}`, + wantFail: true, + }, + { + name: "npm: with invalid name after prefix", + input: `{"package":"npm:-badname"}`, + wantFail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/v1/packages/install", bytes.NewBufferString(tt.input)) + w := httptest.NewRecorder() + + result := parseAndValidatePackage(w, req) + + isEmpty := result == "" + if isEmpty != tt.wantFail { + t.Errorf("empty = %v, wantFail = %v", isEmpty, tt.wantFail) + } + }) + } +} + +// TestParseAndValidatePackage_ReturnsOriginalPackageString tests that returned string includes prefix. +func TestParseAndValidatePackage_ReturnsOriginalPackageString(t *testing.T) { + tests := []struct { + input string + want string + }{ + {`{"package":"github-cli"}`, "github-cli"}, + {`{"package":"pip:pandas"}`, "pip:pandas"}, + {`{"package":"npm:typescript"}`, "npm:typescript"}, + {`{"package":"npm:@scope/pkg"}`, "npm:@scope/pkg"}, + } + + for _, tt := range tests { + req := httptest.NewRequest("POST", "/v1/packages/install", bytes.NewBufferString(tt.input)) + w := httptest.NewRecorder() + + result := parseAndValidatePackage(w, req) + + if result != tt.want { + t.Errorf("parseAndValidatePackage() = %q, want %q", result, tt.want) + } + } +} + +// TestValidPkgNameRegex tests the regex directly. +func TestValidPkgNameRegex(t *testing.T) { + tests := []struct { + name string + valid bool + }{ + // Valid + {"github-cli", true}, + {"my_package", true}, + {"package.name", true}, + {"python3", true}, + {"c++", true}, + {"@scope/pkg", true}, + {"pkg123", true}, + {"a", true}, + {"A", true}, + {"0abc", true}, // can start with number + // Invalid + {"-invalid", false}, // starts with hyphen + {"--flag", false}, // starts with hyphen + {"pkg name", false}, // contains space + {"pkg;cmd", false}, // contains semicolon + {"pkg|cmd", false}, // contains pipe + {"pkg&cmd", false}, // contains ampersand + {"pkg`cmd`", false}, // contains backtick + {"pkg$var", false}, // contains dollar sign + {"pkgfile", false}, // contains angle bracket + {"", false}, // empty + } + + for _, tt := range tests { + got := validPkgName.MatchString(tt.name) + if got != tt.valid { + t.Errorf("validPkgName.MatchString(%q) = %v, want %v", tt.name, got, tt.valid) + } + } +} + +// TestParseAndValidatePackage_ErrorResponseFormat tests error response JSON format. +func TestParseAndValidatePackage_ErrorResponseFormat(t *testing.T) { + req := httptest.NewRequest("POST", "/v1/packages/install", bytes.NewBufferString(`{"package":""}`)) + w := httptest.NewRecorder() + + parseAndValidatePackage(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } + + var errResp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if errMsg, ok := errResp["error"]; !ok || errMsg == "" { + t.Error("response missing 'error' field") + } +} diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index d14458b8..f715450d 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -319,19 +319,32 @@ func (m *Manager) ActivateTools(names []string) { // Returns true if the tool is now in the registry. // Used by the Registry's deferredActivator callback for lazy tool activation. func (m *Manager) ActivateToolIfDeferred(name string) bool { - m.mu.RLock() + m.mu.Lock() _, isDeferred := m.deferredTools[name] _, isActivated := m.activatedTools[name] - m.mu.RUnlock() if isActivated { + m.mu.Unlock() return true // already activated by a concurrent call } if !isDeferred { + m.mu.Unlock() return false } - m.ActivateTools([]string{name}) - _, ok := m.registry.Get(name) - return ok + // Mark as activated under lock to prevent concurrent ActivateTools races. + m.activatedTools[name] = struct{}{} + bt := m.deferredTools[name] + delete(m.deferredTools, name) + activeNames := make([]string, 0, len(m.activatedTools)) + for n := range m.activatedTools { + activeNames = append(activeNames, n) + } + m.mu.Unlock() + + // Register in registry outside lock (registry has its own sync). + m.registry.Register(bt) + tools.RegisterToolGroup("mcp", activeNames) + slog.Info("mcp.tools.activated", "tools", []string{name}) + return true } // Stop shuts down all MCP server connections and unregisters tools. diff --git a/internal/skills/dep_installer.go b/internal/skills/dep_installer.go index d9c9a770..80cfc426 100644 --- a/internal/skills/dep_installer.go +++ b/internal/skills/dep_installer.go @@ -1,9 +1,12 @@ package skills import ( + "bufio" "context" + "encoding/json" "fmt" "log/slog" + "net" "os/exec" "strings" "time" @@ -11,6 +14,9 @@ import ( const installTimeout = 5 * time.Minute +// pkgHelperSocket is the Unix socket path for the root-privileged pkg-helper. +const pkgHelperSocket = "/tmp/pkg.sock" + // InstallResult holds per-category install outcomes. type InstallResult struct { System []string `json:"system,omitempty"` @@ -45,24 +51,32 @@ func InstallSingleDep(ctx context.Context, dep string) (bool, string) { slog.Info("skills: installing dep", "dep", dep) - var cmd *exec.Cmd switch { case strings.HasPrefix(dep, "pip:"): pkg := strings.TrimPrefix(dep, "pip:") - cmd = exec.CommandContext(ctx, "pip3", "install", "--no-cache-dir", "--break-system-packages", pkg) + cmd := exec.CommandContext(ctx, "pip3", "install", "--no-cache-dir", "--break-system-packages", pkg) + out, err := cmd.CombinedOutput() + if err != nil { + msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) + slog.Error("skills: dep install failed", "dep", dep, "error", msg) + return false, msg + } case strings.HasPrefix(dep, "npm:"): pkg := strings.TrimPrefix(dep, "npm:") - cmd = exec.CommandContext(ctx, "npm", "install", "-g", pkg) + cmd := exec.CommandContext(ctx, "npm", "install", "-g", pkg) + out, err := cmd.CombinedOutput() + if err != nil { + msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) + slog.Error("skills: dep install failed", "dep", dep, "error", msg) + return false, msg + } default: - // System binary via apk - cmd = exec.CommandContext(ctx, "doas", "apk", "add", "--no-cache", dep) - } - - out, err := cmd.CombinedOutput() - if err != nil { - msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) - slog.Error("skills: dep install failed", "dep", dep, "error", msg) - return false, msg + // System package via pkg-helper (root-privileged Unix socket). + // pkg-helper handles persist to apk-packages file. + ok, errMsg := apkViaHelper(ctx, "install", dep) + if !ok { + return false, errMsg + } } slog.Info("skills: dep installed", "dep", dep) @@ -90,15 +104,19 @@ func InstallDeps(ctx context.Context, manifest *SkillManifest, missing []string) } } + // System packages: install one by one via pkg-helper. if len(sysPkgs) > 0 { slog.Info("skills: installing system packages", "pkgs", sysPkgs) - args := append([]string{"apk", "add", "--no-cache"}, sysPkgs...) - cmd := exec.CommandContext(ctx, "doas", args...) - if out, err := cmd.CombinedOutput(); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("apk: %s (%v)", strings.TrimSpace(string(out)), err)) - } else { - result.System = sysPkgs + var successful []string + for _, pkg := range sysPkgs { + ok, errMsg := apkViaHelper(ctx, "install", pkg) + if !ok { + result.Errors = append(result.Errors, fmt.Sprintf("apk %s: %s", pkg, errMsg)) + } else { + successful = append(successful, pkg) + } } + result.System = successful } if len(pipPkgs) > 0 { @@ -127,8 +145,84 @@ func InstallDeps(ctx context.Context, manifest *SkillManifest, missing []string) return result, nil } +// UninstallPackage removes one package (format: "pip:pkg", "npm:pkg", or plain apk name). +// Returns (ok, errorMessage). +func UninstallPackage(ctx context.Context, dep string) (bool, string) { + ctx, cancel := context.WithTimeout(ctx, installTimeout) + defer cancel() + + slog.Info("skills: uninstalling package", "dep", dep) + + switch { + case strings.HasPrefix(dep, "pip:"): + pkg := strings.TrimPrefix(dep, "pip:") + cmd := exec.CommandContext(ctx, "pip3", "uninstall", "-y", pkg) + out, err := cmd.CombinedOutput() + if err != nil { + msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) + slog.Error("skills: uninstall failed", "dep", dep, "error", msg) + return false, msg + } + case strings.HasPrefix(dep, "npm:"): + pkg := strings.TrimPrefix(dep, "npm:") + cmd := exec.CommandContext(ctx, "npm", "uninstall", "-g", pkg) + out, err := cmd.CombinedOutput() + if err != nil { + msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err) + slog.Error("skills: uninstall failed", "dep", dep, "error", msg) + return false, msg + } + default: + // System package via pkg-helper. Helper handles persist file removal. + ok, errMsg := apkViaHelper(ctx, "uninstall", dep) + if !ok { + return false, errMsg + } + } + + slog.Info("skills: package uninstalled", "dep", dep) + return true, "" +} + +// apkViaHelper sends an install/uninstall request to the root-privileged pkg-helper +// via Unix socket. The helper runs apk add/del as root and manages the persist file. +func apkViaHelper(ctx context.Context, action, pkg string) (bool, string) { + conn, err := net.DialTimeout("unix", pkgHelperSocket, 5*time.Second) + if err != nil { + return false, fmt.Sprintf("pkg-helper unavailable: %v", err) + } + defer conn.Close() + + // Set deadline from context. + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) //nolint:errcheck + } + + // Send request as JSON line. + req := map[string]string{"action": action, "package": pkg} + if err := json.NewEncoder(conn).Encode(req); err != nil { + return false, fmt.Sprintf("pkg-helper send failed: %v", err) + } + + // Read response. + scanner := bufio.NewScanner(conn) + if !scanner.Scan() { + return false, "pkg-helper: no response" + } + + var resp struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + return false, fmt.Sprintf("pkg-helper: invalid response: %v", err) + } + + return resp.OK, resp.Error +} + // cleanCaches removes pip and npm caches to save disk space. func cleanCaches(ctx context.Context) { - exec.CommandContext(ctx, "pip3", "cache", "purge").Run() //nolint:errcheck - exec.CommandContext(ctx, "rm", "-rf", "/tmp/npm-*").Run() //nolint:errcheck + exec.CommandContext(ctx, "pip3", "cache", "purge").Run() //nolint:errcheck + exec.CommandContext(ctx, "sh", "-c", "rm -rf /tmp/npm-*").Run() //nolint:errcheck } diff --git a/internal/skills/package_lister.go b/internal/skills/package_lister.go new file mode 100644 index 00000000..39365fa5 --- /dev/null +++ b/internal/skills/package_lister.go @@ -0,0 +1,150 @@ +package skills + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// PackageInfo describes a single installed package. +type PackageInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// InstalledPackages groups installed packages by manager. +type InstalledPackages struct { + System []PackageInfo `json:"system"` + Pip []PackageInfo `json:"pip"` + Npm []PackageInfo `json:"npm"` +} + +const listTimeout = 15 * time.Second + +// ListInstalledPackages queries apk, pip3, and npm for installed packages. +// Only returns user-installed packages (filters out base Alpine packages for system). +func ListInstalledPackages(ctx context.Context) *InstalledPackages { + ctx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + result := &InstalledPackages{} + result.System = listApkUserPackages(ctx) + result.Pip = listPipPackages(ctx) + result.Npm = listNpmPackages(ctx) + return result +} + +// listApkUserPackages returns packages from the apk-packages persist file +// (user-installed on-demand packages only, not base Alpine). +func listApkUserPackages(ctx context.Context) []PackageInfo { + runtimeDir := os.Getenv("RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = "/app/data/.runtime" + } + listFile := filepath.Join(runtimeDir, "apk-packages") + + f, err := os.Open(listFile) + if err != nil { + return nil + } + defer f.Close() + + // Read unique package names from persist file. + seen := make(map[string]bool) + var names []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" && !seen[name] { + seen[name] = true + names = append(names, name) + } + } + + if len(names) == 0 { + return nil + } + + // Get versions for persisted packages via apk info. + var pkgs []PackageInfo + for _, name := range names { + version := getApkVersion(ctx, name) + pkgs = append(pkgs, PackageInfo{Name: name, Version: version}) + } + return pkgs +} + +// getApkVersion returns the installed version of an apk package, or empty string. +// Uses "apk list --installed" which works without root and gives versioned output. +func getApkVersion(ctx context.Context, name string) string { + // Output format: "github-cli-2.72.0-r6 aarch64 {github-cli} (MIT) [installed]" + out, err := exec.CommandContext(ctx, "apk", "list", "--installed", name).Output() + if err != nil { + return "" + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Extract version: everything between "name-" and the first space. + if strings.HasPrefix(line, name+"-") { + rest := strings.TrimPrefix(line, name+"-") + if idx := strings.IndexByte(rest, ' '); idx > 0 { + return rest[:idx] + } + return rest + } + } + return "" +} + +// listPipPackages returns pip3-installed packages via JSON output. +func listPipPackages(ctx context.Context) []PackageInfo { + out, err := exec.CommandContext(ctx, "pip3", "list", "--format", "json").CombinedOutput() + if err != nil { + return nil + } + + var raw []struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return nil + } + + pkgs := make([]PackageInfo, 0, len(raw)) + for _, r := range raw { + pkgs = append(pkgs, PackageInfo{Name: r.Name, Version: r.Version}) + } + return pkgs +} + +// listNpmPackages returns globally installed npm packages. +func listNpmPackages(ctx context.Context) []PackageInfo { + out, err := exec.CommandContext(ctx, "npm", "list", "-g", "--json", "--depth=0").CombinedOutput() + if err != nil && len(out) == 0 { + return nil + } + + var raw struct { + Dependencies map[string]struct { + Version string `json:"version"` + } `json:"dependencies"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return nil + } + + pkgs := make([]PackageInfo, 0, len(raw.Dependencies)) + for name, info := range raw.Dependencies { + pkgs = append(pkgs, PackageInfo{Name: name, Version: info.Version}) + } + return pkgs +} diff --git a/internal/skills/package_lister_test.go b/internal/skills/package_lister_test.go new file mode 100644 index 00000000..b850c555 --- /dev/null +++ b/internal/skills/package_lister_test.go @@ -0,0 +1,587 @@ +package skills + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestGetApkVersion tests parsing of apk list output. +func TestGetApkVersion(t *testing.T) { + tests := []struct { + name string + pkgName string + // Mock apk command output + mockOutput string + mockErr bool + want string + }{ + { + name: "single version", + pkgName: "github-cli", + mockOutput: "github-cli-2.72.0-r6 aarch64 {github-cli} (MIT) [installed]", + want: "2.72.0-r6", + }, + { + name: "version with multiple parts", + pkgName: "openssl", + mockOutput: "openssl-3.1.4-r5 x86_64 {openssl} (Apache-2.0) [installed]", + want: "3.1.4-r5", + }, + { + name: "empty output (not installed)", + pkgName: "nonexistent", + mockOutput: "", + want: "", + }, + { + name: "whitespace in output", + pkgName: "curl", + mockOutput: " curl-8.5.0-r1 x86_64 {curl} (MIT) [installed] ", + want: "8.5.0-r1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the version extraction logic directly + got := extractApkVersion(tt.mockOutput, tt.pkgName) + if got != tt.want { + t.Errorf("extractApkVersion() = %q, want %q", got, tt.want) + } + }) + } +} + +// extractApkVersion is extracted from getApkVersion logic for testing. +// This simulates the parsing without requiring actual apk command. +func extractApkVersion(output, name string) string { + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Extract version: everything between "name-" and the first space. + if strings.HasPrefix(line, name+"-") { + rest := strings.TrimPrefix(line, name+"-") + if idx := strings.IndexByte(rest, ' '); idx > 0 { + return rest[:idx] + } + return rest + } + } + return "" +} + +// TestListPipPackages tests pip JSON parsing. +func TestListPipPackages_Parsing(t *testing.T) { + tests := []struct { + name string + jsonOutput string + wantCount int + wantNames []string + }{ + { + name: "empty list", + jsonOutput: "[]", + wantCount: 0, + }, + { + name: "single package", + jsonOutput: `[ + {"name": "pandas", "version": "2.0.3"} + ]`, + wantCount: 1, + wantNames: []string{"pandas"}, + }, + { + name: "multiple packages", + jsonOutput: `[ + {"name": "pandas", "version": "2.0.3"}, + {"name": "numpy", "version": "1.24.3"}, + {"name": "requests", "version": "2.31.0"} + ]`, + wantCount: 3, + wantNames: []string{"pandas", "numpy", "requests"}, + }, + { + name: "preserves version info", + jsonOutput: `[ + {"name": "pytest", "version": "7.4.0"}, + {"name": "black", "version": "23.7.0"} + ]`, + wantCount: 2, + wantNames: []string{"pytest", "black"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parsePipJSON(tt.jsonOutput) + + if len(pkgs) != tt.wantCount { + t.Errorf("got %d packages, want %d", len(pkgs), tt.wantCount) + } + + for i, want := range tt.wantNames { + if i >= len(pkgs) { + t.Errorf("package %d missing", i) + continue + } + if pkgs[i].Name != want { + t.Errorf("package[%d].Name = %q, want %q", i, pkgs[i].Name, want) + } + } + }) + } +} + +// parsePipJSON extracts pip package info for testing. +func parsePipJSON(jsonOutput string) []PackageInfo { + var raw []struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err := json.Unmarshal([]byte(jsonOutput), &raw); err != nil { + return nil + } + + pkgs := make([]PackageInfo, 0, len(raw)) + for _, r := range raw { + pkgs = append(pkgs, PackageInfo{Name: r.Name, Version: r.Version}) + } + return pkgs +} + +// TestListNpmPackages tests npm JSON parsing. +func TestListNpmPackages_Parsing(t *testing.T) { + tests := []struct { + name string + jsonOutput string + wantCount int + wantNames []string + }{ + { + name: "no dependencies", + jsonOutput: `{ + "dependencies": {} + }`, + wantCount: 0, + }, + { + name: "single package", + jsonOutput: `{ + "dependencies": { + "typescript": {"version": "5.1.6"} + } + }`, + wantCount: 1, + wantNames: []string{"typescript"}, + }, + { + name: "multiple packages", + jsonOutput: `{ + "dependencies": { + "typescript": {"version": "5.1.6"}, + "webpack": {"version": "5.88.0"}, + "prettier": {"version": "3.0.0"} + } + }`, + wantCount: 3, + wantNames: []string{"typescript", "webpack", "prettier"}, + }, + { + name: "scoped packages", + jsonOutput: `{ + "dependencies": { + "@types/node": {"version": "20.4.5"}, + "@angular/core": {"version": "16.1.0"} + } + }`, + wantCount: 2, + wantNames: []string{"@types/node", "@angular/core"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseNpmJSON(tt.jsonOutput) + + if len(pkgs) != tt.wantCount { + t.Errorf("got %d packages, want %d", len(pkgs), tt.wantCount) + } + + nameSet := make(map[string]bool) + for _, pkg := range pkgs { + nameSet[pkg.Name] = true + } + + for _, want := range tt.wantNames { + if !nameSet[want] { + t.Errorf("package %q not found", want) + } + } + }) + } +} + +// parseNpmJSON extracts npm package info for testing. +func parseNpmJSON(jsonOutput string) []PackageInfo { + var raw struct { + Dependencies map[string]struct { + Version string `json:"version"` + } `json:"dependencies"` + } + if err := json.Unmarshal([]byte(jsonOutput), &raw); err != nil { + return nil + } + + pkgs := make([]PackageInfo, 0, len(raw.Dependencies)) + for name, info := range raw.Dependencies { + pkgs = append(pkgs, PackageInfo{Name: name, Version: info.Version}) + } + return pkgs +} + +// TestListApkUserPackages tests reading from persist file. +func TestListApkUserPackages_PersistFile(t *testing.T) { + tests := []struct { + name string + fileLines string + wantCount int + wantNames []string + }{ + { + name: "empty file", + fileLines: "", + wantCount: 0, + }, + { + name: "single package", + fileLines: "github-cli\n", + wantCount: 1, + wantNames: []string{"github-cli"}, + }, + { + name: "multiple packages", + fileLines: "github-cli\ncurl\nopenssl\n", + wantCount: 3, + wantNames: []string{"github-cli", "curl", "openssl"}, + }, + { + name: "deduplicates duplicates", + fileLines: "curl\ngithub-cli\ncurl\n", + wantCount: 2, + wantNames: []string{"curl", "github-cli"}, + }, + { + name: "ignores blank lines and whitespace", + fileLines: "curl\n\n \ngithub-cli\n", + wantCount: 2, + wantNames: []string{"curl", "github-cli"}, + }, + { + name: "no trailing newline", + fileLines: "curl\ngithub-cli", + wantCount: 2, + wantNames: []string{"curl", "github-cli"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + names := parseApkPersistFile(tt.fileLines) + + if len(names) != tt.wantCount { + t.Errorf("got %d packages, want %d", len(names), tt.wantCount) + } + + nameSet := make(map[string]bool) + for _, name := range names { + nameSet[name] = true + } + + for _, want := range tt.wantNames { + if !nameSet[want] { + t.Errorf("package %q not found", want) + } + } + }) + } +} + +// parseApkPersistFile simulates listApkUserPackages parsing logic. +func parseApkPersistFile(fileContent string) []string { + seen := make(map[string]bool) + var names []string + for _, line := range strings.Split(fileContent, "\n") { + name := strings.TrimSpace(line) + if name != "" && !seen[name] { + seen[name] = true + names = append(names, name) + } + } + return names +} + +// TestPackageInfo_JSON tests PackageInfo JSON marshaling. +func TestPackageInfo_JSON(t *testing.T) { + pkg := PackageInfo{ + Name: "curl", + Version: "8.5.0-r1", + } + + data, err := json.Marshal(pkg) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var decoded PackageInfo + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + if decoded.Name != pkg.Name { + t.Errorf("Name = %q, want %q", decoded.Name, pkg.Name) + } + if decoded.Version != pkg.Version { + t.Errorf("Version = %q, want %q", decoded.Version, pkg.Version) + } +} + +// TestInstalledPackages_JSON tests InstalledPackages JSON marshaling. +func TestInstalledPackages_JSON(t *testing.T) { + pkgs := &InstalledPackages{ + System: []PackageInfo{ + {Name: "curl", Version: "8.5.0-r1"}, + }, + Pip: []PackageInfo{ + {Name: "pandas", Version: "2.0.3"}, + }, + Npm: []PackageInfo{ + {Name: "typescript", Version: "5.1.6"}, + }, + } + + data, err := json.Marshal(pkgs) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var decoded InstalledPackages + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + if len(decoded.System) != 1 || decoded.System[0].Name != "curl" { + t.Errorf("System packages not decoded correctly") + } + if len(decoded.Pip) != 1 || decoded.Pip[0].Name != "pandas" { + t.Errorf("Pip packages not decoded correctly") + } + if len(decoded.Npm) != 1 || decoded.Npm[0].Name != "typescript" { + t.Errorf("Npm packages not decoded correctly") + } +} + +// TestListInstalledPackages_ReturnsNonNil ensures return is not nil. +func TestListInstalledPackages_ReturnsNonNil(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := ListInstalledPackages(ctx) + if result == nil { + t.Error("ListInstalledPackages returned nil, want *InstalledPackages") + } +} + +// TestListInstalledPackages_HasThreeCategories ensures all categories are accessible. +func TestListInstalledPackages_HasThreeCategories(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := ListInstalledPackages(ctx) + // Categories can be nil if no packages are installed in that category + // Just verify the struct itself is not nil + if result == nil { + t.Error("ListInstalledPackages returned nil") + } +} + +// TestListInstalledPackages_ContextTimeout tests timeout behavior. +func TestListInstalledPackages_ContextTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + // Should return a result even if timeout occurs during execution. + // The function creates its own timeout context internally. + result := ListInstalledPackages(ctx) + if result == nil { + t.Fatal("ListInstalledPackages returned nil on timeout") + } + // Categories may be nil if no packages are installed or command timed out + // Just verify the struct itself was initialized +} + +// TestApkListFile_RuntimeDir tests runtime directory handling. +func TestApkListFile_RuntimeDir(t *testing.T) { + // Save original env + orig := os.Getenv("RUNTIME_DIR") + defer func() { + if orig != "" { + os.Setenv("RUNTIME_DIR", orig) + } else { + os.Unsetenv("RUNTIME_DIR") + } + }() + + tests := []struct { + name string + envVal string + wantDir string + }{ + { + name: "uses RUNTIME_DIR when set", + envVal: "/custom/runtime", + wantDir: "/custom/runtime", + }, + { + name: "uses default when empty", + envVal: "", + wantDir: "/app/data/.runtime", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVal != "" { + os.Setenv("RUNTIME_DIR", tt.envVal) + } else { + os.Unsetenv("RUNTIME_DIR") + } + + got := getApkListFileDir() + want := filepath.Join(tt.wantDir, "apk-packages") + + if got != want { + t.Errorf("getApkListFileDir() = %q, want %q", got, want) + } + }) + } +} + +// getApkListFileDir extracts the directory logic for testing. +func getApkListFileDir() string { + runtimeDir := os.Getenv("RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = "/app/data/.runtime" + } + return filepath.Join(runtimeDir, "apk-packages") +} + +// TestExtractApkVersion_EdgeCases tests version extraction edge cases. +func TestExtractApkVersion_EdgeCases(t *testing.T) { + tests := []struct { + name string + pkgName string + output string + want string + }{ + { + name: "version with no space suffix", + pkgName: "pkg", + output: "pkg-1.0.0", + want: "1.0.0", + }, + { + name: "multiple matching lines (takes first)", + pkgName: "curl", + output: "curl-7.0.0 arch1 info\ncurl-8.0.0 arch2 info", + want: "7.0.0", + }, + { + name: "name not found", + pkgName: "notfound", + output: "curl-8.5.0-r1 aarch64 {curl} (MIT) [installed]", + want: "", + }, + { + name: "case sensitive match", + pkgName: "Curl", + output: "curl-8.5.0-r1 aarch64", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractApkVersion(tt.output, tt.pkgName) + if got != tt.want { + t.Errorf("extractApkVersion(%q, %q) = %q, want %q", tt.output, tt.pkgName, got, tt.want) + } + }) + } +} + +// TestParsePipJSON_InvalidJSON tests error handling. +func TestParsePipJSON_InvalidJSON(t *testing.T) { + pkgs := parsePipJSON("not valid json") + if pkgs != nil { + t.Errorf("parsePipJSON with invalid JSON = %v, want nil", pkgs) + } +} + +// TestParseNpmJSON_InvalidJSON tests error handling. +func TestParseNpmJSON_InvalidJSON(t *testing.T) { + pkgs := parseNpmJSON("not valid json") + if pkgs != nil { + t.Errorf("parseNpmJSON with invalid JSON = %v, want nil", pkgs) + } +} + +// TestParseNpmJSON_NoDependencies tests npm output with no dependencies. +func TestParseNpmJSON_NoDependencies(t *testing.T) { + pkgs := parseNpmJSON(`{"dependencies": null}`) + if len(pkgs) != 0 { + t.Errorf("parseNpmJSON with null dependencies = %d packages, want 0", len(pkgs)) + } +} + +// TestParseApkPersistFile_Deduplication ensures duplicates are removed. +func TestParseApkPersistFile_Deduplication(t *testing.T) { + content := "curl\ngithub-cli\ncurl\nopenssl\ngithub-cli\ncurl" + names := parseApkPersistFile(content) + + if len(names) != 3 { + t.Errorf("deduplication got %d packages, want 3", len(names)) + } + + seen := make(map[string]bool) + for _, name := range names { + if seen[name] { + t.Errorf("duplicate package found: %q", name) + } + seen[name] = true + } +} + +// TestParseApkPersistFile_PreservesOrder ensures first occurrence is kept. +func TestParseApkPersistFile_PreservesOrder(t *testing.T) { + content := "curl\ngithub-cli\nopenssl" + names := parseApkPersistFile(content) + + if len(names) != 3 { + t.Fatalf("got %d packages, want 3", len(names)) + } + + want := []string{"curl", "github-cli", "openssl"} + for i, w := range want { + if names[i] != w { + t.Errorf("names[%d] = %q, want %q", i, names[i], w) + } + } +} diff --git a/internal/skills/runtime_check.go b/internal/skills/runtime_check.go index 39d192ba..0293f3fb 100644 --- a/internal/skills/runtime_check.go +++ b/internal/skills/runtime_check.go @@ -2,6 +2,7 @@ package skills import ( "context" + "os" "os/exec" "strings" "time" @@ -32,7 +33,6 @@ func CheckRuntimes() *RuntimeStatus { {"pip3", "pip3", "--version", true}, {"node", "node", "--version", false}, {"npm", "npm", "--version", false}, - {"doas", "doas", "", false}, } status := &RuntimeStatus{Ready: true} @@ -55,6 +55,14 @@ func CheckRuntimes() *RuntimeStatus { status.Runtimes = append(status.Runtimes, info) } + // Check pkg-helper socket availability (not a binary, but a Unix socket). + pkgInfo := RuntimeInfo{Name: "pkg-helper"} + if fi, err := os.Stat(pkgHelperSocket); err == nil && fi.Mode().Type()&os.ModeSocket != 0 { + pkgInfo.Available = true + pkgInfo.Version = "socket" + } + status.Runtimes = append(status.Runtimes, pkgInfo) + return status } diff --git a/internal/store/context.go b/internal/store/context.go index 2f0461b4..bccc13d3 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -25,8 +25,21 @@ const ( LocaleKey contextKey = "goclaw_locale" // SharedMemoryKey indicates memory should be shared (no per-user scoping). SharedMemoryKey contextKey = "goclaw_shared_memory" + // ShellDenyGroupsKey holds per-agent shell deny group overrides. + ShellDenyGroupsKey contextKey = "goclaw_shell_deny_groups" ) +// WithShellDenyGroups returns a new context with shell deny group overrides. +func WithShellDenyGroups(ctx context.Context, groups map[string]bool) context.Context { + return context.WithValue(ctx, ShellDenyGroupsKey, groups) +} + +// ShellDenyGroupsFromContext returns shell deny group overrides from the context, or nil. +func ShellDenyGroupsFromContext(ctx context.Context) map[string]bool { + v, _ := ctx.Value(ShellDenyGroupsKey).(map[string]bool) + return v +} + // WithUserID returns a new context with the given user ID. func WithUserID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, UserIDKey, id) diff --git a/internal/tools/dynamic_tool.go b/internal/tools/dynamic_tool.go index ae9b291b..58e19ae1 100644 --- a/internal/tools/dynamic_tool.go +++ b/internal/tools/dynamic_tool.go @@ -43,8 +43,8 @@ func (t *DynamicTool) Execute(ctx context.Context, args map[string]any) *Result // Render command template with shell-escaped args command := renderCommand(t.def.Command, args) - // Check deny patterns (same package — direct access to DefaultDenyPatterns) - for _, pattern := range DefaultDenyPatterns { + // Check deny patterns (uses all defaults for dynamic tools — no per-agent override) + for _, pattern := range DefaultDenyPatterns() { if pattern.MatchString(command) { return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) } diff --git a/internal/tools/policy.go b/internal/tools/policy.go index e593bcb6..c664280b 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -3,11 +3,15 @@ package tools import ( "log/slog" "strings" + "sync" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" ) +// toolGroupsMu protects toolGroups from concurrent access. +var toolGroupsMu sync.RWMutex + // Tool groups map group names to tool names. var toolGroups = map[string][]string{ "memory": {"memory_search", "memory_get"}, @@ -36,12 +40,16 @@ var toolGroups = map[string][]string{ // RegisterToolGroup adds or replaces a dynamic tool group. // Used by the MCP manager to register "mcp" and "mcp:{serverName}" groups. func RegisterToolGroup(name string, members []string) { + toolGroupsMu.Lock() toolGroups[name] = members + toolGroupsMu.Unlock() } // UnregisterToolGroup removes a dynamic tool group. func UnregisterToolGroup(name string) { + toolGroupsMu.Lock() delete(toolGroups, name) + toolGroupsMu.Unlock() } // Tool profiles define preset allow sets. @@ -239,6 +247,9 @@ func (pe *PolicyEngine) applyProfile(allTools []string, profile string) []string // expandSpec expands a spec list (which may contain "group:xxx") into concrete tool names, // filtered against available tools. func expandSpec(available []string, spec []string) []string { + toolGroupsMu.RLock() + defer toolGroupsMu.RUnlock() + expanded := make(map[string]bool) for _, s := range spec { if after, ok := strings.CutPrefix(s, "group:"); ok { @@ -264,6 +275,9 @@ func expandSpec(available []string, spec []string) []string { // intersectWithSpec keeps only tools in `current` that match the spec (with group expansion). func intersectWithSpec(current []string, spec []string) []string { + toolGroupsMu.RLock() + defer toolGroupsMu.RUnlock() + expanded := make(map[string]bool) for _, s := range spec { if after, ok := strings.CutPrefix(s, "group:"); ok { @@ -289,6 +303,9 @@ func intersectWithSpec(current []string, spec []string) []string { // subtractSpec removes tools matching the spec (with group expansion) from current. func subtractSpec(current []string, spec []string) []string { + toolGroupsMu.RLock() + defer toolGroupsMu.RUnlock() + denied := make(map[string]bool) for _, s := range spec { if after, ok := strings.CutPrefix(s, "group:"); ok { @@ -362,6 +379,9 @@ func (pe *PolicyEngine) IsDenied(name string, agentPolicy *config.ToolPolicySpec // matchDenySpec returns true if name matches any entry in the deny spec (with group expansion). func matchDenySpec(name string, spec []string) bool { + toolGroupsMu.RLock() + defer toolGroupsMu.RUnlock() + for _, s := range spec { if after, ok := strings.CutPrefix(s, "group:"); ok { if members, ok := toolGroups[after]; ok { diff --git a/internal/tools/publish_skill.go b/internal/tools/publish_skill.go index 9e28d003..637fb1bb 100644 --- a/internal/tools/publish_skill.go +++ b/internal/tools/publish_skill.go @@ -177,7 +177,12 @@ func (t *PublishSkillTool) Execute(ctx context.Context, args map[string]any) *Re result += "\n- Granted to current agent" } if depsWarning != "" { - result += fmt.Sprintf("\n\n⚠ Missing dependencies: %s\nTry installing them with exec (e.g. pip install or npm install ). If you cannot install system binaries, inform the user.", depsWarning) + denyGroups := store.ShellDenyGroupsFromContext(ctx) + if IsGroupDenied(denyGroups, "package_install") { + result += fmt.Sprintf("\n\n⚠ Missing dependencies: %s\nPackage installation is restricted. Inform the user to install via Web UI Packages page.", depsWarning) + } else { + result += fmt.Sprintf("\n\n⚠ Missing dependencies: %s\nTry installing them with exec (e.g. pip install or npm install ).", depsWarning) + } } return NewResult(result) diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 48b58909..cc0b2f25 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -16,169 +16,64 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -// Dangerous command patterns to deny by default. -// Defense-in-depth: these patterns complement Docker hardening (cap-drop ALL, -// read-only rootfs, no-new-privileges, pids-limit, memory limit). +// Dangerous command patterns organized into configurable deny groups. +// Defense-in-depth: patterns complement Docker hardening (cap-drop ALL, +// no-new-privileges, pids-limit, memory limit). // Sources: OWASP Agentic AI Top 10, Claude Code CVE-2025-66032, MITRE ATT&CK, // PayloadsAllTheThings, Trail of Bits prompt-injection-to-RCE research. -// DefaultDenyPatterns contains dangerous command patterns to deny by default. -var DefaultDenyPatterns = []*regexp.Regexp{ - // ── Destructive file operations ── - regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), - regexp.MustCompile(`\brm\s+.*--recursive`), - regexp.MustCompile(`\brm\s+.*--force`), - regexp.MustCompile(`\bdel\s+/[fq]\b`), - regexp.MustCompile(`\brmdir\s+/s\b`), - regexp.MustCompile(`\b(mkfs|diskpart)\b|\bformat\s`), - regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), - regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), // fork bomb +// Groups and patterns defined in shell_deny_groups.go. - // ── Data exfiltration ── - regexp.MustCompile(`\bcurl\b.*\|\s*(ba)?sh\b`), // curl | sh - regexp.MustCompile(`\bcurl\b.*(-d\b|-F\b|--data|--upload|--form|-T\b|-X\s*P(UT|OST|ATCH))`), // curl POST/PUT - regexp.MustCompile(`\bwget\b.*-O\s*-\s*\|\s*(ba)?sh\b`), // wget | sh - regexp.MustCompile(`\bwget\b.*--post-(data|file)`), // wget POST - regexp.MustCompile(`\b(nslookup|dig|host)\b`), // DNS exfiltration - regexp.MustCompile(`/dev/tcp/`), // bash tcp redirect - - // ── Reverse shells ── - regexp.MustCompile(`\b(nc|ncat|netcat)\b.*-[el]\b`), - regexp.MustCompile(`\bsocat\b`), - regexp.MustCompile(`\bopenssl\b.*s_client`), - regexp.MustCompile(`\btelnet\b.*\d+`), - regexp.MustCompile(`\bpython[23]?\b.*\bimport\s+(socket|http\.client|urllib|requests)\b`), - regexp.MustCompile(`\bperl\b.*-e\s*.*\b[Ss]ocket\b`), - regexp.MustCompile(`\bruby\b.*-e\s*.*\b(TCPSocket|Socket)\b`), - regexp.MustCompile(`\bnode\b.*-e\s*.*\b(net\.connect|child_process)\b`), - regexp.MustCompile(`\bawk\b.*/inet/`), // awk built-in networking - regexp.MustCompile(`\bmkfifo\b`), // named pipes for shell redirection - - // ── Dangerous eval / code injection ── - regexp.MustCompile(`\beval\s*\$`), - regexp.MustCompile(`\bbase64\s+-d\b.*\|\s*(ba)?sh\b`), - - // ── Privilege escalation ── - regexp.MustCompile(`\bsudo\b`), - regexp.MustCompile(`\bsu\s+-`), - regexp.MustCompile(`\bnsenter\b`), - regexp.MustCompile(`\bunshare\b`), - regexp.MustCompile(`\b(mount|umount)\b`), - regexp.MustCompile(`\b(capsh|setcap|getcap)\b`), - - // ── Dangerous path operations ── - regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\s+/`), - regexp.MustCompile(`\bchown\b.*\s+/`), - regexp.MustCompile(`\bchmod\b.*\+x.*/tmp/`), // make tmpfs executable - regexp.MustCompile(`\bchmod\b.*\+x.*/var/tmp/`), - regexp.MustCompile(`\bchmod\b.*\+x.*/dev/shm/`), - - // ── Environment variable injection ── - regexp.MustCompile(`\bLD_PRELOAD\s*=`), - regexp.MustCompile(`\bDYLD_INSERT_LIBRARIES\s*=`), - regexp.MustCompile(`\bLD_LIBRARY_PATH\s*=`), - regexp.MustCompile(`/etc/ld\.so\.preload`), - regexp.MustCompile(`\bGIT_EXTERNAL_DIFF\s*=`), // git diff arbitrary code exec - regexp.MustCompile(`\bGIT_DIFF_OPTS\s*=`), // git diff behavior injection - regexp.MustCompile(`\bBASH_ENV\s*=`), // shell init injection - regexp.MustCompile(`\bENV\s*=.*\bsh\b`), // sh init injection - - // ── Container escape ── - regexp.MustCompile(`/var/run/docker\.sock|docker\.(sock|socket)`), - regexp.MustCompile(`/proc/sys/(kernel|fs|net)/`), // proc writes - regexp.MustCompile(`/sys/(kernel|fs|class|devices)/`), // sysfs manipulation - - // ── Crypto mining ── - regexp.MustCompile(`\b(xmrig|cpuminer|minerd|cgminer|bfgminer|ethminer|nbminer|t-rex|phoenixminer|lolminer|gminer|claymore)\b`), - regexp.MustCompile(`stratum\+tcp://|stratum\+ssl://`), - - // ── Filter bypass (Claude Code CVE-2025-66032) ── - regexp.MustCompile(`\bsed\b.*['"]/e\b`), // sed /e command execution - regexp.MustCompile(`\bsort\b.*--compress-program`), // sort arbitrary exec - regexp.MustCompile(`\bgit\b.*(--upload-pack|--receive-pack|--exec)=`), // git exec flags - regexp.MustCompile(`\b(rg|grep)\b.*--pre=`), // preprocessor execution - regexp.MustCompile(`\bman\b.*--html=`), // man command injection - regexp.MustCompile(`\bhistory\b.*-[saw]\b`), // history file injection - regexp.MustCompile(`\$\{[^}]*@[PpEeAaKk]\}`), // ${var@P} parameter expansion - - // ── Network abuse / reconnaissance ── - regexp.MustCompile(`\b(nmap|masscan|zmap|rustscan)\b`), - regexp.MustCompile(`\b(ssh|scp|sftp)\b.*@`), // outbound SSH - regexp.MustCompile(`\b(chisel|frp|ngrok|cloudflared|bore|localtunnel)\b`), // tunneling tools - - // ── Persistence ── - regexp.MustCompile(`\bcrontab\b`), - regexp.MustCompile(`>\s*~/?\.(bashrc|bash_profile|profile|zshrc)`), // shell RC injection - regexp.MustCompile(`\btee\b.*\.(bashrc|bash_profile|profile|zshrc)`), - - // ── Process manipulation ── - regexp.MustCompile(`\bkill\s+-9\s`), - regexp.MustCompile(`\b(killall|pkill)\b`), - - // ── Environment variable dumping ── - // Bare env/printenv/set/export dumps all vars including secrets (API keys, DSN, encryption keys). - // 'env VAR=val cmd' (env with assignment before command) is still allowed. - regexp.MustCompile(`^\s*env\s*$`), // bare 'env' - regexp.MustCompile(`^\s*env\s*\|`), // 'env | ...' (piped) - regexp.MustCompile(`^\s*env\s*>\s`), // 'env > file' - regexp.MustCompile(`\bprintenv\b`), // any printenv usage - regexp.MustCompile(`^\s*(set|export\s+-p|declare\s+-x)\s*($|\|)`), // shell var dumps - regexp.MustCompile(`\bcompgen\s+-e\b`), // bash env completion dump - regexp.MustCompile(`/proc/[^/]+/environ`), // /proc/PID/environ (leaks all env vars) - regexp.MustCompile(`/proc/self/environ`), // /proc/self/environ - regexp.MustCompile(`(?i)\bstrings\b.*/proc/`), // strings on /proc files (binary env dump) +// DefaultDenyPatterns returns all patterns from groups where Default=true. +// Backward-compatible wrapper for code that doesn't use per-agent overrides. +func DefaultDenyPatterns() []*regexp.Regexp { + return ResolveDenyPatterns(nil) } // ExecTool executes shell commands, optionally inside a sandbox container. type ExecTool struct { - workingDir string - timeout time.Duration - denyPatterns []*regexp.Regexp - denyExemptions []string // substrings that exempt a command from deny (e.g. ".goclaw/skills-store/") - restrict bool - sandboxMgr sandbox.Manager // nil = no sandbox, execute on host - approvalMgr *ExecApprovalManager // nil = no approval needed - agentID string // for approval request context - secureCLIStore store.SecureCLIStore // nil = no credentialed exec + workingDir string + timeout time.Duration + pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths) + denyExemptions []string // substrings that exempt a command from deny + restrict bool + sandboxMgr sandbox.Manager // nil = no sandbox, execute on host + approvalMgr *ExecApprovalManager // nil = no approval needed + agentID string // for approval request context + secureCLIStore store.SecureCLIStore // nil = no credentialed exec } // NewExecTool creates an exec tool that runs commands directly on the host. func NewExecTool(workingDir string, restrict bool) *ExecTool { return &ExecTool{ - workingDir: workingDir, - timeout: 60 * time.Second, - denyPatterns: DefaultDenyPatterns, - restrict: restrict, + workingDir: workingDir, + timeout: 60 * time.Second, + restrict: restrict, } } // NewSandboxedExecTool creates an exec tool that routes commands through a sandbox container. func NewSandboxedExecTool(workingDir string, restrict bool, mgr sandbox.Manager) *ExecTool { return &ExecTool{ - workingDir: workingDir, - timeout: 300 * time.Second, // sandbox allows longer timeout - denyPatterns: DefaultDenyPatterns, - restrict: restrict, - sandboxMgr: mgr, + workingDir: workingDir, + timeout: 300 * time.Second, // sandbox allows longer timeout + restrict: restrict, + sandboxMgr: mgr, } } // SetSandboxKey is a no-op; sandbox key is now read from ctx (thread-safe). func (t *ExecTool) SetSandboxKey(key string) {} -// DenyPaths adds dynamic deny patterns that block commands referencing the given paths. -// Used to prevent exec from reading/copying files from sensitive directories (e.g. data dir, .goclaw). +// DenyPaths adds always-on deny patterns that block commands referencing the given paths. +// These are NOT configurable via deny groups — they always apply regardless of group config. func (t *ExecTool) DenyPaths(paths ...string) { for _, p := range paths { escaped := regexp.QuoteMeta(p) - t.denyPatterns = append(t.denyPatterns, regexp.MustCompile(escaped)) + t.pathDenyPatterns = append(t.pathDenyPatterns, regexp.MustCompile(escaped)) } } // AllowPathExemptions adds substrings that exempt a command from deny pattern matches. -// E.g. AllowPathExemptions(".goclaw/skills-store/") lets commands referencing -// skills-store pass even though ".goclaw/" is denied. func (t *ExecTool) AllowPathExemptions(substrings ...string) { t.denyExemptions = append(t.denyExemptions, substrings...) } @@ -190,7 +85,6 @@ func (t *ExecTool) SetApprovalManager(mgr *ExecApprovalManager, agentID string) } // SetSecureCLIStore sets the credential store for credentialed exec. -// When set, commands matching a configured binary use Direct Exec Mode. func (t *ExecTool) SetSecureCLIStore(s store.SecureCLIStore) { t.secureCLIStore = s } @@ -220,8 +114,23 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { return ErrorResult("command is required") } - // Check for dangerous commands (applies to both host and sandbox) - for _, pattern := range t.denyPatterns { + // Resolve deny patterns: per-agent overrides from context, fallback to all defaults. + denyOverrides := store.ShellDenyGroupsFromContext(ctx) + groupPatterns := ResolveDenyPatterns(denyOverrides) + + // Also resolve package_install patterns separately for approval routing. + var pkgInstallPatterns []*regexp.Regexp + if pkgGroup, ok := DenyGroupRegistry["package_install"]; ok && IsGroupDenied(denyOverrides, "package_install") { + pkgInstallPatterns = pkgGroup.Patterns + } + + // Combine group-based patterns + always-on path denials. + allPatterns := make([]*regexp.Regexp, 0, len(groupPatterns)+len(t.pathDenyPatterns)) + allPatterns = append(allPatterns, groupPatterns...) + allPatterns = append(allPatterns, t.pathDenyPatterns...) + + // Check for dangerous commands (applies to both host and sandbox). + for _, pattern := range allPatterns { if pattern.MatchString(command) { // Check if any exemption applies (e.g. skills-store within .goclaw) exempt := false @@ -231,9 +140,26 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { break } } - if !exempt { - return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) + if exempt { + continue } + + // Package install commands: route through approval flow instead of hard deny. + // This lets agents "request permission" from admin to install packages. + if t.approvalMgr != nil && matchesAny(command, pkgInstallPatterns) { + slog.Info("exec: package install requires approval", "command", truncateCmd(command, 100), "agent", t.agentID) + decision, err := t.approvalMgr.RequestApproval(command, t.agentID, 2*time.Minute) + if err != nil { + return ErrorResult(fmt.Sprintf("package install approval: %v", err)) + } + if decision == ApprovalDeny { + return ErrorResult("package installation denied by admin") + } + // Approved — skip deny, continue to execution. + continue + } + + return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) } } @@ -300,6 +226,16 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { return t.executeOnHost(ctx, command, cwd) } +// matchesAny checks if a command matches any pattern in the list. +func matchesAny(command string, patterns []*regexp.Regexp) bool { + for _, p := range patterns { + if p.MatchString(command) { + return true + } + } + return false +} + // executeOnHost runs a command directly on the host (original behavior). func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Result { ctx, cancel := context.WithTimeout(ctx, t.timeout) diff --git a/internal/tools/shell_deny_groups.go b/internal/tools/shell_deny_groups.go new file mode 100644 index 00000000..9f344763 --- /dev/null +++ b/internal/tools/shell_deny_groups.go @@ -0,0 +1,259 @@ +package tools + +import ( + "regexp" +) + +// DenyGroup is a named set of shell command deny patterns. +type DenyGroup struct { + Name string // machine name: "package_install" + Description string // human label: "Package Installation" + Default bool // true = denied by default + Patterns []*regexp.Regexp // deny patterns for this group +} + +// DenyGroupRegistry holds all known deny groups, keyed by name. +// All groups are ON (denied) by default — admin must explicitly allow. +var DenyGroupRegistry = map[string]*DenyGroup{ + "destructive_ops": { + Name: "destructive_ops", + Description: "Destructive Operations", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), + regexp.MustCompile(`\brm\s+.*--recursive`), + regexp.MustCompile(`\brm\s+.*--force`), + regexp.MustCompile(`\bdel\s+/[fq]\b`), + regexp.MustCompile(`\brmdir\s+/s\b`), + regexp.MustCompile(`\b(mkfs|diskpart)\b|\bformat\s`), + regexp.MustCompile(`\bdd\s+if=`), + regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), + regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), // fork bomb + }, + }, + "data_exfiltration": { + Name: "data_exfiltration", + Description: "Data Exfiltration", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bcurl\b.*\|\s*(ba)?sh\b`), // curl | sh + regexp.MustCompile(`\bcurl\b.*(-d\b|-F\b|--data|--upload|--form|-T\b|(-X|--request)\s*P(UT|OST|ATCH))`), // curl POST/PUT + regexp.MustCompile(`\bwget\b.*-O\s*-\s*\|\s*(ba)?sh\b`), // wget | sh + regexp.MustCompile(`\bwget\b.*(--post-(data|file)|--method=P(UT|OST|ATCH)|--body-data)`), // wget POST + regexp.MustCompile(`\b(nslookup|dig|host)\b`), // DNS exfiltration + regexp.MustCompile(`/dev/tcp/`), // bash tcp redirect + regexp.MustCompile(`\b(curl|wget)\b.*\blocalhost\b`), // curl/wget to localhost + regexp.MustCompile(`\b(curl|wget)\b.*\b127\.0\.0\.1\b`), // curl/wget to 127.0.0.1 + regexp.MustCompile(`\b(curl|wget)\b.*\b0\.0\.0\.0\b`), // curl/wget to 0.0.0.0 + }, + }, + "reverse_shell": { + Name: "reverse_shell", + Description: "Reverse Shell", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\b(nc|ncat|netcat)\b.*(\s+-[a-z]|\s+\d+\.\d+\.\d+\.\d+|\s+localhost\b)`), + regexp.MustCompile(`\bsocat\b`), + regexp.MustCompile(`\bopenssl\b.*s_client`), + regexp.MustCompile(`\btelnet\b.*\d+`), + regexp.MustCompile(`\bpython[23]?\b.*(import|from)\s+(socket|http|urllib|requests|httpx|aiohttp)\b`), + regexp.MustCompile(`\bperl\b.*-e\s*.*\b[Ss]ocket\b`), + regexp.MustCompile(`\bruby\b.*-e\s*.*\b(TCPSocket|Socket)\b`), + regexp.MustCompile(`\bnode\b.*-e\s*.*\b(net\.|http\.|https\.|fetch\(|axios|got\(|undici)\b`), + regexp.MustCompile(`\bnode\b.*-e\s*.*require\s*\(\s*['"]https?['"]\s*\)`), + regexp.MustCompile(`\bawk\b.*/inet/`), + regexp.MustCompile(`\bmkfifo\b`), + }, + }, + "code_injection": { + Name: "code_injection", + Description: "Code Injection & Eval", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\beval\s*\$`), + regexp.MustCompile(`\bbase64\s+-d\b.*\|\s*(ba)?sh\b`), + }, + }, + "privilege_escalation": { + Name: "privilege_escalation", + Description: "Privilege Escalation", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bsu\s+-`), + regexp.MustCompile(`\bnsenter\b`), + regexp.MustCompile(`\bunshare\b`), + regexp.MustCompile(`\b(mount|umount)\b`), + regexp.MustCompile(`\b(capsh|setcap|getcap)\b`), + }, + }, + "dangerous_paths": { + Name: "dangerous_paths", + Description: "Dangerous Path Operations", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\s+/`), + regexp.MustCompile(`\bchown\b.*\s+/`), + regexp.MustCompile(`\bchmod\b.*\+x.*/tmp/`), + regexp.MustCompile(`\bchmod\b.*\+x.*/var/tmp/`), + regexp.MustCompile(`\bchmod\b.*\+x.*/dev/shm/`), + }, + }, + "env_injection": { + Name: "env_injection", + Description: "Environment Variable Injection", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bLD_PRELOAD\s*=`), + regexp.MustCompile(`\bDYLD_INSERT_LIBRARIES\s*=`), + regexp.MustCompile(`\bLD_LIBRARY_PATH\s*=`), + regexp.MustCompile(`/etc/ld\.so\.preload`), + regexp.MustCompile(`\bGIT_EXTERNAL_DIFF\s*=`), + regexp.MustCompile(`\bGIT_DIFF_OPTS\s*=`), + regexp.MustCompile(`\bBASH_ENV\s*=`), + regexp.MustCompile(`\bENV\s*=.*\bsh\b`), + }, + }, + "container_escape": { + Name: "container_escape", + Description: "Container Escape", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`/var/run/docker\.sock|docker\.(sock|socket)`), + regexp.MustCompile(`/proc/sys/(kernel|fs|net)/`), + regexp.MustCompile(`/sys/(kernel|fs|class|devices)/`), + }, + }, + "crypto_mining": { + Name: "crypto_mining", + Description: "Crypto Mining", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\b(xmrig|cpuminer|minerd|cgminer|bfgminer|ethminer|nbminer|t-rex|phoenixminer|lolminer|gminer|claymore)\b`), + regexp.MustCompile(`stratum\+tcp://|stratum\+ssl://`), + }, + }, + "filter_bypass": { + Name: "filter_bypass", + Description: "Filter Bypass (CVE mitigations)", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bsed\b.*['"]/e\b`), + regexp.MustCompile(`\bsort\b.*--compress-program`), + regexp.MustCompile(`\bgit\b.*(--upload-pack|--receive-pack|--exec)=`), + regexp.MustCompile(`\b(rg|grep)\b.*--pre=`), + regexp.MustCompile(`\bman\b.*--html=`), + regexp.MustCompile(`\bhistory\b.*-[saw]\b`), + regexp.MustCompile(`\$\{[^}]*@[PpEeAaKk]\}`), + }, + }, + "network_recon": { + Name: "network_recon", + Description: "Network Reconnaissance & Tunneling", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\b(nmap|masscan|zmap|rustscan)\b`), + regexp.MustCompile(`\b(ssh|scp|sftp)\b.*@`), + regexp.MustCompile(`\b(chisel|frp|ngrok|cloudflared|bore|localtunnel)\b`), + }, + }, + "package_install": { + Name: "package_install", + Description: "Package Installation", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bpip3?\s+install\b`), + regexp.MustCompile(`\bnpm\s+install\b`), + regexp.MustCompile(`\bnpm\s+i\b`), + regexp.MustCompile(`\bapk\s+(add|del)\b`), + regexp.MustCompile(`\bdoas\s+apk\b`), + regexp.MustCompile(`\byarn\s+(add|global)\b`), + regexp.MustCompile(`\bpnpm\s+(add|install)\b`), + regexp.MustCompile(`\bpip3?\s+uninstall\b`), + regexp.MustCompile(`\bnpm\s+uninstall\b`), + regexp.MustCompile(`\bpython[23]?\b.*-m\s+pip\b`), + }, + }, + "persistence": { + Name: "persistence", + Description: "Persistence Mechanisms", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bcrontab\b`), + regexp.MustCompile(`>\s*~/?\.(bashrc|bash_profile|profile|zshrc)`), + regexp.MustCompile(`\btee\b.*\.(bashrc|bash_profile|profile|zshrc)`), + }, + }, + "process_control": { + Name: "process_control", + Description: "Process Manipulation", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`\bkill\s+-9\s`), + regexp.MustCompile(`\b(killall|pkill)\b`), + }, + }, + "env_dump": { + Name: "env_dump", + Description: "Environment Variable Dumping", + Default: true, + Patterns: []*regexp.Regexp{ + regexp.MustCompile(`^\s*env\s*$`), + regexp.MustCompile(`^\s*env\s*\|`), + regexp.MustCompile(`^\s*env\s*>\s`), + regexp.MustCompile(`\bprintenv\b`), + regexp.MustCompile(`^\s*(set|export\s+-p|declare\s+-x)\s*($|\|)`), + regexp.MustCompile(`\bcompgen\s+-e\b`), + regexp.MustCompile(`/proc/[^/]+/environ`), + regexp.MustCompile(`/proc/self/environ`), + regexp.MustCompile(`(?i)\bstrings\b.*/proc/`), + regexp.MustCompile(`(?i)\becho\b.*\$\{?GOCLAW_(GATEWAY_TOKEN|ENCRYPTION_KEY|POSTGRES_DSN)`), + regexp.MustCompile(`(?i)\bprintf\b.*\$\{?GOCLAW_(GATEWAY_TOKEN|ENCRYPTION_KEY|POSTGRES_DSN)`), + regexp.MustCompile(`\bpython[23]?\b.*os\.(environ|getenv).*GOCLAW_`), + regexp.MustCompile(`\bnode\b.*-e.*process\.env\.GOCLAW_`), + }, + }, +} + +// DenyGroupNames returns all registered group names in stable order. +func DenyGroupNames() []string { + return []string{ + "destructive_ops", "data_exfiltration", "reverse_shell", "code_injection", + "privilege_escalation", "dangerous_paths", "env_injection", "container_escape", + "crypto_mining", "filter_bypass", "network_recon", "package_install", + "persistence", "process_control", "env_dump", + } +} + +// ResolveDenyPatterns merges group defaults with overrides and returns the effective deny patterns. +// overrides: map[groupName]enabled — true=deny, false=allow. nil = all defaults. +func ResolveDenyPatterns(overrides map[string]bool) []*regexp.Regexp { + var patterns []*regexp.Regexp + for _, name := range DenyGroupNames() { + group := DenyGroupRegistry[name] + enabled := group.Default + if overrides != nil { + if v, ok := overrides[name]; ok { + enabled = v + } + } + if enabled { + patterns = append(patterns, group.Patterns...) + } + } + return patterns +} + +// IsGroupDenied checks if a specific deny group is active for the given context. +func IsGroupDenied(overrides map[string]bool, group string) bool { + if overrides != nil { + if v, ok := overrides[group]; ok { + return v + } + } + if g, ok := DenyGroupRegistry[group]; ok { + return g.Default + } + return true // unknown group = denied +} diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index c22a3d98..3632ab4b 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -40,7 +40,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index eaf95c32..c7ae94c9 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -14,6 +14,7 @@ import { Users, Link, Package, + Blocks, Plug, Volume2, Cpu, @@ -113,6 +114,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { + diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index ff2ce1ea..cd1d1b88 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -33,6 +33,7 @@ import enContacts from "./locales/en/contacts.json"; import enActivity from "./locales/en/activity.json"; import enApiKeys from "./locales/en/api-keys.json"; import enCliCredentials from "./locales/en/cli-credentials.json"; +import enPackages from "./locales/en/packages.json"; // --- VI namespaces --- import viCommon from "./locales/vi/common.json"; @@ -66,6 +67,7 @@ import viContacts from "./locales/vi/contacts.json"; import viActivity from "./locales/vi/activity.json"; import viApiKeys from "./locales/vi/api-keys.json"; import viCliCredentials from "./locales/vi/cli-credentials.json"; +import viPackages from "./locales/vi/packages.json"; // --- ZH namespaces --- import zhCommon from "./locales/zh/common.json"; @@ -99,6 +101,7 @@ import zhContacts from "./locales/zh/contacts.json"; import zhActivity from "./locales/zh/activity.json"; import zhApiKeys from "./locales/zh/api-keys.json"; import zhCliCredentials from "./locales/zh/cli-credentials.json"; +import zhPackages from "./locales/zh/packages.json"; const STORAGE_KEY = "goclaw:language"; @@ -117,7 +120,7 @@ const ns = [ "channels", "providers", "traces", "events", "usage", "approvals", "nodes", "logs", "tools", "mcp", "tts", "setup", "memory", "storage", "pending-messages", "contacts", "activity", "api-keys", - "cli-credentials", + "cli-credentials", "packages", ] as const; i18n.use(initReactI18next).init({ @@ -133,6 +136,7 @@ i18n.use(initReactI18next).init({ "pending-messages": enPendingMessages, contacts: enContacts, activity: enActivity, "api-keys": enApiKeys, "cli-credentials": enCliCredentials, + packages: enPackages, }, vi: { common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin, @@ -145,6 +149,7 @@ i18n.use(initReactI18next).init({ "pending-messages": viPendingMessages, contacts: viContacts, activity: viActivity, "api-keys": viApiKeys, "cli-credentials": viCliCredentials, + packages: viPackages, }, zh: { common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin, @@ -157,6 +162,7 @@ i18n.use(initReactI18next).init({ "pending-messages": zhPendingMessages, contacts: zhContacts, activity: zhActivity, "api-keys": zhApiKeys, "cli-credentials": zhCliCredentials, + packages: zhPackages, }, }, ns: [...ns], diff --git a/ui/web/src/i18n/locales/en/packages.json b/ui/web/src/i18n/locales/en/packages.json new file mode 100644 index 00000000..f29d2bdf --- /dev/null +++ b/ui/web/src/i18n/locales/en/packages.json @@ -0,0 +1,43 @@ +{ + "title": "Runtime & Packages", + "description": "Manage system runtimes and installed packages", + "runtimes": { + "title": "Runtimes", + "available": "Available", + "missing": "Missing" + }, + "system": { + "title": "System Packages", + "placeholder": "Package name (e.g. github-cli)" + }, + "pip": { + "title": "Python Packages", + "placeholder": "Package name (e.g. pandas)" + }, + "npm": { + "title": "Node Packages", + "placeholder": "Package name (e.g. typescript)" + }, + "actions": { + "install": "Install", + "uninstall": "Uninstall", + "installing": "Installing...", + "uninstalling": "Removing..." + }, + "messages": { + "installSuccess": "{{name}} installed successfully", + "installError": "Failed to install {{name}}", + "uninstallSuccess": "{{name}} removed successfully", + "uninstallError": "Failed to remove {{name}}" + }, + "confirmUninstall": { + "title": "Confirm Uninstall", + "description": "Are you sure you want to remove {{name}}? This action cannot be undone." + }, + "table": { + "name": "Name", + "version": "Version", + "actions": "Actions", + "empty": "No packages installed" + } +} diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index b92a1904..eb010653 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -37,6 +37,7 @@ "activity": "Activity", "cliCredentials": "CLI Credentials", "apiKeys": "API Keys", - "apiDocs": "API Docs" + "apiDocs": "API Docs", + "packages": "Packages" } } diff --git a/ui/web/src/i18n/locales/vi/packages.json b/ui/web/src/i18n/locales/vi/packages.json new file mode 100644 index 00000000..56fff9d9 --- /dev/null +++ b/ui/web/src/i18n/locales/vi/packages.json @@ -0,0 +1,43 @@ +{ + "title": "Runtime & Gói phần mềm", + "description": "Quản lý runtime hệ thống và các gói đã cài đặt", + "runtimes": { + "title": "Runtimes", + "available": "Sẵn sàng", + "missing": "Chưa cài" + }, + "system": { + "title": "Gói hệ thống", + "placeholder": "Tên gói (vd: github-cli)" + }, + "pip": { + "title": "Gói Python", + "placeholder": "Tên gói (vd: pandas)" + }, + "npm": { + "title": "Gói Node", + "placeholder": "Tên gói (vd: typescript)" + }, + "actions": { + "install": "Cài đặt", + "uninstall": "Gỡ bỏ", + "installing": "Đang cài...", + "uninstalling": "Đang gỡ..." + }, + "messages": { + "installSuccess": "Đã cài {{name}} thành công", + "installError": "Cài {{name}} thất bại", + "uninstallSuccess": "Đã gỡ {{name}} thành công", + "uninstallError": "Gỡ {{name}} thất bại" + }, + "confirmUninstall": { + "title": "Xác nhận gỡ bỏ", + "description": "Bạn có chắc muốn gỡ {{name}}? Thao tác này không thể hoàn tác." + }, + "table": { + "name": "Tên", + "version": "Phiên bản", + "actions": "Thao tác", + "empty": "Chưa có gói nào được cài" + } +} diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index 0e42735f..5aaf5278 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -37,6 +37,7 @@ "activity": "Hoạt động", "cliCredentials": "Xác thực CLI", "apiKeys": "Khóa API", - "apiDocs": "Tài liệu API" + "apiDocs": "Tài liệu API", + "packages": "Gói phần mềm" } } diff --git a/ui/web/src/i18n/locales/zh/packages.json b/ui/web/src/i18n/locales/zh/packages.json new file mode 100644 index 00000000..35fa4c46 --- /dev/null +++ b/ui/web/src/i18n/locales/zh/packages.json @@ -0,0 +1,43 @@ +{ + "title": "运行时与软件包", + "description": "管理系统运行时和已安装的软件包", + "runtimes": { + "title": "运行时", + "available": "可用", + "missing": "缺失" + }, + "system": { + "title": "系统软件包", + "placeholder": "包名(例如 github-cli)" + }, + "pip": { + "title": "Python 软件包", + "placeholder": "包名(例如 pandas)" + }, + "npm": { + "title": "Node 软件包", + "placeholder": "包名(例如 typescript)" + }, + "actions": { + "install": "安装", + "uninstall": "卸载", + "installing": "安装中...", + "uninstalling": "卸载中..." + }, + "messages": { + "installSuccess": "{{name}} 安装成功", + "installError": "{{name}} 安装失败", + "uninstallSuccess": "{{name}} 已卸载", + "uninstallError": "{{name}} 卸载失败" + }, + "confirmUninstall": { + "title": "确认卸载", + "description": "确定要卸载 {{name}} 吗?此操作不可撤销。" + }, + "table": { + "name": "名称", + "version": "版本", + "actions": "操作", + "empty": "暂无已安装的软件包" + } +} diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index 885b7c7a..0748e5d9 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -37,6 +37,7 @@ "activity": "活动日志", "cliCredentials": "CLI 凭证", "apiKeys": "API 密钥", - "apiDocs": "API 文档" + "apiDocs": "API 文档", + "packages": "软件包" } } diff --git a/ui/web/src/index.css b/ui/web/src/index.css index 830f09d2..f8d2fe4b 100644 --- a/ui/web/src/index.css +++ b/ui/web/src/index.css @@ -276,11 +276,22 @@ /* Links */ .md-render :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *)) { - color: var(--primary); - text-decoration: none; + color: oklch(0.55 0.2 250); + text-decoration: underline; + text-decoration-color: oklch(0.55 0.2 250 / 0.4); + text-underline-offset: 2px; } .md-render :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *)):hover { - text-decoration: underline; + color: oklch(0.48 0.22 250); + text-decoration-color: oklch(0.48 0.22 250 / 0.8); +} +:is(.dark) .md-render :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *)) { + color: oklch(0.72 0.18 250); + text-decoration-color: oklch(0.72 0.18 250 / 0.4); +} +:is(.dark) .md-render :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *)):hover { + color: oklch(0.80 0.16 250); + text-decoration-color: oklch(0.80 0.16 250 / 0.8); } /* Strong & emphasis */ diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index f8b10e4f..470a9c40 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -36,6 +36,7 @@ export const ROUTES = { KNOWLEDGE_GRAPH: "/knowledge-graph", ACTIVITY: "/activity", API_KEYS: "/api-keys", + PACKAGES: "/packages", SETUP: "/setup", } as const; diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 5d597fcd..83045d21 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -70,6 +70,10 @@ export const queryKeys = { all: ["memory"] as const, list: (params: Record) => ["memory", params] as const, }, + packages: { + all: ["packages"] as const, + runtimes: ["packages", "runtimes"] as const, + }, kg: { all: ["kg"] as const, list: (params: Record) => ["kg", params] as const, diff --git a/ui/web/src/pages/config/config-page.tsx b/ui/web/src/pages/config/config-page.tsx index 2370feeb..cb58093a 100644 --- a/ui/web/src/pages/config/config-page.tsx +++ b/ui/web/src/pages/config/config-page.tsx @@ -17,6 +17,7 @@ import { QuotaSection } from "./sections/quota-section"; import { ToolsProfileSection } from "./sections/tools-profile-section"; import { ToolsExecSection } from "./sections/tools-exec-section"; import { ToolsWebSection } from "./sections/tools-web-section"; +import { ShellSecuritySection } from "./sections/shell-security-section"; import { TtsSection } from "./sections/tts-section"; import { CronSection } from "./sections/cron-section"; import { TelemetrySection } from "./sections/telemetry-section"; @@ -148,6 +149,11 @@ export function ConfigPage() { onSave={(v) => patch({ tools: v })} saving={saving} /> + patch({ tools: v })} + saving={saving} + /> diff --git a/ui/web/src/pages/config/sections/shell-security-section.tsx b/ui/web/src/pages/config/sections/shell-security-section.tsx new file mode 100644 index 00000000..3fb43f6d --- /dev/null +++ b/ui/web/src/pages/config/sections/shell-security-section.tsx @@ -0,0 +1,119 @@ +import { useState, useEffect, useCallback } from "react"; +import { Save, Shield } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useHttp } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type ToolsData = Record; + +interface DenyGroupInfo { + name: string; + description: string; + default: boolean; +} + +interface Props { + data: ToolsData | undefined; + onSave: (value: ToolsData) => Promise; + saving: boolean; +} + +export function ShellSecuritySection({ data, onSave, saving }: Props) { + const { t } = useTranslation("config"); + const http = useHttp(); + const connected = useAuthStore((s) => s.connected); + + // Fetch available deny groups from API. + const { data: groupsData } = useQuery({ + queryKey: ["shell-deny-groups"], + queryFn: () => http.get<{ groups: DenyGroupInfo[] }>("/v1/shell-deny-groups"), + staleTime: 300_000, + enabled: connected, + }); + + const groups = groupsData?.groups ?? []; + + // Local draft of shellDenyGroups config. + const [draft, setDraft] = useState>({}); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + setDraft(data?.shellDenyGroups ?? {}); + setDirty(false); + }, [data]); + + const toggle = useCallback((name: string, enabled: boolean) => { + setDraft((prev) => ({ ...prev, [name]: enabled })); + setDirty(true); + }, []); + + const handleSave = async () => { + await onSave({ ...data, shellDenyGroups: draft }); + setDirty(false); + }; + + // Resolve effective state: draft override → group default. + const isGroupDenied = (g: DenyGroupInfo) => { + if (draft[g.name] !== undefined) return draft[g.name]; + return g.default; + }; + + if (!data || groups.length === 0) return null; + + return ( + + + + + {t("shellSecurity.title", "Shell Security Groups")} + + {dirty && ( + + )} + + +

+ {t("shellSecurity.description", "Control which command categories AI agents are blocked from executing. Disabling a group allows ALL agents to run those commands unless overridden per-agent.")} +

+ +
+ {groups.map((g) => { + const denied = isGroupDenied(g); + return ( +
+
+ +

{g.name}

+
+ toggle(g.name, checked)} + /> +
+ ); + })} +
+
+
+ ); +} diff --git a/ui/web/src/pages/packages/hooks/use-package-runtimes.ts b/ui/web/src/pages/packages/hooks/use-package-runtimes.ts new file mode 100644 index 00000000..26671b10 --- /dev/null +++ b/ui/web/src/pages/packages/hooks/use-package-runtimes.ts @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { queryKeys } from "@/lib/query-keys"; + +export interface RuntimeInfo { + name: string; + available: boolean; + version?: string; +} + +export interface RuntimeStatus { + runtimes: RuntimeInfo[]; + ready: boolean; +} + +export function usePackageRuntimes() { + const http = useHttp(); + const connected = useAuthStore((s) => s.connected); + + const { data, isFetching: loading, refetch } = useQuery({ + queryKey: queryKeys.packages.runtimes, + queryFn: () => http.get("/v1/packages/runtimes"), + staleTime: 120_000, + enabled: connected, + }); + + const refresh = useCallback(() => { refetch(); }, [refetch]); + + return { runtimes: data, loading, refresh }; +} diff --git a/ui/web/src/pages/packages/hooks/use-packages.ts b/ui/web/src/pages/packages/hooks/use-packages.ts new file mode 100644 index 00000000..3f88f260 --- /dev/null +++ b/ui/web/src/pages/packages/hooks/use-packages.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { toast } from "@/stores/use-toast-store"; +import { queryKeys } from "@/lib/query-keys"; + +export interface PackageInfo { + name: string; + version: string; +} + +export interface InstalledPackages { + system: PackageInfo[] | null; + pip: PackageInfo[] | null; + npm: PackageInfo[] | null; +} + +interface InstallResult { + ok: boolean; + error: string; +} + +export function usePackages() { + const http = useHttp(); + const qc = useQueryClient(); + const connected = useAuthStore((s) => s.connected); + + const { data, isFetching: loading, refetch } = useQuery({ + queryKey: queryKeys.packages.all, + queryFn: () => http.get("/v1/packages"), + staleTime: 30_000, + enabled: connected, + }); + + const refresh = useCallback(() => { refetch(); }, [refetch]); + + const installPackage = useCallback(async (pkg: string, t: (key: string, opts?: Record) => string) => { + const res = await http.post("/v1/packages/install", { package: pkg }); + if (res.ok) { + toast.success(t("messages.installSuccess", { name: pkg })); + qc.invalidateQueries({ queryKey: queryKeys.packages.all }); + qc.invalidateQueries({ queryKey: queryKeys.packages.runtimes }); + } else { + toast.error(t("messages.installError", { name: pkg }) + (res.error ? `: ${res.error}` : "")); + } + return res; + }, [http, qc]); + + const uninstallPackage = useCallback(async (pkg: string, t: (key: string, opts?: Record) => string) => { + const res = await http.post("/v1/packages/uninstall", { package: pkg }); + if (res.ok) { + toast.success(t("messages.uninstallSuccess", { name: pkg })); + qc.invalidateQueries({ queryKey: queryKeys.packages.all }); + qc.invalidateQueries({ queryKey: queryKeys.packages.runtimes }); + } else { + toast.error(t("messages.uninstallError", { name: pkg }) + (res.error ? `: ${res.error}` : "")); + } + return res; + }, [http, qc]); + + return { packages: data, loading, refresh, installPackage, uninstallPackage }; +} diff --git a/ui/web/src/pages/packages/packages-page.tsx b/ui/web/src/pages/packages/packages-page.tsx new file mode 100644 index 00000000..d955fe52 --- /dev/null +++ b/ui/web/src/pages/packages/packages-page.tsx @@ -0,0 +1,243 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshCw, Loader2, Trash2, Download, CheckCircle2, XCircle } from "lucide-react"; +import { PageHeader } from "@/components/shared/page-header"; +import { ConfirmDialog } from "@/components/shared/confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { usePackages, type PackageInfo } from "./hooks/use-packages"; +import { usePackageRuntimes } from "./hooks/use-package-runtimes"; + +type ActionStatus = "idle" | "loading" | "success" | "error"; + +export function PackagesPage() { + const { t } = useTranslation("packages"); + const { packages, loading, refresh, installPackage, uninstallPackage } = usePackages(); + const { runtimes, loading: runtimesLoading, refresh: refreshRuntimes } = usePackageRuntimes(); + + return ( +
+ { refresh(); refreshRuntimes(); }} + disabled={loading || runtimesLoading} + > + + {t("actions.refresh", { defaultValue: "Refresh" })} + + } + /> + + {/* Runtimes Section */} +
+

{t("runtimes.title")}

+
+ {runtimes?.runtimes?.map((rt) => ( +
+
+ {rt.name} + {rt.available ? ( + + ) : ( + + )} +
+ {rt.version && ( +

{rt.version}

+ )} + {!rt.available && ( +

{t("runtimes.missing")}

+ )} +
+ ))} +
+
+ + {/* Package Sections */} + installPackage(pkg, t)} + onUninstall={(pkg) => uninstallPackage(pkg, t)} + /> + + installPackage(`pip:${pkg}`, t)} + onUninstall={(pkg) => uninstallPackage(`pip:${pkg}`, t)} + /> + + installPackage(`npm:${pkg}`, t)} + onUninstall={(pkg) => uninstallPackage(`npm:${pkg}`, t)} + /> +
+ ); +} + +interface PackageSectionProps { + title: string; + placeholder: string; + packages: PackageInfo[] | null | undefined; + loading: boolean; + onInstall: (pkg: string) => Promise; + onUninstall: (pkg: string) => Promise; +} + +function PackageSection({ title, placeholder, packages, loading, onInstall, onUninstall }: PackageSectionProps) { + const { t } = useTranslation("packages"); + const [input, setInput] = useState(""); + const [installStatus, setInstallStatus] = useState("idle"); + const [actionStatuses, setActionStatuses] = useState>({}); + const [uninstallTarget, setUninstallTarget] = useState(null); + + async function handleInstall() { + const pkg = input.trim(); + if (!pkg) return; + setInstallStatus("loading"); + try { + await onInstall(pkg); + setInstallStatus("success"); + setInput(""); + setTimeout(() => setInstallStatus("idle"), 2000); + } catch { + setInstallStatus("error"); + setTimeout(() => setInstallStatus("idle"), 3000); + } + } + + async function handleUninstall(name: string) { + setActionStatuses((s) => ({ ...s, [name]: "loading" })); + try { + await onUninstall(name); + setActionStatuses((s) => ({ ...s, [name]: "success" })); + } catch { + setActionStatuses((s) => ({ ...s, [name]: "error" })); + setTimeout(() => setActionStatuses((s) => ({ ...s, [name]: "idle" })), 3000); + } + } + + return ( +
+

{title}

+ + {/* Install input */} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleInstall()} + disabled={installStatus === "loading"} + /> + +
+ + {/* Package table */} +
+ + + + + + + + + + {loading && !packages ? ( + + + + ) : !packages?.length ? ( + + + + ) : ( + packages.map((pkg) => { + const status = actionStatuses[pkg.name] ?? "idle"; + return ( + + + + + + ); + }) + )} + +
{t("table.name")}{t("table.version")}{t("table.actions")}
+ +
+ {t("table.empty")} +
{pkg.name}{pkg.version} + {status === "success" ? ( + + ) : ( + + )} +
+
+ + setUninstallTarget(null)} + title={t("confirmUninstall.title")} + description={t("confirmUninstall.description", { name: uninstallTarget })} + confirmLabel={t("actions.uninstall")} + variant="destructive" + onConfirm={async () => { + if (uninstallTarget) { + await handleUninstall(uninstallTarget); + setUninstallTarget(null); + } + }} + /> +
+ ); +} diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx index 46c2cb4b..45f352c9 100644 --- a/ui/web/src/routes.tsx +++ b/ui/web/src/routes.tsx @@ -95,6 +95,9 @@ const CliCredentialsPage = lazyWithRetry(() => const ApiKeysPage = lazyWithRetry(() => import("@/pages/api-keys/api-keys-page").then((m) => ({ default: m.ApiKeysPage })), ); +const PackagesPage = lazyWithRetry(() => + import("@/pages/packages/packages-page").then((m) => ({ default: m.PackagesPage })), +); function PageLoader() { return ( @@ -168,6 +171,7 @@ export function AppRoutes() { } /> } /> } /> + } /> {/* Catch-all → overview */}