Skip to content

Commit 94cb600

Browse files
committed
Enforce the skill search deny at the worker mount
1 parent 60c79e3 commit 94cb600

9 files changed

Lines changed: 187 additions & 34 deletions

File tree

‎src/agent/directors/identity.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,24 @@ describe("formatDirectorSystemPrompt", () => {
2828
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.builder);
2929
expect(text).not.toContain("# Baked skill guidance");
3030
expect(text).toContain(
31-
"Optional skills (names for awareness; load with skill_search then use_skill): style, philosophy, native-runtime, idiot-proof, ponytail.",
31+
"Optional skills (names for awareness; load brief-named skills straight through use_skill, skill_search for discovery when mounted): style, philosophy, native-runtime, idiot-proof, ponytail.",
3232
);
3333
expect(text).toContain(WORKER_SKILL_SCOPING);
3434
expect(text).toContain(
3535
"Skills are available; search only when the brief names a skill or the task is outside your lane. For a small, bounded edit, do not search skills.",
3636
);
3737
expect(text).toContain(
38-
"Call skill_search for descriptions, then use_skill",
38+
"Load a brief-named skill straight through use_skill",
3939
);
4040
expect(text).toContain("load only the skills the task needs");
4141
});
4242

43+
test("worker guidance never mandates skill_search (deny-safe for grok/kimi leaves)", () => {
44+
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.builder);
45+
expect(text).not.toContain("Call skill_search for descriptions");
46+
expect(text).toContain("only when choosing among skills and it is mounted");
47+
});
48+
4349
test("skywalker does not bake Ponytail", () => {
4450
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.skywalker);
4551
expect(text).not.toContain("### ponytail");
@@ -60,7 +66,7 @@ describe("formatDirectorSystemPrompt", () => {
6066
});
6167
expect(text).not.toContain("# Baked skill guidance");
6268
expect(text).toContain(
63-
"Optional skills (names for awareness; load with skill_search then use_skill): does-not-exist-xyz.",
69+
"Optional skills (names for awareness; load brief-named skills straight through use_skill, skill_search for discovery when mounted): does-not-exist-xyz.",
6470
);
6571
expect(text).toContain(WORKER_SKILL_SCOPING);
6672
});

‎src/agent/directors/identity.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,17 @@ export function formatDirectorSystemPrompt(pkg: DirectorPackage): string {
3333
} else if (isPrimaryOrchestrator) {
3434
skillsLine = `Optional skills (names for awareness; use_skill is primary-mounted): ${names.join(", ")}.`;
3535
} else {
36-
skillsLine = `Optional skills (names for awareness; load with skill_search then use_skill): ${names.join(", ")}.`;
36+
skillsLine = `Optional skills (names for awareness; load brief-named skills straight through use_skill, skill_search for discovery when mounted): ${names.join(", ")}.`;
3737
}
3838

3939
// Worker skill scoping (CL-7668): skills mount on every worker, so search
4040
// only when the brief or the lane calls for it — never bulk-load.
41+
// Deny-safe: grok/kimi leaves omit skill_search (family policy) and load
42+
// brief-named skills straight through use_skill, so the guidance never
43+
// mandates a skill_search call — it is discovery-only, when mounted.
4144
const skillGuidance =
4245
names !== undefined && names.length > 0 && !isPrimaryOrchestrator
43-
? `${WORKER_SKILL_SCOPING} Call skill_search for descriptions, then use_skill with the skill name; load only the skills the task needs.`
46+
? `${WORKER_SKILL_SCOPING} Load a brief-named skill straight through use_skill with its exact name; call skill_search for descriptions only when choosing among skills and it is mounted, then use_skill; load only the skills the task needs.`
4447
: null;
4548

4649
const header = [

‎src/agent/directors/intern/package.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Do not invent fixes. If blocked, ask_director (parent, not the human). After the
8383
- Read error messages and report them
8484
- Execute mechanical, deterministic operations with zero ambiguity
8585
- Perform exact path writes when the brief spells them out
86-
- Load exactly the brief-named skill (if any) with \`skill_search\` then \`use_skill\` and follow it literally — never wander beyond it
86+
- Load exactly the brief-named skill (if any) with \`use_skill\` directly using its exact brief-given name — never a \`skill_search\` round-trip for a known name (\`skill_search\` is only for choosing among skills, and some workers do not mount it) — and follow it literally — never wander beyond it
8787
8888
**How to Report Back**
8989

‎src/agent/directors/tool-sets.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,21 @@ describe("SKYWALKER_TOOLS / ORCHESTRATOR_TOOLS", () => {
109109
expect(surface as readonly string[]).not.toContain("ask_operator");
110110
}
111111
});
112+
113+
test("no surface lists a tool twice (SKILL_TOOLS spread once via READ_TOOLS)", () => {
114+
for (const surface of [
115+
READ_TOOLS,
116+
BUILD_TOOLS,
117+
DOCS_TOOLS,
118+
REVIEW_TOOLS,
119+
INTERN_TOOLS,
120+
ORCHESTRATOR_TOOLS,
121+
SKYWALKER_TOOLS,
122+
] as const) {
123+
const names = surface as readonly string[];
124+
expect(new Set(names).size).toBe(names.length);
125+
}
126+
});
112127
});
113128

114129
describe("REVIEW_TOOLS / INTERN_TOOLS", () => {

‎src/agent/directors/tool-sets.ts‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export const PRODUCT_WRITE_TOOLS = [
4242
export const BUILD_TOOLS = [
4343
...READ_TOOLS,
4444
...PRODUCT_WRITE_TOOLS,
45-
...SKILL_TOOLS,
4645
"apply_patch",
4746
"shell",
4847
"update_plan",
@@ -62,17 +61,12 @@ export const BUILD_TOOLS = [
6261
export const DOCS_TOOLS = [
6362
...READ_TOOLS.filter((t) => t !== "run_shell" && t !== "shell_collect"),
6463
...PRODUCT_WRITE_TOOLS,
65-
...SKILL_TOOLS,
6664
"apply_patch",
6765
"update_plan",
6866
] as const;
6967

70-
/** Review / counsel: read surface + path writes + skill tools (lane discipline in prompts). */
71-
export const REVIEW_TOOLS = [
72-
...READ_TOOLS,
73-
...PRODUCT_WRITE_TOOLS,
74-
...SKILL_TOOLS,
75-
] as const;
68+
/** Review / counsel: read surface + path writes (skill tools arrive via READ_TOOLS; lane discipline in prompts). */
69+
export const REVIEW_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS] as const;
7670

7771
/** Mechanical intern: shell-first + path writes when the brief requires them. */
7872
export const INTERN_TOOLS = [
@@ -92,7 +86,6 @@ export const INTERN_TOOLS = [
9286
export const ORCHESTRATOR_TOOLS = [
9387
...READ_TOOLS,
9488
...PRODUCT_WRITE_TOOLS,
95-
...SKILL_TOOLS,
9689
"spawn_agent",
9790
"list_agents",
9891
"close_agent",

‎src/session/assemble-runtime.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ describe("createAdvertisedToolset", () => {
101101
expect(names).toContain("mcp__linear__save_issue");
102102
expect(names).not.toContain("mcp__acme__do");
103103
});
104+
105+
test("primary keeps skill_search for grok/kimi providers (always orchestrator; leaf deny lives at the worker mount)", () => {
106+
for (const getProvider of [
107+
() => ({ providerName: "xai", model: "grok-4-1-fast-non-reasoning" }),
108+
() => ({ providerName: "moonshot", model: "kimi-k2-0711" }),
109+
]) {
110+
const { computeAdvertised, isAdvertised } = createAdvertisedToolset(
111+
wiring({ getProvider }),
112+
);
113+
const names = computeAdvertised([
114+
def("skill_search"),
115+
def("use_skill"),
116+
]).map((d) => d.name);
117+
expect(names).toContain("skill_search");
118+
expect(names).toContain("use_skill");
119+
expect(isAdvertised("skill_search")).toBe(true);
120+
}
121+
});
104122
});
105123

106124
describe("loadSessionLocalSettings", () => {

‎src/session/assemble-runtime.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,12 @@ export function createAdvertisedToolset(args: {
370370
];
371371
const activated = createActivatedToolTracker();
372372
// Advertise then family-gate wire schemas (kimi gets a non-recursive present).
373-
// advertisedToolDeny (CL-7668) drops grok/kimi-leaf skill_search from the
374-
// wire prefix and the dispatch gate; orchestrators keep the full surface.
375-
// Resolved per call so a live model switch re-gates without a rebuild.
373+
// The primary session is always the orchestrator (SessionMode is the single
374+
// literal "orchestrator"), so orchestrator: true is passed directly instead
375+
// of comparing against sessionMode — the comparison was always true and the
376+
// deny always [], an unexecuted committed claim. Leaf gating lives at the
377+
// worker mount in subagent/run.ts, which resolves the same policy with the
378+
// leaf's provider and its own orchestrator flag.
376379
// use_skill is never denied — leaves load brief-named skills by exact name.
377380
const deniedFor = (provider: {
378381
providerName: string;
@@ -381,7 +384,7 @@ export function createAdvertisedToolset(args: {
381384
resolveModelFamilyPolicy({
382385
providerName: provider.providerName,
383386
model: provider.model,
384-
orchestrator: args.sessionMode === "orchestrator",
387+
orchestrator: true,
385388
}).advertisedToolDeny;
386389
const computeAdvertised = (
387390
all: readonly ToolDefinition[],

‎src/subagent/run-skill-scope.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,106 @@ describe("runSubAgent worker skill mounts (CL-7668)", () => {
191191
'No skills matched "off-lane". Try different keywords describing the capability.',
192192
);
193193
}, 15_000);
194+
195+
test("grok/kimi leaves omit skill_search but keep scoped use_skill; orchestrators keep both", async () => {
196+
const cwd = await tmpCwd();
197+
await writeSkill(
198+
cwd,
199+
"style",
200+
"Code style rules.",
201+
"Follow the style guide.",
202+
);
203+
204+
async function runCase(params: RunSubAgentParams): Promise<{
205+
searchCalls: number;
206+
useSkillCalls: number;
207+
}> {
208+
let searchCalls = 0;
209+
let useSkillCalls = 0;
210+
await runWithFailingInference((baseURL) =>
211+
withMockedModuleDuring(
212+
import.meta.resolve("../agent/skill-search.js"),
213+
(real: typeof import("../agent/skill-search.js")) => ({
214+
...real,
215+
createSkillSearchTool: (
216+
args: Parameters<typeof real.createSkillSearchTool>[0],
217+
) => {
218+
searchCalls += 1;
219+
return real.createSkillSearchTool(args);
220+
},
221+
}),
222+
() =>
223+
withMockedModuleDuring(
224+
import.meta.resolve("../agent/use-skill.js"),
225+
(real: typeof import("../agent/use-skill.js")) => ({
226+
...real,
227+
createUseSkillTool: (...args: unknown[]) => {
228+
useSkillCalls += 1;
229+
return (
230+
real.createUseSkillTool as (...a: never[]) => unknown
231+
)(...(args as never[]));
232+
},
233+
}),
234+
async () => {
235+
const { runSubAgent: run } = await import("./run.js");
236+
await run({
237+
...params,
238+
cwd,
239+
workdirBase: join(cwd, ".ctx"),
240+
provider: { ...params.provider, baseURL },
241+
}).catch(() => {
242+
// Inference fails by design; mount decisions run first.
243+
});
244+
},
245+
),
246+
),
247+
);
248+
return { searchCalls, useSkillCalls };
249+
}
250+
251+
function leafParams(
252+
providerName: string,
253+
model: string,
254+
extra?: Partial<RunSubAgentParams>,
255+
): RunSubAgentParams {
256+
return {
257+
cwd,
258+
workdirBase: join(cwd, ".ctx"),
259+
permissionGate: testPermissionGate,
260+
provider: { providerName, baseURL: "http://localhost", model },
261+
description: "skill deny probe",
262+
prompt: "no-op",
263+
allowedSkillNames: ["style"],
264+
...extra,
265+
};
266+
}
267+
268+
// Grok + kimi leaves: deny executes — skill_search omitted, use_skill kept.
269+
for (const [providerName, model] of [
270+
["xai", "grok-4-1-fast-non-reasoning"],
271+
["moonshot", "kimi-k2-0711"],
272+
] as const) {
273+
const counts = await runCase(leafParams(providerName, model));
274+
expect({ providerName, ...counts }).toEqual({
275+
providerName,
276+
searchCalls: 0,
277+
useSkillCalls: 1,
278+
});
279+
}
280+
281+
// Default family leaf: both mount (existing behavior unchanged).
282+
expect(await runCase(leafParams("test", "test-model"))).toEqual({
283+
searchCalls: 1,
284+
useSkillCalls: 1,
285+
});
286+
287+
// Grok orchestrator: deny cleared — both mount.
288+
expect(
289+
await runCase(
290+
leafParams("xai", "grok-4-1-fast-non-reasoning", {
291+
orchestrator: true,
292+
}),
293+
),
294+
).toEqual({ searchCalls: 1, useSkillCalls: 1 });
295+
}, 30_000);
194296
});

‎src/subagent/run.ts‎

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -719,19 +719,34 @@ async function runSubAgentInner(
719719
}),
720720
];
721721

722-
// Every worker mounts skill_search + use_skill, scoped to the dispatch's
723-
// allowedSkillNames (pkg.optionalSkills). Mounted before the capability
724-
// filter so worker allowlists keep them like any other named tool; the
725-
// scope cannot widen — use_skill refuses names outside the allowlist.
722+
// Worker skill mounts, family-gated (CL-7668): grok/kimi leaves omit
723+
// skill_search and load brief-named skills straight through use_skill,
724+
// which is never denied. Scoped to the dispatch's allowedSkillNames
725+
// (pkg.optionalSkills). Mounted before the capability filter so worker
726+
// allowlists keep them like any other named tool; the scope cannot
727+
// widen — use_skill refuses names outside the allowlist.
728+
// Resolved here (not below with the director wiring) so the mount itself
729+
// executes the deny; toolNames/prompt derivation below inherits it.
730+
const modelFamilyPolicy = resolveModelFamilyPolicy({
731+
providerName: params.provider.providerName,
732+
model: params.provider.model,
733+
orchestrator: params.orchestrator === true,
734+
});
726735
const skillSnapshot = await discoverSkills(params.cwd);
736+
const skillSearchDenied =
737+
modelFamilyPolicy.advertisedToolDeny.includes("skill_search");
727738
tools = [
728739
...tools,
729-
createSkillSearchTool({
730-
skills: skillSnapshot,
731-
...(params.allowedSkillNames !== undefined
732-
? { allowedNames: params.allowedSkillNames }
733-
: {}),
734-
}),
740+
...(skillSearchDenied
741+
? []
742+
: [
743+
createSkillSearchTool({
744+
skills: skillSnapshot,
745+
...(params.allowedSkillNames !== undefined
746+
? { allowedNames: params.allowedSkillNames }
747+
: {}),
748+
}),
749+
]),
735750
createUseSkillTool(
736751
params.cwd,
737752
[],
@@ -981,11 +996,9 @@ async function runSubAgentInner(
981996
});
982997
};
983998

984-
const modelFamilyPolicy = resolveModelFamilyPolicy({
985-
providerName: params.provider.providerName,
986-
model: params.provider.model,
987-
orchestrator: params.orchestrator === true,
988-
});
999+
// modelFamilyPolicy is resolved above at the skill mount so the
1000+
// grok/kimi skill_search deny executes there; reused here for stall
1001+
// timing and wire-schema normalization.
9891002

9901003
// Family-gate wire schemas the same way main sessions do (kimi present rewrite).
9911004
// Sub-agent toolsets currently omit `present` (main-session only); normalize is

0 commit comments

Comments
 (0)