Files
goclaw/internal/http/packages.go
T
viettranx 843b550651 feat: runtime packages UI, pkg-helper, configurable shell deny groups (#244)
Runtime package management with security hardening:

- pkg-helper: root-privileged daemon for apk install/uninstall via Unix socket
- HTTP API: /v1/packages (list/install/uninstall/runtimes), admin role required for writes
- Shell deny groups: 15 configurable groups (per-agent overrides via context)
- Packages UI: Web page for managing system/pip/npm packages with confirmation dialogs
- Docker: privilege separation (root entrypoint → su-exec drop), init for zombie reaping
- Security: umask socket creation, persist file validation, deny pattern hardening
  (Node.js fetch/http, Python from/import, curl localhost, sensitive env vars)
- Auth: empty gateway token → admin role (dev/single-user mode)
2026-03-17 19:50:26 +07:00

135 lines
4.8 KiB
Go

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