Ships the MVP slice of spec 018 (dynamic agent spawning):
- 5 new SQLite migrations (021-025): goals + goal_tasks + agent_proposals
+ reputation_evidence + secrets + harness_runs.task_id. The legacy
`tasks` table (channel auctions) and `agent_trust` table (reactions
workflow) are left untouched — the new schema coexists.
- 4 new internal packages, fully tested:
- internal/goals: Goal struct + store + service, slug collision dedup,
backing-channel auto-create via ChannelCreator adapter
- internal/goaltasks: goal_tasks table with denormalized 16 KB
ancestry snapshots, single-statement optimistic-lock atomic claim,
recursive-CTE cost rollup, state machine, per-billing-code rollup
- internal/secrets: NaCl-secretbox encrypted blobs, user/agent/task
scope precedence, sanitized env injection, master-key bootstrap
- internal/trust additions: ConfigHash (deterministic SHA-256 of
model + prompt + tools + skills + mcp + subagents, sorted),
DelegationCap (tier + tool-scope + budget + depth enforcement),
append-only Ledger with exponential time-decay rolling score and
70%-of-parent child seeding. Existing trust package unchanged.
- Critical invariants under test:
- 50-goroutine concurrent claim race → exactly one winner per round
- ConfigHash stable under shuffled array inputs, sensitive to
capability changes
- DelegationCap full tier × tool-scope matrix
- Ledger time-decay + parent seed at 70 % ± 1 %
- Secret name sanitization, scope precedence, plaintext never
returned via MCP-equivalent paths
- internal/agents/types.go extended with dynamic-spawning columns
(config_hash, parent_agent_id, spawn_depth, system_prompt,
autonomy_tier, tool_scope_json, quarantined_at). Existing tests
still pass.
- cmd/docgardener: self-contained demo binary driving the end-to-end
flow. `docgardener run` creates a goal, builds a task tree with
denormalized ancestry, spawns 3 specialists (each going through
real delegation-cap validation and config-hash computation and
70 %-of-parent reputation seeding), claims tasks atomically, runs
them through the state machine, records reputation evidence.
`docgardener report` queries all of that back out and renders a
rich dark-mode HTML report (header, spend metrics, task tree,
spawned-agent cards with reputation bars, cost breakdown, artifacts,
timeline).
- examples/doc-gardener: start.sh / run_task.sh / report.sh / stop.sh
mirroring the cold-topic-explainer pattern. Launches an isolated
synapbus instance on port 18089, drives the demo, renders
report.html, cleans up. Full README documenting what's real vs
deferred, plus examples/README.md listing both examples.
- specs/018: tasks.md updated with MVP completion status; legacy tasks
naming collision noted.
Deferred (marked explicitly in example README):
- Real LLM-driven coordinator (needs MCP tool wiring + prompt
iteration)
- Real subprocess runs (needs reactor integration with task_id on
ExecRequest)
- Full MCP tool surface (contracts are written at
specs/018-dynamic-agent-spawning/contracts/mcp-tools.md)
- Svelte /goals UI (REST endpoints remain a follow-up)
- Full budget race + quarantine auto-trigger wiring
- Full resource-request → secrets fulfill reaction-workflow path
Cross-compiles clean for linux/amd64 and darwin/arm64 with no CGO
(SC-010). All new package tests pass (SC-004, SC-005, SC-007).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
361 lines
9.5 KiB
Go
361 lines
9.5 KiB
Go
package secrets
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/nacl/secretbox"
|
|
)
|
|
|
|
const (
|
|
// nonceSize is the NaCl secretbox nonce size in bytes.
|
|
nonceSize = 24
|
|
// keySize is the NaCl secretbox key size in bytes.
|
|
keySize = 32
|
|
// masterKeyFilename is the file inside the data dir holding the 32-byte
|
|
// master key. Stored with 0600 permissions.
|
|
masterKeyFilename = "secrets.key"
|
|
)
|
|
|
|
// Store provides CRUD over encrypted secrets backed by SQLite.
|
|
type Store struct {
|
|
db *sql.DB
|
|
logger *slog.Logger
|
|
masterKey [keySize]byte
|
|
}
|
|
|
|
// NewStore constructs a Store. It bootstraps the master key from
|
|
// <dataDir>/secrets.key, generating a fresh 32-byte key (0600 perms) if the
|
|
// file does not yet exist.
|
|
func NewStore(db *sql.DB, dataDir string, logger *slog.Logger) (*Store, error) {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
if db == nil {
|
|
return nil, fmt.Errorf("secrets: db is required")
|
|
}
|
|
if dataDir == "" {
|
|
return nil, fmt.Errorf("secrets: dataDir is required")
|
|
}
|
|
|
|
key, err := loadOrCreateMasterKey(dataDir, logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := &Store{db: db, logger: logger}
|
|
copy(s.masterKey[:], key)
|
|
return s, nil
|
|
}
|
|
|
|
func loadOrCreateMasterKey(dataDir string, logger *slog.Logger) ([]byte, error) {
|
|
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("%w: mkdir %s: %v", ErrMasterKeyMissing, dataDir, err)
|
|
}
|
|
path := filepath.Join(dataDir, masterKeyFilename)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
if len(data) != keySize {
|
|
return nil, fmt.Errorf("%w: %s has wrong size %d (want %d)", ErrMasterKeyMissing, path, len(data), keySize)
|
|
}
|
|
return data, nil
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("%w: read %s: %v", ErrMasterKeyMissing, path, err)
|
|
}
|
|
|
|
// Generate a new key.
|
|
buf := make([]byte, keySize)
|
|
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
|
|
return nil, fmt.Errorf("%w: generate: %v", ErrMasterKeyMissing, err)
|
|
}
|
|
if err := os.WriteFile(path, buf, 0o600); err != nil {
|
|
return nil, fmt.Errorf("%w: write %s: %v", ErrMasterKeyMissing, path, err)
|
|
}
|
|
logger.Info("generated new secrets master key", "path", path)
|
|
return buf, nil
|
|
}
|
|
|
|
// Set encrypts value and writes a new secret row. If an active secret with the
|
|
// same (scope_type, scope_id, name) already exists, it is revoked first so a
|
|
// new immutable history row can be inserted.
|
|
func (s *Store) Set(ctx context.Context, name, scopeType string, scopeID, createdBy int64, value string) (*Secret, error) {
|
|
clean, err := sanitizeName(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !validScope(scopeType) {
|
|
return nil, fmt.Errorf("secrets: invalid scope_type %q", scopeType)
|
|
}
|
|
|
|
blob, err := s.encrypt([]byte(value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secrets: encrypt: %w", err)
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secrets: begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Revoke any existing active row for the same (scope, name).
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE secrets
|
|
SET revoked_at = CURRENT_TIMESTAMP
|
|
WHERE name = ?
|
|
AND scope_type = ?
|
|
AND scope_id = ?
|
|
AND revoked_at IS NULL`,
|
|
clean, scopeType, scopeID,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("secrets: revoke previous: %w", err)
|
|
}
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO secrets (name, scope_type, scope_id, value_blob, created_by)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
clean, scopeType, scopeID, blob, createdBy,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secrets: insert: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secrets: last insert id: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("secrets: commit: %w", err)
|
|
}
|
|
|
|
// Re-read to populate created_at consistently.
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name, scope_type, scope_id, created_by, created_at, revoked_at, last_used_at
|
|
FROM secrets WHERE id = ?`,
|
|
id,
|
|
)
|
|
sec, err := scanSecret(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secrets: read back: %w", err)
|
|
}
|
|
s.logger.Info("secret set",
|
|
"id", sec.ID,
|
|
"name", sec.Name,
|
|
"scope_type", sec.ScopeType,
|
|
"scope_id", sec.ScopeID,
|
|
)
|
|
return sec, nil
|
|
}
|
|
|
|
// Get decrypts and returns the plaintext for the active secret matching
|
|
// (name, scope_type, scope_id). Caller must treat the returned string as
|
|
// sensitive — never log it.
|
|
func (s *Store) Get(ctx context.Context, name, scopeType string, scopeID int64) (string, error) {
|
|
clean, err := sanitizeName(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !validScope(scopeType) {
|
|
return "", fmt.Errorf("secrets: invalid scope_type %q", scopeType)
|
|
}
|
|
|
|
var blob []byte
|
|
err = s.db.QueryRowContext(ctx,
|
|
`SELECT value_blob
|
|
FROM secrets
|
|
WHERE name = ?
|
|
AND scope_type = ?
|
|
AND scope_id = ?
|
|
AND revoked_at IS NULL`,
|
|
clean, scopeType, scopeID,
|
|
).Scan(&blob)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return "", fmt.Errorf("secrets: query: %w", err)
|
|
}
|
|
|
|
plain, err := s.decrypt(blob)
|
|
if err != nil {
|
|
return "", fmt.Errorf("secrets: decrypt: %w", err)
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
// List returns Info entries for all active secrets in the given scopes.
|
|
// Values are never returned. Order is stable: by (scope_type, scope_id, name).
|
|
func (s *Store) List(ctx context.Context, scopes []Scope) ([]Info, error) {
|
|
if len(scopes) == 0 {
|
|
return []Info{}, nil
|
|
}
|
|
|
|
// Build dynamic IN clause: (scope_type=? AND scope_id=?) OR (...)
|
|
var (
|
|
parts []string
|
|
args []any
|
|
)
|
|
for _, sc := range scopes {
|
|
if !validScope(sc.Type) {
|
|
return nil, fmt.Errorf("secrets: invalid scope_type %q", sc.Type)
|
|
}
|
|
parts = append(parts, "(scope_type = ? AND scope_id = ?)")
|
|
args = append(args, sc.Type, sc.ID)
|
|
}
|
|
query := `SELECT name, scope_type, scope_id, last_used_at
|
|
FROM secrets
|
|
WHERE revoked_at IS NULL
|
|
AND (` + strings.Join(parts, " OR ") + `)
|
|
ORDER BY scope_type, scope_id, name`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secrets: list query: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Info
|
|
for rows.Next() {
|
|
var (
|
|
info Info
|
|
lastUsed sql.NullTime
|
|
)
|
|
if err := rows.Scan(&info.Name, &info.ScopeType, &info.ScopeID, &lastUsed); err != nil {
|
|
return nil, fmt.Errorf("secrets: scan: %w", err)
|
|
}
|
|
info.Available = true
|
|
if lastUsed.Valid {
|
|
t := lastUsed.Time
|
|
info.LastUsedAt = &t
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if out == nil {
|
|
out = []Info{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Revoke marks a secret revoked by primary key. It is idempotent in the sense
|
|
// that a non-existent row returns ErrNotFound and an already-revoked row
|
|
// returns ErrAlreadyRevoked.
|
|
func (s *Store) Revoke(ctx context.Context, id int64) error {
|
|
var revokedAt sql.NullTime
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT revoked_at FROM secrets WHERE id = ?`, id,
|
|
).Scan(&revokedAt)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return fmt.Errorf("secrets: lookup: %w", err)
|
|
}
|
|
if revokedAt.Valid {
|
|
return ErrAlreadyRevoked
|
|
}
|
|
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE secrets SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?`, id,
|
|
); err != nil {
|
|
return fmt.Errorf("secrets: revoke: %w", err)
|
|
}
|
|
s.logger.Info("secret revoked", "id", id)
|
|
return nil
|
|
}
|
|
|
|
// encrypt returns nonce(24) || ciphertext.
|
|
func (s *Store) encrypt(plain []byte) ([]byte, error) {
|
|
var nonce [nonceSize]byte
|
|
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]byte, 0, nonceSize+len(plain)+secretbox.Overhead)
|
|
out = append(out, nonce[:]...)
|
|
out = secretbox.Seal(out, plain, &nonce, &s.masterKey)
|
|
return out, nil
|
|
}
|
|
|
|
// decrypt parses nonce(24) || ciphertext and returns the plaintext.
|
|
func (s *Store) decrypt(blob []byte) ([]byte, error) {
|
|
if len(blob) < nonceSize+secretbox.Overhead {
|
|
return nil, fmt.Errorf("secrets: ciphertext too short (%d bytes)", len(blob))
|
|
}
|
|
var nonce [nonceSize]byte
|
|
copy(nonce[:], blob[:nonceSize])
|
|
plain, ok := secretbox.Open(nil, blob[nonceSize:], &nonce, &s.masterKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("secrets: decryption failed (key mismatch or corruption)")
|
|
}
|
|
return plain, nil
|
|
}
|
|
|
|
// sanitizeName uppercases raw and validates that it contains only [A-Z0-9_].
|
|
// Empty input is rejected. Lowercase letters are folded to uppercase before
|
|
// validation so callers may pass either case.
|
|
func sanitizeName(raw string) (string, error) {
|
|
if raw == "" {
|
|
return "", ErrInvalidName
|
|
}
|
|
upper := strings.ToUpper(raw)
|
|
for i := 0; i < len(upper); i++ {
|
|
c := upper[i]
|
|
switch {
|
|
case c >= 'A' && c <= 'Z':
|
|
case c >= '0' && c <= '9':
|
|
case c == '_':
|
|
default:
|
|
return "", ErrInvalidName
|
|
}
|
|
}
|
|
return upper, nil
|
|
}
|
|
|
|
func validScope(t string) bool {
|
|
switch t {
|
|
case ScopeUser, ScopeAgent, ScopeTask:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// scanSecret scans a single secret row from a *sql.Row.
|
|
func scanSecret(row *sql.Row) (*Secret, error) {
|
|
var (
|
|
s Secret
|
|
revoked sql.NullTime
|
|
lastUsed sql.NullTime
|
|
createdAt time.Time
|
|
)
|
|
if err := row.Scan(&s.ID, &s.Name, &s.ScopeType, &s.ScopeID, &s.CreatedBy, &createdAt, &revoked, &lastUsed); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
s.CreatedAt = createdAt
|
|
if revoked.Valid {
|
|
t := revoked.Time
|
|
s.RevokedAt = &t
|
|
}
|
|
if lastUsed.Valid {
|
|
t := lastUsed.Time
|
|
s.LastUsedAt = &t
|
|
}
|
|
return &s, nil
|
|
}
|