Files
narratio/internal/adapters/subprocess/diagnostics.go

392 lines
9.8 KiB
Go

package subprocess
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
const (
// MaxStdoutDiagnosticBytes bounds persisted stdout from one external command.
MaxStdoutDiagnosticBytes int64 = 8 * 1024 * 1024
// MaxStderrDiagnosticBytes bounds persisted stderr from one external command.
MaxStderrDiagnosticBytes int64 = 8 * 1024 * 1024
diagnosticTailBytes = 2048
)
var inheritedEnvironmentNames = map[string]struct{}{
"COMSPEC": {},
"HOME": {},
"PATH": {},
"SYSTEMROOT": {},
"TMP": {},
"TMPDIR": {},
"TEMP": {},
"WINDIR": {},
// These test-only helper destinations let the adapter package tests exercise
// real command invocation without widening the production environment.
"AUDITA_HELPER_RECORD_PATH": {},
"AUDITA_HELPER_MODE": {},
"GO_WANT_AUDITA_HELPER": {},
"GO_WANT_SCRIPTORIUM_HELPER": {},
"GO_WANT_SERIATIM_HELPER": {},
"GO_WANT_SUBPROCESS_HELPER": {},
"NOTARIUS_CAPTURE_DIR": {},
"NOTARIUS_RECEIPT_FIXTURE": {},
"SCRIPTORIUM_HELPER_RECORD_PATH": {},
"SCRIPTORIUM_HELPER_MODE": {},
"SERIATIM_HELPER_RECORD_PATH": {},
"SERIATIM_HELPER_MODE": {},
}
var sensitiveEnvironmentNames = map[string]struct{}{
"ANTHROPIC_API_KEY": {},
"API_KEY": {},
"AUDITA_LLM_API_KEY": {},
"AWS_ACCESS_KEY_ID": {},
"AWS_SECRET_ACCESS_KEY": {},
"AWS_SESSION_TOKEN": {},
"OPENAI_API_KEY": {},
"OPENROUTER_API_KEY": {},
}
type captureLimitError struct {
stream string
owner string
limit int64
}
func (e *captureLimitError) Error() string {
return fmt.Sprintf("%s diagnostic capture for %s exceeded %d bytes", e.stream, e.owner, e.limit)
}
type logWriters struct {
files []*os.File
Stdout io.Writer
Stderr io.Writer
limits chan *captureLimitError
mu sync.Mutex
limit *captureLimitError
stdout *diagnosticWriter
stderr *diagnosticWriter
}
type diagnosticWriter struct {
logs *logWriters
stream string
owner string
target io.Writer
limit int64
received int64
persisted int64
redactor streamRedactor
tail []byte
}
func openLogWriters(stdoutPath, stderrPath, owner string, sensitiveValues []string) (*logWriters, error) {
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
cleanStdout := cleanLogPath(stdoutPath)
cleanStderr := cleanLogPath(stderrPath)
stdoutFile, err := openDiagnosticFile(cleanStdout)
if err != nil {
return nil, fmt.Errorf("open stdout log: %w", err)
}
stderrFile := stdoutFile
if cleanStdout != cleanStderr {
stderrFile, err = openDiagnosticFile(cleanStderr)
if err != nil {
_ = stdoutFile.Close()
return nil, fmt.Errorf("open stderr log: %w", err)
}
}
if cleanStdout == cleanStderr {
logs.files = []*os.File{stdoutFile}
} else {
logs.files = []*os.File{stdoutFile, stderrFile}
}
logs.stdout = newDiagnosticWriter(logs, "stdout", owner, stdoutFile, MaxStdoutDiagnosticBytes, sensitiveValues)
logs.stderr = newDiagnosticWriter(logs, "stderr", owner, stderrFile, MaxStderrDiagnosticBytes, sensitiveValues)
logs.Stdout = logs.stdout
logs.Stderr = logs.stderr
return logs, nil
}
func newDiagnosticWriter(logs *logWriters, stream, owner string, target io.Writer, limit int64, sensitiveValues []string) *diagnosticWriter {
return &diagnosticWriter{
logs: logs,
stream: stream,
owner: owner,
target: target,
limit: limit,
redactor: newStreamRedactor(sensitiveValues),
}
}
func (w *diagnosticWriter) Write(data []byte) (int, error) {
if w.received >= w.limit {
return len(data), w.reachLimit()
}
accepted := data
if remaining := w.limit - w.received; int64(len(accepted)) > remaining {
accepted = accepted[:remaining]
}
w.received += int64(len(accepted))
if err := w.writeRedacted(w.redactor.Write(accepted)); err != nil {
return len(data), err
}
if len(accepted) != len(data) {
return len(data), w.reachLimit()
}
return len(data), nil
}
func (w *diagnosticWriter) Flush() error {
return w.writeRedacted(w.redactor.Flush())
}
func (w *diagnosticWriter) writeRedacted(data []byte) error {
if len(data) == 0 {
return nil
}
w.logs.mu.Lock()
remaining := w.limit - w.persisted
if remaining <= 0 {
w.logs.mu.Unlock()
return w.reachLimit()
}
toWrite := data
exceeded := int64(len(data)) > remaining
if exceeded {
toWrite = toWrite[:remaining]
}
written, err := w.target.Write(toWrite)
w.persisted += int64(written)
w.retainTail(toWrite[:written])
w.logs.mu.Unlock()
if err != nil {
return err
}
if exceeded {
return w.reachLimit()
}
return nil
}
func (w *diagnosticWriter) Tail() string {
w.logs.mu.Lock()
defer w.logs.mu.Unlock()
return strings.TrimSpace(string(w.tail))
}
func (w *diagnosticWriter) retainTail(data []byte) {
if len(data) >= diagnosticTailBytes {
if cap(w.tail) < diagnosticTailBytes {
w.tail = make([]byte, diagnosticTailBytes)
} else {
w.tail = w.tail[:diagnosticTailBytes]
}
copy(w.tail, data[len(data)-diagnosticTailBytes:])
return
}
if cap(w.tail) < diagnosticTailBytes {
retained := make([]byte, len(w.tail), diagnosticTailBytes)
copy(retained, w.tail)
w.tail = retained
}
if overflow := len(w.tail) + len(data) - diagnosticTailBytes; overflow > 0 {
copy(w.tail, w.tail[overflow:])
w.tail = w.tail[:len(w.tail)-overflow]
}
w.tail = append(w.tail, data...)
}
func (w *diagnosticWriter) reachLimit() error {
limit := &captureLimitError{stream: w.stream, owner: w.owner, limit: w.limit}
w.logs.mu.Lock()
if w.logs.limit == nil {
w.logs.limit = limit
w.logs.limits <- limit
}
w.logs.mu.Unlock()
return limit
}
func (l *logWriters) Limits() <-chan *captureLimitError { return l.limits }
func (l *logWriters) Limit() *captureLimitError {
l.mu.Lock()
defer l.mu.Unlock()
return l.limit
}
func (l *logWriters) Flush() error {
return joinErrors(l.stdout.Flush(), l.stderr.Flush())
}
func (l *logWriters) Close() {
_ = l.Flush()
for _, file := range l.files {
_ = file.Close()
}
}
func cleanLogPath(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return ""
}
return filepath.Clean(trimmed)
}
func openDiagnosticFile(path string) (*os.File, error) {
if path == "" {
return os.OpenFile(os.DevNull, os.O_WRONLY, 0)
}
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
}
file, err := fileops.OpenFileConfined(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileops.WorkspaceFileMode)
if err != nil {
return nil, fmt.Errorf("open log file %q: %w", path, err)
}
if err := file.Chmod(fileops.WorkspaceFileMode); err != nil {
_ = file.Close()
return nil, fmt.Errorf("set log file permissions %q: %w", path, err)
}
return file, nil
}
func (r RunRequest) diagnosticOwner() string {
if owner := strings.TrimSpace(r.DiagnosticOwner); owner != "" {
return owner
}
return "subprocess"
}
func buildChildEnvironment(base []string, overrides map[string]string) []string {
values := make(map[string]string, len(inheritedEnvironmentNames)+len(overrides))
for _, item := range base {
name, value, ok := strings.Cut(item, "=")
if !ok {
continue
}
normalized := strings.ToUpper(name)
if _, allowed := inheritedEnvironmentNames[normalized]; allowed {
values[name] = value
}
}
for name, value := range overrides {
values[name] = value
}
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
out := make([]string, 0, len(names))
for _, name := range names {
out = append(out, name+"="+values[name])
}
return out
}
func sensitiveEnvironmentValues(environment []string, additionalNames []string) []string {
names := make(map[string]struct{}, len(sensitiveEnvironmentNames)+len(additionalNames))
for name := range sensitiveEnvironmentNames {
names[name] = struct{}{}
}
for _, name := range additionalNames {
if trimmed := strings.ToUpper(strings.TrimSpace(name)); trimmed != "" {
names[trimmed] = struct{}{}
}
}
values := make([]string, 0, len(names))
for _, item := range environment {
name, value, ok := strings.Cut(item, "=")
if !ok || strings.TrimSpace(value) == "" {
continue
}
if _, sensitive := names[strings.ToUpper(name)]; sensitive {
values = append(values, value)
}
}
return values
}
type streamRedactor struct {
values []string
buffer []byte
maxLen int
}
func newStreamRedactor(values []string) streamRedactor {
unique := make(map[string]struct{}, len(values))
for _, value := range values {
if value != "" {
unique[value] = struct{}{}
}
}
sorted := make([]string, 0, len(unique))
for value := range unique {
sorted = append(sorted, value)
}
sort.Slice(sorted, func(i, j int) bool { return len(sorted[i]) > len(sorted[j]) })
maxLen := 1
for _, value := range sorted {
if len(value) > maxLen {
maxLen = len(value)
}
}
return streamRedactor{values: sorted, maxLen: maxLen}
}
func (r *streamRedactor) Write(data []byte) []byte {
r.buffer = append(r.buffer, data...)
safeCut := len(r.buffer) - r.maxLen + 1
if safeCut <= 0 {
return nil
}
emitCut := safeCut
for _, value := range r.values {
start := 0
for {
index := bytes.Index(r.buffer[start:], []byte(value))
if index < 0 {
break
}
index += start
if index+len(value) > safeCut && index < emitCut {
emitCut = index
}
start = index + 1
}
}
output := redactBytes(r.buffer[:emitCut], r.values)
r.buffer = append(r.buffer[:0], r.buffer[emitCut:]...)
return output
}
func (r *streamRedactor) Flush() []byte {
output := redactBytes(r.buffer, r.values)
r.buffer = nil
return output
}
func redactBytes(data []byte, values []string) []byte {
out := append([]byte(nil), data...)
for _, value := range values {
out = bytes.ReplaceAll(out, []byte(value), []byte("<redacted>"))
}
return out
}