Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,8 +493,19 @@ function addScanCommand(program: Command, version: string, deps: CliDeps): void
? true
: (baseConfig.workflow_audits?.enabled ?? false),
},
// When the target was a single local file that got staged into a
// temp dir (explicitCandidates set), walking the full user-scope
// tree is off-target: the user asked to scan one file, not their
// whole home. Leaving user-scope on here let sibling findings
// (e.g. `~/.agents/skills/*/SKILL.md`) leak into single-file
// scans of configs like `.claude/settings.json`. Explicit opt-in
// via `--include-user-scope` still forces it on.
scan_user_scope:
options.includeUserScope === true ? true : (baseConfig.scan_user_scope ?? false),
options.includeUserScope === true
? true
: resolvedTarget.explicitCandidates && resolvedTarget.explicitCandidates.length > 0
? false
: (baseConfig.scan_user_scope ?? false),
};

if (options.resetState) {
Expand Down
45 changes: 38 additions & 7 deletions src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,22 +260,53 @@ function isPathInside(root: string, candidatePath: string): boolean {
* User-scope patterns (e.g. `~/.agents/skills/*/SKILL.md`) walk the whole
* home directory, so they can match files belonging to completely unrelated
* skills or agents. When the scan target is itself a specific location
* **inside** the user's home — e.g. scanning a single skill directory — any
* user-scope match outside that scan target belongs to a different scan and
* must not be attributed here.
* **inside** the user's home — e.g. scanning a single skill directory or a
* single config file like `~/.claude/settings.json` — any user-scope match
* that does not belong to that target is a cross-scan leak and must be
* dropped.
*
* When the scan target lives outside the home directory (for example a
* project root in a workspace), user-scope matches are accepted as legitimate
* host-wide context for that scan.
* Three cases:
* - `scanTarget` is a directory inside `homeDir`: only keep candidates inside
* that directory (existing PR #53 behavior).
* - `scanTarget` is a file inside `homeDir`: only keep candidates that resolve
* to that exact file. "Inside" semantics do not apply to files, so the
* previous check let every sibling through.
* - `scanTarget` lives outside the home directory (or cannot be stat'd,
* e.g. a URL or a staged path that has been cleaned up): user-scope
* matches are accepted as legitimate host-wide context.
*/
function shouldKeepUserScopeCandidate(
scanTarget: string,
homeDir: string,
candidatePath: string,
): boolean {
if (isPathInside(homeDir, scanTarget)) {
if (!isPathInside(homeDir, scanTarget)) {
return true;
}

// Follow symlinks the same way the rest of the scan code does (walker,
// wildcard-base check, `isRegularFile`): `statSync` resolves them. If the
// target cannot be stat'd (missing / permission denied / URL that was never
// a local path), fall through to the pre-PR-#53 outside-home behavior so
// we do not over-filter project-scope scans on unusual inputs.
let targetStat;
try {
targetStat = statSync(scanTarget);
} catch {
return true;
}

if (targetStat.isFile()) {
// Nothing is "inside" a file. The only user-scope candidate that can
// legitimately belong to a file-target scan is the file itself.
return resolve(candidatePath) === resolve(scanTarget);
}

if (targetStat.isDirectory()) {
return isPathInside(scanTarget, candidatePath);
}

// Sockets, devices, etc. — behave like the outside-home case.
return true;
}

Expand Down
42 changes: 42 additions & 0 deletions tests/layer2/cross-scan-attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,46 @@ describe("cross-scan attribution — Layer 2 hidden-unicode rule", () => {
),
).toBe(true);
});

it("engine-level: file-target scan drops user-scope siblings", async () => {
// Covers the case where runScanEngine is called directly with a file
// target inside homeDir (library/embedded callers). The scope filter
// in `shouldKeepUserScopeCandidate` rejects every candidate that is
// not the target file itself.
//
// NB: the CLI stages file targets into a temp dir before calling the
// engine — see the "CLI-level" test below for that path.
const home = mkdtempSync(join(tmpdir(), "codegate-cross-scan-file-home-"));

// Sibling: a skill with hidden Unicode under home.
mkdirSync(join(home, ".agents", "skills", "bar"), { recursive: true });
writeFileSync(
join(home, ".agents", "skills", "bar", "SKILL.md"),
"Sibling skill​ with hidden zero-width space.\n",
"utf8",
);

// Target dir wrapping a single config file — simulates a consumer that
// has already placed the file in a dedicated dir (runScanEngine
// rejects bare files, hence the wrapper).
const targetDir = join(home, ".claude");
mkdirSync(targetDir, { recursive: true });
writeFileSync(
join(targetDir, "settings.json"),
`{\n "permissions": { "allow": [] }\n}\n`,
"utf8",
);

const report = await runScanEngine({
version: "0.1.0",
scanTarget: targetDir,
config: BASE_CONFIG,
homeDir: home,
});

const leaked = report.findings.filter(
(f) => typeof f.file_path === "string" && f.file_path.includes(".agents/skills/bar"),
);
expect(leaked).toEqual([]);
});
});
Loading