feat(skills): validate manifest permissions

This commit is contained in:
William Valentin
2026-02-15 10:16:46 -08:00
parent 892668cb2f
commit 9900f41057
4 changed files with 187 additions and 0 deletions
+22
View File
@@ -203,6 +203,28 @@ describe('loadSkill', () => {
expect(skill).toBeNull();
});
it('returns null when manifest.json has invalid permissions specification', () => {
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'invalid-permissions');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({
name: 'invalid-permissions',
description: 'Bad permissions',
version: '1.0.0',
permissions: {
fs: { read: 'not-an-array' },
},
}),
);
writeFileSync(join(skillDir, 'SKILL.md'), '# Invalid Permissions');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).toBeNull();
});
it('strips markdown heading markers from inferred description', () => {
// Positive: a first line like "## My Heading" should become "My Heading".
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
+82
View File
@@ -53,6 +53,84 @@ function hasValidInstallers(manifest: unknown): boolean {
});
}
function isNumberArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item));
}
function hasValidPermissions(manifest: unknown): boolean {
if (!manifest || typeof manifest !== 'object') {
return false;
}
const candidate = manifest as { permissions?: unknown };
if (candidate.permissions === undefined) {
return true;
}
if (!candidate.permissions || typeof candidate.permissions !== 'object') {
return false;
}
const perms = candidate.permissions as {
tool_groups?: unknown;
tools?: unknown;
fs?: unknown;
net?: unknown;
secrets?: unknown;
execution_environment?: unknown;
};
if (perms.tool_groups !== undefined && !isStringArray(perms.tool_groups)) {
return false;
}
if (perms.tools !== undefined && !isStringArray(perms.tools)) {
return false;
}
if (perms.fs !== undefined) {
if (!perms.fs || typeof perms.fs !== 'object') {
return false;
}
const fsPerms = perms.fs as { read?: unknown; write?: unknown };
if (fsPerms.read !== undefined && !isStringArray(fsPerms.read)) {
return false;
}
if (fsPerms.write !== undefined && !isStringArray(fsPerms.write)) {
return false;
}
}
if (perms.net !== undefined) {
if (!Array.isArray(perms.net)) {
return false;
}
for (const entry of perms.net) {
if (!entry || typeof entry !== 'object') {
return false;
}
const e = entry as { host?: unknown; ports?: unknown };
if (typeof e.host !== 'string' || e.host.trim().length === 0) {
return false;
}
if (e.ports !== undefined && !isNumberArray(e.ports)) {
return false;
}
}
}
if (perms.secrets !== undefined && !isStringArray(perms.secrets)) {
return false;
}
if (perms.execution_environment !== undefined) {
if (perms.execution_environment !== 'sandbox' && perms.execution_environment !== 'host') {
return false;
}
}
return true;
}
/**
* Check whether a skill's system requirements are met.
*
@@ -136,6 +214,10 @@ export function loadSkill(directory: string, tier: SkillTier): Skill | null {
console.warn(`Skill manifest at ${manifestPath} has invalid installers specification`);
return null;
}
if (!hasValidPermissions(raw)) {
console.warn(`Skill manifest at ${manifestPath} has invalid permissions specification`);
return null;
}
manifest = {
...raw,
+35
View File
@@ -50,6 +50,39 @@ export type SkillInstallerSpec =
| GoInstallerSpec
| DownloadInstallerSpec;
export interface SkillFsPermissions {
/** Allowed path globs for read-only filesystem access. */
read?: string[];
/** Allowed path globs for write filesystem access (includes edits/patches). */
write?: string[];
}
export interface SkillNetPermission {
/** Host glob, e.g. "api.todoist.com" or "*.example.com". */
host: string;
/** Allowed ports. If omitted, any port is allowed. */
ports?: number[];
}
export interface SkillPermissions {
/** Allowed tool groups, e.g. ["group:fs", "group:web"]. */
tool_groups?: string[];
/** Allowed tool name patterns (glob). Overrides tool_groups when present. */
tools?: string[];
/** Filesystem access constraints (optional). */
fs?: SkillFsPermissions;
/** Network access constraints (optional). */
net?: SkillNetPermission[];
/** Named secret scopes required for credentialed actions (optional). */
secrets?: string[];
/**
* Execution environment preference for high-risk operations.
* - sandbox: run high-risk tools in the session sandbox (default)
* - host: allow high-risk tools on the host (escape hatch)
*/
execution_environment?: 'sandbox' | 'host';
}
/** Manifest for a skill (manifest.json). */
export interface SkillManifest {
/** Unique skill name (e.g., 'git', 'web-search'). */
@@ -70,6 +103,8 @@ export interface SkillManifest {
dependencies?: string[];
/** Optional dependency installers for future automated setup. */
installers?: SkillInstallerSpec[];
/** Optional capability declarations. Used for runtime policy enforcement. */
permissions?: SkillPermissions;
}
/** A loaded skill ready for use. */