Bound external result file reads

This commit is contained in:
2026-08-10 18:30:28 +00:00
parent 99b2e1cd81
commit ab5a7e8e3d
22 changed files with 215 additions and 84 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

View File

@@ -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 |

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)