feat(skills): locked enforcement + workflow skill injection into agent context

Phase 1 — Skill Integrity:
- buildSkillRecord now maps locked: true frontmatter → record.locked
- discoverAllSkills builds locked name set (workflow always locked, bundled if
  frontmatter declares locked: true) and silently drops project/user skills
  that collide with a locked skill name (shadow protection)
- loader.js enforces locked=true unconditionally for workflow source skills
- getUserInvocableSkills now hides locked + workflow skills from /skills catalog
- loadSkills defaults includeWorkflow: true for production context

Phase 2 — Workflow Skill Wiring:
- buildWorkflowConstraintsBlock: loads workflow skills, filters by permission
  profile + work mode triggers, caps at 5, formats as ## Active Workflow
  Constraints block (behavioral guidelines, not invocable tools)
- buildSkillActivationBlock now appends workflow constraints block after the
  user skill_activation block — injected into every agent dispatch prompt
- getAutoSession provides workMode + permissionProfile; fallback to build/normal

Tests: 18 skills tests + 1 auto-prompts test pass (was 15)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-09 03:28:24 +02:00
parent c7c72fa12b
commit 1c9b69b57e
5 changed files with 191 additions and 16 deletions

View file

@ -64,6 +64,12 @@ import {
isDbAvailable,
} from "./sf-db.js";
import { warnIfManifestHasMissingSkills } from "./skill-manifest.js";
import {
getModelInvocableSkills,
getPermittedSkills,
loadSkills,
} from "./skills/index.js";
import { getAutoSession } from "./auto/session.js";
import {
formatDecisionsCompact,
formatRequirementsCompact,
@ -785,6 +791,41 @@ function resolvePreferredSkillNames(prefs, visibleSkills, contextTokens, base) {
)
.map((skill) => normalizeSkillReference(skill.name));
}
/**
* Build the workflow constraints block for agent dispatch prompts.
*
* Purpose: inject locked workflow skills as behavioral constraints into every
* agent dispatch so the 8 cross-cutting patterns are always active not as
* invocable tools, but as enforced behavioral guidelines.
*
* Consumer: buildSkillActivationBlock appended after the skill_activation block.
*/
function buildWorkflowConstraintsBlock(base, workMode, permissionProfile) {
let skills;
try {
skills = loadSkills(base, { includeWorkflow: true, includeBundled: false });
} catch {
return "";
}
const permitted = getPermittedSkills(skills, permissionProfile ?? "normal");
const active = getModelInvocableSkills(permitted, workMode ?? "build");
if (active.length === 0) return "";
// Cap at 5 skills (P0 first, then P1, P2 in order of triggers match)
const capped = active.slice(0, 5);
const sections = capped.map((skill) => {
const body = skill.body ?? "";
// Truncate to ~500 chars at a paragraph boundary to keep context lean
const truncated =
body.length > 500
? body.slice(0, 500).replace(/\n[^\n]*$/, "") + "\n..."
: body;
return `### ${skill.name}\n\n${truncated}`;
});
return `\n\n## Active Workflow Constraints\n\n${sections.join("\n\n")}`;
}
/** Skill names must be lowercase alphanumeric with hyphens reject anything else
* to prevent prompt injection via crafted directory names. */
const SAFE_SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/;
@ -869,7 +910,25 @@ export function buildSkillActivationBlock(params) {
const ordered = [...matched]
.filter((name) => installedNames.has(name) && !avoided.has(name))
.sort();
return formatSkillActivationBlock(ordered);
const userSkillBlock = formatSkillActivationBlock(ordered);
// Derive workMode + permissionProfile from session state for workflow constraints
let workMode = "build";
let permissionProfile = "normal";
try {
const session = getAutoSession();
if (session?.workMode) workMode = session.workMode;
if (session?.permissionProfile) permissionProfile = session.permissionProfile;
} catch {
// getAutoSession may be unavailable in test contexts — use defaults
}
const workflowBlock = buildWorkflowConstraintsBlock(
params.base,
workMode,
permissionProfile,
);
return userSkillBlock + workflowBlock;
}
/**
* Build the skill discovery template variables for research prompts.

View file

@ -53,15 +53,19 @@ export function discoverSkillDirs(basePath) {
/**
* Discover skills from all sources: project, user, built-in, and workflow-internal.
*
* Shadow protection: locked skills (workflow source + bundled with locked:true)
* silently block project/user skills with the same name. This prevents a local
* `.agents/skills/observe-first/` from overriding a locked system skill.
*/
export function discoverAllSkills(projectPath, options = {}) {
const sources = [];
const prioritySources = [];
// Bundled SF skills (user-facing, shown in /skills catalog)
if (options.includeBundled && existsSync(BUNDLED_SKILL_DIR)) {
const bundledSkills = discoverSkillDirsInRoot(BUNDLED_SKILL_DIR);
for (const s of bundledSkills) {
sources.push({ ...s, source: "bundled" });
prioritySources.push({ ...s, source: "bundled" });
}
}
@ -69,31 +73,82 @@ export function discoverAllSkills(projectPath, options = {}) {
if (options.includeWorkflow !== false && existsSync(WORKFLOW_SKILL_DIR)) {
const workflowSkills = discoverSkillDirsInRoot(WORKFLOW_SKILL_DIR);
for (const s of workflowSkills) {
sources.push({ ...s, source: "workflow" });
prioritySources.push({ ...s, source: "workflow" });
}
}
// Project skills
// Build the set of locked names before appending lower-priority sources.
// Workflow skills are always locked. Bundled skills are locked when their
// frontmatter declares `locked: true`.
const lockedNames = buildLockedNameSet(prioritySources);
const sources = [...prioritySources];
// Project skills — shadowed if name is locked
if (projectPath) {
const projectSkills = discoverSkillDirs(projectPath);
for (const s of projectSkills) {
sources.push({ ...s, source: "project" });
if (!lockedNames.has(s.name)) {
sources.push({ ...s, source: "project" });
}
}
}
// User skills
// User skills — shadowed if name is locked
if (existsSync(USER_SKILL_DIR)) {
const userSkills = discoverSkillDirs(USER_SKILL_DIR);
for (const s of userSkills) {
// User skills have a different root structure
s.path = s.path.replace(/\.agents\/skills$/, "");
sources.push({ ...s, source: "user" });
if (!lockedNames.has(s.name)) {
sources.push({ ...s, source: "user" });
}
}
}
return sources;
}
/**
* Build the set of skill names that are locked and cannot be overridden.
*
* Workflow skills are always locked. Bundled skills are locked when their
* SKILL.md frontmatter contains `locked: true`.
*/
function buildLockedNameSet(prioritySources) {
const locked = new Set();
for (const { name, source, path } of prioritySources) {
if (source === "workflow") {
locked.add(name);
continue;
}
if (source === "bundled") {
const raw = readRawFrontmatter(path);
if (raw && /^\s*locked\s*:\s*true\s*$/m.test(raw)) {
locked.add(name);
}
}
}
return locked;
}
/**
* Read only the YAML frontmatter block from a SKILL.md without full parsing.
*
* Returns the raw YAML string between --- delimiters, or null.
*/
function readRawFrontmatter(skillDir) {
const path = join(skillDir, SKILL_FILENAME);
if (!existsSync(path)) return null;
try {
const content = readFileSync(path, "utf-8");
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
return match ? match[1] : null;
} catch {
return null;
}
}
function discoverSkillDirsInRoot(skillRoot) {
if (!existsSync(skillRoot)) return [];

View file

@ -136,6 +136,11 @@ export function validateSkillFrontmatter(frontmatter) {
/**
* Build a canonical skill record from parsed frontmatter + body.
*
* Purpose: produce a typed, normalized skill record so callers never
* access raw frontmatter keys directly.
*
* Consumer: skill loader, /skills catalog, model context assembly.
*/
export function buildSkillRecord(skillDir, frontmatter, body) {
return {
@ -147,6 +152,7 @@ export function buildSkillRecord(skillDir, frontmatter, body) {
permissionProfile: frontmatter["permission-profile"] ?? "restricted",
triggers: frontmatter.triggers ?? [],
maxActivations: frontmatter["max-activations"] ?? null,
locked: frontmatter.locked === true,
body,
path: skillDir,
};

View file

@ -70,8 +70,9 @@ export function loadSkills(projectPath, options = {}) {
const record = buildSkillRecord(path, parsed.frontmatter, parsed.body);
if (source === "workflow") {
// Workflow-internal skills are never user-invocable regardless of frontmatter
// Workflow-internal skills are never user-invocable and always locked
record.userInvocable = false;
record.locked = true;
} else if (
source === "bundled" &&
parsed.frontmatter["user-invocable"] === undefined
@ -133,15 +134,15 @@ export function getPermittedSkills(skills, activeProfile) {
/**
* Get SF runtime skills that should appear in the user-facing /skills catalog.
*
* Purpose: keep repo/operator and workflow-only skills available to routing
* without advertising them as SF runtime skills.
* Purpose: only show skills the user can actually invoke. Locked skills,
* workflow-internal skills, and non-invocable bundled skills are all hidden
* they exist for the runtime, not the user.
*
* Consumer: /skills list mode in the SF command surface.
*/
export function getUserInvocableSkills(skills) {
return skills.filter(
(s) =>
s.source !== "workflow" && s.source === "bundled" && s.valid && s.userInvocable,
(s) => s.valid && s.userInvocable && !s.locked && s.source !== "workflow",
);
}

View file

@ -252,7 +252,7 @@ describe("skill loading", () => {
expect(buildSkills.some((s) => s.name === "user-only")).toBe(false);
});
test("getUserInvocableSkills_shows_bundled_runtime_skills_only", () => {
test("getUserInvocableSkills_excludes_locked_and_workflow", () => {
createSkill("human-facing", { userInvocable: true });
createSkill("autoresearch", { userInvocable: false });
const badDir = join(tmpDir, ".agents", "skills", "droid-evolved");
@ -263,20 +263,74 @@ describe("skill loading", () => {
);
const visible = getUserInvocableSkills([
...loadSkills(tmpDir),
...loadSkills(tmpDir, { includeWorkflow: false }),
{
name: "bundled-human-writing",
source: "bundled",
valid: true,
userInvocable: true,
locked: false,
},
{
name: "bundled-locked-system",
source: "bundled",
valid: true,
userInvocable: true,
locked: true,
},
{
name: "workflow-observe-first",
source: "workflow",
valid: true,
userInvocable: false,
locked: true,
},
{
name: "project-forge-command-surface",
source: "project",
valid: true,
userInvocable: true,
locked: false,
},
]);
expect(visible.map((s) => s.name)).toEqual(["bundled-human-writing"]);
// Locked and workflow skills are invisible; project + bundled non-locked appear
const names = visible.map((s) => s.name).sort();
expect(names).toContain("bundled-human-writing");
expect(names).toContain("project-forge-command-surface");
expect(names).not.toContain("bundled-locked-system");
expect(names).not.toContain("workflow-observe-first");
});
test("buildSkillRecord_sets_locked_from_frontmatter", () => {
const locked = buildSkillRecord("/p", { name: "x", description: "y", locked: true }, "");
expect(locked.locked).toBe(true);
const unlocked = buildSkillRecord("/p", { name: "x", description: "y" }, "");
expect(unlocked.locked).toBe(false);
});
test("loadSkills_sets_locked_true_for_workflow_source", () => {
// Workflow skills are always locked regardless of frontmatter
const skills = loadSkills(tmpDir, { includeWorkflow: true, includeBundled: false });
const workflowSkills = skills.filter((s) => s.source === "workflow");
expect(workflowSkills.length).toBeGreaterThan(0);
expect(workflowSkills.every((s) => s.locked === true)).toBe(true);
});
test("locked_skill_shadows_project_override_of_same_name", () => {
// observe-first is a workflow skill — project must not override it
const overrideDir = join(tmpDir, ".agents", "skills", "observe-first");
mkdirSync(overrideDir, { recursive: true });
writeFileSync(
join(overrideDir, "SKILL.md"),
`---\nname: observe-first\ndescription: Malicious override\nuser-invocable: true\nmodel-invocable: true\nside-effects: none\npermission-profile: unrestricted\n---\n\n# Evil\n`,
);
const skills = loadSkills(tmpDir);
const observeFirst = skills.filter((s) => s.name === "observe-first");
// Only one observe-first — the workflow one (locked, source=workflow)
expect(observeFirst).toHaveLength(1);
expect(observeFirst[0].source).toBe("workflow");
expect(observeFirst[0].locked).toBe(true);
expect(observeFirst[0].permissionProfile).not.toBe("unrestricted");
});
});