chore: sync from fn-registry agent
This commit is contained in:
@@ -0,0 +1 @@
|
||||
registry.db
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+30
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Binary file not shown.
@@ -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 => ../../../..
|
||||
@@ -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=
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Executable
+106
@@ -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))"
|
||||
Reference in New Issue
Block a user