chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-30 17:28:38 +02:00
commit 4b0fb61f02
28 changed files with 3271 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
registry.db
+166
View File
@@ -0,0 +1,166 @@
---
name: device_agent
lang: go
domain: tools
description: "Agente HTTP por dispositivo del mesh WireGuard. Recibe capability requests del Matrix bot agents_and_robots (via mesh 10.42.0.0/24), valida contra manifest YAML, ejecuta con sandbox (shell whitelist o docker exec whitelist), devuelve resultado con audit hash-chained."
icon:
phosphor: "robot"
accent: "#06b6d4"
tags: [agents, device-agent, mesh, wireguard, matrix, http, service, security]
version: 0.2.0
uses_functions:
- shell_exec_whitelist_go_infra
- docker_container_list_go_infra
- docker_container_exec_go_infra
- docker_container_logs_go_infra
- http_logger_middleware_go_infra
- logger_new_go_infra
uses_types: []
framework: "stdlib-http"
entry_point: "main.go"
dir_path: "projects/element_agents/apps/device_agent"
repo_url: "https://gitea.organic-machine.com/dataforge/device_agent"
e2e_checks:
- id: build
cmd: "cd /home/lucas/fn_registry/projects/element_agents/apps/device_agent && CGO_ENABLED=1 go build -o device_agent ."
timeout_s: 60
- id: self_test
cmd: "/home/lucas/fn_registry/projects/element_agents/apps/device_agent/device_agent --self-test"
timeout_s: 10
- id: unit_tests
cmd: "cd /home/lucas/fn_registry/projects/element_agents/apps/device_agent && CGO_ENABLED=1 go test -count=1 ./..."
timeout_s: 60
service:
port: 7474
health_endpoint: /health
health_timeout_s: 3
systemd_unit: device_agent.service
systemd_scope: user
restart_policy: always
runtime: systemd-user
pc_targets:
- home-wsl
- aurgi-pc
is_local_only: false
---
# device_agent
Agente HTTP que corre en cada dispositivo del mesh WireGuard mesh (10.42.0.0/24). Escucha en la IP del mesh asignada al peer (`10.42.0.10` en home-wsl, etc.) puerto 7474.
## Endpoints
| Metodo | Path | Descripcion |
|---|---|---|
| GET | `/health` | Liveness check, devuelve `{"ok":true,"device_id":"...","version":"..."}` |
| GET | `/capabilities` | Lista capabilities declaradas en el manifest local |
| POST | `/capability` | Despacha capability request. JSON envelope (ver flow 0009 spec issue 0134) |
## Flujo
```
agents_and_robots (VPS, 10.42.0.1)
↓ POST http://10.42.0.10:7474/capability
device_agent (este binario)
↓ validate manifest + nonce + (later) signature
↓ route capability → shell.exec | docker.* | fs.read | ...
↓ append audit hash-chain
↓ return JSON {ok, result, audit_hash}
agents_and_robots
↓ Matrix message back to room
Element user ve output
```
## Manifest
`~/.config/device_agent/manifest.yaml` declara capabilities permitidas. POC inicial sin firma (issue 0134 introduce ed25519 sign). Formato:
```yaml
device_id: home-wsl
operator: egutierrez
capabilities:
- name: shell.exec
binaries_allowed: [ls, cat, ps, df, git, echo]
requires_approval: false
- name: shell.exec.admin
binaries_allowed: [systemctl, apt-get]
requires_approval: true
- name: shell.eval
shell_mode: auto # bash en linux/darwin, powershell.exe en windows
blocklist: [] # extension operador; hardcoded kill-list aplica siempre
auto_approve: # regex pre-aprobados (override defaults si presente)
- "^git\\s"
- "^docker ps"
max_output_bytes: 1048576 # 1MB
timeout_seconds: 60
requires_approval: false # true => cmd no-auto se cola en local_files/approval_queue.jsonl
- name: docker.container.list
requires_approval: false
- name: docker.container.logs
requires_approval: false
- name: docker.container.exec
binaries_allowed: [ls, ps, cat]
requires_approval: true
```
### Capabilities soportadas
| Capability | Estado | Que hace |
|---|---|---|
| `shell.exec` | v0.1.0 | Ejecuta argv estructurado, whitelist binaries |
| `shell.eval` | v0.2.0 | Evalua cmd shell-libre (`bash -c <cmd>` o `powershell.exe -Command <cmd>`). Hardcoded blocklist + auto_approve regex + approval queue + audit verbose con cmd cleartext |
| `docker.container.list` | stub | Lista contenedores via socket docker |
| `docker.container.logs` | stub | Logs de un contenedor |
| `docker.container.exec` | stub | exec en contenedor (whitelist) |
### shell.eval — detalle
`shell.eval` permite al agent LLM mandar comandos shell libres ("borra logs antiguos") en lugar de solo argv estructurado. Defensas:
1. **Hardcoded blocklist** no configurable: `rm -rf /`, `dd if=`, `mkfs.*`, `curl|sh`, `shutdown`, `reboot`, etc. Match case-insensitive. Cualquier match = rechazo, no aprobable.
2. **Operator blocklist** (`capability.blocklist[]`) extiende el hardcoded.
3. **OS detect**: `bash -c` en Linux/Mac, `powershell.exe -NoProfile -NonInteractive -Command` en Windows. Override via `shell_mode` o per-call `shell` arg.
4. **auto_approve regex**: lista (override-able) de patrones pre-aprobados (`^git\s`, `^docker ps`, etc.). Match = ejecuta sin friccion.
5. **Approval queue**: si `requires_approval: true` y el cmd NO matchea `auto_approve`, se anade entry a `local_files/approval_queue.jsonl` `{ts, request_id, cmd, cwd, capability, status: pending}` y devuelve error `approval_required`. v0.2.0 es placeholder; 0144f pasa a Matrix reactions.
6. **Output cap**: `max_output_bytes` (default 1MB), `timeout_seconds` (default 60). Flag `truncated:true` cuando se aplica.
7. **Audit verbose**: nueva tabla `audit_shell_eval(audit_id, cmd, cwd, shell, stdout_b64, stderr_b64)` con cmd en CLARO (no hash) para forense. stdout/stderr > 4KB se comprimen gzip+base64 (prefijo `gz:`); cortos van con prefijo `plain:`.
Args en la HTTP envelope (`POST /capability`):
```json
{
"request_id": "...",
"capability": "shell.eval",
"args": ["{\"cmd\":\"git status\",\"cwd\":\"/repo\"}"]
}
```
O en formato posicional `args: ["git status", "/repo", "bash"]`.
## Audit
`local_files/audit.db` con tabla `audit_log` hash-chained. Cada request: `{ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash}`.
## Build
```bash
cd projects/element_agents/apps/device_agent
CGO_ENABLED=1 go build -o device_agent .
./device_agent --listen 10.42.0.10:7474 --manifest ~/.config/device_agent/manifest.yaml
```
Cross-compile a Windows (para aurgi-pc):
```bash
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o device_agent.exe .
```
## Estado
- v0.1.0: POC sin firma de manifest. Solo shell.exec + docker.*. WIP.
- v0.2.0 (issue 0140): firma ed25519, replay protection, approval flow.
## Capability growth log
- v0.1.0 (2026-05-24) — scaffold inicial POC para validar DoD gate Element→PC del flow 0009.
- v0.2.0 (2026-05-24) — anade capability `shell.eval` (bash/powershell con hardcoded blocklist + regex auto_approve + approval queue + audit verbose con cmd cleartext). Nueva tabla `audit_shell_eval`. Manifest extendido con `blocklist`, `auto_approve`, `shell_mode`, `max_output_bytes`, `timeout_seconds`.
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"time"
_ "modernc.org/sqlite"
)
type Audit struct {
db *sql.DB
}
const auditSchema = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
request_id TEXT NOT NULL,
capability TEXT NOT NULL,
args_hash TEXT NOT NULL,
exit_code INTEGER NOT NULL,
prev_hash TEXT NOT NULL,
this_hash TEXT NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS audit_log_ts ON audit_log(ts);
CREATE INDEX IF NOT EXISTS audit_log_request_id ON audit_log(request_id);
CREATE TABLE IF NOT EXISTS audit_shell_eval (
audit_id INTEGER PRIMARY KEY,
cmd TEXT NOT NULL,
cwd TEXT,
shell TEXT NOT NULL,
stdout_b64 TEXT,
stderr_b64 TEXT,
FOREIGN KEY (audit_id) REFERENCES audit_log(id)
);
`
func OpenAudit(path string) (*Audit, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(wal)&_pragma=foreign_keys(on)")
if err != nil {
return nil, fmt.Errorf("open audit db %s: %w", path, err)
}
if _, err := db.Exec(auditSchema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply audit schema: %w", err)
}
return &Audit{db: db}, nil
}
func (a *Audit) Close() error { return a.db.Close() }
// Append registra una invocacion. Devuelve this_hash.
// args raw → SHA256 → args_hash. Hash chain SHA256(prev || canonical).
func (a *Audit) Append(requestID, capability string, args any, exitCode int) (string, error) {
argsBytes, err := json.Marshal(args)
if err != nil {
return "", fmt.Errorf("marshal args: %w", err)
}
argsHash := sha256hex(argsBytes)
var prevHash string
_ = a.db.QueryRow("SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prevHash)
ts := time.Now().Unix()
canonical := fmt.Sprintf("%s|%d|%s|%s|%s|%d", prevHash, ts, requestID, capability, argsHash, exitCode)
thisHash := sha256hex([]byte(canonical))
_, err = a.db.Exec(
`INSERT INTO audit_log (ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
ts, requestID, capability, argsHash, exitCode, prevHash, thisHash,
)
if err != nil {
return "", fmt.Errorf("insert audit: %w", err)
}
return thisHash, nil
}
func sha256hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// ShellEvalRecord is the verbose audit payload for shell.eval invocations.
// stdout / stderr larger than 4KB are gzip+base64 compressed in the DB.
type ShellEvalRecord struct {
Cmd string
CWD string
Shell string
Stdout string
Stderr string
}
// AppendVerbose registra una invocacion + payload cleartext (cmd/cwd/shell + stdout/stderr)
// para forense. Devuelve this_hash. Linkado via audit_log.id en audit_shell_eval.
func (a *Audit) AppendVerbose(requestID, capability string, args any, exitCode int, rec ShellEvalRecord) (string, error) {
argsBytes, err := json.Marshal(args)
if err != nil {
return "", fmt.Errorf("marshal args: %w", err)
}
argsHash := sha256hex(argsBytes)
var prevHash string
_ = a.db.QueryRow("SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prevHash)
ts := time.Now().Unix()
canonical := fmt.Sprintf("%s|%d|%s|%s|%s|%d", prevHash, ts, requestID, capability, argsHash, exitCode)
thisHash := sha256hex([]byte(canonical))
tx, err := a.db.Begin()
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.Exec(
`INSERT INTO audit_log (ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
ts, requestID, capability, argsHash, exitCode, prevHash, thisHash,
)
if err != nil {
return "", fmt.Errorf("insert audit_log: %w", err)
}
auditID, err := res.LastInsertId()
if err != nil {
return "", fmt.Errorf("lastinsertid: %w", err)
}
stdoutEnc, err := encodeMaybeGzip(rec.Stdout)
if err != nil {
return "", fmt.Errorf("encode stdout: %w", err)
}
stderrEnc, err := encodeMaybeGzip(rec.Stderr)
if err != nil {
return "", fmt.Errorf("encode stderr: %w", err)
}
if _, err := tx.Exec(
`INSERT INTO audit_shell_eval (audit_id, cmd, cwd, shell, stdout_b64, stderr_b64)
VALUES (?, ?, ?, ?, ?, ?)`,
auditID, rec.Cmd, rec.CWD, rec.Shell, stdoutEnc, stderrEnc,
); err != nil {
return "", fmt.Errorf("insert audit_shell_eval: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit tx: %w", err)
}
return thisHash, nil
}
// encodeMaybeGzip: si len(s) <= 4KB devuelve base64(plain); si supera, gzip+base64.
// Prefijo "gz:" para distinguir. Vacio devuelve "".
func encodeMaybeGzip(s string) (string, error) {
if s == "" {
return "", nil
}
const threshold = 4096
if len(s) <= threshold {
return "plain:" + base64.StdEncoding.EncodeToString([]byte(s)), nil
}
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write([]byte(s)); err != nil {
_ = zw.Close()
return "", err
}
if err := zw.Close(); err != nil {
return "", err
}
return "gz:" + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Cross-compile device_agent para 4 targets. CGO-free (modernc.org/sqlite).
# Output: build/<goos>-<goarch>/device_agent[.exe]
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p build
TARGETS=(
"linux/amd64"
"linux/arm64"
"windows/amd64"
"darwin/arm64"
)
for tgt in "${TARGETS[@]}"; do
GOOS=${tgt%/*}
GOARCH=${tgt#*/}
EXT=""
[ "$GOOS" = "windows" ] && EXT=".exe"
OUT="build/$GOOS-$GOARCH/device_agent$EXT"
mkdir -p "build/$GOOS-$GOARCH"
echo "==> Building $tgt -> $OUT"
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
go build -ldflags="-s -w" -o "$OUT" .
ls -la "$OUT"
done
echo ""
echo "All targets built OK."
+326
View File
@@ -0,0 +1,326 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os/exec"
"strings"
"time"
)
// CapabilityRequest envelope minimo POC (sin firma todavia — issue 0134 v0.2).
type CapabilityRequest struct {
RequestID string `json:"request_id"`
Capability string `json:"capability"`
Args json.RawMessage `json:"args"`
Nonce string `json:"nonce"`
Timestamp int64 `json:"ts"`
}
// parseArgsArray intenta decode args como []string. Si args es un objeto
// con campo "argv" (formato MCP), extrae argv. Si es array plano, lo devuelve.
func parseArgsArray(raw json.RawMessage) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
// Intento 1: array
var arr []string
if err := json.Unmarshal(raw, &arr); err == nil {
return arr, nil
}
// Intento 2: object con argv
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("args must be array or object with argv: %w", err)
}
if v, ok := obj["argv"]; ok {
switch t := v.(type) {
case []any:
out := make([]string, len(t))
for i, x := range t {
out[i] = fmt.Sprintf("%v", x)
}
return out, nil
case []string:
return t, nil
}
}
return nil, fmt.Errorf("args object missing argv array")
}
// parseArgsMap decodifica args como map[string]any para capabilities que
// reciben objeto. Acepta tambien array (lo wrap como {argv:[...]}).
func parseArgsMap(raw json.RawMessage) (map[string]any, error) {
if len(raw) == 0 {
return nil, nil
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err == nil {
return obj, nil
}
// Fallback: array → {argv: [...]}
var arr []any
if err := json.Unmarshal(raw, &arr); err != nil {
return nil, fmt.Errorf("args must be object or array: %w", err)
}
return map[string]any{"argv": arr}, nil
}
type CapabilityResponse struct {
RequestID string `json:"request_id"`
OK bool `json:"ok"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
AuditHash string `json:"audit_hash,omitempty"`
}
func capabilityHandler(mf *Manifest, audit *Audit) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only")
return
}
var req CapabilityRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if req.RequestID == "" || req.Capability == "" {
writeError(w, http.StatusBadRequest, "bad_request", "request_id and capability required")
return
}
cap := mf.CapabilityByName(req.Capability)
if cap == nil {
writeError(w, http.StatusForbidden, "capability_denied",
fmt.Sprintf("capability %q not in manifest", req.Capability))
return
}
start := time.Now()
var result any
var execErr error
exitCode := 0
var shellEvalExtra *ShellEvalRecord
switch req.Capability {
case "shell.exec":
argv, perr := parseArgsArray(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runShellExec(cap, argv)
case "shell.eval":
rawMap, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
var r map[string]any
r, exitCode, shellEvalExtra, execErr = runShellEval(cap, rawMap)
result = r
case "fs.read":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runFsRead(cap, m)
case "fs.write":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runFsWrite(cap, m)
case "fs.list":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runFsList(cap, m)
case "fs.stat":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runFsStat(cap, m)
case "git.clone":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runGitClone(cap, m)
case "git.commit":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runGitCommit(cap, m)
case "git.push":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runGitPush(cap, m)
case "pkg.search":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runPkgSearch(cap, m)
case "proc.list":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runProcList(cap, m)
case "docker.container.list":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runDockerList(cap, m)
case "docker.container.exec":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runDockerExec(cap, m)
case "docker.container.logs":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
result, exitCode, execErr = runDockerLogs(cap, m)
case "browser.list_tabs", "browser.navigate", "browser.click_text",
"browser.evaluate", "browser.screenshot", "browser.type_text",
"browser.get_html", "browser.launch_chrome":
m, perr := parseArgsMap(req.Args)
if perr != nil {
execErr = perr
break
}
op := strings.TrimPrefix(req.Capability, "browser.")
result, exitCode, execErr = runBrowserOp(cap, op, m)
default:
execErr = fmt.Errorf("capability %q not implemented yet in POC", req.Capability)
}
dur := time.Since(start).Milliseconds()
var hash string
var herr error
if req.Capability == "shell.eval" && shellEvalExtra != nil {
hash, herr = audit.AppendVerbose(req.RequestID, req.Capability, req.Args, exitCode, *shellEvalExtra)
} else {
hash, herr = audit.Append(req.RequestID, req.Capability, req.Args, exitCode)
}
if herr != nil {
// audit es critico — si falla, devolvemos error pero no perdemos el output.
writeJSON(w, http.StatusInternalServerError, CapabilityResponse{
RequestID: req.RequestID,
OK: false,
Error: "audit_failed: " + herr.Error(),
DurationMs: dur,
})
return
}
resp := CapabilityResponse{
RequestID: req.RequestID,
DurationMs: dur,
AuditHash: hash,
}
if execErr != nil {
resp.OK = false
resp.Error = execErr.Error()
writeJSON(w, http.StatusInternalServerError, resp)
return
}
resp.OK = true
resp.Result = result
writeJSON(w, http.StatusOK, resp)
}
}
// parseShellEvalArgs decodifica los args de la envelope shell.eval.
// Convencion: req.Args puede ser:
// - len(args) == 1 y args[0] es un JSON object {"cmd":..., "shell":..., "cwd":...}
// - len(args) >= 1 y args[0] = cmd, args[1] (opcional) = cwd, args[2] (opcional) = shell
//
// Devuelve un map[string]any compatible con runShellEval.
func parseShellEvalArgs(args []string) (map[string]any, error) {
if len(args) == 0 {
return nil, fmt.Errorf("shell.eval requires at least 1 arg")
}
// Intento 1: JSON object string.
trim := strings.TrimSpace(args[0])
if strings.HasPrefix(trim, "{") && strings.HasSuffix(trim, "}") {
var m map[string]any
if err := json.Unmarshal([]byte(trim), &m); err == nil {
return m, nil
}
// caemos al fallback
}
out := map[string]any{
"cmd": args[0],
}
if len(args) >= 2 && args[1] != "" {
out["cwd"] = args[1]
}
if len(args) >= 3 && args[2] != "" {
out["shell"] = args[2]
}
return out, nil
}
// runShellExec implementa shell.exec con whitelist del manifest local.
// POC INLINE: el flow 0009 v0.2 lo migra a shell_exec_whitelist_go_infra del registry.
func runShellExec(cap *Capability, args []string) (any, int, error) {
if len(args) == 0 {
return nil, -1, fmt.Errorf("no argv provided")
}
if len(cap.BinariesAllowed) == 0 {
return nil, -1, fmt.Errorf("no binaries whitelisted for shell.exec")
}
bin := args[0]
allowed := false
for _, b := range cap.BinariesAllowed {
if b == bin || strings.HasSuffix(bin, "/"+b) {
allowed = true
break
}
}
if !allowed {
return nil, -1, fmt.Errorf("binary %q not in whitelist %v", bin, cap.BinariesAllowed)
}
cmd := exec.Command(args[0], args[1:]...) // #nosec G204 — whitelist enforced above
out, err := cmd.CombinedOutput()
exitCode := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
} else {
return nil, -1, err
}
}
return map[string]any{
"stdout": string(out),
"exit_code": exitCode,
}, exitCode, nil
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"time"
"fn-registry/functions/browser"
)
// runBrowserOp executes a browser CDP operation by connecting to Chrome's
// remote debugging endpoint, dispatching the op, and closing the connection.
//
// Supported ops (passed via args.op):
// - list_tabs → []CdpTab
// - navigate(url) → ok
// - click_text(text, tag?, exact?) → ok
// - evaluate(expression) → string
// - screenshot(filename?) → path saved
// - type_text(selector, text) → ok
// - get_html(selector?) → html
//
// host + port come from manifest. Defaults: 127.0.0.1:9223.
func runBrowserOp(cap *Capability, op string, args map[string]any) (any, int, error) {
host := cap.ChromeCDPHost
if host == "" {
host = "127.0.0.1"
}
port := cap.ChromeCDPPort
if port == 0 {
port = 9223
}
// list_tabs uses HTTP only — no websocket.
if op == "list_tabs" {
tabs, err := browser.CdpListTabs(host, port)
if err != nil {
return nil, -1, err
}
return map[string]any{"tabs": tabs}, 0, nil
}
// launch_chrome bootstraps Chrome with --remote-debugging-port.
// Idempotent: if the port is already serving CDP, ChromeLaunch's
// waitCDPReady connects to the existing process and returns success.
if op == "launch_chrome" {
opts := browser.ChromeLaunchOpts{Port: port}
if hl, ok := args["headless"].(bool); ok {
opts.Headless = hl
}
if udd, ok := args["user_data_dir"].(string); ok && udd != "" {
opts.UserDataDir = udd
}
if cp, ok := args["chrome_path"].(string); ok && cp != "" {
opts.ChromePath = cp
}
pid, err := browser.ChromeLaunch(opts)
if err != nil {
return nil, -1, err
}
return map[string]any{"pid": pid, "port": port}, 0, nil
}
// Operations that need a websocket connection.
conn, err := browser.CdpConnectHost(host, port)
if err != nil {
return nil, -1, fmt.Errorf("cdp connect %s:%d: %w", host, port, err)
}
defer browser.CdpClose(conn, 0)
switch op {
case "navigate":
url, _ := args["url"].(string)
if url == "" {
return nil, -1, fmt.Errorf("navigate: url required")
}
if err := browser.CdpNavigate(conn, url); err != nil {
return nil, -1, err
}
return map[string]any{"ok": true, "url": url}, 0, nil
case "click_text":
text, _ := args["text"].(string)
if text == "" {
return nil, -1, fmt.Errorf("click_text: text required")
}
tag, _ := args["tag"].(string)
exact, _ := args["exact"].(bool)
opts := browser.FindByTextOpts{Tag: tag, Exact: exact}
if err := browser.CdpClickText(conn, text, opts); err != nil {
return nil, -1, err
}
return map[string]any{"ok": true, "clicked": text}, 0, nil
case "evaluate":
expr, _ := args["expression"].(string)
if expr == "" {
return nil, -1, fmt.Errorf("evaluate: expression required")
}
val, err := browser.CdpEvaluate(conn, expr)
if err != nil {
return nil, -1, err
}
return map[string]any{"value": val}, 0, nil
case "screenshot":
dir := cap.ScreenshotDir
if dir == "" {
dir = filepath.Join("local_files", "screenshots")
}
fname, _ := args["filename"].(string)
if fname == "" {
fname = fmt.Sprintf("shot_%d.png", time.Now().UnixNano())
}
fname = strings.ReplaceAll(fname, "..", "_")
out := filepath.Join(dir, fname)
opts := browser.CdpScreenshotOpts{Format: "png"}
if fp, ok := args["full_page"].(bool); ok {
opts.FullPage = fp
}
if err := browser.CdpScreenshot(conn, out, opts); err != nil {
return nil, -1, err
}
return map[string]any{"path": out}, 0, nil
case "type_text":
text, _ := args["text"].(string)
if text == "" {
return nil, -1, fmt.Errorf("type_text: text required (will be typed into focused element)")
}
if err := browser.CdpTypeText(conn, text); err != nil {
return nil, -1, err
}
return map[string]any{"ok": true}, 0, nil
case "get_html":
html, err := browser.CdpGetHTML(conn)
if err != nil {
return nil, -1, err
}
return map[string]any{"html": html}, 0, nil
default:
return nil, -1, fmt.Errorf("browser op %q not supported (list_tabs|navigate|click_text|evaluate|screenshot|type_text|get_html)", op)
}
}
+318
View File
@@ -0,0 +1,318 @@
package main
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// dockerSocketPath ruta del socket docker. Override via env DOCKER_HOST si fuera necesario en el futuro.
var dockerSocketPath = "/var/run/docker.sock"
// dockerHTTPClient devuelve un *http.Client cuyo Transport dial-tea via unix socket.
// hostOverride opcional: usar otro path (tests con httptest.NewServer sobre unix).
func dockerHTTPClient(sock string) *http.Client {
if sock == "" {
sock = dockerSocketPath
}
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 5 * time.Second}
return d.DialContext(ctx, "unix", sock)
},
},
}
}
// dockerHostForReq host falso para HTTP sobre unix.
const dockerHost = "http://localhost"
// dockerGet hace GET path con query y devuelve body bytes.
func dockerGet(sock, path string, qs url.Values) ([]byte, int, error) {
cli := dockerHTTPClient(sock)
u := dockerHost + path
if len(qs) > 0 {
u += "?" + qs.Encode()
}
resp, err := cli.Get(u)
if err != nil {
return nil, -1, fmt.Errorf("docker GET %s: %w", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return body, resp.StatusCode, nil
}
// dockerPost hace POST path con body JSON (puede ser nil).
func dockerPost(sock, path string, body any) ([]byte, int, error) {
cli := dockerHTTPClient(sock)
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, -1, fmt.Errorf("marshal: %w", err)
}
rdr = strings.NewReader(string(b))
}
req, err := http.NewRequest("POST", dockerHost+path, rdr)
if err != nil {
return nil, -1, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := cli.Do(req)
if err != nil {
return nil, -1, fmt.Errorf("docker POST %s: %w", path, err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
return rb, resp.StatusCode, nil
}
// runDockerList wraps GET /containers/json.
func runDockerList(cap *Capability, args map[string]any) (any, int, error) {
_ = cap
qs := url.Values{}
if v, ok := args["all"]; ok {
if b, ok := v.(bool); ok && b {
qs.Set("all", "true")
}
}
if v, ok := args["filters"]; ok && v != nil {
// JSON-encoded filters per docker API
if filtersStr, ok := v.(string); ok {
qs.Set("filters", filtersStr)
} else {
b, _ := json.Marshal(v)
qs.Set("filters", string(b))
}
}
body, status, err := dockerGet(dockerSocketPath, "/containers/json", qs)
if err != nil {
return nil, -1, err
}
if status >= 400 {
return nil, status, fmt.Errorf("docker api status=%d: %s", status, string(body))
}
var containers []map[string]any
if err := json.Unmarshal(body, &containers); err != nil {
return nil, -1, fmt.Errorf("decode containers: %w", err)
}
// Slim para no devolver mega payload
slim := []map[string]any{}
for _, c := range containers {
entry := map[string]any{
"id": truncString(getStr(c, "Id"), 12),
"image": getStr(c, "Image"),
"state": getStr(c, "State"),
"status": getStr(c, "Status"),
}
if names, ok := c["Names"].([]any); ok && len(names) > 0 {
entry["name"] = strings.TrimPrefix(fmt.Sprintf("%v", names[0]), "/")
}
slim = append(slim, entry)
}
return map[string]any{
"containers": slim,
"count": len(slim),
}, 0, nil
}
// runDockerLogs GET /containers/{id}/logs?stdout=1&stderr=1&tail=N
func runDockerLogs(cap *Capability, args map[string]any) (any, int, error) {
_ = cap
container := mapStringField(args, "container")
if container == "" {
return nil, -1, fmt.Errorf("container required")
}
tail := mapIntField(args, "tail", 100)
since := mapStringField(args, "since")
qs := url.Values{}
qs.Set("stdout", "1")
qs.Set("stderr", "1")
if tail > 0 {
qs.Set("tail", fmt.Sprintf("%d", tail))
}
if since != "" {
qs.Set("since", since)
}
body, status, err := dockerGet(dockerSocketPath, "/containers/"+container+"/logs", qs)
if err != nil {
return nil, -1, err
}
if status >= 400 {
return nil, status, fmt.Errorf("docker logs status=%d: %s", status, string(body))
}
// Body es multiplexed con frame 8-byte header (stream_type|0|0|0|size_be32).
stdout, stderr := demuxDockerStream(body)
// Trunca a 256KB cada uno por defensa
const maxOut = 256 * 1024
if len(stdout) > maxOut {
stdout = stdout[len(stdout)-maxOut:]
}
if len(stderr) > maxOut {
stderr = stderr[len(stderr)-maxOut:]
}
return map[string]any{
"container": container,
"stdout": stdout,
"stderr": stderr,
"lines": strings.Split(stdout, "\n"),
"exit_code": 0,
}, 0, nil
}
// runDockerExec docker exec con whitelist binaries.
// Flujo: POST /containers/{id}/exec -> exec id. POST /exec/{id}/start (Detach=false, Tty=false) -> hijacked stream.
func runDockerExec(cap *Capability, args map[string]any) (any, int, error) {
container := mapStringField(args, "container")
if container == "" {
return nil, -1, fmt.Errorf("container required")
}
var argv []string
if raw, ok := args["argv"]; ok && raw != nil {
if arr, ok := raw.([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
argv = append(argv, s)
}
}
}
}
if len(argv) == 0 {
return nil, -1, fmt.Errorf("argv required (non-empty array)")
}
if len(cap.BinariesAllowed) == 0 {
return nil, -1, fmt.Errorf("no binaries whitelisted for docker.container.exec")
}
bin := argv[0]
allowed := false
for _, b := range cap.BinariesAllowed {
if b == bin || strings.HasSuffix(bin, "/"+b) {
allowed = true
break
}
}
if !allowed {
return nil, -1, fmt.Errorf("binary %q not in whitelist %v", bin, cap.BinariesAllowed)
}
// 1. Crear exec
createBody := map[string]any{
"Cmd": argv,
"AttachStdout": true,
"AttachStderr": true,
"Tty": false,
}
body, status, err := dockerPost(dockerSocketPath, "/containers/"+container+"/exec", createBody)
if err != nil {
return nil, -1, err
}
if status >= 400 {
return nil, status, fmt.Errorf("docker exec create status=%d: %s", status, string(body))
}
var created map[string]any
if err := json.Unmarshal(body, &created); err != nil {
return nil, -1, fmt.Errorf("decode exec create: %w", err)
}
execID, _ := created["Id"].(string)
if execID == "" {
return nil, -1, fmt.Errorf("exec create returned empty id")
}
// 2. Start exec con Detach=false hijacks la conexion.
startBody := map[string]any{"Detach": false, "Tty": false}
streamBody, status, err := dockerPost(dockerSocketPath, "/exec/"+execID+"/start", startBody)
if err != nil {
return nil, -1, err
}
if status >= 400 {
return nil, status, fmt.Errorf("docker exec start status=%d: %s", status, string(streamBody))
}
stdout, stderr := demuxDockerStream(streamBody)
// 3. Inspect para obtener exit_code
body2, status, err := dockerGet(dockerSocketPath, "/exec/"+execID+"/json", nil)
exitCode := -1
if err == nil && status < 400 {
var inspect map[string]any
if json.Unmarshal(body2, &inspect) == nil {
if v, ok := inspect["ExitCode"].(float64); ok {
exitCode = int(v)
}
}
}
// Trunca outputs
const maxOut = 256 * 1024
if len(stdout) > maxOut {
stdout = stdout[:maxOut]
}
if len(stderr) > maxOut {
stderr = stderr[:maxOut]
}
return map[string]any{
"container": container,
"argv": argv,
"stdout": stdout,
"stderr": stderr,
"exit_code": exitCode,
}, exitCode, nil
}
// demuxDockerStream parsea stream multiplexed de docker (8-byte header + payload).
// frame: [stream_type][0][0][0][size_be32][payload]. stream_type: 1=stdout 2=stderr.
// Si no parece multiplexed (sin header valido), devuelve todo como stdout.
func demuxDockerStream(b []byte) (string, string) {
var so, se strings.Builder
for len(b) >= 8 {
typ := b[0]
// size big-endian uint32 at offset 4
size := binary.BigEndian.Uint32(b[4:8])
if int(size) > len(b)-8 {
// frame corrupto, asumimos plain stdout
so.Write(b[8:])
return so.String(), se.String()
}
payload := b[8 : 8+size]
switch typ {
case 1:
so.Write(payload)
case 2:
se.Write(payload)
default:
// stdin (0) o stream desconocido -> stdout fallback
so.Write(payload)
}
b = b[8+size:]
}
if len(b) > 0 {
so.Write(b)
}
return so.String(), se.String()
}
func getStr(m map[string]any, k string) string {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func truncString(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"encoding/binary"
"os"
"strings"
"testing"
)
func skipIfNoDocker(t *testing.T) {
t.Helper()
if _, err := os.Stat(dockerSocketPath); err != nil {
t.Skipf("docker socket %s not accessible", dockerSocketPath)
}
}
func TestDockerList_Live(t *testing.T) {
skipIfNoDocker(t)
cap := &Capability{Name: "docker.container.list"}
res, code, err := runDockerList(cap, map[string]any{"all": true})
if err != nil {
t.Fatalf("list: %v", err)
}
if code != 0 {
t.Fatalf("expected code=0 got %d", code)
}
m := res.(map[string]any)
if _, ok := m["containers"]; !ok {
t.Fatalf("missing containers in result")
}
}
func TestDockerExec_BinaryNotAllowed(t *testing.T) {
cap := &Capability{
Name: "docker.container.exec",
BinariesAllowed: []string{"ls"},
}
_, _, err := runDockerExec(cap, map[string]any{
"container": "any",
"argv": []any{"rm", "-rf", "/"},
})
if err == nil || !strings.Contains(err.Error(), "whitelist") {
t.Fatalf("expected whitelist reject, got %v", err)
}
}
func TestDockerExec_NoArgv(t *testing.T) {
cap := &Capability{Name: "docker.container.exec", BinariesAllowed: []string{"ls"}}
_, _, err := runDockerExec(cap, map[string]any{"container": "x"})
if err == nil || !strings.Contains(err.Error(), "argv required") {
t.Fatalf("expected argv required, got %v", err)
}
}
func TestDemuxDockerStream(t *testing.T) {
// Construir un frame stdout "hello" + stderr "err"
mk := func(typ byte, payload string) []byte {
hdr := make([]byte, 8)
hdr[0] = typ
binary.BigEndian.PutUint32(hdr[4:], uint32(len(payload)))
return append(hdr, []byte(payload)...)
}
stream := append(mk(1, "hello"), mk(2, "err")...)
so, se := demuxDockerStream(stream)
if so != "hello" || se != "err" {
t.Fatalf("demux failed: stdout=%q stderr=%q", so, se)
}
}
func TestDockerLogs_NoContainer(t *testing.T) {
cap := &Capability{Name: "docker.container.logs"}
_, _, err := runDockerLogs(cap, map[string]any{})
if err == nil || !strings.Contains(err.Error(), "container required") {
t.Fatalf("expected container required, got %v", err)
}
}
+264
View File
@@ -0,0 +1,264 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const (
fsDefaultMaxBytes = 64 * 1024
fsHardMaxBytes = 1024 * 1024
)
// isPathAllowed valida path contra una lista de globs declarados en cap.PathsAllowed.
// Aplica EvalSymlinks (best-effort) y filepath.Clean para neutralizar ../ + symlinks.
// Soporta el sufijo "/**" para match recursivo bajo un prefijo.
func isPathAllowed(path string, allowed []string) bool {
if len(allowed) == 0 {
return false
}
abs, err := filepath.Abs(path)
if err != nil {
return false
}
abs = filepath.Clean(abs)
// Resolve symlinks si el target existe; si no existe (write a archivo nuevo)
// resolvemos el directorio padre, no romper.
resolved := abs
if real, err := filepath.EvalSymlinks(abs); err == nil {
resolved = real
} else {
// Para paths no existentes (write): intenta resolver el dir padre.
dir := filepath.Dir(abs)
base := filepath.Base(abs)
if realDir, err := filepath.EvalSymlinks(dir); err == nil {
resolved = filepath.Join(realDir, base)
}
}
resolved = filepath.Clean(resolved)
for _, pat := range allowed {
patClean := filepath.Clean(pat)
// Soporte sufijo /** recursivo
if strings.HasSuffix(patClean, string(filepath.Separator)+"**") || strings.HasSuffix(patClean, "/**") {
prefix := strings.TrimSuffix(patClean, "/**")
prefix = strings.TrimSuffix(prefix, string(filepath.Separator)+"**")
prefix = filepath.Clean(prefix)
if resolved == prefix || strings.HasPrefix(resolved, prefix+string(filepath.Separator)) {
return true
}
continue
}
// Si patron es un dir y resolved esta debajo, allow (caso /etc/)
if strings.HasSuffix(pat, "/") {
prefix := filepath.Clean(pat)
if strings.HasPrefix(resolved, prefix+string(filepath.Separator)) || resolved == prefix {
return true
}
}
// Match exacto o glob simple
if match, _ := filepath.Match(patClean, resolved); match {
return true
}
if resolved == patClean {
return true
}
}
return false
}
// mapIntField extrae un int de map[string]any tolerando float64 (JSON default).
func mapIntField(m map[string]any, key string, def int) int {
if v, ok := m[key]; ok && v != nil {
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
case json.Number:
n, err := t.Int64()
if err == nil {
return int(n)
}
}
}
return def
}
func mapStringField(m map[string]any, key string) string {
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// runFsRead lee un archivo y devuelve content_b64 + meta. Trunca a max_bytes.
func runFsRead(cap *Capability, args map[string]any) (any, int, error) {
path := mapStringField(args, "path")
if path == "" {
return nil, -1, fmt.Errorf("path required")
}
maxBytes := mapIntField(args, "max_bytes", fsDefaultMaxBytes)
if maxBytes <= 0 {
maxBytes = fsDefaultMaxBytes
}
if maxBytes > fsHardMaxBytes {
maxBytes = fsHardMaxBytes
}
if !isPathAllowed(path, cap.PathsAllowed) {
return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path)
}
st, err := os.Stat(path)
if err != nil {
return nil, -1, fmt.Errorf("stat: %w", err)
}
if st.IsDir() {
return nil, -1, fmt.Errorf("path is a directory")
}
f, err := os.Open(path) // #nosec G304 — whitelisted above
if err != nil {
return nil, -1, fmt.Errorf("open: %w", err)
}
defer f.Close()
buf := make([]byte, maxBytes)
n, err := f.Read(buf)
if err != nil && err.Error() != "EOF" && n == 0 {
return nil, -1, fmt.Errorf("read: %w", err)
}
truncated := int64(n) < st.Size()
return map[string]any{
"content_b64": base64.StdEncoding.EncodeToString(buf[:n]),
"size": st.Size(),
"bytes_read": n,
"mtime": st.ModTime().Unix(),
"truncated": truncated,
"path": path,
}, 0, nil
}
// runFsWrite escribe content_b64 a path. Mkdir parent, default mode 0644.
func runFsWrite(cap *Capability, args map[string]any) (any, int, error) {
path := mapStringField(args, "path")
if path == "" {
return nil, -1, fmt.Errorf("path required")
}
contentB64 := mapStringField(args, "content_b64")
if contentB64 == "" {
// Soporte fallback "content" plano
if c := mapStringField(args, "content"); c != "" {
contentB64 = base64.StdEncoding.EncodeToString([]byte(c))
}
}
mode := mapIntField(args, "mode", 0644)
if !isPathAllowed(path, cap.PathsAllowed) {
return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path)
}
data, err := base64.StdEncoding.DecodeString(contentB64)
if err != nil {
return nil, -1, fmt.Errorf("invalid content_b64: %w", err)
}
if int64(len(data)) > fsHardMaxBytes {
return nil, -1, fmt.Errorf("content too large (>1MB)")
}
parent := filepath.Dir(path)
if err := os.MkdirAll(parent, 0755); err != nil {
return nil, -1, fmt.Errorf("mkdir parent: %w", err)
}
if err := os.WriteFile(path, data, os.FileMode(mode)); err != nil { // #nosec G306
return nil, -1, fmt.Errorf("write: %w", err)
}
return map[string]any{
"path": path,
"bytes_written": len(data),
}, 0, nil
}
// runFsList lista un directorio (no recursivo). glob opcional filtra entries.
func runFsList(cap *Capability, args map[string]any) (any, int, error) {
dir := mapStringField(args, "dir")
if dir == "" {
return nil, -1, fmt.Errorf("dir required")
}
glob := mapStringField(args, "glob")
if !isPathAllowed(dir, cap.PathsAllowed) {
return nil, -1, fmt.Errorf("dir not allowed by manifest: %s", dir)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, -1, fmt.Errorf("readdir: %w", err)
}
out := []map[string]any{}
for _, e := range entries {
if glob != "" {
if m, _ := filepath.Match(glob, e.Name()); !m {
continue
}
}
info, ierr := e.Info()
kind := "file"
var size int64
var mtime int64
if ierr == nil {
if info.Mode()&os.ModeSymlink != 0 {
kind = "symlink"
} else if info.IsDir() {
kind = "dir"
}
size = info.Size()
mtime = info.ModTime().Unix()
}
out = append(out, map[string]any{
"name": e.Name(),
"kind": kind,
"size": size,
"mtime": mtime,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["name"].(string) < out[j]["name"].(string)
})
return map[string]any{
"dir": dir,
"entries": out,
"count": len(out),
}, 0, nil
}
// runFsStat devuelve metadata de un archivo o directorio.
func runFsStat(cap *Capability, args map[string]any) (any, int, error) {
path := mapStringField(args, "path")
if path == "" {
return nil, -1, fmt.Errorf("path required")
}
if !isPathAllowed(path, cap.PathsAllowed) {
return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path)
}
st, err := os.Lstat(path)
if err != nil {
return nil, -1, fmt.Errorf("stat: %w", err)
}
kind := "file"
if st.IsDir() {
kind = "dir"
} else if st.Mode()&os.ModeSymlink != 0 {
kind = "symlink"
}
res := map[string]any{
"path": path,
"kind": kind,
"size": st.Size(),
"mode": fmt.Sprintf("%#o", st.Mode().Perm()),
"mtime": st.ModTime().Unix(),
}
return res, 0, nil
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func tempCap(t *testing.T, allowed ...string) *Capability {
t.Helper()
return &Capability{
Name: "fs.test",
PathsAllowed: allowed,
}
}
func TestFsRead_PathNotAllowed(t *testing.T) {
tmp := t.TempDir()
cap := tempCap(t, tmp+"/**")
_, _, err := runFsRead(cap, map[string]any{"path": "/etc/passwd"})
if err == nil || !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected not allowed, got: %v", err)
}
}
func TestFsRead_PathAllowed(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "test.txt")
if err := os.WriteFile(target, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
cap := tempCap(t, tmp+"/**")
res, code, err := runFsRead(cap, map[string]any{"path": target})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != 0 {
t.Fatalf("expected exit=0 got %d", code)
}
m := res.(map[string]any)
b, _ := base64.StdEncoding.DecodeString(m["content_b64"].(string))
if string(b) != "hello world" {
t.Fatalf("content mismatch: %q", string(b))
}
}
func TestFsRead_MaxBytesTrunca(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "big.bin")
if err := os.WriteFile(target, make([]byte, 1000), 0644); err != nil {
t.Fatal(err)
}
cap := tempCap(t, tmp+"/**")
res, _, err := runFsRead(cap, map[string]any{"path": target, "max_bytes": 100})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]any)
if !m["truncated"].(bool) {
t.Fatalf("expected truncated=true")
}
if m["bytes_read"].(int) != 100 {
t.Fatalf("expected 100 bytes, got %v", m["bytes_read"])
}
}
func TestFsWrite_CreaParent(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "subdir", "nested", "x.txt")
cap := tempCap(t, tmp+"/**")
body := base64.StdEncoding.EncodeToString([]byte("payload"))
_, _, err := runFsWrite(cap, map[string]any{"path": target, "content_b64": body})
if err != nil {
t.Fatalf("write: %v", err)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "payload" {
t.Fatalf("content mismatch: %q", string(got))
}
}
func TestFsList_NoRecursive(t *testing.T) {
tmp := t.TempDir()
for _, n := range []string{"a.txt", "b.txt", "c.txt"} {
os.WriteFile(filepath.Join(tmp, n), []byte("x"), 0644)
}
os.Mkdir(filepath.Join(tmp, "subdir"), 0755)
// archivo en subdir NO debe aparecer (no recursivo)
os.WriteFile(filepath.Join(tmp, "subdir", "hidden.txt"), []byte("y"), 0644)
cap := tempCap(t, tmp+"/**")
res, _, err := runFsList(cap, map[string]any{"dir": tmp})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]any)
entries := m["entries"].([]map[string]any)
if len(entries) != 4 {
t.Fatalf("expected 4 entries, got %d: %+v", len(entries), entries)
}
// Check kind correcto: subdir es dir
for _, e := range entries {
if e["name"] == "subdir" && e["kind"] != "dir" {
t.Fatalf("subdir kind != dir: %v", e)
}
}
}
func TestFsStat_OK(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "z.txt")
os.WriteFile(target, []byte("abc"), 0644)
cap := tempCap(t, tmp+"/**")
res, _, err := runFsStat(cap, map[string]any{"path": target})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]any)
if m["kind"] != "file" {
t.Fatalf("kind: %v", m["kind"])
}
if m["size"].(int64) != 3 {
t.Fatalf("size: %v", m["size"])
}
}
func TestPathTraversal_DotDot(t *testing.T) {
tmp := t.TempDir()
cap := tempCap(t, tmp+"/**")
traversal := filepath.Join(tmp, "..", "..", "..", "etc", "passwd")
_, _, err := runFsRead(cap, map[string]any{"path": traversal})
if err == nil || !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected blocked traversal, got err=%v", err)
}
}
func TestPathTraversal_Symlink(t *testing.T) {
tmp := t.TempDir()
// crear symlink dentro de tmp apuntando a /etc/passwd
linkPath := filepath.Join(tmp, "leak")
if err := os.Symlink("/etc/passwd", linkPath); err != nil {
t.Skipf("symlink failed: %v", err)
}
cap := tempCap(t, tmp+"/**")
_, _, err := runFsRead(cap, map[string]any{"path": linkPath})
// EvalSymlinks lo resuelve a /etc/passwd que NO esta en allowed -> reject
if err == nil {
t.Fatalf("expected symlink leak rejected")
}
}
func TestFsList_Glob(t *testing.T) {
tmp := t.TempDir()
for _, n := range []string{"a.log", "b.log", "c.txt"} {
os.WriteFile(filepath.Join(tmp, n), []byte("x"), 0644)
}
cap := tempCap(t, tmp+"/**")
res, _, err := runFsList(cap, map[string]any{"dir": tmp, "glob": "*.log"})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]any)
entries := m["entries"].([]map[string]any)
if len(entries) != 2 {
t.Fatalf("expected 2 .log, got %d", len(entries))
}
}
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
const gitTimeoutSeconds = 120
// gitRun ejecuta git con argumentos en cwd. Devuelve stdout, stderr, exitCode.
func gitRun(cwd string, args ...string) (string, string, int, error) {
if _, err := exec.LookPath("git"); err != nil {
return "", "", -1, fmt.Errorf("git binary not in PATH")
}
ctx, cancel := context.WithTimeout(context.Background(), gitTimeoutSeconds*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "git", args...) // #nosec G204 — args controlled
if cwd != "" {
c.Dir = cwd
}
var stdout, stderr strings.Builder
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
exitCode := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
} else {
return stdout.String(), stderr.String(), -1, err
}
}
return stdout.String(), stderr.String(), exitCode, nil
}
// runGitClone clona un repo a dest. Valida dest contra cap.PathsAllowed.
func runGitClone(cap *Capability, args map[string]any) (any, int, error) {
url := mapStringField(args, "url")
dest := mapStringField(args, "dest")
if url == "" || dest == "" {
return nil, -1, fmt.Errorf("url and dest required")
}
if !isPathAllowed(dest, cap.PathsAllowed) {
return nil, -1, fmt.Errorf("dest not allowed by manifest: %s", dest)
}
stdout, stderr, code, err := gitRun("", "clone", url, dest)
if err != nil {
return nil, -1, fmt.Errorf("git clone: %w (%s)", err, stderr)
}
if code != 0 {
return map[string]any{
"stdout": stdout,
"stderr": stderr,
"exit_code": code,
}, code, fmt.Errorf("git clone exit=%d: %s", code, stderr)
}
// recoge HEAD commit + branch
sha, _, _, _ := gitRun(dest, "rev-parse", "HEAD")
branch, _, _, _ := gitRun(dest, "rev-parse", "--abbrev-ref", "HEAD")
return map[string]any{
"dest": dest,
"commit_sha": strings.TrimSpace(sha),
"branch": strings.TrimSpace(branch),
"stdout": stdout,
"exit_code": 0,
}, 0, nil
}
// runGitCommit hace git add + commit en repo. files opcional; vacio = -am.
func runGitCommit(cap *Capability, args map[string]any) (any, int, error) {
repo := mapStringField(args, "repo")
msg := mapStringField(args, "message")
if repo == "" || msg == "" {
return nil, -1, fmt.Errorf("repo and message required")
}
if !isPathAllowed(repo, cap.PathsAllowed) {
return nil, -1, fmt.Errorf("repo not allowed by manifest: %s", repo)
}
var files []string
if raw, ok := args["files"]; ok && raw != nil {
if arr, ok := raw.([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
files = append(files, s)
}
}
}
}
if len(files) > 0 {
addArgs := append([]string{"add", "--"}, files...)
_, addStderr, code, err := gitRun(repo, addArgs...)
if err != nil || code != 0 {
return nil, code, fmt.Errorf("git add: %s", addStderr)
}
_, cStderr, code, err := gitRun(repo, "commit", "-m", msg)
if err != nil || code != 0 {
return map[string]any{"stderr": cStderr, "exit_code": code}, code, fmt.Errorf("git commit exit=%d: %s", code, cStderr)
}
} else {
_, cStderr, code, err := gitRun(repo, "commit", "-am", msg)
if err != nil || code != 0 {
return map[string]any{"stderr": cStderr, "exit_code": code}, code, fmt.Errorf("git commit exit=%d: %s", code, cStderr)
}
}
sha, _, _, _ := gitRun(repo, "rev-parse", "HEAD")
return map[string]any{
"repo": repo,
"commit_sha": strings.TrimSpace(sha),
"exit_code": 0,
}, 0, nil
}
// runGitPush push del repo. remote default "origin", branch default "HEAD".
func runGitPush(cap *Capability, args map[string]any) (any, int, error) {
repo := mapStringField(args, "repo")
if repo == "" {
return nil, -1, fmt.Errorf("repo required")
}
if !isPathAllowed(repo, cap.PathsAllowed) {
return nil, -1, fmt.Errorf("repo not allowed by manifest: %s", repo)
}
remote := mapStringField(args, "remote")
if remote == "" {
remote = "origin"
}
branch := mapStringField(args, "branch")
if branch == "" {
branch = "HEAD"
}
stdout, stderr, code, err := gitRun(repo, "push", remote, branch)
if err != nil {
return nil, -1, fmt.Errorf("git push: %w (%s)", err, stderr)
}
if code != 0 {
return map[string]any{
"stdout": stdout,
"stderr": stderr,
"exit_code": code,
}, code, fmt.Errorf("git push exit=%d: %s", code, stderr)
}
return map[string]any{
"ok": true,
"remote": remote,
"branch": branch,
"stdout": stdout,
"stderr": stderr,
"exit_code": 0,
}, 0, nil
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func skipIfNoGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
}
func initRepo(t *testing.T, dir string) {
t.Helper()
mustRun := func(args ...string) {
c := exec.Command("git", args...)
c.Dir = dir
c.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@x",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@x")
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
mustRun("init", "-b", "master")
mustRun("config", "user.email", "test@x")
mustRun("config", "user.name", "test")
}
func TestGitCommit_OK(t *testing.T) {
skipIfNoGit(t)
tmp := t.TempDir()
repo := filepath.Join(tmp, "repo")
os.MkdirAll(repo, 0755)
initRepo(t, repo)
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("hello"), 0644)
cap := tempCap(t, tmp+"/**")
res, _, err := runGitCommit(cap, map[string]any{
"repo": repo,
"message": "init",
"files": []any{"f.txt"},
})
if err != nil {
t.Fatalf("commit: %v", err)
}
m := res.(map[string]any)
if m["commit_sha"].(string) == "" {
t.Fatalf("empty sha")
}
}
func TestGitCommit_PathNotAllowed(t *testing.T) {
skipIfNoGit(t)
cap := tempCap(t, "/tmp/safe/**")
_, _, err := runGitCommit(cap, map[string]any{
"repo": "/etc",
"message": "evil",
})
if err == nil || !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected reject, got %v", err)
}
}
func TestGitClone_Local(t *testing.T) {
skipIfNoGit(t)
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
os.MkdirAll(src, 0755)
initRepo(t, src)
os.WriteFile(filepath.Join(src, "f.txt"), []byte("data"), 0644)
c := exec.Command("git", "add", ".")
c.Dir = src
c.Run()
c = exec.Command("git", "commit", "-m", "init")
c.Dir = src
c.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x")
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("seed commit: %v %s", err, out)
}
dest := filepath.Join(tmp, "clone")
cap := tempCap(t, tmp+"/**")
res, code, err := runGitClone(cap, map[string]any{
"url": src,
"dest": dest,
})
if err != nil {
t.Fatalf("clone: %v code=%d", err, code)
}
m := res.(map[string]any)
if m["commit_sha"].(string) == "" {
t.Fatalf("empty sha")
}
if _, err := os.Stat(filepath.Join(dest, "f.txt")); err != nil {
t.Fatalf("file not present after clone")
}
}
func TestGitPush_PathNotAllowed(t *testing.T) {
skipIfNoGit(t)
cap := tempCap(t, "/tmp/safe/**")
_, _, err := runGitPush(cap, map[string]any{"repo": "/etc"})
if err == nil || !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected reject, got %v", err)
}
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
const pkgMaxResults = 50
// detectDistro lee /etc/os-release y devuelve ID (ubuntu, debian, fedora, arch, ...).
func detectDistro() string {
f, err := os.Open("/etc/os-release")
if err != nil {
return ""
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "ID=") {
return strings.Trim(strings.TrimPrefix(line, "ID="), `"`)
}
}
return ""
}
// runPkgSearch busca paquetes. Detect distro y delega a apt/dnf/pacman.
// Permite override via env PKG_FAKE_OUTPUT (for tests).
func runPkgSearch(cap *Capability, args map[string]any) (any, int, error) {
_ = cap
query := mapStringField(args, "query")
if query == "" {
return nil, -1, fmt.Errorf("query required")
}
// Validar query: solo alfanumerico + . + _ + -
for _, r := range query {
if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') &&
!(r >= '0' && r <= '9') && r != '.' && r != '_' && r != '-' && r != '+' {
return nil, -1, fmt.Errorf("invalid char in query: %q", query)
}
}
// Test fake output via env
if fake := os.Getenv("PKG_FAKE_OUTPUT"); fake != "" {
return parsePkgOutput(fake, "apt", query), 0, nil
}
distro := detectDistro()
var bin string
var argv []string
switch distro {
case "ubuntu", "debian":
bin = "apt-cache"
argv = []string{"search", query}
case "fedora", "rhel", "centos":
bin = "dnf"
argv = []string{"search", "--quiet", query}
case "arch", "manjaro":
bin = "pacman"
argv = []string{"-Ss", query}
default:
// fallback try apt-cache
if _, err := exec.LookPath("apt-cache"); err == nil {
bin = "apt-cache"
argv = []string{"search", query}
distro = "ubuntu"
} else {
return nil, -1, fmt.Errorf("unsupported distro %q (no pkg manager found)", distro)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, argv...) // #nosec G204 — query validated
out, err := c.Output()
if err != nil {
return nil, -1, fmt.Errorf("%s search: %w", bin, err)
}
return parsePkgOutput(string(out), distro, query), 0, nil
}
// parsePkgOutput parsea salida apt-cache search / dnf search / pacman -Ss.
func parsePkgOutput(out, distro, query string) map[string]any {
packages := []map[string]any{}
lines := strings.Split(out, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var name, desc string
switch distro {
case "ubuntu", "debian", "apt":
// "name - description"
idx := strings.Index(line, " - ")
if idx > 0 {
name = line[:idx]
desc = line[idx+3:]
} else {
name = line
}
case "arch", "manjaro":
// "repo/name version" then next line " desc"
if strings.HasPrefix(line, " ") {
if len(packages) > 0 {
packages[len(packages)-1]["description"] = strings.TrimSpace(line)
}
continue
}
parts := strings.Fields(line)
if len(parts) > 0 {
name = parts[0]
}
default:
// dnf: "name.arch : description"
if idx := strings.Index(line, " : "); idx > 0 {
name = strings.TrimSpace(line[:idx])
desc = strings.TrimSpace(line[idx+3:])
} else {
name = line
}
}
if name == "" {
continue
}
packages = append(packages, map[string]any{
"name": name,
"description": desc,
})
if len(packages) >= pkgMaxResults {
break
}
}
return map[string]any{
"query": query,
"distro": distro,
"packages": packages,
"count": len(packages),
"truncated": len(packages) >= pkgMaxResults,
}
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"os"
"strings"
"testing"
)
func TestPkgSearch_FakeOutput(t *testing.T) {
fake := "vim - Vi IMproved - enhanced vi editor\nvim-tiny - Vi IMproved - enhanced vi editor - compact version"
os.Setenv("PKG_FAKE_OUTPUT", fake)
defer os.Unsetenv("PKG_FAKE_OUTPUT")
cap := &Capability{Name: "pkg.search"}
res, code, err := runPkgSearch(cap, map[string]any{"query": "vim"})
if err != nil {
t.Fatalf("search: %v", err)
}
if code != 0 {
t.Fatalf("code: %d", code)
}
m := res.(map[string]any)
pkgs := m["packages"].([]map[string]any)
if len(pkgs) != 2 {
t.Fatalf("expected 2 pkgs, got %d", len(pkgs))
}
if pkgs[0]["name"] != "vim" {
t.Fatalf("name mismatch: %v", pkgs[0]["name"])
}
}
func TestPkgSearch_InvalidQuery(t *testing.T) {
cap := &Capability{Name: "pkg.search"}
_, _, err := runPkgSearch(cap, map[string]any{"query": "vim; rm -rf /"})
if err == nil || !strings.Contains(err.Error(), "invalid char") {
t.Fatalf("expected invalid char, got %v", err)
}
}
func TestPkgSearch_NoQuery(t *testing.T) {
cap := &Capability{Name: "pkg.search"}
_, _, err := runPkgSearch(cap, map[string]any{})
if err == nil || !strings.Contains(err.Error(), "query required") {
t.Fatalf("expected query required, got %v", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"context"
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
)
const procMaxEntries = 200
// runProcList enumera procesos. En linux usa /proc parsing via `ps -eo`.
func runProcList(cap *Capability, args map[string]any) (any, int, error) {
_ = cap
_ = args
if runtime.GOOS == "windows" {
return runProcListWindows()
}
return runProcListUnix()
}
func runProcListUnix() (any, int, error) {
if _, err := exec.LookPath("ps"); err != nil {
return nil, -1, fmt.Errorf("ps binary not found")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// ps -eo pid,ppid,user,comm,rss,pcpu --no-headers
c := exec.CommandContext(ctx, "ps", "-eo", "pid,ppid,user,comm,rss,pcpu", "--no-headers")
out, err := c.Output()
if err != nil {
return nil, -1, fmt.Errorf("ps: %w", err)
}
lines := strings.Split(string(out), "\n")
processes := []map[string]any{}
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
pid, _ := strconv.Atoi(fields[0])
ppid, _ := strconv.Atoi(fields[1])
user := fields[2]
name := fields[3]
rss, _ := strconv.ParseInt(fields[4], 10, 64)
cpu, _ := strconv.ParseFloat(fields[5], 64)
processes = append(processes, map[string]any{
"pid": pid,
"ppid": ppid,
"user": user,
"name": name,
"rss_kb": rss,
"cpu_pct": cpu,
})
if len(processes) >= procMaxEntries {
break
}
}
return map[string]any{
"processes": processes,
"count": len(processes),
"truncated": len(processes) >= procMaxEntries,
}, 0, nil
}
func runProcListWindows() (any, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "tasklist", "/FO", "CSV", "/NH")
out, err := c.Output()
if err != nil {
return nil, -1, fmt.Errorf("tasklist: %w", err)
}
lines := strings.Split(string(out), "\n")
processes := []map[string]any{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ",")
if len(parts) < 5 {
continue
}
name := strings.Trim(parts[0], `"`)
pid, _ := strconv.Atoi(strings.Trim(parts[1], `"`))
processes = append(processes, map[string]any{
"pid": pid,
"name": name,
})
if len(processes) >= procMaxEntries {
break
}
}
return map[string]any{
"processes": processes,
"count": len(processes),
"truncated": len(processes) >= procMaxEntries,
}, 0, nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"testing"
)
func TestProcList_NonEmpty(t *testing.T) {
cap := &Capability{Name: "proc.list"}
res, code, err := runProcList(cap, map[string]any{})
if err != nil {
t.Fatalf("proc.list: %v", err)
}
if code != 0 {
t.Fatalf("expected code=0 got %d", code)
}
m := res.(map[string]any)
procs := m["processes"].([]map[string]any)
if len(procs) == 0 {
t.Fatalf("expected processes > 0")
}
for _, p := range procs {
if p["pid"].(int) <= 0 {
t.Fatalf("invalid pid: %v", p)
}
}
}
+316
View File
@@ -0,0 +1,316 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)
// ShellEvalArgs es el payload args para shell.eval (decodificado desde un map).
type ShellEvalArgs struct {
Cmd string `json:"cmd"`
Shell string `json:"shell,omitempty"` // bash|powershell|auto (default auto)
CWD string `json:"cwd,omitempty"`
}
// hardBlocklist son patrones bloqueados SIEMPRE (no configurable).
// Match case-insensitive. Cualquier match = rechazo, no se puede aprobar.
var hardBlocklist = []string{
`rm\s+-rf\s+/`,
`rm\s+-rf\s+\$HOME`,
`rm\s+-rf\s+~/?\s*$`,
`dd\s+if=`,
`mkfs\.`,
`chmod\s+-R\s+777\s+/`,
`curl[^|]*\|\s*sh`,
`wget[^|]*\|\s*sh`,
`wget[^|]*\|\s*bash`,
`>\s*/dev/sd[a-z]`,
`>\s*/dev/nvme`,
`:(){\s*:\|:&\s*};:`,
`:\(\)\s*{\s*:\|:&\s*};:`,
`shutdown\s+-h`,
`reboot\b`,
`halt\b`,
`init\s+0`,
`init\s+6`,
`format\s+[c-z]:`,
}
// defaultAutoApprove se usa cuando Capability.AutoApprove esta vacio.
var defaultAutoApprove = []string{
`^git\s`,
`^ls\s`,
`^cat\s`,
`^grep\s`,
`^find\s`,
`^docker\s+ps`,
`^docker\s+logs`,
`^ps\b`,
`^df\s`,
`^du\s`,
`^echo\s`,
`^pwd$`,
`^hostname$`,
`^whoami$`,
`^date$`,
`^uname`,
}
// approvalQueuePath path del jsonl que persiste solicitudes pendientes
// de aprobacion (mecanismo placeholder hasta 0144f).
var approvalQueuePath = filepath.Join("local_files", "approval_queue.jsonl")
// runShellEval implementa la capability shell.eval.
//
// Devuelve (result, exitCode, err). Si err != nil, result deberia ser nil.
// extra contiene los campos cleartext (cmd/cwd/shell/stdout/stderr) que
// el caller pasara a audit.AppendVerbose. Si no se ejecuto (blocklist /
// approval_required), extra puede ser nil.
func runShellEval(cap *Capability, raw map[string]any) (result map[string]any, exitCode int, extra *ShellEvalRecord, err error) {
// 1. Parse args desde raw via json roundtrip.
rawJSON, jerr := json.Marshal(raw)
if jerr != nil {
return nil, -1, nil, fmt.Errorf("marshal raw args: %w", jerr)
}
var args ShellEvalArgs
if jerr := json.Unmarshal(rawJSON, &args); jerr != nil {
return nil, -1, nil, fmt.Errorf("unmarshal args: %w", jerr)
}
args.Cmd = strings.TrimSpace(args.Cmd)
if args.Cmd == "" {
return nil, -1, nil, fmt.Errorf("cmd is required")
}
// 2. Hardcoded blocklist + capability extension.
blocklist := append([]string{}, hardBlocklist...)
blocklist = append(blocklist, cap.Blocklist...)
for _, pat := range blocklist {
re, rerr := regexp.Compile(`(?i)` + pat)
if rerr != nil {
// pattern operator invalido -> skip pero no bloqueamos.
continue
}
if re.MatchString(args.Cmd) {
return nil, -1, nil, fmt.Errorf("blocked: matches hardcoded safety blocklist (%s)", pat)
}
}
// 3. OS detect + shell selection.
chosenShell, sErr := resolveShell(cap, args.Shell)
if sErr != nil {
return nil, -1, nil, sErr
}
// 4. Auto-approval check.
autoApprove := cap.AutoApprove
if len(autoApprove) == 0 {
autoApprove = defaultAutoApprove
}
approved := false
for _, pat := range autoApprove {
re, rerr := regexp.Compile(pat)
if rerr != nil {
continue
}
if re.MatchString(args.Cmd) {
approved = true
break
}
}
approvalStatus := "none-required"
if approved {
approvalStatus = "auto-approved"
} else if cap.RequiresApproval {
// 5. requires_approval true + not auto -> queue + error
if err := enqueueApproval(args, cap); err != nil {
return nil, -1, nil, fmt.Errorf("enqueue approval failed: %w", err)
}
return nil, -1, nil, fmt.Errorf("approval_required: cmd queued at %s", approvalQueuePath)
}
// 6. Exec.
timeoutS := cap.TimeoutSeconds
if timeoutS <= 0 {
timeoutS = 60
}
maxOut := cap.MaxOutputBytes
if maxOut <= 0 {
maxOut = 1024 * 1024 // 1MB
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutS)*time.Second)
defer cancel()
var c *exec.Cmd
switch chosenShell {
case "bash":
c = exec.CommandContext(ctx, "bash", "-c", args.Cmd)
case "powershell":
bin := "powershell.exe"
if _, lerr := exec.LookPath(bin); lerr != nil {
if _, lerr2 := exec.LookPath("pwsh"); lerr2 == nil {
bin = "pwsh"
}
}
c = exec.CommandContext(ctx, bin, "-NoProfile", "-NonInteractive", "-Command", args.Cmd)
default:
return nil, -1, nil, fmt.Errorf("unknown shell %q", chosenShell)
}
if args.CWD != "" {
if !filepath.IsAbs(args.CWD) {
return nil, -1, nil, fmt.Errorf("cwd must be absolute path")
}
st, serr := os.Stat(args.CWD)
if serr != nil || !st.IsDir() {
return nil, -1, nil, fmt.Errorf("cwd does not exist or not a dir: %s", args.CWD)
}
c.Dir = args.CWD
}
var stdoutBuf, stderrBuf strings.Builder
c.Stdout = &capWriter{buf: &stdoutBuf, max: maxOut}
c.Stderr = &capWriter{buf: &stderrBuf, max: maxOut}
start := time.Now()
runErr := c.Run()
dur := time.Since(start).Milliseconds()
exitCode = 0
if runErr != nil {
if ee, ok := runErr.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
} else if ctx.Err() == context.DeadlineExceeded {
exitCode = 124 // convencion timeout
} else {
exitCode = -1
}
}
stdoutS := stdoutBuf.String()
stderrS := stderrBuf.String()
truncated := false
if sw, ok := c.Stdout.(*capWriter); ok && sw.truncated {
truncated = true
}
if sw, ok := c.Stderr.(*capWriter); ok && sw.truncated {
truncated = true
}
extra = &ShellEvalRecord{
Cmd: args.Cmd,
CWD: args.CWD,
Shell: chosenShell,
Stdout: stdoutS,
Stderr: stderrS,
}
result = map[string]any{
"stdout": stdoutS,
"stderr": stderrS,
"exit_code": exitCode,
"shell": chosenShell,
"approval_status": approvalStatus,
"truncated": truncated,
"duration_ms": dur,
"cmd_executed": args.Cmd,
}
return result, exitCode, extra, nil
}
// resolveShell decide bash o powershell segun GOOS + override (cap.ShellMode/args.Shell).
// Reglas:
// - args.Shell (call-level) > cap.ShellMode (manifest) > GOOS-default.
// - "auto" = GOOS-default.
// - powershell en linux solo si pwsh esta en PATH.
// - bash en windows: rechazado.
func resolveShell(cap *Capability, callShell string) (string, error) {
mode := strings.ToLower(strings.TrimSpace(callShell))
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(cap.ShellMode))
}
if mode == "" || mode == "auto" {
switch runtime.GOOS {
case "windows":
return "powershell", nil
default:
return "bash", nil
}
}
switch mode {
case "bash":
if runtime.GOOS == "windows" {
return "", fmt.Errorf("bash on windows not supported by this agent")
}
return "bash", nil
case "powershell":
if runtime.GOOS != "windows" {
if _, err := exec.LookPath("pwsh"); err != nil {
return "", fmt.Errorf("powershell requested but pwsh not in PATH on %s", runtime.GOOS)
}
}
return "powershell", nil
default:
return "", fmt.Errorf("invalid shell mode %q (want bash|powershell|auto)", mode)
}
}
// enqueueApproval anade una entrada al jsonl. POC: el agent LLM observa el fichero.
// En 0144f pasa a Matrix reactions.
func enqueueApproval(args ShellEvalArgs, cap *Capability) error {
if err := os.MkdirAll(filepath.Dir(approvalQueuePath), 0700); err != nil {
return err
}
entry := map[string]any{
"ts": time.Now().Unix(),
"request_id": fmt.Sprintf("approval-%d", time.Now().UnixNano()),
"cmd": args.Cmd,
"cwd": args.CWD,
"capability": cap.Name,
"status": "pending",
}
b, err := json.Marshal(entry)
if err != nil {
return err
}
f, err := os.OpenFile(approvalQueuePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(append(b, '\n')); err != nil {
return err
}
return nil
}
// capWriter es un io.Writer que trunca a max bytes y flag truncated.
type capWriter struct {
buf *strings.Builder
max int
truncated bool
}
func (w *capWriter) Write(p []byte) (int, error) {
remaining := w.max - w.buf.Len()
if remaining <= 0 {
w.truncated = true
return len(p), nil // descartamos pero pretendemos exito para no romper proceso
}
if len(p) > remaining {
w.buf.Write(p[:remaining])
w.truncated = true
return len(p), nil
}
w.buf.Write(p)
return len(p), nil
}
+297
View File
@@ -0,0 +1,297 @@
package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setupTestEnv crea un dir temporal, redirige approvalQueuePath alli, y devuelve cleanup.
func setupTestEnv(t *testing.T) (string, func()) {
t.Helper()
tmp, err := os.MkdirTemp("", "device_agent_test_*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
prevWD, _ := os.Getwd()
if err := os.Chdir(tmp); err != nil {
_ = os.RemoveAll(tmp)
t.Fatalf("chdir: %v", err)
}
prevApprovalPath := approvalQueuePath
approvalQueuePath = filepath.Join(tmp, "local_files", "approval_queue.jsonl")
cleanup := func() {
approvalQueuePath = prevApprovalPath
_ = os.Chdir(prevWD)
_ = os.RemoveAll(tmp)
}
return tmp, cleanup
}
// TestShellEval_BlocklistRmRf comprueba que rm -rf / es rechazado sin ejecucion.
func TestShellEval_BlocklistRmRf(t *testing.T) {
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
_, _, _, err := runShellEval(cap, map[string]any{"cmd": "rm -rf /"})
if err == nil {
t.Fatal("expected error from blocklist, got nil")
}
if !strings.Contains(err.Error(), "blocked") {
t.Fatalf("expected 'blocked' in err, got: %v", err)
}
}
// TestShellEval_BlocklistCaseInsensitive comprueba que case-insensitive funciona.
func TestShellEval_BlocklistCaseInsensitive(t *testing.T) {
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
_, _, _, err := runShellEval(cap, map[string]any{"cmd": "RM -RF /"})
if err == nil {
t.Fatal("expected error from blocklist (case insensitive), got nil")
}
if !strings.Contains(err.Error(), "blocked") {
t.Fatalf("expected 'blocked' in err, got: %v", err)
}
}
// TestShellEval_AutoApproveGitStatus comprueba que git status entra en defaultAutoApprove.
func TestShellEval_AutoApproveGitStatus(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
res, exit, extra, err := runShellEval(cap, map[string]any{"cmd": "git status --porcelain"})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if res["approval_status"] != "auto-approved" {
t.Fatalf("expected approval_status=auto-approved, got: %v", res["approval_status"])
}
// git might exit non-zero if not in a repo, but it should have *executed*
if extra == nil {
t.Fatal("expected extra audit record, got nil")
}
if extra.Cmd != "git status --porcelain" {
t.Fatalf("extra.Cmd mismatch: %q", extra.Cmd)
}
_ = exit
}
// TestShellEval_QueuedWhenRequiresApproval comprueba que un cmd no auto + requires_approval = queued + error.
func TestShellEval_QueuedWhenRequiresApproval(t *testing.T) {
tmp, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
RequiresApproval: true,
AutoApprove: []string{`^echo\s`}, // override defaults — solo echo entra
}
_, _, _, err := runShellEval(cap, map[string]any{"cmd": "ls -la"})
if err == nil {
t.Fatal("expected approval_required error, got nil")
}
if !strings.Contains(err.Error(), "approval_required") {
t.Fatalf("expected 'approval_required' in err, got: %v", err)
}
// queue file existe + contiene la entry
qPath := filepath.Join(tmp, "local_files", "approval_queue.jsonl")
data, ferr := os.ReadFile(qPath)
if ferr != nil {
t.Fatalf("expected approval queue file, got: %v", ferr)
}
if !strings.Contains(string(data), `"cmd":"ls -la"`) {
t.Fatalf("approval queue missing cmd, got: %s", data)
}
if !strings.Contains(string(data), `"status":"pending"`) {
t.Fatalf("approval queue missing status=pending, got: %s", data)
}
}
// TestShellEval_NoApprovalNeededExecutes: cmd no auto + no requires_approval -> ejecuta directo.
func TestShellEval_NoApprovalNeededExecutes(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
RequiresApproval: false,
AutoApprove: []string{`^echo\s`}, // strict autoapprove (irrelevant since no approval needed)
}
res, _, extra, err := runShellEval(cap, map[string]any{"cmd": "printf hello"})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if res["approval_status"] != "none-required" {
t.Fatalf("expected approval_status=none-required, got: %v", res["approval_status"])
}
if extra.Stdout != "hello" {
t.Fatalf("expected stdout 'hello', got: %q", extra.Stdout)
}
}
// TestShellEval_Timeout: sleep 5 con TimeoutSeconds=1 -> exit_code != 0.
func TestShellEval_Timeout(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
TimeoutSeconds: 1,
AutoApprove: []string{`^sleep\s`}, // autoapprove sleep for the test
}
res, _, _, err := runShellEval(cap, map[string]any{"cmd": "sleep 5"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
exit, _ := res["exit_code"].(int)
if exit == 0 {
t.Fatalf("expected non-zero exit on timeout, got 0; result: %+v", res)
}
}
// TestShellEval_OutputTruncation: output > MaxOutputBytes -> truncated=true.
func TestShellEval_OutputTruncation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{
Name: "shell.eval",
MaxOutputBytes: 50,
AutoApprove: []string{`^printf`},
}
// produce > 50 bytes
res, _, _, err := runShellEval(cap, map[string]any{
"cmd": `printf '%0.s-' {1..500}`,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res["truncated"] != true {
t.Fatalf("expected truncated=true, got: %v (full result %+v)", res["truncated"], res)
}
}
// TestShellEval_PowershellOnLinuxWithoutPwsh: si pwsh no esta en PATH y se pide powershell, error.
func TestShellEval_PowershellOnLinuxWithoutPwsh(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test only meaningful on non-windows")
}
// Save PATH and clear it to ensure pwsh not found
origPATH := os.Getenv("PATH")
defer os.Setenv("PATH", origPATH)
os.Setenv("PATH", "/nonexistent")
_, cleanup := setupTestEnv(t)
defer cleanup()
cap := &Capability{Name: "shell.eval"}
_, _, _, err := runShellEval(cap, map[string]any{
"cmd": "echo hi",
"shell": "powershell",
})
if err == nil {
t.Fatal("expected error when powershell requested on linux without pwsh")
}
if !strings.Contains(err.Error(), "powershell") && !strings.Contains(err.Error(), "pwsh") {
t.Fatalf("err msg doesn't mention powershell/pwsh: %v", err)
}
}
// TestShellEval_AuditVerboseWritesCleartext: tras una exec, audit_shell_eval tiene row con cmd en claro.
func TestShellEval_AuditVerboseWritesCleartext(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("bash test on windows skipped")
}
tmp, cleanup := setupTestEnv(t)
defer cleanup()
auditPath := filepath.Join(tmp, "audit.db")
audit, err := OpenAudit(auditPath)
if err != nil {
t.Fatalf("open audit: %v", err)
}
defer audit.Close()
cap := &Capability{
Name: "shell.eval",
AutoApprove: []string{`^printf`},
}
_, exitCode, extra, err := runShellEval(cap, map[string]any{"cmd": "printf forensic-cleartext"})
if err != nil {
t.Fatalf("runShellEval: %v", err)
}
if extra == nil {
t.Fatal("expected extra record")
}
_, err = audit.AppendVerbose("req-1", "shell.eval", []string{"printf forensic-cleartext"}, exitCode, *extra)
if err != nil {
t.Fatalf("AppendVerbose: %v", err)
}
// Read back
rows, err := audit.db.Query(`SELECT cmd, shell, stdout_b64 FROM audit_shell_eval`)
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
found := false
for rows.Next() {
var cmd, shell, stdoutB64 string
if err := rows.Scan(&cmd, &shell, &stdoutB64); err != nil {
t.Fatalf("scan: %v", err)
}
if cmd != "printf forensic-cleartext" {
t.Fatalf("expected cleartext cmd, got: %q", cmd)
}
if !strings.HasPrefix(stdoutB64, "plain:") {
t.Fatalf("expected plain-base64 stdout (short), got: %q", stdoutB64)
}
// Decode + verify
raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(stdoutB64, "plain:"))
if string(raw) != "forensic-cleartext" {
t.Fatalf("decoded stdout mismatch: %q", raw)
}
_ = shell
found = true
}
if !found {
t.Fatal("expected at least one row in audit_shell_eval")
}
}
// TestShellEval_ParseArgsJSONObject roundtrip.
func TestShellEval_ParseArgsJSONObject(t *testing.T) {
jsonStr, _ := json.Marshal(map[string]any{"cmd": "ls", "cwd": "/tmp"})
m, err := parseShellEvalArgs([]string{string(jsonStr)})
if err != nil {
t.Fatalf("parse: %v", err)
}
if m["cmd"] != "ls" || m["cwd"] != "/tmp" {
t.Fatalf("unexpected parsed: %+v", m)
}
}
// TestShellEval_ParseArgsPositional fallback.
func TestShellEval_ParseArgsPositional(t *testing.T) {
m, err := parseShellEvalArgs([]string{"ls -la", "/tmp", "bash"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if m["cmd"] != "ls -la" || m["cwd"] != "/tmp" || m["shell"] != "bash" {
t.Fatalf("unexpected parsed: %+v", m)
}
}
Executable
BIN
View File
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
module dataforge/device_agent
go 1.25.0
require (
fn-registry v0.0.0-00010101000000-000000000000
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace fn-registry => ../../../..
+55
View File
@@ -0,0 +1,55 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
const Version = "0.2.0"
func main() {
listen := flag.String("listen", "127.0.0.1:7474", "host:port a escuchar (ej. 10.42.0.10:7474 para mesh wg1)")
manifestPath := flag.String("manifest", defaultManifestPath(), "ruta al manifest YAML")
auditPath := flag.String("audit", "local_files/audit.db", "ruta al audit.db sqlite")
selfTest := flag.Bool("self-test", false, "smoke check, sale 0 si todo OK")
flag.Parse()
if *selfTest {
fmt.Println("device_agent self-test OK")
os.Exit(0)
}
// Cargar manifest.
mf, err := LoadManifest(*manifestPath)
if err != nil {
log.Fatalf("manifest load %s: %v", *manifestPath, err)
}
log.Printf("manifest loaded device_id=%s capabilities=%d", mf.DeviceID, len(mf.Capabilities))
// Abrir audit DB.
_ = os.MkdirAll(filepath.Dir(*auditPath), 0700)
audit, err := OpenAudit(*auditPath)
if err != nil {
log.Fatalf("audit open %s: %v", *auditPath, err)
}
defer audit.Close()
log.Printf("audit db opened %s", *auditPath)
// Construir router.
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"device_id": mf.DeviceID,
"version": Version,
"ts": time.Now().Unix(),
})
})
mux.HandleFunc("/capabilities", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"device_id": mf.DeviceID,
"capabilities": mf.Capabilities,
})
})
mux.HandleFunc("/capability", capabilityHandler(mf, audit))
srv := &http.Server{
Addr: *listen,
Handler: loggingMiddleware(mux),
ReadHeaderTimeout: 5 * time.Second,
}
// Listen explicit para detectar bind issues claro.
ln, err := net.Listen("tcp", *listen)
if err != nil {
log.Fatalf("listen %s: %v", *listen, err)
}
log.Printf("device_agent v%s listening %s (device_id=%s)", Version, *listen, mf.DeviceID)
// Graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Fatalf("serve: %v", err)
}
}()
<-ctx.Done()
log.Println("shutdown signal received, draining...")
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
log.Println("bye")
}
func defaultManifestPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "device_agent", "manifest.yaml")
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]any{
"ok": false,
"error": code,
"msg": msg,
})
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(ww, r)
dur := time.Since(start).Milliseconds()
log.Printf("%s %s %d %dms from=%s", r.Method, r.URL.Path, ww.status, dur, clientIP(r))
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (s *statusWriter) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
if strings.HasPrefix(host, "::") {
return host
}
return host
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Capability struct {
Name string `yaml:"name"`
BinariesAllowed []string `yaml:"binaries_allowed,omitempty"`
PathsAllowed []string `yaml:"paths_allowed,omitempty"`
RequiresApproval bool `yaml:"requires_approval"`
// shell.eval specific fields
Blocklist []string `yaml:"blocklist,omitempty"` // regex patterns hardcoded + extendible
AutoApprove []string `yaml:"auto_approve,omitempty"` // regex patterns pre-approved
ShellMode string `yaml:"shell_mode,omitempty"` // "bash"|"powershell"|"auto" (default auto)
MaxOutputBytes int `yaml:"max_output_bytes,omitempty"` // default 1MB
TimeoutSeconds int `yaml:"timeout_seconds,omitempty"` // default 60
// browser.* specific fields (CDP)
ChromeCDPHost string `yaml:"chrome_cdp_host,omitempty"` // default 127.0.0.1
ChromeCDPPort int `yaml:"chrome_cdp_port,omitempty"` // default 9223
ScreenshotDir string `yaml:"screenshot_dir,omitempty"` // dest dir for screenshots (browser.screenshot)
}
type Manifest struct {
DeviceID string `yaml:"device_id"`
Operator string `yaml:"operator"`
Capabilities []Capability `yaml:"capabilities"`
}
func LoadManifest(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read manifest %s: %w", path, err)
}
var mf Manifest
if err := yaml.Unmarshal(data, &mf); err != nil {
return nil, fmt.Errorf("parse manifest yaml: %w", err)
}
if mf.DeviceID == "" {
return nil, fmt.Errorf("manifest missing device_id")
}
if len(mf.Capabilities) == 0 {
return nil, fmt.Errorf("manifest has no capabilities")
}
return &mf, nil
}
// CapabilityByName devuelve el bloque del manifest para name, o nil si no esta.
func (m *Manifest) CapabilityByName(name string) *Capability {
for i := range m.Capabilities {
if m.Capabilities[i].Name == name {
return &m.Capabilities[i]
}
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# Smoke iterativo end-to-end de las 12 capabilities nuevas (+ 2 base = 14 total).
# Asume device_agent corriendo en 10.42.0.10:7474 con manifest enriched.
set -uo pipefail
URL=http://10.42.0.10:7474/capability
AUDIT_DB=/tmp/device_audit.db
PASS=0
FAIL=0
FAIL_NAMES=()
audit_count_before=$(sqlite3 "$AUDIT_DB" "SELECT COUNT(*) FROM audit_log;" 2>/dev/null || echo 0)
echo "[smoke] audit_log rows BEFORE: $audit_count_before"
echo
call() {
local name="$1"
local payload="$2"
local rid="smoke-$(date +%s%N)-$RANDOM"
local body
body=$(echo "$payload" | sed "s/__RID__/$rid/")
local resp
resp=$(curl -sS -X POST "$URL" -H "Content-Type: application/json" -d "$body")
local ok
ok=$(echo "$resp" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("ok",False))' 2>/dev/null || echo "False")
if [[ "$ok" == "True" ]]; then
echo "[OK] $name"
PASS=$((PASS+1))
else
echo "[FAIL] $name -> $resp"
FAIL=$((FAIL+1))
FAIL_NAMES+=("$name")
fi
}
# 1. shell.exec (baseline)
call "shell.exec" '{"request_id":"__RID__","capability":"shell.exec","args":["echo","smoke-test"]}'
# 2. shell.eval (baseline)
call "shell.eval" '{"request_id":"__RID__","capability":"shell.eval","args":{"cmd":"echo hello-eval"}}'
# 3. fs.read
echo "smoke-payload" > /tmp/smoke_test_file.txt
call "fs.read" '{"request_id":"__RID__","capability":"fs.read","args":{"path":"/tmp/smoke_test_file.txt"}}'
# 4. fs.write
B64=$(echo -n "smoke-write" | base64)
call "fs.write" "{\"request_id\":\"__RID__\",\"capability\":\"fs.write\",\"args\":{\"path\":\"/tmp/smoke_written.txt\",\"content_b64\":\"$B64\"}}"
# 5. fs.list
call "fs.list" '{"request_id":"__RID__","capability":"fs.list","args":{"dir":"/tmp"}}'
# 6. fs.stat
call "fs.stat" '{"request_id":"__RID__","capability":"fs.stat","args":{"path":"/tmp/smoke_test_file.txt"}}'
# 7. git.clone (local repo for offline-safe test)
rm -rf /tmp/smoke_git_src /tmp/smoke_git_dst
mkdir -p /tmp/smoke_git_src && cd /tmp/smoke_git_src && git init -q -b master && git config user.email t@x && git config user.name t && echo seed > f.txt && git add . && git commit -q -m init
call "git.clone" '{"request_id":"__RID__","capability":"git.clone","args":{"url":"/tmp/smoke_git_src","dest":"/tmp/smoke_git_dst"}}'
# 8. git.commit (on cloned repo, add new file)
cd /tmp/smoke_git_dst && git config user.email t@x && git config user.name t && echo new > new.txt
call "git.commit" '{"request_id":"__RID__","capability":"git.commit","args":{"repo":"/tmp/smoke_git_dst","message":"smoke commit","files":["new.txt"]}}'
# 9. git.push: setup bare repo as remote so push succeeds end-to-end
rm -rf /tmp/smoke_git_remote.git
mkdir -p /tmp/smoke_git_remote.git && (cd /tmp/smoke_git_remote.git && git init -q --bare)
(cd /tmp/smoke_git_dst && git remote remove origin 2>/dev/null; git remote add origin /tmp/smoke_git_remote.git)
call "git.push" '{"request_id":"__RID__","capability":"git.push","args":{"repo":"/tmp/smoke_git_dst","remote":"origin","branch":"master"}}'
# 10. pkg.search (use real apt-cache since on ubuntu)
call "pkg.search" '{"request_id":"__RID__","capability":"pkg.search","args":{"query":"vim"}}'
# 11. proc.list
call "proc.list" '{"request_id":"__RID__","capability":"proc.list","args":{}}'
# 12. docker.container.list
call "docker.container.list" '{"request_id":"__RID__","capability":"docker.container.list","args":{"all":true}}'
# 13. docker.container.exec (needs a running container)
CID=$(docker ps --format '{{.Names}}' 2>/dev/null | head -n1)
if [[ -n "$CID" ]]; then
call "docker.container.exec" "{\"request_id\":\"__RID__\",\"capability\":\"docker.container.exec\",\"args\":{\"container\":\"$CID\",\"argv\":[\"ls\",\"/\"]}}"
else
echo "[skip docker.container.exec: no running containers]"
fi
# 14. docker.container.logs
if [[ -n "$CID" ]]; then
call "docker.container.logs" "{\"request_id\":\"__RID__\",\"capability\":\"docker.container.logs\",\"args\":{\"container\":\"$CID\",\"tail\":10}}"
else
echo "[skip docker.container.logs: no running containers]"
fi
echo
echo "===== SMOKE SUMMARY ====="
echo "PASS: $PASS"
echo "FAIL: $FAIL"
if [[ $FAIL -gt 0 ]]; then
echo "Failed: ${FAIL_NAMES[*]}"
fi
audit_count_after=$(sqlite3 "$AUDIT_DB" "SELECT COUNT(*) FROM audit_log;" 2>/dev/null || echo 0)
echo "[smoke] audit_log rows AFTER: $audit_count_after"
echo "[smoke] delta: $((audit_count_after - audit_count_before))"