Skip to content

Commit 39f3e7b

Browse files
committed
guard skill deps and fix react-doctor findings
1 parent 910f0df commit 39f3e7b

5 files changed

Lines changed: 108 additions & 12 deletions

File tree

packages/ui/src/features/loops/components/LoopDetailView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
366366
function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
367367
const { localWorkspaces } = useHostCapabilities();
368368
const trpc = useHostTRPC();
369-
const skillsQuery = useQuery({
369+
const { data: localSkillData } = useQuery({
370370
...trpc.skills.list.queryOptions(),
371371
enabled: localWorkspaces,
372372
});
@@ -381,7 +381,7 @@ function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
381381
// same-named skill from another source (say, an opened repo) can never silently
382382
// replace the loop's snapshot. Ambiguous cases go through the edit form, where
383383
// the picker shows each candidate.
384-
const candidates = (skillsQuery.data ?? []).filter(
384+
const candidates = (localSkillData ?? []).filter(
385385
(skill) =>
386386
skill.name === primary.skill_name &&
387387
skill.source === primary.skill_source,

packages/ui/src/features/loops/components/LoopSkillFields.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ export function LoopInstructionsFields({
4141
}) {
4242
const { localWorkspaces } = useHostCapabilities();
4343
const trpc = useHostTRPC();
44-
const skillsQuery = useQuery({
44+
const { data: localSkillData } = useQuery({
4545
...trpc.skills.list.queryOptions(),
4646
enabled: localWorkspaces,
4747
});
48-
const localSkills: PickableSkill[] = (skillsQuery.data ?? []).flatMap(
48+
const localSkills: PickableSkill[] = (localSkillData ?? []).flatMap(
4949
(skill) =>
5050
isUploadableSkillSource(skill.source)
5151
? [
@@ -58,13 +58,12 @@ export function LoopInstructionsFields({
5858
]
5959
: [],
6060
);
61+
const nameCounts = new Map<string, number>();
62+
for (const skill of localSkills) {
63+
nameCounts.set(skill.name, (nameCounts.get(skill.name) ?? 0) + 1);
64+
}
6165
const duplicatedNames = new Set(
62-
localSkills
63-
.filter(
64-
(skill, _, all) =>
65-
all.filter((other) => other.name === skill.name).length > 1,
66-
)
67-
.map((skill) => skill.name),
66+
[...nameCounts].flatMap(([name, count]) => (count > 1 ? [name] : [])),
6867
);
6968

7069
const skill = values.skill;

packages/ui/src/features/loops/hooks/useLoopSkillBundles.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
} from "@posthog/api-client/loops";
55
import { useHostTRPCClient } from "@posthog/host-router/react";
66
import { useMutation, useQueryClient } from "@tanstack/react-query";
7+
import { isTrustedSkillDependency } from "../loopSkill";
78
import { loopsKeys } from "./loopsKeys";
89
import { useLoopsClient } from "./useLoopsClient";
910

@@ -20,7 +21,18 @@ async function buildSkillUploads(
2021
skill: LoopLocalSkillRef,
2122
): Promise<LoopSchemas.LoopSkillBundleUpload[]> {
2223
const refs = await hostClient.skills.resolveDependencies.query([skill]);
23-
const ordered = [skill, ...refs.filter((ref) => ref.name !== skill.name)];
24+
const dependencies = refs.filter((ref) => ref.name !== skill.name);
25+
const untrusted = dependencies.find(
26+
(dep) => !isTrustedSkillDependency(dep, skill),
27+
);
28+
if (untrusted) {
29+
throw new Error(
30+
`The ${skill.name} skill references /${untrusted.name}, which resolved to a skill ` +
31+
`outside its own scope (${untrusted.source}: ${untrusted.path}). Install ` +
32+
`${untrusted.name} as one of your user skills, or alongside ${skill.name}, and retry.`,
33+
);
34+
}
35+
const ordered = [skill, ...dependencies];
2436
const bundles = await Promise.all(
2537
ordered.map((ref) => hostClient.skills.bundleLocal.query(ref)),
2638
);

packages/ui/src/features/loops/loopSkill.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { buildSkillInstructions, parseSkillContext } from "./loopSkill";
2+
import {
3+
buildSkillInstructions,
4+
isTrustedSkillDependency,
5+
parseSkillContext,
6+
} from "./loopSkill";
37

48
describe("buildSkillInstructions", () => {
59
it.each([
@@ -14,6 +18,60 @@ describe("buildSkillInstructions", () => {
1418
});
1519
});
1620

21+
describe("isTrustedSkillDependency", () => {
22+
const repoPrimary = {
23+
source: "repo" as const,
24+
path: "/work/app/.claude/skills/deploy-checks",
25+
};
26+
27+
it.each([
28+
{
29+
name: "user dependency is always trusted",
30+
dep: { source: "user" as const, path: "/home/me/.claude/skills/helper" },
31+
primary: repoPrimary,
32+
expected: true,
33+
},
34+
{
35+
name: "sibling from the same repo skills dir is trusted",
36+
dep: { source: "repo" as const, path: "/work/app/.claude/skills/helper" },
37+
primary: repoPrimary,
38+
expected: true,
39+
},
40+
{
41+
name: "same-source skill from another repo is rejected",
42+
dep: {
43+
source: "repo" as const,
44+
path: "/work/other-repo/.claude/skills/helper",
45+
},
46+
primary: repoPrimary,
47+
expected: false,
48+
},
49+
{
50+
name: "cross-source non-user match is rejected",
51+
dep: {
52+
source: "marketplace" as const,
53+
path: "/plugins/x/skills/helper",
54+
},
55+
primary: repoPrimary,
56+
expected: false,
57+
},
58+
{
59+
name: "windows-style sibling paths are compared by directory",
60+
dep: {
61+
source: "repo" as const,
62+
path: "C:\\app\\.claude\\skills\\helper",
63+
},
64+
primary: {
65+
source: "repo" as const,
66+
path: "C:\\app\\.claude\\skills\\deploy-checks",
67+
},
68+
expected: true,
69+
},
70+
])("$name", ({ dep, primary, expected }) => {
71+
expect(isTrustedSkillDependency(dep, primary)).toBe(expected);
72+
});
73+
});
74+
1775
describe("parseSkillContext", () => {
1876
it.each([
1977
{ name: "bare invocation", instructions: "/deploy-checks", expected: "" },

packages/ui/src/features/loops/loopSkill.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,33 @@ export function parseSkillContext(
4545
return trimmed;
4646
}
4747

48+
function parentDir(skillPath: string): string {
49+
const separatorIndex = Math.max(
50+
skillPath.lastIndexOf("/"),
51+
skillPath.lastIndexOf("\\"),
52+
);
53+
return separatorIndex > 0 ? skillPath.slice(0, separatorIndex) : skillPath;
54+
}
55+
56+
/**
57+
* Whether a resolved skill dependency may ride along in a loop's bundle set.
58+
* The resolver matches dependencies by name across every local skill, so a
59+
* same-named skill in another opened repository could otherwise shadow the
60+
* real dependency and get itself installed into every recurring run. Trusted:
61+
* the user's own machine-level skills, and siblings from the same skills
62+
* directory as the skill that referenced them.
63+
*/
64+
export function isTrustedSkillDependency(
65+
dep: { source: LoopSchemas.LoopSkillSourceEnum; path: string },
66+
primary: { source: LoopSchemas.LoopSkillSourceEnum; path: string },
67+
): boolean {
68+
if (dep.source === "user") return true;
69+
return (
70+
dep.source === primary.source &&
71+
parentDir(dep.path) === parentDir(primary.path)
72+
);
73+
}
74+
4875
/** The loop's attached bundles, tolerating a backend that predates the field. */
4976
export function loopSkillBundles(
5077
loop: LoopSchemas.Loop,

0 commit comments

Comments
 (0)