-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat: add instructionFile support for external instruction file references #1021
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,19 @@ export class TemplateLoadError extends Error { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Error thrown when loading an instruction file fails. | ||
| */ | ||
| export class InstructionLoadError extends Error { | ||
| constructor( | ||
| message: string, | ||
| public readonly instructionPath: string | ||
| ) { | ||
| super(message); | ||
| this.name = 'InstructionLoadError'; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Change context containing graph, completion state, and metadata. | ||
| */ | ||
|
|
@@ -160,6 +173,53 @@ export function loadTemplate( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Loads an instruction file from a schema's instructions directory. | ||
| * | ||
| * Mirrors loadTemplate() — resolves the file relative to | ||
| * <schemaDir>/instructions/<instructionPath>. | ||
| * | ||
| * @param schemaName - Schema name (e.g., "spec-driven") | ||
| * @param instructionPath - Relative path within the instructions directory (e.g., "requirements.md") | ||
| * @param projectRoot - Optional project root for project-local schema resolution | ||
| * @returns The instruction content | ||
| * @throws InstructionLoadError if the instruction file cannot be loaded | ||
| */ | ||
| export function loadInstruction( | ||
| schemaName: string, | ||
| instructionPath: string, | ||
| projectRoot?: string | ||
| ): string { | ||
| const schemaDir = getSchemaDir(schemaName, projectRoot); | ||
| if (!schemaDir) { | ||
| throw new InstructionLoadError( | ||
| `Schema '${schemaName}' not found`, | ||
| instructionPath | ||
| ); | ||
| } | ||
|
|
||
| const instructionPathOnDisk = path.join(schemaDir, 'instructions', instructionPath); | ||
|
|
||
| if (!fs.existsSync(instructionPathOnDisk)) { | ||
| throw new InstructionLoadError( | ||
| `Instruction file not found: ${instructionPathOnDisk}`, | ||
| instructionPathOnDisk | ||
| ); | ||
| } | ||
|
|
||
| const fullPath = FileSystemUtils.canonicalizeExistingPath(instructionPathOnDisk); | ||
|
|
||
| try { | ||
| return fs.readFileSync(fullPath, 'utf-8'); | ||
| } catch (err) { | ||
| const ioError = err instanceof Error ? err : new Error(String(err)); | ||
| throw new InstructionLoadError( | ||
| `Failed to read instruction file: ${ioError.message}`, | ||
| fullPath | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Loads change context combining graph and completion state. | ||
| * | ||
|
|
@@ -224,6 +284,12 @@ export function generateInstructions( | |
| } | ||
|
|
||
| const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot); | ||
|
|
||
| // Resolve instruction: external file (instructionFile) takes priority over inline (instruction) | ||
| const instructionContent = artifact.instructionFile | ||
| ? loadInstruction(context.schemaName, artifact.instructionFile, context.projectRoot) | ||
| : artifact.instruction; | ||
|
Comment on lines
+289
to
+291
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use explicit Using truthiness here can silently ignore an empty-string value and fall back to inline content, which masks malformed schema input. ✅ Suggested fix- const instructionContent = artifact.instructionFile
+ const instructionContent = artifact.instructionFile !== undefined
? loadInstruction(context.schemaName, artifact.instructionFile, context.projectRoot)
: artifact.instruction;🤖 Prompt for AI Agents |
||
|
|
||
| const dependencies = getDependencyInfo(artifact, context.graph, context.completed); | ||
| const unlocks = getUnlockedArtifacts(context.graph, artifactId); | ||
|
|
||
|
|
@@ -270,7 +336,7 @@ export function generateInstructions( | |
| changeDir: context.changeDir, | ||
| outputPath: artifact.generates, | ||
| description: artifact.description, | ||
| instruction: artifact.instruction, | ||
| instruction: instructionContent, | ||
| context: configContext, | ||
| rules: configRules, | ||
| template: templateContent, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import * as fs from 'node:fs'; | ||
| import { parse as parseYaml } from 'yaml'; | ||
| import { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js'; | ||
| import { SchemaYamlSchema, type SchemaYaml, type Artifact, type ApplyPhase } from './types.js'; | ||
|
|
||
| export class SchemaValidationError extends Error { | ||
| constructor(message: string) { | ||
|
|
@@ -41,6 +41,9 @@ export function parseSchema(yamlContent: string): SchemaYaml { | |
| // Check for cycles | ||
| validateNoCycles(schema.artifacts); | ||
|
|
||
| // Check instruction / instructionFile mutual exclusivity | ||
| validateInstructionExclusivity(schema.artifacts, schema.apply); | ||
|
|
||
| return schema; | ||
| } | ||
|
|
||
|
|
@@ -74,6 +77,25 @@ function validateRequiresReferences(artifacts: Artifact[]): void { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validates that instruction and instructionFile are mutually exclusive. | ||
| * An artifact (or apply phase) cannot have both fields at the same time. | ||
| */ | ||
| function validateInstructionExclusivity(artifacts: Artifact[], apply?: ApplyPhase): void { | ||
| for (const artifact of artifacts) { | ||
| if (artifact.instruction && artifact.instructionFile) { | ||
| throw new SchemaValidationError( | ||
| `Artifact '${artifact.id}' cannot have both 'instruction' and 'instructionFile'` | ||
| ); | ||
| } | ||
| } | ||
| if (apply?.instruction && apply?.instructionFile) { | ||
| throw new SchemaValidationError( | ||
| `Apply phase cannot have both 'instruction' and 'instructionFile'` | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+84
to
+97
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mutual-exclusivity check is bypassable with empty strings. At Line 86 and Line 92, truthy checks allow Suggested fix function validateInstructionExclusivity(artifacts: Artifact[], apply?: ApplyPhase): void {
for (const artifact of artifacts) {
- if (artifact.instruction && artifact.instructionFile) {
+ if (artifact.instruction !== undefined && artifact.instructionFile !== undefined) {
throw new SchemaValidationError(
`Artifact '${artifact.id}' cannot have both 'instruction' and 'instructionFile'`
);
}
}
- if (apply?.instruction && apply?.instructionFile) {
+ if (apply?.instruction !== undefined && apply?.instructionFile !== undefined) {
throw new SchemaValidationError(
`Apply phase cannot have both 'instruction' and 'instructionFile'`
);
}
}🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Validates that there are no cyclic dependencies. | ||
| * Uses DFS to detect cycles and reports the full cycle path. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,9 @@ export const ArtifactSchema = z.object({ | |
| description: z.string(), | ||
| template: z.string().min(1, { error: 'template field is required' }), | ||
| instruction: z.string().optional(), | ||
| // External instruction file (relative to schema's instructions/ directory). | ||
| // Mutually exclusive with instruction — validated in parseSchema(). | ||
| instructionFile: z.string().optional(), | ||
| requires: z.array(z.string()).default([]), | ||
|
Comment on lines
+10
to
13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
At Line 12 and Line 26, unconstrained strings allow absolute paths and Suggested hardening+import path from 'node:path';
import { z } from 'zod';
+
+const RelativeInstructionPathSchema = z
+ .string()
+ .min(1, { error: 'instructionFile is required' })
+ .refine(
+ (p) => !path.isAbsolute(p) && !p.split(/[\\/]+/).includes('..'),
+ { error: 'instructionFile must be a relative path under instructions/' }
+ );
// Artifact definition schema
export const ArtifactSchema = z.object({
@@
- instructionFile: z.string().optional(),
+ instructionFile: RelativeInstructionPathSchema.optional(),
@@
export const ApplyPhaseSchema = z.object({
@@
- instructionFile: z.string().optional(),
+ instructionFile: RelativeInstructionPathSchema.optional(),
});Also applies to: 24-27 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
|
|
@@ -18,6 +21,9 @@ export const ApplyPhaseSchema = z.object({ | |
| tracks: z.string().nullable().optional(), | ||
| // Custom guidance for the apply phase | ||
| instruction: z.string().optional(), | ||
| // External instruction file for apply phase (relative to schema's instructions/ directory). | ||
| // Mutually exclusive with instruction — validated in parseSchema(). | ||
| instructionFile: z.string().optional(), | ||
| }); | ||
|
|
||
| // Full schema YAML structure | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Constrain
instructionFilepaths to stay underinstructions/directory.Current path construction allows
../segments to escape the intended base directory. That can read unintended files if a schema provides a crafted path.🔒 Suggested containment check
export function loadInstruction( schemaName: string, instructionPath: string, projectRoot?: string ): string { const schemaDir = getSchemaDir(schemaName, projectRoot); if (!schemaDir) { @@ } - const instructionPathOnDisk = path.join(schemaDir, 'instructions', instructionPath); + const instructionsDir = path.resolve(schemaDir, 'instructions'); + const instructionPathOnDisk = path.resolve(instructionsDir, instructionPath); + + // Prevent path traversal outside <schemaDir>/instructions + if ( + instructionPathOnDisk !== instructionsDir && + !instructionPathOnDisk.startsWith(`${instructionsDir}${path.sep}`) + ) { + throw new InstructionLoadError( + `Instruction file path escapes instructions directory: ${instructionPath}`, + instructionPath + ); + } if (!fs.existsSync(instructionPathOnDisk)) {🤖 Prompt for AI Agents