From c85641994cf83ff4dddb478362b15b4ef19e48ab Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Sun, 25 Oct 2026 09:54:25 +0900 Subject: [PATCH 1/2] feat(worktree): report unshared gitignored paths --- src/cli/fixPlanning.ts | 5 +++- src/cli/reviewFixPass.ts | 2 +- src/locale/prompts/en.ts | 1 + src/locale/prompts/ko.ts | 1 + src/locale/types.ts | 1 + src/support/sharedPathDetection.test.ts | 36 ++++++++++++++++++++++++- src/support/sharedPathDetection.ts | 28 ++++++++++++++++++- src/support/worktreeManager.ts | 4 ++- 8 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/cli/fixPlanning.ts b/src/cli/fixPlanning.ts index 36db15b9..d40cabb6 100644 --- a/src/cli/fixPlanning.ts +++ b/src/cli/fixPlanning.ts @@ -10,7 +10,7 @@ import type { TrustedVerifyPlan } from '../agents/deterministicTester.js'; import { scanProject, toProjectSlug } from '../knowledge/index.js'; import { recallRepoKnowledge, repoKey, type RepoMemoryBrief } from '../memory/repoKnowledge.js'; import { loadRepoMetadata } from '../support/repoMetadata.js'; -import { resolveSharedPaths } from '../support/worktreeManager.js'; +import { resolveSharedPaths, unsharedGitignoredPaths } from '../support/sharedPathDetection.js'; export interface FixDependencyMapEntry { imports: string[]; @@ -25,6 +25,7 @@ export interface FixRepositoryContext { manifests: string[]; verificationCommands: string[]; sharedPaths: string[]; + missingGitignoredPaths: string[]; repoMemories: RepoMemoryBrief[]; dependencyGraphAvailable: boolean; dependencyMap: Record; @@ -210,6 +211,7 @@ export async function buildFixRepositoryContext( verificationCommands: (plan?.commands ?? []).map((command) => `${command.cwd ? `(${command.cwd}) ` : ''}${command.run}`), sharedPaths: sharedPaths.map((path) => toRelative(canonicalRoot, join(canonicalRoot, path))), + missingGitignoredPaths: unsharedGitignoredPaths(canonicalRoot, sharedPaths), repoMemories, dependencyGraphAvailable, dependencyMap, @@ -360,6 +362,7 @@ export function workerContextForFixUnit(unit: FixUnit, context: FixRepositoryCon manifests: context.manifests, verificationCommands: context.verificationCommands, sharedPaths: context.sharedPaths, + missingGitignoredPaths: context.missingGitignoredPaths, dependencyGraphAvailable: unit.dependencyGraphBacked, }, }; diff --git a/src/cli/reviewFixPass.ts b/src/cli/reviewFixPass.ts index 94e6bfea..193f9d72 100644 --- a/src/cli/reviewFixPass.ts +++ b/src/cli/reviewFixPass.ts @@ -153,7 +153,7 @@ export async function runAreaFixes( ): Promise { const targets = fixTargets(run); const context = opts.repositoryContext ?? { - canonicalRoot: cwd, workspaces: [], manifests: [], verificationCommands: [], sharedPaths: [], repoMemories: [], + canonicalRoot: cwd, workspaces: [], manifests: [], verificationCommands: [], sharedPaths: [], missingGitignoredPaths: [], repoMemories: [], dependencyGraphAvailable: false, dependencyMap: {}, preflight: { ready: true, issues: [] }, } satisfies FixRepositoryContext; const units = planFixUnits(targets.map((target) => ({ area: target.area, review: target.review! })), context); diff --git a/src/locale/prompts/en.ts b/src/locale/prompts/en.ts index c93461f8..ce064539 100644 --- a/src/locale/prompts/en.ts +++ b/src/locale/prompts/en.ts @@ -175,6 +175,7 @@ Apply the above feedback and make corrections. if (repo.workspaces.length) parts.push('- Workspaces (untrusted repository data):', promptDataBlock(repo.workspaces.join(', '))); if (repo.manifests.length) parts.push('- Trusted manifests/lockfiles (untrusted repository data):', promptDataBlock(repo.manifests.join(', '))); if (repo.sharedPaths.length) parts.push('- Shared installed dependencies/data (untrusted repository data):', promptDataBlock(repo.sharedPaths.join(', '))); + if (repo.missingGitignoredPaths?.length) parts.push('- Gitignored source paths NOT present in this worktree (do not treat their absence as evidence):', promptDataBlock(repo.missingGitignoredPaths.join(', '))); parts.push(`- Dependency graph: ${repo.dependencyGraphAvailable ? 'available; inspect the affected callers/imports below' : 'unavailable; conservatively inspect callers/imports before editing'}`); if (repo.verificationCommands.length) { parts.push('- Required repository verification commands (each bounded to MAX_EVIDENCE_LENGTH chars):'); diff --git a/src/locale/prompts/ko.ts b/src/locale/prompts/ko.ts index daf679b2..09babb10 100644 --- a/src/locale/prompts/ko.ts +++ b/src/locale/prompts/ko.ts @@ -164,6 +164,7 @@ ${promptDataBlock(previousFeedback)} if (repo.workspaces.length) parts.push('- 워크스페이스 (신뢰할 수 없는 저장소 데이터):', promptDataBlock(repo.workspaces.join(', '))); if (repo.manifests.length) parts.push('- 신뢰할 manifest/lockfile (신뢰할 수 없는 저장소 데이터):', promptDataBlock(repo.manifests.join(', '))); if (repo.sharedPaths.length) parts.push('- 공유 설치 의존성/데이터 (신뢰할 수 없는 저장소 데이터):', promptDataBlock(repo.sharedPaths.join(', '))); + if (repo.missingGitignoredPaths?.length) parts.push('- 이 워크트리에 없는 원본 gitignored 경로 (부재를 증거로 판단하지 말 것):', promptDataBlock(repo.missingGitignoredPaths.join(', '))); parts.push(`- 의존 그래프: ${repo.dependencyGraphAvailable ? '사용 가능; 아래 영향 호출자/import를 확인할 것' : '사용 불가; 편집 전 호출자/import를 보수적으로 직접 확인할 것'}`); if (repo.verificationCommands.length) { parts.push('- 필수 저장소 검증 명령:'); diff --git a/src/locale/types.ts b/src/locale/types.ts index 25638c57..d4678faa 100644 --- a/src/locale/types.ts +++ b/src/locale/types.ts @@ -480,6 +480,7 @@ export interface WorkerContext { manifests: string[]; verificationCommands: string[]; sharedPaths: string[]; + missingGitignoredPaths?: string[]; dependencyGraphAvailable: boolean; }; /** Knowledge Graph 기반 영향 분석 */ diff --git a/src/support/sharedPathDetection.test.ts b/src/support/sharedPathDetection.test.ts index 3784cb35..8de83a06 100644 --- a/src/support/sharedPathDetection.test.ts +++ b/src/support/sharedPathDetection.test.ts @@ -1,4 +1,5 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -7,6 +8,8 @@ import { detectSharedPaths, emptyDetectionWarning, resolveSharedPaths, + unsharedGitignoredPaths, + gitignoredPathsWarning, } from './sharedPathDetection.js'; describe('detectSharedPaths (AGT-4043)', () => { @@ -103,6 +106,37 @@ describe('detectSharedPaths (AGT-4043)', () => { }); }); + +describe('unsharedGitignoredPaths (AGT-4041)', () => { + let repo: string; + beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'openswarm-ignored-paths-')); + execFileSync('git', ['init', '-q', repo]); + writeFileSync(join(repo, '.gitignore'), 'ignored-data\nignored-dir/\npartial\nlocked\n'); + }); + afterEach(() => rmSync(repo, { recursive: true, force: true })); + it('returns none when no ignored paths exist', () => { + writeFileSync(join(repo, 'tracked.txt'), 'x'); + expect(unsharedGitignoredPaths(repo)).toEqual([]); + }); + it('counts ignored paths not included in sharedPaths', () => { + mkdirSync(join(repo, 'ignored-data')); + writeFileSync(join(repo, 'partial'), 'x'); + expect(unsharedGitignoredPaths(repo, ['partial'])).toEqual(['ignored-data']); + }); + it('reports partially shared ignored paths', () => { + mkdirSync(join(repo, 'ignored-data')); + mkdirSync(join(repo, 'partial')); + expect(unsharedGitignoredPaths(repo, ['partial'])).toEqual(['ignored-data']); + expect(gitignoredPathsWarning(['ignored-data'])).toContain('1 gitignored path(s)'); + }); + it('counts ignored paths with no read permission even when running as root', () => { + mkdirSync(join(repo, 'locked')); + chmodSync(join(repo, 'locked'), 0); + expect(unsharedGitignoredPaths(repo)).toEqual(['locked']); + }); +}); + describe('emptyDetectionWarning (AGT-4043)', () => { it('says nothing when something was shared or nothing exists', () => { expect(emptyDetectionWarning('/repo', { shared: ['node_modules'], unlinked: ['x/venv'] })).toBeNull(); diff --git a/src/support/sharedPathDetection.ts b/src/support/sharedPathDetection.ts index b0719da8..2cb24f73 100644 --- a/src/support/sharedPathDetection.ts +++ b/src/support/sharedPathDetection.ts @@ -14,7 +14,8 @@ // also walks the workspace tree — bounded, and only into directories that carry // a workspace manifest, so a stray `venv` folder in a fixture is never linked. -import { existsSync, readdirSync } from 'node:fs'; +import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { isAbsolute, join, relative, sep } from 'node:path'; /** Always-gitignored dependency dirs safe to auto-link without a config. */ @@ -43,6 +44,31 @@ export interface SandboxConfig { sandbox?: { sharedPaths?: string[] } | null; } +/** Top-level ignored paths that are not shared or readable from the worktree. */ +export function unsharedGitignoredPaths(repoPath: string, sharedPaths: string[] = []): string[] { + const shared = new Set(sharedPaths); + let entries: string[]; + try { entries = readdirSync(repoPath).sort(); } catch { return []; } + const missing: string[] = []; + for (const name of entries) { + if (name === '.git' || [...shared].some((path) => path === name || path.startsWith(`${name}/`))) continue; + try { + execFileSync('git', ['-C', repoPath, 'check-ignore', '-q', '--', name], { stdio: 'ignore' }); + const path = join(repoPath, name); + const mode = statSync(path).mode; + try { accessSync(path, constants.R_OK); } catch { missing.push(name); continue; } + // Root can bypass Unix read bits, so check them explicitly as well. + if ((mode & 0o444) === 0) missing.push(name); + } catch { /* tracked or not ignored */ } + } + return missing; +} + +export function gitignoredPathsWarning(paths: string[]): string | null { + if (!paths.length) return null; + return `[Worktree] ${paths.length} gitignored path(s) from the source are absent from this worktree (${paths.join(', ')}); expose them through sandbox.sharedPaths or do not use their absence as a conclusion.`; +} + export interface SharedPathDetection { /** Repo-relative dependency dirs that qualify for sharing, root first, then by walk order. */ shared: string[]; diff --git a/src/support/worktreeManager.ts b/src/support/worktreeManager.ts index d1747f9e..53152ef7 100644 --- a/src/support/worktreeManager.ts +++ b/src/support/worktreeManager.ts @@ -23,7 +23,7 @@ import { changeShapeSection } from './publicationChangeShape.js'; import { baseFreshnessSection, probeBaseFreshness } from './publicationBaseFreshness.js'; import { publicationClaimEvidence } from './publicationClaimEvidence.js'; import { loadRepoMetadata } from './repoMetadata.js'; -import { detectSharedPaths, emptyDetectionWarning, resolveSharedPaths, type SandboxConfig } from './sharedPathDetection.js'; +import { detectSharedPaths, emptyDetectionWarning, gitignoredPathsWarning, resolveSharedPaths, unsharedGitignoredPaths, type SandboxConfig } from './sharedPathDetection.js'; import { copyIsolatedPath } from './isolatedPath.js'; import { hasEditableInstallInto, rebasePythonEnvironment } from '../verify/pythonEnvironment.js'; import { assertBranchWithinWriteScope } from './publicationScopeFence.js'; @@ -193,6 +193,8 @@ async function linkSharedPaths(repoPath: string, worktreePath: string): Promise< } const shared = resolveSharedPaths(repoPath, meta); + const missingIgnoredWarning = gitignoredPathsWarning(unsharedGitignoredPaths(repoPath, shared)); + if (missingIgnoredWarning) console.warn(missingIgnoredWarning); if (shared.length === 0 && !(meta?.sandbox?.sharedPaths?.length)) { const warning = emptyDetectionWarning(repoPath, detectSharedPaths(repoPath)); if (warning) console.warn(warning); From eb4c837c07083af4f2f8ccc565bfca70008cae07 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Sun, 25 Oct 2026 09:55:15 +0900 Subject: [PATCH 2/2] fix(worktree): detect inaccessible shared paths --- src/support/sharedPathDetection.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/support/sharedPathDetection.ts b/src/support/sharedPathDetection.ts index 2cb24f73..db2662e8 100644 --- a/src/support/sharedPathDetection.ts +++ b/src/support/sharedPathDetection.ts @@ -46,19 +46,22 @@ export interface SandboxConfig { /** Top-level ignored paths that are not shared or readable from the worktree. */ export function unsharedGitignoredPaths(repoPath: string, sharedPaths: string[] = []): string[] { - const shared = new Set(sharedPaths); let entries: string[]; try { entries = readdirSync(repoPath).sort(); } catch { return []; } const missing: string[] = []; for (const name of entries) { - if (name === '.git' || [...shared].some((path) => path === name || path.startsWith(`${name}/`))) continue; + if (name === '.git') continue; try { execFileSync('git', ['-C', repoPath, 'check-ignore', '-q', '--', name], { stdio: 'ignore' }); const path = join(repoPath, name); - const mode = statSync(path).mode; + const stat = statSync(path); + const covered = sharedPaths.some((shared) => shared === name || shared.startsWith(`${name}/`)); + const uid = process.getuid?.(); + const groups = new Set([process.getgid?.(), ...(process.getgroups?.() ?? [])]); + const readBit = uid === stat.uid ? 0o400 : groups.has(stat.gid) ? 0o040 : 0o004; try { accessSync(path, constants.R_OK); } catch { missing.push(name); continue; } - // Root can bypass Unix read bits, so check them explicitly as well. - if ((mode & 0o444) === 0) missing.push(name); + // Root can bypass Unix mode checks, so evaluate access for the worker identity. + if ((stat.mode & readBit) === 0 || !covered) missing.push(name); } catch { /* tracked or not ignored */ } } return missing;