feat(context): implement runtime discovery and permission validation#238
feat(context): implement runtime discovery and permission validation#238Protocol-zero-0 wants to merge 1 commit intodarrenhinde:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements the core runtime context discovery and permission validation system as requested in issue #140. It adds the foundational infrastructure for discovering YAML/JSON context definitions from .opencode/context and validating agent skill/tool permissions.
Changes:
- Adds Zod schemas and TypeScript types for context definitions and agent permissions
- Implements ContextDiscovery class to scan and load context files from the filesystem
- Implements PermissionValidator class to validate agent permissions and check skill/tool access
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 14 comments.
| File | Description |
|---|---|
| packages/plugin-abilities/src/context/types.ts | Defines Zod schemas and TypeScript types for ContextDefinition, LoadedContext, and AgentPermissions |
| packages/plugin-abilities/src/context/discovery.ts | Implements ContextDiscovery class for scanning .opencode/context directory and loading context files |
| packages/plugin-abilities/src/validator/permissions.ts | Implements PermissionValidator class for validating agent permissions and checking skill/tool access |
| packages/plugin-abilities/src/index.ts | Exports new context discovery and permission validation modules |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| checkSkillAccess(agentPermissions: AgentPermissions, skillName: string, toolName: string): boolean { | ||
| const permission = agentPermissions.permissions.find(p => p.skill === skillName); | ||
|
|
||
| if (!permission) { | ||
| // Default deny if no explicit permission for skill | ||
| return false; | ||
| } | ||
|
|
||
| if (!permission.tools) { | ||
| // If tools not specified, assume strict/deny or allow all? | ||
| // Security best practice: Default deny. |
There was a problem hiding this comment.
The comment "Security best practice: Default deny" explains the rationale but remains as a comment instead of being removed. According to the issue description, this feature implements "Skill permission validation in agent frontmatter" with "Tool access restrictions per skill". The behavior of undefined tools should be explicitly documented in the schema or type definition, not just in implementation comments, to ensure API consumers understand the security model.
| checkSkillAccess(agentPermissions: AgentPermissions, skillName: string, toolName: string): boolean { | |
| const permission = agentPermissions.permissions.find(p => p.skill === skillName); | |
| if (!permission) { | |
| // Default deny if no explicit permission for skill | |
| return false; | |
| } | |
| if (!permission.tools) { | |
| // If tools not specified, assume strict/deny or allow all? | |
| // Security best practice: Default deny. | |
| /** | |
| * Determines whether the given {@link skillName} is allowed to use {@link toolName} | |
| * according to the provided {@link AgentPermissions}. | |
| * | |
| * Security model: | |
| * - If there is no permission entry for the skill, access is denied (default deny). | |
| * - If the permission entry does not define a `tools` array, access is denied (default deny). | |
| * - If `tools` contains `"*"`, all tools are allowed for that skill. | |
| * - Otherwise, only tools explicitly listed in `tools` are allowed. | |
| */ | |
| checkSkillAccess(agentPermissions: AgentPermissions, skillName: string, toolName: string): boolean { | |
| const permission = agentPermissions.permissions.find(p => p.skill === skillName); | |
| if (!permission) { | |
| // No explicit permission for this skill -> deny access (default deny). | |
| return false; | |
| } | |
| if (!permission.tools) { | |
| // Tools list omitted for this skill -> deny access (default deny). |
| export class PermissionValidator { | ||
| validateAgentPermissions(data: unknown): PermissionValidationResult { | ||
| const result = AgentPermissionsSchema.safeParse(data); | ||
|
|
||
| if (!result.success) { | ||
| return { | ||
| valid: false, | ||
| errors: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`), | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| valid: true, | ||
| errors: [], | ||
| }; | ||
| } | ||
|
|
||
| checkSkillAccess(agentPermissions: AgentPermissions, skillName: string, toolName: string): boolean { | ||
| const permission = agentPermissions.permissions.find(p => p.skill === skillName); | ||
|
|
||
| if (!permission) { | ||
| // Default deny if no explicit permission for skill | ||
| return false; | ||
| } | ||
|
|
||
| if (!permission.tools) { | ||
| // If tools not specified, assume strict/deny or allow all? | ||
| // Security best practice: Default deny. | ||
| return false; | ||
| } | ||
|
|
||
| if (permission.tools.includes('*') || permission.tools.includes(toolName)) { | ||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing test coverage for the new PermissionValidator class. The codebase has comprehensive test coverage (87 tests across 7 test files as mentioned in README.md), including validator.test.ts for validation logic. The new permission validation functionality should have corresponding tests to verify: 1) validateAgentPermissions correctly validates schema, 2) checkSkillAccess handles wildcard permissions, 3) checkSkillAccess handles missing skills, 4) checkSkillAccess handles missing tools array, and 5) checkSkillAccess handles specific tool names.
| return null; | ||
| } | ||
| } | ||
| // TODO: Handle URL and API types |
There was a problem hiding this comment.
The loadContext method only implements the 'file' type and returns null for 'url' and 'api' types with a TODO comment. According to the ContextTypeSchema in types.ts, these are valid context types. This creates an incomplete implementation where valid context definitions will silently fail to load. Consider either: 1) throwing an error for unsupported types to make the limitation explicit, 2) filtering these types during discovery, or 3) implementing basic support for these types before merging.
| // TODO: Handle URL and API types | |
| if (definition.type === 'url' || definition.type === 'api') { | |
| console.warn( | |
| `Context type '${definition.type}' is currently not supported by loadContext and will not be loaded.`, | |
| ); | |
| return null; | |
| } | |
| console.warn( | |
| `Unknown or unsupported context type '${(definition as any).type}' encountered in loadContext; skipping.`, | |
| ); |
| source: filePath, | ||
| }; | ||
| } catch (error) { | ||
| console.warn(`Failed to read context file ${definition.path}:`, error); |
There was a problem hiding this comment.
The loadContext method uses console.warn for file read failures. The existing loader pattern (loader/index.ts:76) uses console.error with a prefix for file loading failures. For consistency, this should use console.error with an appropriate prefix like '[context]'.
| console.warn(`Failed to read context file ${definition.path}:`, error); | |
| console.error(`[context] Failed to read context file ${definition.path}:`, error); |
| export class ContextDiscovery { | ||
| private rootDir: string; | ||
| private contextDir: string; | ||
|
|
||
| constructor(options: DiscoveryOptions = {}) { | ||
| this.rootDir = options.rootDir || process.cwd(); | ||
| this.contextDir = options.contextDir || path.join(this.rootDir, '.opencode', 'context'); | ||
| } | ||
|
|
||
| async discover(): Promise<ContextDefinition[]> { | ||
| const files = await glob('**/*.{yaml,yml,json}', { | ||
| cwd: this.contextDir, | ||
| ignore: ['node_modules/**'], | ||
| }); | ||
|
|
||
| const definitions: ContextDefinition[] = []; | ||
|
|
||
| for (const file of files) { | ||
| const filePath = path.join(this.contextDir, file); | ||
| try { | ||
| const content = await fs.readFile(filePath, 'utf-8'); | ||
| const parsed = file.endsWith('.json') ? JSON.parse(content) : parseYaml(content); | ||
|
|
||
| // Handle array of definitions or single definition | ||
| const items = Array.isArray(parsed) ? parsed : [parsed]; | ||
|
|
||
| for (const item of items) { | ||
| const result = ContextDefinitionSchema.safeParse(item); | ||
| if (result.success) { | ||
| definitions.push(result.data); | ||
| } else { | ||
| console.warn(`Invalid context definition in ${file}:`, result.error.format()); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.warn(`Failed to load context file ${file}:`, error); | ||
| } | ||
| } | ||
|
|
||
| return definitions; | ||
| } | ||
|
|
||
| async loadContext(definition: ContextDefinition): Promise<LoadedContext | null> { | ||
| if (definition.type === 'file') { | ||
| const filePath = path.resolve(this.rootDir, definition.path); | ||
| try { | ||
| const content = await fs.readFile(filePath, 'utf-8'); | ||
| return { | ||
| definition, | ||
| content, | ||
| source: filePath, | ||
| }; | ||
| } catch (error) { | ||
| console.warn(`Failed to read context file ${definition.path}:`, error); | ||
| return null; | ||
| } | ||
| } | ||
| // TODO: Handle URL and API types | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing test coverage for the new ContextDiscovery class. The codebase has comprehensive test coverage (87 tests across 7 test files as mentioned in README.md). The new context discovery functionality should have corresponding tests to verify: 1) discover method finds YAML and JSON files, 2) discover method handles multiple definitions in one file, 3) discover method validates schemas correctly, 4) discover method handles missing directories gracefully, 5) loadContext method loads file types correctly, and 6) error handling for invalid files.
|
|
||
| async loadContext(definition: ContextDefinition): Promise<LoadedContext | null> { | ||
| if (definition.type === 'file') { | ||
| const filePath = path.resolve(this.rootDir, definition.path); |
There was a problem hiding this comment.
Potential path traversal vulnerability in loadContext. The method uses path.resolve(this.rootDir, definition.path) which could allow loading files outside the intended directory if definition.path contains path traversal sequences like '../../../etc/passwd'. Since ContextDefinition comes from user-controlled YAML/JSON files in .opencode/context, this creates a security risk. Consider validating that the resolved path is within the allowed directory using path normalization and checking if the resolved path starts with rootDir.
| const filePath = path.resolve(this.rootDir, definition.path); | |
| const rootDir = path.resolve(this.rootDir); | |
| const filePath = path.resolve(rootDir, definition.path); | |
| // Ensure the resolved path is within the allowed root directory | |
| if (filePath !== rootDir && !filePath.startsWith(rootDir + path.sep)) { | |
| console.warn( | |
| `Refusing to load context file outside of root directory: ${definition.path} -> ${filePath}`, | |
| ); | |
| return null; | |
| } |
| content: string; | ||
| source: string; | ||
| } | ||
|
|
There was a problem hiding this comment.
The SkillPermission schema makes the tools field optional. However, according to the permission validation logic in permissions.ts (lines 33-36), when tools is undefined, access is denied by default. This important security behavior is not documented in the schema. Consider adding a JSDoc comment to the SkillPermissionSchema explaining that omitting tools results in default-deny behavior, or making tools required with an empty array default to make the security model more explicit.
| /** | |
| * Schema for skill-level permissions. | |
| * | |
| * NOTE: The `tools` field is optional here, but the permission validation logic | |
| * (see `permissions.ts`) treats an omitted/undefined `tools` value as | |
| * "deny all tools" (default-deny). To explicitly allow no tools, you may | |
| * still provide an empty array; to allow specific tools, list them here. | |
| */ |
| @@ -0,0 +1,35 @@ | |||
| import { z } from 'zod'; | |||
|
|
|||
| export const ContextTypeSchema = z.enum(['file', 'url', 'api', 'snippet']); | |||
There was a problem hiding this comment.
The ContextTypeSchema defines 'snippet' as a valid context type, but there is no implementation or documentation explaining what a snippet context is or how it differs from a file context. This creates ambiguity for API consumers. Consider either: 1) implementing snippet support in loadContext, 2) documenting what snippet means and when to use it vs file, or 3) removing it if not needed for the initial implementation.
| export const ContextTypeSchema = z.enum(['file', 'url', 'api', 'snippet']); | |
| // Supported context types; additional types must be implemented and documented before being added here. | |
| export const ContextTypeSchema = z.enum(['file', 'url', 'api']); |
| if (!result.success) { | ||
| return { | ||
| valid: false, | ||
| errors: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`), |
There was a problem hiding this comment.
The error message formatting in validateAgentPermissions concatenates the error path with dots. However, when the path is empty (which can happen for root-level validation errors), this will produce error messages starting with a colon like ": Required". Consider handling empty paths specially to avoid confusing error messages.
| errors: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`), | |
| errors: result.error.errors.map(e => | |
| e.path.length > 0 ? `${e.path.join('.')}: ${e.message}` : e.message | |
| ), |
| // If tools not specified, assume strict/deny or allow all? | ||
| // Security best practice: Default deny. |
There was a problem hiding this comment.
The comment on lines 34-35 introduces ambiguity about the intended behavior when tools are not specified. While the implementation chooses default deny (security best practice), this decision should be documented in the type definition or through comprehensive JSDoc comments. This is critical for API consumers to understand the permission model. Consider adding JSDoc to the SkillPermission type in types.ts explaining that omitting the tools field denies all access.
| // If tools not specified, assume strict/deny or allow all? | |
| // Security best practice: Default deny. | |
| // If tools are not specified for a skill, deny access to all tools (default-deny). |
Description
Implements the core runtime context discovery and permission validation system requested in #140.
Features
.opencode/contextfor YAML/JSON definitions.PermissionValidatorto check agent access to skills/tools.Implementation Details
packages/plugin-abilities/src/context/discovery.tspackages/plugin-abilities/src/validator/permissions.tsindex.tsUsage
Closes #140