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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ junit.xml

# codeoid is a bun project — bun.lock is the lockfile; never commit an npm lock
package-lock.json

# Local Claude Code state — agent worktree pointers + personal settings.
# These were committed by accident once; keep them out of the tree.
.claude/worktrees/
.claude/settings.local.json
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"format": "biome format --write src/ packages/protocol/src/ packages/core/src/",
"test": "bun test src/tests src/daemon packages/protocol/src packages/core/src",
"test:coverage": "bun test src/tests src/daemon packages/protocol/src packages/core/src --coverage --coverage-reporter=lcov --reporter=junit --reporter-outfile=junit.xml",
"test:integration": "bun test src/tests/*.integration.test.ts",
"test:integration": "bun test src/integration",
"test:web": "cd web && bun run test",
"typecheck": "bun x tsc --noEmit && bun x tsc --noEmit -p packages/protocol && bun x tsc --noEmit -p packages/core",
"smoke": "bash scripts/release-smoke.sh",
Expand Down
42 changes: 37 additions & 5 deletions src/daemon/pipeline/pack-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,16 @@ describe("install / trust / select / remove", () => {
const { svc, persisted } = makeService({ cacheDir, skillsDir, fixture, sink });
await svc.addRegistry({ url: "https://github.com/highflame-ai/ai-factory.git" });

const installed = svc.install({ packId: "aif-sdlc", trusted: false });
const installed = svc.install({ packId: "aif-sdlc", trusted: true });
const pack = installed.find((p) => p.id === "aif-sdlc")!;
expect(pack).toBeTruthy();
expect(pack.trusted).toBe(false);
expect(pack.trusted).toBe(true);
expect(pack.active).toBe(true); // registered into the sink
expect(pack.registry).toBe("ai-factory");
expect(pack.phases.map((ph) => ph.id)).toEqual(["impl"]);
expect(pack.roles).toEqual(["implementer"]);
expect(sink._packs.has("aif-sdlc")).toBe(true);
// config persisted, and skills linked into the skills dir
// config persisted, and skills linked into the skills dir (trusted pack)
expect(persisted.at(-1)!.packs).toHaveLength(1);
expect(existsSync(join(skillsDir, "spec"))).toBe(true);
expect(existsSync(join(skillsDir, "review"))).toBe(true);
Expand Down Expand Up @@ -242,11 +242,29 @@ describe("install / trust / select / remove", () => {
const skillsDir = join(tmp(), "skills");
const { svc } = makeService({ cacheDir: join(tmp(), "c"), skillsDir, fixture });
await svc.addRegistry({ url: "https://github.com/a/reg.git" });
svc.install({ packId: "p" });
svc.install({ packId: "p", trusted: true });
expect(existsSync(join(skillsDir, "real"))).toBe(true); // a real skill dir is linked
expect(existsSync(join(skillsDir, "evil"))).toBe(false); // the symlink is NOT
});

test("an UNTRUSTED pack links no skills (declaring is not executing)", async () => {
const fixture = tmp();
writeRegistry(fixture, ["p"], ["spec", "review"]);
const skillsDir = join(tmp(), "skills");
const sink = fakeSink();
const { svc } = makeService({ cacheDir: join(tmp(), "c"), skillsDir, fixture, sink });
await svc.addRegistry({ url: "https://github.com/a/reg.git" });

// Default trust is false. The pack still installs and indexes...
const installed = svc.install({ packId: "p" });
expect(installed.find((x) => x.id === "p")!.trusted).toBe(false);
expect(sink._packs.has("p")).toBe(true);
// ...but its `!`…`` skill frontmatter must not become runnable host shell
// without an explicit trust opt-in, exactly like its command gates.
expect(existsSync(join(skillsDir, "spec"))).toBe(false);
expect(existsSync(join(skillsDir, "review"))).toBe(false);
});

test("skill-linking never clobbers an existing skill", async () => {
const fixture = tmp();
writeRegistry(fixture, ["p"], ["spec"]);
Expand All @@ -255,7 +273,7 @@ describe("install / trust / select / remove", () => {
writeFileSync(join(skillsDir, "spec", "SKILL.md"), "PRE-EXISTING");
const { svc } = makeService({ cacheDir: join(tmp(), "c"), skillsDir, fixture });
await svc.addRegistry({ url: "https://github.com/a/reg.git" });
svc.install({ packId: "p" });
svc.install({ packId: "p", trusted: true });
// The pre-existing skill dir is untouched (not a symlink to the registry).
expect(require("node:fs").readFileSync(join(skillsDir, "spec", "SKILL.md"), "utf8")).toBe("PRE-EXISTING");
});
Expand All @@ -271,6 +289,20 @@ describe("install / trust / select / remove", () => {
expect(after.find((x) => x.id === "p")!.trusted).toBe(true);
});

test("trusting an installed-untrusted pack links its skills (no re-install)", async () => {
const fixture = tmp();
writeRegistry(fixture, ["p"], ["spec"]);
const skillsDir = join(tmp(), "skills");
const { svc } = makeService({ cacheDir: join(tmp(), "c"), skillsDir, fixture });
await svc.addRegistry({ url: "https://github.com/a/reg.git" });

svc.install({ packId: "p" }); // untrusted → not linked
expect(existsSync(join(skillsDir, "spec"))).toBe(false);

svc.trust("p", true); // trusting it must make the skill runnable
expect(existsSync(join(skillsDir, "spec"))).toBe(true);
});

test("select sets the default pack (and rejects an uninstalled id)", () => {
const dir = join(tmp(), "p");
writePack(dir, "sel");
Expand Down
18 changes: 17 additions & 1 deletion src/daemon/pipeline/pack-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,14 @@ export class PackService {

// Make the pack runnable: link the registry's slash-skills into the skills
// dir (best-effort, never overwrites an existing skill).
if (registryRoot) this.#linkSkills(registryRoot);
//
// Trust-gated, same as command gates. A skill's `!`…`` frontmatter runs a
// host shell at slash-command expansion time, so linking an UNTRUSTED
// pack's skills is executing declared shell without the operator opt-in the
// gate path already requires — the exact "declaring is not executing" model
// in loadPack. Only trusted packs get linked; an untrusted pack still
// installs and indexes, it just contributes no runnable slash-skills.
if (registryRoot && trusted) this.#linkSkills(registryRoot);

return this.installed();
}
Expand All @@ -367,6 +374,15 @@ export class PackService {
this.#save();
// Re-register at the new trust level (gates are compiled at load).
this.#manager?.()?.installPack(loadPack(entry.dir, { trusted }));
// Trust gates skill linking (see install). Toggling ON must now make the
// pack's slash-skills runnable — otherwise trusting is a no-op for skills
// and the pack silently stays half-installed. (Toggling OFF leaves the
// links; removing them belongs to `remove`, and unlinking live skills
// mid-session is out of scope here.)
if (trusted && entry.registry) {
const root = this.#cachePath(entry.registry);
if (existsSync(root)) this.#linkSkills(root);
}
return this.installed();
}

Expand Down
Loading
Loading