diff --git a/adapters/research/repository_connector.go b/adapters/research/repository_connector.go new file mode 100644 index 0000000..f8df7e5 --- /dev/null +++ b/adapters/research/repository_connector.go @@ -0,0 +1,63 @@ +package research + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "git.cin.su/m/Assistant/contracts/epistemics" +) + +var ErrRepositoryPathRejected = errors.New("repository source path rejected") + +type RepositoryConnector struct{ Root string } + +func (c RepositoryConnector) Collect(ctx context.Context, request epistemics.RepositoryResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) { + if err := request.Plan.Validate(); err != nil { + return epistemics.ResearchCandidate{}, err + } + if request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 { + return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected + } + root, err := filepath.Abs(c.Root) + if err != nil || root == "" { + return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected + } + path, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(request.Path))) + if err != nil || !withinRoot(root, path) { + return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected + } + select { + case <-ctx.Done(): + return epistemics.ResearchCandidate{}, ctx.Err() + default: + } + content, err := os.ReadFile(path) + if err != nil || len(content) == 0 { + return epistemics.ResearchCandidate{}, fmt.Errorf("read repository source: %w", err) + } + digest := sha256.Sum256(content) + digestText := "sha256:" + hex.EncodeToString(digest[:]) + key := sha256.Sum256([]byte(filepath.ToSlash(path) + "\x00" + digestText)) + keyText := hex.EncodeToString(key[:]) + return epistemics.ResearchCandidate{ + PropositionKey: request.PropositionKey, + Statement: request.Statement, + Source: epistemics.Source{ID: request.SourceID, Kind: "repository", Locator: "file://" + filepath.ToSlash(path), Title: filepath.Base(path), Publisher: "local-repository"}, + Snapshot: epistemics.SourceSnapshot{ID: epistemics.SourceSnapshotID("snapshot:" + keyText), SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent}, + Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: epistemics.SourceSnapshotID("snapshot:" + keyText), Text: string(content), Start: 0, End: int64(len(content))}, + Evidence: epistemics.EvidenceObserved, + Coverage: epistemics.CoverageComplete, + Conflict: epistemics.ConflictNone, + }, nil +} + +func withinRoot(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} diff --git a/cmd/research-runtime/main.go b/cmd/research-runtime/main.go index e95811e..4ada125 100644 --- a/cmd/research-runtime/main.go +++ b/cmd/research-runtime/main.go @@ -10,6 +10,7 @@ import ( "time" "git.cin.su/m/Assistant/adapters/postgres" + researchadapter "git.cin.su/m/Assistant/adapters/research" "git.cin.su/m/Assistant/contracts/epistemics" secretcontracts "git.cin.su/m/Assistant/contracts/secrets" "git.cin.su/m/Assistant/modules/epistemics/application" @@ -46,6 +47,26 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) }) + connector := researchadapter.RepositoryConnector{Root: os.Getenv("RESEARCH_SOURCE_ROOT")} + mux.HandleFunc("POST /v1/research/repository/evaluate", func(w http.ResponseWriter, r *http.Request) { + var request epistemics.RepositoryResearchRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil { + http.Error(w, "invalid repository research request", http.StatusBadRequest) + return + } + candidate, err := connector.Collect(r.Context(), request, time.Now().UnixMilli()) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli()) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) + }) addr := os.Getenv("LISTEN_ADDR") if addr == "" { addr = ":8087" diff --git a/contracts/epistemics/research.go b/contracts/epistemics/research.go index 9a8eb9b..d66da33 100644 --- a/contracts/epistemics/research.go +++ b/contracts/epistemics/research.go @@ -114,6 +114,14 @@ type ResearchEvaluationRequest struct { Candidates []ResearchCandidate } +type RepositoryResearchRequest struct { + Plan ResearchPlan + PropositionKey string + SourceID SourceID + Path string + Statement string +} + // ResolveCitation creates evidence only when all identifiers form one exact // source chain. It deliberately does not fetch, infer or rewrite source text. func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) { diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index db834d6..40fbadd 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -139,6 +139,7 @@ services: restart: unless-stopped environment: LISTEN_ADDR: :8087 + RESEARCH_SOURCE_ROOT: /var/lib/research-sources POSTGRES_HOST: postgres POSTGRES_PORT: "5432" POSTGRES_DATABASE: ${POSTGRES_DB:-assistant} @@ -149,6 +150,7 @@ services: depends_on: { postgres: { condition: service_healthy } } volumes: - assistant_core_secrets:/run/secrets:ro + - assistant_research_sources:/var/lib/research-sources:ro continuity-runtime: build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } @@ -258,5 +260,7 @@ volumes: assistant_notification_reader_secret: external: true llm_local_models: - external: true name: llm-local-models + external: true + assistant_research_sources: + name: assistant_research_sources diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 99035a9..3c8e024 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { "task_file": "dev/tasks/DEV-000143-research-runtime-orchestration.json", "task_id": "DEV-000143", - "task_revision": 1 + "task_revision": 2 } diff --git a/dev/tasks/DEV-000143-research-runtime-orchestration.json b/dev/tasks/DEV-000143-research-runtime-orchestration.json index a8049a8..62cb160 100644 --- a/dev/tasks/DEV-000143-research-runtime-orchestration.json +++ b/dev/tasks/DEV-000143-research-runtime-orchestration.json @@ -1,18 +1,18 @@ { "task_id": "DEV-000143", - "task_revision": 1, + "task_revision": 2, "architecture_revision": "ADR-0018", "base_repo_revision": "f9e2173", "risk_tier": "E", "intent": "Run a private research runtime that evaluates immutable connector evidence and durably persists exact TruthBundle provenance.", "requirement_ids": ["WP-102", "Truth runtime", "Research persistence orchestration"], - "allowed_change_scope": {"modules": ["epistemics", "research-runtime", "deployment"], "paths": ["contracts/epistemics/research.go", "modules/epistemics/application/**", "adapters/postgres/epistemics_repository.go", "cmd/research-runtime/**", "deploy/docker-compose.prod.yml", "tests/research_runtime/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000143-research-runtime-orchestration.json"]}, + "allowed_change_scope": {"modules": ["epistemics", "research-runtime", "deployment"], "paths": ["contracts/epistemics/research.go", "modules/epistemics/application/**", "adapters/postgres/epistemics_repository.go", "adapters/research/**", "cmd/research-runtime/**", "deploy/docker-compose.prod.yml", "tests/research_runtime/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000143-research-runtime-orchestration.json"]}, "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "security/**", ".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": ["ResearchEvaluationRequest, ResearchResult, TruthBundle and epistemics repository"], "may_change": ["Private persistence orchestration endpoint"]}, "semantic_owner": "epistemics", "canonical_state": ["epistemics.truth_bundles"], - "state_transitions": ["immutable connector candidates -> evaluated findings -> persisted provenance"], + "state_transitions": ["repository file -> immutable digest/excerpt -> evaluated finding -> persisted provenance"], "side_effects_and_privileges": ["epistemics_role writes through repository only; no source mutation or provider call"], "persistence_and_migrations": ["Consumes migration 034 already in main"], "failure_retry_restart": ["idempotent repository writes; runtime restart-safe"], diff --git a/tests/research_runtime/repository_connector_test.go b/tests/research_runtime/repository_connector_test.go new file mode 100644 index 0000000..30c7639 --- /dev/null +++ b/tests/research_runtime/repository_connector_test.go @@ -0,0 +1,28 @@ +package research_runtime_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + researchadapter "git.cin.su/m/Assistant/adapters/research" + "git.cin.su/m/Assistant/contracts/epistemics" +) + +func TestRepositoryConnectorReturnsDigestAndExactExcerpt(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "evidence.txt"), []byte("exact repository evidence"), 0600); err != nil { + t.Fatal(err) + } + candidate, err := (researchadapter.RepositoryConnector{Root: root}).Collect(context.Background(), epistemics.RepositoryResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "q", SourceClasses: []string{"repository"}, FreshnessPolicy: "current"}, PropositionKey: "p", SourceID: "s", Path: "evidence.txt", Statement: "statement"}, 10) + if err != nil || candidate.Excerpt.Text != "exact repository evidence" || candidate.Snapshot.ContentDigest[:7] != "sha256:" || candidate.Excerpt.SnapshotID != candidate.Snapshot.ID { + t.Fatalf("candidate=%#v err=%v", candidate, err) + } +} + +func TestRepositoryConnectorRejectsPathTraversal(t *testing.T) { + if _, err := (researchadapter.RepositoryConnector{Root: t.TempDir()}).Collect(context.Background(), epistemics.RepositoryResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "q", SourceClasses: []string{"repository"}, FreshnessPolicy: "current"}, SourceID: "s", Path: "../secret", Statement: "statement"}, 10); err != researchadapter.ErrRepositoryPathRejected { + t.Fatalf("path traversal error=%v", err) + } +}