From e22a97c0129aed96eefd452a25517dc98af100a0 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Tue, 7 Jul 2026 10:17:26 +0530 Subject: [PATCH 1/4] security: harden gosec findings (part of hawk-eco full-repo audit) - Integer-overflow guards on numeric conversions - File/dir permission tightening (0600/0750) - Path-traversal cleaning and error-return handling - Narrow, justified #nosec annotations where risk is not applicable Module now scans clean with gosec (0 issues). --- cli/agent/claudecode/claude.go | 4 +++- cli/agent/claudecode/discovery.go | 2 ++ cli/agent/claudecode/hooks.go | 5 ++++- cli/agent/claudecode/lifecycle.go | 2 ++ cli/agent/claudecode/reviewer.go | 2 +- cli/agent/claudecode/types.go | 1 + cli/agent/codex/codex.go | 3 ++- cli/agent/codex/hooks.go | 4 ++++ cli/agent/codex/spawner.go | 2 +- cli/agent/codex/transcript.go | 3 +++ cli/agent/codex/trust.go | 3 +++ cli/agent/copilotcli/copilotcli.go | 1 + cli/agent/copilotcli/hooks.go | 3 +++ cli/agent/copilotcli/transcript.go | 5 +++++ cli/agent/cursor/hooks.go | 5 ++++- cli/agent/cursor/lifecycle.go | 1 + cli/agent/cursor/transcript.go | 2 ++ cli/agent/external/external.go | 2 +- cli/agent/factoryaidroid/factoryaidroid.go | 1 + cli/agent/factoryaidroid/hooks.go | 5 ++++- cli/agent/factoryaidroid/lifecycle.go | 1 + cli/agent/factoryaidroid/transcript.go | 2 ++ cli/agent/geminicli/gemini.go | 2 ++ cli/agent/geminicli/hooks.go | 5 ++++- cli/agent/geminicli/lifecycle.go | 1 + cli/agent/geminicli/transcript.go | 1 + cli/agent/opencode/cli_commands.go | 2 +- cli/agent/opencode/hooks.go | 4 ++++ cli/agent/opencode/lifecycle.go | 2 +- cli/agent/opencode/opencode.go | 1 + cli/agent/opencode/transcript.go | 2 ++ cli/agent/pi/hooks.go | 10 ++++------ cli/agent/pi/lifecycle.go | 4 ++-- cli/agent/pi/pi.go | 2 +- cli/agent/pi/transcript.go | 6 +++--- cli/agent/testutil/hooks.go | 1 + cli/agent/vogon/vogon.go | 1 + cli/attach.go | 2 +- cli/benchutil/benchutil.go | 12 ++++++------ cli/checkpoint/committed_3.go | 1 + cli/checkpoint/configloader.go | 4 +++- cli/checkpoint/fetching_tree.go | 4 ++-- cli/checkpoint/shadow_ref.go | 7 ++++--- cli/checkpoint/temporary.go | 2 +- cli/checkpoint/temporary_2.go | 1 + cli/checkpoint/v2_pending_rotation.go | 3 ++- cli/checkpoint/v2_read.go | 4 ++-- cli/cost/pricing.go | 2 +- cli/dispatch/mode_local.go | 6 ++++-- cli/doctor_bundle.go | 2 ++ cli/doctor_logs.go | 2 ++ cli/explain_4.go | 2 +- cli/git_operations.go | 2 +- cli/gitrepo/repository.go | 2 ++ cli/head_checkpoint_flags.go | 2 +- cli/help.go | 2 ++ cli/internal/flock/flock_unix.go | 1 + cli/investigate/cmd.go | 1 + cli/investigate/cmd_2.go | 2 ++ cli/investigate/loop.go | 1 + cli/investigate/manifest.go | 2 ++ cli/jsonutil/write.go | 1 + cli/lifecycle.go | 2 ++ cli/lockfile/lockfile.go | 2 ++ cli/logging/logger.go | 1 + cli/login.go | 2 +- cli/objectsigner.go | 1 + cli/paths/transcript.go | 2 +- cli/paths/worktree.go | 2 +- cli/plugin_store.go | 2 ++ cli/review/manifest.go | 2 +- cli/review/marker_fallback.go | 2 +- cli/review_context.go | 2 +- cli/review_helpers.go | 2 +- cli/search_cmd.go | 2 ++ cli/session_share.go | 1 + cli/settings/settings.go | 4 ++-- cli/settings/settings_2.go | 9 ++++----- cli/setup.go | 1 + cli/setup_2.go | 2 ++ cli/setup_3.go | 5 +++-- cli/setup_subagents.go | 1 + cli/state.go | 3 ++- cli/status.go | 6 ++++-- cli/strategy/common_2.go | 15 ++++++++++----- cli/strategy/content_overlap.go | 1 + cli/strategy/hooks.go | 6 ++++++ cli/strategy/manual_commit_condensation.go | 1 + cli/strategy/manual_commit_condensation_2.go | 3 +++ cli/strategy/manual_commit_hooks.go | 7 +++++-- cli/strategy/manual_commit_hooks_2.go | 4 +++- cli/strategy/manual_commit_hooks_3.go | 1 + cli/strategy/manual_commit_hooks_4.go | 2 ++ cli/strategy/manual_commit_rewind.go | 1 + cli/strategy/push_common.go | 2 +- cli/strategy/session_state.go | 4 ++++ cli/telemetry/detached_unix.go | 2 +- cli/testutil/testutil.go | 18 ++++++++---------- cli/trace.go | 3 ++- cli/trail/store.go | 8 ++++---- cli/transcript.go | 10 ++++++---- cli/transcript/parse.go | 2 ++ cli/vercelconfig/vercelconfig.go | 2 +- cli/versioncheck/versioncheck.go | 4 ++-- redact/packs.go | 3 ++- redact/redact.go | 2 +- 106 files changed, 231 insertions(+), 99 deletions(-) diff --git a/cli/agent/claudecode/claude.go b/cli/agent/claudecode/claude.go index c7c0a65..83ec6a2 100644 --- a/cli/agent/claudecode/claude.go +++ b/cli/agent/claudecode/claude.go @@ -278,6 +278,7 @@ func (c *ClaudeCodeAgent) GetTranscriptPosition(path string) (int, error) { return 0, nil } + // #nosec G304 -- path comes from Claude Code transcript location, not remote/untrusted input file, err := os.Open(path) //nolint:gosec // Path comes from Claude Code transcript location if err != nil { if os.IsNotExist(err) { @@ -319,6 +320,7 @@ func (c *ClaudeCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffse return nil, 0, nil } + // #nosec G304 -- path comes from Claude Code transcript location, not remote/untrusted input file, openErr := os.Open(path) //nolint:gosec // Path comes from Claude Code transcript location if openErr != nil { return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) @@ -381,7 +383,7 @@ func (c *ClaudeCodeAgent) LaunchCmd(ctx context.Context, initialPrompt string) ( if err != nil { return nil, fmt.Errorf("claude binary not on PATH: %w", err) } - cmd := exec.CommandContext(ctx, bin, initialPrompt) + cmd := exec.CommandContext(ctx, bin, initialPrompt) // #nosec G204 -- bin is resolved via exec.LookPath("claude"); initialPrompt is passed as a single argument, not shell-interpreted cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/cli/agent/claudecode/discovery.go b/cli/agent/claudecode/discovery.go index dd35b8a..87c24f9 100644 --- a/cli/agent/claudecode/discovery.go +++ b/cli/agent/claudecode/discovery.go @@ -184,6 +184,7 @@ func readSkillsDir(ctx context.Context, dir, pluginName string) []agent.Discover } skillDir := filepath.Join(dir, skillEntry.Name()) skillFile := filepath.Join(skillDir, "SKILL.md") + // #nosec G304 -- skillFile is constructed from a ReadDir walk under HOME, not user input data, err := os.ReadFile(skillFile) //nolint:gosec // G304: skillFile is constructed from a ReadDir walk under HOME, not user input if err != nil { continue @@ -232,6 +233,7 @@ func scanFlatMarkdownDir(ctx context.Context, dir, pluginName string) []agent.Di continue } filePath := filepath.Join(dir, entry.Name()) + // #nosec G304 -- filePath is constructed from a ReadDir walk under HOME, not user input data, err := os.ReadFile(filePath) //nolint:gosec // G304: filePath is constructed from a ReadDir walk under HOME, not user input if err != nil { continue diff --git a/cli/agent/claudecode/hooks.go b/cli/agent/claudecode/hooks.go index 5d79885..dfebd57 100644 --- a/cli/agent/claudecode/hooks.go +++ b/cli/agent/claudecode/hooks.go @@ -70,6 +70,7 @@ func (c *ClaudeCodeAgent) InstallHooks(ctx context.Context, localDev bool, force // rawPermissions preserves unknown permission fields (e.g., "ask") var rawPermissions map[string]json.RawMessage + // #nosec G304 -- path is constructed from repo root + settings file name, not external input existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + settings file name if readErr == nil { if err := json.Unmarshal(existingData, &rawSettings); err != nil { @@ -233,7 +234,7 @@ func (c *ClaudeCodeAgent) InstallHooks(ctx context.Context, localDev bool, force func parseHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]ClaudeHookMatcher) { if data, ok := rawHooks[hookType]; ok { //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty - json.Unmarshal(data, target) + json.Unmarshal(data, target) // #nosec G104 -- intentionally ignoring parse errors, leave target as nil/empty } } @@ -259,6 +260,7 @@ func (c *ClaudeCodeAgent) UninstallHooks(ctx context.Context) error { repoRoot = "." // Fallback to CWD if not in a git repo } settingsPath := filepath.Join(repoRoot, ".claude", ClaudeSettingsFileName) + // #nosec G304 -- path is constructed from repo root + fixed path, not external input data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return nil //nolint:nilerr // No settings file means nothing to uninstall @@ -378,6 +380,7 @@ func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) bool { repoRoot = "." // Fallback to CWD if not in a git repo } settingsPath := filepath.Join(repoRoot, ".claude", ClaudeSettingsFileName) + // #nosec G304 -- path is constructed from repo root + fixed path, not external input data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return false diff --git a/cli/agent/claudecode/lifecycle.go b/cli/agent/claudecode/lifecycle.go index f02fb1f..e548a62 100644 --- a/cli/agent/claudecode/lifecycle.go +++ b/cli/agent/claudecode/lifecycle.go @@ -75,6 +75,7 @@ func (c *ClaudeCodeAgent) ParseHookEvent(_ context.Context, hookName string, std // ReadTranscript reads the raw JSONL transcript bytes for a session. func (c *ClaudeCodeAgent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- path comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return nil, fmt.Errorf("failed to read transcript: %w", err) @@ -244,6 +245,7 @@ func waitForTranscriptFlush(ctx context.Context, transcriptPath string, hookStar // checkStopSentinel reads the tail of the transcript file and looks for the sentinel. func checkStopSentinel(path string, tailBytes int64, hookStartTime time.Time, maxSkew time.Duration) bool { + // #nosec G304 -- path comes from agent hook input (trusted lifecycle payload), not remote/untrusted input f, err := os.Open(path) //nolint:gosec // path comes from agent hook input if err != nil { return false diff --git a/cli/agent/claudecode/reviewer.go b/cli/agent/claudecode/reviewer.go index 7f247c1..0b210b1 100644 --- a/cli/agent/claudecode/reviewer.go +++ b/cli/agent/claudecode/reviewer.go @@ -29,7 +29,7 @@ func NewReviewer() *reviewtypes.ReviewerTemplate { // Exposed at package level for test inspection of argv and env. func buildReviewCmd(ctx context.Context, cfg reviewtypes.RunConfig) *exec.Cmd { prompt := review.ComposeReviewPrompt(cfg) - cmd := exec.CommandContext(ctx, "claude", "-p", prompt) + cmd := exec.CommandContext(ctx, "claude", "-p", prompt) // #nosec G204 -- fixed "claude" binary; prompt is passed as a single argument, not shell-interpreted cmd.Env = review.AppendReviewEnv(os.Environ(), "claude-code", cfg, prompt) return cmd } diff --git a/cli/agent/claudecode/types.go b/cli/agent/claudecode/types.go index 2a8898e..402b4d0 100644 --- a/cli/agent/claudecode/types.go +++ b/cli/agent/claudecode/types.go @@ -69,6 +69,7 @@ const ( ToolWrite = "Write" ToolEdit = "Edit" ToolNotebookEdit = "NotebookEdit" + // #nosec G101 -- not a credential: this is an MCP tool name constant ToolMCPWrite = "mcp__acp__Write" //nolint:gosec // G101: This is a tool name, not a credential ToolMCPEdit = "mcp__acp__Edit" ) diff --git a/cli/agent/codex/codex.go b/cli/agent/codex/codex.go index 777bbf8..13a85c6 100644 --- a/cli/agent/codex/codex.go +++ b/cli/agent/codex/codex.go @@ -190,6 +190,7 @@ func (c *CodexAgent) FormatResumeCommand(sessionID string) string { // ReadTranscript reads the raw JSONL transcript bytes for a session. func (c *CodexAgent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- path comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return nil, fmt.Errorf("failed to read transcript: %w", err) @@ -256,7 +257,7 @@ func (c *CodexAgent) LaunchCmd(ctx context.Context, initialPrompt string) (*exec if err != nil { return nil, fmt.Errorf("codex binary not on PATH: %w", err) } - cmd := exec.CommandContext(ctx, bin, initialPrompt) + cmd := exec.CommandContext(ctx, bin, initialPrompt) // #nosec G204 -- bin is resolved via exec.LookPath("codex"); initialPrompt is passed as a single argument, not shell-interpreted cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/cli/agent/codex/hooks.go b/cli/agent/codex/hooks.go index b5a867b..2e52482 100644 --- a/cli/agent/codex/hooks.go +++ b/cli/agent/codex/hooks.go @@ -38,6 +38,7 @@ func (c *CodexAgent) InstallHooks(ctx context.Context, localDev bool, force bool // Read existing hooks.json if present var rawHooks map[string]json.RawMessage + // #nosec G304 -- hooksPath is constructed from repo root + fixed subpath, not external input existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path constructed from repo root if readErr == nil { var hooksFile map[string]json.RawMessage @@ -163,6 +164,7 @@ func (c *CodexAgent) UninstallHooks(ctx context.Context) error { } hooksPath := filepath.Join(repoRoot, ".codex", HooksFileName) + // #nosec G304 -- hooksPath is constructed from repo root + fixed subpath, not external input data, err := os.ReadFile(hooksPath) //nolint:gosec // path constructed from repo root if err != nil { return nil //nolint:nilerr // No hooks.json means nothing to uninstall @@ -230,6 +232,7 @@ func (c *CodexAgent) AreHooksInstalled(ctx context.Context) bool { } hooksPath := filepath.Join(repoRoot, ".codex", HooksFileName) + // #nosec G304 -- hooksPath is constructed from repo root + fixed subpath, not external input data, err := os.ReadFile(hooksPath) //nolint:gosec // path constructed from repo root if err != nil { return false @@ -342,6 +345,7 @@ const featureLine = "codex_hooks = true" func ensureProjectFeatureEnabled(repoRoot string) error { configPath := filepath.Join(repoRoot, ".codex", configFileName) + // #nosec G304 -- configPath is constructed from repo root + fixed subpath, not external input data, err := os.ReadFile(configPath) //nolint:gosec // path constructed from repo root if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to read config.toml: %w", err) diff --git a/cli/agent/codex/spawner.go b/cli/agent/codex/spawner.go index ac4223e..f875d59 100644 --- a/cli/agent/codex/spawner.go +++ b/cli/agent/codex/spawner.go @@ -31,7 +31,7 @@ func NewSpawner() spawn.Spawner { //nolint:ireturn // factory returns interface func (codexSpawner) Name() string { return string(agent.AgentNameCodex) } func (codexSpawner) BuildCmd(ctx context.Context, env []string, prompt string) *exec.Cmd { - cmd := exec.CommandContext( + cmd := exec.CommandContext( // #nosec G204 -- fixed "codex" binary name and fixed argv flags; prompt is piped via stdin, not an argument ctx, string(agent.AgentNameCodex), codexExecCommand, "--skip-git-repo-check", diff --git a/cli/agent/codex/transcript.go b/cli/agent/codex/transcript.go index bc649e3..024c4eb 100644 --- a/cli/agent/codex/transcript.go +++ b/cli/agent/codex/transcript.go @@ -83,6 +83,7 @@ func (c *CodexAgent) GetTranscriptPosition(path string) (int, error) { return 0, nil } + // #nosec G304 -- path comes from agent hook input (trusted lifecycle payload), not remote/untrusted input file, err := os.Open(path) //nolint:gosec // Path comes from agent hook input if err != nil { if os.IsNotExist(err) { @@ -116,6 +117,7 @@ func (c *CodexAgent) ExtractModifiedFilesFromOffset(path string, startOffset int return nil, 0, nil } + // #nosec G304 -- path comes from agent hook input (trusted lifecycle payload), not remote/untrusted input file, openErr := os.Open(path) //nolint:gosec // Path comes from agent hook input if openErr != nil { return nil, 0, fmt.Errorf("failed to open transcript: %w", openErr) @@ -262,6 +264,7 @@ func (c *CodexAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) // ExtractPrompts returns user prompts from the transcript starting at the given offset. func (c *CodexAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { if os.IsNotExist(err) { diff --git a/cli/agent/codex/trust.go b/cli/agent/codex/trust.go index a91ae49..1fdbb64 100644 --- a/cli/agent/codex/trust.go +++ b/cli/agent/codex/trust.go @@ -68,6 +68,7 @@ func codexConfigPath() string { // whether the read+parse succeeded — false on missing/malformed file so // callers can stay silent rather than mid-flow noise. func declaredCodexEvents(hooksJSONPath string) ([]string, bool) { + // #nosec G304 -- hooksJSONPath constructed from caller-controlled repo root, not remote/untrusted input data, err := os.ReadFile(hooksJSONPath) //nolint:gosec // path constructed from caller-controlled repo root if err != nil { return nil, false @@ -103,6 +104,7 @@ func declaredCodexEvents(hooksJSONPath string) ([]string, bool) { // are "Codex isn't enabled here", which is a different problem. func MissingEntireHooks(repoRoot string) []string { hooksJSONPath := filepath.Join(repoRoot, ".codex", "hooks.json") + // #nosec G304 -- hooksJSONPath constructed from caller-controlled repo root, not remote/untrusted input data, err := os.ReadFile(hooksJSONPath) //nolint:gosec // path constructed from caller-controlled repo root if err != nil { return nil @@ -131,6 +133,7 @@ func MissingEntireHooks(repoRoot string) []string { var codexTrustStateHeaderRegex = regexp.MustCompile(`(?m)^\[hooks\.state\."([^"]+)"\]`) func readCodexTrustedKeys(configPath string) (map[string]struct{}, bool) { + // #nosec G304 -- configPath resolved from CODEX_HOME env var or user home dir, a standard trusted config location data, err := os.ReadFile(configPath) //nolint:gosec // path resolved from CODEX_HOME or HOME if err != nil { return nil, false diff --git a/cli/agent/copilotcli/copilotcli.go b/cli/agent/copilotcli/copilotcli.go index 615ca1f..14a147a 100644 --- a/cli/agent/copilotcli/copilotcli.go +++ b/cli/agent/copilotcli/copilotcli.go @@ -147,6 +147,7 @@ func (c *CopilotCLIAgent) FormatResumeCommand(sessionID string) string { // ReadTranscript reads the raw JSONL transcript bytes for a session. func (c *CopilotCLIAgent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return nil, fmt.Errorf("failed to read transcript: %w", err) diff --git a/cli/agent/copilotcli/hooks.go b/cli/agent/copilotcli/hooks.go index 62cbfae..38a803b 100644 --- a/cli/agent/copilotcli/hooks.go +++ b/cli/agent/copilotcli/hooks.go @@ -56,6 +56,7 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force var rawFile map[string]json.RawMessage var rawHooks map[string]json.RawMessage + // #nosec G304 -- hooksPath is constructed from repo root + fixed path, not external input existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path switch { case readErr == nil: @@ -172,6 +173,7 @@ func (c *CopilotCLIAgent) UninstallHooks(ctx context.Context) error { worktreeRoot = "." } hooksPath := filepath.Join(worktreeRoot, hooksDir, HooksFileName) + // #nosec G304 -- hooksPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -238,6 +240,7 @@ func (c *CopilotCLIAgent) AreHooksInstalled(ctx context.Context) bool { worktreeRoot = "." } hooksPath := filepath.Join(worktreeRoot, hooksDir, HooksFileName) + // #nosec G304 -- hooksPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { if !errors.Is(err, os.ErrNotExist) { diff --git a/cli/agent/copilotcli/transcript.go b/cli/agent/copilotcli/transcript.go index e0998a2..2575f04 100644 --- a/cli/agent/copilotcli/transcript.go +++ b/cli/agent/copilotcli/transcript.go @@ -341,6 +341,7 @@ func ExtractModelFromTranscript(ctx context.Context, transcriptPath string) stri return "" } + // #nosec G304 -- transcriptPath derived from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(transcriptPath) //nolint:gosec // Path derived from agent hook input if err != nil { logging.Debug(ctx, "copilot-cli: failed to read transcript for model extraction", @@ -374,6 +375,7 @@ func (c *CopilotCLIAgent) GetTranscriptPosition(path string) (int, error) { return 0, nil } + // #nosec G304 -- path comes from Copilot CLI transcript location, not remote/untrusted input file, err := os.Open(path) //nolint:gosec // Path comes from Copilot CLI transcript location if err != nil { if os.IsNotExist(err) { @@ -415,6 +417,7 @@ func (c *CopilotCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffse return nil, 0, nil } + // #nosec G304 -- path comes from Copilot CLI transcript location, not remote/untrusted input file, openErr := os.Open(path) //nolint:gosec // Path comes from Copilot CLI transcript location if openErr != nil { return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) @@ -452,6 +455,7 @@ func (c *CopilotCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffse // ExtractPrompts extracts user prompts from the transcript starting at the given offset. func (c *CopilotCLIAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return nil, fmt.Errorf("failed to read transcript: %w", err) @@ -466,6 +470,7 @@ func (c *CopilotCLIAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]s // ExtractSummary extracts the last assistant message as a session summary. func (c *CopilotCLIAgent) ExtractSummary(sessionRef string) (string, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return "", fmt.Errorf("failed to read transcript: %w", err) diff --git a/cli/agent/cursor/hooks.go b/cli/agent/cursor/hooks.go index 88ad070..4096a40 100644 --- a/cli/agent/cursor/hooks.go +++ b/cli/agent/cursor/hooks.go @@ -72,6 +72,7 @@ func (c *CursorAgent) InstallHooks(ctx context.Context, localDev bool, force boo var rawFile map[string]json.RawMessage var rawHooks map[string]json.RawMessage + // #nosec G304 -- hooksPath is constructed from repo root + fixed path, not external input existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path if readErr == nil { if err := json.Unmarshal(existingData, &rawFile); err != nil { @@ -218,6 +219,7 @@ func (c *CursorAgent) UninstallHooks(ctx context.Context) error { worktreeRoot = "." } hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName) + // #nosec G304 -- hooksPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return nil //nolint:nilerr // No hooks file means nothing to uninstall @@ -297,6 +299,7 @@ func (c *CursorAgent) AreHooksInstalled(ctx context.Context) bool { worktreeRoot = "." } hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName) + // #nosec G304 -- hooksPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return false @@ -333,7 +336,7 @@ func (c *CursorAgent) GetSupportedHooks() []agent.HookType { func parseCursorHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]CursorHookEntry) { if data, ok := rawHooks[hookType]; ok { //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty - json.Unmarshal(data, target) + json.Unmarshal(data, target) // #nosec G104 -- intentionally ignoring parse errors, leave target as nil/empty } } diff --git a/cli/agent/cursor/lifecycle.go b/cli/agent/cursor/lifecycle.go index 690e339..ea9fbd4 100644 --- a/cli/agent/cursor/lifecycle.go +++ b/cli/agent/cursor/lifecycle.go @@ -48,6 +48,7 @@ func (c *CursorAgent) ParseHookEvent(ctx context.Context, hookName string, stdin // ReadTranscript reads the raw JSONL transcript bytes for a session. func (c *CursorAgent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return nil, fmt.Errorf("failed to read transcript: %w", err) diff --git a/cli/agent/cursor/transcript.go b/cli/agent/cursor/transcript.go index b2e5fdc..ec363f4 100644 --- a/cli/agent/cursor/transcript.go +++ b/cli/agent/cursor/transcript.go @@ -24,6 +24,7 @@ func (c *CursorAgent) GetTranscriptPosition(path string) (int, error) { return 0, nil } + // #nosec G304 -- path comes from Cursor transcript location, not remote/untrusted input file, err := os.Open(path) //nolint:gosec // Path comes from Cursor transcript location if err != nil { if os.IsNotExist(err) { @@ -77,6 +78,7 @@ func (c *CursorAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]strin // ExtractSummary extracts the last assistant message as a session summary. func (c *CursorAgent) ExtractSummary(sessionRef string) (string, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return "", fmt.Errorf("failed to read transcript: %w", err) diff --git a/cli/agent/external/external.go b/cli/agent/external/external.go index de9a3c3..d6611cc 100644 --- a/cli/agent/external/external.go +++ b/cli/agent/external/external.go @@ -430,7 +430,7 @@ func (e *Agent) run(ctx context.Context, stdin []byte, args ...string) ([]byte, ctx, cancel = context.WithTimeout(ctx, defaultRunTimeout) defer cancel() } - cmd := exec.CommandContext(ctx, e.binaryPath, args...) + cmd := exec.CommandContext(ctx, e.binaryPath, args...) // #nosec G204 -- e.binaryPath is the user-configured external agent binary, a trusted operator-provided path; args are internally constructed // Ensure I/O goroutines are released shortly after the process is killed, // so cmd.Run() doesn't block waiting for pipe reads. cmd.WaitDelay = 3 * time.Second diff --git a/cli/agent/factoryaidroid/factoryaidroid.go b/cli/agent/factoryaidroid/factoryaidroid.go index 4fc2256..6d81a94 100644 --- a/cli/agent/factoryaidroid/factoryaidroid.go +++ b/cli/agent/factoryaidroid/factoryaidroid.go @@ -69,6 +69,7 @@ func (f *FactoryAIDroidAgent) DetectPresence(ctx context.Context) (bool, error) // ReadTranscript reads the raw JSONL transcript bytes for a session. func (f *FactoryAIDroidAgent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return nil, fmt.Errorf("failed to read transcript: %w", err) diff --git a/cli/agent/factoryaidroid/hooks.go b/cli/agent/factoryaidroid/hooks.go index 53c47b7..4d644da 100644 --- a/cli/agent/factoryaidroid/hooks.go +++ b/cli/agent/factoryaidroid/hooks.go @@ -72,6 +72,7 @@ func (f *FactoryAIDroidAgent) InstallHooks(ctx context.Context, localDev bool, f // rawPermissions preserves unknown permission fields (e.g., "ask") var rawPermissions map[string]json.RawMessage + // #nosec G304 -- settingsPath is constructed from cwd + fixed path, not external input existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from cwd + fixed path if readErr == nil { if err := json.Unmarshal(existingData, &rawSettings); err != nil { @@ -247,7 +248,7 @@ func (f *FactoryAIDroidAgent) InstallHooks(ctx context.Context, localDev bool, f func parseHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]FactoryHookMatcher) { if data, ok := rawHooks[hookType]; ok { //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty - json.Unmarshal(data, target) + json.Unmarshal(data, target) // #nosec G104 -- intentionally ignoring parse errors, leave target as nil/empty } } @@ -273,6 +274,7 @@ func (f *FactoryAIDroidAgent) UninstallHooks(ctx context.Context) error { repoRoot = "." // Fallback to CWD if not in a git repo } settingsPath := filepath.Join(repoRoot, ".factory", FactorySettingsFileName) + // #nosec G304 -- settingsPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return nil //nolint:nilerr // No settings file means nothing to uninstall @@ -395,6 +397,7 @@ func (f *FactoryAIDroidAgent) AreHooksInstalled(ctx context.Context) bool { repoRoot = "." // Fallback to CWD if not in a git repo } settingsPath := filepath.Join(repoRoot, ".factory", FactorySettingsFileName) + // #nosec G304 -- settingsPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return false diff --git a/cli/agent/factoryaidroid/lifecycle.go b/cli/agent/factoryaidroid/lifecycle.go index 45101d6..a83bfb8 100644 --- a/cli/agent/factoryaidroid/lifecycle.go +++ b/cli/agent/factoryaidroid/lifecycle.go @@ -119,6 +119,7 @@ func (f *FactoryAIDroidAgent) ExtractPrompts(sessionRef string, fromOffset int) // ExtractSummary extracts the last assistant message as a session summary. func (f *FactoryAIDroidAgent) ExtractSummary(sessionRef string) (string, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return "", fmt.Errorf("failed to read transcript: %w", err) diff --git a/cli/agent/factoryaidroid/transcript.go b/cli/agent/factoryaidroid/transcript.go index 487ef5b..5af07d4 100644 --- a/cli/agent/factoryaidroid/transcript.go +++ b/cli/agent/factoryaidroid/transcript.go @@ -38,6 +38,7 @@ type droidMessageRole struct { // shared transcript.Line format (type="assistant"/"user", message=inner content). // Non-message entries (session_start, etc.) are skipped. func ParseDroidTranscript(path string, startLine int) ([]transcript.Line, int, error) { + // #nosec G304 -- path is a controlled transcript file path, not remote/untrusted input file, err := os.Open(path) //nolint:gosec // path is a controlled transcript file path if err != nil { return nil, 0, fmt.Errorf("failed to open transcript: %w", err) @@ -298,6 +299,7 @@ func ExtractModelFromTranscript(transcriptPath string) string { } settingsPath := strings.TrimSuffix(transcriptPath, ".jsonl") + ".settings.json" + // #nosec G304 -- settingsPath derived from agent hook input transcriptPath, not remote/untrusted input data, err := os.ReadFile(settingsPath) //nolint:gosec // Path derived from agent hook input if err != nil { return "" diff --git a/cli/agent/geminicli/gemini.go b/cli/agent/geminicli/gemini.go index a7dada0..295c5e1 100644 --- a/cli/agent/geminicli/gemini.go +++ b/cli/agent/geminicli/gemini.go @@ -216,6 +216,7 @@ func (g *GeminiCLIAgent) GetTranscriptPosition(path string) (int, error) { return 0, nil } + // #nosec G304 -- reading from controlled transcript path, not remote/untrusted input data, err := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path if err != nil { if os.IsNotExist(err) { @@ -247,6 +248,7 @@ func (g *GeminiCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffset return nil, 0, nil } + // #nosec G304 -- reading from controlled transcript path, not remote/untrusted input data, readErr := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path if readErr != nil { if os.IsNotExist(readErr) { diff --git a/cli/agent/geminicli/hooks.go b/cli/agent/geminicli/hooks.go index e5518e5..36e078e 100644 --- a/cli/agent/geminicli/hooks.go +++ b/cli/agent/geminicli/hooks.go @@ -69,6 +69,7 @@ func (g *GeminiCLIAgent) InstallHooks(ctx context.Context, localDev bool, force var hooksConfig GeminiHooksConfig + // #nosec G304 -- settingsPath is constructed from cwd + fixed path, not external input existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from cwd + fixed path if readErr == nil { if err := json.Unmarshal(existingData, &rawSettings); err != nil { @@ -295,7 +296,7 @@ func writeGeminiSettingsFile(rawSettings map[string]json.RawMessage, rawHooks ma func parseGeminiHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]GeminiHookMatcher) { if data, ok := rawHooks[hookType]; ok { //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty - json.Unmarshal(data, target) + json.Unmarshal(data, target) // #nosec G104 -- intentionally ignoring parse errors, leave target as nil/empty } } @@ -321,6 +322,7 @@ func (g *GeminiCLIAgent) UninstallHooks(ctx context.Context) error { repoRoot = "." // Fallback to CWD if not in a git repo } settingsPath := filepath.Join(repoRoot, ".gemini", GeminiSettingsFileName) + // #nosec G304 -- settingsPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return nil //nolint:nilerr // No settings file means nothing to uninstall @@ -418,6 +420,7 @@ func (g *GeminiCLIAgent) AreHooksInstalled(ctx context.Context) bool { repoRoot = "." // Fallback to CWD if not in a git repo } settingsPath := filepath.Join(repoRoot, ".gemini", GeminiSettingsFileName) + // #nosec G304 -- settingsPath is constructed from repo root + fixed path, not external input data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return false diff --git a/cli/agent/geminicli/lifecycle.go b/cli/agent/geminicli/lifecycle.go index 850db2c..8f1a3a0 100644 --- a/cli/agent/geminicli/lifecycle.go +++ b/cli/agent/geminicli/lifecycle.go @@ -75,6 +75,7 @@ func (g *GeminiCLIAgent) ParseHookEvent(_ context.Context, hookName string, stdi // ReadTranscript reads the raw JSON transcript bytes for a session. func (g *GeminiCLIAgent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input if err != nil { return nil, fmt.Errorf("failed to read transcript: %w", err) diff --git a/cli/agent/geminicli/transcript.go b/cli/agent/geminicli/transcript.go index 2ae72d7..ee4a183 100644 --- a/cli/agent/geminicli/transcript.go +++ b/cli/agent/geminicli/transcript.go @@ -198,6 +198,7 @@ func GetLastMessageIDFromFile(path string) (string, error) { return "", nil } + // #nosec G304 -- reading from controlled transcript path, not remote/untrusted input data, err := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path if err != nil { if os.IsNotExist(err) { diff --git a/cli/agent/opencode/cli_commands.go b/cli/agent/opencode/cli_commands.go index 8e16637..576c624 100644 --- a/cli/agent/opencode/cli_commands.go +++ b/cli/agent/opencode/cli_commands.go @@ -20,7 +20,7 @@ func runOpenCodeExportToFile(ctx context.Context, sessionID, outputPath string) defer cancel() //nolint:gosec // outputPath is generated by the caller under .trace/tmp - file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- outputPath is generated by the caller under .trace/tmp, not external input if err != nil { return fmt.Errorf("failed to create export file: %w", err) } diff --git a/cli/agent/opencode/hooks.go b/cli/agent/opencode/hooks.go index 60a8806..2b44610 100644 --- a/cli/agent/opencode/hooks.go +++ b/cli/agent/opencode/hooks.go @@ -61,6 +61,7 @@ func (a *OpenCodeAgent) InstallHooks(ctx context.Context, localDev bool, force b // Check if already installed with identical content (idempotent) unless force if !force { + // #nosec G304 -- pluginPath is constructed from repo root + fixed subpath, not external input if existing, readErr := os.ReadFile(pluginPath); readErr == nil { //nolint:gosec // Path constructed from repo root if string(existing) == content { return 0, nil // Already up-to-date @@ -71,12 +72,14 @@ func (a *OpenCodeAgent) InstallHooks(ctx context.Context, localDev bool, force b // Ensure directory exists pluginDir := filepath.Dir(pluginPath) //nolint:gosec // G301: Plugin directory needs standard permissions + // #nosec G301 -- plugin directory under .opencode/ must be traversable/readable by the OpenCode tool itself, not private data if err := os.MkdirAll(pluginDir, 0o755); err != nil { return 0, fmt.Errorf("failed to create plugin directory: %w", err) } // Write plugin file //nolint:gosec // G306: Plugin file needs standard permissions for OpenCode to read + // #nosec G306 -- plugin file must be world-readable so the OpenCode tool can load it, not private data if err := os.WriteFile(pluginPath, []byte(content), 0o644); err != nil { return 0, fmt.Errorf("failed to write plugin file: %w", err) } @@ -105,6 +108,7 @@ func (a *OpenCodeAgent) AreHooksInstalled(ctx context.Context) bool { return false } + // #nosec G304 -- pluginPath is constructed from repo root + fixed subpath, not external input data, err := os.ReadFile(pluginPath) //nolint:gosec // Path constructed from repo root if err != nil { return false diff --git a/cli/agent/opencode/lifecycle.go b/cli/agent/opencode/lifecycle.go index 3d83ea0..9bb9c68 100644 --- a/cli/agent/opencode/lifecycle.go +++ b/cli/agent/opencode/lifecycle.go @@ -197,7 +197,7 @@ func (a *OpenCodeAgent) fetchAndCacheExport(ctx context.Context, sessionID strin } //nolint:gosec // tmpFile is constructed from validated session ID under repo .trace/tmp - data, err := os.ReadFile(tmpFile) + data, err := os.ReadFile(tmpFile) // #nosec G304 -- tmpFile is constructed from a validated session ID under repo .trace/tmp, not external input if err != nil { return "", fmt.Errorf("failed to read export file: %w", err) } diff --git a/cli/agent/opencode/opencode.go b/cli/agent/opencode/opencode.go index a6bcb04..212c183 100644 --- a/cli/agent/opencode/opencode.go +++ b/cli/agent/opencode/opencode.go @@ -60,6 +60,7 @@ func (a *OpenCodeAgent) DetectPresence(ctx context.Context) (bool, error) { // ReadTranscript reads the transcript for a session. // The sessionRef is expected to be a path to the export JSON file. func (a *OpenCodeAgent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path from agent hook if err != nil { return nil, fmt.Errorf("failed to read opencode transcript: %w", err) diff --git a/cli/agent/opencode/transcript.go b/cli/agent/opencode/transcript.go index afe44b8..8432435 100644 --- a/cli/agent/opencode/transcript.go +++ b/cli/agent/opencode/transcript.go @@ -34,6 +34,7 @@ func ParseExportSession(data []byte) (*ExportSession, error) { // parseExportSessionFromFile reads a file and parses its contents as an ExportSession. func parseExportSessionFromFile(path string) (*ExportSession, error) { + // #nosec G304 -- path comes from agent hook/session state, not remote/untrusted input data, err := os.ReadFile(path) //nolint:gosec // path from agent hook/session state if err != nil { return nil, err //nolint:wrapcheck // caller adds context or checks os.IsNotExist @@ -284,6 +285,7 @@ func ExtractAllUserPrompts(data []byte) ([]string, error) { // ExtractPrompts extracts user prompts from an OpenCode export transcript starting // at the given message offset. func (a *OpenCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + // #nosec G304 -- sessionRef comes from validated agent session state, not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // path comes from validated agent session state if err != nil { return nil, fmt.Errorf("failed to read opencode transcript for prompt extraction: %w", err) diff --git a/cli/agent/pi/hooks.go b/cli/agent/pi/hooks.go index 29ab953..98e985b 100644 --- a/cli/agent/pi/hooks.go +++ b/cli/agent/pi/hooks.go @@ -74,7 +74,7 @@ func (a *PiAgent) InstallHooks(ctx context.Context, localDev bool, force bool) ( content := renderExtension(localDev) if !force { - //nolint:gosec // path constructed from validated repo root + // #nosec G304 -- path constructed from validated repo root existing, readErr := os.ReadFile(path) switch { case readErr == nil && string(existing) == content: @@ -84,12 +84,10 @@ func (a *PiAgent) InstallHooks(ctx context.Context, localDev bool, force bool) ( } } - //nolint:gosec // G301: pi reads the directory; standard 0755 permissions - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return 0, fmt.Errorf("create extension dir: %w", err) } - //nolint:gosec // G306: pi reads the file; standard 0644 permissions - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { return 0, fmt.Errorf("write extension: %w", err) } return 1, nil @@ -115,7 +113,7 @@ func (a *PiAgent) AreHooksInstalled(ctx context.Context) bool { if err != nil { return false } - //nolint:gosec // path from validated repo root + // #nosec G304 -- path from validated repo root data, err := os.ReadFile(path) if err != nil { return false diff --git a/cli/agent/pi/lifecycle.go b/cli/agent/pi/lifecycle.go index c15a2a6..b13e85e 100644 --- a/cli/agent/pi/lifecycle.go +++ b/cli/agent/pi/lifecycle.go @@ -213,7 +213,7 @@ func cacheSessionID(ctx context.Context, id string) { func readCachedSessionID(ctx context.Context) string { dir := resolveSessionDir(ctx) - //nolint:gosec // path constructed from validated repo root + // #nosec G304 -- path constructed from validated repo root data, err := os.ReadFile(filepath.Join(dir, activeSessionFile)) if err != nil { return "" @@ -242,7 +242,7 @@ func captureTranscript(ctx context.Context, sessionID, piSessionFile string) str return "" } dst := filepath.Join(dir, sessionID+".json") - //nolint:gosec // G703: piSessionFile from trusted Pi extension stdin payload + // #nosec G304 -- piSessionFile from trusted Pi extension stdin payload data, err := os.ReadFile(piSessionFile) if err != nil { logging.Warn(ctx, "pi: capture transcript read failed", diff --git a/cli/agent/pi/pi.go b/cli/agent/pi/pi.go index 91956d9..78499d4 100644 --- a/cli/agent/pi/pi.go +++ b/cli/agent/pi/pi.go @@ -84,7 +84,7 @@ func (a *PiAgent) ReadTranscript(sessionRef string) ([]byte, error) { if sessionRef == "" { return nil, errors.New("empty session ref") } - //nolint:gosec // SessionRef from validated lifecycle hook input + // #nosec G304 -- SessionRef from validated lifecycle hook input data, err := os.ReadFile(sessionRef) if err != nil { return nil, fmt.Errorf("read pi transcript %s: %w", sessionRef, err) diff --git a/cli/agent/pi/transcript.go b/cli/agent/pi/transcript.go index 3f587ae..e5cc432 100644 --- a/cli/agent/pi/transcript.go +++ b/cli/agent/pi/transcript.go @@ -62,7 +62,7 @@ func (a *PiAgent) GetTranscriptPosition(path string) (int, error) { if path == "" { return 0, nil } - //nolint:gosec // path from validated SessionRef set by lifecycle hooks + // #nosec G304 -- path from validated SessionRef set by lifecycle hooks data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -81,7 +81,7 @@ func (a *PiAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ( if path == "" { return nil, 0, nil } - //nolint:gosec // path from validated SessionRef + // #nosec G304 -- path from validated SessionRef data, err := os.ReadFile(path) if err != nil { return nil, 0, fmt.Errorf("read pi transcript: %w", err) @@ -141,7 +141,7 @@ func (a *PiAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, e if sessionRef == "" { return nil, nil } - //nolint:gosec // sessionRef from validated SessionRef + // #nosec G304 -- sessionRef from validated SessionRef data, err := os.ReadFile(sessionRef) if err != nil { return nil, fmt.Errorf("read pi transcript: %w", err) diff --git a/cli/agent/testutil/hooks.go b/cli/agent/testutil/hooks.go index bc22cb0..fd0229d 100644 --- a/cli/agent/testutil/hooks.go +++ b/cli/agent/testutil/hooks.go @@ -13,6 +13,7 @@ import ( func ReadRawHooks(t *testing.T, tempDir, settingsDir string) map[string]json.RawMessage { t.Helper() settingsPath := filepath.Join(tempDir, settingsDir, "settings.json") + // #nosec G304 -- test utility; path is constructed from the test's own tempDir, not external input data, err := os.ReadFile(settingsPath) //nolint:gosec // Test utility, path constructed from test tempDir if err != nil { t.Fatalf("failed to read settings.json: %v", err) diff --git a/cli/agent/vogon/vogon.go b/cli/agent/vogon/vogon.go index a258823..cb3bc68 100644 --- a/cli/agent/vogon/vogon.go +++ b/cli/agent/vogon/vogon.go @@ -50,6 +50,7 @@ func (v *Agent) IsTestOnly() bool { return true } // --- Transcript Storage --- func (v *Agent) ReadTranscript(sessionRef string) ([]byte, error) { + // #nosec G304 -- sessionRef comes from agent hook input (trusted lifecycle payload), not remote/untrusted input data, err := os.ReadFile(sessionRef) //nolint:gosec // Path from hook input if err != nil { return nil, fmt.Errorf("read transcript: %w", err) diff --git a/cli/attach.go b/cli/attach.go index 1a943d4..344aaca 100644 --- a/cli/attach.go +++ b/cli/attach.go @@ -625,7 +625,7 @@ func promptAmendCommit(ctx context.Context, w io.Writer, headCommit *object.Comm newMessage := trailers.AppendCheckpointTrailer(headCommit.Message, checkpointIDStr) - cmd := exec.CommandContext(ctx, "git", "commit", "--amend", "--only", "-m", newMessage) + cmd := exec.CommandContext(ctx, "git", "commit", "--amend", "--only", "-m", newMessage) // #nosec G204 -- fixed git subcommand; newMessage is passed as a single argument, not shell-interpreted if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to amend commit: %w\n%s", err, output) } diff --git a/cli/benchutil/benchutil.go b/cli/benchutil/benchutil.go index cdd4af8..a1d1d40 100644 --- a/cli/benchutil/benchutil.go +++ b/cli/benchutil/benchutil.go @@ -481,23 +481,23 @@ func GenerateFileContent(seed, sizeBytes int) string { return buf.String() } -//nolint:gosec // G301/G306: benchmark fixtures use standard permissions in temp dirs +// benchmark fixtures written to temp dirs with tightened permissions func writeFile(b *testing.B, dir, relPath, content string) { b.Helper() abs := filepath.Join(dir, relPath) - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(abs), 0o750); err != nil { b.Fatalf("mkdir %s: %v", filepath.Dir(relPath), err) } - if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + if err := os.WriteFile(abs, []byte(content), 0o600); err != nil { b.Fatalf("write %s: %v", relPath, err) } } -//nolint:gosec // G301/G306: benchmark fixtures use standard permissions in temp dirs +// benchmark fixtures written to temp dirs with tightened permissions func initTraceSettings(b *testing.B, dir, strategy string) { b.Helper() traceDir := filepath.Join(dir, ".trace") - if err := os.MkdirAll(filepath.Join(traceDir, "tmp"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(traceDir, "tmp"), 0o750); err != nil { b.Fatalf("mkdir .trace: %v", err) } @@ -509,7 +509,7 @@ func initTraceSettings(b *testing.B, dir, strategy string) { if err != nil { b.Fatalf("marshal settings: %v", err) } - if err := os.WriteFile(filepath.Join(traceDir, paths.SettingsFileName), data, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(traceDir, paths.SettingsFileName), data, 0o600); err != nil { b.Fatalf("write settings: %v", err) } } diff --git a/cli/checkpoint/committed_3.go b/cli/checkpoint/committed_3.go index 8592adc..0bcff22 100644 --- a/cli/checkpoint/committed_3.go +++ b/cli/checkpoint/committed_3.go @@ -169,6 +169,7 @@ func createRedactedBlobFromFile(repo *git.Repository, filePath, treePath string) mode = filemode.Executable } + // #nosec G304 -- filePath comes from walking the metadata directory, not external input content, err := os.ReadFile(filePath) //nolint:gosec // filePath comes from walking the metadata directory if err != nil { return plumbing.ZeroHash, 0, fmt.Errorf("failed to read file: %w", err) diff --git a/cli/checkpoint/configloader.go b/cli/checkpoint/configloader.go index b1a4431..4e998d5 100644 --- a/cli/checkpoint/configloader.go +++ b/cli/checkpoint/configloader.go @@ -36,7 +36,7 @@ func init() { // package init. This runs afterwards because this package imports x/plugin, // and before any plugin.Get call (which only happens at command runtime). //nolint:errcheck,gosec // Best-effort: Register only fails after a plugin.Get, which cannot precede init; go-git's default loader remains as fallback. - registerSymlinkConfigLoader() + registerSymlinkConfigLoader() // #nosec G104 -- best-effort: Register only fails after a plugin.Get, which cannot precede init; go-git default loader remains as fallback } // registerSymlinkConfigLoader registers the symlink-following config loader as @@ -48,6 +48,7 @@ func registerSymlinkConfigLoader() error { } func (osSymlinkFS) Open(name string) (billy.File, error) { //nolint:ireturn // implements billy.Filesystem interface + // #nosec G304 -- name comes from git's own config-path resolution, not user input f, err := os.Open(name) //nolint:gosec // G304: name comes from git's own config-path resolution, not user input. if err != nil { return nil, fmt.Errorf("open %s: %w", name, err) @@ -60,6 +61,7 @@ func (osSymlinkFS) Stat(name string) (fs.FileInfo, error) { } func (osSymlinkFS) OpenFile(name string, flag int, perm fs.FileMode) (billy.File, error) { //nolint:ireturn // implements billy.Filesystem interface + // #nosec G304 -- name comes from git's own config-path resolution, not user input f, err := os.OpenFile(name, flag, perm) //nolint:gosec // G304: name comes from git's own config-path resolution, not user input. if err != nil { return nil, fmt.Errorf("openfile %s: %w", name, err) diff --git a/cli/checkpoint/fetching_tree.go b/cli/checkpoint/fetching_tree.go index 7c9f76c..234eb96 100644 --- a/cli/checkpoint/fetching_tree.go +++ b/cli/checkpoint/fetching_tree.go @@ -174,7 +174,7 @@ func (t *FetchingTree) collectMissingBlobs(tree *object.Tree) []plumbing.Hash { // disk but invisible to go-git's storer (filtered out, or in a packfile // not in the cached index). We'd rather skip a wasted network round-trip. func (t *FetchingTree) blobOnDisk(hash plumbing.Hash) bool { - cmd := exec.CommandContext(t.ctx, "git", "cat-file", "-e", hash.String()) + cmd := exec.CommandContext(t.ctx, "git", "cat-file", "-e", hash.String()) // #nosec G204 -- fixed "git" binary; hash.String() is an internally resolved object hash, not remote input return cmd.Run() == nil } @@ -182,7 +182,7 @@ func (t *FetchingTree) blobOnDisk(hash plumbing.Hash) bool { // in-memory *object.File. This bypasses go-git's storer which may have a // stale packfile index after external git commands fetched new objects. func (t *FetchingTree) readFileViaGit(path string, entry *object.TreeEntry) (*object.File, error) { - cmd := exec.CommandContext(t.ctx, "git", "cat-file", "-p", entry.Hash.String()) + cmd := exec.CommandContext(t.ctx, "git", "cat-file", "-p", entry.Hash.String()) // #nosec G204 -- fixed "git" binary; entry.Hash.String() is an internally resolved object hash, not remote input content, cmdErr := cmd.Output() if cmdErr != nil { logging.Warn( diff --git a/cli/checkpoint/shadow_ref.go b/cli/checkpoint/shadow_ref.go index 792cbc0..f06209e 100644 --- a/cli/checkpoint/shadow_ref.go +++ b/cli/checkpoint/shadow_ref.go @@ -78,7 +78,7 @@ func casUpdateShadowBranchRef(ctx context.Context, repoRoot, branchName string, oldValue = expectedHash.String() } - cmd := exec.CommandContext(ctx, "git", "update-ref", refName, newValue, oldValue) + cmd := exec.CommandContext(ctx, "git", "update-ref", refName, newValue, oldValue) // #nosec G204 -- fixed "git" binary; refName/newValue/oldValue are internally resolved ref names and object hashes, not remote input cmd.Dir = repoRoot // Force English diagnostics so the CAS-conflict pattern match below // isn't defeated by a translated stderr message in a non-C locale. @@ -90,7 +90,7 @@ func casUpdateShadowBranchRef(ctx context.Context, repoRoot, branchName string, // objects reachable through these refs, even without a reflog entry. // Best-effort: failure here is non-fatal — the shadow branch still exists. keepRef := "refs/keep-around/" + newValue - keepCmd := exec.CommandContext(ctx, "git", "update-ref", keepRef, newValue) + keepCmd := exec.CommandContext(ctx, "git", "update-ref", keepRef, newValue) // #nosec G204 -- fixed "git" binary; keepRef/newValue are internally resolved ref name and object hash, not remote input keepCmd.Dir = repoRoot keepCmd.Env = cmd.Env _ = keepCmd.Run() //nolint:errcheck // Best-effort keep-around ref; failure is non-fatal @@ -117,7 +117,8 @@ func shadowRefBackoff(ctx context.Context, attempt int) error { } // Add a 1ms floor so the chosen sleep is always non-trivial, even when // rand.Int64N happens to return 0. - d := time.Duration(rand.Int64N(int64(base))) + time.Millisecond //nolint:gosec // jitter, not security-sensitive + // #nosec G404 -- non-cryptographic use (retry backoff jitter) + d := time.Duration(rand.Int64N(int64(base))) + time.Millisecond //nolint:gosec // jitter, not security-sensitive select { case <-time.After(d): return nil diff --git a/cli/checkpoint/temporary.go b/cli/checkpoint/temporary.go index 0c63445..506971f 100644 --- a/cli/checkpoint/temporary.go +++ b/cli/checkpoint/temporary.go @@ -752,7 +752,7 @@ func (s *GitStore) DeleteShadowBranch(ctx context.Context, baseCommit, worktreeI defer StorerMu.Unlock() shadowBranchName := ShadowBranchNameForCommit(baseCommit, worktreeID) - cmd := exec.CommandContext(ctx, "git", "branch", "-D", "--", shadowBranchName) + cmd := exec.CommandContext(ctx, "git", "branch", "-D", "--", shadowBranchName) // #nosec G204 -- fixed "git" binary; shadowBranchName is internally derived from a commit hash and worktree ID, not remote input if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to delete shadow branch %s: %s: %w", shadowBranchName, strings.TrimSpace(string(output)), err) } diff --git a/cli/checkpoint/temporary_2.go b/cli/checkpoint/temporary_2.go index ad72b88..7434122 100644 --- a/cli/checkpoint/temporary_2.go +++ b/cli/checkpoint/temporary_2.go @@ -160,6 +160,7 @@ func createBlobFromFile(repo *git.Repository, filePath string) (plumbing.Hash, f } // Read file contents + // #nosec G304 -- filePath comes from walking the repository tree, not external input content, err := os.ReadFile(filePath) //nolint:gosec // filePath comes from walking the repository if err != nil { return plumbing.ZeroHash, 0, fmt.Errorf("failed to read file: %w", err) diff --git a/cli/checkpoint/v2_pending_rotation.go b/cli/checkpoint/v2_pending_rotation.go index 8a6b871..a4e1ff0 100644 --- a/cli/checkpoint/v2_pending_rotation.go +++ b/cli/checkpoint/v2_pending_rotation.go @@ -141,6 +141,7 @@ func (s *V2GitStore) readPendingFullGenerationPublicationState(ctx context.Conte if err != nil { return pendingV2FullGenerationPublicationState{}, err } + // #nosec G304 -- path is under git common dir, not external input data, err := os.ReadFile(path) //nolint:gosec // path is under git common dir if os.IsNotExist(err) { return pendingV2FullGenerationPublicationState{Version: pendingV2FullGenerationPublicationVersion}, nil @@ -232,7 +233,7 @@ func resolveGitCommonDir(ctx context.Context, repo *git.Repository) (string, err return "", errors.New("resolve worktree root for pending v2 full generation publications") } - cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--git-common-dir") + cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--git-common-dir") // #nosec G204 -- fixed "git" binary; root is the resolved worktree filesystem root, not remote input output, err := cmd.Output() if err != nil { return "", fmt.Errorf("resolve git common dir for pending v2 full generation publications: %w", err) diff --git a/cli/checkpoint/v2_read.go b/cli/checkpoint/v2_read.go index 9a4dadf..6ef72e5 100644 --- a/cli/checkpoint/v2_read.go +++ b/cli/checkpoint/v2_read.go @@ -421,7 +421,7 @@ func (s *V2GitStore) fetchRemoteFullRefs(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() - lsCmd := exec.CommandContext(ctx, "git", "ls-remote", s.FetchRemote, paths.V2FullRefPrefix+"*") + lsCmd := exec.CommandContext(ctx, "git", "ls-remote", s.FetchRemote, paths.V2FullRefPrefix+"*") // #nosec G204 -- fixed "git" binary; s.FetchRemote is an internally configured remote name, not remote/untrusted input output, err := lsCmd.Output() if err != nil { return fmt.Errorf("ls-remote failed: %w", err) @@ -451,7 +451,7 @@ func (s *V2GitStore) fetchRemoteFullRefs(ctx context.Context) error { } args := append([]string{"fetch", "--no-tags", s.FetchRemote}, refSpecs...) - fetchCmd := exec.CommandContext(ctx, "git", args...) + fetchCmd := exec.CommandContext(ctx, "git", args...) // #nosec G204 -- fixed "git" binary; args are internally constructed fetch flags/refspecs, not remote/untrusted input if fetchOutput, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil { return fmt.Errorf("fetch failed: %s", fetchOutput) } diff --git a/cli/cost/pricing.go b/cli/cost/pricing.go index 320d302..8b2d7fa 100644 --- a/cli/cost/pricing.go +++ b/cli/cost/pricing.go @@ -94,7 +94,7 @@ func LoadTable() (*Table, error) { return t, nil //nolint:nilerr // missing home dir falls back to defaults } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is either a fixed ~/.hawk/trace-pricing.json location or the TRACE_PRICING_CONFIG env var set by the operator running the CLI, not remote/untrusted input if err != nil { if errors.Is(err, fs.ErrNotExist) { return t, nil diff --git a/cli/dispatch/mode_local.go b/cli/dispatch/mode_local.go index 250705e..2ddd8d1 100644 --- a/cli/dispatch/mode_local.go +++ b/cli/dispatch/mode_local.go @@ -113,7 +113,7 @@ func resolveRepoRoots(ctx context.Context, repoPaths []string) ([]string, error) roots := make([]string, 0, len(repoPaths)) for _, repoPath := range repoPaths { - cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", "--show-toplevel") + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", "--show-toplevel") // #nosec G204 -- fixed "git" binary; repoPath is a CLI-provided repo path, not remote/untrusted input output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("resolve repo root for %q: %w", repoPath, err) @@ -219,6 +219,7 @@ func enumerateRepoCandidates(ctx context.Context, repoRoot string, opts Options, } func reachableCheckpointIDsInRange(ctx context.Context, repoRoot, revRange string, since time.Time) (map[string]struct{}, error) { + // #nosec G204 -- fixed "git" binary; repoRoot/revRange are internally resolved repo paths and refs, not remote input cmd := exec.CommandContext( ctx, "git", @@ -291,7 +292,7 @@ func isAncestorOfHEAD(ctx context.Context, repoRoot, ref string) bool { func runGitOutput(ctx context.Context, repoRoot string, args ...string) (string, bool) { fullArgs := append([]string{"-C", repoRoot}, args...) - out, err := exec.CommandContext(ctx, "git", fullArgs...).Output() + out, err := exec.CommandContext(ctx, "git", fullArgs...).Output() // #nosec G204 -- fixed "git" binary; args are internally constructed rev/ref names, not remote input if err != nil { return "", false } @@ -352,6 +353,7 @@ func localBranchNames(repo *git.Repository) ([]string, error) { } func loadCommitSubjectsByCheckpoint(ctx context.Context, repoRoot string, since time.Time) (map[string]string, error) { + // #nosec G204 -- fixed "git" binary; repoRoot is an internally resolved repo path, not remote input cmd := exec.CommandContext( ctx, "git", diff --git a/cli/doctor_bundle.go b/cli/doctor_bundle.go index 8da5904..7c182c4 100644 --- a/cli/doctor_bundle.go +++ b/cli/doctor_bundle.go @@ -79,6 +79,7 @@ that path is printed to stdout. Use --out to choose a specific path.`, } func writeDoctorBundle(ctx context.Context, repoRoot, outPath string, raw bool) error { + // #nosec G304 -- outPath is user-provided via --out flag, a standard trusted CLI argument out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) //nolint:gosec // user-provided output path is intentional if err != nil { return fmt.Errorf("create bundle: %w", err) @@ -191,6 +192,7 @@ func zipEntryName(parts ...string) string { } func addFileToZip(zw *zip.Writer, src, archivePath string, raw bool) error { + // #nosec G304 -- src comes from repo-internal walk (settings files, logs dir), not external input f, err := os.Open(src) //nolint:gosec // path comes from repo-internal walk if err != nil { if errors.Is(err, os.ErrNotExist) { diff --git a/cli/doctor_logs.go b/cli/doctor_logs.go index 9f6d330..7e13e42 100644 --- a/cli/doctor_logs.go +++ b/cli/doctor_logs.go @@ -53,6 +53,7 @@ Use --follow to stream new lines as they are written (Ctrl+C to exit).`, } func printTail(w io.Writer, path string, n int) error { + // #nosec G304 -- path is .trace/logs/trace.log under repo root, not external input f, err := os.Open(path) //nolint:gosec // path is .trace/logs/trace.log under repo root if err != nil { return fmt.Errorf("open log: %w", err) @@ -112,6 +113,7 @@ func readLastNLines(r io.Reader, n int) ([]string, error) { // followFile polls the log file for appended bytes. It exits cleanly when the // command's context is cancelled (Ctrl+C in a TTY). func followFile(ctx context.Context, w io.Writer, path string) error { + // #nosec G304 -- path is .trace/logs/trace.log under repo root, not external input f, err := os.Open(path) //nolint:gosec // path is .trace/logs/trace.log under repo root if err != nil { return fmt.Errorf("open log: %w", err) diff --git a/cli/explain_4.go b/cli/explain_4.go index 2ef862c..280d166 100644 --- a/cli/explain_4.go +++ b/cli/explain_4.go @@ -187,7 +187,7 @@ func buildPagerCmd(ctx context.Context) (*exec.Cmd, string) { } } - cmd := exec.CommandContext(ctx, pager) + cmd := exec.CommandContext(ctx, pager) // #nosec G204 -- pager comes from the PAGER env var (or a hardcoded default), a standard trusted user-configuration mechanism if pager == lessPagerName && pagerLookupEnv(pagerEnvVar) == "" && pagerLookupEnv(lessEnvVar) == "" { cmd.Env = upsertEnv(os.Environ(), lessEnvVar, "-R") } diff --git a/cli/git_operations.go b/cli/git_operations.go index 0b1ba82..7bcdbaa 100644 --- a/cli/git_operations.go +++ b/cli/git_operations.go @@ -276,7 +276,7 @@ func BranchExistsOnRemote(ctx context.Context, branchName string) (bool, error) lsCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - lsCmd := exec.CommandContext(lsCtx, "git", "ls-remote", "--heads", "origin", "refs/heads/"+branchName) + lsCmd := exec.CommandContext(lsCtx, "git", "ls-remote", "--heads", "origin", "refs/heads/"+branchName) // #nosec G204 -- branchName is passed as a single argument (not shell-interpreted); fixed git subcommand output, lsErr := lsCmd.Output() if lsErr != nil { // ls-remote failed (no network, no remote, etc.) — treat as not found diff --git a/cli/gitrepo/repository.go b/cli/gitrepo/repository.go index fd0013a..c0c5ebb 100644 --- a/cli/gitrepo/repository.go +++ b/cli/gitrepo/repository.go @@ -136,6 +136,7 @@ func resolveDotGitPath(repoRoot string) (string, error) { return gitPath, nil } + // #nosec G304 -- gitPath is resolved from the git worktree root, not external input content, err := os.ReadFile(gitPath) //nolint:gosec // gitPath is resolved from the git worktree root. if err != nil { return "", fmt.Errorf("read .git file: %w", err) @@ -155,6 +156,7 @@ func resolveDotGitPath(repoRoot string) (string, error) { } func resolveCommonGitPath(dotGitPath string) (string, error) { + // #nosec G304 -- dotGitPath is resolved from the git worktree root, not external input content, err := os.ReadFile(filepath.Join(dotGitPath, "commondir")) //nolint:gosec // dotGitPath is resolved from the git worktree root. if errors.Is(err, os.ErrNotExist) { return "", nil diff --git a/cli/head_checkpoint_flags.go b/cli/head_checkpoint_flags.go index 2906113..0378f12 100644 --- a/cli/head_checkpoint_flags.go +++ b/cli/head_checkpoint_flags.go @@ -24,7 +24,7 @@ func headHasInvestigateCheckpoint(ctx context.Context) (bool, string) { logging.Debug(ctx, "head investigate check: locate worktree root", slog.String("error", err.Error())) return false, "" } - execCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "log", "-1", "--format=%B") + execCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "log", "-1", "--format=%B") // #nosec G204 -- repoRoot is the resolved worktree root, not user input output, err := execCmd.Output() if err != nil { logging.Debug(ctx, "head investigate check: read HEAD commit message", slog.String("error", err.Error())) diff --git a/cli/help.go b/cli/help.go index a7da6d2..3fa2d1d 100644 --- a/cli/help.go +++ b/cli/help.go @@ -27,11 +27,13 @@ Simply type '` + rootCmd.Name() + ` help [command]' for full details.`, if err != nil || targetCmd == nil { targetCmd = rootCmd } + // #nosec G104 -- Help() only fails on write errors to stdout, non-actionable targetCmd.Help() //nolint:errcheck,gosec // Help() only fails on write errors to stdout }, } helpCmd.Flags().BoolVarP(&showTree, "tree", "t", false, "Show full command tree") + // #nosec G104 -- MarkHidden error is only returned for an undefined flag, which cannot happen here helpCmd.Flags().MarkHidden("tree") //nolint:errcheck,gosec // flag is defined above return helpCmd diff --git a/cli/internal/flock/flock_unix.go b/cli/internal/flock/flock_unix.go index 680153f..6cfc219 100644 --- a/cli/internal/flock/flock_unix.go +++ b/cli/internal/flock/flock_unix.go @@ -18,6 +18,7 @@ import ( // runs — flock state is held by the file descriptor, not by the inode on // disk — so the lockfile contents are immaterial. func Acquire(path string) (release func(), err error) { + // #nosec G304 -- caller is responsible for path validation; path is an internal lock file location, not external input f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // caller is responsible for path validation if err != nil { return nil, fmt.Errorf("open flock: %w", err) diff --git a/cli/investigate/cmd.go b/cli/investigate/cmd.go index 23fa932..0042f8c 100644 --- a/cli/investigate/cmd.go +++ b/cli/investigate/cmd.go @@ -472,6 +472,7 @@ func saveInvestigateConfig(ctx context.Context, cfg *settings.InvestigateConfig) } local := &settings.TraceSettings{} + // #nosec G304 -- localPath is derived from AbsPath for the internal settings.local.json location, not external input data, readErr := os.ReadFile(localPath) //nolint:gosec // path is from AbsPath if readErr != nil && !os.IsNotExist(readErr) { return fmt.Errorf("read local settings: %w", readErr) diff --git a/cli/investigate/cmd_2.go b/cli/investigate/cmd_2.go index 049eba0..710197a 100644 --- a/cli/investigate/cmd_2.go +++ b/cli/investigate/cmd_2.go @@ -67,6 +67,7 @@ func resolveTopicAndSeed(ctx context.Context, args []string, f runFlags, pickerP switch { case len(args) == 1: seedDoc = args[0] + // #nosec G304 -- seedDoc is a user-supplied positional CLI argument, standard trusted CLI input body, readErr := os.ReadFile(seedDoc) //nolint:gosec // path is user-supplied positional arg if readErr != nil { return "", "", nil, "", fmt.Errorf("read seed doc %s: %w", seedDoc, readErr) @@ -221,6 +222,7 @@ func writeRunManifest( findingsContent := "" captured := false if terminal && findingsDoc != "" { + // #nosec G304 -- path computed from runID + git common dir, not external input data, readErr := os.ReadFile(findingsDoc) //nolint:gosec // path computed from runID + git common dir if readErr != nil { logging.Debug(ctx, "investigate: read findings for manifest capture", diff --git a/cli/investigate/loop.go b/cli/investigate/loop.go index 14ec66c..9b9cd4a 100644 --- a/cli/investigate/loop.go +++ b/cli/investigate/loop.go @@ -544,6 +544,7 @@ func fileFingerprint(ctx context.Context, path string) string { slog.String("path", path), sErr(err)) return "" } + // #nosec G304 -- path is the findings doc, internally computed from runID + git common dir f, err := os.Open(path) //nolint:gosec // path is the findings doc the caller already validated if err != nil { // Fall back to size+mtime when content cannot be read; better than diff --git a/cli/investigate/manifest.go b/cli/investigate/manifest.go index c84eb0f..d61a7f5 100644 --- a/cli/investigate/manifest.go +++ b/cli/investigate/manifest.go @@ -163,6 +163,7 @@ func (s *LocalManifestStore) List(ctx context.Context) ([]LocalManifest, error) if !strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".tmp") { continue } + // #nosec G304 -- name from os.ReadDir(s.dir), not external input b, readErr := os.ReadFile(filepath.Join(s.dir, name)) //nolint:gosec // names from os.ReadDir(s.dir) if readErr != nil { return nil, fmt.Errorf("read manifest %s: %w", name, readErr) @@ -310,6 +311,7 @@ func (s *LocalManifestStore) Latest(ctx context.Context) (LocalManifest, bool, e if latest == "" { return LocalManifest{}, false, nil } + // #nosec G304 -- name from os.ReadDir(s.dir), not external input b, err := os.ReadFile(filepath.Join(s.dir, latest)) //nolint:gosec // name from os.ReadDir(s.dir) if err != nil { return LocalManifest{}, false, fmt.Errorf("read manifest %s: %w", latest, err) diff --git a/cli/jsonutil/write.go b/cli/jsonutil/write.go index a7f3d45..e4b6b1c 100644 --- a/cli/jsonutil/write.go +++ b/cli/jsonutil/write.go @@ -64,6 +64,7 @@ func WriteFileAtomic(filePath string, data []byte, perm fs.FileMode) error { // Directory fsync isn't supported on Windows, and on POSIX an error // after a successful rename would mislead callers who already have the // file in place. + // #nosec G304 -- dir is filepath.Dir of caller-supplied filePath, not user input if d, err := os.Open(dir); err == nil { //nolint:gosec // G304: dir is filepath.Dir of caller-supplied filePath, not user input _ = d.Sync() //nolint:errcheck // best-effort directory fsync; failure does not roll back the rename _ = d.Close() diff --git a/cli/lifecycle.go b/cli/lifecycle.go index 5fc0678..73d7b99 100644 --- a/cli/lifecycle.go +++ b/cli/lifecycle.go @@ -301,6 +301,7 @@ func handleLifecycleTurnStart(ctx context.Context, ag agent.Agent, event *agent. if sessionDirAbs, absErr := paths.AbsPath(ctx, sessionDir); absErr == nil { if mkErr := os.MkdirAll(sessionDirAbs, 0o750); mkErr == nil { promptPath := filepath.Join(sessionDirAbs, paths.PromptFileName) + // #nosec G304 -- promptPath is internal session metadata path, not external input existing, readErr := os.ReadFile(promptPath) //nolint:gosec // session metadata path var content string if readErr == nil && len(existing) > 0 { @@ -450,6 +451,7 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev // update session state after SaveStep (which may reinitialize state). var backfilledPrompt string promptPath := filepath.Join(sessionDirAbs, paths.PromptFileName) + // #nosec G304 -- promptPath is internal session metadata path, not external input existingPrompt, readPromptErr := os.ReadFile(promptPath) //nolint:gosec // file content is safe session metadata if readPromptErr != nil && !os.IsNotExist(readPromptErr) { logging.Warn(logCtx, "failed to read prompt.txt, skipping backfill", diff --git a/cli/lockfile/lockfile.go b/cli/lockfile/lockfile.go index 86da0f3..dfe7a37 100644 --- a/cli/lockfile/lockfile.go +++ b/cli/lockfile/lockfile.go @@ -27,6 +27,7 @@ type Lock struct { // diagnostic only — see ReadHolderPID. FD_CLOEXEC is set so // subprocesses don't inherit the lock FD. func Acquire(path string) (*Lock, error) { + // #nosec G304 -- lock path is supplied by the caller (trusted internal path) f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // G304: lock path is supplied by the caller (trusted) if err != nil { return nil, fmt.Errorf("open lock file %s: %w", path, err) @@ -104,6 +105,7 @@ func (l *Lock) Release() error { // file is empty/unreadable/contains garbage. Best-effort; for diagnostic // messages only. func ReadHolderPID(path string) int { + // #nosec G304 -- lock path is supplied by the caller (trusted internal path) data, err := os.ReadFile(path) //nolint:gosec // G304: lock path is supplied by the caller (trusted) if err != nil { return 0 diff --git a/cli/logging/logger.go b/cli/logging/logger.go index db3ece8..6b68758 100644 --- a/cli/logging/logger.go +++ b/cli/logging/logger.go @@ -125,6 +125,7 @@ func Init(ctx context.Context, sessionID string) error { } logFilePath := filepath.Join(logsPath, "trace.log") + // #nosec G304 -- logFilePath is a fixed filename under the repo's .trace/logs dir, not user-controlled f, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) //nolint:gosec // fixed filename, not user-controlled if err != nil { // Fall back to stderr diff --git a/cli/login.go b/cli/login.go index ce368e5..be5acb1 100644 --- a/cli/login.go +++ b/cli/login.go @@ -209,7 +209,7 @@ func openBrowser(ctx context.Context, browserURL string) error { return fmt.Errorf("unsupported platform %s", runtime.GOOS) } - cmd := exec.CommandContext(ctx, command, args...) + cmd := exec.CommandContext(ctx, command, args...) // #nosec G204 -- command is a fixed OS-specific opener chosen by switch on runtime.GOOS, args carry only the already-validated https/http browserURL if err := cmd.Start(); err != nil { return fmt.Errorf("start browser command %q: %w", command, err) } diff --git a/cli/objectsigner.go b/cli/objectsigner.go index 890ea6e..010c806 100644 --- a/cli/objectsigner.go +++ b/cli/objectsigner.go @@ -29,6 +29,7 @@ func RegisterObjectSigner() { } //nolint:errcheck,gosec // best-effort; if registration fails, commits are left unsigned + // #nosec G104 -- best-effort registration; if it fails, commits are left unsigned rather than the process aborting plugin.Register(plugin.ObjectSigner(), func() plugin.Signer { return signer }) diff --git a/cli/paths/transcript.go b/cli/paths/transcript.go index 51a224d..e84ce50 100644 --- a/cli/paths/transcript.go +++ b/cli/paths/transcript.go @@ -12,7 +12,7 @@ import ( // and extracts the timestamp field. Returns zero time if file doesn't exist // or no valid timestamp is found. func GetLastTimestampFromFile(path string) time.Time { - file, err := os.Open(path) //nolint:gosec // path is from controlled session directory + file, err := os.Open(path) // #nosec G304 -- path is from controlled session directory, not external input if err != nil { return time.Time{} } diff --git a/cli/paths/worktree.go b/cli/paths/worktree.go index 6e0f3c3..d0b68b9 100644 --- a/cli/paths/worktree.go +++ b/cli/paths/worktree.go @@ -25,7 +25,7 @@ func GetWorktreeID(worktreePath string) (string, error) { } // Linked worktree has .git as a file with content: "gitdir: /path/to/.git/worktrees/" - content, err := os.ReadFile(gitPath) //nolint:gosec // gitPath is constructed from worktreePath + ".git" + content, err := os.ReadFile(gitPath) // #nosec G304 -- gitPath is constructed from worktreePath + ".git", not external input if err != nil { return "", fmt.Errorf("failed to read .git file: %w", err) } diff --git a/cli/plugin_store.go b/cli/plugin_store.go index 9d97be1..6fa7389 100644 --- a/cli/plugin_store.go +++ b/cli/plugin_store.go @@ -467,6 +467,7 @@ func copyFileStreaming(src, dest string, srcInfo os.FileInfo) error { if mode == 0 { mode = 0o755 } + // #nosec G304 -- src is the user-provided plugin executable; reading it is the point in, err := os.Open(src) //nolint:gosec // src is the user-provided plugin executable; reading it is the point if err != nil { return fmt.Errorf("open source for copy fallback: %w", err) @@ -476,6 +477,7 @@ func copyFileStreaming(src, dest string, srcInfo os.FileInfo) error { // G304: dest is always inside the managed bin dir. The basename comes // from a validated plugin name (validatePluginName ran upstream), and // the parent dir comes from EnsurePluginBinDir. + // #nosec G304 -- dest is constrained to the managed bin dir with a validated plugin name out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) //nolint:gosec // dest is constrained to the managed bin dir if err != nil { return fmt.Errorf("open destination for copy fallback: %w", err) diff --git a/cli/review/manifest.go b/cli/review/manifest.go index 0d3409e..c19f7f6 100644 --- a/cli/review/manifest.go +++ b/cli/review/manifest.go @@ -225,7 +225,7 @@ func loadLocalReviewManifests(ctx context.Context, worktreeRoot string) ([]Local if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } - b, readErr := os.ReadFile(filepath.Join(dir, entry.Name())) //nolint:gosec // entry names come directly from os.ReadDir(dir). + b, readErr := os.ReadFile(filepath.Join(dir, entry.Name())) // #nosec G304 -- entry names come directly from os.ReadDir(dir), not external input if readErr != nil { return nil, fmt.Errorf("read review manifest %s: %w", entry.Name(), readErr) } diff --git a/cli/review/marker_fallback.go b/cli/review/marker_fallback.go index eac6c7a..0c178ac 100644 --- a/cli/review/marker_fallback.go +++ b/cli/review/marker_fallback.go @@ -90,7 +90,7 @@ func ReadPendingReviewMarker(ctx context.Context) (PendingReviewMarker, bool, er if err != nil { return PendingReviewMarker{}, false, err } - data, err := os.ReadFile(path) //nolint:gosec // path derived from git dir + data, err := os.ReadFile(path) // #nosec G304 -- path derived from git dir, not external input if errors.Is(err, os.ErrNotExist) { return PendingReviewMarker{}, false, nil } diff --git a/cli/review_context.go b/cli/review_context.go index b57980b..9bacc74 100644 --- a/cli/review_context.go +++ b/cli/review_context.go @@ -259,7 +259,7 @@ func reviewContextCommitMessages(ctx context.Context, repoRoot string, scopeBase func reviewContextGitRecords(ctx context.Context, repoRoot string, args ...string) ([]string, error) { full := append([]string{"-C", repoRoot}, args...) - output, err := exec.CommandContext(ctx, "git", full...).Output() + output, err := exec.CommandContext(ctx, "git", full...).Output() // #nosec G204 -- args are fixed git subcommand flags plus repoRoot/ref values from internal callers, not raw user input if err != nil { return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } diff --git a/cli/review_helpers.go b/cli/review_helpers.go index a14daa1..9b9e138 100644 --- a/cli/review_helpers.go +++ b/cli/review_helpers.go @@ -44,7 +44,7 @@ func headHasReviewCheckpoint(ctx context.Context) (bool, string) { logging.Debug(ctx, "head review check: locate worktree root", slog.String("error", err.Error())) return false, "" } - execCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "log", "-1", "--format=%B") + execCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "log", "-1", "--format=%B") // #nosec G204 -- repoRoot is the resolved worktree root, not user input output, err := execCmd.Output() if err != nil { logging.Debug(ctx, "head review check: read HEAD commit message", slog.String("error", err.Error())) diff --git a/cli/search_cmd.go b/cli/search_cmd.go index da1a0fa..0e70dfd 100644 --- a/cli/search_cmd.go +++ b/cli/search_cmd.go @@ -192,9 +192,11 @@ branch:, repo:, and repo:* to search all accessible repos.`, cmd.Flags().StringVar(&branchFlag, "branch", "", "Filter by branch name") cmd.Flags().StringVar(&repoFlag, "repo", "", "Filter by repository (owner/name or *)") + // #nosec G104 -- only fails if the flag is not defined; defined directly above cmd.RegisterFlagCompletionFunc("date", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { //nolint:errcheck,gosec // only fails if the flag isn't defined; defined directly above return []string{"week", "month"}, cobra.ShellCompDirectiveNoFileComp }) + // #nosec G104 -- only fails if the flag is not defined; defined directly above cmd.RegisterFlagCompletionFunc("repo", completeRepoFlag) //nolint:errcheck,gosec // only fails if the flag isn't defined; defined directly above return cmd diff --git a/cli/session_share.go b/cli/session_share.go index abe8f79..4f91827 100644 --- a/cli/session_share.go +++ b/cli/session_share.go @@ -247,6 +247,7 @@ func runSessionImport(ctx context.Context, w, errW io.Writer, filePath string) e } // Read the export file + // #nosec G304 -- filePath is a user-provided CLI argument, standard trusted CLI input data, err := os.ReadFile(filePath) //nolint:gosec // user-specified file path if err != nil { return fmt.Errorf("failed to read import file: %w", err) diff --git a/cli/settings/settings.go b/cli/settings/settings.go index 18321e0..0f80434 100644 --- a/cli/settings/settings.go +++ b/cli/settings/settings.go @@ -420,7 +420,7 @@ func loadMergedSettings(settingsFileAbs, localSettingsFileAbs string) (*TraceSet } // Apply local overrides if they exist - localData, err := os.ReadFile(localSettingsFileAbs) //nolint:gosec // path is from AbsPath or constant + localData, err := os.ReadFile(localSettingsFileAbs) // #nosec G304 -- path is from AbsPath or constant, not external input if err != nil { if !os.IsNotExist(err) { return nil, fmt.Errorf("reading local settings file: %w", err) @@ -469,7 +469,7 @@ func loadFromFile(filePath string) (*TraceSettings, error) { Enabled: true, // Default to enabled } - data, err := os.ReadFile(filePath) //nolint:gosec // path is from caller + data, err := os.ReadFile(filePath) // #nosec G304 -- path is from AbsPath or constant, not external input if err != nil { if os.IsNotExist(err) { return settings, nil diff --git a/cli/settings/settings_2.go b/cli/settings/settings_2.go index 5b50dde..8ee9fd0 100644 --- a/cli/settings/settings_2.go +++ b/cli/settings/settings_2.go @@ -406,8 +406,7 @@ func saveToFile(ctx context.Context, settings *TraceSettings, filePath string) e return fmt.Errorf("marshaling settings: %w", err) } - //nolint:gosec // G306: settings file is config, not secrets; 0o644 is appropriate - if err := os.WriteFile(filePathAbs, data, 0o644); err != nil { + if err := os.WriteFile(filePathAbs, data, 0o600); err != nil { return fmt.Errorf("writing settings file: %w", err) } return nil @@ -434,7 +433,7 @@ func LoadProjectRaw(ctx context.Context) (path string, raw map[string]json.RawMe if err != nil { path = TraceSettingsFile } - data, readErr := os.ReadFile(path) //nolint:gosec + data, readErr := os.ReadFile(path) // #nosec G304 -- path is project settings file derived from AbsPath/constant, not external input if readErr != nil { if os.IsNotExist(readErr) { return path, map[string]json.RawMessage{}, false, nil @@ -454,7 +453,7 @@ func LoadLocalRaw(ctx context.Context) (path string, raw map[string]json.RawMess if err != nil { path = TraceSettingsLocalFile } - data, readErr := os.ReadFile(path) //nolint:gosec + data, readErr := os.ReadFile(path) // #nosec G304 -- path is local settings file derived from AbsPath/constant, not external input if readErr != nil { if os.IsNotExist(readErr) { return path, map[string]json.RawMessage{}, false, nil @@ -509,7 +508,7 @@ func SaveClonePreferences(ctx context.Context, prefs *ClonePreferences) error { func loadClonePreferencesFromFile(filePath string) (*ClonePreferences, error) { prefs := &ClonePreferences{} - data, err := os.ReadFile(filePath) //nolint:gosec + data, err := os.ReadFile(filePath) // #nosec G304 -- path is clone preferences file derived from git common dir, not external input if err != nil { if os.IsNotExist(err) { return prefs, nil diff --git a/cli/setup.go b/cli/setup.go index f83af09..44b34e9 100644 --- a/cli/setup.go +++ b/cli/setup.go @@ -682,6 +682,7 @@ Examples: } cmd.Flags().BoolVar(&opts.LocalDev, flagLocalDev, false, "Use go run instead of trace binary for hooks") + // #nosec G104 -- only fails if the flag is not defined; defined directly above cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json") cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists") diff --git a/cli/setup_2.go b/cli/setup_2.go index 36ec293..77a19e7 100644 --- a/cli/setup_2.go +++ b/cli/setup_2.go @@ -153,8 +153,10 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, } cmd.Flags().BoolVar(&opts.LocalDev, flagLocalDev, false, "Use go run instead of trace binary for hooks") + // #nosec G104 -- only fails if the flag is not defined; defined directly above cmd.Flags().MarkHidden(flagLocalDev) //nolint:errcheck,gosec // flag is defined above cmd.Flags().BoolVar(&ignoreUntracked, "ignore-untracked", false, "Commit all new files without tracking pre-existing untracked files") + // #nosec G104 -- only fails if the flag is not defined; defined directly above cmd.Flags().MarkHidden("ignore-untracked") //nolint:errcheck,gosec // flag is defined above cmd.Flags().BoolVar(&opts.UseLocalSettings, "local", false, "Write settings to .trace/settings.local.json instead of .trace/settings.json") cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .trace/settings.json even if it already exists") diff --git a/cli/setup_3.go b/cli/setup_3.go index d8508ff..bf4c9a2 100644 --- a/cli/setup_3.go +++ b/cli/setup_3.go @@ -175,8 +175,7 @@ func setupTraceDirectory(ctx context.Context) (bool, error) { //nolint:unparam / } // Create .trace directory - //nolint:gosec // G301: Project directory needs standard permissions for git - if err := os.MkdirAll(traceDirAbs, 0o755); err != nil { + if err := os.MkdirAll(traceDirAbs, 0o750); err != nil { return false, fmt.Errorf("failed to create .trace directory: %w", err) } @@ -295,6 +294,7 @@ func promptShellCompletion(w io.Writer) error { // isCompletionConfigured checks if shell completion is already in the rc file. func isCompletionConfigured(rcFile string) bool { //nolint:gosec // G304: rcFile is constructed from home dir + known filename, not user input + // #nosec G304 -- rcFile is constructed from home dir + known filename, not user input content, err := os.ReadFile(rcFile) if err != nil { return false // File doesn't exist or can't read, treat as not configured @@ -308,6 +308,7 @@ func appendShellCompletion(rcFile, completionLine string) error { return fmt.Errorf("creating directory: %w", err) } //nolint:gosec // G302: Shell rc files need 0644 for user readability + // #nosec G302,G304 -- shell rc files (.zshrc/.bashrc/config.fish) are intentionally user-readable/editable at the standard 0644 mode; rcFile is derived from home dir + known filename, not external input f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("opening file: %w", err) diff --git a/cli/setup_subagents.go b/cli/setup_subagents.go index f5313b8..1537b36 100644 --- a/cli/setup_subagents.go +++ b/cli/setup_subagents.go @@ -51,6 +51,7 @@ func scaffoldSearchSubagent(ctx context.Context, ag agent.Agent) (searchSubagent } func writeManagedSearchSubagent(targetPath, relPath string, content []byte) (searchSubagentScaffoldResult, error) { + // #nosec G304 -- targetPath is derived from repo root + a fixed relative path, not external input existingData, err := os.ReadFile(targetPath) //nolint:gosec // target path is derived from repo root + fixed relative path if err == nil { if !bytes.Contains(existingData, []byte(traceManagedSearchSubagentMarker)) { diff --git a/cli/state.go b/cli/state.go index 7a95c47..dde11a1 100644 --- a/cli/state.go +++ b/cli/state.go @@ -356,7 +356,8 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri // File is in HEAD — compare content with working tree absPath := filepath.Join(repoRoot, relPath) - workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source + // #nosec G304 -- path joined from repo root and a git index entry, not external input + workingContent, err := os.ReadFile(absPath) if err != nil { // Can't read working tree file (deleted?) — keep it result = append(result, relPath) diff --git a/cli/status.go b/cli/status.go index 7f24eb2..a069a82 100644 --- a/cli/status.go +++ b/cli/status.go @@ -401,7 +401,8 @@ func resolveWorktreeBranch(ctx context.Context, worktreePath string) string { headPath = filepath.Join(gitPath, "HEAD") } else { // Worktree: .git is a file containing "gitdir: " - data, err := os.ReadFile(gitPath) //nolint:gosec // path derived from known worktree dir + // #nosec G304 -- .git file path derived from a known worktree dir, not external input + data, err := os.ReadFile(gitPath) if err != nil { return "" } @@ -416,7 +417,8 @@ func resolveWorktreeBranch(ctx context.Context, worktreePath string) string { headPath = filepath.Join(gitdirPath, "HEAD") } - data, err := os.ReadFile(headPath) //nolint:gosec // path constructed from .git/HEAD + // #nosec G304 -- path constructed internally to point at .git/HEAD, not external input + data, err := os.ReadFile(headPath) if err != nil { return "" } diff --git a/cli/strategy/common_2.go b/cli/strategy/common_2.go index 92e021e..38ea847 100644 --- a/cli/strategy/common_2.go +++ b/cli/strategy/common_2.go @@ -185,6 +185,7 @@ func GetMainRepoRoot(ctx context.Context) (string, error) { // Worktree .git file contains: "gitdir: /path/to/main/.git/worktrees/" gitFilePath := filepath.Join(repoRoot, gitDir) + // #nosec G304 -- gitFilePath is constructed from repo root, not user input content, err := os.ReadFile(gitFilePath) //nolint:gosec // G304: gitFilePath is constructed from repo root, not user input if err != nil { return "", fmt.Errorf("failed to read .git file: %w", err) @@ -206,7 +207,7 @@ func GetMainRepoRoot(ctx context.Context) (string, error) { // In a worktree, this is the main repo's .git/ (not .git/worktrees//) // Uses git rev-parse --git-common-dir for reliable handling of worktrees. func GetGitCommonDir(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir") + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir") // #nosec G204 -- fixed "git" binary with fixed args, no variable input cmd.Dir = "." output, err := cmd.Output() if err != nil { @@ -235,6 +236,7 @@ func EnsureTraceGitignore(ctx context.Context) error { // Read existing content var content string + // #nosec G304 -- path is from AbsPath or a constant, not external input if data, err := os.ReadFile(gitignoreAbs); err == nil { //nolint:gosec // path is from AbsPath or constant content = string(data) } @@ -272,6 +274,7 @@ func EnsureTraceGitignore(ctx context.Context) error { } content += sb.String() + // #nosec G306 -- gitignoreAbs is from AbsPath or a constant; .gitignore is intentionally a normal readable text file if err := os.WriteFile(gitignoreAbs, []byte(content), 0o644); err != nil { //nolint:gosec // path is from AbsPath or constant return fmt.Errorf("failed to write gitignore: %w", err) } @@ -351,6 +354,7 @@ func checkCanRewindWithWarning(ctx context.Context) (bool, string, error) { change.status = "added" // New file - count all lines as added absPath := filepath.Join(repoRoot, file) + // #nosec G304 -- absPath is repo root + relative path from git status, not external input if content, err := os.ReadFile(absPath); err == nil { //nolint:gosec // absPath is repo root + relative path from git status change.added = countLines(content) } @@ -372,6 +376,7 @@ func checkCanRewindWithWarning(ctx context.Context) (bool, string, error) { } } absPath := filepath.Join(repoRoot, file) + // #nosec G304 -- absPath is repo root + relative path from git status, not external input if content, err := os.ReadFile(absPath); err == nil { //nolint:gosec // absPath is repo root + relative path from git status workContent = content } @@ -605,7 +610,7 @@ func DeleteBranchCLI(ctx context.Context, branchName string) error { // git show-ref exits 1 for "not found" and 128+ for fatal errors (corrupt // repo, permissions, not a git directory). Only map exit code 1 to // ErrBranchNotFound; propagate other failures as-is. - check := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) + check := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) // #nosec G204 -- fixed "git" binary; branchName is an internally managed shadow/checkpoint branch name, not remote input if err := check.Run(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { @@ -644,7 +649,7 @@ func DeleteRefCLI(ctx context.Context, refName string, expectedOID string) error if expectedOID != "" { args = append(args, expectedOID) } - cmd := exec.CommandContext(ctx, "git", args...) + cmd := exec.CommandContext(ctx, "git", args...) // #nosec G204 -- fixed "git" binary; refName/expectedOID are internally resolved ref name and object hash, not remote input if output, err := cmd.CombinedOutput(); err != nil { return classifyDeleteRefFailure(ctx, refName, expectedOID, output, err) } @@ -690,7 +695,7 @@ func refStateCLI(ctx context.Context, refName string) (exists bool, oid string, // branchExistsCLI checks if a branch exists using git CLI. // Returns nil if the branch exists, or an error if it does not. func branchExistsCLI(ctx context.Context, branchName string) error { - cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) + cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) // #nosec G204 -- fixed "git" binary; branchName is an internally managed shadow/checkpoint branch name, not remote input if err := cmd.Run(); err != nil { return fmt.Errorf("branch %s not found: %w", branchName, err) } @@ -703,7 +708,7 @@ func branchExistsCLI(ctx context.Context, branchName string) error { // Returns the short commit ID (7 chars) on success for display purposes. func HardResetWithProtection(ctx context.Context, commitHash plumbing.Hash) (shortID string, err error) { hashStr := commitHash.String() - cmd := exec.CommandContext(ctx, "git", "reset", "--hard", hashStr) + cmd := exec.CommandContext(ctx, "git", "reset", "--hard", hashStr) // #nosec G204 -- fixed "git" binary; hashStr is an internally resolved commit hash, not remote input if output, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("reset failed: %s: %w", strings.TrimSpace(string(output)), err) } diff --git a/cli/strategy/content_overlap.go b/cli/strategy/content_overlap.go index 7ce4cef..ef561e5 100644 --- a/cli/strategy/content_overlap.go +++ b/cli/strategy/content_overlap.go @@ -559,6 +559,7 @@ func filesWithRemainingAgentChanges( // Returns true if the working tree is clean for this file (no remaining changes). func workingTreeMatchesCommit(worktreeRoot, filePath string, commitHash plumbing.Hash) bool { absPath := filepath.Join(worktreeRoot, filePath) + // #nosec G304 -- filePath is from git status, not external input diskContent, err := os.ReadFile(absPath) //nolint:gosec // filePath is from git status, not user input if err != nil { return false diff --git a/cli/strategy/hooks.go b/cli/strategy/hooks.go index dc686ed..4dbd823 100644 --- a/cli/strategy/hooks.go +++ b/cli/strategy/hooks.go @@ -155,6 +155,7 @@ func IsGitHookInstalledInDir(ctx context.Context, repoDir string) bool { func isGitHookInstalledInHooksDir(hooksDir string) bool { for _, hook := range gitHookNames { hookPath := filepath.Join(hooksDir, hook) + // #nosec G304 -- hookPath is constructed from constants (hooksDir + known hook name), not external input data, err := os.ReadFile(hookPath) //nolint:gosec // Path is constructed from constants if err != nil { return false @@ -224,6 +225,7 @@ func InstallGitHook(ctx context.Context, silent, localDev, absolutePath bool) (i return 0, err } + // #nosec G301 -- git hooks directory must be traversable/executable (standard git hooks dir mode), not private data if err := os.MkdirAll(hooksDir, 0o755); err != nil { //nolint:gosec // Git hooks require executable permissions return 0, fmt.Errorf("failed to create hooks directory: %w", err) } @@ -241,6 +243,7 @@ func InstallGitHook(ctx context.Context, silent, localDev, absolutePath bool) (i backupExists := fileExists(backupPath) // Back up existing non-Trace hooks + // #nosec G304 -- hookPath is constructed from constants (hooksDir + known hook name), not external input existing, existingErr := os.ReadFile(hookPath) //nolint:gosec // path is controlled if existingErr == nil && !strings.Contains(string(existing), traceHookMarker) { if !backupExists { @@ -281,12 +284,14 @@ func InstallGitHook(ctx context.Context, silent, localDev, absolutePath bool) (i // Returns true if the file was written, false if it already had the same content. func writeHookFile(path, content string) (bool, error) { // Check if file already exists with same content + // #nosec G304 -- path is constructed from constants (hooksDir + known hook name), not external input existing, err := os.ReadFile(path) //nolint:gosec // path is controlled if err == nil && string(existing) == content { return false, nil // Already up to date } // Git hooks must be executable (0o755) + // #nosec G306 -- git hooks must be executable (0755) to run as shell scripts, not private data if err := os.WriteFile(path, []byte(content), 0o755); err != nil { //nolint:gosec // Git hooks require executable permissions return false, fmt.Errorf("failed to write hook file %s: %w", path, err) } @@ -310,6 +315,7 @@ func RemoveGitHook(ctx context.Context) (int, error) { backupPath := hookPath + backupSuffix // Remove the hook if it contains our marker + // #nosec G304 -- hookPath is constructed from constants (hooksDir + known hook name), not external input data, err := os.ReadFile(hookPath) //nolint:gosec // path is controlled hookIsOurs := err == nil && strings.Contains(string(data), traceHookMarker) hookExists := err == nil diff --git a/cli/strategy/manual_commit_condensation.go b/cli/strategy/manual_commit_condensation.go index 30dc252..a98ce56 100644 --- a/cli/strategy/manual_commit_condensation.go +++ b/cli/strategy/manual_commit_condensation.go @@ -122,6 +122,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re // Re-resolve transcript path before any reads — handles agents that relocate // transcripts mid-session (e.g., Cursor CLI flat → nested layout change). // Errors are ignored; downstream readers handle missing transcripts gracefully. + // #nosec G104 -- best-effort call; downstream readers handle missing/unresolved transcript files gracefully resolveTranscriptPath(state) //nolint:errcheck,gosec // best-effort; downstream readers handle missing files extractStart := time.Now() diff --git a/cli/strategy/manual_commit_condensation_2.go b/cli/strategy/manual_commit_condensation_2.go index a8ee538..3edb252 100644 --- a/cli/strategy/manual_commit_condensation_2.go +++ b/cli/strategy/manual_commit_condensation_2.go @@ -221,6 +221,7 @@ func (s *ManualCommitStrategy) extractSessionData(ctx context.Context, repo *git if isActive { prepareTranscriptIfNeeded(ctx, ag, liveTranscriptPath) } + // #nosec G304 -- liveTranscriptPath comes from session state, not external input if liveData, readErr := os.ReadFile(liveTranscriptPath); readErr == nil && len(liveData) > 0 { //nolint:gosec // path from session state fullTranscript = string(liveData) } @@ -284,6 +285,7 @@ func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(ctx context. return nil, resolveErr } + // #nosec G304 -- transcriptPath validated by resolveTranscriptPath liveData, err := os.ReadFile(transcriptPath) //nolint:gosec // path validated by resolveTranscriptPath if err != nil { return nil, fmt.Errorf("failed to read live transcript: %w", err) @@ -510,6 +512,7 @@ func readPromptsFromFilesystem(ctx context.Context, sessionID string) []string { if err != nil { return nil } + // #nosec G304 -- path constructed from internal session ID, not external input data, err := os.ReadFile(filepath.Join(sessionDirAbs, paths.PromptFileName)) //nolint:gosec // path from session ID if err != nil || len(data) == 0 { return nil diff --git a/cli/strategy/manual_commit_hooks.go b/cli/strategy/manual_commit_hooks.go index a1947ec..8d394c2 100644 --- a/cli/strategy/manual_commit_hooks.go +++ b/cli/strategy/manual_commit_hooks.go @@ -113,6 +113,7 @@ func saveCommitLinkingAlways(ctx context.Context) error { // Read existing file as raw JSON map to preserve all existing fields. // If the file doesn't exist, start with an empty map so we only write commit_linking. var raw map[string]json.RawMessage + // #nosec G304 -- localPath is from AbsPath (internal settings.local.json location), not external input data, readErr := os.ReadFile(localPath) //nolint:gosec // path is from AbsPath if readErr == nil { if err := json.Unmarshal(data, &raw); err != nil { @@ -136,8 +137,7 @@ func saveCommitLinkingAlways(ctx context.Context) error { if err := os.MkdirAll(filepath.Dir(localPath), 0o750); err != nil { return fmt.Errorf("creating settings directory: %w", err) } - //nolint:gosec // G306: settings file is config, not secrets; 0o644 is appropriate - if err := os.WriteFile(localPath, out, 0o644); err != nil { + if err := os.WriteFile(localPath, out, 0o600); err != nil { return fmt.Errorf("writing local settings: %w", err) } return nil @@ -148,6 +148,7 @@ func saveCommitLinkingAlways(ctx context.Context) error { // so git will abort the commit due to empty message. func (s *ManualCommitStrategy) CommitMsg(_ context.Context, commitMsgFile string) error { //nolint:unparam // error return is part of the hook contract; callers check it + // #nosec G304 -- commitMsgFile is the path git passes to the commit-msg hook, a trusted local git invocation, not external input content, err := os.ReadFile(commitMsgFile) //nolint:gosec // Path comes from git hook if err != nil { return nil //nolint:nilerr // Hook must be silent on failure @@ -437,6 +438,7 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi // Read current commit message _, readCommitMessageSpan := perf.Start(ctx, "read_commit_message") + // #nosec G304 -- commitMsgFile is the path git passes to the commit-msg hook, a trusted local git invocation, not external input content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by git hook if err != nil { readCommitMessageSpan.RecordError(err) @@ -560,6 +562,7 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi func (s *ManualCommitStrategy) handleAmendCommitMsg(ctx context.Context, commitMsgFile string) error { logCtx := logging.WithComponent(ctx, "checkpoint") // Read current commit message + // #nosec G304 -- commitMsgFile is the path git passes to the commit-msg hook, a trusted local git invocation, not external input content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by git hook if err != nil { return nil //nolint:nilerr // Hook must be silent on failure diff --git a/cli/strategy/manual_commit_hooks_2.go b/cli/strategy/manual_commit_hooks_2.go index 4f22a9a..adc7f69 100644 --- a/cli/strategy/manual_commit_hooks_2.go +++ b/cli/strategy/manual_commit_hooks_2.go @@ -36,9 +36,11 @@ func warnStaleEndedSessionsTo(ctx context.Context, count int, w io.Writer) { } } //nolint:errcheck,gosec // G104: Best-effort warning — fail-open if file ops fail + // #nosec G104 -- best-effort directory creation; fails open if file ops fail os.MkdirAll(warnDir, 0o750) //nolint:errcheck,gosec // G104: Best-effort sentinel file write - os.WriteFile(warnFile, []byte{}, 0o644) + // #nosec G104 -- best-effort sentinel file write; failure is non-fatal (rate-limit warning skipped) + os.WriteFile(warnFile, []byte{}, 0o600) fmt.Fprintf( w, "\ntrace: %d ended session(s) are accumulating and slowing down commits.\n"+ diff --git a/cli/strategy/manual_commit_hooks_3.go b/cli/strategy/manual_commit_hooks_3.go index cc76702..cc83d99 100644 --- a/cli/strategy/manual_commit_hooks_3.go +++ b/cli/strategy/manual_commit_hooks_3.go @@ -559,6 +559,7 @@ func (s *ManualCommitStrategy) addTrailerForAgentCommit(logCtx context.Context, return nil //nolint:nilerr // Hook must be silent on failure } + // #nosec G304 -- commitMsgFile is the path git passes to the commit-msg hook, a trusted local git invocation, not external input content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by git hook if err != nil { return nil //nolint:nilerr // Hook must be silent on failure diff --git a/cli/strategy/manual_commit_hooks_4.go b/cli/strategy/manual_commit_hooks_4.go index 068c92f..0fad1b3 100644 --- a/cli/strategy/manual_commit_hooks_4.go +++ b/cli/strategy/manual_commit_hooks_4.go @@ -124,6 +124,7 @@ func (s *ManualCommitStrategy) calculatePromptAttributionAtStart( // Always read from worktree to match checkpoint behavior fullPath := filepath.Join(worktreeRoot, filePath) var content string + // #nosec G304 -- fullPath is worktreeRoot + filePath from git worktree status, not external input if data, err := os.ReadFile(fullPath); err == nil { //nolint:gosec // filePath is from git worktree status // Use git's binary detection algorithm (matches getFileContent behavior). // Binary files are excluded from line-based attribution calculations. @@ -358,6 +359,7 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s return 1 // Count as error - all checkpoints will be skipped } + // #nosec G304 -- transcriptPath validated by resolveTranscriptPath fullTranscript, err := os.ReadFile(transcriptPath) //nolint:gosec // path validated by resolveTranscriptPath if err != nil || len(fullTranscript) == 0 { msg := "finalize: empty transcript, skipping" diff --git a/cli/strategy/manual_commit_rewind.go b/cli/strategy/manual_commit_rewind.go index ac3b313..81026ea 100644 --- a/cli/strategy/manual_commit_rewind.go +++ b/cli/strategy/manual_commit_rewind.go @@ -432,6 +432,7 @@ func (s *ManualCommitStrategy) Rewind(ctx context.Context, w, errW io.Writer, po dir := filepath.Dir(absPath) if dir != "." { //nolint:gosec // G301: Need 0o755 for user directories during rewind + // #nosec G301 -- restoring the user's project working tree during rewind; directories must match standard project directory permissions (0o755), not private data if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create directory %s: %w", dir, err) } diff --git a/cli/strategy/push_common.go b/cli/strategy/push_common.go index 17f8e44..2e718fb 100644 --- a/cli/strategy/push_common.go +++ b/cli/strategy/push_common.go @@ -538,7 +538,7 @@ func collectCommitsSince(ctx context.Context, repo *git.Repository, repoPath str // cherryPickOnto computes each commit's delta against its first parent, so // replaying merge commits would incorrectly re-apply changes that arrived via // non-first-parent history. Limit the replay set to non-merge commits. - cmd := exec.CommandContext(ctx, "git", "rev-list", "--reverse", "--topo-order", "--no-merges", exclude.String()+".."+tip.String()) + cmd := exec.CommandContext(ctx, "git", "rev-list", "--reverse", "--topo-order", "--no-merges", exclude.String()+".."+tip.String()) // #nosec G204 -- fixed "git" binary; exclude/tip are internally resolved commit hashes, not remote input cmd.Dir = repoPath output, err := cmd.Output() if err != nil { diff --git a/cli/strategy/session_state.go b/cli/strategy/session_state.go index 82d8d35..da7ae56 100644 --- a/cli/strategy/session_state.go +++ b/cli/strategy/session_state.go @@ -266,6 +266,7 @@ func LoadModelHint(ctx context.Context, sessionID string) string { } hintPath := filepath.Join(stateDir, sessionID+".model") + // #nosec G304 -- sessionID is validated above data, err := os.ReadFile(hintPath) //nolint:gosec // sessionID is validated above if err != nil { if !os.IsNotExist(err) { @@ -318,6 +319,7 @@ func StoreAgentTypeHint(ctx context.Context, sessionID string, agentType types.A } hintFile := filepath.Join(stateDir, sessionID+".agent") + // #nosec G304 -- hintFile path is built from validated sessionID f, oErr := os.OpenFile(hintFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // hintFile path is built from validated sessionID if oErr != nil { if errors.Is(oErr, os.ErrExist) { @@ -348,6 +350,7 @@ func ClaimSessionStartBanner(ctx context.Context, sessionID string) (claimed boo } markerFile := filepath.Join(stateDir, sessionID+".banner") + // #nosec G304 -- markerFile path is built from validated sessionID f, oErr := os.OpenFile(markerFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // markerFile path is built from validated sessionID if oErr != nil { if errors.Is(oErr, os.ErrExist) { @@ -375,6 +378,7 @@ func LoadAgentTypeHint(ctx context.Context, sessionID string) types.AgentType { } hintPath := filepath.Join(stateDir, sessionID+".agent") + // #nosec G304 -- sessionID is validated above data, err := os.ReadFile(hintPath) //nolint:gosec // sessionID is validated above if err != nil { if !os.IsNotExist(err) { diff --git a/cli/telemetry/detached_unix.go b/cli/telemetry/detached_unix.go index b5f724b..f3bb81d 100644 --- a/cli/telemetry/detached_unix.go +++ b/cli/telemetry/detached_unix.go @@ -19,7 +19,7 @@ func spawnDetachedAnalytics(payloadJSON string) { return } - cmd := exec.CommandContext(context.Background(), executable, "__send_analytics", payloadJSON) + cmd := exec.CommandContext(context.Background(), executable, "__send_analytics", payloadJSON) // #nosec G204 -- executable is os.Executable() (this binary), args are fixed/internal payload // Detach from parent process group so subprocess survives parent exit cmd.SysProcAttr = &syscall.SysProcAttr{ diff --git a/cli/testutil/testutil.go b/cli/testutil/testutil.go index 1da0f5b..5a626f9 100644 --- a/cli/testutil/testutil.go +++ b/cli/testutil/testutil.go @@ -70,13 +70,11 @@ func WriteFile(t *testing.T, repoDir, path, content string) { // Create parent directories dir := filepath.Dir(fullPath) - //nolint:gosec // test code, permissions are intentionally standard - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatalf("failed to create directory %s: %v", dir, err) } - //nolint:gosec // test code, permissions are intentionally standard - if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + if err := os.WriteFile(fullPath, []byte(content), 0o600); err != nil { t.Fatalf("failed to write file %s: %v", path, err) } } @@ -86,7 +84,7 @@ func ReadFile(t *testing.T, repoDir, path string) string { t.Helper() fullPath := filepath.Join(repoDir, path) - //nolint:gosec // test code, path is from test setup + // #nosec G304 -- test code, path is from test setup (test's own tempDir), not external input data, err := os.ReadFile(fullPath) if err != nil { t.Fatalf("failed to read file %s: %v", path, err) @@ -99,7 +97,7 @@ func TryReadFile(t *testing.T, repoDir, path string) string { t.Helper() fullPath := filepath.Join(repoDir, path) - //nolint:gosec // test code, path is from test setup + // #nosec G304 -- test code, path is from test setup (test's own tempDir), not external input data, err := os.ReadFile(fullPath) if err != nil { return "" @@ -228,8 +226,8 @@ func BranchExists(t *testing.T, repoDir, branchName string) bool { } found := false - //nolint:errcheck,gosec // ForEach callback doesn't return errors we need to handle - refs.ForEach(func(ref *plumbing.Reference) error { + // Best-effort iteration in a test helper; the callback never returns an error. + _ = refs.ForEach(func(ref *plumbing.Reference) error { if ref.Name().Short() == branchName { found = true } @@ -279,8 +277,8 @@ func GetLatestCheckpointIDFromHistory(t *testing.T, repoDir string) (string, err } var checkpointID string - //nolint:errcheck,gosec // ForEach callback returns error to stop iteration - commitIter.ForEach(func(c *object.Commit) error { + // Best-effort iteration in a test helper; the callback only returns an error to stop early. + _ = commitIter.ForEach(func(c *object.Commit) error { // Look for Trace-Checkpoint trailer for line := range strings.SplitSeq(c.Message, "\n") { line = strings.TrimSpace(line) diff --git a/cli/trace.go b/cli/trace.go index f4ab577..ce56f68 100644 --- a/cli/trace.go +++ b/cli/trace.go @@ -205,7 +205,8 @@ func traceStepChildIndex(parentName, childName string) (int, bool) { // ordered newest first. If hookFilter is non-empty, only entries with a matching // Op field are included. func collectTraceEntries(logFile string, last int, hookFilter string) ([]traceEntry, error) { - f, err := os.Open(logFile) //nolint:gosec // logFile is a CLI-resolved path, not user-supplied input + // #nosec G304 -- logFile is a CLI-resolved log path, not external input + f, err := os.Open(logFile) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil diff --git a/cli/trail/store.go b/cli/trail/store.go index 49a53d3..4642be7 100644 --- a/cli/trail/store.go +++ b/cli/trail/store.go @@ -181,8 +181,8 @@ func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { if blobErr == nil { discussionReader, readerErr := discussionBlob.Reader() if readerErr == nil { - //nolint:errcheck,gosec // best-effort decode of optional discussion - json.NewDecoder(discussionReader).Decode(&discussion) + // Best-effort decode of optional discussion data. + _ = json.NewDecoder(discussionReader).Decode(&discussion) _ = discussionReader.Close() } } @@ -196,8 +196,8 @@ func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { if blobErr == nil { checkpointsReader, readerErr := checkpointsBlob.Reader() if readerErr == nil { - //nolint:errcheck,gosec // best-effort decode of optional checkpoints - json.NewDecoder(checkpointsReader).Decode(&checkpoints) + // Best-effort decode of optional checkpoints data. + _ = json.NewDecoder(checkpointsReader).Decode(&checkpoints) _ = checkpointsReader.Close() } } diff --git a/cli/transcript.go b/cli/transcript.go index 24f4fc7..bb26cf8 100644 --- a/cli/transcript.go +++ b/cli/transcript.go @@ -173,12 +173,12 @@ func writeTranscript(path string, lines []transcriptLine) error { raw := buf.Bytes() if len(raw) >= compressGzipThreshold { - if err := os.WriteFile(path+".gz", gzipCompress(raw), 0o644); err != nil { //nolint:gosec // Writing to controlled git metadata path + if err := os.WriteFile(path+".gz", gzipCompress(raw), 0o600); err != nil { return fmt.Errorf("writing compressed transcript: %w", err) } return nil } - if err := os.WriteFile(path, raw, 0o644); err != nil { //nolint:gosec // Writing to controlled git metadata path + if err := os.WriteFile(path, raw, 0o600); err != nil { return fmt.Errorf("writing transcript: %w", err) } return nil @@ -212,7 +212,8 @@ func gzipDecompress(data []byte) ([]byte, error) { func readTranscriptBytes(path string) ([]byte, bool, error) { // Try compressed path first. gzPath := path + ".gz" - data, err := os.ReadFile(gzPath) //nolint:gosec // Reading from controlled transcript path + // #nosec G304 -- internally constructed transcript path under git metadata, not external input + data, err := os.ReadFile(gzPath) if err == nil { decompressed, dErr := gzipDecompress(data) if dErr != nil { @@ -222,7 +223,8 @@ func readTranscriptBytes(path string) ([]byte, bool, error) { } // Fall back to uncompressed path. - data, err = os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path + // #nosec G304 -- internally constructed transcript path under git metadata, not external input + data, err = os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, false, nil diff --git a/cli/transcript/parse.go b/cli/transcript/parse.go index 0d8d102..ac0562c 100644 --- a/cli/transcript/parse.go +++ b/cli/transcript/parse.go @@ -103,6 +103,7 @@ func ParseFromFileAtLine(path string, startLine int) ([]Line, error) { // and a cleanup function that must be called when done. func openTranscriptReader(path string) (*bufio.Reader, func(), error) { // Try the exact path first. + // #nosec G304 -- path is an internally resolved transcript file path, not remote/untrusted input file, err := os.Open(path) //nolint:gosec // path is a controlled transcript file path if err == nil { return bufio.NewReader(file), func() { _ = file.Close() }, nil @@ -110,6 +111,7 @@ func openTranscriptReader(path string) (*bufio.Reader, func(), error) { // Try the gzip-compressed variant. gzPath := path + ".gz" + // #nosec G304 -- gzPath is derived from the same internally resolved transcript path, not remote/untrusted input gzFile, gzErr := os.Open(gzPath) //nolint:gosec // path is a controlled transcript file path if gzErr != nil { return nil, func() {}, fmt.Errorf("failed to open transcript: %w", err) diff --git a/cli/vercelconfig/vercelconfig.go b/cli/vercelconfig/vercelconfig.go index 36a5e6c..dfd0d9e 100644 --- a/cli/vercelconfig/vercelconfig.go +++ b/cli/vercelconfig/vercelconfig.go @@ -73,7 +73,7 @@ func ResetSettingsCache() { // Load reads a Vercel config file if present. func Load(path string) (map[string]any, bool, error) { //nolint:gosec // path is provided by repository-local callers and intentionally supports arbitrary locations in tests - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is repo-local, provided by callers within this tool, not remote/untrusted input if err != nil { if os.IsNotExist(err) { return make(map[string]any), false, nil diff --git a/cli/versioncheck/versioncheck.go b/cli/versioncheck/versioncheck.go index 904bbb0..e0819ff 100644 --- a/cli/versioncheck/versioncheck.go +++ b/cli/versioncheck/versioncheck.go @@ -113,8 +113,7 @@ func ensureGlobalConfigDir() error { return err } - //nolint:gosec // ~/.config/trace is user home directory, 0o755 is appropriate - if err := os.MkdirAll(configDir, 0o755); err != nil { + if err := os.MkdirAll(configDir, 0o750); err != nil { return fmt.Errorf("creating config directory: %w", err) } @@ -138,6 +137,7 @@ func loadCache() (*VersionCache, error) { return nil, err } + // #nosec G304 -- filePath is derived from os.UserHomeDir()/~/.config/trace, not user/remote input data, err := os.ReadFile(filePath) //nolint:gosec // cacheFilePath is safe if err != nil { return nil, fmt.Errorf("reading cache file: %w", err) diff --git a/redact/packs.go b/redact/packs.go index e9d1ff0..adf0de3 100644 --- a/redact/packs.go +++ b/redact/packs.go @@ -240,7 +240,8 @@ func LoadPacks(dir string) ([]*Pack, error) { return nil } - data, err := os.ReadFile(path) //nolint:gosec // path comes from WalkDir under a configured dir + // #nosec G304 -- path comes from WalkDir under a configured redactor pack dir, not external input + data, err := os.ReadFile(path) if err != nil { slog.Warn("skipping unreadable redactor pack", componentAttr, diff --git a/redact/redact.go b/redact/redact.go index e848ad4..850eab7 100644 --- a/redact/redact.go +++ b/redact/redact.go @@ -29,7 +29,7 @@ var credentialedURIPattern = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]{1,31}:// // optional `_word`/`-word` segments + `password`/`passwd`/`pwd`). Used to // compose both the env-var assignment regex and the JSON-key regex so the // vendor list stays in one place. -const dbPasswordKeyShape = `(?:db|database|pg|postgres|postgresql|mysql|mariadb|redis|mongo|mongodb|sqlserver|mssql|jdbc)(?:[_-]+[a-z0-9]+)*[_-]*(?:password|passwd|pwd)` //nolint:gosec // regex literal, not a credential +const dbPasswordKeyShape = `(?:db|database|pg|postgres|postgresql|mysql|mariadb|redis|mongo|mongodb|sqlserver|mssql|jdbc)(?:[_-]+[a-z0-9]+)*[_-]*(?:password|passwd|pwd)` // #nosec G101 -- regex pattern name matching credential-like key shapes, not a credential itself var ( jdbcPattern = regexp.MustCompile(`(?i)\bjdbc:[^\s"'<>` + "`" + `]+`) From 7646120643d5e4df03c331f366761569e02b8812 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Tue, 7 Jul 2026 14:47:11 +0530 Subject: [PATCH 2/4] feat: add root version.go with //go:embed VERSION as single source of truth --- version.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 version.go diff --git a/version.go b/version.go new file mode 100644 index 0000000..a96fbe1 --- /dev/null +++ b/version.go @@ -0,0 +1,15 @@ +// Package trace provides distributed tracing and observability. +// +// The Version variable is sourced from the VERSION file at the repo root. +package trace + +import ( + _ "embed" + "strings" +) + +//go:embed VERSION +var versionFile string + +// Version of the trace library. Single source of truth: VERSION file. +var Version = strings.TrimSpace(versionFile) From 38a39939fc1ac6d5677ea6f165f9c20562c308ce Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Tue, 7 Jul 2026 14:50:21 +0530 Subject: [PATCH 3/4] chore: sync VERSION to 0.1.3 (match standalone) --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6e8bf73..b1e80bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.3 From 54560eddf267ab3741ea545f9e9c0ad422fec352 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 8 Jul 2026 05:20:06 +0530 Subject: [PATCH 4/4] Upgrade go-git to v6 alpha.4, fixing GO-2026-5496 - Migrate all call sites to the new Worktree.Filesystem() method API - Fix zero-hash tree entry rejection in v2_generation_test.go --- cli/checkpoint/checkpoint_test.go | 6 +++--- cli/checkpoint/shadow_ref.go | 2 +- cli/checkpoint/store.go | 2 +- cli/checkpoint/temporary_2.go | 4 ++-- cli/checkpoint/v2_generation_test.go | 2 +- cli/checkpoint/v2_pending_rotation.go | 2 +- cli/clean_2_test.go | 26 +++++++++++++------------- cli/clean_test.go | 12 ++++++------ cli/explain_test.go | 2 +- cli/migrate_test.go | 2 +- cli/reset_test.go | 2 +- cli/review/scope.go | 2 +- cli/strategy/content_overlap.go | 2 +- cli/strategy/manual_commit_hooks_4.go | 2 +- cli/strategy/metadata_reconcile.go | 2 +- go.mod | 2 +- go.sum | 8 ++++---- 17 files changed, 40 insertions(+), 40 deletions(-) diff --git a/cli/checkpoint/checkpoint_test.go b/cli/checkpoint/checkpoint_test.go index c795f83..e61d42f 100644 --- a/cli/checkpoint/checkpoint_test.go +++ b/cli/checkpoint/checkpoint_test.go @@ -451,9 +451,9 @@ func TestEnsureSessionsBranch_WritesVercelConfigWhenEnabled(t *testing.T) { if err != nil { t.Fatalf("repo.Worktree() error = %v", err) } - t.Chdir(worktree.Filesystem.Root()) + t.Chdir(worktree.Filesystem().Root()) - traceDir := filepath.Join(worktree.Filesystem.Root(), ".trace") + traceDir := filepath.Join(worktree.Filesystem().Root(), ".trace") if err := os.MkdirAll(traceDir, 0o755); err != nil { t.Fatalf("mkdir .trace: %v", err) } @@ -505,7 +505,7 @@ func TestWriteCommitted_MergesVercelConfigOnMetadataBranch(t *testing.T) { if err != nil { t.Fatalf("repo.Worktree() error = %v", err) } - repoRoot := worktree.Filesystem.Root() + repoRoot := worktree.Filesystem().Root() t.Chdir(repoRoot) traceDir := filepath.Join(repoRoot, ".trace") diff --git a/cli/checkpoint/shadow_ref.go b/cli/checkpoint/shadow_ref.go index f06209e..febb9a0 100644 --- a/cli/checkpoint/shadow_ref.go +++ b/cli/checkpoint/shadow_ref.go @@ -40,7 +40,7 @@ func (s *GitStore) repoDirs(ctx context.Context) (worktreeRoot, commonDir string if err != nil { return "", "", fmt.Errorf("open worktree: %w", err) } - worktreeRoot = wt.Filesystem.Root() + worktreeRoot = wt.Filesystem().Root() if worktreeRoot == "" { return "", "", errors.New("repository worktree filesystem has no root path") } diff --git a/cli/checkpoint/store.go b/cli/checkpoint/store.go index 77f3d69..5ee182a 100644 --- a/cli/checkpoint/store.go +++ b/cli/checkpoint/store.go @@ -32,7 +32,7 @@ func NewGitStore(repo *git.Repository) *GitStore { wt, err := repo.Worktree() var repoPath string if err == nil { - repoPath = wt.Filesystem.Root() + repoPath = wt.Filesystem().Root() } return &GitStore{repo: repo, repoPath: repoPath} } diff --git a/cli/checkpoint/temporary_2.go b/cli/checkpoint/temporary_2.go index 7434122..162c71b 100644 --- a/cli/checkpoint/temporary_2.go +++ b/cli/checkpoint/temporary_2.go @@ -452,7 +452,7 @@ func filterGitIgnoredFiles(ctx context.Context, repo *git.Repository, files []st slog.String("error", err.Error())) return nil } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() // Use git check-ignore to identify which files are ignored. // Pass files via stdin (-z for NUL-separated, --stdin) to handle special characters. @@ -521,7 +521,7 @@ func collectChangedFiles(ctx context.Context, repo *git.Repository) (changedFile if err != nil { return changedFilesResult{}, fmt.Errorf("failed to get worktree: %w", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() // Use -z for NUL-separated output (handles quoted filenames with spaces/special chars) // Use -uall to list individual untracked files instead of collapsed directories. diff --git a/cli/checkpoint/v2_generation_test.go b/cli/checkpoint/v2_generation_test.go index 6eea21b..e43cdd9 100644 --- a/cli/checkpoint/v2_generation_test.go +++ b/cli/checkpoint/v2_generation_test.go @@ -131,7 +131,7 @@ func TestAddGenerationJSONToTree(t *testing.T) { shardEntries["aa/bbccddeeff/0/"+paths.V2RawTranscriptFileName] = object.TreeEntry{ Name: paths.V2RawTranscriptFileName, Mode: 0o100644, - Hash: plumbing.ZeroHash, // dummy + Hash: storeBlob(t, repo, "dummy"), } rootTreeHash, err := BuildTreeFromEntries(context.Background(), repo, shardEntries) require.NoError(t, err) diff --git a/cli/checkpoint/v2_pending_rotation.go b/cli/checkpoint/v2_pending_rotation.go index a4e1ff0..0c86f74 100644 --- a/cli/checkpoint/v2_pending_rotation.go +++ b/cli/checkpoint/v2_pending_rotation.go @@ -228,7 +228,7 @@ func resolveGitCommonDir(ctx context.Context, repo *git.Repository) (string, err if err != nil { return "", fmt.Errorf("open worktree for pending v2 full generation publications: %w", err) } - root := worktree.Filesystem.Root() + root := worktree.Filesystem().Root() if root == "" { return "", errors.New("resolve worktree root for pending v2 full generation publications") } diff --git a/cli/clean_2_test.go b/cli/clean_2_test.go index 1761e88..e86e7a2 100644 --- a/cli/clean_2_test.go +++ b/cli/clean_2_test.go @@ -75,7 +75,7 @@ func TestCleanCmd_All_InvalidSettingsWarnsAndContinues(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true,`) @@ -109,9 +109,9 @@ func TestCleanCmd_All_Subdirectory(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() subDir := filepath.Join(repoRoot, "subdir") - if err := wt.Filesystem.MkdirAll("subdir", 0o755); err != nil { + if err := wt.Filesystem().MkdirAll("subdir", 0o755); err != nil { t.Fatalf("failed to create subdir: %v", err) } @@ -144,7 +144,7 @@ func TestCleanCmd_All_FindsSessionWithShadowBranch(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) @@ -196,7 +196,7 @@ func TestCleanCmd_All_DryRunListsEligibleV2Generations(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRef(t, repo, "0000000000001", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15)) @@ -227,7 +227,7 @@ func TestCleanCmd_All_DryRunListsRemoteOnlyEligibleV2Generations(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) addCleanBareOrigin(t, repoRoot) @@ -265,7 +265,7 @@ func TestCleanCmd_All_UsesRawTranscriptTimeForV2GenerationRetention(t *testing.T if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) @@ -301,7 +301,7 @@ func TestCleanCmd_All_ForceDeletesRemoteOnlyEligibleV2Generations(t *testing.T) if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) addCleanBareOrigin(t, repoRoot) @@ -336,7 +336,7 @@ func TestCleanCmd_All_ForceDeletesEligibleV2Generations(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createCleanV2Ref(t, repo, plumbing.ReferenceName(paths.V2MainRefName)) @@ -371,7 +371,7 @@ func TestCleanCmd_All_DryRunSkipsV2GenerationsWithinRetention(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRef(t, repo, "0000000000003", time.Now().AddDate(0, 0, -5), time.Now().AddDate(0, 0, -1)) @@ -402,7 +402,7 @@ func TestCleanCmd_All_ForceSkipsV2GenerationMissingMetadata(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRefWithoutMetadata(t, repo, "0000000000001") @@ -432,7 +432,7 @@ func TestCleanCmd_All_ForceSkipsV2GenerationWithInvalidTimestamps(t *testing.T) if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRef(t, repo, "0000000000004", time.Now().AddDate(0, 0, -1), time.Now().AddDate(0, 0, -20)) @@ -462,7 +462,7 @@ func TestCleanCmd_All_ForceWarnsWithErrorDetailsForUnreadableV2Ref(t *testing.T) if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) diff --git a/cli/clean_test.go b/cli/clean_test.go index a74c3d6..f0c2a19 100644 --- a/cli/clean_test.go +++ b/cli/clean_test.go @@ -155,7 +155,7 @@ func TestCleanLongDescription_DefaultIsGeneric(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {}}`) @@ -175,7 +175,7 @@ func TestCleanLongDescription_IncludesV2CleanupWhenEnabled(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) @@ -460,7 +460,7 @@ func TestCleanCmd_DefaultMode_WithForce(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) @@ -506,7 +506,7 @@ func TestCleanCmd_DefaultMode_DryRun(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) @@ -581,7 +581,7 @@ func TestCleanCmd_DefaultMode_SessionsWithoutShadowBranch(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() // Create session state files WITHOUT a shadow branch sessionFile := createSessionStateFile(t, worktreePath, "2026-02-02-orphaned", commitHash) @@ -610,7 +610,7 @@ func TestCleanCmd_DefaultMode_MultipleSessions(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) diff --git a/cli/explain_test.go b/cli/explain_test.go index 5f05b88..46e03e5 100644 --- a/cli/explain_test.go +++ b/cli/explain_test.go @@ -352,7 +352,7 @@ func TestRunExplainAuto_CommitRefWithCheckpointTrailer(t *testing.T) { wt, err := repo.Worktree() require.NoError(t, err) - tmpDir := wt.Filesystem.Root() + tmpDir := wt.Filesystem().Root() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "feature.txt"), []byte("feature"), 0o644)) _, err = wt.Add("feature.txt") require.NoError(t, err) diff --git a/cli/migrate_test.go b/cli/migrate_test.go index 7d0ca3b..c4c464f 100644 --- a/cli/migrate_test.go +++ b/cli/migrate_test.go @@ -590,7 +590,7 @@ func TestMigrateCmd_RepairsArchivedGenerationMetadata(t *testing.T) { repo := initMigrateTestRepo(t) wt, err := repo.Worktree() require.NoError(t, err) - t.Chdir(wt.Filesystem.Root()) + t.Chdir(wt.Filesystem().Root()) paths.ClearWorktreeRootCache() cpID := id.MustCheckpointID("123456789abc") diff --git a/cli/reset_test.go b/cli/reset_test.go index 64f1e8c..f1048f1 100644 --- a/cli/reset_test.go +++ b/cli/reset_test.go @@ -103,7 +103,7 @@ func TestResetCmd_WithForce(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) diff --git a/cli/review/scope.go b/cli/review/scope.go index ffe7610..b56a080 100644 --- a/cli/review/scope.go +++ b/cli/review/scope.go @@ -276,7 +276,7 @@ func repoWorktreePath(repo *git.Repository) (string, error) { if err != nil { return "", fmt.Errorf("resolve worktree: %w", err) } - return wt.Filesystem.Root(), nil + return wt.Filesystem().Root(), nil } // fallbackScopeRef returns the first existing ref from the fallback chain: diff --git a/cli/strategy/content_overlap.go b/cli/strategy/content_overlap.go index ef561e5..1d151a7 100644 --- a/cli/strategy/content_overlap.go +++ b/cli/strategy/content_overlap.go @@ -471,7 +471,7 @@ func filesWithRemainingAgentChanges( // differs from shadow (distinguishes replacement from partial staging). var worktreeRoot string if wt, wtErr := repo.Worktree(); wtErr == nil { - worktreeRoot = wt.Filesystem.Root() + worktreeRoot = wt.Filesystem().Root() } var remaining []string diff --git a/cli/strategy/manual_commit_hooks_4.go b/cli/strategy/manual_commit_hooks_4.go index 0fad1b3..caeec32 100644 --- a/cli/strategy/manual_commit_hooks_4.go +++ b/cli/strategy/manual_commit_hooks_4.go @@ -105,7 +105,7 @@ func (s *ManualCommitStrategy) calculatePromptAttributionAtStart( return result } - worktreeRoot := worktree.Filesystem.Root() + worktreeRoot := worktree.Filesystem().Root() // Build map of changed files with their worktree content // IMPORTANT: We read from worktree (not staging area) to match what WriteTemporary diff --git a/cli/strategy/metadata_reconcile.go b/cli/strategy/metadata_reconcile.go index 596f835..1e39d70 100644 --- a/cli/strategy/metadata_reconcile.go +++ b/cli/strategy/metadata_reconcile.go @@ -614,5 +614,5 @@ func getRepoPath(repo *git.Repository) (string, error) { if err != nil { return "", fmt.Errorf("failed to get worktree: %w", err) } - return wt.Filesystem.Root(), nil + return wt.Filesystem().Root(), nil } diff --git a/go.mod b/go.mod index 6a1b7bc..42fae97 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/entireio/auth-go v0.4.0 github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 - github.com/go-git/go-git/v6 v6.0.0-alpha.2 + github.com/go-git/go-git/v6 v6.0.0-alpha.4 github.com/go-git/x/plugin/objectsigner/auto v0.1.0 github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260509055934-990a63433b45 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 6552d8a..5ed3c39 100644 --- a/go.sum +++ b/go.sum @@ -126,10 +126,10 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 h1:AaQOU2NVLxnBGWkv5YSoxomcDCqlaqfCW0t00pNKtnk= github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= -github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= -github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= github.com/go-git/x/plugin/objectsigner/auto v0.1.0 h1:RcLW29RgwSCmqrNSs7QOxvWkRbM1vPu0Vp9TCECZjMs= github.com/go-git/x/plugin/objectsigner/auto v0.1.0/go.mod h1:iP2cXPyXc//9v9THS3y/MLi0jnt7vEqwUDj11qQfFPg= github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 h1:NEGVSOD+LPnus6j4iNkAZaHVTc4DNY223y1/I2Jq2yI=