Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/commands/workflow/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as fs from 'fs';
import {
loadChangeContext,
generateInstructions,
loadInstruction,
resolveSchema,
resolveArtifactOutputs,
type ArtifactInstructions,
Expand Down Expand Up @@ -260,7 +261,9 @@ export async function generateApplyInstructions(
// Fallback: if no apply block, require all artifacts
const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id);
const tracksFile = applyConfig?.tracks ?? null;
const schemaInstruction = applyConfig?.instruction ?? null;
const schemaInstruction = applyConfig?.instructionFile
? loadInstruction(context.schemaName, applyConfig.instructionFile, projectRoot)?.trim()
: applyConfig?.instruction?.trim() ?? null;

// Check which required artifacts are missing
const missingArtifacts: string[] = [];
Expand Down Expand Up @@ -320,7 +323,7 @@ export async function generateApplyInstructions(
} else if (!tracksFile) {
// No tracking file configured in schema - ready to apply
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.';
instruction = schemaInstruction ?? 'All required artifacts complete. Proceed with implementation.';
} else {
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
Expand Down
39 changes: 29 additions & 10 deletions src/commands/workflow/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface TemplatesOptions {
export interface TemplateInfo {
artifactId: string;
templatePath: string;
instructionFilePath: string | null;
source: 'project' | 'user' | 'package';
}

Expand Down Expand Up @@ -67,20 +68,35 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void>
source = 'package';
}

const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: FileSystemUtils.canonicalizeExistingPath(
path.join(schemaDir, 'templates', artifact.template)
),
source,
}));
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => {
const instructionFilePath = artifact.instructionFile
? FileSystemUtils.canonicalizeExistingPath(
path.join(schemaDir, 'instructions', artifact.instructionFile)
)
: null;

return {
artifactId: artifact.id,
templatePath: FileSystemUtils.canonicalizeExistingPath(
path.join(schemaDir, 'templates', artifact.template)
),
instructionFilePath,
source,
};
});

spinner?.stop();

if (options.json) {
const output: Record<string, { path: string; source: string }> = {};
const output: Record<string, { template: { path: string; source: string }; instructionFile?: { path: string } }> = {};
for (const t of templates) {
output[t.artifactId] = { path: t.templatePath, source: t.source };
const entry: { template: { path: string; source: string }; instructionFile?: { path: string } } = {
template: { path: t.templatePath, source: t.source },
};
if (t.instructionFilePath) {
entry.instructionFile = { path: t.instructionFilePath };
}
output[t.artifactId] = entry;
}
console.log(JSON.stringify(output, null, 2));
return;
Expand All @@ -92,7 +108,10 @@ export async function templatesCommand(options: TemplatesOptions): Promise<void>

for (const t of templates) {
console.log(`${t.artifactId}:`);
console.log(` ${t.templatePath}`);
console.log(` template: ${t.templatePath}`);
if (t.instructionFilePath) {
console.log(` instruction: ${t.instructionFilePath}`);
}
}
} catch (error) {
spinner?.stop();
Expand Down
2 changes: 2 additions & 0 deletions src/core/artifact-graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ export {
// Instruction loading
export {
loadTemplate,
loadInstruction,
loadChangeContext,
generateInstructions,
formatChangeStatus,
TemplateLoadError,
InstructionLoadError,
type ChangeContext,
type ArtifactInstructions,
type DependencyInfo,
Expand Down
68 changes: 67 additions & 1 deletion src/core/artifact-graph/instruction-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
Comment on lines +201 to +210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Constrain instructionFile paths to stay under instructions/ 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
Verify each finding against the current code and only fix it if needed.

In `@src/core/artifact-graph/instruction-loader.ts` around lines 201 - 210, The
constructed instructionPathOnDisk can escape the intended
schemaDir/'instructions' via ../ segments; after canonicalizing the target path
with FileSystemUtils.canonicalizeExistingPath, also canonicalize the base
instructions directory (path.join(schemaDir, 'instructions')) and verify that
fullPath starts with that canonical base (use path normalization and ensure path
separators are handled, e.g., compare base + path.sep). If the check fails,
throw an InstructionLoadError (similar to the existing one) to refuse access;
perform this containment check before returning/using fullPath so
instructionPath cannot reference files outside the instructions/ directory.


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.
*
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use explicit undefined check for instructionFile presence.

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
Verify each finding against the current code and only fix it if needed.

In `@src/core/artifact-graph/instruction-loader.ts` around lines 289 - 291, The
current ternary uses truthiness on artifact.instructionFile which treats an
empty string as "absent"; change the check to an explicit undefined check so an
explicitly provided (even empty) instructionFile is respected: replace the
condition with artifact.instructionFile !== undefined (or typeof
artifact.instructionFile !== "undefined") in the statement that sets
instructionContent so that loadInstruction(context.schemaName,
artifact.instructionFile, context.projectRoot) is invoked only when
instructionFile is explicitly present.


const dependencies = getDependencyInfo(artifact, context.graph, context.completed);
const unlocks = getUnlockedArtifacts(context.graph, artifactId);

Expand Down Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/core/artifact-graph/schema.ts
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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mutual-exclusivity check is bypassable with empty strings.

At Line 86 and Line 92, truthy checks allow { instruction: '', instructionFile: 'x.md' } to pass validation. Use explicit presence checks against undefined to enforce exclusivity consistently.

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
Verify each finding against the current code and only fix it if needed.

In `@src/core/artifact-graph/schema.ts` around lines 84 - 97, The current
mutual-exclusivity checks in validateInstructionExclusivity use truthy checks
which allow empty strings to bypass validation; update the checks to test for
presence explicitly against undefined (e.g., artifact.instruction !== undefined
&& artifact.instructionFile !== undefined and apply.instruction !== undefined &&
apply.instructionFile !== undefined) so that empty-string values still count as
present and trigger SchemaValidationError in validateInstructionExclusivity for
both artifact.* and apply.* cases.


/**
* Validates that there are no cyclic dependencies.
* Uses DFS to detect cycles and reports the full cycle path.
Expand Down
6 changes: 6 additions & 0 deletions src/core/artifact-graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

instructionFile needs path-safety validation.

At Line 12 and Line 26, unconstrained strings allow absolute paths and .. traversal segments. Add validation so instruction files remain relative to the schema instructions/ directory.

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
Verify each finding against the current code and only fix it if needed.

In `@src/core/artifact-graph/types.ts` around lines 10 - 13, The instructionFile
string schema currently allows absolute paths and path traversal; update the
schema for instructionFile (and the companion instruction field) to enforce
path-safety by replacing z.string().optional() with a validator that rejects
absolute paths, drive-letter prefixes, backslashes, and any ".." path segment
(e.g., use z.string().optional().refine(...) or z.string().regex(..., message)
to enforce these rules), and include a clear error message indicating the value
must be a relative path under the schema's instructions/ directory.

});

Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions test/commands/artifact-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,10 @@ describe('artifact-workflow CLI commands', () => {

const json = JSON.parse(result.stdout);
expect(json.proposal).toBeDefined();
expect(json.proposal.path).toContain('proposal.md');
expect(json.proposal.source).toBe('package');
expect(json.proposal.template.path).toContain('proposal.md');
expect(json.proposal.template.source).toBe('package');
// spec-driven schema uses inline instruction, so no instructionFile
expect(json.proposal.instructionFile).toBeUndefined();
});

it('errors for unknown schema', async () => {
Expand Down
Loading