Skip to content

Post-refactor cleanup: security fixes, skill restructure, stale reference removal#28

Merged
josephgoksu merged 9 commits intomainfrom
fix/security-quality-freshness-review
Mar 18, 2026
Merged

Post-refactor cleanup: security fixes, skill restructure, stale reference removal#28
josephgoksu merged 9 commits intomainfrom
fix/security-quality-freshness-review

Conversation

@josephgoksu
Copy link
Owner

@josephgoksu josephgoksu commented Mar 18, 2026

Summary

  • Security hardening: path traversal guards, cache validation, safe file handling
  • MCP-only planning: removed CLI plan/goal commands in favor of MCP tool workflow
  • Skill restructure (8 -> 4): consolidated slash commands to plan, next, done, context; moved ask, remember, explain, debug to MCP-only tools
  • Stale reference cleanup: updated CLI output, hints, comments, and docs to match the new 4-command set
  • Bootstrap UX: unified context API, model catalog updates, TUI improvements
  • Freshness validation: tightened staleness checks and test coverage

Greptile Summary

This is a substantial cleanup PR (89 files, net −1,235 lines) that consolidates the skill/command architecture, hardens path security, and improves bootstrap reliability. The changes are well-motivated and the overall direction is correct — eliminating the shell-out pattern for slash commands in favour of embedded SKILL.md content is a clear quality improvement, and the two-wave bootstrap execution model is a meaningful reliability upgrade.

Key changes and their status:

  • Security hardening (safepath throughout factory.go, handlers.go, initializer.go): solid. One gap — hasDependencyFiles in factory.go uses a hardcoded / separator for subdirectory manifest paths instead of filepath.Join, which will produce wrong paths on Windows.
  • Unified context API (knowledge/context.go): the GetProjectContext function is a good centralisation, but its error return value is always nil — all failures are silently swallowed into SearchLog. Callers' if err != nil fallback branches are permanently dead code, masking DB failures.
  • Stat cache refactor (freshness.go): bounded size + TTL eviction is a genuine improvement. However, there is a TOCTOU race: after releasing the RLock, a concurrent goroutine can refresh the entry before the stale-reader acquires the Lock, resulting in the fresh entry being overwritten with an older stat result.
  • Skill restructure 8→4: correctly removes deprecated skills from both code and generated files. skills.List() still returns whatever directories exist under the embed glob, so any leftover deprecated directories would silently reappear in the list.
  • ReAct bootstrap mode (react_bootstrap.go): clean implementation with proper ErrNoToolCalling fallback sentinel. One inconsistency: analysis_doc.go logs via legacy log.Printf while every other agent path uses log/slog.
  • Model catalog (llm/models.go): updated with newest-first ordering. GetCategoryBadge was removed — confirm no callers outside the diff use it (the UI rendering path should be audited).
  • cmd/hook.go: the previous review comment about staleID logging was addressed cleanly.

Confidence Score: 3/5

  • PR is safe to merge with targeted fixes — no data-loss risks, but two correctness issues (always-nil error from GetProjectContext, stat-cache TOCTOU) should be resolved before merging to avoid masking failures in production.
  • Score reflects: (1) the GetProjectContext nil-error contract silently kills error-handling fallback paths that callers have coded; (2) the TOCTOU in statCache.stat can overwrite a fresh entry with a stale result under concurrent load; (3) the hardcoded / separator in hasDependencyFiles breaks Windows. These are not catastrophic but are correctness issues introduced by this PR rather than pre-existing. The remainder of the PR is clean, well-structured, and a genuine improvement.
  • internal/knowledge/context.go (nil error contract), internal/freshness/freshness.go (TOCTOU race), internal/bootstrap/factory.go (path separator portability)

Important Files Changed

Filename Overview
internal/freshness/freshness.go Refactored stat cache from package-level globals to a bounded statCache struct with TTL eviction and LRU fallback. Good structural improvement, but introduces a TOCTOU race between the RLock release and the subsequent write-lock acquisition that can overwrite a concurrently-refreshed cache entry.
internal/knowledge/context.go New unified context retrieval API that consolidates planning, task, hook, and MCP context fetching into a single GetProjectContext function. Two issues: (1) the error return is always nil — all failures are silently logged to SearchLog making the error contract misleading; (2) archPath uses a raw filepath.Join with .. bypassing the safepath abstraction introduced elsewhere in this PR.
internal/bootstrap/factory.go Adds adaptive agent selection based on project snapshot, using safepath throughout. One portability issue: the subdirectory manifest check in hasDependencyFiles uses e.Name()+"/"+name (hardcoded /) instead of filepath.Join, which will produce incorrect paths on Windows.
internal/bootstrap/runner.go Restructured from single-pass parallel execution to a two-wave model (doc+deps first, then code+git with wave-1 context). Wave-1 errors are now logged via slog.Debug rather than silently dropped. Clean implementation with appropriate context budget truncation.
internal/bootstrap/initializer.go Major restructure of Claude skill generation: moves from shell-out (!taskwing slash) to embedded SKILL.md content. Adds deprecatedSlashCommands pruning list for upgrade cleanup. SafeJoin used for tw-* directory removal. The createClaudeSkills path is now the canonical path for Claude Code, while the generic path also embeds content — duplication is minor but intentional.
internal/agents/impl/analysis_doc.go Adds ReAct mode as a first-pass attempt before falling back to the deterministic chain. New decisions finding type extracted from doc analysis. Uses legacy log.Printf inconsistently — the rest of the agent package uses slog. Otherwise logic is clean with appropriate minimum-findings threshold guarding.
internal/llm/models.go Updated model catalog with newer models across OpenAI, Anthropic, AWS Bedrock, and Google. Changed default model selection for OpenAI (gpt-5.4-mini) and Gemini (gemini-2.5-flash-preview). Sort order switched from price-descending to registry-order (newest-first). GetCategoryBadge removed — verify no callers remain outside the diff.
skills/skills.go New embedded skills package providing Get, GetBody, and List backed by //go:embed. The List() function returns all directories matching taskwing-*/SKILL.md — if any deprecated skill directories remain in the tree, they will appear in this list even though they are no longer generated as commands.
cmd/hook.go Fixes the stale task-ID logging bug (saves stale ID to staleID before clearing), syncs TasksCompleted from DB (source of truth instead of session cache), and adds a one-retry save path for hook session writes. Clean improvements throughout.
internal/agents/impl/react_bootstrap.go New shared ReAct wrapper for bootstrap agents using Eino's react.Agent. Clean abstraction with proper resource cleanup (defer Close()), ErrNoToolCalling sentinel for graceful fallback, and configurable maxSteps to prevent run-away tool loops.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI / MCP Hook
    participant Runner as bootstrap.Runner
    participant W1 as Wave 1<br/>(doc + deps)
    participant W2 as Wave 2<br/>(code + git)
    participant KCtx as knowledge.GetProjectContext
    participant Cache as freshness.statCache
    participant DB as SQLite / Memory DB

    CLI->>Runner: RunWithOptions(ctx, path, opts)
    Runner->>W1: runParallel(doc, deps)
    W1-->>Runner: wave1Results + wave1Err (logged, non-fatal)
    Runner->>Runner: buildWaveContext(wave1Results)
    Runner->>W2: runParallel(code, git) [+ExistingContext=wave1_summary]
    W2-->>Runner: wave2Results
    Runner-->>CLI: append(wave1Results, wave2Results)

    CLI->>KCtx: GetProjectContext(ctx, svc, opts)
    KCtx->>DB: ListNodesByType(constraint)
    DB-->>KCtx: constraints (errors → SearchLog only)
    KCtx->>Cache: stat(evidencePath) per node
    Cache-->>KCtx: FileInfo (TTL-checked, bounded eviction)
    KCtx->>DB: Search(query, nodesPerQuery)
    DB-->>KCtx: ScoredNodes
    KCtx-->>CLI: *ProjectContext, nil (always nil error)
Loading

Comments Outside Diff (1)

  1. internal/migration/upgrade_test.go, line 122-126 (link)

    P0 Test asserts a file that is no longer generated

    The test checks for ask.md in the namespace directory, but ask was removed from SlashCommands in this PR. The new canonical command set is {plan, next, done, context}, so the generated files will be plan.md, next.md, done.md, and context.md — never ask.md. This test will always fail in CI.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/migration/upgrade_test.go
    Line: 122-126
    
    Comment:
    **Test asserts a file that is no longer generated**
    
    The test checks for `ask.md` in the namespace directory, but `ask` was removed from `SlashCommands` in this PR. The new canonical command set is `{plan, next, done, context}`, so the generated files will be `plan.md`, `next.md`, `done.md`, and `context.md` — never `ask.md`. This test will **always fail** in CI.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: internal/knowledge/context.go
Line: 172-175

Comment:
**`GetProjectContext` always returns a nil error**

The function signature declares `(*ProjectContext, error)`, but every internal error (DB failure, search failure) is only logged to `SearchLog` — the error return is always `nil`. This breaks the standard Go error contract: callers who check `if err != nil` can never distinguish a fully successful retrieval from a complete database failure, silently receiving an empty `ProjectContext` instead.

For example, in `cmd/hook.go`:
```go
pc, err := knowledge.GetProjectContext(ctx, ks, opts)
if err != nil {
    return task.FormatRichContext(ctx, nextTask, plan, nil) // fallback
}
```
This fallback will never fire even if `svc.ListNodesByType` returns a driver error or the SQLite file is corrupted.

Either propagate the first critical error encountered (e.g., when `IncludeConstraints` is true and the DB call fails), or document clearly with a comment that this function never errors and callers should inspect `SearchLog` instead.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/freshness/freshness.go
Line: 213-232

Comment:
**TOCTOU race in `stat` can overwrite a freshly-written cache entry**

There is a classic check-then-act race between releasing the read lock and acquiring the write lock. If goroutine A reads a stale entry, drops the `RLock`, and goroutine B concurrently completes `os.Stat` and writes a fresh entry under the write lock first, goroutine A will then acquire the write lock and overwrite B's up-to-date entry with A's (potentially older) stat result. On high-frequency watch-mode paths this can produce a spurious stale annotation that persists for up to `cacheTTL`.

The standard remedy is to re-check the cache under the write lock before writing:

```go
info, err := os.Stat(path)

c.mu.Lock()
// Re-check: another goroutine may have refreshed this entry while we were statting
if existing, ok := c.entries[path]; ok && time.Since(existing.checkedAt) < cacheTTL {
    c.mu.Unlock()
    return existing.info, existing.err
}
c.entries[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()}
// ... eviction logic
c.mu.Unlock()
return info, err
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/bootstrap/factory.go
Line: 66

Comment:
**Hardcoded forward-slash separator breaks portability**

The inner subdirectory join uses a hardcoded `"/"` literal instead of `filepath.Join`:

```go
p, err := safepath.SafeJoin(basePath, e.Name()+"/"+name)
```

On Windows, `e.Name()+"/"+name` produces `subdir/go.mod` with a Unix separator. Depending on how `safepath.SafeJoin` resolves this internally, the resulting path may not match what `os.Stat` expects, causing `hasDependencyFiles` to always return false for monorepo sub-manifests on Windows.

```suggestion
			p, err := safepath.SafeJoin(basePath, filepath.Join(e.Name(), name))
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/agents/impl/analysis_doc.go
Line: 61-70

Comment:
**`log` package used instead of `slog`**

This file is the only place in the agent `impl` package that uses the legacy `log.Printf`. Every other diagnostic path in the codebase (including the runner and bootstrap service) uses `log/slog` for structured logging. Using both packages produces inconsistent output: `log.Printf` emits to stderr with a timestamp prefix, while `slog` emits structured JSON/text with fields. This makes it harder to filter and correlate agent fallback events in production logs.

```suggestion
			slog.Debug("[doc] ReAct produced only N findings, falling back to deterministic",
				"count", len(findings), "threshold", reactMinFindingsDoc)
```

```suggestion
		slog.Debug("[doc] ReAct mode failed, falling back to deterministic", "error", err)
```

Replace both `log.Printf` calls with `slog.Debug`/`slog.Warn` and remove the `"log"` import.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/knowledge/context.go
Line: 179-184

Comment:
**`archPath` uses raw `..` bypassing safepath abstraction**

The ARCHITECTURE.md path is resolved with a raw `filepath.Join` using `..`:

```go
archPath := filepath.Join(memoryBasePath, "..", "ARCHITECTURE.md")
```

While `memoryBasePath` is currently derived from internal config and not directly user-supplied, this is architecturally inconsistent with the explicit decision in this very PR to route all file operations through `safepath.SafeJoin`. If `opts.MemoryBasePath` is ever exposed to an external caller (e.g., an MCP tool request), the `..` in the join is the exact traversal vector `safepath` was added to prevent.

Consider computing the architecture path from the resolved `basePath` directly (which is already validated), rather than from `memoryBasePath` via `..`:

```go
if opts.IncludeArchitectureMD && basePath != "" {
    archPath := filepath.Join(basePath, ".taskwing", "ARCHITECTURE.md")
    ...
}
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix: address remaini..."

Greptile also left 3 inline comments on this PR.

Security (3 medium-severity):
- Block find -exec/-execdir/-delete/-ok in ExecTool (injection risk)
- Add "--" before grep pattern to prevent flag injection
- Replace ad-hoc path validation in MCP handlers with safepath.SafeJoin
- Use safepath.SafeJoin in policy builtins resolvePath (traversal risk)
- Change crash log permissions from 0644 to 0600

Code quality:
- Add rows.Err() checks after all 8 rows.Next() loops in codeintel/repository.go
- Replace err == io.EOF with errors.Is(err, io.EOF) in 3 files
- Fix out.Close() error handling in memory_export.go (named return)

Freshness review fixes (Greptile PR #26 feedback):
- Fix decay discontinuity: unified formula (1.0 - ratio * 0.8) replaces
  hardcoded 0.2 all-missing + 0.4 formula partial-missing
- Add cache eviction (purge entries >2x TTL when cache exceeds 1000)
- Convert global cache to struct-based statCache (testable, bounded)
- Add TODO(freshness-level2) comments for dead code paths
- Mark freshness plan doc as "implemented with deviations"
- Add TestDecaySmoothCurve test verifying no discontinuities
- Updated migration tests to clarify directory structure and improve comments for better understanding.
- Enhanced error handling in the TUI by treating agent completion warnings as non-fatal, allowing for smoother user experience.
- Improved list view rendering by adding workspace checks and refactoring header rendering for better clarity.
- Introduced new skills for TaskWing, including detailed SKILL.md files for various commands to enhance project knowledge and workflow.
- Added functionality to simplify code while preserving behavior, ensuring that optimization does not bypass necessary gates.
- Implemented status command to provide current task progress and acceptance criteria, improving user awareness of task status.
- Remove cmd/plan.go, cmd/goal.go, and plan TUI (1,982 lines deleted)
- MCP plan tool (clarify/generate/decompose/expand/finalize/audit) unchanged
- Planning prompt now scales task count dynamically to goal complexity
- Tasks include self-contained context (files, patterns, constraints, tech stack)
- Task enricher always provides baseline project context (constraints + decisions)
- Fix stop hook reliability: sync TasksCompleted from DB on each invocation
- Add session save retry logic to prevent stale state from failed writes
- Clear stale CurrentTaskID when DB lookup fails
- policy/builtins.go: use safepath.ValidateAbsPath for absolute paths,
  fixes prefix collision bypass (e.g. /project-evil matching /project)
- freshness/freshness.go: add evictOldest fallback when all cache entries
  are fresh but cache exceeds max size during burst scenarios
- mcp/handlers.go: restore absolute path support in validateAndResolvePath
  via safepath.ValidateAbsPath, fixing regression for MCP clients
- agents/impl/analysis_deps.go: use filepath.Join instead of string concat
- bootstrap/factory.go: use safepath.SafeJoin for dependency file checks
…improvements

- Unified GetProjectContext replaces 3 separate retrieval paths (ContextRetriever, TaskEnricher, buildTaskContext)
- Bootstrap: fix duplicate "Bootstrap complete!" in multi-repo, batch link warnings into single summary, clean verification output
- Bootstrap: per-service project context in workspace mode, progress callbacks, repo count accuracy
- Models: add gpt-5.4/5.4-mini/5.4-nano, claude-opus-4-6/sonnet-4-6, gemini-3.1, remove deprecated models
- Models: newest-first ordering in selection UI
- UI: grouped knowledge output, freshness indicators, consistent header boxes across all commands
- UI: markdown rendering in ask --answer output, adaptive width
- Skills: add /taskwing:context, remove /taskwing:debug and /taskwing:simplify, fix stale file pruning
- Skills: clarify questions presented to user (not auto-answered), auto-start first task after plan creation
- Hooks: stop hook syncs TasksCompleted from DB, session save retry, verbose logs gated behind --debug
- MCP: behavioral CLAUDE.md instructions tell AI tools when to use TaskWing
- Prompts: generic and directive (no hardcoded tech stack examples), dynamic task count scaling
- Fix: read_file tool returns helpful message for directories instead of crashing ReAct agent
- Fix: auto-regenerate slash commands on MCP server start after brew upgrade
- Fix: truncateString consolidated to utils.Truncate, nil guards in context.go, brief.go pluralization
- Remove: CLI plan/goal/slash commands, docs/_partials, sync-docs scripts, stale doc references
Update CLI output, hints, comments, and docs to reflect the 8->4 skill
restructure. Replace references to /taskwing:ask, /taskwing:status,
/taskwing:explain, and /taskwing:remember with their current equivalents
(MCP tools or /taskwing:context).
// hasAnyDependencyFile checks if at least one common dependency manifest exists.
func hasAnyDependencyFile(basePath string) bool {
for _, name := range commonDepFiles {
if _, err := os.Stat(filepath.Join(basePath, name)); err == nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 4 days ago

In general, to fix uncontrolled path usage, ensure that any filesystem operation using a path influenced by user input first validates or constrains that path to a known-safe directory (or otherwise normalizes and checks it). In this codebase, safepath.ValidateAbsPath is already the standard mechanism for doing that. The best approach here is to validate basePath inside hasAnyDependencyFile before using it with filepath.Join and os.Stat. That makes the dependency-agent logic robust even if input.BasePath is ever used from another call-site that does not perform validation, and also signals to static analysis that the path has been sanitized.

Concretely, in internal/agents/impl/analysis_deps.go, update hasAnyDependencyFile so that it first calls safepath.ValidateAbsPath with a trusted root (the process working directory is a reasonable default here) and the provided basePath. If validation fails, the function should return false (no dependency files found) rather than attempting any filesystem access. If validation succeeds, the loop should use the validated path when joining with name. The package already imports internal/safepath, so no new imports are required. No changes are needed in internal/server/handlers.go or internal/bootstrap/runner.go, since they already pass through sanitized values; the fix is fully localized to analysis_deps.go and does not alter functionality for legitimate inputs.

Suggested changeset 1
internal/agents/impl/analysis_deps.go

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/internal/agents/impl/analysis_deps.go b/internal/agents/impl/analysis_deps.go
--- a/internal/agents/impl/analysis_deps.go
+++ b/internal/agents/impl/analysis_deps.go
@@ -278,8 +278,19 @@
 
 // hasAnyDependencyFile checks if at least one common dependency manifest exists.
 func hasAnyDependencyFile(basePath string) bool {
+	// Ensure the basePath is a validated absolute path rooted at the current working directory.
+	// This is a defensive check in case hasAnyDependencyFile is called with an unvalidated path.
+	cwd, err := os.Getwd()
+	if err != nil {
+		return false
+	}
+	validBasePath, err := safepath.ValidateAbsPath(cwd, basePath)
+	if err != nil {
+		return false
+	}
+
 	for _, name := range commonDepFiles {
-		if _, err := os.Stat(filepath.Join(basePath, name)); err == nil {
+		if _, err := os.Stat(filepath.Join(validBasePath, name)); err == nil {
 			return true
 		}
 	}
EOF
@@ -278,8 +278,19 @@

// hasAnyDependencyFile checks if at least one common dependency manifest exists.
func hasAnyDependencyFile(basePath string) bool {
// Ensure the basePath is a validated absolute path rooted at the current working directory.
// This is a defensive check in case hasAnyDependencyFile is called with an unvalidated path.
cwd, err := os.Getwd()
if err != nil {
return false
}
validBasePath, err := safepath.ValidateAbsPath(cwd, basePath)
if err != nil {
return false
}

for _, name := range commonDepFiles {
if _, err := os.Stat(filepath.Join(basePath, name)); err == nil {
if _, err := os.Stat(filepath.Join(validBasePath, name)); err == nil {
return true
}
}
Copilot is powered by AI and may make mistakes. Always verify output.
if err != nil {
continue
}
if _, err := os.Stat(p); err == nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 4 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

}
}
// Also check for dependency files in subdirectories (monorepo)
if entries, err := os.ReadDir(basePath); err == nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 4 days ago

In general, to fix this kind of issue you must avoid using user-controlled path strings directly in file-system APIs. Either: (a) validate and normalize the path and ensure it stays within a safe root, or (b) when you need to construct subpaths, use a safe-join routine that enforces that constraint.

In this specific case, hasDependencyFiles already uses safepath.SafeJoin(basePath, ...) when forming paths to check with os.Stat, but falls back to os.ReadDir(basePath) when enumerating subdirectories. To align with the existing safety approach and remove the tainted sink, we can rework the monorepo scan to avoid calling os.ReadDir on basePath directly. Instead, we can:

  1. Use filepath.WalkDir starting at basePath to traverse directories; this call uses the same basePath, but only once, and we immediately sanitize any derived paths before using them with os.Stat.
  2. For each directory visited up to one level deep (to preserve the current “project root + immediate subdirectories” behavior), we:
    • Join the directory path and each manifest name via safepath.SafeJoin.
    • os.Stat that safe-joined path as before.

This change removes the direct os.ReadDir(basePath) sink; every per-file check continues to use SafeJoin, and traversal is constrained to a single WalkDir call at the already-validated basePath. Functionality remains effectively the same: the root is checked, and then each immediate subdirectory is checked for any of the dependency manifest files.

Concretely:

  • In internal/bootstrap/factory.go, extend the imports to include "io/fs".
  • Replace the os.ReadDir(basePath) block (lines 72–88) in hasDependencyFiles with a filepath.WalkDir over basePath that:
    • Treats basePath as depth 0, its direct children as depth 1, and skips deeper directories.
    • For each directory at depth 0 or 1, attempts safepath.SafeJoin with each manifest name and uses os.Stat to detect existence.
    • Returns early (by capturing a local variable and using an err sentinel) when a manifest is found.

This keeps all path construction for stats under the protection of SafeJoin and avoids the specific flagged pattern.

Suggested changeset 1
internal/bootstrap/factory.go

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/internal/bootstrap/factory.go b/internal/bootstrap/factory.go
--- a/internal/bootstrap/factory.go
+++ b/internal/bootstrap/factory.go
@@ -1,6 +1,7 @@
 package bootstrap
 
 import (
+	"io/fs"
 	"os"
 	"path/filepath"
 
@@ -69,22 +70,52 @@
 			return true
 		}
 	}
-	// Also check for dependency files in subdirectories (monorepo)
-	if entries, err := os.ReadDir(basePath); err == nil {
-		for _, e := range entries {
-			if !e.IsDir() {
+
+	// Also check for dependency files in subdirectories (monorepo).
+	// We limit the scan to the project root and its immediate subdirectories.
+	found := false
+	err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, walkErr error) error {
+		if walkErr != nil {
+			// Skip entries that cause errors.
+			return nil
+		}
+
+		if !d.IsDir() {
+			// We only care about directories where manifests may live.
+			return nil
+		}
+
+		// Determine depth relative to basePath to avoid traversing too deep.
+		if path == basePath {
+			// Depth 0: project root
+		} else {
+			rel, relErr := filepath.Rel(basePath, path)
+			if relErr != nil {
+				return nil
+			}
+			// If there is more than one path separator, this is deeper than an immediate child.
+			if rel == ".." || strings.Contains(rel, string(os.PathSeparator)+string(os.PathSeparator)) {
+				// Skip walking deeper directories.
+				return filepath.SkipDir
+			}
+		}
+
+		for _, name := range dependencyManifests {
+			p, err := safepath.SafeJoin(path, name)
+			if err != nil {
 				continue
 			}
-			for _, name := range dependencyManifests {
-				p, err := safepath.SafeJoin(basePath, filepath.Join(e.Name(), name))
-				if err != nil {
-					continue
-				}
-				if _, err := os.Stat(p); err == nil {
-					return true
-				}
+			if _, err := os.Stat(p); err == nil {
+				found = true
+				// Stop walking once we find any manifest.
+				return filepath.SkipDir
 			}
 		}
+
+		return nil
+	})
+	if err != nil {
+		return found
 	}
-	return false
+	return found
 }
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
if err != nil {
continue
}
if _, err := os.Stat(p); err == nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 4 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

Replace removed taskwing goal references, rewrite CHANGELOG unreleased
section to reflect consolidated slash commands and MCP-first planning.
- Save task ID before clearing so debug log prints actual value
- Use safepath.SafeJoin for tw-* directory cleanup to prevent traversal
- Log wave 1 agent errors instead of silently discarding them
- Remove dead legacy marker check (duplicate of primary path)
- Fix plan_id -> clarify_session_id reference in plan skill
@josephgoksu
Copy link
Owner Author

@greptileai

Comment on lines +172 to +175
sb.WriteString("## Relevant Context\n")
for _, node := range pc.RelevantNodes {
if node.Node == nil {
continue
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 GetProjectContext always returns a nil error

The function signature declares (*ProjectContext, error), but every internal error (DB failure, search failure) is only logged to SearchLog — the error return is always nil. This breaks the standard Go error contract: callers who check if err != nil can never distinguish a fully successful retrieval from a complete database failure, silently receiving an empty ProjectContext instead.

For example, in cmd/hook.go:

pc, err := knowledge.GetProjectContext(ctx, ks, opts)
if err != nil {
    return task.FormatRichContext(ctx, nextTask, plan, nil) // fallback
}

This fallback will never fire even if svc.ListNodesByType returns a driver error or the SQLite file is corrupted.

Either propagate the first critical error encountered (e.g., when IncludeConstraints is true and the DB call fails), or document clearly with a comment that this function never errors and callers should inspect SearchLog instead.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/knowledge/context.go
Line: 172-175

Comment:
**`GetProjectContext` always returns a nil error**

The function signature declares `(*ProjectContext, error)`, but every internal error (DB failure, search failure) is only logged to `SearchLog` — the error return is always `nil`. This breaks the standard Go error contract: callers who check `if err != nil` can never distinguish a fully successful retrieval from a complete database failure, silently receiving an empty `ProjectContext` instead.

For example, in `cmd/hook.go`:
```go
pc, err := knowledge.GetProjectContext(ctx, ks, opts)
if err != nil {
    return task.FormatRichContext(ctx, nextTask, plan, nil) // fallback
}
```
This fallback will never fire even if `svc.ListNodesByType` returns a driver error or the SQLite file is corrupted.

Either propagate the first critical error encountered (e.g., when `IncludeConstraints` is true and the DB call fails), or document clearly with a comment that this function never errors and callers should inspect `SearchLog` instead.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines 213 to +232

info, err := os.Stat(path)

cacheMu.Lock()
cache[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()}
cacheMu.Unlock()
c.mu.Lock()
c.entries[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()}
// Evict when cache grows too large
if len(c.entries) > cacheMaxSize {
c.evictExpired()
// Fallback: if still over limit (burst scenario), evict oldest entries
if len(c.entries) > cacheMaxSize {
c.evictOldest(len(c.entries) - cacheMaxSize)
}
}
c.mu.Unlock()

return info, err
}

// evictExpired removes entries older than 2x TTL. Caller must hold write lock.
func (c *statCache) evictExpired() {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TOCTOU race in stat can overwrite a freshly-written cache entry

There is a classic check-then-act race between releasing the read lock and acquiring the write lock. If goroutine A reads a stale entry, drops the RLock, and goroutine B concurrently completes os.Stat and writes a fresh entry under the write lock first, goroutine A will then acquire the write lock and overwrite B's up-to-date entry with A's (potentially older) stat result. On high-frequency watch-mode paths this can produce a spurious stale annotation that persists for up to cacheTTL.

The standard remedy is to re-check the cache under the write lock before writing:

info, err := os.Stat(path)

c.mu.Lock()
// Re-check: another goroutine may have refreshed this entry while we were statting
if existing, ok := c.entries[path]; ok && time.Since(existing.checkedAt) < cacheTTL {
    c.mu.Unlock()
    return existing.info, existing.err
}
c.entries[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()}
// ... eviction logic
c.mu.Unlock()
return info, err
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/freshness/freshness.go
Line: 213-232

Comment:
**TOCTOU race in `stat` can overwrite a freshly-written cache entry**

There is a classic check-then-act race between releasing the read lock and acquiring the write lock. If goroutine A reads a stale entry, drops the `RLock`, and goroutine B concurrently completes `os.Stat` and writes a fresh entry under the write lock first, goroutine A will then acquire the write lock and overwrite B's up-to-date entry with A's (potentially older) stat result. On high-frequency watch-mode paths this can produce a spurious stale annotation that persists for up to `cacheTTL`.

The standard remedy is to re-check the cache under the write lock before writing:

```go
info, err := os.Stat(path)

c.mu.Lock()
// Re-check: another goroutine may have refreshed this entry while we were statting
if existing, ok := c.entries[path]; ok && time.Since(existing.checkedAt) < cacheTTL {
    c.mu.Unlock()
    return existing.info, existing.err
}
c.entries[path] = cacheEntry{info: info, err: err, checkedAt: time.Now()}
// ... eviction logic
c.mu.Unlock()
return info, err
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

p, err := safepath.SafeJoin(basePath, name)
if err != nil {
continue
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded forward-slash separator breaks portability

The inner subdirectory join uses a hardcoded "/" literal instead of filepath.Join:

p, err := safepath.SafeJoin(basePath, e.Name()+"/"+name)

On Windows, e.Name()+"/"+name produces subdir/go.mod with a Unix separator. Depending on how safepath.SafeJoin resolves this internally, the resulting path may not match what os.Stat expects, causing hasDependencyFiles to always return false for monorepo sub-manifests on Windows.

Suggested change
}
p, err := safepath.SafeJoin(basePath, filepath.Join(e.Name(), name))
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/bootstrap/factory.go
Line: 66

Comment:
**Hardcoded forward-slash separator breaks portability**

The inner subdirectory join uses a hardcoded `"/"` literal instead of `filepath.Join`:

```go
p, err := safepath.SafeJoin(basePath, e.Name()+"/"+name)
```

On Windows, `e.Name()+"/"+name` produces `subdir/go.mod` with a Unix separator. Depending on how `safepath.SafeJoin` resolves this internally, the resulting path may not match what `os.Stat` expects, causing `hasDependencyFiles` to always return false for monorepo sub-manifests on Windows.

```suggestion
			p, err := safepath.SafeJoin(basePath, filepath.Join(e.Name(), name))
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

- Propagate critical error from GetProjectContext when constraint DB fetch fails
- Resolve ARCHITECTURE.md from basePath directly instead of raw .. traversal
- Add TOCTOU re-check in stat cache to prevent stale overwrites under concurrency
- Use filepath.Join instead of hardcoded "/" for monorepo subdirectory paths
- Replace legacy log.Printf with slog.Debug in doc analysis agent
@josephgoksu josephgoksu merged commit 5521507 into main Mar 18, 2026
7 of 8 checks passed
@josephgoksu josephgoksu deleted the fix/security-quality-freshness-review branch March 18, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant