From ab5a7e8e3dcd7fcb2815f8bdc91abf1f32f043b2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 10 Aug 2026 18:30:28 +0000 Subject: [PATCH] Bound external result file reads --- docs/integrations/audita.md | 3 + docs/integrations/scriptorium.md | 3 + docs/integrations/seriatim.md | 2 + docs/internal/fileops.md | 9 +-- docs/operations.md | 5 ++ docs/roadmap/implementation.md | 2 +- internal/adapters/audita/subprocess.go | 22 +++++-- internal/adapters/notarius/subprocess.go | 29 +-------- internal/adapters/scriptorium/subprocess.go | 12 ++-- internal/adapters/seriatim/subprocess.go | 24 +++++--- internal/artifacts/artifact_resolver.go | 29 +++++---- internal/artifacts/extraction_catalog.go | 6 +- internal/contracts/bounds.go | 23 ++++++-- internal/fileops/read_confined.go | 11 ++++ internal/fileops/read_confined_test.go | 65 +++++++++++++++++++++ internal/stage/analyze.go | 13 ++--- internal/stage/external_result.go | 19 ++++++ internal/stage/extract.go | 4 +- internal/stage/polish.go | 4 +- internal/stage/run_local.go | 5 +- internal/stage/transcribe.go | 4 +- internal/stage/trim.go | 5 +- 22 files changed, 215 insertions(+), 84 deletions(-) create mode 100644 internal/fileops/read_confined_test.go create mode 100644 internal/stage/external_result.go diff --git a/docs/integrations/audita.md b/docs/integrations/audita.md index 94b7889..5caae6b 100644 --- a/docs/integrations/audita.md +++ b/docs/integrations/audita.md @@ -41,6 +41,9 @@ Run fails for: - invalid processed transcript JSON (`segments` array required); - invalid report JSON when reporting is enabled. +Processed transcript JSON is limited to 64 MiB and optional report JSON to 16 +MiB. Both must be regular files without symlinked path components. + Failure results still include output/log/config/exit metadata for diagnostics. ## Deterministic Behavior diff --git a/docs/integrations/scriptorium.md b/docs/integrations/scriptorium.md index c1473fd..317768e 100644 --- a/docs/integrations/scriptorium.md +++ b/docs/integrations/scriptorium.md @@ -46,6 +46,9 @@ Run behavior: - `run` exit code `2` is mapped to `ValidationFailed=true`; - successful subprocess still fails if output file is missing or empty. +Each artifact result is limited to 64 MiB and must be a regular file without +symlinked path components. + Render behavior: - subprocess errors propagate; - output file must exist and be non-empty. diff --git a/docs/integrations/seriatim.md b/docs/integrations/seriatim.md index fc82fad..e7080ac 100644 --- a/docs/integrations/seriatim.md +++ b/docs/integrations/seriatim.md @@ -41,6 +41,8 @@ Invocation fails on: - empty render output files. When report paths are provided/enabled, report files must parse as JSON. +Each Seriatim JSON or rendered-text result is limited to 64 MiB and must be a +regular file without symlinked path components. ## Deterministic Behavior - argument ordering is deterministic per command construction. diff --git a/docs/internal/fileops.md b/docs/internal/fileops.md index a201059..0dc6936 100644 --- a/docs/internal/fileops.md +++ b/docs/internal/fileops.md @@ -34,10 +34,11 @@ successful. Command and post-publish policy remains owned by `internal/app`. ## Confined Reads `ReadRegularFileUnderRoot` is the no-follow, bounded read primitive for a -caller-selected root and relative file path. It verifies the root and every -ancestor through directory handles, admits only a stable regular-file handle, -and lets the caller enforce its own byte limit and access policy. Credential -mode policy and environment precedence remain owned by `internal/app`. +caller-selected root and relative file path; `ReadRegularFile` is its +path-based convenience wrapper. They verify every ancestor through directory +handles and admit only a stable regular-file handle. Callers enforce their own +byte limits and access policy. Credential mode policy and environment +precedence remain owned by `internal/app`. ## Replacement Contract diff --git a/docs/operations.md b/docs/operations.md index e75b922..55d0232 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -307,6 +307,11 @@ On POSIX, provision a credential directory as `0700` and credential files as `0600`; Narratio rejects group- or other-readable configured credential paths. On Windows, restrict the directory and files with ACLs to the credential owner. +External adapter results are individually bounded before Narratio validates or +materializes them. These per-file limits do not reserve disk space: prevent hard +disk exhaustion with filesystem, service, container, or volume quotas sized for +the session workload. + ## Cleanup Session-scoped cleanup: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 030a5d9..08ef2c9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -22,7 +22,7 @@ All stages are pending when this plan is created. | 4 | Add confined destination and download/install capabilities | COR-003, DUP-003, TST-003 | Completed | | 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Completed | | 6 | Harden API-key file acquisition | RSK-010 | Completed | -| 7 | Bound and verify external result acquisition | RSK-013, TST-007 | Pending | +| 7 | Bound and verify external result acquisition | RSK-013, TST-007 | Completed | | 8 | Terminate owned subprocess trees | RSK-011 | Pending | | 9 | Redact and cap subprocess diagnostics | RSK-012 | Pending | | 10 | Confine publish archive reads | COR-005 | Pending | diff --git a/internal/adapters/audita/subprocess.go b/internal/adapters/audita/subprocess.go index c545c56..b70696a 100644 --- a/internal/adapters/audita/subprocess.go +++ b/internal/adapters/audita/subprocess.go @@ -14,6 +14,12 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) +// MaxProcessedOutputBytes bounds Audita's processed-transcript JSON result. +const MaxProcessedOutputBytes int64 = 64 * 1024 * 1024 + +// MaxReportOutputBytes bounds Audita's optional report JSON result. +const MaxReportOutputBytes int64 = 16 * 1024 * 1024 + // SubprocessRunnerConfig defines deterministic settings for Audita CLI execution. type SubprocessRunnerConfig struct { Binary string @@ -375,9 +381,9 @@ func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []strin } func validateProcessedOutput(path string) error { - data, err := os.ReadFile(path) + data, err := readAuditaResult(path, MaxProcessedOutputBytes, "processed transcript") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } var payload map[string]any @@ -406,9 +412,9 @@ func addSubprocessStreamHint(message string, runErr error) string { } func validateJSONFile(path string) error { - data, err := os.ReadFile(path) + data, err := readAuditaResult(path, MaxReportOutputBytes, "report") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } var v any if err := json.Unmarshal(data, &v); err != nil { @@ -416,3 +422,11 @@ func validateJSONFile(path string) error { } return nil } + +func readAuditaResult(path string, limit int64, category string) ([]byte, error) { + data, err := fileops.ReadRegularFile(path, limit) + if err != nil { + return nil, fmt.Errorf("audita %s result exceeds or cannot be read within %d-byte limit: %w", category, limit, err) + } + return data, nil +} diff --git a/internal/adapters/notarius/subprocess.go b/internal/adapters/notarius/subprocess.go index 0a8603c..d8b2337 100644 --- a/internal/adapters/notarius/subprocess.go +++ b/internal/adapters/notarius/subprocess.go @@ -5,12 +5,12 @@ import ( "encoding/json" "errors" "fmt" - "io" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" + "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) @@ -388,32 +388,9 @@ func loadWarnings(path string) ([]WarningSummary, error) { } func decodeBoundedJSON(path string, limit int64, destination any) error { - inspected, err := os.Lstat(path) + data, err := fileops.ReadRegularFile(path, limit) if err != nil { - return err - } - if inspected.Mode()&os.ModeSymlink != 0 || !inspected.Mode().IsRegular() { - return fmt.Errorf("path %q must be a regular file without symlinks", path) - } - file, err := os.Open(path) - if err != nil { - return err - } - defer func() { _ = file.Close() }() - opened, err := file.Stat() - if err != nil { - return err - } - if !opened.Mode().IsRegular() || !os.SameFile(inspected, opened) { - return fmt.Errorf("file %q changed before it could be read", path) - } - reader := io.LimitReader(file, limit+1) - data, err := io.ReadAll(reader) - if err != nil { - return err - } - if int64(len(data)) > limit { - return fmt.Errorf("file %q exceeds %d-byte limit", path, limit) + return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, err) } if err := json.Unmarshal(data, destination); err != nil { return err diff --git a/internal/adapters/scriptorium/subprocess.go b/internal/adapters/scriptorium/subprocess.go index a9cca72..727d0e2 100644 --- a/internal/adapters/scriptorium/subprocess.go +++ b/internal/adapters/scriptorium/subprocess.go @@ -12,6 +12,9 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) +// MaxOutputFileBytes bounds one Scriptorium artifact result. +const MaxOutputFileBytes int64 = 64 * 1024 * 1024 + // SubprocessRunner invokes Scriptorium through its public CLI. type SubprocessRunner struct{} @@ -326,14 +329,11 @@ func writeInvocationConfig(path string, payload invocationPayload) error { } func validateNonEmptyOutput(path string) error { - info, err := os.Stat(path) + data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes) if err != nil { - return fmt.Errorf("stat file: %w", err) + return fmt.Errorf("scriptorium artifact output exceeds or cannot be read within %d-byte limit: %w", MaxOutputFileBytes, err) } - if info.IsDir() { - return fmt.Errorf("path is a directory") - } - if info.Size() <= 0 { + if len(data) == 0 { return fmt.Errorf("file is empty") } return nil diff --git a/internal/adapters/seriatim/subprocess.go b/internal/adapters/seriatim/subprocess.go index 36ac87a..8bbb82c 100644 --- a/internal/adapters/seriatim/subprocess.go +++ b/internal/adapters/seriatim/subprocess.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "strconv" "strings" "time" @@ -14,6 +13,9 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) +// MaxOutputFileBytes bounds each Seriatim JSON or rendered-text result. +const MaxOutputFileBytes int64 = 64 * 1024 * 1024 + // EnvConfig defines optional Seriatim environment tuning values. type EnvConfig struct { OverlapWordRunGap *float64 @@ -636,9 +638,9 @@ func writeRenderInvocationConfig(req RenderRequest, args []string, binary string } func validateJSONFile(path string) error { - data, err := os.ReadFile(path) + data, err := readSeriatimResult(path, "JSON output") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } var v any if err := json.Unmarshal(data, &v); err != nil { @@ -648,9 +650,9 @@ func validateJSONFile(path string) error { } func validateJSONFileWithSegments(path string) error { - data, err := os.ReadFile(path) + data, err := readSeriatimResult(path, "transcript JSON output") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } var payload map[string]any @@ -669,9 +671,9 @@ func validateJSONFileWithSegments(path string) error { } func validateNonEmptyTextFile(path string) error { - data, err := os.ReadFile(path) + data, err := readSeriatimResult(path, "rendered text output") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } if len(data) == 0 { return fmt.Errorf("file is empty") @@ -684,3 +686,11 @@ func validateNonEmptyTextFile(path string) error { } return nil } + +func readSeriatimResult(path, category string) ([]byte, error) { + data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes) + if err != nil { + return nil, fmt.Errorf("seriatim %s exceeds or cannot be read within %d-byte limit: %w", category, MaxOutputFileBytes, err) + } + return data, nil +} diff --git a/internal/artifacts/artifact_resolver.go b/internal/artifacts/artifact_resolver.go index 579d8d2..c289680 100644 --- a/internal/artifacts/artifact_resolver.go +++ b/internal/artifacts/artifact_resolver.go @@ -10,9 +10,13 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) +// MaxResolvedArtifactBytes bounds externally generated artifact content before validation. +const MaxResolvedArtifactBytes int64 = 64 * 1024 * 1024 + const ( ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished @@ -445,9 +449,9 @@ func validateResolvedContent(path string, kind artifactContentKind) error { } func validateTranscriptSegmentsJSON(path string) error { - data, err := os.ReadFile(path) + data, err := readResolvedArtifact(path, "transcript JSON") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { @@ -464,9 +468,9 @@ func validateTranscriptSegmentsJSON(path string) error { } func validateJSONContent(path string) error { - data, err := os.ReadFile(path) + data, err := readResolvedArtifact(path, "JSON artifact") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } var payload any if err := json.Unmarshal(data, &payload); err != nil { @@ -476,15 +480,20 @@ func validateJSONContent(path string) error { } func validateNonEmptyContent(path string) error { - info, err := os.Stat(path) + data, err := readResolvedArtifact(path, "text artifact") if err != nil { - return fmt.Errorf("stat file: %w", err) + return err } - if info.IsDir() { - return fmt.Errorf("path is a directory") - } - if info.Size() <= 0 { + if len(data) == 0 { return fmt.Errorf("file is empty") } return nil } + +func readResolvedArtifact(path, category string) ([]byte, error) { + data, err := fileops.ReadRegularFile(path, MaxResolvedArtifactBytes) + if err != nil { + return nil, fmt.Errorf("resolved %s exceeds or cannot be read within %d-byte limit: %w", category, MaxResolvedArtifactBytes, err) + } + return data, nil +} diff --git a/internal/artifacts/extraction_catalog.go b/internal/artifacts/extraction_catalog.go index ca225ce..45614ce 100644 --- a/internal/artifacts/extraction_catalog.go +++ b/internal/artifacts/extraction_catalog.go @@ -7,9 +7,13 @@ import ( "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) +// MaxExtractionPayloadBytes bounds one validated Notarius lane or index payload. +const MaxExtractionPayloadBytes int64 = 64 * 1024 * 1024 + const ( extractStageName = "extract" extractionLaneKind = "notarius_lane" @@ -131,7 +135,7 @@ func validExtractionPayload(bundleRoot, path, checksum string) bool { if err != nil || actual != checksum { return false } - body, err := os.ReadFile(path) + body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes) return err == nil && json.Valid(body) } diff --git a/internal/contracts/bounds.go b/internal/contracts/bounds.go index 8b09e82..26e294a 100644 --- a/internal/contracts/bounds.go +++ b/internal/contracts/bounds.go @@ -4,12 +4,17 @@ import ( "encoding/json" "fmt" "math" - "os" "strconv" "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) const ( + // MaxSessionBoundsFileBytes bounds one Scriptorium bounds JSON result. + MaxSessionBoundsFileBytes int64 = 1 * 1024 * 1024 + // MaxTranscriptFileBytes bounds a transcript JSON handoff used for bounds validation. + MaxTranscriptFileBytes int64 = 64 * 1024 * 1024 // BoundsTrimActionTrim keeps only a selected segment range. BoundsTrimActionTrim = "trim" // BoundsTrimActionNone indicates no trimming should be applied. @@ -36,9 +41,9 @@ type SessionBounds struct { // ParseSessionBoundsFile reads and parses one Scriptorium bounds output JSON file. func ParseSessionBoundsFile(path string) (SessionBounds, error) { - data, err := os.ReadFile(path) + data, err := readContractResult(path, MaxSessionBoundsFileBytes, "scriptorium bounds result") if err != nil { - return SessionBounds{}, fmt.Errorf("read bounds file: %w", err) + return SessionBounds{}, err } var bounds SessionBounds if err := json.Unmarshal(data, &bounds); err != nil { @@ -119,9 +124,9 @@ func normalizeBoundsTrimAction(raw string) (string, error) { } func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) { - data, err := os.ReadFile(path) + data, err := readContractResult(path, MaxTranscriptFileBytes, "transcript result") if err != nil { - return nil, fmt.Errorf("read transcript file: %w", err) + return nil, err } var payload map[string]any @@ -158,6 +163,14 @@ func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) { return ids, nil } +func readContractResult(path string, limit int64, category string) ([]byte, error) { + data, err := fileops.ReadRegularFile(path, limit) + if err != nil { + return nil, fmt.Errorf("%s exceeds or cannot be read within %d-byte limit: %w", category, limit, err) + } + return data, nil +} + func parseJSONSegmentID(v any) (int, error) { n, ok := v.(float64) if !ok { diff --git a/internal/fileops/read_confined.go b/internal/fileops/read_confined.go index 4f6ee9e..7ff3e35 100644 --- a/internal/fileops/read_confined.go +++ b/internal/fileops/read_confined.go @@ -108,3 +108,14 @@ func ReadRegularFileUnderRoot( } return content, nil } + +// ReadRegularFile reads a bounded regular file without following symbolic links +// in its parent hierarchy. It is intended for externally produced results; +// callers own the limit and semantic validation contract. +func ReadRegularFile(path string, maxBytes int64) ([]byte, error) { + clean := filepath.Clean(path) + if clean == "." || filepath.Base(clean) == "." { + return nil, fmt.Errorf("file path is required") + } + return ReadRegularFileUnderRoot(filepath.Dir(clean), filepath.Base(clean), maxBytes, nil, nil) +} diff --git a/internal/fileops/read_confined_test.go b/internal/fileops/read_confined_test.go new file mode 100644 index 0000000..0a59b32 --- /dev/null +++ b/internal/fileops/read_confined_test.go @@ -0,0 +1,65 @@ +package fileops + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestReadRegularFileEnforcesLimitAndRegularFileIdentity(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "result.json") + if err := os.WriteFile(path, []byte("1234"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + data, err := ReadRegularFile(path, 4) + if err != nil { + t.Fatalf("ReadRegularFile(exact limit) error = %v", err) + } + if string(data) != "1234" { + t.Fatalf("data = %q, want exact file content", data) + } + if _, err := ReadRegularFile(path, 3); err == nil || !strings.Contains(err.Error(), "3-byte limit") { + t.Fatalf("ReadRegularFile(limit plus one) error = %v, want limit failure", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("Remove(file) error = %v", err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("Mkdir(non-regular file) error = %v", err) + } + if _, err := ReadRegularFile(path, 4); err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("ReadRegularFile(directory) error = %v, want regular-file failure", err) + } +} + +func TestReadRegularFileRejectsSymlinkedPaths(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior differs on windows") + } + root := t.TempDir() + outside := t.TempDir() + outsideFile := filepath.Join(outside, "result.json") + if err := os.WriteFile(outsideFile, []byte("outside"), 0o600); err != nil { + t.Fatalf("WriteFile(outside) error = %v", err) + } + leaf := filepath.Join(root, "result.json") + if err := os.Symlink(outsideFile, leaf); err != nil { + t.Fatalf("Symlink(leaf) error = %v", err) + } + if _, err := ReadRegularFile(leaf, 64); err == nil { + t.Fatal("ReadRegularFile(leaf symlink) error = nil, want rejection") + } + if err := os.Remove(leaf); err != nil { + t.Fatalf("Remove(leaf symlink) error = %v", err) + } + linkedParent := filepath.Join(root, "linked") + if err := os.Symlink(outside, linkedParent); err != nil { + t.Fatalf("Symlink(parent) error = %v", err) + } + if _, err := ReadRegularFile(filepath.Join(linkedParent, "result.json"), 64); err == nil { + t.Fatal("ReadRegularFile(symlinked parent) error = nil, want rejection") + } +} diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 9d34300..a29445a 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -916,14 +916,11 @@ func deriveSessionVarValue(name string, session *config.SessionConfig) (string, } func requireNonEmptyFile(path string, label string) error { - if err := requireFile(path, label); err != nil { + data, err := readExternalResult(path, label) + if err != nil { return err } - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("%s %q stat failed: %w", label, path, err) - } - if info.Size() <= 0 { + if len(data) == 0 { return fmt.Errorf("%s %q is empty", label, path) } return nil @@ -968,9 +965,9 @@ func resolveRenderDebugEnabled(global bool, perArtifact *bool) bool { } func validateJSONFile(path string) error { - data, err := os.ReadFile(path) + data, err := readExternalResult(path, "scriptorium artifact result") if err != nil { - return fmt.Errorf("read file: %w", err) + return err } var payload any if err := json.Unmarshal(data, &payload); err != nil { diff --git a/internal/stage/external_result.go b/internal/stage/external_result.go new file mode 100644 index 0000000..3af4245 --- /dev/null +++ b/internal/stage/external_result.go @@ -0,0 +1,19 @@ +package stage + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/narratio/internal/fileops" +) + +// MaxExternalResultFileBytes bounds transcript and artifact files produced by +// external adapters before a stage validates or materializes them. +const MaxExternalResultFileBytes int64 = 64 * 1024 * 1024 + +func readExternalResult(path, category string) ([]byte, error) { + data, err := fileops.ReadRegularFile(path, MaxExternalResultFileBytes) + if err != nil { + return nil, fmt.Errorf("%s exceeds or cannot be read within %d-byte limit: %w", category, MaxExternalResultFileBytes, err) + } + return data, nil +} diff --git a/internal/stage/extract.go b/internal/stage/extract.go index cf1fc9f..962d1f5 100644 --- a/internal/stage/extract.go +++ b/internal/stage/extract.go @@ -349,9 +349,9 @@ func checksumRegularFile(path string, requireJSON bool) (string, error) { if info.Size() == 0 { return "", fmt.Errorf("path %q must be non-empty", path) } - data, err := os.ReadFile(path) + data, err := fileops.ReadRegularFile(path, artifacts.MaxExtractionPayloadBytes) if err != nil { - return "", err + return "", fmt.Errorf("notarius lane result exceeds or cannot be read within %d-byte limit: %w", artifacts.MaxExtractionPayloadBytes, err) } if requireJSON && !json.Valid(data) { return "", fmt.Errorf("path %q is not valid JSON", path) diff --git a/internal/stage/polish.go b/internal/stage/polish.go index b7c42f3..af1566b 100644 --- a/internal/stage/polish.go +++ b/internal/stage/polish.go @@ -284,9 +284,9 @@ func validateProcessedTranscriptOutput(path string) error { if err := requireFile(path, "processed transcript"); err != nil { return err } - data, err := os.ReadFile(path) + data, err := readExternalResult(path, "audita processed transcript result") if err != nil { - return fmt.Errorf("read processed transcript: %w", err) + return err } var payload map[string]any diff --git a/internal/stage/run_local.go b/internal/stage/run_local.go index 388b952..1e7c3e4 100644 --- a/internal/stage/run_local.go +++ b/internal/stage/run_local.go @@ -2,7 +2,6 @@ package stage import ( "fmt" - "os" "path/filepath" "strings" @@ -130,9 +129,9 @@ func materializeRunLocalOutput( if canonicalPath == "" { return artifacts.Ref{}, fmt.Errorf("canonical destination path is required") } - data, err := os.ReadFile(srcPath) + data, err := readExternalResult(srcPath, "run-local external output") if err != nil { - return artifacts.Ref{}, fmt.Errorf("read run-local output %q: %w", srcPath, err) + return artifacts.Ref{}, err } if err := store.WriteFileAtomic(canonicalPath, data, fileops.WorkspaceFileMode); err != nil { return artifacts.Ref{}, fmt.Errorf("materialize output to %q: %w", canonicalPath, err) diff --git a/internal/stage/transcribe.go b/internal/stage/transcribe.go index a94db87..dd8ad64 100644 --- a/internal/stage/transcribe.go +++ b/internal/stage/transcribe.go @@ -321,9 +321,9 @@ func validateTranscriptJSONFile(path string) error { if err := requireFile(path, "raw transcript"); err != nil { return err } - data, err := os.ReadFile(path) + data, err := readExternalResult(path, "whisperx transcript result") if err != nil { - return fmt.Errorf("read transcript: %w", err) + return err } var payload any if err := json.Unmarshal(data, &payload); err != nil { diff --git a/internal/stage/trim.go b/internal/stage/trim.go index b6839a5..233bb57 100644 --- a/internal/stage/trim.go +++ b/internal/stage/trim.go @@ -3,7 +3,6 @@ package stage import ( "context" "fmt" - "os" "path/filepath" "strings" "time" @@ -389,9 +388,9 @@ func resolveTrimmedOutputPath(paths artifacts.SessionPaths, cfg *config.TrimConf } func copyTranscript(store artifacts.Store, src, dst string) error { - data, err := os.ReadFile(src) + data, err := readExternalResult(src, "seriatim trimmed transcript result") if err != nil { - return fmt.Errorf("read source transcript: %w", err) + return err } if err := store.WriteFileAtomic(dst, data, fileops.WorkspaceFileMode); err != nil { return fmt.Errorf("write destination transcript: %w", err)