fix: protect agent canary database credentials #170
@@ -6,12 +6,16 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
type memoryStore struct{ writes int }
|
||||
@@ -37,7 +41,12 @@ func main() {
|
||||
var store ports.ExecutionStore = &memoryStore{}
|
||||
var closeStore func()
|
||||
if *postgresMode {
|
||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: os.Getenv("WORK_POSTGRES_PASSWORD")})
|
||||
password, err := resolveDatabasePassword()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
@@ -67,3 +76,27 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDatabasePassword() (string, error) {
|
||||
if _, ok := os.LookupEnv("WORK_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv("WORK_POSTGRES_PASSWORD_FILE"), os.Getenv("WORK_POSTGRES_CREDENTIAL_REF")
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD_FILE and WORK_POSTGRES_CREDENTIAL_REF are required")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
cleanRoot := filepath.Clean(root)
|
||||
cleanPath := filepath.Clean(path)
|
||||
if cleanPath != cleanRoot && !strings.HasPrefix(cleanPath, cleanRoot+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("database password file escapes protected secret root")
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(cleanRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerWorkWorker, Path: cleanPath})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDatabasePasswordRejectsDirectEnvironmentSecret(t *testing.T) {
|
||||
t.Setenv("WORK_POSTGRES_PASSWORD", "literal-secret")
|
||||
if _, err := resolveDatabasePassword(); err == nil {
|
||||
t.Fatal("direct database password environment variable was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDatabasePasswordRejectsPathOutsideProtectedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("SECRET_BACKEND_ROOT", root)
|
||||
t.Setenv("WORK_POSTGRES_PASSWORD_FILE", filepath.Join(root, "..", "outside"))
|
||||
t.Setenv("WORK_POSTGRES_CREDENTIAL_REF", "credential://domovoy/work/db-password/v1")
|
||||
if _, err := resolveDatabasePassword(); err == nil {
|
||||
t.Fatal("database password path outside protected root was accepted")
|
||||
}
|
||||
}
|
||||
@@ -99,8 +99,12 @@ services:
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
WORK_POSTGRES_USER: work_role
|
||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
||||
WORK_POSTGRES_CREDENTIAL_REF: credential://domovoy/work/db-password/v1
|
||||
WORK_POSTGRES_PASSWORD_FILE: /run/secrets/work_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
|
||||
agent-worker:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: agent-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000193-fleet-live-degraded-evidence.json",
|
||||
"task_id": "DEV-000193",
|
||||
"task_file": "dev/tasks/DEV-000194-agent-canary-protected-storage.json",
|
||||
"task_id": "DEV-000194",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000194",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "c1db571a39514e65156123b7f9394645183cf2ab",
|
||||
"risk_tier": "E",
|
||||
"intent": "Move the explicit production Agent Execution dry-run canary from direct database secret environment input to protected file/CredentialRef storage and preserve durable lease/callback evidence.",
|
||||
"requirement_ids": ["Agent Execution runtime and recovery", "protected canary configuration", "durable lease/callback canary"],
|
||||
"allowed_change_scope": {"modules": ["agent-canary", "deployment"], "paths": ["cmd/agent-canary/main.go", "cmd/agent-canary/main_test.go", "deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000194-agent-canary-protected-storage.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/**", "contracts/**", ".gitea/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["existing protected secret resolver", "existing ExecutionStore and dry-run canary contract"], "may_change": ["agent-canary runtime configuration"]},
|
||||
"semantic_owner": "work-agent-execution",
|
||||
"canonical_state": ["work.agent_dispatch_intents", "work.agent_leases", "work.agent_callback_heads"],
|
||||
"state_transitions": ["explicit canary -> durable dispatch intent -> lease -> heartbeat callback -> result callback"],
|
||||
"side_effects_and_privileges": ["manual opt-in dry-run only; protected database credential read; no external provider or source mutation"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["missing/invalid protected secret fails before database open; direct secret env is rejected; canary remains explicit opt-in"],
|
||||
"assumptions": ["the production compose secret volume already contains the work database credential reference used by agent-worker"],
|
||||
"acceptance_scenarios": ["direct password environment is rejected", "path outside protected root is rejected", "compose renders canary with protected file/ref only", "production opt-in canary writes lease and callback evidence", "normal agent-worker remains recovery-only without an external bridge"],
|
||||
"required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "git diff --check", "CI", "production canary and public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
Reference in New Issue
Block a user