Skip to content

Commit 1c13ce3

Browse files
committed
close review gaps in skill bundling flow
1 parent 0a9039e commit 1c13ce3

5 files changed

Lines changed: 107 additions & 23 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
455455

456456
function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
457457
const updateLoop = useUpdateLoop(loop.id);
458+
const primarySkill = primaryLoopSkillBundle(loop);
458459
const [draft, setDraft] = useState<string | null>(null);
459460
// Escape reverts and blurs; skip the resulting onBlur save.
460461
const skipCommit = useRef(false);
@@ -516,6 +517,13 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
516517
}
517518
}}
518519
/>
520+
{primarySkill ? (
521+
<Text className="text-[11px] text-gray-10 leading-snug">
522+
This loop runs the {primarySkill.skill_name} skill: the leading /
523+
{primarySkill.skill_name} line invokes its attached snapshot. Editing
524+
here changes only the text; use Edit to change or detach the skill.
525+
</Text>
526+
) : null}
519527
</Flex>
520528
);
521529
}

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,22 @@ export function LoopForm({ loop }: LoopFormProps) {
188188
error instanceof Error ? error.message : undefined;
189189
if (!isEdit) {
190190
// Roll the just-created loop back rather than leaving one that
191-
// fires `/skill-name` with no bundle behind it.
192-
await deleteLoop.mutateAsync(saved.id).catch(() => {});
193-
toast.error("Failed to create loop", { description });
191+
// fires `/skill-name` with no bundle behind it. If the rollback
192+
// itself fails, an orphaned loop exists — say so instead of
193+
// pretending nothing was created.
194+
try {
195+
await deleteLoop.mutateAsync(saved.id);
196+
toast.error("Failed to create loop", { description });
197+
} catch {
198+
toast.error("Loop created, but attaching its skill failed", {
199+
description: [
200+
description,
201+
`Delete "${saved.name}" or re-save it from Edit.`,
202+
]
203+
.filter(Boolean)
204+
.join(" "),
205+
});
206+
}
194207
return;
195208
}
196209
// Keep the form open with its state intact: saving again retries

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

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,24 @@ import {
33
replaceLoopSkillBundles,
44
} from "@posthog/api-client/loops";
55
import { useHostTRPCClient } from "@posthog/host-router/react";
6-
import { useMutation, useQueryClient } from "@tanstack/react-query";
6+
import {
7+
type QueryClient,
8+
useMutation,
9+
useQueryClient,
10+
} from "@tanstack/react-query";
711
import { isTrustedSkillDependency } from "../loopSkill";
812
import { loopsKeys } from "./loopsKeys";
913
import { useLoopsClient } from "./useLoopsClient";
1014

15+
function applyLoopToCache(
16+
queryClient: QueryClient,
17+
projectId: string | null,
18+
loop: LoopSchemas.Loop,
19+
): void {
20+
queryClient.setQueryData(loopsKeys.detail(projectId, loop.id), loop);
21+
void queryClient.invalidateQueries({ queryKey: loopsKeys.list(projectId) });
22+
}
23+
1124
export interface LoopLocalSkillRef {
1225
name: string;
1326
source: LoopSchemas.LoopSkillSourceEnum;
@@ -81,15 +94,8 @@ export function useReplaceLoopSkillBundles() {
8194
uploads,
8295
);
8396
},
84-
onSuccess: (loop) => {
85-
queryClient.setQueryData(
86-
loopsKeys.detail(loopsClient?.projectId ?? null, loop.id),
87-
loop,
88-
);
89-
void queryClient.invalidateQueries({
90-
queryKey: loopsKeys.list(loopsClient?.projectId ?? null),
91-
});
92-
},
97+
onSuccess: (loop) =>
98+
applyLoopToCache(queryClient, loopsClient?.projectId ?? null, loop),
9399
});
94100
}
95101

@@ -118,14 +124,7 @@ export function useSyncLoopSkillBundles() {
118124
uploads,
119125
);
120126
},
121-
onSuccess: (loop) => {
122-
queryClient.setQueryData(
123-
loopsKeys.detail(loopsClient?.projectId ?? null, loop.id),
124-
loop,
125-
);
126-
void queryClient.invalidateQueries({
127-
queryKey: loopsKeys.list(loopsClient?.projectId ?? null),
128-
});
129-
},
127+
onSuccess: (loop) =>
128+
applyLoopToCache(queryClient, loopsClient?.projectId ?? null, loop),
130129
});
131130
}

packages/workspace-server/src/services/skills/skills.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,25 @@ describe("write-path guard", () => {
502502
).rejects.toThrow("not a known skill directory");
503503
});
504504

505-
it("rejects a symlinked skill root", async () => {
505+
it("rejects a repo skill reached through a symlinked skills directory", async () => {
506+
// The leaf check alone misses this: a repo committing `.claude/skills`
507+
// itself as a symlink makes every skill under it resolve outside the repo.
508+
const outside = path.join(root, "outside-skills");
509+
await createSkill(outside, "escapee");
510+
await rm(repoSkillsDir, { recursive: true, force: true });
511+
await symlink(outside, repoSkillsDir, "dir");
512+
const service = makeService();
513+
514+
await expect(
515+
service.bundleLocalSkill({
516+
name: "escapee",
517+
source: "repo",
518+
path: path.join(repoSkillsDir, "escapee"),
519+
}),
520+
).rejects.toThrow("resolves outside its repository");
521+
});
522+
523+
it("rejects a symlinked repo skill root", async () => {
506524
// A repository could commit `.claude/skills/foo` as a symlink to a
507525
// directory outside the repo; bundling must refuse to follow it rather
508526
// than upload the external target.
@@ -517,6 +535,24 @@ describe("write-path guard", () => {
517535
source: "repo",
518536
path: linkPath,
519537
}),
538+
).rejects.toThrow("resolves outside its repository");
539+
});
540+
541+
it("rejects a symlinked skill root outside repo roots", async () => {
542+
// Non-repo roots have no repository anchor, so the bundler's own
543+
// leaf-symlink check is the guard there.
544+
const target = await createSkill(root, "linked");
545+
await mkdir(userSkillsHome.dir, { recursive: true });
546+
const linkPath = path.join(userSkillsHome.dir, "linked");
547+
await symlink(target, linkPath, "dir");
548+
const service = makeService();
549+
550+
await expect(
551+
service.bundleLocalSkill({
552+
name: "linked",
553+
source: "user",
554+
path: linkPath,
555+
}),
520556
).rejects.toThrow("not a symlink");
521557
});
522558

packages/workspace-server/src/services/skills/skills.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,13 +499,41 @@ export class SkillsService {
499499
input: BundleLocalSkillInput,
500500
): Promise<BundleLocalSkillOutput> {
501501
const skillDir = await this.resolveKnownSkillDir(input.path);
502+
await this.assertRepoSkillStaysInRepo(skillDir);
502503
return bundleLocalSkill({
503504
name: input.name,
504505
source: input.source,
505506
skillPath: skillDir,
506507
});
507508
}
508509

510+
/**
511+
* A repository can commit any ancestor of its skills (`.claude` or
512+
* `.claude/skills`) as a symlink pointing outside the repo, which passes the
513+
* lexical known-root check and the bundler's leaf-symlink check yet bundles
514+
* an external directory. Uploads must stay inside the real repository, so
515+
* anchor the check on the workspace folder itself — the one path segment a
516+
* repo author cannot control.
517+
*/
518+
private async assertRepoSkillStaysInRepo(skillDir: string): Promise<void> {
519+
const parent = path.dirname(skillDir);
520+
const folders = await this.folders.getFolders();
521+
const owningFolder = folders.find(
522+
(folder) =>
523+
path.resolve(path.join(folder.path, ".claude", "skills")) === parent,
524+
);
525+
if (!owningFolder) return;
526+
const [realSkill, realFolder] = await Promise.all([
527+
fs.promises.realpath(skillDir),
528+
fs.promises.realpath(path.resolve(owningFolder.path)),
529+
]);
530+
if (!realSkill.startsWith(realFolder + path.sep)) {
531+
throw new Error(
532+
"Access denied: repository skill resolves outside its repository",
533+
);
534+
}
535+
}
536+
509537
/**
510538
* Expand a set of tagged skill refs to include their transitive dependency
511539
* skills, so a skill that needs another pulls it into the same cloud run.

0 commit comments

Comments
 (0)