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 ) 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 } 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.logs.mu.Unlock() if err != nil { return err } if exceeded { return w.reachLimit() } return nil } 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 := os.Create(path) 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("")) } return out }